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-detail="can('feedback.detail')"
:can-export="can('feedback.export')" :can-export="can('feedback.export')"
/> />
<UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" /> <UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" :can-export="can('behavior.export')" />
<RecordAuditView v-if="activeMenu === 'records'" /> <RecordAuditView v-if="activeMenu === 'records'" :can-export="can('records.export')" />
<AdminManagementView v-if="activeMenu === 'admins'" /> <AdminManagementView v-if="activeMenu === 'admins'" />
</section> </section>
</section> </section>

View File

@@ -5,6 +5,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api"; import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ const props = defineProps<{
config: Record<string, unknown>; config: Record<string, unknown>;
@@ -165,10 +166,6 @@ function executionLabel(job: AgentBatchJob) {
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit}` : "逐条串行"; 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) { function itemDuration(item: AgentBatchItem) {
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)}`; 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> <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 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="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="进度" 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> <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 AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue"; import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue";
import { formatDateTime } from "../utils/dateTime";
const props = withDefaults(defineProps<{ previewKnowledgeId?: number | null; canBatch?: boolean }>(), { canBatch: false }); const props = withDefaults(defineProps<{ previewKnowledgeId?: number | null; canBatch?: boolean }>(), { canBatch: false });
const emit = defineEmits<{ consumedPreview: [] }>(); const emit = defineEmits<{ consumedPreview: [] }>();
@@ -479,11 +480,6 @@ function changeTypeTag(value?: string) {
return "info"; 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) { function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback; return error instanceof Error ? error.message : fallback;
} }
@@ -497,7 +493,7 @@ function errorMessage(error: unknown, fallback: string) {
</div> </div>
<div class="agent-current-summary"> <div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag> <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>
</div> </div>
@@ -697,7 +693,7 @@ function errorMessage(error: unknown, fallback: string) {
<strong>版本 #{{ item.id }}</strong> <strong>版本 #{{ item.id }}</strong>
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag> <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> <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> </div>
<p>{{ item.preview || "空内容" }}</p> <p>{{ item.preview || "空内容" }}</p>
<div class="prompt-history-foot"> <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> <el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
<div v-loading="historyDetailLoading" class="prompt-history-detail"> <div v-loading="historyDetailLoading" class="prompt-history-detail">
<template v-if="selectedHistory"> <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> <pre>{{ selectedHistory.promptContent }}</pre>
</template> </template>
</div> </div>

View File

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

View File

@@ -6,19 +6,28 @@ import type { AttentionItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import AttentionRuleConfigPanel from "./AttentionRuleConfigPanel.vue"; import AttentionRuleConfigPanel from "./AttentionRuleConfigPanel.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue"; import ChatDetailDrawer from "./ChatDetailDrawer.vue";
import { formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
defineProps<{ canConfig: boolean; canPreview: boolean }>(); defineProps<{ canConfig: boolean; canPreview: boolean }>();
const loading = ref(false); const loading = ref(false);
const records = ref<AttentionItem[]>([]); const records = ref<AttentionItem[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 }); const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const filters = reactive({ priority: "", dateFrom: "", dateTo: "" });
const chatDetailOpen = ref(false); const chatDetailOpen = ref(false);
const sessionId = ref<number | null>(null); const sessionId = ref<number | null>(null);
const focusMessageId = ref<number | null>(null);
onMounted(() => loadRecords()); onMounted(() => loadRecords());
async function loadRecords(page = 1, pageSize = pager.pageSize) { async function loadRecords(page = 1, pageSize = pager.pageSize) {
loading.value = true; loading.value = true;
try { 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; records.value = result.items;
Object.assign(pager, { Object.assign(pager, {
page: result.page, page: result.page,
@@ -29,6 +38,17 @@ async function loadRecords(page = 1, pageSize = pager.pageSize) {
loading.value = false; 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) { async function updateStatus(row: AttentionItem, status: string) {
await api.updateAttention(row.id, status); await api.updateAttention(row.id, status);
ElMessage.success("人工关注状态已更新"); ElMessage.success("人工关注状态已更新");
@@ -52,8 +72,9 @@ async function deleteRecord(row: AttentionItem) {
ElMessage.success("人工关注记录已删除"); ElMessage.success("人工关注记录已删除");
await loadRecords(pager.page, pager.pageSize); await loadRecords(pager.page, pager.pageSize);
} }
function openChat(id: number) { function openChat(row: AttentionItem) {
sessionId.value = id; sessionId.value = row.sessionId;
focusMessageId.value = row.messageId;
chatDetailOpen.value = true; chatDetailOpen.value = true;
} }
function priorityLabel(priority: string) { function priorityLabel(priority: string) {
@@ -73,6 +94,22 @@ function priorityLabel(priority: string) {
</div> </div>
<el-tabs class="attention-tabs"> <el-tabs class="attention-tabs">
<el-tab-pane label="关注记录"> <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 :data="records" stripe
><el-table-column label="优先级" width="100" ><el-table-column label="优先级" width="100"
><template #default="{ row }" ><template #default="{ row }"
@@ -116,13 +153,18 @@ function priorityLabel(priority: string) {
value="resolved" /><el-option value="resolved" /><el-option
label="无需处理" label="无需处理"
value="ignored" /></el-select></template></el-table-column value="ignored" /></el-select></template></el-table-column
><el-table-column ><el-table-column label="触发时间" width="180"
prop="createdAt" ><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
label="创建时间" ></el-table-column><el-table-column label="操作" width="130" fixed="right"
width="180"
/><el-table-column label="操作" width="130" fixed="right"
><template #default="{ row }" ><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
><el-button link type="danger" @click="deleteRecord(row)" ><el-button link type="danger" @click="deleteRecord(row)"
>删除</el-button >删除</el-button
@@ -134,7 +176,11 @@ function priorityLabel(priority: string) {
:page-size="pager.pageSize" :page-size="pager.pageSize"
:total="pager.total" :total="pager.total"
@change="loadRecords" @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>
<el-tab-pane v-if="canConfig || canPreview" label="筛选规则与预览" lazy> <el-tab-pane v-if="canConfig || canPreview" label="筛选规则与预览" lazy>
<AttentionRuleConfigPanel :can-config="canConfig" :can-preview="canPreview" /> <AttentionRuleConfigPanel :can-config="canConfig" :can-preview="canPreview" />
@@ -145,4 +191,18 @@ function priorityLabel(priority: string) {
<style scoped> <style scoped>
.attention-tabs { margin-top: 14px; } .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> </style>

View File

@@ -5,6 +5,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api"; import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ canConfig: boolean; canPreview: boolean }>(); 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="userName" label="用户" width="120" />
<el-table-column prop="sessionTitle" label="会话" min-width="180" show-overflow-tooltip /> <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 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> </el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" /> <AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" />
</article> </article>

View File

@@ -5,17 +5,24 @@ import { nextTick, reactive, ref, watch } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { ChatDetail } from "../types/api"; import type { ChatDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; 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 emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<ChatDetail | null>(null); const detail = ref<ChatDetail | null>(null);
const loading = ref(false); const loading = ref(false);
const messagesLoading = ref(false); const messagesLoading = ref(false);
const exporting = ref(false);
const bodyRef = ref<HTMLElement | null>(null); const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 }); const pager = reactive({ page: 1, pageSize: 20, total: 0 });
watch( watch(
() => [props.modelValue, props.sessionId] as const, () => [props.modelValue, props.sessionId, props.focusMessageId] as const,
async ([open, sessionId]) => { async ([open, sessionId]) => {
if (!open || !sessionId) return; if (!open || !sessionId) return;
detail.value = null; detail.value = null;
@@ -30,6 +37,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
detail.value = await api.chatDetail(sessionId, { detail.value = await api.chatDetail(sessionId, {
messagePage: page, messagePage: page,
messagePageSize: pageSize, messagePageSize: pageSize,
focusMessageId: props.focusMessageId || undefined,
}); });
const messagePage = detail.value.messagesPage; const messagePage = detail.value.messagesPage;
Object.assign(pager, { Object.assign(pager, {
@@ -37,6 +45,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
pageSize: messagePage?.pageSize ?? pageSize, pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length, total: messagePage?.total ?? detail.value.messages.length,
}); });
await scrollToFocusedMessage();
} catch (error) { } catch (error) {
ElMessage.error( ElMessage.error(
error instanceof Error ? error.message : "聊天详情加载失败", 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) { async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return; if (!props.sessionId || !detail.value) return;
messagesLoading.value = true; messagesLoading.value = true;
@@ -71,6 +89,19 @@ async function changeMessagePage(page: number, pageSize: number) {
messagesLoading.value = false; 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> </script>
<template> <template>
@@ -82,6 +113,9 @@ async function changeMessagePage(page: number, pageSize: number) {
> >
<div ref="bodyRef" v-loading="loading" class="chat-detail-body"> <div ref="bodyRef" v-loading="loading" class="chat-detail-body">
<template v-if="detail"> <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"> <section class="chat-summary">
<div> <div>
<span>会话ID</span><strong>{{ detail.session.id }}</strong> <span>会话ID</span><strong>{{ detail.session.id }}</strong>
@@ -115,8 +149,8 @@ async function changeMessagePage(page: number, pageSize: number) {
><span>消息数{{ topic.messageCount }}</span ><span>消息数{{ topic.messageCount }}</span
><span ><span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span >Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
><span>开始{{ topic.startedAt }}</span ><span>开始{{ formatDateTime(topic.startedAt) }}</span
><span>结束{{ topic.endedAt || "-" }}</span> ><span>结束{{ formatDateTime(topic.endedAt) }}</span>
</div> </div>
<template v-if="topic.summary"> <template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre> <pre>{{ topic.summary.summary }}</pre>
@@ -151,8 +185,8 @@ async function changeMessagePage(page: number, pageSize: number) {
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`" :title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
><section class="topic-summary-card"> ><section class="topic-summary-card">
<div class="topic-summary-meta"> <div class="topic-summary-meta">
<span>生成{{ card.createdAt }}</span <span>生成{{ formatDateTime(card.createdAt) }}</span
><span>复制{{ card.copiedAt || "-" }}</span ><span>复制{{ formatDateTime(card.copiedAt) }}</span
><span>来源{{ card.source }}</span> ><span>来源{{ card.source }}</span>
</div> </div>
<pre>{{ card.content }}</pre> <pre>{{ card.content }}</pre>
@@ -176,8 +210,8 @@ async function changeMessagePage(page: number, pageSize: number) {
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`" :title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
><section class="topic-summary-card"> ><section class="topic-summary-card">
<div class="topic-summary-meta"> <div class="topic-summary-meta">
<span>生成{{ draft.createdAt }}</span <span>生成{{ formatDateTime(draft.createdAt) }}</span
><span>复制{{ draft.copiedAt || "-" }}</span ><span>复制{{ formatDateTime(draft.copiedAt) }}</span
><span>来源{{ draft.source }}</span> ><span>来源{{ draft.source }}</span>
</div> </div>
<pre>{{ draft.content }}</pre> <pre>{{ draft.content }}</pre>
@@ -202,13 +236,22 @@ async function changeMessagePage(page: number, pageSize: number) {
v-for="message in detail.messages" v-for="message in detail.messages"
:key="message.id" :key="message.id"
class="conversation-message" class="conversation-message"
:class="message.role" :class="[
message.role,
{ 'focus-message': message.id === focusMessageId },
]"
:data-message-id="message.id"
> >
<div class="message-meta"> <div class="message-meta">
<strong>{{ <strong>{{
message.role === "user" ? "用户" : "大本营答疑助手" message.role === "user" ? "用户" : "大本营答疑助手"
}}</strong }}</strong
><span>{{ message.createdAt }}</span> ><el-tag
v-if="message.id === focusMessageId"
type="warning"
effect="light"
>触发人工关注的消息</el-tag
><span>{{ formatDateTime(message.createdAt) }}</span>
</div> </div>
<pre>{{ message.content }}</pre> <pre>{{ message.content }}</pre>
<div class="message-extra"> <div class="message-extra">
@@ -242,7 +285,7 @@ async function changeMessagePage(page: number, pageSize: number) {
><span>命中数{{ log.retrieveCount }}</span ><span>命中数{{ log.retrieveCount }}</span
><span>Token{{ log.totalToken || "-" }}</span ><span>Token{{ log.totalToken || "-" }}</span
><span>耗时{{ log.costMs || "-" }}ms</span ><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ log.createdAt }}</span> ><span>时间{{ formatDateTime(log.createdAt) }}</span>
</div> </div>
<section <section
v-if="log.retrievedChunks?.length" v-if="log.retrievedChunks?.length"
@@ -291,3 +334,26 @@ async function changeMessagePage(page: number, pageSize: number) {
</div> </div>
</el-drawer> </el-drawer>
</template> </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"; } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue"; import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue";
import { formatDateTime } from "../utils/dateTime";
const activeType = ref<ContentGenerationType>("help_card"); const activeType = ref<ContentGenerationType>("help_card");
const loading = ref(false); 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) { function changeTypeLabel(value: string) {
return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value; return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value;
} }
@@ -374,7 +370,7 @@ function changeTypeLabel(value: string) {
<section class="generation-history generation-panel"> <section class="generation-history generation-panel">
<header> <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> <el-tag v-if="current" type="success">{{ changeTypeLabel(current.changeType) }}</el-tag>
</header> </header>
<el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" /> <el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" />
@@ -383,7 +379,7 @@ function changeTypeLabel(value: string) {
<div> <div>
<strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong> <strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong>
<p>{{ item.preview }}</p> <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> </div>
<el-button @click="openHistory(item)">查看</el-button> <el-button @click="openHistory(item)">查看</el-button>
</article> </article>
@@ -404,7 +400,7 @@ function changeTypeLabel(value: string) {
<div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div> <div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div>
<div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div> <div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div>
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</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> </dl>
<h4>变量定义</h4> <h4>变量定义</h4>
<div class="history-variables"> <div class="history-variables">

View File

@@ -8,6 +8,7 @@ import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api"; import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]); use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
@@ -160,7 +161,7 @@ function modelSceneLabel(scene: string) {
<section class="dashboard-panel" v-loading="storageLoading"> <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="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> <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>
</section> </section>
</template> </template>

View File

@@ -5,6 +5,7 @@ import { onMounted, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api"; import type { FeedbackDetail, FeedbackItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
defineProps<{ canDelete: boolean; canDetail: boolean; canExport: boolean }>(); 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="userName" label="用户" width="130" />
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip /> <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="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-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> </el-table>
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" /> <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"> <el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
<template v-if="detail"> <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> <h3>当时对话记录</h3>
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]"> <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> <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 { onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import { formatDateTime } from "../utils/dateTime";
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue"; import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
import TableRowActions from "./TableRowActions.vue"; import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.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="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="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 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-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> </el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" /> <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 AdminPagination from "./AdminPagination.vue";
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue"; import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue"; import ChatDetailDrawer from "./ChatDetailDrawer.vue";
import { formatDateTime, toUtcIsoFromBusinessDateTime } from "../utils/dateTime";
defineProps<{ canExport: boolean }>();
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs"; type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false); const loading = ref(false);
@@ -115,8 +118,8 @@ async function loadInsights(
pageSize = pagers.questionInsights.pageSize, pageSize = pagers.questionInsights.pageSize,
) { ) {
insights.value = await api.questionInsights({ insights.value = await api.questionInsights({
dateFrom: formatDateTime(insightFilters.dateFrom, "start"), dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
dateTo: formatDateTime(insightFilters.dateTo, "end"), dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
minCount: insightFilters.minCount, minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages, maxMessages: insightFilters.maxMessages,
page, page,
@@ -149,8 +152,8 @@ async function refreshInsights() {
loading.value = true; loading.value = true;
try { try {
const result = await api.refreshQuestionInsights({ const result = await api.refreshQuestionInsights({
dateFrom: formatDateTime(insightFilters.dateFrom, "start"), dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
dateTo: formatDateTime(insightFilters.dateTo, "end"), dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
maxMessages: insightFilters.maxMessages, maxMessages: insightFilters.maxMessages,
}); });
await loadInsights(1); await loadInsights(1);
@@ -177,9 +180,9 @@ async function resetInsightFilters() {
await refreshInsights(); await refreshInsights();
} }
async function exportInsights() { async function exportInsights() {
const dateFrom = formatDateTime(insightFilters.dateFrom, "start"); const dateFrom = toUtcIsoFromBusinessDateTime(insightFilters.dateFrom);
const dateTo = formatDateTime(insightFilters.dateTo, "end"); const dateTo = toUtcIsoFromBusinessDateTime(insightFilters.dateTo);
if (dateFrom && dateTo && new Date(dateFrom.replace(" ", "T")) > new Date(dateTo.replace(" ", "T"))) { if (dateFrom && dateTo && dateFrom > dateTo) {
return ElMessage.warning("开始时间不能晚于结束时间"); return ElMessage.warning("开始时间不能晚于结束时间");
} }
insightExporting.value = true; insightExporting.value = true;
@@ -217,17 +220,10 @@ function buildChatQuery() {
status: chatFilters.status, status: chatFilters.status,
sourceType: chatFilters.sourceType, sourceType: chatFilters.sourceType,
sourceClientId: chatFilters.sourceClientId, sourceClientId: chatFilters.sourceClientId,
dateFrom: formatDateTime(chatFilters.dateFrom, "start"), dateFrom: toUtcIsoFromBusinessDateTime(chatFilters.dateFrom),
dateTo: formatDateTime(chatFilters.dateTo, "end"), 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") { function formatMoney(value?: number | null, currency = "CNY") {
return typeof value === "number" return typeof value === "number"
? `${currency || "CNY"} ${value.toFixed(6)}` ? `${currency || "CNY"} ${value.toFixed(6)}`
@@ -290,7 +286,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
<div class="record-filter-actions"> <div class="record-filter-actions">
<el-button type="primary" @click="searchChats">搜索</el-button <el-button type="primary" @click="searchChats">搜索</el-button
><el-button @click="resetChatFilters">重置</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 >导出</el-button
> >
</div> </div>
@@ -322,11 +318,9 @@ function formatMoney(value?: number | null, currency = "CNY") {
prop="messageCount" prop="messageCount"
label="消息数" label="消息数"
width="90" width="90"
/><el-table-column /><el-table-column label="最后消息" width="180"
prop="lastMessageAt" ><template #default="{ row }">{{ formatDateTime(row.lastMessageAt) }}</template
label="最后消息" ></el-table-column><el-table-column label="操作" width="100" fixed="right"
width="180"
/><el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }" ><template #default="{ row }"
><el-button size="small" @click="openChat(row.id)" ><el-button size="small" @click="openChat(row.id)"
>详情</el-button >详情</el-button
@@ -382,7 +376,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
@click="refreshInsights" @click="refreshInsights"
>刷新并统计</el-button >刷新并统计</el-button
><el-button @click="resetInsightFilters">重置</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>
</div> </div>
<p class="question-insight-help"> <p class="question-insight-help">
@@ -475,7 +469,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
sample.userPhone || sample.userPhone ||
`用户 #${sample.userId}` `用户 #${sample.userId}`
}}</strong }}</strong
><span>{{ sample.createdAt }}</span> ><span>{{ formatDateTime(sample.createdAt) }}</span>
</div> </div>
<p>清洗后{{ sample.cleaned }}</p> <p>清洗后{{ sample.cleaned }}</p>
<pre>{{ sample.raw }}</pre> <pre>{{ sample.raw }}</pre>
@@ -569,9 +563,9 @@ function formatMoney(value?: number | null, currency = "CNY") {
prop="action" prop="action"
label="动作" /><el-table-column label="动作" /><el-table-column
prop="result" prop="result"
label="结果" /><el-table-column label="结果" /><el-table-column label="时间" width="180"
prop="createdAt" ><template #default="{ row }">{{ formatDateTime(String(row.createdAt || "")) }}</template
label="时间" /></el-table ></el-table-column></el-table
><AdminPagination ><AdminPagination
:page="pagers.operationLogs.page" :page="pagers.operationLogs.page"
:page-size="pagers.operationLogs.pageSize" :page-size="pagers.operationLogs.pageSize"
@@ -579,7 +573,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
@change="(page, size) => changePage('operationLogs', page, size)" @change="(page, size) => changePage('operationLogs', page, size)"
/></el-tab-pane> /></el-tab-pane>
</el-tabs> </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" /> <AiLogDetailDrawer v-model="aiLogDetailOpen" :log-id="aiLogId" />
</section> </section>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -159,10 +159,15 @@ export const api = {
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`), request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
userBehaviorOverview: (query: { start?: string; end?: string } = {}) => userBehaviorOverview: (query: { start?: string; end?: string } = {}) =>
request<UserBehaviorOverview>(`/admin/user-behavior/overview${queryString(query)}`), 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)}`), request<PageResult<UserBehaviorUserSummary>>(`/admin/user-behavior/users${queryString(query)}`),
userBehaviorTimeline: (userId: number, query: { start?: string; end?: string; page?: number; pageSize?: number } = {}) => userBehaviorTimeline: (userId: number, query: { start?: string; end?: string; page?: number; pageSize?: number } = {}) =>
request<UserBehaviorTimeline>(`/admin/user-behavior/user/${userId}/timeline${queryString(query)}`), 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)}`), 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`), userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) => 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 } = {}) => ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`), request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`),
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/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)}`), request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) => chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<ChatMessageRecord>>(`/admin/chat/${sessionId}/messages${queryString(query)}`), 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"), exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) => aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`), 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 }) }), 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" : ""}`), storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`), 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"), attentionConfig: () => request<AttentionConfig>("/admin/attention/config"),
attentionPreviewConfig: () => request<AttentionConfig>("/admin/attention/preview/config"), attentionPreviewConfig: () => request<AttentionConfig>("/admin/attention/preview/config"),
saveAttentionConfig: (payload: AttentionConfig) => request<AttentionConfig>("/admin/attention/config", { method: "PUT", body: JSON.stringify(payload) }), 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; priority: string;
status: string; status: string;
handlerNote?: string | null; handlerNote?: string | null;
targetAvailable?: boolean;
createdAt: string; createdAt: string;
} }
@@ -725,6 +726,7 @@ export interface ChatDetail {
session: ChatRecord; session: ChatRecord;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];
messagesPage?: PageResult<ChatMessageRecord>; messagesPage?: PageResult<ChatMessageRecord>;
focusedMessageId?: number | null;
aiLogs: AiLogRecord[]; aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[]; topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[]; 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();
}

View File

@@ -0,0 +1,25 @@
"""add human attention time filter index
Revision ID: 0040_attention_filters
Revises: 0039_attention_config
"""
from alembic import op
revision = "0040_attention_filters"
down_revision = "0039_attention_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_human_attention_created",
"sys_human_attention_record",
["created_at", "id"],
)
def downgrade() -> None:
op.drop_index("ix_human_attention_created", table_name="sys_human_attention_record")

View File

@@ -0,0 +1,87 @@
"""normalize legacy server timestamps to UTC
Revision ID: 0041_utc_time_contract
Revises: 0040_attention_filters
"""
from alembic import op
from sqlalchemy import text
revision = "0041_utc_time_contract"
down_revision = "0040_attention_filters"
branch_labels = None
depends_on = None
# Columns populated by MySQL CURRENT_TIMESTAMP. Fields that application
# services already wrote explicitly in UTC are intentionally excluded.
SERVER_TIMESTAMP_COLUMNS: dict[str, tuple[str, ...]] = {
"sys_admin": ("created_at", "updated_at"),
"sys_agent_batch_test": ("created_at", "updated_at"),
"sys_agent_batch_test_item": ("created_at", "updated_at"),
"sys_ai_request_log": ("created_at",),
"sys_chat_session": ("created_at", "updated_at"),
"sys_content_generation_config": ("updated_at",),
"sys_entitlement_plan": ("created_at", "updated_at"),
"sys_growth_profile_revision": ("created_at",),
"sys_human_attention_history": ("created_at",),
"sys_human_attention_job": ("created_at",),
"sys_human_attention_record": ("created_at", "updated_at"),
"sys_knowledge": ("created_at", "updated_at"),
"sys_knowledge_publish_log": ("created_at",),
"sys_knowledge_retrieval_log": ("created_at",),
"sys_knowledge_review_record": ("created_at",),
"sys_knowledge_sync_job": ("created_at",),
"sys_knowledge_version": ("created_at",),
"sys_log_retention_policy": ("updated_at",),
"sys_message_feedback": ("created_at",),
"sys_operation_log": ("created_at",),
"sys_periodic_report": ("created_at", "updated_at"),
"sys_prompt": ("updated_at",),
"sys_question_insight_cleaned_question": ("created_at", "updated_at"),
"sys_role": ("created_at", "updated_at"),
"sys_share_draft": ("created_at",),
"sys_sso_client": ("created_at", "updated_at"),
"sys_sso_login_audit": ("created_at",),
"sys_storage_snapshot": ("created_at",),
"sys_system_config": ("updated_at",),
"sys_teacher_help_card": ("created_at",),
"sys_topic_session": ("created_at", "updated_at"),
"sys_topic_summary": ("created_at", "updated_at"),
"sys_user": ("created_at", "updated_at"),
"sys_user_behavior_event": ("created_at",),
"sys_user_entitlement": ("created_at", "updated_at"),
"sys_user_external_identity": ("created_at", "updated_at"),
"sys_user_growth_profile": ("created_at", "updated_at"),
"sys_user_kb": ("created_at", "updated_at"),
}
def _server_offset_seconds() -> int:
connection = op.get_bind()
if connection.dialect.name != "mysql":
return 0
return int(connection.scalar(text("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())")) or 0)
def _shift_server_timestamps(offset_seconds: int) -> None:
if not offset_seconds:
return
connection = op.get_bind()
operation = "SUB" if offset_seconds > 0 else "ADD"
seconds = abs(offset_seconds)
for table_name, columns in SERVER_TIMESTAMP_COLUMNS.items():
assignments = ", ".join(
f"`{column}` = DATE_{operation}(`{column}`, INTERVAL {seconds} SECOND)"
for column in columns
)
connection.execute(text(f"UPDATE `{table_name}` SET {assignments}"))
def upgrade() -> None:
_shift_server_timestamps(_server_offset_seconds())
def downgrade() -> None:
_shift_server_timestamps(-_server_offset_seconds())

View File

@@ -0,0 +1,48 @@
"""add granular record export permission
Revision ID: 0042_record_export_permission
Revises: 0041_utc_time_contract
"""
from __future__ import annotations
import json
from alembic import op
from sqlalchemy import text
revision = "0042_record_export_permission"
down_revision = "0041_utc_time_contract"
branch_labels = None
depends_on = None
def _rewrite_permissions(*, add_export: bool) -> None:
connection = op.get_bind()
rows = connection.execute(text("SELECT id, permissions FROM sys_role")).mappings().all()
for row in rows:
try:
permissions = list(json.loads(row["permissions"] or "[]"))
except (TypeError, ValueError, json.JSONDecodeError):
continue
changed = False
if add_export and "records.view" in permissions and "records.export" not in permissions:
permissions.append("records.export")
changed = True
if not add_export and "records.export" in permissions:
permissions = [item for item in permissions if item != "records.export"]
changed = True
if changed:
connection.execute(
text("UPDATE sys_role SET permissions = :permissions WHERE id = :role_id"),
{"permissions": json.dumps(sorted(set(permissions))), "role_id": row["id"]},
)
def upgrade() -> None:
_rewrite_permissions(add_export=True)
def downgrade() -> None:
_rewrite_permissions(add_export=False)

View File

@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, business_day_boundary
from app.models.admin import Admin from app.models.admin import Admin
from app.models.chat import ChatMessage
from app.models.knowledge import ( from app.models.knowledge import (
HumanAttentionHistory, HumanAttentionHistory,
HumanAttentionRecord, HumanAttentionRecord,
@@ -95,7 +97,9 @@ def _cleanup_before(payload: dict) -> datetime:
if not raw: if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期") raise HTTPException(status_code=400, detail="必须指定清理日期")
try: try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None) if len(raw) == 10:
return business_day_boundary(datetime.fromisoformat(raw).date())
return as_utc_naive(datetime.fromisoformat(raw.replace("Z", "+00:00")))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@@ -151,21 +155,46 @@ def _knowledge_type_label(value: str) -> str:
@router.get("/attention/list") @router.get("/attention/list")
def attention_list( def attention_list(
priority: str = Query(default=""), priority: str = Query(default="", pattern="^(|urgent|important|normal)$"),
statusValue: str = Query(default="", alias="status"), statusValue: str = Query(default="", alias="status", pattern="^(|pending|processing|resolved|ignored)$"),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100), pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
query = select(HumanAttentionRecord) query = select(HumanAttentionRecord)
if priority: if priority:
query = query.where(HumanAttentionRecord.priority == priority) query = query.where(HumanAttentionRecord.priority == priority)
if statusValue: if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue) query = query.where(HumanAttentionRecord.status == statusValue)
if dateFrom is not None:
query = query.where(HumanAttentionRecord.created_at >= dateFrom)
if dateTo is not None:
query = query.where(HumanAttentionRecord.created_at <= dateTo)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all() rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize)) message_keys = {
(message_id, session_id)
for message_id, session_id in db.execute(
select(ChatMessage.id, ChatMessage.session_id).where(
ChatMessage.id.in_([item.message_id for item in rows])
)
).all()
} if rows else set()
items = [
_attention_dict(
item,
target_available=(item.message_id, item.session_id) in message_keys,
)
for item in rows
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.put("/attention/{attention_id}") @router.put("/attention/{attention_id}")
@@ -251,8 +280,8 @@ def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
return data return data
def _attention_dict(item: HumanAttentionRecord) -> dict: def _attention_dict(item: HumanAttentionRecord, *, target_available: bool | None = None) -> dict:
return { data = {
"id": item.id, "id": item.id,
"sessionId": item.session_id, "sessionId": item.session_id,
"messageId": item.message_id, "messageId": item.message_id,
@@ -267,6 +296,9 @@ def _attention_dict(item: HumanAttentionRecord) -> dict:
"createdAt": item.created_at, "createdAt": item.created_at,
"updatedAt": item.updated_at, "updatedAt": item.updated_at,
} }
if target_available is not None:
data["targetAvailable"] = target_available
return data
def _json(raw: str | None): def _json(raw: str | None):

View File

@@ -18,6 +18,7 @@ from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client from app.services.redis_client import get_sync_redis_client
from app.services.request_traffic_service import RequestTrafficService from app.services.request_traffic_service import RequestTrafficService
from app.core.time_utils import business_day_boundary
router = APIRouter() router = APIRouter()
@@ -29,12 +30,8 @@ def dashboard(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None start_dt = business_day_boundary(datetime.strptime(start, "%Y-%m-%d").date()) if start else None
if end: end_dt = business_day_boundary(datetime.strptime(end, "%Y-%m-%d").date(), end_exclusive=True) if end else None
end_dt = datetime.strptime(end, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
else:
end_dt = None
stats = AdminDashboardService.stats(db, start_dt, end_dt) stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump()) return api_success(DashboardStats.model_validate(stats).model_dump())

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session, load_only
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, to_business_naive
from app.models.admin import Admin from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
@@ -27,6 +28,7 @@ from app.services.question_insight_export_service import QuestionInsightExportSe
from app.services.growth_profile_service import topic_dict, topic_summary_dict from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict from app.services.share_draft_service import share_draft_dict
from app.services.chat_export_service import ChatExportService
router = APIRouter() router = APIRouter()
@@ -45,6 +47,8 @@ def chat_list(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
query = _chat_query( query = _chat_query(
keyword=keyword, keyword=keyword,
user_id=userId, user_id=userId,
@@ -72,6 +76,8 @@ def export_chats(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> Response: ) -> Response:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
rows = db.execute( rows = db.execute(
_chat_query( _chat_query(
keyword=keyword, keyword=keyword,
@@ -98,8 +104,8 @@ def export_chats(
source_client.name if source_client else "千问千答直接访问", source_client.name if source_client else "千问千答直接访问",
session.title, session.title,
session.message_count, session.message_count,
session.last_message_at, to_business_naive(session.last_message_at),
session.updated_at, to_business_naive(session.updated_at),
] ]
) )
content = "\ufeff" + output.getvalue() content = "\ufeff" + output.getvalue()
@@ -132,11 +138,53 @@ def chat_messages(
return api_success(page_result([_message_dict(item) for item in messages], total=total, page=page, page_size=pageSize)) return api_success(page_result([_message_dict(item) for item in messages], total=total, page=page, page_size=pageSize))
@router.get("/chat/{session_id}/export")
def export_chat_detail(
session_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
session_row = db.execute(
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.id == session_id)
).first()
if session_row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user, source_client = session_row
messages = db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
).all()
stream = ChatExportService.build(
session=session,
user=user,
source_name=source_client.name if source_client else "千问千答直接访问",
messages=messages,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="records",
action="export_chat_detail",
target_id=session.id,
)
db.commit()
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="chat_session_{session.id}.xlsx"'},
)
@router.get("/chat/{session_id}") @router.get("/chat/{session_id}")
def chat_detail( def chat_detail(
session_id: int, session_id: int,
messagePage: int = Query(default=1, ge=1), messagePage: int = Query(default=1, ge=1),
messagePageSize: int = Query(default=20, ge=10, le=100), messagePageSize: int = Query(default=20, ge=10, le=100),
focusMessageId: int | None = Query(default=None, gt=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
@@ -154,6 +202,28 @@ def chat_detail(
session, user, source_client = session_row session, user, source_client = session_row
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id) message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0 message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
if focusMessageId is not None:
focused_message = db.scalar(
select(ChatMessage).where(
ChatMessage.id == focusMessageId,
ChatMessage.session_id == session_id,
)
)
if focused_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在")
focused_position = db.scalar(
select(func.count()).select_from(ChatMessage).where(
ChatMessage.session_id == session_id,
or_(
ChatMessage.created_at < focused_message.created_at,
(
(ChatMessage.created_at == focused_message.created_at)
& (ChatMessage.id <= focused_message.id)
),
),
)
) or 1
messagePage = (focused_position - 1) // messagePageSize + 1
messages = db.scalars( messages = db.scalars(
message_query message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc()) .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
@@ -198,6 +268,7 @@ def chat_detail(
"topics": topics, "topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards], "helpCards": [help_card_dict(item) for item in help_cards],
"shareDrafts": [share_draft_dict(item) for item in share_drafts], "shareDrafts": [share_draft_dict(item) for item in share_drafts],
"focusedMessageId": focusMessageId,
} }
) )
@@ -288,6 +359,8 @@ def question_insights(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
return api_success( return api_success(
QuestionInsightService.summarize( QuestionInsightService.summarize(
db, db,
@@ -309,6 +382,8 @@ def refresh_question_insights(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
result = QuestionInsightService.refresh( result = QuestionInsightService.refresh(
db, db,
date_from=dateFrom, date_from=dateFrom,
@@ -334,6 +409,8 @@ def export_question_insights(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse: ) -> StreamingResponse:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo: if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
result = QuestionInsightService.summarize( result = QuestionInsightService.summarize(
@@ -387,9 +464,9 @@ def _chat_query(
if source_client_id is not None: if source_client_id is not None:
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id) query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
if date_from is not None: if date_from is not None:
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None)) query = query.where(ChatSession.updated_at >= date_from)
if date_to is not None: if date_to is not None:
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None)) query = query.where(ChatSession.updated_at <= date_to)
if status: if status:
query = query.where( query = query.where(
exists() exists()

View File

@@ -3,12 +3,15 @@ from __future__ import annotations
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.services.admin_service import OperationLogService
from app.services.user_behavior_export_service import UserBehaviorExportService
from app.services.user_behavior_service import UserBehaviorService from app.services.user_behavior_service import UserBehaviorService
@@ -30,6 +33,8 @@ def behavior_users(
start: date | None = Query(default=None), start: date | None = Query(default=None),
end: date | None = Query(default=None), end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50), keyword: str = Query(default="", max_length=50),
sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=5, le=100), pageSize: int = Query(default=20, ge=5, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -43,10 +48,42 @@ def behavior_users(
keyword=keyword, keyword=keyword,
page=page, page=page,
page_size=pageSize, page_size=pageSize,
sort_by=sortBy,
sort_order=sortOrder,
) )
) )
@router.get("/user-behavior/export")
def export_behavior_report(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
stream = UserBehaviorExportService.build_workbook(
db,
start=start,
end=end,
keyword=keyword,
sort_by=sortBy,
sort_order=sortOrder,
)
OperationLogService.write(db, admin_id=current_admin.id, module="behavior", action="export")
db.commit()
start_label = start.isoformat() if start else "default"
end_label = end.isoformat() if end else "today"
filename = f"user_behavior_report_{start_label}_{end_label}.xlsx"
return Response(
content=stream.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/user-behavior/user/{user_id}/timeline") @router.get("/user-behavior/user/{user_id}/timeline")
def behavior_timeline( def behavior_timeline(
user_id: int, user_id: int,

View File

@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, business_datetime_to_utc_naive, business_day_boundary
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import SystemConfig from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement from app.models.entitlement import EntitlementPlan, UserEntitlement
@@ -475,7 +476,7 @@ def update_user(
if payload.dailyChatLimit is not None: if payload.dailyChatLimit is not None:
user.daily_chat_limit = payload.dailyChatLimit user.daily_chat_limit = payload.dailyChatLimit
if payload.expiredAt is not None: if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None) user.expired_at = as_utc_naive(payload.expiredAt)
db.add(user) db.add(user)
if payload.entitlementPlanId is not None: if payload.entitlementPlanId is not None:
EntitlementService.assign_user_plan( EntitlementService.assign_user_plan(
@@ -698,7 +699,7 @@ def _apply_user_payload(
user.nickname = payload.nickname.strip() if payload.nickname else None user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None user.expired_at = as_utc_naive(payload.expiredAt) if payload.expiredAt is not None else None
def _default_daily_chat_limit(db: Session) -> int: def _default_daily_chat_limit(db: Session) -> int:
@@ -763,14 +764,17 @@ def _parse_optional_datetime(value: object | None) -> datetime | None:
if value is None or _cell_text(value) == "": if value is None or _cell_text(value) == "":
return None return None
if isinstance(value, datetime): if isinstance(value, datetime):
return value.replace(tzinfo=None) return business_datetime_to_utc_naive(value)
if isinstance(value, date): if isinstance(value, date):
return datetime.combine(value, datetime.min.time()) return business_day_boundary(value, end_exclusive=True) - timedelta(microseconds=1)
text = _cell_text(value) text = _cell_text(value)
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"): for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
try: try:
return datetime.strptime(text, fmt) parsed = datetime.strptime(text, fmt)
if "%H" not in fmt:
return business_day_boundary(parsed.date(), end_exclusive=True) - timedelta(microseconds=1)
return business_datetime_to_utc_naive(parsed)
except ValueError: except ValueError:
continue continue
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss") raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, timedelta
from io import BytesIO from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -18,6 +18,7 @@ from app.api.pagination import page_result
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin, get_current_user from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success from app.core.responses import api_success
from app.core.time_utils import business_day_boundary, to_business_naive
from app.models.admin import Admin from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback from app.models.feedback import MessageFeedback
@@ -136,9 +137,9 @@ def _feedback_query(read_status: str, start_date: date | None, end_date: date |
if read_status != "all": if read_status != "all":
query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0)) query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0))
if start_date is not None: if start_date is not None:
query = query.where(MessageFeedback.created_at >= datetime.combine(start_date, time.min)) query = query.where(MessageFeedback.created_at >= business_day_boundary(start_date))
if end_date is not None: if end_date is not None:
query = query.where(MessageFeedback.created_at < datetime.combine(end_date + timedelta(days=1), time.min)) query = query.where(MessageFeedback.created_at < business_day_boundary(end_date, end_exclusive=True))
return query return query
@@ -159,8 +160,8 @@ def _feedback_workbook(rows: list[tuple]) -> Workbook:
_excel_safe_text(session.title), _excel_safe_text(session.title),
message.id, message.id,
_excel_safe_text(ensure_ai_generated_notice(message.content)), _excel_safe_text(ensure_ai_generated_notice(message.content)),
feedback.created_at, to_business_naive(feedback.created_at),
feedback.read_at, to_business_naive(feedback.read_at),
]) ])
header_fill = PatternFill("solid", fgColor="1F6F5F") header_fill = PatternFill("solid", fgColor="1F6F5F")
for cell in sheet[1]: for cell in sheet[1]:

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings from app.core.config import get_settings
@@ -20,6 +20,16 @@ if not settings.database_url.startswith("sqlite"):
pool_recycle=settings.db_pool_recycle, pool_recycle=settings.db_pool_recycle,
) )
engine = create_engine(settings.database_url, **engine_options) engine = create_engine(settings.database_url, **engine_options)
if engine.dialect.name == "mysql":
@event.listens_for(engine, "connect")
def _set_mysql_session_timezone(dbapi_connection, _connection_record) -> None:
"""Keep CURRENT_TIMESTAMP and application-written values on one UTC contract."""
cursor = dbapi_connection.cursor()
try:
cursor.execute("SET time_zone = '+00:00'")
finally:
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
register_knowledge_cache_invalidation_events() register_knowledge_cache_invalidation_events()

View File

@@ -126,7 +126,9 @@ def enforce_admin_access(
else: else:
permission = "attention.view" if method == "GET" else "attention.edit" permission = "attention.view" if method == "GET" else "attention.edit"
elif path.startswith("user-behavior"): elif path.startswith("user-behavior"):
permission = "behavior.view" permission = "behavior.export" if path.startswith("user-behavior/export") else "behavior.view"
elif path == "chat/export" or (path.startswith("chat/") and path.endswith("/export")) or path.startswith("question-insights/export"):
permission = "records.export"
else: else:
permission = "records.view" permission = "records.view"
require_permission(admin, permission) require_permission(admin, permission)

View File

@@ -2,10 +2,12 @@ from __future__ import annotations
from typing import Any from typing import Any
from app.core.time_utils import normalize_api_datetimes
def api_success(data: Any = None, message: str = "success") -> dict[str, Any]: def api_success(data: Any = None, message: str = "success") -> dict[str, Any]:
return {"code": 0, "message": message, "data": data} return {"code": 0, "message": message, "data": normalize_api_datetimes(data)}
def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]: def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]:
return {"code": code, "message": message, "data": data} return {"code": code, "message": message, "data": normalize_api_datetimes(data)}

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai")
def utc_now_naive() -> datetime:
"""Return the database timestamp convention: UTC without tzinfo."""
return datetime.now(UTC).replace(tzinfo=None)
def as_utc_naive(value: datetime) -> datetime:
"""Normalize an API datetime to the database UTC-naive convention."""
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
def business_day_boundary(value: date, *, end_exclusive: bool = False) -> datetime:
"""Convert a Beijing calendar-day boundary to UTC-naive database time."""
local_value = datetime.combine(value, time.min, tzinfo=BUSINESS_TIMEZONE)
if end_exclusive:
local_value += timedelta(days=1)
return local_value.astimezone(UTC).replace(tzinfo=None)
def business_datetime_to_utc_naive(value: datetime) -> datetime:
"""Interpret a timezone-free operator input as Beijing local time."""
if value.tzinfo is not None:
return as_utc_naive(value)
return value.replace(tzinfo=BUSINESS_TIMEZONE).astimezone(UTC).replace(tzinfo=None)
def to_business_naive(value: datetime | None) -> datetime | None:
"""Convert UTC database time to a timezone-free Beijing value for Excel."""
if value is None:
return None
aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return aware.astimezone(BUSINESS_TIMEZONE).replace(tzinfo=None)
def api_datetime(value: datetime) -> str:
"""Serialize datetimes with an explicit offset; naive values are database UTC."""
aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value
return aware.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def normalize_api_datetimes(value: Any) -> Any:
"""Recursively enforce the API time contract for ordinary JSON responses."""
if isinstance(value, datetime):
return api_datetime(value)
if isinstance(value, date):
return value.isoformat()
if isinstance(value, dict):
return {key: normalize_api_datetimes(item) for key, item in value.items()}
if isinstance(value, list):
return [normalize_api_datetimes(item) for item in value]
if isinstance(value, tuple):
return [normalize_api_datetimes(item) for item in value]
return value

View File

@@ -248,6 +248,9 @@ class KnowledgeRetrievalCandidate(Base):
class HumanAttentionRecord(Base): class HumanAttentionRecord(Base):
__tablename__ = "sys_human_attention_record" __tablename__ = "sys_human_attention_record"
__table_args__ = (
Index("ix_human_attention_created", "created_at", "id"),
)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False) session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)

View File

@@ -17,11 +17,11 @@ PERMISSION_TREE = [
{"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]}, {"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]},
{"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]}, {"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},
{"code": "sso", "name": "应用接入", "children": [{"code": "sso.view", "name": "查看应用"}, {"code": "sso.edit", "name": "管理应用"}]}, {"code": "sso", "name": "应用接入", "children": [{"code": "sso.view", "name": "查看应用"}, {"code": "sso.edit", "name": "管理应用"}]},
{"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]}, {"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看记录"}, {"code": "records.export", "name": "导出记录/洞察"}]},
{"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]}, {"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]},
{"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理/删除关注项"}, {"code": "attention.config", "name": "查看/修改筛选规则"}, {"code": "attention.preview", "name": "使用历史消息预览筛选效果"}]}, {"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理/删除关注项"}, {"code": "attention.config", "name": "查看/修改筛选规则"}, {"code": "attention.preview", "name": "使用历史消息预览筛选效果"}]},
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈列表/筛选分页"}, {"code": "feedback.detail", "name": "查看详情/标记已读"}, {"code": "feedback.export", "name": "导出反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]}, {"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈列表/筛选分页"}, {"code": "feedback.detail", "name": "查看详情/标记已读"}, {"code": "feedback.export", "name": "导出反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
{"code": "behavior", "name": "用户行为分析", "children": [{"code": "behavior.view", "name": "查看行为总览和用户轨迹"}]}, {"code": "behavior", "name": "用户行为分析", "children": [{"code": "behavior.view", "name": "查看行为总览和用户轨迹"}, {"code": "behavior.export", "name": "导出用户行为报表"}]},
{"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]}, {"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]},
] ]

View File

@@ -141,10 +141,10 @@ class AdminDashboardService:
msg_filter.append(ChatMessage.created_at >= start) msg_filter.append(ChatMessage.created_at >= start)
ai_filter.append(AiRequestLog.created_at >= start) ai_filter.append(AiRequestLog.created_at >= start)
if end: if end:
user_filter.append(User.created_at <= end) user_filter.append(User.created_at < end)
session_filter.append(ChatSession.created_at <= end) session_filter.append(ChatSession.created_at < end)
msg_filter.append(ChatMessage.created_at <= end) msg_filter.append(ChatMessage.created_at < end)
ai_filter.append(AiRequestLog.created_at <= end) ai_filter.append(AiRequestLog.created_at < end)
user_where = and_(true(), *user_filter) user_where = and_(true(), *user_filter)
session_where = and_(true(), *session_filter) session_where = and_(true(), *session_filter)

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
from collections.abc import Iterable
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo
from app.core.ai_content_label import ensure_ai_generated_notice
from app.core.time_utils import to_business_naive
from app.models.chat import ChatMessage, ChatSession
from app.models.user import User
class ChatExportService:
"""Build a complete, pagination-independent workbook for one chat session."""
@staticmethod
def build(
*,
session: ChatSession,
user: User | None,
source_name: str,
messages: Iterable[ChatMessage],
) -> BytesIO:
message_rows = list(messages)
workbook = Workbook()
info_sheet = workbook.active
info_sheet.title = "会话信息"
_write_session_info(
info_sheet,
session=session,
user=user,
source_name=source_name,
message_count=len(message_rows),
)
message_sheet = workbook.create_sheet("完整对话")
_write_messages(message_sheet, message_rows)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return stream
def _write_session_info(
sheet,
*,
session: ChatSession,
user: User | None,
source_name: str,
message_count: int,
) -> None:
sheet.sheet_view.showGridLines = False
sheet.append(["项目", "内容"])
rows = [
("会话ID", session.id),
("会话标题", _excel_safe(session.title)),
("用户ID", session.user_id),
("用户姓名", _excel_safe(user.name if user else "")),
("手机号", _excel_safe(user.phone if user else "")),
("会话来源", _excel_safe(source_name)),
("消息总数", message_count),
("最后消息时间", to_business_naive(session.last_message_at)),
("会话更新时间", to_business_naive(session.updated_at)),
]
for label, value in rows:
sheet.append([label, value])
_style_header(sheet[1])
sheet.freeze_panes = "A2"
sheet.column_dimensions["A"].width = 20
sheet.column_dimensions["B"].width = 72
for row in sheet.iter_rows(min_row=2):
row[0].font = Font(bold=True, color="31584F")
row[1].alignment = Alignment(vertical="top", wrap_text=True)
for row_number in (9, 10):
sheet.cell(row_number, 2).number_format = "yyyy-mm-dd hh:mm:ss"
def _write_messages(sheet, messages: Iterable[ChatMessage]) -> None:
headers = ["序号", "消息ID", "发送方", "发送时间", "消息内容", "状态", "输入Token", "输出Token", "耗时(ms)"]
sheet.append(headers)
count = 0
for count, message in enumerate(messages, start=1):
content = ensure_ai_generated_notice(message.content) if message.role == "assistant" else message.content
sheet.append(
[
count,
message.id,
"用户" if message.role == "user" else "大本营答疑助手",
to_business_naive(message.created_at),
_excel_safe(content),
message.message_status,
message.token_input,
message.token_output,
message.response_time_ms,
]
)
_style_header(sheet[1])
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:I{max(1, sheet.max_row)}"
widths = (8, 12, 20, 22, 88, 14, 14, 14, 14)
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
for row in sheet.iter_rows(min_row=2):
row[3].number_format = "yyyy-mm-dd hh:mm:ss"
row[4].alignment = Alignment(vertical="top", wrap_text=True)
if count:
table = Table(displayName="ChatMessages", ref=f"A1:I{sheet.max_row}")
table.tableStyleInfo = TableStyleInfo(
name="TableStyleMedium4",
showFirstColumn=False,
showLastColumn=False,
showRowStripes=True,
)
sheet.add_table(table)
def _style_header(cells) -> None:
fill = PatternFill("solid", fgColor="176B57")
for cell in cells:
cell.fill = fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
def _excel_safe(value: object | None) -> str:
text = str(value or "")
return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.user import User from app.models.user import User
from app.core.time_utils import as_utc_naive
DEFAULT_PLAN_TYPE = "basic" DEFAULT_PLAN_TYPE = "basic"
@@ -260,12 +261,12 @@ class EntitlementService:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
now = _now() now = _now()
if effective_at is not None: if effective_at is not None:
effective_at = effective_at.replace(tzinfo=None) effective_at = as_utc_naive(effective_at)
if expired_at is None and plan.validity_days: if expired_at is None and plan.validity_days:
start = effective_at or now start = effective_at or now
expired_at = start + timedelta(days=plan.validity_days) expired_at = start + timedelta(days=plan.validity_days)
elif expired_at is not None: elif expired_at is not None:
expired_at = expired_at.replace(tzinfo=None) expired_at = as_utc_naive(expired_at)
if expired_at is not None and effective_at is not None and expired_at < effective_at: if expired_at is not None and effective_at is not None and expired_at < effective_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="权益到期时间不能早于生效时间") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="权益到期时间不能早于生效时间")

View File

@@ -13,6 +13,7 @@ from app.services.redis_client import get_sync_redis_client
from app.services.entitlement_service import EntitlementService from app.services.entitlement_service import EntitlementService
from app.services.user_behavior_service import UserBehaviorService from app.services.user_behavior_service import UserBehaviorService
from app.core.config import get_settings from app.core.config import get_settings
from app.core.time_utils import utc_now_naive
class MaintenanceService: class MaintenanceService:
@@ -43,11 +44,12 @@ class MaintenanceService:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
if not policy or not policy.enabled or not policy.retention_days: if not policy or not policy.enabled or not policy.retention_days:
return return
if policy.last_run_at and policy.last_run_at.date() >= datetime.now().date(): now = utc_now_naive()
if policy.last_run_at and policy.last_run_at.date() >= now.date():
return return
before = datetime.now() - timedelta(days=policy.retention_days) before = now - timedelta(days=policy.retention_days)
deleted = cls.delete_retrieval_logs(db, before) deleted = cls.delete_retrieval_logs(db, before)
policy.last_run_at = datetime.now() policy.last_run_at = now
policy.last_result = json.dumps({"status": "success", "deleted": deleted, "before": before.isoformat()}, ensure_ascii=False) policy.last_result = json.dumps({"status": "success", "deleted": deleted, "before": before.isoformat()}, ensure_ascii=False)
db.add(policy) db.add(policy)
db.commit() db.commit()
@@ -55,7 +57,7 @@ class MaintenanceService:
with SessionLocal() as db: with SessionLocal() as db:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
if policy: if policy:
policy.last_run_at = datetime.now() policy.last_run_at = utc_now_naive()
policy.last_result = json.dumps({"status": "failed", "error": str(exc)[:1000]}, ensure_ascii=False) policy.last_result = json.dumps({"status": "failed", "error": str(exc)[:1000]}, ensure_ascii=False)
db.add(policy) db.add(policy)
db.commit() db.commit()

View File

@@ -629,4 +629,4 @@ def _local_datetime(value: datetime) -> datetime:
except ZoneInfoNotFoundError: except ZoneInfoNotFoundError:
timezone = ZoneInfo("Asia/Shanghai") timezone = ZoneInfo("Asia/Shanghai")
aware_utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) aware_utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return aware_utc.astimezone(timezone).replace(tzinfo=None) return aware_utc.astimezone(timezone)

View File

@@ -8,6 +8,7 @@ from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo from openpyxl.worksheet.table import Table, TableStyleInfo
from app.core.ai_content_label import AI_GENERATED_NOTICE from app.core.ai_content_label import AI_GENERATED_NOTICE
from app.core.time_utils import to_business_naive, utc_now_naive
class QuestionInsightExportService: class QuestionInsightExportService:
@@ -116,7 +117,7 @@ def _append_summary(sheet, result: dict) -> None:
("导出问题组", summary.get("visibleClusterCount", 0)), ("导出问题组", summary.get("visibleClusterCount", 0)),
("清洗规则版本", summary.get("cleanerVersion", "")), ("清洗规则版本", summary.get("cleanerVersion", "")),
("内容标识", AI_GENERATED_NOTICE), ("内容标识", AI_GENERATED_NOTICE),
("导出时间", datetime.now()), ("导出时间", to_business_naive(utc_now_naive())),
("说明", "导出结果按所选日期范围和最低频次生成,包含全部符合条件的问题组,不受页面分页影响。"), ("说明", "导出结果按所选日期范围和最低频次生成,包含全部符合条件的问题组,不受页面分页影响。"),
] ]
for label, value in rows: for label, value in rows:
@@ -165,6 +166,6 @@ def _excel_safe(value: object | None) -> str:
def _excel_datetime(value: object) -> object: def _excel_datetime(value: object) -> object:
if isinstance(value, datetime) and value.tzinfo is not None: if isinstance(value, datetime):
return value.replace(tzinfo=None) return to_business_naive(value)
return value return value

View File

@@ -17,6 +17,7 @@ from app.models.chat import ChatMessage, ChatSession
from app.models.insight import QuestionInsightCleanedQuestion from app.models.insight import QuestionInsightCleanedQuestion
from app.models.logs import AiRequestLog from app.models.logs import AiRequestLog
from app.models.user import User from app.models.user import User
from app.core.time_utils import as_utc_naive
CLEANER_VERSION = "v1" CLEANER_VERSION = "v1"
@@ -249,9 +250,9 @@ def _load_unprocessed_user_messages(
) )
) )
if date_from is not None: if date_from is not None:
query = query.where(ChatMessage.created_at >= date_from.replace(tzinfo=None)) query = query.where(ChatMessage.created_at >= as_utc_naive(date_from))
if date_to is not None: if date_to is not None:
query = query.where(ChatMessage.created_at <= date_to.replace(tzinfo=None)) query = query.where(ChatMessage.created_at <= as_utc_naive(date_to))
return list( return list(
db.execute( db.execute(
query.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc()).limit(limit) query.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc()).limit(limit)
@@ -268,9 +269,9 @@ def _load_persisted_cleaned_questions(
) -> list[QuestionInsightCleanedQuestion]: ) -> list[QuestionInsightCleanedQuestion]:
range_filters = [QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION] range_filters = [QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION]
if date_from is not None: if date_from is not None:
range_filters.append(QuestionInsightCleanedQuestion.source_created_at >= date_from.replace(tzinfo=None)) range_filters.append(QuestionInsightCleanedQuestion.source_created_at >= as_utc_naive(date_from))
if date_to is not None: if date_to is not None:
range_filters.append(QuestionInsightCleanedQuestion.source_created_at <= date_to.replace(tzinfo=None)) range_filters.append(QuestionInsightCleanedQuestion.source_created_at <= as_utc_naive(date_to))
latest_messages = ( latest_messages = (
select( select(
@@ -312,9 +313,9 @@ def _load_ai_logs(
) -> list[AiRequestLog]: ) -> list[AiRequestLog]:
query = select(AiRequestLog) query = select(AiRequestLog)
if date_from is not None: if date_from is not None:
query = query.where(AiRequestLog.created_at >= date_from.replace(tzinfo=None)) query = query.where(AiRequestLog.created_at >= as_utc_naive(date_from))
if date_to is not None: if date_to is not None:
query = query.where(AiRequestLog.created_at <= date_to.replace(tzinfo=None)) query = query.where(AiRequestLog.created_at <= as_utc_naive(date_to))
return list(db.scalars(query.order_by(AiRequestLog.created_at.desc(), AiRequestLog.id.desc()).limit(limit)).all()) return list(db.scalars(query.order_by(AiRequestLog.created_at.desc(), AiRequestLog.id.desc()).limit(limit)).all())

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
from datetime import date, datetime
from io import BytesIO
from copy import copy
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from sqlalchemy.orm import Session
from app.services.user_behavior_service import EVENT_CATALOG, UserBehaviorService
from app.core.time_utils import to_business_naive, utc_now_naive
TITLE_FILL = PatternFill("solid", fgColor="176B55")
HEADER_FILL = PatternFill("solid", fgColor="DDEFE9")
TITLE_FONT = Font(color="FFFFFF", bold=True, size=16)
HEADER_FONT = Font(color="214239", bold=True)
EVENT_TYPE_LABELS = {"page": "页面", "dialog": "弹窗", "button": "按钮"}
SORT_LABELS = {
"userName": "用户名称",
"phone": "手机号",
"eventCount": "交互次数",
"lastEventAt": "最后交互时间",
}
FONT_NAME = "Hiragino Sans GB"
class UserBehaviorExportService:
@staticmethod
def build_workbook(
db: Session,
*,
start: date | None,
end: date | None,
keyword: str,
sort_by: str,
sort_order: str,
) -> BytesIO:
overview = UserBehaviorService.overview(
db,
start=start,
end=end,
ranking_limit=len(EVENT_CATALOG),
)
users = UserBehaviorService.users_for_export(
db,
start=start,
end=end,
keyword=keyword,
sort_by=sort_by,
sort_order=sort_order,
)
workbook = Workbook()
summary = workbook.active
summary.title = "报表说明"
_write_summary(summary, overview, keyword=keyword, sort_by=sort_by, sort_order=sort_order)
_write_daily(workbook.create_sheet("每日趋势"), overview["daily"])
_write_ranking(workbook.create_sheet("功能使用频率"), overview["eventRanking"])
_write_users(workbook.create_sheet("用户汇总"), users)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return stream
def _write_summary(sheet, overview: dict, *, keyword: str, sort_by: str, sort_order: str) -> None:
sheet.sheet_view.showGridLines = False
sheet.merge_cells("A1:D1")
title = sheet["A1"]
title.value = "用户行为分析报表"
title.fill = TITLE_FILL
title.font = TITLE_FONT
title.alignment = Alignment(horizontal="center", vertical="center")
sheet.row_dimensions[1].height = 30
metadata = [
("统计周期", f'{overview["startDate"]}{overview["endDate"]}'),
("用户筛选", _excel_safe(keyword.strip()) if keyword.strip() else "全部用户"),
("用户汇总排序", f'{SORT_LABELS[sort_by]}·{"升序" if sort_order == "asc" else "降序"}'),
("数据保留", f'{overview["retentionDays"]}'),
("导出时间", to_business_naive(utc_now_naive())),
]
for row_index, (label, value) in enumerate(metadata, start=3):
sheet.cell(row_index, 1, label).font = HEADER_FONT
sheet.cell(row_index, 2, value)
metrics = [
("交互事件", overview["totalEvents"]),
("活跃用户", overview["activeUsers"]),
("页面/弹窗打开", overview["pageDialogOpens"]),
("关键按钮点击", overview["buttonClicks"]),
]
sheet.cell(10, 1, "核心指标").fill = HEADER_FILL
sheet.cell(10, 1).font = HEADER_FONT
sheet.cell(10, 2, "数值").fill = HEADER_FILL
sheet.cell(10, 2).font = HEADER_FONT
for row_index, (label, value) in enumerate(metrics, start=11):
sheet.cell(row_index, 1, label)
sheet.cell(row_index, 2, value).number_format = "#,##0"
sheet.column_dimensions["A"].width = 22
sheet.column_dimensions["B"].width = 34
sheet.column_dimensions["C"].width = 16
sheet.column_dimensions["D"].width = 16
sheet["B7"].number_format = "yyyy-mm-dd hh:mm:ss"
_finish_sheet(sheet, landscape=True)
def _write_daily(sheet, rows: list[dict]) -> None:
_write_tabular_sheet(sheet, ["日期", "交互事件", "活跃用户"])
for item in rows:
sheet.append([date.fromisoformat(item["date"]), item["eventCount"], item["activeUsers"]])
for cell in sheet["A"][1:]:
cell.number_format = "yyyy-mm-dd"
for column in ("B", "C"):
for cell in sheet[column][1:]:
cell.number_format = "#,##0"
_finish_tabular_sheet(sheet, widths=(15, 15, 15))
def _write_ranking(sheet, rows: list[dict]) -> None:
_write_tabular_sheet(sheet, ["排名", "功能名称", "事件类型", "使用次数", "用户数", "事件代码"])
for index, item in enumerate(rows, start=1):
sheet.append(
[
index,
_excel_safe(item["eventName"]),
EVENT_TYPE_LABELS.get(item["eventType"], item["eventType"]),
item["count"],
item["userCount"],
item["eventCode"],
]
)
for column in ("A", "D", "E"):
for cell in sheet[column][1:]:
cell.number_format = "#,##0"
_finish_tabular_sheet(sheet, widths=(10, 24, 14, 14, 14, 30))
def _write_users(sheet, rows: list[dict]) -> None:
_write_tabular_sheet(sheet, ["用户ID", "用户", "手机号", "交互次数", "最后交互时间"])
for item in rows:
sheet.append(
[
item["userId"],
_excel_safe(item["userName"]),
_excel_safe(item["phone"]),
item["eventCount"],
to_business_naive(item["lastEventAt"]),
]
)
for cell in sheet["A"][1:]:
cell.number_format = "0"
for cell in sheet["C"][1:]:
cell.number_format = "@"
for cell in sheet["D"][1:]:
cell.number_format = "#,##0"
for cell in sheet["E"][1:]:
cell.number_format = "yyyy-mm-dd hh:mm:ss"
_finish_tabular_sheet(sheet, widths=(12, 22, 18, 14, 22))
def _write_tabular_sheet(sheet, headers: list[str]) -> None:
sheet.sheet_view.showGridLines = False
sheet.append(headers)
for cell in sheet[1]:
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.row_dimensions[1].height = 24
sheet.freeze_panes = "A2"
def _finish_tabular_sheet(sheet, *, widths: tuple[int, ...]) -> None:
sheet.auto_filter.ref = sheet.dimensions
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
for row in sheet.iter_rows(min_row=2):
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
_finish_sheet(sheet, landscape=True)
def _finish_sheet(sheet, *, landscape: bool) -> None:
for row in sheet.iter_rows():
for cell in row:
font = copy(cell.font)
font.name = FONT_NAME
cell.font = font
sheet.sheet_properties.pageSetUpPr.fitToPage = True
sheet.page_setup.fitToWidth = 1
sheet.page_setup.fitToHeight = 0
sheet.page_setup.orientation = "landscape" if landscape else "portrait"
sheet.page_setup.paperSize = sheet.PAPERSIZE_A4
sheet.page_margins.left = 0.3
sheet.page_margins.right = 0.3
sheet.page_margins.top = 0.5
sheet.page_margins.bottom = 0.5
sheet.print_area = sheet.dimensions
def _excel_safe(value: object) -> str:
text = "" if value is None else str(value)
return f"'{text}" if text.startswith(("=", "+", "-", "@")) else text

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy import case, delete, func, or_, select from sqlalchemy import case, delete, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -11,6 +10,7 @@ from app.models.behavior import UserBehaviorEvent
from app.models.user import User from app.models.user import User
from app.schemas.behavior import UserBehaviorEventCreate from app.schemas.behavior import UserBehaviorEventCreate
from app.core.config import get_settings from app.core.config import get_settings
from app.core.time_utils import BUSINESS_TIMEZONE, business_day_boundary, to_business_naive
EVENT_CATALOG: dict[str, tuple[str, str]] = { EVENT_CATALOG: dict[str, tuple[str, str]] = {
@@ -48,6 +48,8 @@ EVENT_CATALOG: dict[str, tuple[str, str]] = {
TARGET_TYPES = {"session", "message", "help_card", "share_draft", "report"} TARGET_TYPES = {"session", "message", "help_card", "share_draft", "report"}
DEFAULT_RANGE_DAYS = 7 DEFAULT_RANGE_DAYS = 7
MAX_RANGE_DAYS = 31 MAX_RANGE_DAYS = 31
USER_SORT_FIELDS = {"userName", "phone", "eventCount", "lastEventAt"}
USER_SORT_ORDERS = {"asc", "desc"}
class UserBehaviorService: class UserBehaviorService:
@@ -97,7 +99,7 @@ class UserBehaviorService:
return accepted return accepted
@staticmethod @staticmethod
def overview(db: Session, *, start: date | None, end: date | None) -> dict: def overview(db: Session, *, start: date | None, end: date | None, ranking_limit: int = 20) -> dict:
start_dt, end_dt = _date_range(start, end) start_dt, end_dt = _date_range(start, end)
base = (UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt) base = (UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt)
total, active_users, page_opens, button_clicks = db.execute( total, active_users, page_opens, button_clicks = db.execute(
@@ -119,7 +121,7 @@ class UserBehaviorService:
.where(*base) .where(*base)
.group_by(UserBehaviorEvent.event_code, UserBehaviorEvent.event_name, UserBehaviorEvent.event_type) .group_by(UserBehaviorEvent.event_code, UserBehaviorEvent.event_name, UserBehaviorEvent.event_type)
.order_by(func.count(UserBehaviorEvent.id).desc(), UserBehaviorEvent.event_code) .order_by(func.count(UserBehaviorEvent.id).desc(), UserBehaviorEvent.event_code)
.limit(20) .limit(max(1, min(ranking_limit, len(EVENT_CATALOG))))
).all() ).all()
if db.bind and db.bind.dialect.name == "mysql": if db.bind and db.bind.dialect.name == "mysql":
day_expression = func.date(func.convert_tz(UserBehaviorEvent.occurred_at, "+00:00", "+08:00")) day_expression = func.date(func.convert_tz(UserBehaviorEvent.occurred_at, "+00:00", "+08:00"))
@@ -135,8 +137,8 @@ class UserBehaviorService:
.group_by(day_expression) .group_by(day_expression)
.order_by(day_expression) .order_by(day_expression)
).all() ).all()
local_start = (start_dt + timedelta(hours=8)).date() local_start = to_business_naive(start_dt).date()
local_end = (end_dt + timedelta(hours=8) - timedelta(days=1)).date() local_end = (to_business_naive(end_dt) - timedelta(days=1)).date()
daily_map = {str(day): (event_count, users) for day, event_count, users in daily_rows} daily_map = {str(day): (event_count, users) for day, event_count, users in daily_rows}
daily = [] daily = []
cursor = local_start cursor = local_start
@@ -160,47 +162,57 @@ class UserBehaviorService:
} }
@staticmethod @staticmethod
def users(db: Session, *, start: date | None, end: date | None, keyword: str, page: int, page_size: int) -> dict: def users(
start_dt, end_dt = _date_range(start, end) db: Session,
filters = [UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt] *,
if keyword.strip(): start: date | None,
pattern = f"%{keyword.strip()}%" end: date | None,
filters.append(or_(User.name.like(pattern), User.nickname.like(pattern), User.phone.like(pattern))) keyword: str,
grouped = ( page: int,
select( page_size: int,
User.id.label("user_id"), sort_by: str = "lastEventAt",
User.name, sort_order: str = "desc",
User.nickname, ) -> dict:
User.phone, grouped, order_expression = _user_summary_query(
func.count(UserBehaviorEvent.id).label("event_count"), start=start,
func.max(UserBehaviorEvent.occurred_at).label("last_event_at"), end=end,
) keyword=keyword,
.join(UserBehaviorEvent, UserBehaviorEvent.user_id == User.id) sort_by=sort_by,
.where(*filters) sort_order=sort_order,
.group_by(User.id, User.name, User.nickname, User.phone)
) )
total = db.scalar(select(func.count()).select_from(grouped.subquery())) or 0 total = db.scalar(select(func.count()).select_from(grouped.subquery())) or 0
rows = db.execute( rows = db.execute(
grouped.order_by(func.max(UserBehaviorEvent.occurred_at).desc(), User.id.desc()) grouped.order_by(order_expression, User.id.asc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
).all() ).all()
return { return {
"items": [ "items": [_user_summary_dict(row) for row in rows],
{
"userId": row.user_id,
"userName": row.nickname or row.name,
"phone": row.phone,
"eventCount": row.event_count,
"lastEventAt": row.last_event_at,
}
for row in rows
],
"total": total, "total": total,
"page": page, "page": page,
"pageSize": page_size, "pageSize": page_size,
} }
@staticmethod
def users_for_export(
db: Session,
*,
start: date | None,
end: date | None,
keyword: str,
sort_by: str,
sort_order: str,
) -> list[dict]:
grouped, order_expression = _user_summary_query(
start=start,
end=end,
keyword=keyword,
sort_by=sort_by,
sort_order=sort_order,
)
rows = db.execute(grouped.order_by(order_expression, User.id.asc())).all()
return [_user_summary_dict(row) for row in rows]
@staticmethod @staticmethod
def timeline(db: Session, *, user_id: int, start: date | None, end: date | None, page: int, page_size: int) -> dict: def timeline(db: Session, *, user_id: int, start: date | None, end: date | None, page: int, page_size: int) -> dict:
user = db.get(User, user_id) user = db.get(User, user_id)
@@ -249,18 +261,67 @@ class UserBehaviorService:
def _date_range(start: date | None, end: date | None) -> tuple[datetime, datetime]: def _date_range(start: date | None, end: date | None) -> tuple[datetime, datetime]:
today = datetime.now(ZoneInfo("Asia/Shanghai")).date() today = datetime.now(BUSINESS_TIMEZONE).date()
end_date = end or today end_date = end or today
start_date = start or (end_date - timedelta(days=DEFAULT_RANGE_DAYS - 1)) start_date = start or (end_date - timedelta(days=DEFAULT_RANGE_DAYS - 1))
if end_date < start_date: if end_date < start_date:
start_date, end_date = end_date, start_date start_date, end_date = end_date, start_date
if (end_date - start_date).days >= MAX_RANGE_DAYS: if (end_date - start_date).days >= MAX_RANGE_DAYS:
start_date = end_date - timedelta(days=MAX_RANGE_DAYS - 1) start_date = end_date - timedelta(days=MAX_RANGE_DAYS - 1)
# Admin date filters are Beijing calendar days; persisted timestamps are UTC-naive. return business_day_boundary(start_date), business_day_boundary(end_date, end_exclusive=True)
return (
datetime.combine(start_date, time.min) - timedelta(hours=8),
datetime.combine(end_date + timedelta(days=1), time.min) - timedelta(hours=8), def _user_summary_query(
*,
start: date | None,
end: date | None,
keyword: str,
sort_by: str,
sort_order: str,
):
if sort_by not in USER_SORT_FIELDS:
raise ValueError(f"unsupported user behavior sort field: {sort_by}")
if sort_order not in USER_SORT_ORDERS:
raise ValueError(f"unsupported user behavior sort order: {sort_order}")
start_dt, end_dt = _date_range(start, end)
filters = [UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt]
if keyword.strip():
pattern = f"%{keyword.strip()}%"
filters.append(or_(User.name.like(pattern), User.nickname.like(pattern), User.phone.like(pattern)))
user_name = func.coalesce(func.nullif(User.nickname, ""), User.name)
event_count = func.count(UserBehaviorEvent.id)
last_event_at = func.max(UserBehaviorEvent.occurred_at)
grouped = (
select(
User.id.label("user_id"),
User.name,
User.nickname,
User.phone,
event_count.label("event_count"),
last_event_at.label("last_event_at"),
)
.join(UserBehaviorEvent, UserBehaviorEvent.user_id == User.id)
.where(*filters)
.group_by(User.id, User.name, User.nickname, User.phone)
) )
sort_expressions = {
"userName": user_name,
"phone": User.phone,
"eventCount": event_count,
"lastEventAt": last_event_at,
}
expression = sort_expressions[sort_by]
return grouped, expression.asc() if sort_order == "asc" else expression.desc()
def _user_summary_dict(row) -> dict:
return {
"userId": row.user_id,
"userName": row.nickname or row.name,
"phone": row.phone,
"eventCount": row.event_count,
"lastEventAt": row.last_event_at,
}
def _event_dict(item: UserBehaviorEvent) -> dict: def _event_dict(item: UserBehaviorEvent) -> dict:

View File

@@ -101,7 +101,14 @@ def test_chat_detail_messages_are_paginated():
]) ])
db.commit() db.commit()
response = chat_detail(1, messagePage=2, messagePageSize=10, db=db, current_admin=object()) response = chat_detail(
1,
messagePage=2,
messagePageSize=10,
focusMessageId=None,
db=db,
current_admin=object(),
)
data = response["data"] data = response["data"]
assert data["session"]["messageCount"] == 25 assert data["session"]["messageCount"] == 25
@@ -111,6 +118,40 @@ def test_chat_detail_messages_are_paginated():
assert data["messages"][0]["content"] == "消息11" assert data["messages"][0]["content"] == "消息11"
def test_chat_detail_focus_message_opens_its_page_in_chronological_order():
with _database() as db:
user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10)
session = ChatSession(id=1, user_id=1, title="长会话", message_count=25)
created_at = datetime(2026, 8, 25, 10, 0, 0)
db.add_all([user, session])
db.add_all([
ChatMessage(
id=index + 1,
session_id=1,
user_id=1,
role="user",
content=f"消息{index + 1}",
created_at=created_at,
)
for index in range(25)
])
db.commit()
response = chat_detail(
1,
messagePage=1,
messagePageSize=10,
focusMessageId=23,
db=db,
current_admin=object(),
)
data = response["data"]
assert data["messagesPage"]["page"] == 3
assert data["focusedMessageId"] == 23
assert [item["id"] for item in data["messages"]] == [21, 22, 23, 24, 25]
def test_chat_messages_endpoint_returns_only_message_page(): def test_chat_messages_endpoint_returns_only_message_page():
with _database() as db: with _database() as db:
user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10) user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10)
@@ -149,11 +190,75 @@ def test_retrieval_and_attention_lists_are_paginated():
sessionId=None, knowledgeId=None, knowledgeCalled=None, statusValue="", attentionCreated=None, sessionId=None, knowledgeId=None, knowledgeCalled=None, statusValue="", attentionCreated=None,
page=3, pageSize=10, db=db, current_admin=object(), page=3, pageSize=10, db=db, current_admin=object(),
)["data"] )["data"]
attention = attention_list(priority="", statusValue="", page=3, pageSize=10, db=db, current_admin=object())["data"] attention = attention_list(
priority="",
statusValue="",
dateFrom=None,
dateTo=None,
page=3,
pageSize=10,
db=db,
current_admin=object(),
)["data"]
assert retrieval["total"] == attention["total"] == 21 assert retrieval["total"] == attention["total"] == 21
assert len(retrieval["items"]) == len(attention["items"]) == 1 assert len(retrieval["items"]) == len(attention["items"]) == 1
def test_attention_list_filters_by_priority_and_trigger_time():
with _database() as db:
db.add_all([
HumanAttentionRecord(
session_id=1,
message_id=1,
user_id=1,
trigger_message="紧急问题",
problem_summary="紧急问题",
trigger_reason="测试",
priority="urgent",
status="pending",
created_at=datetime(2026, 8, 20, 9, 0, 0),
),
HumanAttentionRecord(
session_id=2,
message_id=2,
user_id=2,
trigger_message="普通问题",
problem_summary="普通问题",
trigger_reason="测试",
priority="normal",
status="pending",
created_at=datetime(2026, 8, 21, 9, 0, 0),
),
HumanAttentionRecord(
session_id=3,
message_id=3,
user_id=3,
trigger_message="过期紧急问题",
problem_summary="过期紧急问题",
trigger_reason="测试",
priority="urgent",
status="pending",
created_at=datetime(2026, 8, 10, 9, 0, 0),
),
])
db.commit()
data = attention_list(
priority="urgent",
statusValue="",
dateFrom=datetime(2026, 8, 20, 0, 0, 0),
dateTo=datetime(2026, 8, 22, 23, 59, 59),
page=1,
pageSize=10,
db=db,
current_admin=object(),
)["data"]
assert data["total"] == 1
assert data["items"][0]["messageId"] == 1
assert data["items"][0]["targetAvailable"] is False
def test_question_insights_clean_and_cluster_similar_user_questions(): def test_question_insights_clean_and_cluster_similar_user_questions():
with _database() as db: with _database() as db:
user_a = User(id=1, phone="13800000000", name="学员A", daily_chat_limit=10) user_a = User(id=1, phone="13800000000", name="学员A", daily_chat_limit=10)

View File

@@ -16,8 +16,9 @@ def test_super_admin_has_all_permissions() -> None:
assert permissions_for(admin) == ALL_PERMISSION_CODES assert permissions_for(admin) == ALL_PERMISSION_CODES
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES
assert "prompt.batch" in ALL_PERMISSION_CODES assert "prompt.batch" in ALL_PERMISSION_CODES
assert "behavior.view" in ALL_PERMISSION_CODES assert {"behavior.view", "behavior.export"} <= ALL_PERMISSION_CODES
assert {"attention.view", "attention.edit", "attention.config", "attention.preview"} <= ALL_PERMISSION_CODES assert {"attention.view", "attention.edit", "attention.config", "attention.preview"} <= ALL_PERMISSION_CODES
assert {"records.view", "records.export"} <= ALL_PERMISSION_CODES
def test_role_permissions_are_restricted_to_catalog() -> None: def test_role_permissions_are_restricted_to_catalog() -> None:
@@ -81,6 +82,61 @@ def test_user_behavior_routes_require_behavior_permission() -> None:
enforce_admin_access(request, denied) enforce_admin_access(request, denied)
assert exc.value.status_code == 403 assert exc.value.status_code == 403
export_request = Request({"type": "http", "method": "GET", "path": "/api/admin/user-behavior/export", "headers": []})
with pytest.raises(HTTPException) as exc:
enforce_admin_access(export_request, allowed)
assert exc.value.status_code == 403
export_role = Role(code="behavior-exporter", name="行为报表导出", permissions=json.dumps(["behavior.export"]))
exporter = Admin(
id=6,
username="behavior-exporter",
password="hash",
name="行为报表导出",
status=1,
must_change_password=0,
is_super_admin=0,
role=export_role,
)
assert enforce_admin_access(export_request, exporter) is exporter
@pytest.mark.parametrize(
"path",
[
"/api/admin/chat/export",
"/api/admin/chat/14/export",
"/api/admin/question-insights/export",
],
)
def test_record_export_routes_require_export_permission(path: str) -> None:
viewer = Admin(
id=7,
username="records-viewer",
password="hash",
name="记录查看员",
status=1,
must_change_password=0,
is_super_admin=0,
role=Role(code="records-view", name="记录查看", permissions=json.dumps(["records.view"])),
)
request = Request({"type": "http", "method": "GET", "path": path, "headers": []})
with pytest.raises(HTTPException) as exc:
enforce_admin_access(request, viewer)
assert exc.value.status_code == 403
exporter = Admin(
id=8,
username="records-exporter",
password="hash",
name="记录导出员",
status=1,
must_change_password=0,
is_super_admin=0,
role=Role(code="records-export", name="记录导出", permissions=json.dumps(["records.export"])),
)
assert enforce_admin_access(request, exporter) is exporter
@pytest.mark.parametrize( @pytest.mark.parametrize(
("path", "method", "permission"), ("path", "method", "permission"),

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from openpyxl import load_workbook
from app.core.ai_content_label import AI_GENERATED_NOTICE
from app.models.chat import ChatMessage, ChatSession
from app.models.user import User
from app.services.chat_export_service import ChatExportService
def test_chat_export_contains_all_messages_in_order_and_ai_notice() -> None:
session = ChatSession(
id=9,
user_id=3,
title="完整对话测试",
message_count=99,
last_message_at=datetime(2026, 8, 25, 8, 2),
updated_at=datetime(2026, 8, 25, 8, 3),
)
user = User(id=3, phone="13800000000", name="测试学员", daily_chat_limit=10)
messages = [
ChatMessage(
id=21,
session_id=9,
user_id=3,
role="user",
content="第一个问题",
message_status="FINISHED",
created_at=datetime(2026, 8, 25, 8),
),
ChatMessage(
id=22,
session_id=9,
user_id=3,
role="assistant",
content="第二个回答",
message_status="FINISHED",
token_input=20,
token_output=10,
response_time_ms=800,
created_at=datetime(2026, 8, 25, 8, 1),
),
]
stream = ChatExportService.build(
session=session,
user=user,
source_name="千问千答直接访问",
messages=messages,
)
workbook = load_workbook(stream)
assert workbook.sheetnames == ["会话信息", "完整对话"]
assert workbook["会话信息"]["B8"].value == 2
sheet = workbook["完整对话"]
assert sheet.max_row == 3
assert sheet["B2"].value == 21
assert sheet["E2"].value == "第一个问题"
assert sheet["B3"].value == 22
assert AI_GENERATED_NOTICE in sheet["E3"].value
assert sheet["D2"].value == datetime(2026, 8, 25, 16)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -53,5 +53,5 @@ def test_feedback_export_workbook_is_formatted_and_formula_safe() -> None:
assert sheet["E2"].value.startswith("'=") assert sheet["E2"].value.startswith("'=")
assert sheet["H1"].value == "对应AI回答AI生成" assert sheet["H1"].value == "对应AI回答AI生成"
assert "AI生成内容请结合实际情况核对后使用。" in sheet["H2"].value assert "AI生成内容请结合实际情况核对后使用。" in sheet["H2"].value
assert sheet["I2"].value == created_at assert sheet["I2"].value == created_at + timedelta(hours=8)
assert sheet.tables["FeedbackRecords"].ref == "A1:J2" assert sheet.tables["FeedbackRecords"].ref == "A1:J2"

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import date, datetime
from zoneinfo import ZoneInfo
from app.core.responses import api_success
from app.core.time_utils import business_day_boundary, to_business_naive
def test_api_success_serializes_naive_database_time_as_explicit_utc() -> None:
response = api_success({"createdAt": datetime(2026, 8, 25, 8, 30, 12, 123456)})
assert response["data"]["createdAt"] == "2026-08-25T08:30:12.123Z"
def test_api_success_normalizes_aware_business_time_to_utc() -> None:
response = api_success(
{"createdAt": datetime(2026, 8, 25, 16, 30, tzinfo=ZoneInfo("Asia/Shanghai"))}
)
assert response["data"]["createdAt"] == "2026-08-25T08:30:00.000Z"
def test_beijing_day_boundary_and_excel_display_conversion() -> None:
assert business_day_boundary(date(2026, 8, 25)) == datetime(2026, 8, 24, 16)
assert business_day_boundary(date(2026, 8, 25), end_exclusive=True) == datetime(2026, 8, 25, 16)
assert to_business_naive(datetime(2026, 8, 25, 8, 30)) == datetime(2026, 8, 25, 16, 30)

View File

@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from io import BytesIO
from openpyxl import load_workbook
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -11,19 +13,27 @@ from app.models.behavior import UserBehaviorEvent
from app.models.user import User from app.models.user import User
from app.schemas.behavior import UserBehaviorEventCreate from app.schemas.behavior import UserBehaviorEventCreate
from app.services.user_behavior_service import UserBehaviorService from app.services.user_behavior_service import UserBehaviorService
from app.services.user_behavior_export_service import UserBehaviorExportService
def _engine(): def _engine():
return create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) return create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
def _event(event_id: str, code: str, *, target_type: str | None = None, target_id: int | None = None): def _event(
event_id: str,
code: str,
*,
target_type: str | None = None,
target_id: int | None = None,
occurred_at: datetime | None = None,
):
return UserBehaviorEventCreate( return UserBehaviorEventCreate(
clientEventId=event_id, clientEventId=event_id,
eventCode=code, eventCode=code,
targetType=target_type, targetType=target_type,
targetId=target_id, targetId=target_id,
occurredAt=datetime.now(UTC), occurredAt=occurred_at or datetime.now(UTC),
) )
@@ -67,18 +77,19 @@ def test_behavior_overview_user_list_and_timeline() -> None:
] ]
db.add_all(users) db.add_all(users)
db.commit() db.commit()
base_time = datetime.now(UTC) - timedelta(minutes=5)
UserBehaviorService.record_batch( UserBehaviorService.record_batch(
db, db,
user=users[0], user=users[0],
items=[ items=[
_event("11111111-1111-1111-1111-111111111111", "app_open"), _event("11111111-1111-1111-1111-111111111111", "app_open", occurred_at=base_time),
_event("22222222-2222-2222-2222-222222222222", "send_question_click", target_type="session", target_id=3), _event("22222222-2222-2222-2222-222222222222", "send_question_click", target_type="session", target_id=3, occurred_at=base_time + timedelta(minutes=1)),
], ],
) )
UserBehaviorService.record_batch( UserBehaviorService.record_batch(
db, db,
user=users[1], user=users[1],
items=[_event("33333333-3333-3333-3333-333333333333", "app_open")], items=[_event("33333333-3333-3333-3333-333333333333", "app_open", occurred_at=base_time + timedelta(minutes=2))],
) )
overview = UserBehaviorService.overview(db, start=None, end=None) overview = UserBehaviorService.overview(db, start=None, end=None)
assert overview["totalEvents"] == 3 assert overview["totalEvents"] == 3
@@ -90,9 +101,46 @@ def test_behavior_overview_user_list_and_timeline() -> None:
listed = UserBehaviorService.users(db, start=None, end=None, keyword="", page=1, page_size=20) listed = UserBehaviorService.users(db, start=None, end=None, keyword="", page=1, page_size=20)
assert listed["total"] == 1 assert listed["total"] == 1
assert listed["items"][0]["eventCount"] == 2 assert listed["items"][0]["eventCount"] == 2
by_count = UserBehaviorService.users(
db,
start=None,
end=None,
keyword="",
page=1,
page_size=20,
sort_by="eventCount",
sort_order="asc",
)
assert [item["userName"] for item in by_count["items"]] == ["乙用户", "甲用户"]
by_last_time = UserBehaviorService.users(
db,
start=None,
end=None,
keyword="",
page=1,
page_size=20,
sort_by="lastEventAt",
sort_order="desc",
)
assert [item["userName"] for item in by_last_time["items"]] == ["乙用户", "甲用户"]
timeline = UserBehaviorService.timeline(db, user_id=1, start=None, end=None, page=1, page_size=50) timeline = UserBehaviorService.timeline(db, user_id=1, start=None, end=None, page=1, page_size=50)
assert [item["eventName"] for item in timeline["items"]] == ["进入答疑页面", "发送问题"] assert [item["eventName"] for item in timeline["items"]] == ["进入答疑页面", "发送问题"]
workbook_stream = UserBehaviorExportService.build_workbook(
db,
start=None,
end=None,
keyword="",
sort_by="eventCount",
sort_order="asc",
)
workbook = load_workbook(BytesIO(workbook_stream.getvalue()))
assert workbook.sheetnames == ["报表说明", "每日趋势", "功能使用频率", "用户汇总"]
assert workbook["报表说明"]["B11"].value == 3
assert workbook["用户汇总"]["B2"].value == "乙用户"
assert workbook["用户汇总"]["D2"].value == 1
assert workbook["用户汇总"]["C2"].number_format == "@"
def test_behavior_retention_deletes_only_expired_rows() -> None: def test_behavior_retention_deletes_only_expired_rows() -> None:
engine = _engine() engine = _engine()

View File

@@ -2,6 +2,7 @@
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue"; import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { computed } from "vue"; import { computed } from "vue";
import { formatTime } from "../utils/dateTime";
const props = defineProps<{ const props = defineProps<{
messageId: string; messageId: string;
@@ -79,10 +80,7 @@ function splitReasoning(content: string) {
} }
const displayTime = computed(() => { const displayTime = computed(() => {
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`; return formatTime(props.createdAt, "");
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(date);
}); });
</script> </script>

View File

@@ -16,6 +16,7 @@ import {
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import type { PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "../types/api"; import type { PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "../types/api";
import { formatDate } from "../utils/dateTime";
import AppDialog from "./AppDialog.vue"; import AppDialog from "./AppDialog.vue";
type CenterSection = "overview" | "review" | "reports" | "records"; type CenterSection = "overview" | "review" | "reports" | "records";
@@ -104,13 +105,6 @@ function usagePercent(used: number, limit: number | null) {
return Math.min(100, Math.max(0, Math.round((used / limit) * 100))); return Math.min(100, Math.max(0, Math.round((used / limit) * 100)));
} }
function formatDate(value?: string | null) {
if (!value) return "长期有效";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
}
function settlementStatusLabel(status: string) { function settlementStatusLabel(status: string) {
return { return {
pending: "等待整理", pending: "等待整理",
@@ -189,7 +183,7 @@ function submitDeleteCard() {
</div> </div>
<div> <div>
<dt>有效期至</dt> <dt>有效期至</dt>
<dd>{{ formatDate(entitlement?.expiredAt) }}</dd> <dd>{{ formatDate(entitlement?.expiredAt, "长期有效") }}</dd>
</div> </div>
</dl> </dl>
</article> </article>

View File

@@ -4,6 +4,7 @@ import { computed, ref, watch } from "vue";
import type { ChatSession } from "../types/api"; import type { ChatSession } from "../types/api";
import AppDialog from "./AppDialog.vue"; import AppDialog from "./AppDialog.vue";
import { formatSessionTime } from "../utils/dateTime";
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
@@ -50,18 +51,6 @@ function submitDelete() {
}); });
} }
function formatTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const days = Math.round((today.getTime() - target.getTime()) / 86_400_000);
const time = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(date);
if (days === 0) return time;
if (days === 1) return `昨天 ${time}`;
return `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${time}`;
}
</script> </script>
<template> <template>
@@ -91,7 +80,7 @@ function formatTime(value: string) {
> >
<button type="button" class="history-main" @click="emit('select', session.id)"> <button type="button" class="history-main" @click="emit('select', session.id)">
<strong>{{ session.title }}</strong> <strong>{{ session.title }}</strong>
<time :datetime="session.updatedAt">{{ formatTime(session.updatedAt) }}</time> <time :datetime="session.updatedAt">{{ formatSessionTime(session.updatedAt) }}</time>
<span>{{ session.messageCount }} 条消息</span> <span>{{ session.messageCount }} 条消息</span>
</button> </button>
<div class="history-actions"> <div class="history-actions">

View File

@@ -3,6 +3,7 @@ import { Layers3, LoaderCircle } from "@lucide/vue";
import { computed } from "vue"; import { computed } from "vue";
import type { UserEntitlementSummary } from "../types/api"; import type { UserEntitlementSummary } from "../types/api";
import { formatDate } from "../utils/dateTime";
const props = defineProps<{ const props = defineProps<{
used: number; used: number;
@@ -26,11 +27,6 @@ const usageText = computed(() => {
if (displayLimit.value <= 0) return "今日不可用"; if (displayLimit.value <= 0) return "今日不可用";
return `今日 ${displayUsed.value}/${displayLimit.value}`; return `今日 ${displayUsed.value}/${displayLimit.value}`;
}); });
function formatDate(value?: string | null) {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString("zh-CN");
}
const guidanceText = computed(() => { const guidanceText = computed(() => {
if (props.entitlement?.lifecycleStatus === "expired_fallback") { if (props.entitlement?.lifecycleStatus === "expired_fallback") {
return `${props.entitlement.previousPlanName ? `${props.entitlement.previousPlanName}` : "权益"}已于 ${formatDate(props.entitlement.previousExpiredAt)} 到期,当前已自动切换为${props.entitlement.name},已有对话与回顾仍会保留。`; return `${props.entitlement.previousPlanName ? `${props.entitlement.previousPlanName}` : "权益"}已于 ${formatDate(props.entitlement.previousExpiredAt)} 到期,当前已自动切换为${props.entitlement.name},已有对话与回顾仍会保留。`;

View File

@@ -0,0 +1,76 @@
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 formatDate(value?: string | Date | null, fallback = "-") {
const parts = businessParts(value);
return parts ? `${parts.year}-${parts.month}-${parts.day}` : fallback;
}
export function formatTime(value?: string | Date | null, fallback = "-") {
const parts = businessParts(value);
return parts ? `${parts.hour}:${parts.minute}` : fallback;
}
export function formatDateTime(value?: string | Date | null, fallback = "-") {
const parts = businessParts(value);
return parts
? `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`
: fallback;
}
export function businessDateKey(value?: string | Date | null): string | null {
const parts = businessParts(value);
return parts ? `${parts.year}-${parts.month}-${parts.day}` : null;
}
export function formatSessionTime(
value?: string | Date | null,
now: Date = new Date(),
): string {
const targetKey = businessDateKey(value);
const todayKey = businessDateKey(now);
const time = formatTime(value, "");
if (!targetKey || !todayKey || !time) return "";
const dayNumber = (key: string) => {
const [year, month, day] = key.split("-").map(Number);
return Date.UTC(year, month - 1, day) / 86_400_000;
};
const days = dayNumber(todayKey) - dayNumber(targetKey);
if (days === 0) return time;
if (days === 1) return `昨天 ${time}`;
return `${targetKey.slice(5).replace("-", "/")} ${time}`;
}