diff --git a/TODO.md b/TODO.md index 054aa04..6583c77 100644 --- a/TODO.md +++ b/TODO.md @@ -66,17 +66,17 @@ ### 1. 给 JSON 数据加校验 -- [ ] 为 projects 数据建立 schema 校验 -- [ ] 为 shares 数据建立 schema 校验 -- [ ] 为 gitea activity 数据建立 schema 校验 -- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面 +- [x] 为 projects 数据建立 schema 校验 +- [x] 为 shares 数据建立 schema 校验 +- [x] 为 gitea activity 数据建立 schema 校验 +- [x] 在构建前先校验数据格式,避免远程脏数据直接打爆页面 ### 2. 增加同步元数据展示 - [x] 生成 `updatedAt` - [x] 生成 `source` - [x] 生成 `itemCount`(activity) -- [ ] 前端更明确地显示“数据更新时间” +- [x] 前端更明确地显示“数据更新时间” ### 3. 增加变更检测 diff --git a/package-lock.json b/package-lock.json index c632454..290d997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "personal-homepage", "version": "0.1.0", "dependencies": { - "astro": "^6.2.1" + "astro": "^6.2.1", + "zod": "^4.4.3" }, "engines": { "node": ">=22.12.0" @@ -4720,9 +4721,9 @@ } }, "node_modules/zod": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", - "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 88d5c13..c41a5c6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.2.1" + "astro": "^6.2.1", + "zod": "^4.4.3" } } diff --git a/scripts/sync-content.ts b/scripts/sync-content.ts index ea6bd55..d017970 100644 --- a/scripts/sync-content.ts +++ b/scripts/sync-content.ts @@ -1,26 +1,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import { fetchGiteaSnapshot, type GeneratedGiteaActivity, type GeneratedProject } from './fetch-gitea.ts'; -import { fetchSeafileSnapshot, type GeneratedShare } from './fetch-seafile.ts'; - -type ProjectSeed = { - name: string; - description: string; - gitea_repo: string; - cover_image: string; - demo_video?: string; - download_link?: string; - tags: string[]; - featured: boolean; -}; - -type ShareSeed = { - name: string; - description?: string; - url?: string; - time?: string; -}; +import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts'; +import { fetchSeafileSnapshot } from './fetch-seafile.ts'; +import { + generatedProjectListSchema, + generatedShareListSchema, + giteaActivitySchema, + parseWithSchema, + projectSeedListSchema, + shareSeedListSchema, +} from '../src/data/schema.ts'; type SyncConfig = { rootDir: string; @@ -60,11 +50,15 @@ async function main() { ); } - const seedProjects = await readJson( - path.join(config.rootDir, 'src/content/projects/index.json'), + const seedProjects = parseWithSchema( + projectSeedListSchema, + await readJson(path.join(config.rootDir, 'src/content/projects/index.json')), + 'seed projects', ); - const seedShares = await readJson( - path.join(config.rootDir, 'src/content/shares/index.json'), + const seedShares = parseWithSchema( + shareSeedListSchema, + await readJson(path.join(config.rootDir, 'src/content/shares/index.json')), + 'seed shares', ); const giteaSnapshot = hasGiteaConfig(config) @@ -111,15 +105,31 @@ async function main() { recent: [], } satisfies GeneratedGiteaActivity); + const validatedProjects = parseWithSchema( + generatedProjectListSchema, + seafileSnapshot.projects, + 'generated projects', + ); + const validatedShares = parseWithSchema( + generatedShareListSchema, + seafileSnapshot.shares, + 'generated shares', + ); + const validatedActivity = parseWithSchema( + giteaActivitySchema, + giteaActivity, + 'generated gitea activity', + ); + await Promise.all([ - writeJson(path.join(config.outputDir, 'projects.json'), seafileSnapshot.projects), - writeJson(path.join(config.outputDir, 'shares.json'), seafileSnapshot.shares), - writeJson(path.join(config.outputDir, 'gitea-activity.json'), giteaActivity), + writeJson(path.join(config.outputDir, 'projects.json'), validatedProjects), + writeJson(path.join(config.outputDir, 'shares.json'), validatedShares), + writeJson(path.join(config.outputDir, 'gitea-activity.json'), validatedActivity), ]); - console.log(`[sync-content] wrote ${seafileSnapshot.projects.length} projects`); - console.log(`[sync-content] wrote ${seafileSnapshot.shares.length} shares`); - console.log(`[sync-content] wrote ${giteaActivity.itemCount} activity items`); + console.log(`[sync-content] wrote ${validatedProjects.length} projects`); + console.log(`[sync-content] wrote ${validatedShares.length} shares`); + console.log(`[sync-content] wrote ${validatedActivity.itemCount} activity items`); console.log('[sync-content] done'); } diff --git a/src/components/GiteaActivity.astro b/src/components/GiteaActivity.astro index 17c5130..acf5a44 100644 --- a/src/components/GiteaActivity.astro +++ b/src/components/GiteaActivity.astro @@ -1,6 +1,7 @@ --- import type { GiteaActivityData } from '../data/loaders'; import { site } from '../config'; +import SyncMeta from './SyncMeta.astro'; interface Props { activity: GiteaActivityData; @@ -26,9 +27,6 @@ const heatmap = Array.from({ length: heatmapSize }, (_, index) => { }); const recentActivities = activity.recent.slice(0, 3); -const updatedLabel = activity.updatedAt - ? new Date(activity.updatedAt).toLocaleString('zh-CN', { hour12: false }) - : '尚未同步'; const isPlaceholder = activity.source === 'placeholder'; --- @@ -50,7 +48,13 @@ const isPlaceholder = activity.source === 'placeholder';
-

最近同步:{updatedLabel}

+
{ heatmap.map((level) => ( diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro index 2b38593..e7d54ec 100644 --- a/src/components/ProjectCard.astro +++ b/src/components/ProjectCard.astro @@ -1,6 +1,7 @@ --- import { site } from '../config'; import type { ProjectDownload } from '../data/loaders'; +import { formatDateTime } from '../utils/datetime'; interface Props { name: string; @@ -13,6 +14,8 @@ interface Props { demoVideo?: string; downloadLink?: string; downloads?: ProjectDownload[]; + updatedAt?: string; + source?: 'seed' | 'gitea' | 'gitea+seafile'; } const { @@ -26,11 +29,19 @@ const { demoVideo = '', downloadLink = '', downloads = [], + updatedAt = '', + source, } = Astro.props; const fallbackRepoUrl = repo ? `${site.gitea.url}/${repo}` : ''; const resolvedRepoUrl = repoUrl || fallbackRepoUrl; const visibleDownloads = downloads.filter((item) => item.name || item.url); +const sourceLabels = { + seed: '本地种子', + gitea: 'Gitea', + 'gitea+seafile': 'Gitea + Seafile', +} as const; +const updatedLabel = formatDateTime(updatedAt); ---
@@ -41,6 +52,8 @@ const visibleDownloads = downloads.filter((item) => item.name || item.url);
{repo} {featured ? featured : null} + {source ? {sourceLabels[source]} : null} + {updatedAt ? 更新于 {updatedLabel} : null}

{name}

diff --git a/src/components/SyncMeta.astro b/src/components/SyncMeta.astro new file mode 100644 index 0000000..e82737e --- /dev/null +++ b/src/components/SyncMeta.astro @@ -0,0 +1,64 @@ +--- +import { formatDateTime } from '../utils/datetime'; + +interface Props { + label: string; + updatedAt?: string; + source?: string | string[]; + itemCount?: number; + variant?: 'default' | 'dark'; +} + +const { + label, + updatedAt, + source, + itemCount, + variant = 'default', +} = Astro.props; + +const sourceLabels: Record = { + seed: '本地种子', + gitea: 'Gitea', + seafile: 'Seafile', + 'gitea+seafile': 'Gitea + Seafile', + placeholder: '占位数据', +}; + +const normalizedSources = Array.isArray(source) + ? source + : source + ? [source] + : []; + +const sourceText = [...new Set(normalizedSources)] + .map((item) => sourceLabels[item] ?? item) + .join(' / '); + +const updatedLabel = formatDateTime(updatedAt); +const normalizedUpdatedLabel = updatedLabel || '未同步'; +--- + +
+ {label} +
+ + 更新时间 + {normalizedUpdatedLabel} + + + {sourceText ? ( + + 来源 + {sourceText} + + ) : null} + + {typeof itemCount === 'number' ? ( + + 条目数 + {itemCount} + + ) : null} +
+
diff --git a/src/data/loaders.ts b/src/data/loaders.ts index 65dc0bd..b783fce 100644 --- a/src/data/loaders.ts +++ b/src/data/loaders.ts @@ -1,73 +1,20 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import process from 'node:process'; +import { z } from 'zod'; +import { + type GiteaActivityData, + type ProjectData, + type ProjectDownload, + type ShareData, + generatedProjectListSchema, + generatedShareListSchema, + giteaActivitySchema, + parseWithSchema, + projectSeedListSchema, + shareSeedListSchema, +} from './schema'; -export type ProjectDownload = { - name: string; - description?: string; - url?: string; - type?: 'build' | 'demo' | 'document' | 'asset'; - platform?: string; - size?: number; - updated_at?: string; - source?: 'seed' | 'seafile'; -}; - -type ProjectSeed = { - name: string; - description: string; - gitea_repo: string; - cover_image: string; - demo_video?: string; - download_link?: string; - downloads?: ProjectDownload[]; - tags: string[]; - featured: boolean; -}; - -export type ProjectData = ProjectSeed & { - repo_url?: string; - updated_at?: string; - source?: 'seed' | 'gitea' | 'gitea+seafile'; -}; - -type ShareSeed = { - name: string; - description?: string; - url?: string; - time?: string; -}; - -export type ShareData = ShareSeed & { - type?: 'build' | 'demo' | 'document' | 'asset'; - project?: string; - project_repo?: string; - platform?: string; - size?: number; - source?: 'seed' | 'seafile'; - updated_at?: string; -}; - -export type ActivityDay = { - date: string; - count: number; -}; - -export type RecentActivity = { - type: string; - repo: string; - message: string; - url: string; - time: string; -}; - -export type GiteaActivityData = { - updatedAt: string; - source: 'placeholder' | 'gitea'; - itemCount: number; - days: ActivityDay[]; - recent: RecentActivity[]; -}; +export type { GiteaActivityData, ProjectData, ProjectDownload, ShareData }; const rootDir = process.cwd(); const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json'); @@ -86,34 +33,71 @@ const emptyActivity: GiteaActivityData = { }; export async function loadProjects(): Promise { - return readJsonWithFallback(generatedProjectsPath, seedProjectsPath); + return readJsonWithFallback({ + preferredPath: generatedProjectsPath, + fallbackPath: seedProjectsPath, + preferredLabel: 'generated projects', + fallbackLabel: 'seed projects', + preferredSchema: generatedProjectListSchema, + fallbackSchema: projectSeedListSchema, + }); } export async function loadShares(): Promise { - return readJsonWithFallback(generatedSharesPath, seedSharesPath); + return readJsonWithFallback({ + preferredPath: generatedSharesPath, + fallbackPath: seedSharesPath, + preferredLabel: 'generated shares', + fallbackLabel: 'seed shares', + preferredSchema: generatedShareListSchema, + fallbackSchema: shareSeedListSchema, + }); } export async function loadGiteaActivity(): Promise { try { - return await readJson(generatedActivityPath); + return parseWithSchema( + giteaActivitySchema, + await readJson(generatedActivityPath), + 'generated gitea activity', + ); } catch (error) { if (!isMissingFileError(error)) { - throw error; + console.warn( + `[data-loaders] failed to load generated gitea activity, using empty placeholder: ${getErrorMessage(error)}`, + ); } return emptyActivity; } } -async function readJsonWithFallback(preferredPath: string, fallbackPath: string): Promise { +async function readJsonWithFallback(input: { + preferredPath: string; + fallbackPath: string; + preferredLabel: string; + fallbackLabel: string; + preferredSchema: z.ZodType; + fallbackSchema: z.ZodType; +}): Promise { try { - return await readJson(preferredPath); + return parseWithSchema( + input.preferredSchema, + await readJson(input.preferredPath), + input.preferredLabel, + ); } catch (error) { if (!isMissingFileError(error)) { - throw error; + console.warn( + `[data-loaders] failed to load ${input.preferredLabel}, falling back to ${input.fallbackLabel}: ${getErrorMessage(error)}`, + ); } - return readJson(fallbackPath); + return parseWithSchema( + input.fallbackSchema, + await readJson(input.fallbackPath), + input.fallbackLabel, + ); } } @@ -123,10 +107,9 @@ async function readJson(filePath: string): Promise { } function isMissingFileError(error: unknown) { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === 'ENOENT' - ); + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); } diff --git a/src/data/schema.ts b/src/data/schema.ts new file mode 100644 index 0000000..d682ee7 --- /dev/null +++ b/src/data/schema.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; + +const nonEmptyText = z.string().trim().min(1); +const looseText = z.string().trim(); +const parseableDateTime = z + .string() + .trim() + .refine((value) => !Number.isNaN(Date.parse(value)), { + message: 'Expected a parseable date/time string', + }); + +const isoDay = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Expected a YYYY-MM-DD date string', +}); + +const repoFullName = z.string().trim().regex(/^[^/\s]+\/[^/\s]+$/, { + message: 'Expected an owner/repo string', +}); + +export const seafileResourceTypeSchema = z.enum(['build', 'demo', 'document', 'asset']); + +export const projectDownloadSchema = z.object({ + name: nonEmptyText, + description: looseText.optional(), + url: looseText.optional(), + type: seafileResourceTypeSchema.optional(), + platform: looseText.optional(), + size: z.number().int().nonnegative().optional(), + updated_at: parseableDateTime.optional(), + source: z.enum(['seed', 'seafile']).optional(), +}); + +export const projectDataSchema = z.object({ + name: nonEmptyText, + description: nonEmptyText, + gitea_repo: repoFullName, + cover_image: looseText, + demo_video: looseText.optional(), + download_link: looseText.optional(), + downloads: z.array(projectDownloadSchema).optional(), + tags: z.array(nonEmptyText), + featured: z.boolean(), + repo_url: looseText.optional(), + updated_at: parseableDateTime.optional(), + source: z.enum(['seed', 'gitea', 'gitea+seafile']).optional(), +}); + +export const generatedProjectSchema = projectDataSchema.extend({ + updated_at: parseableDateTime, + source: z.enum(['seed', 'gitea', 'gitea+seafile']), +}); + +export const shareDataSchema = z.object({ + name: nonEmptyText, + description: looseText.optional(), + url: looseText.optional(), + type: seafileResourceTypeSchema.optional(), + project: looseText.optional(), + project_repo: looseText.optional(), + platform: looseText.optional(), + size: z.number().int().nonnegative().optional(), + updated_at: parseableDateTime.optional(), + time: parseableDateTime.optional(), + source: z.enum(['seed', 'seafile']).optional(), +}); + +export const generatedShareSchema = shareDataSchema.extend({ + source: z.enum(['seed', 'seafile']), +}); + +export const activityDaySchema = z.object({ + date: isoDay, + count: z.number().int().nonnegative(), +}); + +export const recentActivitySchema = z.object({ + type: nonEmptyText, + repo: nonEmptyText, + message: nonEmptyText, + url: nonEmptyText, + time: parseableDateTime, +}); + +export const giteaActivitySchema = z.object({ + updatedAt: parseableDateTime, + source: z.enum(['placeholder', 'gitea']), + itemCount: z.number().int().nonnegative(), + days: z.array(activityDaySchema), + recent: z.array(recentActivitySchema), +}); + +export const projectSeedListSchema = z.array(projectDataSchema); +export const generatedProjectListSchema = z.array(generatedProjectSchema); +export const shareSeedListSchema = z.array(shareDataSchema); +export const generatedShareListSchema = z.array(generatedShareSchema); + +export type ProjectDownload = z.infer; +export type ProjectData = z.infer; +export type GeneratedProject = z.infer; +export type ShareData = z.infer; +export type GeneratedShare = z.infer; +export type ActivityDay = z.infer; +export type RecentActivity = z.infer; +export type GiteaActivityData = z.infer; + +export function parseWithSchema(schema: z.ZodType, input: unknown, label: string): T { + const result = schema.safeParse(input); + if (result.success) { + return result.data; + } + + throw new Error(formatSchemaError(label, result.error)); +} + +export function formatSchemaError(label: string, error: z.ZodError) { + const issues = error.issues + .slice(0, 8) + .map((issue) => `- ${label}${formatIssuePath(issue.path)}: ${issue.message}`); + + const suffix = error.issues.length > issues.length ? '\n- ...' : ''; + return ['[data-schema] validation failed', ...issues].join('\n') + suffix; +} + +function formatIssuePath(path: (string | number)[]) { + if (path.length === 0) { + return ''; + } + + const formattedPath = path.reduce((output, segment) => { + if (typeof segment === 'number') { + return `${output}[${segment}]`; + } + + return output ? `${output}.${segment}` : segment; + }, ''); + + return `.${formattedPath}`; +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 9991dcd..61f8087 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -5,9 +5,11 @@ import LogCard from '../components/LogCard.astro'; import ProjectCard from '../components/ProjectCard.astro'; import RandomCornerImage from '../components/RandomCornerImage.astro'; import SectionTitle from '../components/SectionTitle.astro'; +import SyncMeta from '../components/SyncMeta.astro'; import { site } from '../config'; import { loadGiteaActivity, loadProjects } from '../data/loaders'; import Layout from '../layouts/Layout.astro'; +import { getLatestDate } from '../utils/datetime'; const [logs, projects, activity] = await Promise.all([ getCollection('logs'), @@ -20,6 +22,10 @@ const latestLogs = sortedLogs.slice(0, 3); const featuredProjects = projects.filter((project) => project.featured).slice(0, 3); const latestLog = latestLogs[0]; const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?? `${site.gitea.username}/personal-homepage`; +const featuredProjectSources = [...new Set(featuredProjects.map((project) => project.source).filter(Boolean))]; +const featuredProjectsUpdatedAt = getLatestDate( + featuredProjects.map((project) => project.updated_at).filter(Boolean), +); --- @@ -101,6 +107,13 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ? linkLabel="打开项目列表" /> + +
{ featuredProjects.map((project) => ( @@ -115,6 +128,8 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ? demoVideo={project.demo_video} downloadLink={project.download_link} downloads={project.downloads} + updatedAt={project.updated_at} + source={project.source} /> )) } diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro index b405806..af7dbca 100644 --- a/src/pages/projects/index.astro +++ b/src/pages/projects/index.astro @@ -1,10 +1,14 @@ --- import ProjectCard from '../../components/ProjectCard.astro'; +import SyncMeta from '../../components/SyncMeta.astro'; import { site } from '../../config'; import { loadProjects } from '../../data/loaders'; import Layout from '../../layouts/Layout.astro'; +import { getLatestDate } from '../../utils/datetime'; const projects = await loadProjects(); +const projectSources = [...new Set(projects.map((project) => project.source).filter(Boolean))]; +const projectsUpdatedAt = getLatestDate(projects.map((project) => project.updated_at).filter(Boolean)); --- 项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。

+ +
{ projects.map((project) => ( @@ -34,6 +45,8 @@ const projects = await loadProjects(); demoVideo={project.demo_video} downloadLink={project.download_link} downloads={project.downloads} + updatedAt={project.updated_at} + source={project.source} /> )) } diff --git a/src/pages/shares.astro b/src/pages/shares.astro index c095320..66d8a46 100644 --- a/src/pages/shares.astro +++ b/src/pages/shares.astro @@ -2,8 +2,18 @@ import { site } from '../config'; import { loadShares } from '../data/loaders'; import Layout from '../layouts/Layout.astro'; +import SyncMeta from '../components/SyncMeta.astro'; +import { formatDateTime, getLatestDate } from '../utils/datetime'; const shares = await loadShares(); +const shareSources = [...new Set(shares.map((share) => share.source).filter(Boolean))]; +const sharesUpdatedAt = getLatestDate( + shares.map((share) => share.updated_at || share.time).filter(Boolean), +); +const sourceLabels = { + seed: '本地种子', + seafile: 'Seafile', +} as const; --- 这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。

+ +
{shares.map((share) => (

- {share.updated_at || share.time || '未标注时间'} + {formatDateTime(share.updated_at || share.time) || '未标注时间'} {share.project ? {share.project} : null} {share.type ? {share.type} : null} + {share.source ? {sourceLabels[share.source]} : null}

{share.name}

{share.description ?

{share.description}

: null} diff --git a/src/styles/global.css b/src/styles/global.css index f2b775e..6dc6e52 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -342,6 +342,66 @@ main { font-weight: 600; } +.sync-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.8rem 1rem; + margin: 0 0 1rem; + padding: 0.95rem 1rem; + border: 1px solid var(--border); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.9); +} + +.sync-meta--dark { + border-color: rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.3); +} + +.sync-meta__label { + font-family: 'Archivo', sans-serif; + font-size: 0.96rem; + color: var(--muted-strong); +} + +.sync-meta--dark .sync-meta__label { + color: #fff; +} + +.sync-meta__items { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.sync-meta__item { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: var(--surface-muted); + color: var(--muted-strong); + font-size: 0.82rem; +} + +.sync-meta__item strong { + font-family: 'Fira Code', monospace; + font-size: 0.76rem; + color: var(--muted); +} + +.sync-meta--dark .sync-meta__item { + background: rgba(30, 41, 59, 0.88); + color: #e2e8f0; +} + +.sync-meta--dark .sync-meta__item strong { + color: rgba(191, 219, 254, 0.9); +} + .card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts new file mode 100644 index 0000000..e9a4011 --- /dev/null +++ b/src/utils/datetime.ts @@ -0,0 +1,28 @@ +export function formatDateTime(value?: string) { + if (!value) { + return ''; + } + + const time = Date.parse(value); + if (Number.isNaN(time)) { + return value; + } + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(new Date(time)); +} + +export function getLatestDate(values: string[]) { + const timestamps = values.map((value) => Date.parse(value)).filter((value) => !Number.isNaN(value)); + if (timestamps.length === 0) { + return ''; + } + + return new Date(Math.max(...timestamps)).toISOString(); +}