仍然有bug
This commit is contained in:
parent
1f03387619
commit
0fc2899f25
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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: '状态简介(富文本)',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--spacing-3)',
|
||||
padding: 'var(--spacing-3) 0',
|
||||
}}
|
||||
>
|
||||
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={handleSeed}>
|
||||
{loading ? 'Creating…' : '⊕ Seed Standard Precautions (EN)'}
|
||||
</Button>
|
||||
{result && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--font-body-s)',
|
||||
color: 'var(--theme-elevation-500)',
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--spacing-3)',
|
||||
padding: 'var(--spacing-2) 0 var(--spacing-3)',
|
||||
}}
|
||||
>
|
||||
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={handleSeed}>
|
||||
{loading ? 'Adding…' : '⊕ Insert Sample Statuses'}
|
||||
</Button>
|
||||
{result && (
|
||||
<span style={{ fontSize: 'var(--font-body-s)', color: 'var(--theme-elevation-500)' }}>
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
// 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;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
// Recreating the integer-based schema is not worth reverting to since
|
||||
// the whole point is to migrate to the correct varchar-based schema.
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
// Nothing to undo — deleted relationship rows cannot be restored
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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;
|
||||
/**
|
||||
* 排序权重(数值越小越靠前)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue