feat: improve audit exports and admin analytics
This commit is contained in:
@@ -1137,8 +1137,8 @@ async function clearFeishuCache() {
|
||||
:can-detail="can('feedback.detail')"
|
||||
:can-export="can('feedback.export')"
|
||||
/>
|
||||
<UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" />
|
||||
<RecordAuditView v-if="activeMenu === 'records'" />
|
||||
<UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" :can-export="can('behavior.export')" />
|
||||
<RecordAuditView v-if="activeMenu === 'records'" :can-export="can('records.export')" />
|
||||
<AdminManagementView v-if="activeMenu === 'admins'" />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{
|
||||
config: Record<string, unknown>;
|
||||
@@ -165,10 +166,6 @@ function executionLabel(job: AgentBatchJob) {
|
||||
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit} 路` : "逐条串行";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
|
||||
}
|
||||
|
||||
function itemDuration(item: AgentBatchItem) {
|
||||
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)} 秒`;
|
||||
}
|
||||
@@ -213,7 +210,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
|
||||
<div class="agent-batch-list-head"><div><h4>测试记录</h4><p>任务在后台执行,离开页面不会中断。</p></div><el-button :loading="loading" @click="loadJobs()">刷新</el-button></div>
|
||||
<el-table v-loading="loading" :data="jobs" stripe>
|
||||
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDate(row.createdAt) }}</small></template></el-table-column>
|
||||
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDateTime(row.createdAt, "—") }}</small></template></el-table-column>
|
||||
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
|
||||
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
|
||||
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>
|
||||
|
||||
@@ -9,6 +9,7 @@ import AgentBatchTestPanel from "./AgentBatchTestPanel.vue";
|
||||
import AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = withDefaults(defineProps<{ previewKnowledgeId?: number | null; canBatch?: boolean }>(), { canBatch: false });
|
||||
const emit = defineEmits<{ consumedPreview: [] }>();
|
||||
@@ -479,11 +480,6 @@ function changeTypeTag(value?: string) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "尚未保存";
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -497,7 +493,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
</div>
|
||||
<div class="agent-current-summary">
|
||||
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
|
||||
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
|
||||
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDateTime(prompt?.updatedAt, "尚未保存") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -697,7 +693,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
<strong>版本 #{{ item.id }}</strong>
|
||||
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
|
||||
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
|
||||
<span>{{ item.updatedByName }} · {{ formatDate(item.updatedAt) }}</span>
|
||||
<span>{{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "尚未保存") }}</span>
|
||||
</div>
|
||||
<p>{{ item.preview || "空内容" }}</p>
|
||||
<div class="prompt-history-foot">
|
||||
@@ -759,7 +755,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
|
||||
<div v-loading="historyDetailLoading" class="prompt-history-detail">
|
||||
<template v-if="selectedHistory">
|
||||
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
|
||||
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDateTime(selectedHistory.updatedAt, "尚未保存") }} · {{ selectedHistory.charCount }} 字符</span></div>
|
||||
<pre>{{ selectedHistory.promptContent }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElMessage } from "element-plus";
|
||||
import { ref, watch } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { AiLogRecord } from "../types/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; logId: number | null }>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
@@ -77,7 +78,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
><span>知识命中:{{ detail.knowledgeHit ? "是" : "否" }}</span
|
||||
><span>路由原因:{{ detail.routeReason || "-" }}</span
|
||||
><span>耗时:{{ detail.costMs || "-" }}ms</span
|
||||
><span>时间:{{ detail.createdAt }}</span>
|
||||
><span>时间:{{ formatDateTime(detail.createdAt) }}</span>
|
||||
</section>
|
||||
<p v-if="detail.errorMessage" class="error-text">
|
||||
{{ detail.errorMessage }}
|
||||
|
||||
@@ -6,19 +6,28 @@ import type { AttentionItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import AttentionRuleConfigPanel from "./AttentionRuleConfigPanel.vue";
|
||||
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
|
||||
import { formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canConfig: boolean; canPreview: boolean }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const records = ref<AttentionItem[]>([]);
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const filters = reactive({ priority: "", dateFrom: "", dateTo: "" });
|
||||
const chatDetailOpen = ref(false);
|
||||
const sessionId = ref<number | null>(null);
|
||||
const focusMessageId = ref<number | null>(null);
|
||||
onMounted(() => loadRecords());
|
||||
async function loadRecords(page = 1, pageSize = pager.pageSize) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.attentionList({ page, pageSize });
|
||||
const result = await api.attentionList({
|
||||
priority: filters.priority || undefined,
|
||||
dateFrom: toUtcIsoFromBusinessDate(filters.dateFrom, "start"),
|
||||
dateTo: toUtcIsoFromBusinessDate(filters.dateTo, "end"),
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
records.value = result.items;
|
||||
Object.assign(pager, {
|
||||
page: result.page,
|
||||
@@ -29,6 +38,17 @@ async function loadRecords(page = 1, pageSize = pager.pageSize) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function applyFilters() {
|
||||
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
|
||||
ElMessage.warning("开始日期不能晚于结束日期");
|
||||
return;
|
||||
}
|
||||
void loadRecords(1, pager.pageSize);
|
||||
}
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { priority: "", dateFrom: "", dateTo: "" });
|
||||
void loadRecords(1, pager.pageSize);
|
||||
}
|
||||
async function updateStatus(row: AttentionItem, status: string) {
|
||||
await api.updateAttention(row.id, status);
|
||||
ElMessage.success("人工关注状态已更新");
|
||||
@@ -52,8 +72,9 @@ async function deleteRecord(row: AttentionItem) {
|
||||
ElMessage.success("人工关注记录已删除");
|
||||
await loadRecords(pager.page, pager.pageSize);
|
||||
}
|
||||
function openChat(id: number) {
|
||||
sessionId.value = id;
|
||||
function openChat(row: AttentionItem) {
|
||||
sessionId.value = row.sessionId;
|
||||
focusMessageId.value = row.messageId;
|
||||
chatDetailOpen.value = true;
|
||||
}
|
||||
function priorityLabel(priority: string) {
|
||||
@@ -73,6 +94,22 @@ function priorityLabel(priority: string) {
|
||||
</div>
|
||||
<el-tabs class="attention-tabs">
|
||||
<el-tab-pane label="关注记录">
|
||||
<section class="record-filter attention-toolbar" aria-label="人工关注筛选">
|
||||
<el-select v-model="filters.priority" placeholder="全部优先级" clearable aria-label="优先级">
|
||||
<el-option label="紧急" value="urgent" />
|
||||
<el-option label="重要" value="important" />
|
||||
<el-option label="普通" value="normal" />
|
||||
</el-select>
|
||||
<div class="record-date-range" aria-label="触发时间范围">
|
||||
<label class="record-date-field"><span>开始日期</span><input v-model="filters.dateFrom" type="date" aria-label="人工关注开始日期" /></label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="record-date-field"><span>结束日期</span><input v-model="filters.dateTo" type="date" aria-label="人工关注结束日期" /></label>
|
||||
</div>
|
||||
<div class="record-filter-actions">
|
||||
<el-button type="primary" @click="applyFilters">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<el-table :data="records" stripe
|
||||
><el-table-column label="优先级" width="100"
|
||||
><template #default="{ row }"
|
||||
@@ -116,13 +153,18 @@ function priorityLabel(priority: string) {
|
||||
value="resolved" /><el-option
|
||||
label="无需处理"
|
||||
value="ignored" /></el-select></template></el-table-column
|
||||
><el-table-column
|
||||
prop="createdAt"
|
||||
label="创建时间"
|
||||
width="180"
|
||||
/><el-table-column label="操作" width="130" fixed="right"
|
||||
><el-table-column label="触发时间" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
|
||||
></el-table-column><el-table-column label="操作" width="130" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="primary" @click="openChat(row.sessionId)"
|
||||
><el-tooltip
|
||||
v-if="row.targetAvailable === false"
|
||||
content="原会话或触发消息已删除"
|
||||
placement="top"
|
||||
>
|
||||
<span><el-button link disabled>原会话</el-button></span>
|
||||
</el-tooltip>
|
||||
<el-button v-else link type="primary" @click="openChat(row)"
|
||||
>原会话</el-button
|
||||
><el-button link type="danger" @click="deleteRecord(row)"
|
||||
>删除</el-button
|
||||
@@ -134,7 +176,11 @@ function priorityLabel(priority: string) {
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
@change="loadRecords"
|
||||
/><ChatDetailDrawer v-model="chatDetailOpen" :session-id="sessionId" />
|
||||
/><ChatDetailDrawer
|
||||
v-model="chatDetailOpen"
|
||||
:session-id="sessionId"
|
||||
:focus-message-id="focusMessageId"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="canConfig || canPreview" label="筛选规则与预览" lazy>
|
||||
<AttentionRuleConfigPanel :can-config="canConfig" :can-preview="canPreview" />
|
||||
@@ -145,4 +191,18 @@ function priorityLabel(priority: string) {
|
||||
|
||||
<style scoped>
|
||||
.attention-tabs { margin-top: 14px; }
|
||||
.attention-toolbar {
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.attention-toolbar { align-items: stretch; }
|
||||
.attention-toolbar .record-date-range { width: 100%; }
|
||||
.attention-toolbar .record-date-field { flex: 1; width: auto; }
|
||||
.attention-toolbar .record-filter-actions { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ canConfig: boolean; canPreview: boolean }>();
|
||||
|
||||
@@ -182,7 +183,7 @@ function sourceLabel(value: string) {
|
||||
<el-table-column prop="userName" label="用户" width="120" />
|
||||
<el-table-column prop="sessionTitle" label="会话" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="知识缺失" width="100"><template #default="{ row }"><el-tag :type="row.knowledgeMissing ? 'warning' : 'info'">{{ row.knowledgeMissing ? "是" : "否" }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="createdAt" label="提问时间" width="180" />
|
||||
<el-table-column label="提问时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" />
|
||||
</article>
|
||||
|
||||
@@ -5,17 +5,24 @@ import { nextTick, reactive, ref, watch } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { ChatDetail } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; sessionId: number | null }>();
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
sessionId: number | null;
|
||||
focusMessageId?: number | null;
|
||||
canExport?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
const detail = ref<ChatDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const messagesLoading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const bodyRef = ref<HTMLElement | null>(null);
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.sessionId] as const,
|
||||
() => [props.modelValue, props.sessionId, props.focusMessageId] as const,
|
||||
async ([open, sessionId]) => {
|
||||
if (!open || !sessionId) return;
|
||||
detail.value = null;
|
||||
@@ -30,6 +37,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
|
||||
detail.value = await api.chatDetail(sessionId, {
|
||||
messagePage: page,
|
||||
messagePageSize: pageSize,
|
||||
focusMessageId: props.focusMessageId || undefined,
|
||||
});
|
||||
const messagePage = detail.value.messagesPage;
|
||||
Object.assign(pager, {
|
||||
@@ -37,6 +45,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
|
||||
pageSize: messagePage?.pageSize ?? pageSize,
|
||||
total: messagePage?.total ?? detail.value.messages.length,
|
||||
});
|
||||
await scrollToFocusedMessage();
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : "聊天详情加载失败",
|
||||
@@ -46,6 +55,15 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollToFocusedMessage() {
|
||||
if (!props.focusMessageId) return;
|
||||
await nextTick();
|
||||
const target = bodyRef.value?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${props.focusMessageId}"]`,
|
||||
);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
async function changeMessagePage(page: number, pageSize: number) {
|
||||
if (!props.sessionId || !detail.value) return;
|
||||
messagesLoading.value = true;
|
||||
@@ -71,6 +89,19 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
messagesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConversation() {
|
||||
if (!props.sessionId) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await api.exportChatDetail(props.sessionId);
|
||||
ElMessage.success("当前完整对话已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "完整对话导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,6 +113,9 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
>
|
||||
<div ref="bodyRef" v-loading="loading" class="chat-detail-body">
|
||||
<template v-if="detail">
|
||||
<div v-if="canExport" class="chat-detail-actions">
|
||||
<el-button type="primary" plain :loading="exporting" @click="exportConversation">导出完整对话</el-button>
|
||||
</div>
|
||||
<section class="chat-summary">
|
||||
<div>
|
||||
<span>会话ID</span><strong>{{ detail.session.id }}</strong>
|
||||
@@ -115,8 +149,8 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
><span>消息数:{{ topic.messageCount }}</span
|
||||
><span
|
||||
>Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
|
||||
><span>开始:{{ topic.startedAt }}</span
|
||||
><span>结束:{{ topic.endedAt || "-" }}</span>
|
||||
><span>开始:{{ formatDateTime(topic.startedAt) }}</span
|
||||
><span>结束:{{ formatDateTime(topic.endedAt) }}</span>
|
||||
</div>
|
||||
<template v-if="topic.summary">
|
||||
<pre>{{ topic.summary.summary }}</pre>
|
||||
@@ -151,8 +185,8 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
|
||||
><section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ card.createdAt }}</span
|
||||
><span>复制:{{ card.copiedAt || "-" }}</span
|
||||
<span>生成:{{ formatDateTime(card.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(card.copiedAt) }}</span
|
||||
><span>来源:{{ card.source }}</span>
|
||||
</div>
|
||||
<pre>{{ card.content }}</pre>
|
||||
@@ -176,8 +210,8 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
|
||||
><section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ draft.createdAt }}</span
|
||||
><span>复制:{{ draft.copiedAt || "-" }}</span
|
||||
<span>生成:{{ formatDateTime(draft.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(draft.copiedAt) }}</span
|
||||
><span>来源:{{ draft.source }}</span>
|
||||
</div>
|
||||
<pre>{{ draft.content }}</pre>
|
||||
@@ -202,13 +236,22 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
v-for="message in detail.messages"
|
||||
:key="message.id"
|
||||
class="conversation-message"
|
||||
:class="message.role"
|
||||
:class="[
|
||||
message.role,
|
||||
{ 'focus-message': message.id === focusMessageId },
|
||||
]"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
<div class="message-meta">
|
||||
<strong>{{
|
||||
message.role === "user" ? "用户" : "大本营答疑助手"
|
||||
}}</strong
|
||||
><span>{{ message.createdAt }}</span>
|
||||
><el-tag
|
||||
v-if="message.id === focusMessageId"
|
||||
type="warning"
|
||||
effect="light"
|
||||
>触发人工关注的消息</el-tag
|
||||
><span>{{ formatDateTime(message.createdAt) }}</span>
|
||||
</div>
|
||||
<pre>{{ message.content }}</pre>
|
||||
<div class="message-extra">
|
||||
@@ -242,7 +285,7 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
><span>命中数:{{ log.retrieveCount }}</span
|
||||
><span>Token:{{ log.totalToken || "-" }}</span
|
||||
><span>耗时:{{ log.costMs || "-" }}ms</span
|
||||
><span>时间:{{ log.createdAt }}</span>
|
||||
><span>时间:{{ formatDateTime(log.createdAt) }}</span>
|
||||
</div>
|
||||
<section
|
||||
v-if="log.retrievedChunks?.length"
|
||||
@@ -291,3 +334,26 @@ async function changeMessagePage(page: number, pageSize: number) {
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.conversation-message.focus-message {
|
||||
border: 2px solid #e6a23c;
|
||||
background: #fffaf0;
|
||||
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%);
|
||||
}
|
||||
|
||||
.conversation-message.focus-message .message-meta {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.conversation-message.focus-message .message-meta span:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const activeType = ref<ContentGenerationType>("help_card");
|
||||
const loading = ref(false);
|
||||
@@ -259,11 +260,6 @@ async function restoreHistory(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "系统默认";
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function changeTypeLabel(value: string) {
|
||||
return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value;
|
||||
}
|
||||
@@ -374,7 +370,7 @@ function changeTypeLabel(value: string) {
|
||||
|
||||
<section class="generation-history generation-panel">
|
||||
<header>
|
||||
<div><h3>版本记录</h3><p>当前配置:{{ current?.updatedByName }} · {{ formatTime(current?.updatedAt) }}</p></div>
|
||||
<div><h3>版本记录</h3><p>当前配置:{{ current?.updatedByName }} · {{ formatDateTime(current?.updatedAt, "系统默认") }}</p></div>
|
||||
<el-tag v-if="current" type="success">{{ changeTypeLabel(current.changeType) }}</el-tag>
|
||||
</header>
|
||||
<el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" />
|
||||
@@ -383,7 +379,7 @@ function changeTypeLabel(value: string) {
|
||||
<div>
|
||||
<strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong>
|
||||
<p>{{ item.preview }}</p>
|
||||
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatTime(item.updatedAt) }}</small>
|
||||
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "系统默认") }}</small>
|
||||
</div>
|
||||
<el-button @click="openHistory(item)">查看</el-button>
|
||||
</article>
|
||||
@@ -404,7 +400,7 @@ function changeTypeLabel(value: string) {
|
||||
<div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div>
|
||||
<div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div>
|
||||
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</dd></div>
|
||||
<div><dt>时间</dt><dd>{{ formatTime(selectedHistory.updatedAt) }}</dd></div>
|
||||
<div><dt>时间</dt><dd>{{ formatDateTime(selectedHistory.updatedAt, "系统默认") }}</dd></div>
|
||||
</dl>
|
||||
<h4>变量定义</h4>
|
||||
<div class="history-variables">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
|
||||
|
||||
@@ -160,7 +161,7 @@ function modelSceneLabel(scene: string) {
|
||||
<section class="dashboard-panel" v-loading="storageLoading">
|
||||
<div class="panel-heading"><div><span class="section-kicker">基础设施</span><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计,无法取得时明确标记。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div>
|
||||
<div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div>
|
||||
<small class="storage-updated">最后统计:{{ storageStats?.createdAt || "尚未统计" }}</small>
|
||||
<small class="storage-updated">最后统计:{{ formatDateTime(storageStats?.createdAt, "尚未统计") }}</small>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { onMounted, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { FeedbackDetail, FeedbackItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canDelete: boolean; canDetail: boolean; canExport: boolean }>();
|
||||
|
||||
@@ -119,13 +120,13 @@ onMounted(load);
|
||||
<el-table-column prop="userName" label="用户" width="130" />
|
||||
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180" />
|
||||
<el-table-column label="提交时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
|
||||
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
|
||||
<template v-if="detail">
|
||||
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ detail.createdAt }}</small></section>
|
||||
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
|
||||
<h3>当时对话记录</h3>
|
||||
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]">
|
||||
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
|
||||
import TableRowActions from "./TableRowActions.vue";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
@@ -545,7 +546,7 @@ async function confirmAction(
|
||||
<el-table-column label="类型" width="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
|
||||
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||
<el-table-column label="最近同步" width="180"><template #default="{ row }">{{ formatDateTime(row.lastSyncedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row)">同步最新版</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" divided>永久删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
|
||||
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
|
||||
import { formatDateTime, toUtcIsoFromBusinessDateTime } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canExport: boolean }>();
|
||||
|
||||
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
|
||||
const loading = ref(false);
|
||||
@@ -115,8 +118,8 @@ async function loadInsights(
|
||||
pageSize = pagers.questionInsights.pageSize,
|
||||
) {
|
||||
insights.value = await api.questionInsights({
|
||||
dateFrom: formatDateTime(insightFilters.dateFrom, "start"),
|
||||
dateTo: formatDateTime(insightFilters.dateTo, "end"),
|
||||
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
|
||||
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
|
||||
minCount: insightFilters.minCount,
|
||||
maxMessages: insightFilters.maxMessages,
|
||||
page,
|
||||
@@ -149,8 +152,8 @@ async function refreshInsights() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.refreshQuestionInsights({
|
||||
dateFrom: formatDateTime(insightFilters.dateFrom, "start"),
|
||||
dateTo: formatDateTime(insightFilters.dateTo, "end"),
|
||||
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
|
||||
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
|
||||
maxMessages: insightFilters.maxMessages,
|
||||
});
|
||||
await loadInsights(1);
|
||||
@@ -177,9 +180,9 @@ async function resetInsightFilters() {
|
||||
await refreshInsights();
|
||||
}
|
||||
async function exportInsights() {
|
||||
const dateFrom = formatDateTime(insightFilters.dateFrom, "start");
|
||||
const dateTo = formatDateTime(insightFilters.dateTo, "end");
|
||||
if (dateFrom && dateTo && new Date(dateFrom.replace(" ", "T")) > new Date(dateTo.replace(" ", "T"))) {
|
||||
const dateFrom = toUtcIsoFromBusinessDateTime(insightFilters.dateFrom);
|
||||
const dateTo = toUtcIsoFromBusinessDateTime(insightFilters.dateTo);
|
||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||
return ElMessage.warning("开始时间不能晚于结束时间");
|
||||
}
|
||||
insightExporting.value = true;
|
||||
@@ -217,17 +220,10 @@ function buildChatQuery() {
|
||||
status: chatFilters.status,
|
||||
sourceType: chatFilters.sourceType,
|
||||
sourceClientId: chatFilters.sourceClientId,
|
||||
dateFrom: formatDateTime(chatFilters.dateFrom, "start"),
|
||||
dateTo: formatDateTime(chatFilters.dateTo, "end"),
|
||||
dateFrom: toUtcIsoFromBusinessDateTime(chatFilters.dateFrom),
|
||||
dateTo: toUtcIsoFromBusinessDateTime(chatFilters.dateTo),
|
||||
};
|
||||
}
|
||||
function formatDateTime(value: string, boundary: "start" | "end") {
|
||||
if (!value) return "";
|
||||
const [date, time = ""] = value.trim().replace("T", " ").split(" ");
|
||||
if (!date) return "";
|
||||
const [hour = "00", minute = "00", second] = time.split(":");
|
||||
return `${date} ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${(second ?? (boundary === "end" ? "59" : "00")).padStart(2, "0")}`;
|
||||
}
|
||||
function formatMoney(value?: number | null, currency = "CNY") {
|
||||
return typeof value === "number"
|
||||
? `${currency || "CNY"} ${value.toFixed(6)}`
|
||||
@@ -290,7 +286,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
<div class="record-filter-actions">
|
||||
<el-button type="primary" @click="searchChats">搜索</el-button
|
||||
><el-button @click="resetChatFilters">重置</el-button
|
||||
><el-button @click="api.exportChats(buildChatQuery())"
|
||||
><el-button v-if="canExport" @click="api.exportChats(buildChatQuery())"
|
||||
>导出</el-button
|
||||
>
|
||||
</div>
|
||||
@@ -322,11 +318,9 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
prop="messageCount"
|
||||
label="消息数"
|
||||
width="90"
|
||||
/><el-table-column
|
||||
prop="lastMessageAt"
|
||||
label="最后消息"
|
||||
width="180"
|
||||
/><el-table-column label="操作" width="100" fixed="right"
|
||||
/><el-table-column label="最后消息" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(row.lastMessageAt) }}</template
|
||||
></el-table-column><el-table-column label="操作" width="100" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button size="small" @click="openChat(row.id)"
|
||||
>详情</el-button
|
||||
@@ -382,7 +376,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
@click="refreshInsights"
|
||||
>刷新并统计</el-button
|
||||
><el-button @click="resetInsightFilters">重置</el-button>
|
||||
<el-button :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
|
||||
<el-button v-if="canExport" :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="question-insight-help">
|
||||
@@ -475,7 +469,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
sample.userPhone ||
|
||||
`用户 #${sample.userId}`
|
||||
}}</strong
|
||||
><span>{{ sample.createdAt }}</span>
|
||||
><span>{{ formatDateTime(sample.createdAt) }}</span>
|
||||
</div>
|
||||
<p>清洗后:{{ sample.cleaned }}</p>
|
||||
<pre>{{ sample.raw }}</pre>
|
||||
@@ -569,9 +563,9 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
prop="action"
|
||||
label="动作" /><el-table-column
|
||||
prop="result"
|
||||
label="结果" /><el-table-column
|
||||
prop="createdAt"
|
||||
label="时间" /></el-table
|
||||
label="结果" /><el-table-column label="时间" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(String(row.createdAt || "")) }}</template
|
||||
></el-table-column></el-table
|
||||
><AdminPagination
|
||||
:page="pagers.operationLogs.page"
|
||||
:page-size="pagers.operationLogs.pageSize"
|
||||
@@ -579,7 +573,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
@change="(page, size) => changePage('operationLogs', page, size)"
|
||||
/></el-tab-pane>
|
||||
</el-tabs>
|
||||
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" />
|
||||
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" :can-export="canExport" />
|
||||
<AiLogDetailDrawer v-model="aiLogDetailOpen" :log-id="aiLogId" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { RetrievalLogItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<RetrievalLogItem[]>([]);
|
||||
@@ -102,7 +103,7 @@ async function openDetail(row: RetrievalLogItem) {
|
||||
label="180天"
|
||||
:value="180" /></el-select
|
||||
><el-button type="primary" @click="saveRetention">保存规则</el-button
|
||||
><small>最近执行:{{ retentionState?.lastRunAt || "尚未执行" }}</small>
|
||||
><small>最近执行:{{ formatDateTime(retentionState?.lastRunAt, "尚未执行") }}</small>
|
||||
</section>
|
||||
<el-table :data="logs" stripe
|
||||
><el-table-column prop="id" label="ID" width="80" /><el-table-column
|
||||
@@ -136,11 +137,9 @@ async function openDetail(row: RetrievalLogItem) {
|
||||
prop="totalCostMs"
|
||||
label="总耗时"
|
||||
width="100"
|
||||
/><el-table-column
|
||||
prop="createdAt"
|
||||
label="时间"
|
||||
width="180"
|
||||
/><el-table-column label="操作" width="90" fixed="right"
|
||||
/><el-table-column label="时间" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
|
||||
></el-table-column><el-table-column label="操作" width="90" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="primary" @click="openDetail(row)"
|
||||
>详情</el-button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { EntitlementPlan, SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const loading = ref(false);
|
||||
const savingUrl = ref(false);
|
||||
@@ -266,11 +267,6 @@ async function changeAuditPage(page: number, pageSize: number) {
|
||||
await loadAudits();
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
|
||||
}
|
||||
@@ -327,7 +323,7 @@ function showError(error: unknown, fallback: string) {
|
||||
<template #default="{ row }">
|
||||
<div class="sso-status-cell">
|
||||
<div><el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="toggleClient(row)" /><span>{{ row.status === 1 ? "已启用" : "已停用" }}</span></div>
|
||||
<small>最近使用:{{ formatTime(row.lastUsedAt) }}</small>
|
||||
<small>最近使用:{{ formatDateTime(row.lastUsedAt) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -351,7 +347,7 @@ function showError(error: unknown, fallback: string) {
|
||||
<dl>
|
||||
<div><dt>账号策略</dt><dd>{{ client.allowAutoRegister ? `自动注册 · ${client.defaultEntitlementPlanName || "未配置权益"}` : "仅已有学员" }}(已绑定 {{ client.identityCount }} 人)</dd></div>
|
||||
<div><dt>回跳地址</dt><dd>{{ client.redirectUris.join("、") || "未配置" }}</dd></div>
|
||||
<div><dt>最近使用</dt><dd>{{ formatTime(client.lastUsedAt) }}</dd></div>
|
||||
<div><dt>最近使用</dt><dd>{{ formatDateTime(client.lastUsedAt) }}</dd></div>
|
||||
</dl>
|
||||
<div class="sso-card-actions"><el-button size="small" @click="openEditDialog(client)">编辑</el-button><el-button size="small" @click="rotateSecret(client)">更新密钥</el-button></div>
|
||||
</article>
|
||||
@@ -374,7 +370,7 @@ function showError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
|
||||
<el-table-column label="最近免登录" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
|
||||
<template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }"><el-button size="small" type="danger" plain @click="unlinkIdentity(row)">解除绑定</el-button></template>
|
||||
@@ -405,7 +401,7 @@ function showError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorMessage" label="失败原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="请求IP" width="140" />
|
||||
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @change="changeAuditPage" />
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -9,16 +9,23 @@ import type {
|
||||
UserBehaviorUserSummary,
|
||||
} from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canExport: boolean }>();
|
||||
|
||||
const activeView = ref<"overview" | "users">("overview");
|
||||
const overviewLoading = ref(false);
|
||||
const usersLoading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const overview = ref<UserBehaviorOverview | null>(null);
|
||||
const users = ref<UserBehaviorUserSummary[]>([]);
|
||||
const userTotal = ref(0);
|
||||
const userPage = ref(1);
|
||||
const userPageSize = ref(20);
|
||||
const keyword = ref("");
|
||||
type UserSortField = "userName" | "phone" | "eventCount" | "lastEventAt";
|
||||
const userSortBy = ref<UserSortField>("lastEventAt");
|
||||
const userSortOrder = ref<"asc" | "desc">("desc");
|
||||
const timeline = ref<UserBehaviorTimeline | null>(null);
|
||||
const timelineLoading = ref(false);
|
||||
const timelinePage = ref(1);
|
||||
@@ -56,6 +63,8 @@ async function loadUsers() {
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
page: userPage.value,
|
||||
pageSize: userPageSize.value,
|
||||
sortBy: userSortBy.value,
|
||||
sortOrder: userSortOrder.value,
|
||||
});
|
||||
users.value = result.items;
|
||||
userTotal.value = result.total;
|
||||
@@ -67,15 +76,29 @@ async function loadUsers() {
|
||||
}
|
||||
|
||||
function applyDateRange() {
|
||||
if (!filters.start || !filters.end) return ElMessage.warning("请选择完整时间范围");
|
||||
if (filters.start > filters.end) return ElMessage.warning("开始日期不能晚于结束日期");
|
||||
const span = (new Date(filters.end).getTime() - new Date(filters.start).getTime()) / 86_400_000;
|
||||
if (span > 30) return ElMessage.warning("行为数据最多查询31天,请缩小时间范围");
|
||||
if (!validateDateRange()) return;
|
||||
userPage.value = 1;
|
||||
timeline.value = null;
|
||||
void loadAll();
|
||||
}
|
||||
|
||||
function validateDateRange() {
|
||||
if (!filters.start || !filters.end) {
|
||||
ElMessage.warning("请选择完整时间范围");
|
||||
return false;
|
||||
}
|
||||
if (filters.start > filters.end) {
|
||||
ElMessage.warning("开始日期不能晚于结束日期");
|
||||
return false;
|
||||
}
|
||||
const span = (new Date(filters.end).getTime() - new Date(filters.start).getTime()) / 86_400_000;
|
||||
if (span > 30) {
|
||||
ElMessage.warning("行为数据最多查询 31 天,请缩小时间范围");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function switchView(view: "overview" | "users") {
|
||||
activeView.value = view;
|
||||
if (view === "users" && !users.value.length) void loadUsers();
|
||||
@@ -92,6 +115,32 @@ function changeUserPage(page: number, pageSize: number) {
|
||||
void loadUsers();
|
||||
}
|
||||
|
||||
function changeUserSort({ prop, order }: { prop: string; order: "ascending" | "descending" | null }) {
|
||||
const supported: UserSortField[] = ["userName", "phone", "eventCount", "lastEventAt"];
|
||||
userSortBy.value = supported.includes(prop as UserSortField) ? prop as UserSortField : "lastEventAt";
|
||||
userSortOrder.value = order === "ascending" ? "asc" : "desc";
|
||||
userPage.value = 1;
|
||||
void loadUsers();
|
||||
}
|
||||
|
||||
async function exportReport() {
|
||||
if (!validateDateRange() || exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await api.exportUserBehavior({
|
||||
...filters,
|
||||
keyword: activeView.value === "users" ? keyword.value.trim() || undefined : undefined,
|
||||
sortBy: userSortBy.value,
|
||||
sortOrder: userSortOrder.value,
|
||||
});
|
||||
ElMessage.success("用户行为分析报表已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "用户行为报表导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openTimeline(row: UserBehaviorUserSummary, page = 1, pageSize = timelinePageSize.value) {
|
||||
timelineLoading.value = true;
|
||||
timelinePage.value = page;
|
||||
@@ -131,10 +180,6 @@ function targetLabel(type?: string | null, id?: number | null) {
|
||||
return `${name} #${id}`;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatInputDate(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
@@ -164,6 +209,7 @@ function formatInputDate(value: Date) {
|
||||
<i>至</i>
|
||||
<label><span>结束日期</span><input v-model="filters.end" type="date" aria-label="行为结束日期" /></label>
|
||||
<el-button type="primary" @click="applyDateRange">查询</el-button>
|
||||
<el-button v-if="canExport" :loading="exporting" @click="exportReport">导出 Excel</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -207,11 +253,18 @@ function formatInputDate(value: Date) {
|
||||
<div><h3>用户交互记录</h3><p>选择用户后,可查看选定时间范围内的完整关键操作流程。</p></div>
|
||||
<div class="user-search"><el-input v-model="keyword" clearable placeholder="姓名或手机号" @keyup.enter="searchUsers" /><el-button type="primary" @click="searchUsers">搜索</el-button></div>
|
||||
</div>
|
||||
<el-table v-loading="usersLoading" :data="users" stripe empty-text="当前范围暂无用户行为记录">
|
||||
<el-table-column prop="userName" label="用户" min-width="150" />
|
||||
<el-table-column prop="phone" label="手机号" width="150" />
|
||||
<el-table-column prop="eventCount" label="交互次数" width="120" align="center" />
|
||||
<el-table-column label="最后交互时间" width="190"><template #default="{ row }">{{ formatTime(row.lastEventAt) }}</template></el-table-column>
|
||||
<el-table
|
||||
v-loading="usersLoading"
|
||||
:data="users"
|
||||
:default-sort="{ prop: userSortBy, order: userSortOrder === 'asc' ? 'ascending' : 'descending' }"
|
||||
stripe
|
||||
empty-text="当前范围暂无用户行为记录"
|
||||
@sort-change="changeUserSort"
|
||||
>
|
||||
<el-table-column prop="userName" label="用户" min-width="150" sortable="custom" />
|
||||
<el-table-column prop="phone" label="手机号" width="150" sortable="custom" />
|
||||
<el-table-column prop="eventCount" label="交互次数" width="120" align="center" sortable="custom" />
|
||||
<el-table-column prop="lastEventAt" label="最后交互时间" width="190" sortable="custom"><template #default="{ row }">{{ formatDateTime(row.lastEventAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openTimeline(row)">查看流程</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination v-if="userTotal" :page="userPage" :page-size="userPageSize" :total="userTotal" @change="changeUserPage" />
|
||||
@@ -227,7 +280,7 @@ function formatInputDate(value: Date) {
|
||||
<article v-for="item in timeline.items" :key="item.id">
|
||||
<i :class="item.eventType" />
|
||||
<div>
|
||||
<time>{{ formatTime(item.occurredAt) }}</time>
|
||||
<time>{{ formatDateTime(item.occurredAt) }}</time>
|
||||
<strong>{{ item.eventName }}</strong>
|
||||
<small>{{ eventTypeLabel(item.eventType) }}<template v-if="targetLabel(item.targetType, item.targetId)"> · {{ targetLabel(item.targetType, item.targetId) }}</template></small>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, watch } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { AdminUserDetail } from "../types/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; userId: number | null }>();
|
||||
defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
@@ -171,11 +172,11 @@ function reportTriggerLabel(value: string) {
|
||||
</div>
|
||||
<div>
|
||||
<span>最近登录</span
|
||||
><strong>{{ detail.user.lastLoginAt || "-" }}</strong>
|
||||
><strong>{{ formatDateTime(detail.user.lastLoginAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近发言</span
|
||||
><strong>{{ detail.metrics.lastMessageAt || "-" }}</strong>
|
||||
><strong>{{ formatDateTime(detail.metrics.lastMessageAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>生成记录</span
|
||||
@@ -265,9 +266,9 @@ function reportTriggerLabel(value: string) {
|
||||
report.maxAttempts
|
||||
}}</span
|
||||
>
|
||||
<span v-if="report.nextRunAt"
|
||||
>下次执行:{{ report.nextRunAt }}</span
|
||||
><span>完成时间:{{ report.finishedAt || "-" }}</span>
|
||||
<span v-if="report.nextRunAt"
|
||||
>下次执行:{{ formatDateTime(report.nextRunAt) }}</span
|
||||
><span>完成时间:{{ formatDateTime(report.finishedAt) }}</span>
|
||||
</div>
|
||||
<p v-if="report.errorMessage" class="report-error">
|
||||
最近错误:{{ report.errorMessage }}
|
||||
@@ -303,8 +304,8 @@ function reportTriggerLabel(value: string) {
|
||||
<span
|
||||
>Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
|
||||
>
|
||||
<span>开始:{{ topic.startedAt }}</span
|
||||
><span>更新:{{ topic.updatedAt }}</span>
|
||||
<span>开始:{{ formatDateTime(topic.startedAt) }}</span
|
||||
><span>更新:{{ formatDateTime(topic.updatedAt) }}</span>
|
||||
</div>
|
||||
<template v-if="topic.summary">
|
||||
<div class="topic-summary-meta">
|
||||
@@ -319,10 +320,10 @@ function reportTriggerLabel(value: string) {
|
||||
}}</span
|
||||
>
|
||||
<span v-if="topic.summary.lastStartedAt"
|
||||
>开始执行:{{ topic.summary.lastStartedAt }}</span
|
||||
>开始执行:{{ formatDateTime(topic.summary.lastStartedAt) }}</span
|
||||
>
|
||||
<span v-if="topic.summary.finishedAt"
|
||||
>完成:{{ topic.summary.finishedAt }}</span
|
||||
>完成:{{ formatDateTime(topic.summary.finishedAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="topic.summary.errorMessage" class="report-error">
|
||||
@@ -392,8 +393,8 @@ function reportTriggerLabel(value: string) {
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ card.createdAt }}</span
|
||||
><span>复制:{{ card.copiedAt || "-" }}</span
|
||||
<span>生成:{{ formatDateTime(card.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(card.copiedAt) }}</span
|
||||
><span>来源:{{ card.source }}</span>
|
||||
</div>
|
||||
<pre>{{ card.content }}</pre>
|
||||
@@ -420,8 +421,8 @@ function reportTriggerLabel(value: string) {
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ draft.createdAt }}</span
|
||||
><span>复制:{{ draft.copiedAt || "-" }}</span
|
||||
<span>生成:{{ formatDateTime(draft.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(draft.copiedAt) }}</span
|
||||
><span>来源:{{ draft.source }}</span>
|
||||
</div>
|
||||
<pre>{{ draft.content }}</pre>
|
||||
|
||||
@@ -12,6 +12,7 @@ import AdminPagination from "./AdminPagination.vue";
|
||||
import TableRowActions from "./TableRowActions.vue";
|
||||
import UserDetailDrawer from "./UserDetailDrawer.vue";
|
||||
import UserEntitlementRenewDialog from "./UserEntitlementRenewDialog.vue";
|
||||
import { formatDate, formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
|
||||
|
||||
const loading = ref(false);
|
||||
const users = ref<AdminUser[]>([]);
|
||||
@@ -150,14 +151,6 @@ function entitlementStatusType(row: AdminUser) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatEntitlementDate(value?: string | null) {
|
||||
if (!value) return "长期";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
async function assignUserEntitlement(row: AdminUser, planId: number) {
|
||||
row.entitlement = await api.assignUserEntitlement(row.id, { planId });
|
||||
ElMessage.success("用户权益已更新");
|
||||
@@ -186,9 +179,7 @@ async function createStudent() {
|
||||
nickname: studentForm.nickname || null,
|
||||
status: studentForm.status,
|
||||
dailyChatLimit: studentForm.dailyChatLimit,
|
||||
expiredAt: studentForm.expiredAt
|
||||
? `${studentForm.expiredAt}T23:59:59`
|
||||
: null,
|
||||
expiredAt: toUtcIsoFromBusinessDate(studentForm.expiredAt, "end") ?? null,
|
||||
});
|
||||
ElMessage.success("学员已添加");
|
||||
resetStudentForm();
|
||||
@@ -412,8 +403,8 @@ async function deleteUser(row: AdminUser) {
|
||||
>{{ row.entitlement?.name || "默认基础版" }} ·
|
||||
{{
|
||||
row.entitlement?.lifecycleStatus === "expired_fallback"
|
||||
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatEntitlementDate(row.entitlement?.previousExpiredAt)} 到期`
|
||||
: `有效至 ${formatEntitlementDate(row.entitlement?.expiredAt)}`
|
||||
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatDate(row.entitlement?.previousExpiredAt, "长期")} 到期`
|
||||
: `有效至 ${formatDate(row.entitlement?.expiredAt, "长期")}`
|
||||
}}</small
|
||||
>
|
||||
<div class="entitlement-meta-row">
|
||||
@@ -424,7 +415,7 @@ async function deleteUser(row: AdminUser) {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginAt" label="最近登录" width="180" />
|
||||
<el-table-column label="最近登录" width="180"><template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<TableRowActions>
|
||||
|
||||
@@ -159,10 +159,15 @@ export const api = {
|
||||
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
|
||||
userBehaviorOverview: (query: { start?: string; end?: string } = {}) =>
|
||||
request<UserBehaviorOverview>(`/admin/user-behavior/overview${queryString(query)}`),
|
||||
userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
|
||||
request<PageResult<UserBehaviorUserSummary>>(`/admin/user-behavior/users${queryString(query)}`),
|
||||
userBehaviorTimeline: (userId: number, query: { start?: string; end?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<UserBehaviorTimeline>(`/admin/user-behavior/user/${userId}/timeline${queryString(query)}`),
|
||||
exportUserBehavior: (query: { start?: string; end?: string; keyword?: string; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
|
||||
download(
|
||||
`/admin/user-behavior/export${queryString(query)}`,
|
||||
`用户行为分析_${query.start || "开始"}_${query.end || "当前"}.xlsx`,
|
||||
),
|
||||
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
||||
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
||||
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
@@ -335,10 +340,12 @@ export const api = {
|
||||
ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`),
|
||||
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
|
||||
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
|
||||
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number; focusMessageId?: number } = {}) =>
|
||||
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
|
||||
chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<ChatMessageRecord>>(`/admin/chat/${sessionId}/messages${queryString(query)}`),
|
||||
exportChatDetail: (sessionId: number, filename = `完整对话_会话${sessionId}.xlsx`) =>
|
||||
download(`/admin/chat/${sessionId}/export`, filename),
|
||||
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
|
||||
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
|
||||
@@ -359,7 +366,7 @@ export const api = {
|
||||
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
|
||||
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
|
||||
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
|
||||
attentionList: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
|
||||
attentionList: (query: { priority?: string; status?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
|
||||
attentionConfig: () => request<AttentionConfig>("/admin/attention/config"),
|
||||
attentionPreviewConfig: () => request<AttentionConfig>("/admin/attention/preview/config"),
|
||||
saveAttentionConfig: (payload: AttentionConfig) => request<AttentionConfig>("/admin/attention/config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -608,6 +608,7 @@ export interface AttentionItem {
|
||||
priority: string;
|
||||
status: string;
|
||||
handlerNote?: string | null;
|
||||
targetAvailable?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -725,6 +726,7 @@ export interface ChatDetail {
|
||||
session: ChatRecord;
|
||||
messages: ChatMessageRecord[];
|
||||
messagesPage?: PageResult<ChatMessageRecord>;
|
||||
focusedMessageId?: number | null;
|
||||
aiLogs: AiLogRecord[];
|
||||
topics?: TopicSessionRecord[];
|
||||
helpCards?: TeacherHelpCardRecord[];
|
||||
|
||||
81
ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
Normal file
81
ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
Normal 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();
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
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.chat import ChatMessage
|
||||
from app.models.knowledge import (
|
||||
HumanAttentionHistory,
|
||||
HumanAttentionRecord,
|
||||
@@ -95,7 +97,9 @@ def _cleanup_before(payload: dict) -> datetime:
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="必须指定清理日期")
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
|
||||
|
||||
@@ -151,21 +155,46 @@ def _knowledge_type_label(value: str) -> str:
|
||||
|
||||
@router.get("/attention/list")
|
||||
def attention_list(
|
||||
priority: str = Query(default=""),
|
||||
statusValue: str = Query(default="", alias="status"),
|
||||
priority: str = Query(default="", pattern="^(|urgent|important|normal)$"),
|
||||
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),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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)
|
||||
if priority:
|
||||
query = query.where(HumanAttentionRecord.priority == priority)
|
||||
if 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
|
||||
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}")
|
||||
@@ -251,8 +280,8 @@ def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def _attention_dict(item: HumanAttentionRecord) -> dict:
|
||||
return {
|
||||
def _attention_dict(item: HumanAttentionRecord, *, target_available: bool | None = None) -> dict:
|
||||
data = {
|
||||
"id": item.id,
|
||||
"sessionId": item.session_id,
|
||||
"messageId": item.message_id,
|
||||
@@ -267,6 +296,9 @@ def _attention_dict(item: HumanAttentionRecord) -> dict:
|
||||
"createdAt": item.created_at,
|
||||
"updatedAt": item.updated_at,
|
||||
}
|
||||
if target_available is not None:
|
||||
data["targetAvailable"] = target_available
|
||||
return data
|
||||
|
||||
|
||||
def _json(raw: str | None):
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.services.admin_service import AdminDashboardService
|
||||
from app.models.logs import StorageSnapshot
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
from app.services.request_traffic_service import RequestTrafficService
|
||||
from app.core.time_utils import business_day_boundary
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,12 +30,8 @@ def dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
|
||||
if end:
|
||||
end_dt = datetime.strptime(end, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
else:
|
||||
end_dt = None
|
||||
start_dt = business_day_boundary(datetime.strptime(start, "%Y-%m-%d").date()) if start else None
|
||||
end_dt = business_day_boundary(datetime.strptime(end, "%Y-%m-%d").date(), end_exclusive=True) if end else None
|
||||
stats = AdminDashboardService.stats(db, start_dt, end_dt)
|
||||
return api_success(DashboardStats.model_validate(stats).model_dump())
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session, load_only
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
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.chat import ChatMessage, ChatSession, TopicSession
|
||||
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.help_card_service import help_card_dict
|
||||
from app.services.share_draft_service import share_draft_dict
|
||||
from app.services.chat_export_service import ChatExportService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -45,6 +47,8 @@ def chat_list(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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(
|
||||
keyword=keyword,
|
||||
user_id=userId,
|
||||
@@ -72,6 +76,8 @@ def export_chats(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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(
|
||||
_chat_query(
|
||||
keyword=keyword,
|
||||
@@ -98,8 +104,8 @@ def export_chats(
|
||||
source_client.name if source_client else "千问千答直接访问",
|
||||
session.title,
|
||||
session.message_count,
|
||||
session.last_message_at,
|
||||
session.updated_at,
|
||||
to_business_naive(session.last_message_at),
|
||||
to_business_naive(session.updated_at),
|
||||
]
|
||||
)
|
||||
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))
|
||||
|
||||
|
||||
@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}")
|
||||
def chat_detail(
|
||||
session_id: int,
|
||||
messagePage: int = Query(default=1, ge=1),
|
||||
messagePageSize: int = Query(default=20, ge=10, le=100),
|
||||
focusMessageId: int | None = Query(default=None, gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
@@ -154,6 +202,28 @@ def chat_detail(
|
||||
session, user, source_client = session_row
|
||||
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
|
||||
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(
|
||||
message_query
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
@@ -198,6 +268,7 @@ def chat_detail(
|
||||
"topics": topics,
|
||||
"helpCards": [help_card_dict(item) for item in help_cards],
|
||||
"shareDrafts": [share_draft_dict(item) for item in share_drafts],
|
||||
"focusedMessageId": focusMessageId,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -288,6 +359,8 @@ def question_insights(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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(
|
||||
QuestionInsightService.summarize(
|
||||
db,
|
||||
@@ -309,6 +382,8 @@ def refresh_question_insights(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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(
|
||||
db,
|
||||
date_from=dateFrom,
|
||||
@@ -334,6 +409,8 @@ def export_question_insights(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
|
||||
result = QuestionInsightService.summarize(
|
||||
@@ -387,9 +464,9 @@ def _chat_query(
|
||||
if source_client_id is not None:
|
||||
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
|
||||
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:
|
||||
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
|
||||
query = query.where(ChatSession.updated_at <= date_to)
|
||||
if status:
|
||||
query = query.where(
|
||||
exists()
|
||||
|
||||
@@ -3,12 +3,15 @@ from __future__ import annotations
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
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
|
||||
|
||||
|
||||
@@ -30,6 +33,8 @@ def behavior_users(
|
||||
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)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=5, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -43,10 +48,42 @@ def behavior_users(
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
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")
|
||||
def behavior_timeline(
|
||||
user_id: int,
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
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.ai_config import SystemConfig
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement
|
||||
@@ -475,7 +476,7 @@ def update_user(
|
||||
if payload.dailyChatLimit is not None:
|
||||
user.daily_chat_limit = payload.dailyChatLimit
|
||||
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)
|
||||
if payload.entitlementPlanId is not None:
|
||||
EntitlementService.assign_user_plan(
|
||||
@@ -698,7 +699,7 @@ def _apply_user_payload(
|
||||
user.nickname = payload.nickname.strip() if payload.nickname else None
|
||||
user.status = payload.status
|
||||
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:
|
||||
@@ -763,14 +764,17 @@ def _parse_optional_datetime(value: object | None) -> datetime | None:
|
||||
if value is None or _cell_text(value) == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
return business_datetime_to_utc_naive(value)
|
||||
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)
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
|
||||
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:
|
||||
continue
|
||||
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from io import BytesIO
|
||||
|
||||
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.dependencies import get_current_admin, get_current_user
|
||||
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.chat import ChatMessage, ChatSession
|
||||
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":
|
||||
query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0))
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@@ -159,8 +160,8 @@ def _feedback_workbook(rows: list[tuple]) -> Workbook:
|
||||
_excel_safe_text(session.title),
|
||||
message.id,
|
||||
_excel_safe_text(ensure_ai_generated_notice(message.content)),
|
||||
feedback.created_at,
|
||||
feedback.read_at,
|
||||
to_business_naive(feedback.created_at),
|
||||
to_business_naive(feedback.read_at),
|
||||
])
|
||||
header_fill = PatternFill("solid", fgColor="1F6F5F")
|
||||
for cell in sheet[1]:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -20,6 +20,16 @@ if not settings.database_url.startswith("sqlite"):
|
||||
pool_recycle=settings.db_pool_recycle,
|
||||
)
|
||||
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)
|
||||
register_knowledge_cache_invalidation_events()
|
||||
|
||||
|
||||
@@ -126,7 +126,9 @@ def enforce_admin_access(
|
||||
else:
|
||||
permission = "attention.view" if method == "GET" else "attention.edit"
|
||||
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:
|
||||
permission = "records.view"
|
||||
require_permission(admin, permission)
|
||||
|
||||
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
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]:
|
||||
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]:
|
||||
return {"code": code, "message": message, "data": data}
|
||||
return {"code": code, "message": message, "data": normalize_api_datetimes(data)}
|
||||
|
||||
64
ai_knowledge_base_v2/apps/backend/app/core/time_utils.py
Normal file
64
ai_knowledge_base_v2/apps/backend/app/core/time_utils.py
Normal 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
|
||||
@@ -248,6 +248,9 @@ class KnowledgeRetrievalCandidate(Base):
|
||||
|
||||
class HumanAttentionRecord(Base):
|
||||
__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)
|
||||
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
|
||||
@@ -17,11 +17,11 @@ PERMISSION_TREE = [
|
||||
{"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": "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": "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": "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": "删除管理员"}]},
|
||||
]
|
||||
|
||||
|
||||
@@ -141,10 +141,10 @@ class AdminDashboardService:
|
||||
msg_filter.append(ChatMessage.created_at >= start)
|
||||
ai_filter.append(AiRequestLog.created_at >= start)
|
||||
if end:
|
||||
user_filter.append(User.created_at <= end)
|
||||
session_filter.append(ChatSession.created_at <= end)
|
||||
msg_filter.append(ChatMessage.created_at <= end)
|
||||
ai_filter.append(AiRequestLog.created_at <= end)
|
||||
user_filter.append(User.created_at < end)
|
||||
session_filter.append(ChatSession.created_at < end)
|
||||
msg_filter.append(ChatMessage.created_at < end)
|
||||
ai_filter.append(AiRequestLog.created_at < end)
|
||||
|
||||
user_where = and_(true(), *user_filter)
|
||||
session_where = and_(true(), *session_filter)
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||
from app.models.user import User
|
||||
from app.core.time_utils import as_utc_naive
|
||||
|
||||
|
||||
DEFAULT_PLAN_TYPE = "basic"
|
||||
@@ -260,12 +261,12 @@ class EntitlementService:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
|
||||
now = _now()
|
||||
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:
|
||||
start = effective_at or now
|
||||
expired_at = start + timedelta(days=plan.validity_days)
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="权益到期时间不能早于生效时间")
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.services.redis_client import get_sync_redis_client
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.user_behavior_service import UserBehaviorService
|
||||
from app.core.config import get_settings
|
||||
from app.core.time_utils import utc_now_naive
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
@@ -43,11 +44,12 @@ class MaintenanceService:
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
if not policy or not policy.enabled or not policy.retention_days:
|
||||
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
|
||||
before = datetime.now() - timedelta(days=policy.retention_days)
|
||||
before = now - timedelta(days=policy.retention_days)
|
||||
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)
|
||||
db.add(policy)
|
||||
db.commit()
|
||||
@@ -55,7 +57,7 @@ class MaintenanceService:
|
||||
with SessionLocal() as db:
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
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)
|
||||
db.add(policy)
|
||||
db.commit()
|
||||
|
||||
@@ -629,4 +629,4 @@ def _local_datetime(value: datetime) -> datetime:
|
||||
except ZoneInfoNotFoundError:
|
||||
timezone = ZoneInfo("Asia/Shanghai")
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.worksheet.table import Table, TableStyleInfo
|
||||
|
||||
from app.core.ai_content_label import AI_GENERATED_NOTICE
|
||||
from app.core.time_utils import to_business_naive, utc_now_naive
|
||||
|
||||
|
||||
class QuestionInsightExportService:
|
||||
@@ -116,7 +117,7 @@ def _append_summary(sheet, result: dict) -> None:
|
||||
("导出问题组", summary.get("visibleClusterCount", 0)),
|
||||
("清洗规则版本", summary.get("cleanerVersion", "")),
|
||||
("内容标识", AI_GENERATED_NOTICE),
|
||||
("导出时间", datetime.now()),
|
||||
("导出时间", to_business_naive(utc_now_naive())),
|
||||
("说明", "导出结果按所选日期范围和最低频次生成,包含全部符合条件的问题组,不受页面分页影响。"),
|
||||
]
|
||||
for label, value in rows:
|
||||
@@ -165,6 +166,6 @@ def _excel_safe(value: object | None) -> str:
|
||||
|
||||
|
||||
def _excel_datetime(value: object) -> object:
|
||||
if isinstance(value, datetime) and value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
if isinstance(value, datetime):
|
||||
return to_business_naive(value)
|
||||
return value
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.insight import QuestionInsightCleanedQuestion
|
||||
from app.models.logs import AiRequestLog
|
||||
from app.models.user import User
|
||||
from app.core.time_utils import as_utc_naive
|
||||
|
||||
|
||||
CLEANER_VERSION = "v1"
|
||||
@@ -249,9 +250,9 @@ def _load_unprocessed_user_messages(
|
||||
)
|
||||
)
|
||||
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:
|
||||
query = query.where(ChatMessage.created_at <= date_to.replace(tzinfo=None))
|
||||
query = query.where(ChatMessage.created_at <= as_utc_naive(date_to))
|
||||
return list(
|
||||
db.execute(
|
||||
query.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc()).limit(limit)
|
||||
@@ -268,9 +269,9 @@ def _load_persisted_cleaned_questions(
|
||||
) -> list[QuestionInsightCleanedQuestion]:
|
||||
range_filters = [QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION]
|
||||
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:
|
||||
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 = (
|
||||
select(
|
||||
@@ -312,9 +313,9 @@ def _load_ai_logs(
|
||||
) -> list[AiRequestLog]:
|
||||
query = select(AiRequestLog)
|
||||
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:
|
||||
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())
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from sqlalchemy import case, delete, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -11,6 +10,7 @@ from app.models.behavior import UserBehaviorEvent
|
||||
from app.models.user import User
|
||||
from app.schemas.behavior import UserBehaviorEventCreate
|
||||
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]] = {
|
||||
@@ -48,6 +48,8 @@ EVENT_CATALOG: dict[str, tuple[str, str]] = {
|
||||
TARGET_TYPES = {"session", "message", "help_card", "share_draft", "report"}
|
||||
DEFAULT_RANGE_DAYS = 7
|
||||
MAX_RANGE_DAYS = 31
|
||||
USER_SORT_FIELDS = {"userName", "phone", "eventCount", "lastEventAt"}
|
||||
USER_SORT_ORDERS = {"asc", "desc"}
|
||||
|
||||
|
||||
class UserBehaviorService:
|
||||
@@ -97,7 +99,7 @@ class UserBehaviorService:
|
||||
return accepted
|
||||
|
||||
@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)
|
||||
base = (UserBehaviorEvent.occurred_at >= start_dt, UserBehaviorEvent.occurred_at < end_dt)
|
||||
total, active_users, page_opens, button_clicks = db.execute(
|
||||
@@ -119,7 +121,7 @@ class UserBehaviorService:
|
||||
.where(*base)
|
||||
.group_by(UserBehaviorEvent.event_code, UserBehaviorEvent.event_name, UserBehaviorEvent.event_type)
|
||||
.order_by(func.count(UserBehaviorEvent.id).desc(), UserBehaviorEvent.event_code)
|
||||
.limit(20)
|
||||
.limit(max(1, min(ranking_limit, len(EVENT_CATALOG))))
|
||||
).all()
|
||||
if db.bind and db.bind.dialect.name == "mysql":
|
||||
day_expression = func.date(func.convert_tz(UserBehaviorEvent.occurred_at, "+00:00", "+08:00"))
|
||||
@@ -135,8 +137,8 @@ class UserBehaviorService:
|
||||
.group_by(day_expression)
|
||||
.order_by(day_expression)
|
||||
).all()
|
||||
local_start = (start_dt + timedelta(hours=8)).date()
|
||||
local_end = (end_dt + timedelta(hours=8) - timedelta(days=1)).date()
|
||||
local_start = to_business_naive(start_dt).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 = []
|
||||
cursor = local_start
|
||||
@@ -160,47 +162,57 @@ class UserBehaviorService:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def users(db: Session, *, start: date | None, end: date | None, keyword: str, page: int, page_size: int) -> dict:
|
||||
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)))
|
||||
grouped = (
|
||||
select(
|
||||
User.id.label("user_id"),
|
||||
User.name,
|
||||
User.nickname,
|
||||
User.phone,
|
||||
func.count(UserBehaviorEvent.id).label("event_count"),
|
||||
func.max(UserBehaviorEvent.occurred_at).label("last_event_at"),
|
||||
)
|
||||
.join(UserBehaviorEvent, UserBehaviorEvent.user_id == User.id)
|
||||
.where(*filters)
|
||||
.group_by(User.id, User.name, User.nickname, User.phone)
|
||||
def users(
|
||||
db: Session,
|
||||
*,
|
||||
start: date | None,
|
||||
end: date | None,
|
||||
keyword: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str = "lastEventAt",
|
||||
sort_order: str = "desc",
|
||||
) -> dict:
|
||||
grouped, order_expression = _user_summary_query(
|
||||
start=start,
|
||||
end=end,
|
||||
keyword=keyword,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(grouped.subquery())) or 0
|
||||
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)
|
||||
.limit(page_size)
|
||||
).all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"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
|
||||
],
|
||||
"items": [_user_summary_dict(row) for row in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"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
|
||||
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)
|
||||
@@ -249,18 +261,67 @@ class UserBehaviorService:
|
||||
|
||||
|
||||
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
|
||||
start_date = start or (end_date - timedelta(days=DEFAULT_RANGE_DAYS - 1))
|
||||
if end_date < start_date:
|
||||
start_date, end_date = end_date, start_date
|
||||
if (end_date - start_date).days >= MAX_RANGE_DAYS:
|
||||
start_date = end_date - timedelta(days=MAX_RANGE_DAYS - 1)
|
||||
# Admin date filters are Beijing calendar days; persisted timestamps are UTC-naive.
|
||||
return (
|
||||
datetime.combine(start_date, time.min) - timedelta(hours=8),
|
||||
datetime.combine(end_date + timedelta(days=1), time.min) - timedelta(hours=8),
|
||||
return business_day_boundary(start_date), business_day_boundary(end_date, end_exclusive=True)
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -101,7 +101,14 @@ def test_chat_detail_messages_are_paginated():
|
||||
])
|
||||
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"]
|
||||
assert data["session"]["messageCount"] == 25
|
||||
@@ -111,6 +118,40 @@ def test_chat_detail_messages_are_paginated():
|
||||
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():
|
||||
with _database() as db:
|
||||
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,
|
||||
page=3, pageSize=10, db=db, current_admin=object(),
|
||||
)["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 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():
|
||||
with _database() as db:
|
||||
user_a = User(id=1, phone="13800000000", name="学员A", daily_chat_limit=10)
|
||||
|
||||
@@ -16,8 +16,9 @@ def test_super_admin_has_all_permissions() -> None:
|
||||
assert permissions_for(admin) == ALL_PERMISSION_CODES
|
||||
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= 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 {"records.view", "records.export"} <= ALL_PERMISSION_CODES
|
||||
|
||||
|
||||
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)
|
||||
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(
|
||||
("path", "method", "permission"),
|
||||
|
||||
63
ai_knowledge_base_v2/apps/backend/tests/test_chat_export.py
Normal file
63
ai_knowledge_base_v2/apps/backend/tests/test_chat_export.py
Normal 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)
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
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["H1"].value == "对应AI回答(AI生成)"
|
||||
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"
|
||||
|
||||
@@ -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)
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
@@ -11,19 +13,27 @@ from app.models.behavior import UserBehaviorEvent
|
||||
from app.models.user import User
|
||||
from app.schemas.behavior import UserBehaviorEventCreate
|
||||
from app.services.user_behavior_service import UserBehaviorService
|
||||
from app.services.user_behavior_export_service import UserBehaviorExportService
|
||||
|
||||
|
||||
def _engine():
|
||||
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(
|
||||
clientEventId=event_id,
|
||||
eventCode=code,
|
||||
targetType=target_type,
|
||||
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.commit()
|
||||
base_time = datetime.now(UTC) - timedelta(minutes=5)
|
||||
UserBehaviorService.record_batch(
|
||||
db,
|
||||
user=users[0],
|
||||
items=[
|
||||
_event("11111111-1111-1111-1111-111111111111", "app_open"),
|
||||
_event("22222222-2222-2222-2222-222222222222", "send_question_click", target_type="session", target_id=3),
|
||||
_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, occurred_at=base_time + timedelta(minutes=1)),
|
||||
],
|
||||
)
|
||||
UserBehaviorService.record_batch(
|
||||
db,
|
||||
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)
|
||||
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)
|
||||
assert listed["total"] == 1
|
||||
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)
|
||||
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:
|
||||
engine = _engine()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { computed } from "vue";
|
||||
import { formatTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{
|
||||
messageId: string;
|
||||
@@ -79,10 +80,7 @@ function splitReasoning(content: string) {
|
||||
}
|
||||
|
||||
const displayTime = computed(() => {
|
||||
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`;
|
||||
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);
|
||||
return formatTime(props.createdAt, "");
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "../types/api";
|
||||
import { formatDate } from "../utils/dateTime";
|
||||
import AppDialog from "./AppDialog.vue";
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
pending: "等待整理",
|
||||
@@ -189,7 +183,7 @@ function submitDeleteCard() {
|
||||
</div>
|
||||
<div>
|
||||
<dt>有效期至</dt>
|
||||
<dd>{{ formatDate(entitlement?.expiredAt) }}</dd>
|
||||
<dd>{{ formatDate(entitlement?.expiredAt, "长期有效") }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { ChatSession } from "../types/api";
|
||||
import AppDialog from "./AppDialog.vue";
|
||||
import { formatSessionTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +80,7 @@ function formatTime(value: string) {
|
||||
>
|
||||
<button type="button" class="history-main" @click="emit('select', session.id)">
|
||||
<strong>{{ session.title }}</strong>
|
||||
<time :datetime="session.updatedAt">{{ formatTime(session.updatedAt) }}</time>
|
||||
<time :datetime="session.updatedAt">{{ formatSessionTime(session.updatedAt) }}</time>
|
||||
<span>{{ session.messageCount }} 条消息</span>
|
||||
</button>
|
||||
<div class="history-actions">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Layers3, LoaderCircle } from "@lucide/vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { UserEntitlementSummary } from "../types/api";
|
||||
import { formatDate } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{
|
||||
used: number;
|
||||
@@ -26,11 +27,6 @@ const usageText = computed(() => {
|
||||
if (displayLimit.value <= 0) return "今日不可用";
|
||||
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(() => {
|
||||
if (props.entitlement?.lifecycleStatus === "expired_fallback") {
|
||||
return `原${props.entitlement.previousPlanName ? `“${props.entitlement.previousPlanName}”` : "权益"}已于 ${formatDate(props.entitlement.previousExpiredAt)} 到期,当前已自动切换为${props.entitlement.name},已有对话与回顾仍会保留。`;
|
||||
|
||||
76
ai_knowledge_base_v2/apps/user-client/src/utils/dateTime.ts
Normal file
76
ai_knowledge_base_v2/apps/user-client/src/utils/dateTime.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user