feat(product-center): implement Figma-designed UI, upload module, and website tests

- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases)
- Add API upload module (multer) with static serving for uploads/public assets
- Add OriginGood.delisted flag and SDS request retry logic
- Add admin ImageUpload component and goods import/upload flows
- Add vitest suite for website components and composables (32 tests)
- Add skills, docs, plans and PRODUCT.md
This commit is contained in:
yeuimu
2026-08-20 14:32:03 +08:00
parent b5fc88f3fa
commit 79fabd85f7
107 changed files with 5364 additions and 2601 deletions
+143 -42
View File
@@ -42,6 +42,32 @@ export class SyncService {
}
}
/** Fire-and-forget wrappers for manual triggers via HTTP. */
async startCategorySync(): Promise<{ message: string }> {
if (this.running.categories) {
return { message: 'Category sync already in progress' };
}
void this.syncCategories().catch((err) =>
this.logger.error('Category sync failed', err as Error),
);
return { message: 'Category sync started' };
}
async startProductSync(): Promise<{ message: string }> {
if (this.running.products) {
return { message: 'Product sync already in progress' };
}
void this.syncProducts().catch((err) =>
this.logger.error('Product sync failed', err as Error),
);
return { message: 'Product sync started' };
}
/** Check if a sync type is currently running. */
isRunning(type: 'categories' | 'products'): boolean {
return this.running[type];
}
async syncCategories(): Promise<CategorySyncResult> {
if (this.running.categories) {
throw new Error('Category sync already in progress');
@@ -54,57 +80,111 @@ export class SyncService {
const tree = await this.sds.fetchCategoryTree();
const flat = this.flattenCategoryTree(tree);
this.logger.log(`Fetched ${flat.length} SDS categories`);
const seenSdsIds = new Set(flat.map((n) => n.sdsId));
let inserted = 0;
let updated = 0;
for (const node of flat) {
const existing = await this.prisma.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
if (!existing) {
await this.prisma.category.create({
data: {
sdsCategoryId: node.sdsId,
categoryName: node.name,
categoryIcon: node.icon ?? null,
},
});
inserted++;
} else {
await this.prisma.category.update({
where: { id: existing.id },
data: {
categoryName: node.name,
categoryIcon: node.icon ?? null,
},
});
updated++;
}
}
// Single transaction: upsert + wire parents + delete stale.
// SDS tree is the source of truth — anything not in the response gets deleted.
const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => {
let ins = 0;
let upd = 0;
// Second pass: wire up parents by sdsCategoryId.
for (const node of flat) {
if (!node.parentSdsId) continue;
const child = await this.prisma.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
const parent = await this.prisma.category.findUnique({
where: { sdsCategoryId: node.parentSdsId },
});
if (child && parent && child.parentCategoryId !== parent.id) {
await this.prisma.category.update({
where: { id: child.id },
data: { parentCategoryId: parent.id },
// 1. Upsert all SDS categories
for (const node of flat) {
const existing = await tx.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
if (!existing) {
await tx.category.create({
data: {
sdsCategoryId: node.sdsId,
categoryName: node.name,
categoryIcon: node.icon ?? null,
},
});
ins++;
} else {
await tx.category.update({
where: { id: existing.id },
data: {
categoryName: node.name,
categoryIcon: node.icon ?? null,
},
});
upd++;
}
}
}
// 2. Wire parent-child relationships
for (const node of flat) {
if (!node.parentSdsId) continue;
const child = await tx.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
const parent = await tx.category.findUnique({
where: { sdsCategoryId: node.parentSdsId },
});
if (child && parent && child.parentCategoryId !== parent.id) {
await tx.category.update({
where: { id: child.id },
data: { parentCategoryId: parent.id },
});
}
}
// 3. Delete stale categories (in DB but not in SDS response)
// Detach parent links first, then delete leaf-first to respect FK constraints.
const staleCats = await tx.category.findMany({
where: { sdsCategoryId: { notIn: [...seenSdsIds] } },
select: { id: true },
});
const staleIds = staleCats.map((c) => c.id);
// Protect categories that have configured goods — onDelete: Restrict
const goodsInStale = await tx.good.groupBy({
by: ['categoryId'],
where: { categoryId: { in: staleIds } },
});
const protectedIds = new Set(goodsInStale.map((g) => g.categoryId));
const deletableIds = staleIds.filter((id) => !protectedIds.has(id));
// Detach all deletable categories from their parents
if (deletableIds.length > 0) {
await tx.category.updateMany({
where: { id: { in: deletableIds } },
data: { parentCategoryId: null },
});
// Also detach any non-deletable children pointing to deletable parents
await tx.category.updateMany({
where: { parentCategoryId: { in: deletableIds } },
data: { parentCategoryId: null },
});
// Delete leaf-first (repeatedly remove nodes with no children)
let remaining = [...deletableIds];
while (remaining.length > 0) {
const withChildren = await tx.category.findMany({
where: { parentCategoryId: { in: remaining } },
select: { parentCategoryId: true },
distinct: ['parentCategoryId'],
});
const hasChildSet = new Set(
withChildren.filter((c) => c.parentCategoryId).map((c) => c.parentCategoryId!.toString()),
);
const leaves = remaining.filter((id) => !hasChildSet.has(id.toString()));
if (leaves.length === 0) break; // safety: circular dependency
await tx.category.deleteMany({ where: { id: { in: leaves } } });
remaining = remaining.filter((id) => !leaves.some((l) => l === id));
}
}
return { inserted: ins, updated: upd, deletedStale: deletableIds.length };
});
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${flat.length}`,
message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`,
},
});
return { inserted, updated, total: flat.length };
@@ -147,6 +227,7 @@ export class SyncService {
let inserted = 0;
let updated = 0;
let total = 0;
const seenSdsGoodIds = new Set<string>();
for (const leaf of leafRows) {
const sdsCategoryId = leaf.sdsCategoryId!;
@@ -157,6 +238,7 @@ export class SyncService {
const products = resp.items ?? resp.content ?? [];
if (products.length === 0) break;
for (const product of products) {
seenSdsGoodIds.add(String(product.id));
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
if (upserted === 'inserted') inserted++;
else updated++;
@@ -172,12 +254,31 @@ export class SyncService {
}
}
// Detect delisted products: mark origin goods not seen in upstream as delisted,
// and re-activate any previously delisted goods that reappeared.
let delistedCount = 0;
let reactivatedCount = 0;
if (seenSdsGoodIds.size > 0) {
const delistedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
data: { delisted: true },
});
const reactivatedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
data: { delisted: false },
});
delistedCount = delistedResult.count;
reactivatedCount = reactivatedResult.count;
} else {
this.logger.warn('No products seen from SDS — skipping delist detection');
}
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${total} leafCategories=${leafRows.length}`,
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
},
});
return { inserted, updated, total, leafCategories: leafRows.length };