feat: improve audit exports and admin analytics
This commit is contained in:
@@ -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