Add schema validation and sync metadata UI

This commit is contained in:
SepComet 2026-05-06 09:47:00 +08:00
parent e03af0c800
commit 85e510fbc0
14 changed files with 474 additions and 126 deletions

10
TODO.md
View File

@ -66,17 +66,17 @@
### 1. 给 JSON 数据加校验 ### 1. 给 JSON 数据加校验
- [ ] 为 projects 数据建立 schema 校验 - [x] 为 projects 数据建立 schema 校验
- [ ] 为 shares 数据建立 schema 校验 - [x] 为 shares 数据建立 schema 校验
- [ ] 为 gitea activity 数据建立 schema 校验 - [x] 为 gitea activity 数据建立 schema 校验
- [ ] 在构建前先校验数据格式,避免远程脏数据直接打爆页面 - [x] 在构建前先校验数据格式,避免远程脏数据直接打爆页面
### 2. 增加同步元数据展示 ### 2. 增加同步元数据展示
- [x] 生成 `updatedAt` - [x] 生成 `updatedAt`
- [x] 生成 `source` - [x] 生成 `source`
- [x] 生成 `itemCount`activity - [x] 生成 `itemCount`activity
- [ ] 前端更明确地显示“数据更新时间” - [x] 前端更明确地显示“数据更新时间”
### 3. 增加变更检测 ### 3. 增加变更检测

9
package-lock.json generated
View File

@ -8,7 +8,8 @@
"name": "personal-homepage", "name": "personal-homepage",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"astro": "^6.2.1" "astro": "^6.2.1",
"zod": "^4.4.3"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@ -4720,9 +4721,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.4.2", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@ -15,6 +15,7 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"astro": "^6.2.1" "astro": "^6.2.1",
"zod": "^4.4.3"
} }
} }

View File

@ -1,26 +1,16 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { fetchGiteaSnapshot, type GeneratedGiteaActivity, type GeneratedProject } from './fetch-gitea.ts'; import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts';
import { fetchSeafileSnapshot, type GeneratedShare } from './fetch-seafile.ts'; import { fetchSeafileSnapshot } from './fetch-seafile.ts';
import {
type ProjectSeed = { generatedProjectListSchema,
name: string; generatedShareListSchema,
description: string; giteaActivitySchema,
gitea_repo: string; parseWithSchema,
cover_image: string; projectSeedListSchema,
demo_video?: string; shareSeedListSchema,
download_link?: string; } from '../src/data/schema.ts';
tags: string[];
featured: boolean;
};
type ShareSeed = {
name: string;
description?: string;
url?: string;
time?: string;
};
type SyncConfig = { type SyncConfig = {
rootDir: string; rootDir: string;
@ -60,11 +50,15 @@ async function main() {
); );
} }
const seedProjects = await readJson<ProjectSeed[]>( const seedProjects = parseWithSchema(
path.join(config.rootDir, 'src/content/projects/index.json'), projectSeedListSchema,
await readJson<unknown>(path.join(config.rootDir, 'src/content/projects/index.json')),
'seed projects',
); );
const seedShares = await readJson<ShareSeed[]>( const seedShares = parseWithSchema(
path.join(config.rootDir, 'src/content/shares/index.json'), shareSeedListSchema,
await readJson<unknown>(path.join(config.rootDir, 'src/content/shares/index.json')),
'seed shares',
); );
const giteaSnapshot = hasGiteaConfig(config) const giteaSnapshot = hasGiteaConfig(config)
@ -111,15 +105,31 @@ async function main() {
recent: [], recent: [],
} satisfies GeneratedGiteaActivity); } 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([ await Promise.all([
writeJson(path.join(config.outputDir, 'projects.json'), seafileSnapshot.projects), writeJson(path.join(config.outputDir, 'projects.json'), validatedProjects),
writeJson(path.join(config.outputDir, 'shares.json'), seafileSnapshot.shares), writeJson(path.join(config.outputDir, 'shares.json'), validatedShares),
writeJson(path.join(config.outputDir, 'gitea-activity.json'), giteaActivity), writeJson(path.join(config.outputDir, 'gitea-activity.json'), validatedActivity),
]); ]);
console.log(`[sync-content] wrote ${seafileSnapshot.projects.length} projects`); console.log(`[sync-content] wrote ${validatedProjects.length} projects`);
console.log(`[sync-content] wrote ${seafileSnapshot.shares.length} shares`); console.log(`[sync-content] wrote ${validatedShares.length} shares`);
console.log(`[sync-content] wrote ${giteaActivity.itemCount} activity items`); console.log(`[sync-content] wrote ${validatedActivity.itemCount} activity items`);
console.log('[sync-content] done'); console.log('[sync-content] done');
} }

View File

@ -1,6 +1,7 @@
--- ---
import type { GiteaActivityData } from '../data/loaders'; import type { GiteaActivityData } from '../data/loaders';
import { site } from '../config'; import { site } from '../config';
import SyncMeta from './SyncMeta.astro';
interface Props { interface Props {
activity: GiteaActivityData; activity: GiteaActivityData;
@ -26,9 +27,6 @@ const heatmap = Array.from({ length: heatmapSize }, (_, index) => {
}); });
const recentActivities = activity.recent.slice(0, 3); 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'; const isPlaceholder = activity.source === 'placeholder';
--- ---
@ -50,7 +48,13 @@ const isPlaceholder = activity.source === 'placeholder';
<div class="activity-grid"> <div class="activity-grid">
<div> <div>
<p class="activity-copy">最近同步:{updatedLabel}</p> <SyncMeta
label="同步状态"
updatedAt={activity.updatedAt}
source={activity.source}
itemCount={activity.itemCount}
variant="dark"
/>
<div class="heatmap" aria-label="Gitea 贡献热力图"> <div class="heatmap" aria-label="Gitea 贡献热力图">
{ {
heatmap.map((level) => ( heatmap.map((level) => (

View File

@ -1,6 +1,7 @@
--- ---
import { site } from '../config'; import { site } from '../config';
import type { ProjectDownload } from '../data/loaders'; import type { ProjectDownload } from '../data/loaders';
import { formatDateTime } from '../utils/datetime';
interface Props { interface Props {
name: string; name: string;
@ -13,6 +14,8 @@ interface Props {
demoVideo?: string; demoVideo?: string;
downloadLink?: string; downloadLink?: string;
downloads?: ProjectDownload[]; downloads?: ProjectDownload[];
updatedAt?: string;
source?: 'seed' | 'gitea' | 'gitea+seafile';
} }
const { const {
@ -26,11 +29,19 @@ const {
demoVideo = '', demoVideo = '',
downloadLink = '', downloadLink = '',
downloads = [], downloads = [],
updatedAt = '',
source,
} = Astro.props; } = Astro.props;
const fallbackRepoUrl = repo ? `${site.gitea.url}/${repo}` : ''; const fallbackRepoUrl = repo ? `${site.gitea.url}/${repo}` : '';
const resolvedRepoUrl = repoUrl || fallbackRepoUrl; const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
const visibleDownloads = downloads.filter((item) => item.name || item.url); const visibleDownloads = downloads.filter((item) => item.name || item.url);
const sourceLabels = {
seed: '本地种子',
gitea: 'Gitea',
'gitea+seafile': 'Gitea + Seafile',
} as const;
const updatedLabel = formatDateTime(updatedAt);
--- ---
<article class="card"> <article class="card">
@ -41,6 +52,8 @@ const visibleDownloads = downloads.filter((item) => item.name || item.url);
<div class="project-meta"> <div class="project-meta">
<span class="mono">{repo}</span> <span class="mono">{repo}</span>
{featured ? <span class="tag">featured</span> : null} {featured ? <span class="tag">featured</span> : null}
{source ? <span class="tag">{sourceLabels[source]}</span> : null}
{updatedAt ? <span class="mono">更新于 {updatedLabel}</span> : null}
</div> </div>
<h3 class="project-title">{name}</h3> <h3 class="project-title">{name}</h3>

View File

@ -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<string, string> = {
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 || '未同步';
---
<div class:list={['sync-meta', variant === 'dark' && 'sync-meta--dark']}>
<span class="sync-meta__label">{label}</span>
<div class="sync-meta__items">
<span class="sync-meta__item">
<strong>更新时间</strong>
<span>{normalizedUpdatedLabel}</span>
</span>
{sourceText ? (
<span class="sync-meta__item">
<strong>来源</strong>
<span>{sourceText}</span>
</span>
) : null}
{typeof itemCount === 'number' ? (
<span class="sync-meta__item">
<strong>条目数</strong>
<span>{itemCount}</span>
</span>
) : null}
</div>
</div>

View File

@ -1,73 +1,20 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import path from 'node:path'; 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 = { export type { GiteaActivityData, ProjectData, ProjectDownload, ShareData };
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[];
};
const rootDir = process.cwd(); const rootDir = process.cwd();
const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json'); const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json');
@ -86,34 +33,71 @@ const emptyActivity: GiteaActivityData = {
}; };
export async function loadProjects(): Promise<ProjectData[]> { export async function loadProjects(): Promise<ProjectData[]> {
return readJsonWithFallback<ProjectData[]>(generatedProjectsPath, seedProjectsPath); return readJsonWithFallback<ProjectData[]>({
preferredPath: generatedProjectsPath,
fallbackPath: seedProjectsPath,
preferredLabel: 'generated projects',
fallbackLabel: 'seed projects',
preferredSchema: generatedProjectListSchema,
fallbackSchema: projectSeedListSchema,
});
} }
export async function loadShares(): Promise<ShareData[]> { export async function loadShares(): Promise<ShareData[]> {
return readJsonWithFallback<ShareData[]>(generatedSharesPath, seedSharesPath); return readJsonWithFallback<ShareData[]>({
preferredPath: generatedSharesPath,
fallbackPath: seedSharesPath,
preferredLabel: 'generated shares',
fallbackLabel: 'seed shares',
preferredSchema: generatedShareListSchema,
fallbackSchema: shareSeedListSchema,
});
} }
export async function loadGiteaActivity(): Promise<GiteaActivityData> { export async function loadGiteaActivity(): Promise<GiteaActivityData> {
try { try {
return await readJson<GiteaActivityData>(generatedActivityPath); return parseWithSchema(
giteaActivitySchema,
await readJson<unknown>(generatedActivityPath),
'generated gitea activity',
);
} catch (error) { } catch (error) {
if (!isMissingFileError(error)) { if (!isMissingFileError(error)) {
throw error; console.warn(
`[data-loaders] failed to load generated gitea activity, using empty placeholder: ${getErrorMessage(error)}`,
);
} }
return emptyActivity; return emptyActivity;
} }
} }
async function readJsonWithFallback<T>(preferredPath: string, fallbackPath: string): Promise<T> { async function readJsonWithFallback<T>(input: {
preferredPath: string;
fallbackPath: string;
preferredLabel: string;
fallbackLabel: string;
preferredSchema: z.ZodType<T>;
fallbackSchema: z.ZodType<T>;
}): Promise<T> {
try { try {
return await readJson<T>(preferredPath); return parseWithSchema(
input.preferredSchema,
await readJson<unknown>(input.preferredPath),
input.preferredLabel,
);
} catch (error) { } catch (error) {
if (!isMissingFileError(error)) { if (!isMissingFileError(error)) {
throw error; console.warn(
`[data-loaders] failed to load ${input.preferredLabel}, falling back to ${input.fallbackLabel}: ${getErrorMessage(error)}`,
);
} }
return readJson<T>(fallbackPath); return parseWithSchema(
input.fallbackSchema,
await readJson<unknown>(input.fallbackPath),
input.fallbackLabel,
);
} }
} }
@ -123,10 +107,9 @@ async function readJson<T>(filePath: string): Promise<T> {
} }
function isMissingFileError(error: unknown) { function isMissingFileError(error: unknown) {
return ( return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
typeof error === 'object' && }
error !== null &&
'code' in error && function getErrorMessage(error: unknown) {
error.code === 'ENOENT' return error instanceof Error ? error.message : String(error);
);
} }

138
src/data/schema.ts Normal file
View File

@ -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<typeof projectDownloadSchema>;
export type ProjectData = z.infer<typeof projectDataSchema>;
export type GeneratedProject = z.infer<typeof generatedProjectSchema>;
export type ShareData = z.infer<typeof shareDataSchema>;
export type GeneratedShare = z.infer<typeof generatedShareSchema>;
export type ActivityDay = z.infer<typeof activityDaySchema>;
export type RecentActivity = z.infer<typeof recentActivitySchema>;
export type GiteaActivityData = z.infer<typeof giteaActivitySchema>;
export function parseWithSchema<T>(schema: z.ZodType<T>, 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}`;
}

View File

@ -5,9 +5,11 @@ import LogCard from '../components/LogCard.astro';
import ProjectCard from '../components/ProjectCard.astro'; import ProjectCard from '../components/ProjectCard.astro';
import RandomCornerImage from '../components/RandomCornerImage.astro'; import RandomCornerImage from '../components/RandomCornerImage.astro';
import SectionTitle from '../components/SectionTitle.astro'; import SectionTitle from '../components/SectionTitle.astro';
import SyncMeta from '../components/SyncMeta.astro';
import { site } from '../config'; import { site } from '../config';
import { loadGiteaActivity, loadProjects } from '../data/loaders'; import { loadGiteaActivity, loadProjects } from '../data/loaders';
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { getLatestDate } from '../utils/datetime';
const [logs, projects, activity] = await Promise.all([ const [logs, projects, activity] = await Promise.all([
getCollection('logs'), getCollection('logs'),
@ -20,6 +22,10 @@ const latestLogs = sortedLogs.slice(0, 3);
const featuredProjects = projects.filter((project) => project.featured).slice(0, 3); const featuredProjects = projects.filter((project) => project.featured).slice(0, 3);
const latestLog = latestLogs[0]; const latestLog = latestLogs[0];
const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?? `${site.gitea.username}/personal-homepage`; 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),
);
--- ---
<Layout title={`${site.name} | ${site.title}`} description={site.description} currentPath="/"> <Layout title={`${site.name} | ${site.title}`} description={site.description} currentPath="/">
@ -101,6 +107,13 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?
linkLabel="打开项目列表" linkLabel="打开项目列表"
/> />
<SyncMeta
label="项目数据"
updatedAt={featuredProjectsUpdatedAt}
source={featuredProjectSources}
itemCount={featuredProjects.length}
/>
<div class="card-grid"> <div class="card-grid">
{ {
featuredProjects.map((project) => ( featuredProjects.map((project) => (
@ -115,6 +128,8 @@ const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?
demoVideo={project.demo_video} demoVideo={project.demo_video}
downloadLink={project.download_link} downloadLink={project.download_link}
downloads={project.downloads} downloads={project.downloads}
updatedAt={project.updated_at}
source={project.source}
/> />
)) ))
} }

View File

@ -1,10 +1,14 @@
--- ---
import ProjectCard from '../../components/ProjectCard.astro'; import ProjectCard from '../../components/ProjectCard.astro';
import SyncMeta from '../../components/SyncMeta.astro';
import { site } from '../../config'; import { site } from '../../config';
import { loadProjects } from '../../data/loaders'; import { loadProjects } from '../../data/loaders';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { getLatestDate } from '../../utils/datetime';
const projects = await loadProjects(); 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));
--- ---
<Layout <Layout
@ -20,6 +24,13 @@ const projects = await loadProjects();
<p>项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。</p> <p>项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。</p>
</div> </div>
<SyncMeta
label="项目数据状态"
updatedAt={projectsUpdatedAt}
source={projectSources}
itemCount={projects.length}
/>
<div class="card-grid"> <div class="card-grid">
{ {
projects.map((project) => ( projects.map((project) => (
@ -34,6 +45,8 @@ const projects = await loadProjects();
demoVideo={project.demo_video} demoVideo={project.demo_video}
downloadLink={project.download_link} downloadLink={project.download_link}
downloads={project.downloads} downloads={project.downloads}
updatedAt={project.updated_at}
source={project.source}
/> />
)) ))
} }

View File

@ -2,8 +2,18 @@
import { site } from '../config'; import { site } from '../config';
import { loadShares } from '../data/loaders'; import { loadShares } from '../data/loaders';
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import SyncMeta from '../components/SyncMeta.astro';
import { formatDateTime, getLatestDate } from '../utils/datetime';
const shares = await loadShares(); 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;
--- ---
<Layout <Layout
@ -19,13 +29,21 @@ const shares = await loadShares();
<p>这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。</p> <p>这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。</p>
</div> </div>
<SyncMeta
label="分享数据状态"
updatedAt={sharesUpdatedAt}
source={shareSources}
itemCount={shares.length}
/>
<div class="card-grid" style="grid-template-columns: 1fr;"> <div class="card-grid" style="grid-template-columns: 1fr;">
{shares.map((share) => ( {shares.map((share) => (
<article class="card"> <article class="card">
<p class="log-meta"> <p class="log-meta">
<span class="mono">{share.updated_at || share.time || '未标注时间'}</span> <span class="mono">{formatDateTime(share.updated_at || share.time) || '未标注时间'}</span>
{share.project ? <span class="tag">{share.project}</span> : null} {share.project ? <span class="tag">{share.project}</span> : null}
{share.type ? <span class="tag tag--tech">{share.type}</span> : null} {share.type ? <span class="tag tag--tech">{share.type}</span> : null}
{share.source ? <span class="tag">{sourceLabels[share.source]}</span> : null}
</p> </p>
<h2 class="project-title">{share.name}</h2> <h2 class="project-title">{share.name}</h2>
{share.description ? <p>{share.description}</p> : null} {share.description ? <p>{share.description}</p> : null}

View File

@ -342,6 +342,66 @@ main {
font-weight: 600; 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 { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));

28
src/utils/datetime.ts Normal file
View File

@ -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();
}