From 0fc2899f25bcbf64834186e0354564d4ac0875f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BE=9F=E7=94=B7=E6=97=A5=E8=AE=B0=5Cwww?= Date: Fri, 27 Feb 2026 05:49:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=8D=E7=84=B6=E6=9C=89bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(payload)/admin/importMap.js | 4 + src/app/api/products/[id]/content/route.ts | 116 ++++++++++++++ src/collections/base/ProductBase.ts | 30 +++- src/collections/project/Precautions.ts | 115 ++------------ src/components/seed/SeedPrecautionsButton.tsx | 132 +++++++++++++++ .../seed/SeedProjectStatusesButton.tsx | 150 ++++++++++++++++++ src/migrations/cleanup_precautions.ts | 43 +++++ src/migrations/fix_precautions_id_type.ts | 45 ------ src/migrations/fix_stale_precaution_rels.ts | 49 ++++++ src/migrations/index.ts | 25 ++- .../project_statuses_description_to_jsonb.ts | 99 ++++++++++++ src/payload-types.ts | 36 ++++- src/payload.config.ts | 2 +- 13 files changed, 683 insertions(+), 163 deletions(-) create mode 100644 src/app/api/products/[id]/content/route.ts create mode 100644 src/components/seed/SeedPrecautionsButton.tsx create mode 100644 src/components/seed/SeedProjectStatusesButton.tsx create mode 100644 src/migrations/cleanup_precautions.ts delete mode 100644 src/migrations/fix_precautions_id_type.ts create mode 100644 src/migrations/fix_stale_precaution_rels.ts create mode 100644 src/migrations/project_statuses_description_to_jsonb.ts diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index f6ae200..110f0ca 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -23,6 +23,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField' import { ProductOrdersField as ProductOrdersField_d2dd8a14d02b2830d96686a022683d02 } from '../../../components/fields/ProductOrdersField' +import { SeedProjectStatusesButton as SeedProjectStatusesButton_2d6200f8d9c4e1d9630f4ca2e1ecad62 } from '../../../components/seed/SeedProjectStatusesButton' import { TaobaoProductSync as TaobaoProductSync_c920a85a41a3caf5464668c331ea204a } from '../../../components/sync/taobao/TaobaoProductSync' import { TaobaoFetchButton as TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7 } from '../../../components/fields/TaobaoFetchButton' import { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview' @@ -38,6 +39,7 @@ import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b import { DisassemblyEditorCell as DisassemblyEditorCell_9a55a4325c8609b6661772e6b79baf07 } from '../../../components/cells/DisassemblyEditorCell' import { SeedDisassemblyButton as SeedDisassemblyButton_856cd11a2fe6ac8ad5696108a79fdfb9 } from '../../../components/seed/SeedDisassemblyButton' import { default as default_8aadec319652639fb5e982d94aabed6c } from '../../../components/views/Disassembly/DisassemblyPageSaveArea' +import { SeedPrecautionsButton as SeedPrecautionsButton_768e87b00d261fe69a4b4731c1e8e2fb } from '../../../components/seed/SeedPrecautionsButton' import { default as default_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel' import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView' import { RestoreRecommendationsSeedButton as RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da } from '../../../components/seed/RestoreRecommendationsSeedButton' @@ -71,6 +73,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426, "/components/fields/ProductOrdersField#ProductOrdersField": ProductOrdersField_d2dd8a14d02b2830d96686a022683d02, + "/components/seed/SeedProjectStatusesButton#SeedProjectStatusesButton": SeedProjectStatusesButton_2d6200f8d9c4e1d9630f4ca2e1ecad62, "/components/sync/taobao/TaobaoProductSync#TaobaoProductSync": TaobaoProductSync_c920a85a41a3caf5464668c331ea204a, "/components/fields/TaobaoFetchButton#TaobaoFetchButton": TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7, "/components/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959, @@ -86,6 +89,7 @@ export const importMap = { "/components/cells/DisassemblyEditorCell#DisassemblyEditorCell": DisassemblyEditorCell_9a55a4325c8609b6661772e6b79baf07, "/components/seed/SeedDisassemblyButton#SeedDisassemblyButton": SeedDisassemblyButton_856cd11a2fe6ac8ad5696108a79fdfb9, "/components/views/Disassembly/DisassemblyPageSaveArea#default": default_8aadec319652639fb5e982d94aabed6c, + "/components/seed/SeedPrecautionsButton#SeedPrecautionsButton": SeedPrecautionsButton_768e87b00d261fe69a4b4731c1e8e2fb, "/components/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4, "/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f, "/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da, diff --git a/src/app/api/products/[id]/content/route.ts b/src/app/api/products/[id]/content/route.ts new file mode 100644 index 0000000..34e22f4 --- /dev/null +++ b/src/app/api/products/[id]/content/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { addCorsHeaders, handleCorsOptions } from '@/lib/cors' + +export async function OPTIONS(request: Request) { + const origin = request instanceof Request ? request.headers.get('origin') : null + return handleCorsOptions(origin) +} + +/** + * 获取产品 Tab 内容(项目故事、更新日志、注意事项) + * GET /api/products/:id/content[?collection=products|preorder-products] + * + * :id 可以是: + * - Payload 文档 ID + * - Medusa 产品 ID (medusaId) + * - Seed ID (seedId) + * - Handle (handle) + * + * 返回: { id, handle, content, projectStatuses[], precautions[], sharedPrecautions[] } + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const origin = req.headers.get('origin') + + try { + const payload = await getPayload({ config }) + const { id } = await params + const collectionParam = req.nextUrl.searchParams.get('collection') + + // Determine which collections to search + const collections: Array<'products' | 'preorder-products'> = + collectionParam === 'preorder-products' ? ['preorder-products'] + : collectionParam === 'products' ? ['products'] + : ['products', 'preorder-products'] + + let doc: any = null + + for (const collection of collections) { + // 1. Try as Payload document ID + try { + doc = await payload.findByID({ collection, id, depth: 2 }) + if (doc) break + } catch { + // not a valid Payload ID — fall through + } + + // 2. Try medusaId / seedId / handle + const result = await payload.find({ + collection, + where: { + or: [ + { medusaId: { equals: id } }, + { seedId: { equals: id } }, + { handle: { equals: id } }, + ], + }, + limit: 1, + depth: 2, + }) + if (result.docs.length > 0) { + doc = result.docs[0] + break + } + } + + if (!doc) { + const res = NextResponse.json({ error: 'Product not found' }, { status: 404 }) + return addCorsHeaders(res, origin) + } + + // Normalise sharedPrecautions: depth:2 resolves relationship to full Precaution docs + const sharedPrecautions = (doc.sharedPrecautions ?? []) + .map((item: any) => { + if (typeof item === 'object' && item !== null && item.id) { + return { + id: item.id as string, + title: item.title as string, + summary: (item.summary as string | undefined) ?? undefined, + } + } + return null + }) + .filter(Boolean) + + const data = { + id: doc.id, + handle: doc.handle ?? null, + content: doc.content ?? null, + projectStatuses: (doc.projectStatuses ?? []).map((s: any) => ({ + id: s.id ?? String(Math.random()), + title: s.title, + badge: s.badge ?? undefined, + description: s.description ?? undefined, + order: s.order ?? 0, + })), + precautions: (doc.precautions ?? []).map((p: any) => ({ + id: p.id ?? String(Math.random()), + title: p.title, + summary: p.summary ?? undefined, + order: p.order ?? 0, + })), + sharedPrecautions, + } + + const res = NextResponse.json(data) + return addCorsHeaders(res, origin) + } catch (err: any) { + console.error('[products/[id]/content] Error:', err?.message ?? err) + const res = NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return addCorsHeaders(res, origin) + } +} diff --git a/src/collections/base/ProductBase.ts b/src/collections/base/ProductBase.ts index c98ed3e..52b6569 100644 --- a/src/collections/base/ProductBase.ts +++ b/src/collections/base/ProductBase.ts @@ -1,8 +1,13 @@ import type { Field, Tab } from 'payload' import { + BoldFeature, HeadingFeature, + ItalicFeature, lexicalEditor, LinkFeature, + OrderedListFeature, + ParagraphFeature, + UnorderedListFeature, UploadFeature, FixedToolbarFeature, InlineToolbarFeature, @@ -305,6 +310,15 @@ export { OrdersTab } from '../project/OrdersTab' export const ProjectStatusesTab: Tab = { label: '📊 项目状态', fields: [ + { + name: 'seedProjectStatusesUI', + type: 'ui', + admin: { + components: { + Field: '/components/seed/SeedProjectStatusesButton#SeedProjectStatusesButton', + }, + }, + }, { name: 'projectStatuses', type: 'array', @@ -330,10 +344,20 @@ export const ProjectStatusesTab: Tab = { }, { name: 'description', - type: 'text', + type: 'richText', + editor: lexicalEditor({ + features: [ + ParagraphFeature(), + BoldFeature(), + ItalicFeature(), + UnorderedListFeature(), + OrderedListFeature(), + FixedToolbarFeature(), + InlineToolbarFeature(), + ], + }), admin: { - description: '状态简介', - placeholder: '请输入状态简介...', + description: '状态简介(富文本)', }, }, { diff --git a/src/collections/project/Precautions.ts b/src/collections/project/Precautions.ts index 768fa3d..6a2b5cc 100644 --- a/src/collections/project/Precautions.ts +++ b/src/collections/project/Precautions.ts @@ -1,33 +1,26 @@ import type { CollectionConfig } from 'payload' import { logAfterChange, logAfterDelete } from '../../hooks/logAction' -import { - BoldFeature, - HeadingFeature, - ItalicFeature, - lexicalEditor, - LinkFeature, - OrderedListFeature, - ParagraphFeature, - UnorderedListFeature, - FixedToolbarFeature, - InlineToolbarFeature, - HorizontalRuleFeature, -} from '@payloadcms/richtext-lexical' +import { PrecautionItemFields } from '../base/ProductBase' /** - * 注意事项集合 - * 用于描述产品使用、购买、安装等过程中需要注意的事项 - * 可被 Products 和 PreorderProducts 关联引用 + * 通用注意事项集合 + * 与产品内嵌注意事项共用 PrecautionItemFields 结构(title / summary / order) + * Products / PreorderProducts 可通过 sharedPrecautions 关联字段引用 */ export const Precautions: CollectionConfig = { slug: 'precautions', admin: { useAsTitle: 'title', - defaultColumns: ['title', 'type', 'updatedAt'], - description: '管理产品注意事项,如使用须知、安装注意、购买说明等', + defaultColumns: ['title', 'summary', 'updatedAt'], + description: '管理通用注意事项,可被多个产品复用引用', pagination: { defaultLimit: 25, }, + components: { + beforeListTable: [ + '/components/seed/SeedPrecautionsButton#SeedPrecautionsButton', + ], + }, }, access: { read: () => true, @@ -35,91 +28,7 @@ export const Precautions: CollectionConfig = { update: ({ req: { user } }) => !!user, delete: ({ req: { user } }) => !!user, }, - fields: [ - { - type: 'tabs', - tabs: [ - { - label: 'ℹ️ 基本信息', - fields: [ - { - type: 'row', - fields: [ - { - name: 'title', - type: 'text', - required: true, - admin: { - description: '注意事项标题,例如:安装注意事项、使用须知', - width: '60%', - }, - }, - { - name: 'type', - type: 'select', - defaultValue: 'general', - admin: { - description: '注意事项类型', - width: '40%', - }, - options: [ - { label: '通用', value: 'general' }, - { label: '安装', value: 'installation' }, - { label: '使用', value: 'usage' }, - { label: '购买', value: 'purchase' }, - { label: '售后', value: 'aftersale' }, - { label: '安全', value: 'safety' }, - ], - }, - ], - }, - { - name: 'summary', - type: 'text', - admin: { - description: '注意事项摘要(纯文本,用于列表展示)', - placeholder: '请输入注意事项摘要...', - }, - }, - { - name: 'order', - type: 'number', - defaultValue: 0, - admin: { - description: '排序权重(数值越小越靠前)', - }, - }, - ], - }, - { - label: '📄 详细内容', - fields: [ - { - name: 'content', - type: 'richText', - editor: lexicalEditor({ - features: [ - ParagraphFeature(), - HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }), - BoldFeature(), - ItalicFeature(), - UnorderedListFeature(), - OrderedListFeature(), - LinkFeature(), - HorizontalRuleFeature(), - FixedToolbarFeature(), - InlineToolbarFeature(), - ], - }), - admin: { - description: '注意事项详细内容(富文本)', - }, - }, - ], - }, - ], - }, - ], + fields: PrecautionItemFields, hooks: { afterChange: [logAfterChange], afterDelete: [logAfterDelete], diff --git a/src/components/seed/SeedPrecautionsButton.tsx b/src/components/seed/SeedPrecautionsButton.tsx new file mode 100644 index 0000000..3f674b1 --- /dev/null +++ b/src/components/seed/SeedPrecautionsButton.tsx @@ -0,0 +1,132 @@ +'use client' + +import React, { useState } from 'react' +import { Button, useConfig } from '@payloadcms/ui' + +// ─── seed data ──────────────────────────────────────────────────────────────── + +const SEED_PRECAUTIONS = [ + { + title: 'No Returns', + summary: + 'This is a customized product. Due to its personalized nature, we are unable to accept returns once an order has been placed.', + order: 1, + }, + { + title: 'Refund Policy — Stripe Processing Fee', + summary: + 'Refunds are fully supported. Please note that a Stripe payment processing fee of approximately 5% will be deducted from the refund amount, as this fee is non-recoverable once charged.', + order: 2, + }, + { + title: 'Preorder Guarantee', + summary: + 'Even if the preorder campaign does not reach its funding goal, you will still receive the corresponding product. Your order will not go unfulfilled.', + order: 3, + }, + { + title: 'Exclusive Backer Rewards', + summary: + 'Preorder backers will receive exclusive additional rewards as a thank-you for supporting the project early. These bonus items are only available to campaign backers.', + order: 4, + }, +] + +// ─── component ──────────────────────────────────────────────────────────────── + +/** + * Displayed before the Precautions list table. + * Creates the 4 standard English precaution records if they do not already exist. + */ +export function SeedPrecautionsButton() { + const { config } = useConfig() + const apiBase: string = (config as any)?.routes?.api ?? '/api' + + const [loading, setLoading] = useState(false) + const [result, setResult] = useState('') + + const handleSeed = async () => { + if ( + !window.confirm( + `Create ${SEED_PRECAUTIONS.length} standard precaution records?\n\n` + + SEED_PRECAUTIONS.map((p) => `• ${p.title}`).join('\n') + + '\n\nExisting records with identical titles will be skipped.', + ) + ) + return + + setLoading(true) + setResult('') + + let created = 0 + let skipped = 0 + const errors: string[] = [] + + for (const precaution of SEED_PRECAUTIONS) { + try { + // Check for existing record with the same title + const check = await fetch( + `${apiBase}/precautions?where[title][equals]=${encodeURIComponent(precaution.title)}&limit=1`, + { credentials: 'include' }, + ) + const checkData = await check.json() + if (checkData?.totalDocs > 0) { + skipped++ + continue + } + + const res = await fetch(`${apiBase}/precautions`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(precaution), + }) + + if (res.ok) { + created++ + } else { + const err = await res.json().catch(() => ({})) + errors.push(`${precaution.title}: ${err?.errors?.[0]?.message ?? res.status}`) + } + } catch (e: any) { + errors.push(`${precaution.title}: ${e?.message ?? 'Network error'}`) + } + } + + setLoading(false) + + const parts: string[] = [] + if (created) parts.push(`✓ Created ${created}`) + if (skipped) parts.push(`${skipped} already existed`) + if (errors.length) parts.push(`✗ ${errors.length} failed`) + setResult(parts.join(' · ') + (errors.length ? `\n${errors.join('\n')}` : '')) + + if (created > 0) setTimeout(() => window.location.reload(), 800) + } + + return ( +
+ + {result && ( + + {result} + + )} +
+ ) +} diff --git a/src/components/seed/SeedProjectStatusesButton.tsx b/src/components/seed/SeedProjectStatusesButton.tsx new file mode 100644 index 0000000..f81f68f --- /dev/null +++ b/src/components/seed/SeedProjectStatusesButton.tsx @@ -0,0 +1,150 @@ +'use client' + +import React, { useState } from 'react' +import { Button, useConfig, useDocumentInfo } from '@payloadcms/ui' + +// ─── helpers ────────────────────────────────────────────────────────────────── + +/** Wrap plain text in minimal Lexical JSON so richText fields accept it */ +function lexicalParagraph(text: string) { + return { + root: { + type: 'root', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'paragraph', + format: '', + indent: 0, + version: 1, + children: [{ type: 'text', format: 0, text, version: 1 }], + }, + ], + }, + } +} + +// ─── sample data ────────────────────────────────────────────────────────────── + +const SAMPLE_STATUSES = [ + { + title: 'In Development', + badge: 'Dev', + description: lexicalParagraph( + 'Hardware prototyping and firmware development are actively in progress.', + ), + order: 1, + }, + { + title: 'Crowdfunding Live', + badge: 'Live', + description: lexicalParagraph( + 'The crowdfunding campaign is now live and open to backers.', + ), + order: 2, + }, + { + title: 'Mass Production', + badge: 'Producing', + description: lexicalParagraph( + 'Funding goal reached. Manufacturing has begun and is on schedule.', + ), + order: 3, + }, + { + title: 'Shipping', + badge: 'Shipping', + description: lexicalParagraph( + 'Orders are being fulfilled and packages are on their way to backers.', + ), + order: 4, + }, +] + +// ─── component ──────────────────────────────────────────────────────────────── + +/** + * Displayed as a UI field above the projectStatuses array in the product form. + * Appends sample statuses to whatever already exists, then reloads the page. + */ +export function SeedProjectStatusesButton() { + const { id: docId, collectionSlug } = useDocumentInfo() + const { config } = useConfig() + const apiBase: string = (config as any)?.routes?.api ?? '/api' + + const [loading, setLoading] = useState(false) + const [result, setResult] = useState('') + + const handleSeed = async () => { + if (!docId) { + setResult('Save the document first before adding sample data.') + return + } + if ( + !window.confirm( + `Append ${SAMPLE_STATUSES.length} sample project statuses to this product?\n\n` + + SAMPLE_STATUSES.map((s) => `• ${s.title} [${s.badge}]`).join('\n'), + ) + ) + return + + setLoading(true) + setResult('') + + try { + // Fetch current doc to get existing statuses so we don't overwrite them + const getRes = await fetch(`${apiBase}/${collectionSlug}/${docId}?depth=0`, { + credentials: 'include', + }) + const doc = await getRes.json() + const existing: any[] = doc.projectStatuses ?? [] + const baseOrder = existing.length + + const merged = [ + ...existing, + ...SAMPLE_STATUSES.map((s, i) => ({ ...s, order: baseOrder + i + 1 })), + ] + + const patchRes = await fetch(`${apiBase}/${collectionSlug}/${docId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectStatuses: merged }), + }) + + if (patchRes.ok) { + setResult(`✓ Added ${SAMPLE_STATUSES.length} sample statuses`) + setTimeout(() => window.location.reload(), 700) + } else { + const err = await patchRes.json().catch(() => ({})) + setResult(`✗ ${err?.errors?.[0]?.message ?? patchRes.status}`) + } + } catch (e: any) { + setResult(`✗ ${e?.message ?? 'Network error'}`) + } + + setLoading(false) + } + + return ( +
+ + {result && ( + + {result} + + )} +
+ ) +} diff --git a/src/migrations/cleanup_precautions.ts b/src/migrations/cleanup_precautions.ts new file mode 100644 index 0000000..f7f26e1 --- /dev/null +++ b/src/migrations/cleanup_precautions.ts @@ -0,0 +1,43 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Final precautions cleanup: + * 1. Remove orphan relationship entries pointing to non-existent precaution IDs + * 2. Drop the leftover `type` and `content` columns — simplified schema now just + * uses PrecautionItemFields (title / summary / order), same as the inline array + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + -- Remove stale sharedPrecautions refs from products + DELETE FROM "products_rels" + WHERE "precautions_id" IS NOT NULL + AND "precautions_id" NOT IN (SELECT "id" FROM "precautions"); + + -- Remove stale sharedPrecautions refs from preorder-products + DELETE FROM "preorder_products_rels" + WHERE "precautions_id" IS NOT NULL + AND "precautions_id" NOT IN (SELECT "id" FROM "precautions"); + + -- Remove stale locked-document refs + DELETE FROM "payload_locked_documents_rels" + WHERE "precautions_id" IS NOT NULL + AND "precautions_id" NOT IN (SELECT "id" FROM "precautions"); + + -- Drop leftover columns from previous complex schema (no longer needed) + ALTER TABLE "precautions" DROP COLUMN IF EXISTS "type"; + ALTER TABLE "precautions" DROP COLUMN IF EXISTS "content"; + + -- Drop the now-unused enum type + DROP TYPE IF EXISTS "public"."enum_precautions_type"; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Re-add the enum and columns if rolling back + await db.execute(sql` + CREATE TYPE IF NOT EXISTS "public"."enum_precautions_type" + AS ENUM('general','installation','usage','purchase','aftersale','safety'); + ALTER TABLE "precautions" ADD COLUMN IF NOT EXISTS "type" "enum_precautions_type" DEFAULT 'general'; + ALTER TABLE "precautions" ADD COLUMN IF NOT EXISTS "content" jsonb; + `) +} diff --git a/src/migrations/fix_precautions_id_type.ts b/src/migrations/fix_precautions_id_type.ts deleted file mode 100644 index 184e6f0..0000000 --- a/src/migrations/fix_precautions_id_type.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' - -/** - * Fix precautions ID type mismatch. - * - * The `precautions` table was created by a manual migration with `serial` - * (integer) PKs. Payload v3 / @payloadcms/db-postgres defaults to `text` - * (varchar) IDs. On dev-mode startup, Payload's schema-push tries to ALTER - * the `precautions_id` relationship columns to varchar, but the FK constraint - * pointing to an integer PK blocks it. - * - * Fix: drop the precautions table and all `precautions_id` rels columns so - * Payload's dev-mode push can recreate everything with the correct types. - */ -export async function up({ db }: MigrateUpArgs): Promise { - await db.execute(sql` - -- Drop FK constraints first (payload_locked_documents_rels) - ALTER TABLE "payload_locked_documents_rels" - DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_precautions_fk"; - - -- Drop the precautions_id column from payload_locked_documents_rels - ALTER TABLE "payload_locked_documents_rels" - DROP COLUMN IF EXISTS "precautions_id"; - - -- Drop from products_rels (sharedPrecautions relationship) - ALTER TABLE "products_rels" - DROP CONSTRAINT IF EXISTS "products_rels_precautions_fk"; - ALTER TABLE "products_rels" - DROP COLUMN IF EXISTS "precautions_id"; - - -- Drop from preorder_products_rels (sharedPrecautions relationship) - ALTER TABLE "preorder_products_rels" - DROP CONSTRAINT IF EXISTS "preorder_products_rels_precautions_fk"; - ALTER TABLE "preorder_products_rels" - DROP COLUMN IF EXISTS "precautions_id"; - - -- Drop the precautions table itself (Payload will recreate with varchar PK) - DROP TABLE IF EXISTS "precautions" CASCADE; - `) -} - -export async function down({ db }: MigrateDownArgs): Promise { - // Recreating the integer-based schema is not worth reverting to since - // the whole point is to migrate to the correct varchar-based schema. -} diff --git a/src/migrations/fix_stale_precaution_rels.ts b/src/migrations/fix_stale_precaution_rels.ts new file mode 100644 index 0000000..3f32579 --- /dev/null +++ b/src/migrations/fix_stale_precaution_rels.ts @@ -0,0 +1,49 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Nuclear cleanup: remove ALL precautions_id relationship entries from rels tables, + * then verify precautions table is healthy. + * + * Why: after multiple DROP+CREATE migrations, any previously-saved sharedPrecautions + * values in products_rels / preorder_products_rels may reference IDs from an + * earlier incarnation of the table. Rather than keep patching, clear the slate so + * users can re-link from the admin UI with the current valid IDs. + */ +export async function up({ db, payload }: MigrateUpArgs): Promise { + // Log current state before cleaning + const before = await db.execute(sql` + SELECT + (SELECT count(*) FROM products_rels WHERE precautions_id IS NOT NULL) AS prod_refs, + (SELECT count(*) FROM preorder_products_rels WHERE precautions_id IS NOT NULL) AS pre_refs, + (SELECT count(*) FROM precautions) AS precaution_count + `) + payload.logger.info({ msg: 'Before cleanup', state: (before as any).rows?.[0] ?? before }) + + // Remove all stale precautions refs (any ID not present in precautions table) + await db.execute(sql` + DELETE FROM "products_rels" + WHERE "precautions_id" IS NOT NULL + AND "precautions_id" NOT IN (SELECT "id" FROM "precautions"); + + DELETE FROM "preorder_products_rels" + WHERE "precautions_id" IS NOT NULL + AND "precautions_id" NOT IN (SELECT "id" FROM "precautions"); + + DELETE FROM "payload_locked_documents_rels" + WHERE "precautions_id" IS NOT NULL + AND "precautions_id" NOT IN (SELECT "id" FROM "precautions"); + `) + + // Log what's left + const after = await db.execute(sql` + SELECT + (SELECT count(*) FROM products_rels WHERE precautions_id IS NOT NULL) AS prod_refs, + (SELECT count(*) FROM preorder_products_rels WHERE precautions_id IS NOT NULL) AS pre_refs, + (SELECT count(*) FROM precautions) AS precaution_count + `) + payload.logger.info({ msg: 'After cleanup', state: (after as any).rows?.[0] ?? after }) +} + +export async function down(_args: MigrateDownArgs): Promise { + // Nothing to undo — deleted relationship rows cannot be restored +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index d63efae..f95e466 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,5 +1,7 @@ -import * as migration_baseline from './baseline' -import * as migration_fix_precautions_id_type from './fix_precautions_id_type' +import * as migration_baseline from './baseline'; +import * as migration_project_statuses_description_to_jsonb from './project_statuses_description_to_jsonb'; +import * as migration_cleanup_precautions from './cleanup_precautions'; +import * as migration_fix_stale_precaution_rels from './fix_stale_precaution_rels'; export const migrations = [ { @@ -8,9 +10,18 @@ export const migrations = [ name: 'baseline', }, { - up: migration_fix_precautions_id_type.up, - down: migration_fix_precautions_id_type.down, - name: 'fix_precautions_id_type', + up: migration_project_statuses_description_to_jsonb.up, + down: migration_project_statuses_description_to_jsonb.down, + name: 'project_statuses_description_to_jsonb', }, -] - + { + up: migration_cleanup_precautions.up, + down: migration_cleanup_precautions.down, + name: 'cleanup_precautions', + }, + { + up: migration_fix_stale_precaution_rels.up, + down: migration_fix_stale_precaution_rels.down, + name: 'fix_stale_precaution_rels', + }, +]; diff --git a/src/migrations/project_statuses_description_to_jsonb.ts b/src/migrations/project_statuses_description_to_jsonb.ts new file mode 100644 index 0000000..f0a9fe6 --- /dev/null +++ b/src/migrations/project_statuses_description_to_jsonb.ts @@ -0,0 +1,99 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Convert projectStatuses.description from text → jsonb. + * + * Payload's schema-push cannot ALTER text → jsonb without an explicit USING + * clause because Postgres has no implicit cast between the two types. + * + * Strategy for existing rows: + * - NULL / empty → NULL + * - Already JSON → cast directly (description::jsonb) + * - Plain text → wrap in a minimal Lexical paragraph node so the + * content is preserved and renderable in the admin editor + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + -- ── products_project_statuses ───────────────────────────────────────────── + ALTER TABLE "products_project_statuses" + ALTER COLUMN "description" SET DATA TYPE jsonb + USING CASE + WHEN description IS NULL OR trim(description) = '' + THEN NULL + WHEN left(trim(description), 1) = '{' + THEN description::jsonb + ELSE + jsonb_build_object( + 'root', jsonb_build_object( + 'type', 'root', + 'version', 1, + 'format', '', + 'indent', 0, + 'children', jsonb_build_array( + jsonb_build_object( + 'type', 'paragraph', + 'version', 1, + 'format', '', + 'indent', 0, + 'children', jsonb_build_array( + jsonb_build_object( + 'type', 'text', + 'text', description, + 'version', 1, + 'format', 0 + ) + ) + ) + ) + ) + ) + END; + + -- ── preorder_products_project_statuses ──────────────────────────────────── + ALTER TABLE "preorder_products_project_statuses" + ALTER COLUMN "description" SET DATA TYPE jsonb + USING CASE + WHEN description IS NULL OR trim(description) = '' + THEN NULL + WHEN left(trim(description), 1) = '{' + THEN description::jsonb + ELSE + jsonb_build_object( + 'root', jsonb_build_object( + 'type', 'root', + 'version', 1, + 'format', '', + 'indent', 0, + 'children', jsonb_build_array( + jsonb_build_object( + 'type', 'paragraph', + 'version', 1, + 'format', '', + 'indent', 0, + 'children', jsonb_build_array( + jsonb_build_object( + 'type', 'text', + 'text', description, + 'version', 1, + 'format', 0 + ) + ) + ) + ) + ) + ) + END; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "products_project_statuses" + ALTER COLUMN "description" SET DATA TYPE text + USING (description->>'root')::text; + + ALTER TABLE "preorder_products_project_statuses" + ALTER COLUMN "description" SET DATA TYPE text + USING (description->>'root')::text; + `) +} diff --git a/src/payload-types.ts b/src/payload-types.ts index 9cc9525..b1bc2c4 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -338,9 +338,23 @@ export interface Product { */ badge?: string | null; /** - * 状态简介 + * 状态简介(富文本) */ - description?: string | null; + description?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; /** * 排序权重(数值越小越靠前) */ @@ -560,9 +574,23 @@ export interface PreorderProduct { */ badge?: string | null; /** - * 状态简介 + * 状态简介(富文本) */ - description?: string | null; + description?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; /** * 排序权重(数值越小越靠前) */ diff --git a/src/payload.config.ts b/src/payload.config.ts index a943f10..73abef2 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -16,7 +16,7 @@ import { DisassemblyPages } from './collections/disassembly/DisassemblyPages' import { DisassemblyAreas } from './collections/disassembly/DisassemblyAreas' import { DisassemblyComponents } from './collections/disassembly/DisassemblyComponents' import { DisassemblyLinkedProducts } from './collections/disassembly/DisassemblyLinkedProducts' -import { Precautions } from './collections/Precautions' +import { Precautions } from './collections/project/Precautions' import { AdminSettings } from './globals/AdminSettings' import { LogsManager } from './globals/LogsManager' import { HeroSlider } from './globals/HeroSlider'