From 249423d73dd99061957f187b06bbf5d43b3d05fc 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: Thu, 12 Feb 2026 02:30:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E6=97=B6=E5=AE=8C=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(payload)/admin/importMap.js | 2 + src/app/api/clear-data/route.ts | 151 +++++++++ src/app/api/fix-database/route.ts | 46 +++ src/app/api/sync-medusa/route.ts | 14 +- src/app/api/upload-media/route.ts | 77 +++++ src/collections/Announcements.ts | 224 ++++++++++++ src/collections/Articles.ts | 334 ++++++++++++++++++ src/collections/Products.ts | 268 ++++++++------- src/collections/Users.ts | 100 +++++- src/components/fields/ThumbnailField.tsx | 236 +++++++++++-- src/components/nav/AdminPanelNavLink.tsx | 45 +++ src/components/sync/ClearDataButton.tsx | 107 ++++++ src/components/sync/SyncMedusaButton.tsx | 4 +- src/components/views/AdminPanel.tsx | 268 +++++++++++++++ src/globals/AdminSettings.ts | 41 +++ src/lib/medusa.ts | 38 +++ src/migrations/index.ts | 12 + src/payload-types.ts | 415 ++++++++++++++++++++++- src/payload.config.ts | 6 +- tests/helpers/seedUser.ts | 1 + 20 files changed, 2212 insertions(+), 177 deletions(-) create mode 100644 src/app/api/clear-data/route.ts create mode 100644 src/app/api/fix-database/route.ts create mode 100644 src/app/api/upload-media/route.ts create mode 100644 src/collections/Announcements.ts create mode 100644 src/collections/Articles.ts create mode 100644 src/components/nav/AdminPanelNavLink.tsx create mode 100644 src/components/sync/ClearDataButton.tsx create mode 100644 src/components/views/AdminPanel.tsx create mode 100644 src/globals/AdminSettings.ts diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 6248b04..54344ae 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -28,6 +28,7 @@ import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932 import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 } from '../../../components/sync/SyncMedusaButton' import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler' import { ForceSyncButton as ForceSyncButton_28396efe36d6238add95cf44109e281c } from '../../../components/sync/ForceSyncButton' +import { default as default_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel' import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' @@ -62,6 +63,7 @@ export const importMap = { "/components/sync/SyncMedusaButton#SyncMedusaButton": SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08, "/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2, "/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c, + "/components/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/app/api/clear-data/route.ts b/src/app/api/clear-data/route.ts new file mode 100644 index 0000000..07e95e0 --- /dev/null +++ b/src/app/api/clear-data/route.ts @@ -0,0 +1,151 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextResponse } from 'next/server' + +/** + * 清理数据库数据(保留 Users 和 Media) + * GET /api/clear-data?confirm=true + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const confirm = searchParams.get('confirm') + + // 安全检查:必须明确确认 + if (confirm !== 'true') { + return NextResponse.json( + { + success: false, + error: '需要确认参数:?confirm=true', + message: + '此操作将删除 Products, Announcements, Articles 的所有数据(保留 Users 和 Media)', + }, + { status: 400 }, + ) + } + + const payload = await getPayload({ config }) + + const results = { + products: 0, + announcements: 0, + articles: 0, + errors: [] as string[], + } + + // 清理 Products + try { + const deletedProducts = await payload.delete({ + collection: 'products', + where: {}, + }) + results.products = deletedProducts.docs?.length || 0 + console.log(`✅ 已清理 ${results.products} 个商品`) + } catch (error) { + const errorMsg = `清理 Products 失败: ${error instanceof Error ? error.message : '未知错误'}` + console.error('❌', errorMsg) + results.errors.push(errorMsg) + } + + // 清理 Announcements + try { + const deletedAnnouncements = await payload.delete({ + collection: 'announcements', + where: {}, + }) + results.announcements = deletedAnnouncements.docs?.length || 0 + console.log(`✅ 已清理 ${results.announcements} 个公告`) + } catch (error) { + const errorMsg = `清理 Announcements 失败: ${error instanceof Error ? error.message : '未知错误'}` + console.error('❌', errorMsg) + results.errors.push(errorMsg) + } + + // 清理 Articles + try { + const deletedArticles = await payload.delete({ + collection: 'articles', + where: {}, + }) + results.articles = deletedArticles.docs?.length || 0 + console.log(`✅ 已清理 ${results.articles} 个文章`) + } catch (error) { + const errorMsg = `清理 Articles 失败: ${error instanceof Error ? error.message : '未知错误'}` + console.error('❌', errorMsg) + results.errors.push(errorMsg) + } + + return NextResponse.json({ + success: true, + message: `数据清理完成!已删除 ${results.products} 个商品、${results.announcements} 个公告、${results.articles} 个文章`, + results, + }) + } catch (error) { + console.error('Clear data error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} + +/** + * POST /api/clear-data + * 带认证的清理接口 + */ +export async function POST(request: Request) { + try { + const payload = await getPayload({ config }) + + // 可选:添加认证检查 + // const { user } = await payload.auth({ headers: request.headers }) + // if (!user?.roles?.includes('admin')) { + // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // } + + const body = await request.json() + const { collections = ['products', 'announcements', 'articles'] } = body + + const results: Record = {} + const errors: string[] = [] + + for (const collection of collections) { + if (['users', 'media'].includes(collection)) { + errors.push(`跳过受保护的集合: ${collection}`) + continue + } + + try { + const deleted = await payload.delete({ + collection, + where: {}, + }) + results[collection] = deleted.docs?.length || 0 + console.log(`✅ 已清理 ${results[collection]} 个 ${collection}`) + } catch (error) { + const errorMsg = `清理 ${collection} 失败: ${error instanceof Error ? error.message : '未知错误'}` + console.error('❌', errorMsg) + errors.push(errorMsg) + } + } + + return NextResponse.json({ + success: true, + message: '数据清理完成', + results, + errors: errors.length > 0 ? errors : undefined, + }) + } catch (error) { + console.error('Clear data error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/fix-database/route.ts b/src/app/api/fix-database/route.ts new file mode 100644 index 0000000..c478c3b --- /dev/null +++ b/src/app/api/fix-database/route.ts @@ -0,0 +1,46 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextResponse } from 'next/server' + +/** + * 修复数据库字段类型 + * GET /api/fix-database + */ +export async function GET(request: Request) { + try { + const payload = await getPayload({ config }) + const db = payload.db + + console.log('🔧 开始修复数据库字段类型...') + + // 修复 products 表的 thumbnail 字段 + await db.execute({ + raw: ` + ALTER TABLE "products" + ALTER COLUMN "thumbnail" TYPE varchar + USING CASE + WHEN "thumbnail" IS NULL THEN NULL + ELSE "thumbnail"::varchar + END; + `, + }) + + console.log('✅ Products.thumbnail 字段已修复为 varchar 类型') + + return NextResponse.json({ + success: true, + message: '数据库字段类型修复成功!', + fixes: ['products.thumbnail: integer → varchar'], + }) + } catch (error) { + console.error('❌ 修复数据库出错:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + message: '数据库修复失败,请查看控制台日志', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/sync-medusa/route.ts b/src/app/api/sync-medusa/route.ts index 3d15f51..bd58d3d 100644 --- a/src/app/api/sync-medusa/route.ts +++ b/src/app/api/sync-medusa/route.ts @@ -119,7 +119,12 @@ async function syncSingleProductByMedusaId(payload: any, medusaId: string, force } } catch (error) { console.error(`Error syncing product ${medusaId}:`, error) - throw error + return { + success: false, + action: 'error', + message: `同步商品 ${medusaId} 失败`, + error: error instanceof Error ? error.message : 'Unknown error', + } } } @@ -155,7 +160,12 @@ async function syncSingleProductByPayloadId(payload: any, payloadId: string, for return await syncSingleProductByMedusaId(payload, product.medusaId, forceUpdate) } catch (error) { console.error(`Error syncing product by Payload ID ${payloadId}:`, error) - throw error + return { + success: false, + action: 'error', + message: `同步商品 ID ${payloadId} 失败`, + error: error instanceof Error ? error.message : 'Unknown error', + } } } diff --git a/src/app/api/upload-media/route.ts b/src/app/api/upload-media/route.ts new file mode 100644 index 0000000..200e67f --- /dev/null +++ b/src/app/api/upload-media/route.ts @@ -0,0 +1,77 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextRequest } from 'next/server' + +/** + * 自定义媒体上传 API + * POST /api/upload-media + */ +export async function POST(req: NextRequest) { + try { + const payload = await getPayload({ config }) + + // 检查用户认证 + const { user } = await payload.auth({ headers: req.headers }) + if (!user) { + return Response.json({ error: '未授权,请先登录' }, { status: 401 }) + } + + // 获取 FormData + const formData = await req.formData() + const file = formData.get('file') as File + const alt = formData.get('alt') as string + + if (!file) { + return Response.json({ error: '请选择要上传的文件' }, { status: 400 }) + } + + // 验证文件类型 + if (!file.type.startsWith('image/')) { + return Response.json({ error: '只能上传图片文件' }, { status: 400 }) + } + + // 验证文件大小 (最大 10MB) + const maxSize = 10 * 1024 * 1024 + if (file.size > maxSize) { + return Response.json({ error: '文件大小不能超过 10MB' }, { status: 400 }) + } + + // 将 File 转换为 Buffer + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // 上传到 Media collection + const media = await payload.create({ + collection: 'media', + data: { + alt: alt || file.name, + }, + file: { + data: buffer, + name: file.name, + mimetype: file.type, + size: file.size, + }, + user, + }) + + return Response.json({ + success: true, + doc: { + id: media.id, + url: media.url, + filename: media.filename, + alt: media.alt, + }, + }) + } catch (error) { + console.error('Media upload error:', error) + return Response.json( + { + error: '上传失败', + message: error instanceof Error ? error.message : '未知错误', + }, + { status: 500 }, + ) + } +} diff --git a/src/collections/Announcements.ts b/src/collections/Announcements.ts new file mode 100644 index 0000000..776dbdd --- /dev/null +++ b/src/collections/Announcements.ts @@ -0,0 +1,224 @@ +import type { CollectionConfig } from 'payload' +import { + BlocksFeature, + BoldFeature, + HeadingFeature, + InlineCodeFeature, + ItalicFeature, + lexicalEditor, + LinkFeature, + OrderedListFeature, + ParagraphFeature, + UnorderedListFeature, + FixedToolbarFeature, + InlineToolbarFeature, + HorizontalRuleFeature, + BlockquoteFeature, + AlignFeature, +} from '@payloadcms/richtext-lexical' + +export const Announcements: CollectionConfig = { + slug: 'announcements', + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'type', 'status', 'publishedAt', 'updatedAt'], + description: '管理系统公告和通知', + pagination: { + defaultLimit: 25, + }, + }, + access: { + read: ({ req: { user } }) => { + // 公开访问已发布的公告 + if (!user) { + return { + status: { equals: 'published' }, + } + } + // 认证用户可以查看所有 + return true + }, + create: ({ req: { user } }) => { + // 所有已认证用户都可以创建 + return Boolean(user) + }, + update: ({ req: { user } }) => { + // 所有已认证用户都可以更新 + return Boolean(user) + }, + delete: ({ req: { user } }) => { + // 只有 admin 可以删除 + if (!user) return false + return user.roles?.includes('admin') || false + }, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'title', + type: 'text', + required: true, + admin: { + description: '公告标题', + width: '70%', + }, + }, + { + name: 'type', + type: 'select', + required: true, + defaultValue: 'info', + options: [ + { + label: '信息', + value: 'info', + }, + { + label: '警告', + value: 'warning', + }, + { + label: '重要', + value: 'important', + }, + { + label: '紧急', + value: 'urgent', + }, + ], + admin: { + description: '公告类型', + width: '30%', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'draft', + options: [ + { + label: '草稿', + value: 'draft', + }, + { + label: '已发布', + value: 'published', + }, + { + label: '已归档', + value: 'archived', + }, + ], + admin: { + description: '发布状态', + width: '50%', + }, + }, + { + name: 'priority', + type: 'number', + defaultValue: 0, + admin: { + description: '优先级(数字越大越靠前)', + width: '50%', + }, + }, + ], + }, + { + name: 'summary', + type: 'textarea', + admin: { + description: '公告摘要(显示在列表页)', + }, + }, + { + name: 'content', + type: 'richText', + required: true, + admin: { + description: '公告详细内容', + }, + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + BoldFeature(), + ItalicFeature(), + LinkFeature({}), + OrderedListFeature(), + UnorderedListFeature(), + BlockquoteFeature(), + AlignFeature(), + InlineCodeFeature(), + FixedToolbarFeature(), + InlineToolbarFeature(), + HorizontalRuleFeature(), + ], + }), + }, + { + type: 'row', + fields: [ + { + name: 'publishedAt', + type: 'date', + admin: { + description: '发布时间', + width: '50%', + date: { + displayFormat: 'yyyy-MM-dd HH:mm', + }, + }, + }, + { + name: 'expiresAt', + type: 'date', + admin: { + description: '过期时间(可选)', + width: '50%', + date: { + displayFormat: 'yyyy-MM-dd HH:mm', + }, + }, + }, + ], + }, + { + name: 'showOnHomepage', + type: 'checkbox', + defaultValue: false, + admin: { + description: '在首页显示此公告', + }, + }, + { + name: 'author', + type: 'relationship', + relationTo: 'users', + admin: { + description: '发布者', + }, + }, + ], + timestamps: true, + hooks: { + beforeChange: [ + ({ data, operation }) => { + // 自动设置发布时间 + if (operation === 'create' && data.status === 'published' && !data.publishedAt) { + data.publishedAt = new Date() + } + return data + }, + ], + }, +} diff --git a/src/collections/Articles.ts b/src/collections/Articles.ts new file mode 100644 index 0000000..3323b22 --- /dev/null +++ b/src/collections/Articles.ts @@ -0,0 +1,334 @@ +import type { CollectionConfig } from 'payload' +import { + BlocksFeature, + BoldFeature, + HeadingFeature, + InlineCodeFeature, + ItalicFeature, + lexicalEditor, + LinkFeature, + OrderedListFeature, + ParagraphFeature, + UnorderedListFeature, + FixedToolbarFeature, + InlineToolbarFeature, + HorizontalRuleFeature, + BlockquoteFeature, + AlignFeature, + ChecklistFeature, + IndentFeature, + UploadFeature, +} from '@payloadcms/richtext-lexical' + +export const Articles: CollectionConfig = { + slug: 'articles', + admin: { + useAsTitle: 'title', + defaultColumns: ['featuredImage', 'title', 'category', 'status', 'publishedAt', 'updatedAt'], + description: '管理文章内容', + listSearchableFields: ['title', 'slug', 'excerpt'], + pagination: { + defaultLimit: 25, + }, + }, + access: { + read: ({ req: { user } }) => { + // 公开访问已发布的文章 + if (!user) { + return { + status: { equals: 'published' }, + } + } + // 认证用户可以查看所有 + return true + }, + create: ({ req: { user } }) => { + // 所有已认证用户都可以创建 + return Boolean(user) + }, + update: ({ req: { user } }) => { + // 所有已认证用户都可以更新 + return Boolean(user) + }, + delete: ({ req: { user } }) => { + // 只有 admin 可以删除 + if (!user) return false + return user.roles?.includes('admin') || false + }, + }, + versions: { + drafts: { + autosave: true, + schedulePublish: true, + }, + maxPerDoc: 50, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'title', + type: 'text', + required: true, + admin: { + description: '文章标题', + width: '70%', + }, + }, + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'draft', + options: [ + { + label: '草稿', + value: 'draft', + }, + { + label: '已发布', + value: 'published', + }, + ], + admin: { + description: '发布状态', + width: '30%', + }, + }, + ], + }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + index: true, + admin: { + description: '文章 URL 路径(用于 SEO 友好的 URL)', + }, + }, + { + name: 'featuredImage', + type: 'upload', + relationTo: 'media', + admin: { + description: '文章特色图片(封面)', + }, + }, + { + name: 'excerpt', + type: 'textarea', + admin: { + description: '文章摘要(显示在列表页和 SEO)', + }, + }, + { + name: 'content', + type: 'richText', + required: true, + admin: { + description: '文章详细内容', + }, + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + BoldFeature(), + ItalicFeature(), + LinkFeature({ + enabledCollections: ['articles', 'products'], + fields: ({ defaultFields }) => [ + ...defaultFields, + { + name: 'rel', + label: 'Rel Attribute', + type: 'select', + hasMany: true, + options: ['noopener', 'noreferrer', 'nofollow'], + admin: { + description: + 'The rel attribute defines the relationship between a linked resource and the current document.', + }, + }, + ], + }), + OrderedListFeature(), + UnorderedListFeature(), + ChecklistFeature(), + BlockquoteFeature(), + AlignFeature(), + IndentFeature(), + InlineCodeFeature(), + UploadFeature({ + collections: { + media: { + fields: [ + { + name: 'caption', + type: 'richText', + label: '图片说明', + editor: lexicalEditor(), + }, + ], + }, + }, + }), + FixedToolbarFeature(), + InlineToolbarFeature(), + HorizontalRuleFeature(), + ], + }), + }, + { + type: 'row', + fields: [ + { + name: 'category', + type: 'select', + required: true, + options: [ + { + label: '新闻', + value: 'news', + }, + { + label: '教程', + value: 'tutorial', + }, + { + label: '技术', + value: 'tech', + }, + { + label: '产品评测', + value: 'review', + }, + { + label: '行业动态', + value: 'industry', + }, + { + label: '其他', + value: 'other', + }, + ], + admin: { + description: '文章分类', + width: '50%', + }, + }, + { + name: 'featured', + type: 'checkbox', + defaultValue: false, + admin: { + description: '标记为推荐文章', + width: '50%', + }, + }, + ], + }, + { + name: 'tags', + type: 'text', + hasMany: true, + admin: { + description: '文章标签(用于搜索和分类)', + }, + }, + { + type: 'row', + fields: [ + { + name: 'author', + type: 'relationship', + relationTo: 'users', + required: true, + admin: { + description: '文章作者', + width: '50%', + }, + }, + { + name: 'publishedAt', + type: 'date', + admin: { + description: '发布时间', + width: '50%', + date: { + displayFormat: 'yyyy-MM-dd HH:mm', + }, + }, + }, + ], + }, + { + name: 'relatedArticles', + type: 'relationship', + relationTo: 'articles', + hasMany: true, + admin: { + description: '相关文章', + }, + }, + { + name: 'relatedProducts', + type: 'relationship', + relationTo: 'products', + hasMany: true, + admin: { + description: '相关商品', + }, + }, + { + type: 'collapsible', + label: 'SEO 设置', + fields: [ + { + name: 'metaTitle', + type: 'text', + admin: { + description: 'SEO 标题(留空则使用文章标题)', + }, + }, + { + name: 'metaDescription', + type: 'textarea', + admin: { + description: 'SEO 描述(留空则使用摘要)', + }, + }, + { + name: 'metaKeywords', + type: 'text', + hasMany: true, + admin: { + description: 'SEO 关键词', + }, + }, + ], + }, + ], + timestamps: true, + hooks: { + beforeChange: [ + ({ data, operation }) => { + // 自动生成 slug(如果未提供) + if (operation === 'create' && !data.slug && data.title) { + data.slug = data.title + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/(^-|-$)/g, '') + } + + // 自动设置发布时间 + if (operation === 'create' && data.status === 'published' && !data.publishedAt) { + data.publishedAt = new Date() + } + + return data + }, + ], + }, +} diff --git a/src/collections/Products.ts b/src/collections/Products.ts index b206034..11d07e1 100644 --- a/src/collections/Products.ts +++ b/src/collections/Products.ts @@ -46,144 +46,158 @@ export const Products: CollectionConfig = { }, fields: [ { - type: 'row', - fields: [ + type: 'tabs', + tabs: [ { - name: 'medusaId', - type: 'text', - required: true, - unique: true, - index: true, - admin: { - description: 'Medusa 商品 ID', - readOnly: true, - width: '80%', - }, - }, - { - name: 'status', - type: 'select', - required: true, - defaultValue: 'draft', - options: [ + label: '基本信息', + fields: [ { - label: '草稿', - value: 'draft', + type: 'row', + fields: [ + { + name: 'medusaId', + type: 'text', + required: true, + unique: true, + index: true, + admin: { + description: 'Medusa 商品 ID', + readOnly: true, + width: '60%', + }, + }, + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'draft', + options: [ + { + label: '草稿', + value: 'draft', + }, + { + label: '已发布', + value: 'published', + }, + ], + admin: { + description: '商品详情状态', + width: '40%', + }, + }, + ], }, { - label: '已发布', - value: 'published', + name: 'title', + type: 'text', + required: true, + admin: { + description: '商品标题(从 Medusa 同步)', + readOnly: true, + }, }, - ], - admin: { - description: '商品详情状态', - width: '20%', - }, - }, - ], - }, - { - type: 'row', - fields: [ - { - name: 'title', - type: 'text', - required: true, - admin: { - description: '商品标题(从 Medusa 同步)', - readOnly: true, - width: '80%', - }, - }, - { - name: 'thumbnail', - type: 'text', - admin: { - description: '商品封面 URL(从 Medusa 同步)', - readOnly: true, - width: '20%', - components: { - Cell: '/components/cells/ThumbnailCell#ThumbnailCell', - Field: '/components/fields/ThumbnailField#ThumbnailField', + { + name: 'handle', + type: 'text', + admin: { + hidden: true, // 隐藏字段,但保留用于搜索 + }, }, - }, - }, - ], - }, - { - name: 'content', - type: 'richText', - admin: { - description: '商品详细内容', - }, - editor: lexicalEditor({ - features: ({ defaultFeatures }) => [ - ...defaultFeatures, - HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), - LinkFeature({ - enabledCollections: ['products'], - fields: ({ defaultFields }) => [ - ...defaultFields, - { - name: 'rel', - label: 'Rel Attribute', - type: 'select', - hasMany: true, - options: ['noopener', 'noreferrer', 'nofollow'], - admin: { - description: - 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.', + { + name: 'thumbnail', + type: 'text', + admin: { + description: '商品封面 URL(支持上传或输入 URL)', + components: { + Cell: '/components/cells/ThumbnailCell#ThumbnailCell', + Field: '/components/fields/ThumbnailField#ThumbnailField', }, }, - ], - }), - UploadFeature({ - collections: { - media: { - fields: [ - { - name: 'caption', - type: 'richText', - label: '图片说明', - editor: lexicalEditor(), - }, - ], + }, + { + name: 'lastSyncedAt', + type: 'date', + admin: { + description: '最后同步时间', + readOnly: true, + date: { + displayFormat: 'yyyy-MM-dd HH:mm:ss', + }, }, }, - }), - FixedToolbarFeature(), - InlineToolbarFeature(), - HorizontalRuleFeature(), - ], - }), - }, - { - name: 'handle', - type: 'text', - admin: { - description: '商品 URL slug(从 Medusa 同步)', - readOnly: true, - }, - }, - { - name: 'relatedProducts', - type: 'relationship', - relationTo: 'products', - hasMany: true, - admin: { - description: '相关商品,支持搜索联想', - components: { - Field: '/components/fields/RelatedProductsField#RelatedProductsField', + ], }, - }, - }, - { - name: 'lastSyncedAt', - type: 'date', - admin: { - description: '最后同步时间', - readOnly: true, - }, + { + label: '商品详情', + fields: [ + { + name: 'content', + type: 'richText', + admin: { + description: '商品详细内容(支持图文混排)', + }, + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + LinkFeature({ + enabledCollections: ['products'], + fields: ({ defaultFields }) => [ + ...defaultFields, + { + name: 'rel', + label: 'Rel Attribute', + type: 'select', + hasMany: true, + options: ['noopener', 'noreferrer', 'nofollow'], + admin: { + description: + 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.', + }, + }, + ], + }), + UploadFeature({ + collections: { + media: { + fields: [ + { + name: 'caption', + type: 'richText', + label: '图片说明', + editor: lexicalEditor(), + }, + ], + }, + }, + }), + FixedToolbarFeature(), + InlineToolbarFeature(), + HorizontalRuleFeature(), + ], + }), + }, + ], + }, + { + label: '关联信息', + fields: [ + { + name: 'relatedProducts', + type: 'relationship', + relationTo: 'products', + hasMany: true, + admin: { + description: '相关商品,支持搜索联想', + components: { + Field: '/components/fields/RelatedProductsField#RelatedProductsField', + }, + }, + }, + ], + }, + ], }, ], timestamps: true, diff --git a/src/collections/Users.ts b/src/collections/Users.ts index c683d0e..7575814 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -8,6 +8,104 @@ export const Users: CollectionConfig = { auth: true, fields: [ // Email added by default - // Add more fields as needed + { + name: 'roles', + type: 'select', + hasMany: true, + options: ['admin', 'editor', 'user'], + defaultValue: ['user'], + required: true, + saveToJWT: true, // Include in JWT for fast access checks + access: { + update: ({ req: { user } }) => { + // Only admins can update roles + if (!user) return false + return user.roles?.includes('admin') || false + }, + }, + }, ], + hooks: { + beforeChange: [ + async ({ data, req, operation }) => { + // 如果是创建操作,检查是否为第一个用户 + if (operation === 'create') { + const { totalDocs } = await req.payload.count({ + collection: 'users', + }) + + // 如果这是第一个用户,自动设置为 admin + if (totalDocs === 0) { + data.roles = ['admin'] + console.log('🎉 第一个用户注册,自动设置为管理员') + } + } + + // 如果是更新操作,检查是否为唯一用户且 roles 为空 + if (operation === 'update') { + const { totalDocs } = await req.payload.count({ + collection: 'users', + }) + + // 如果只有一个用户且 roles 为空或只有 user,自动升级为 admin + if ( + totalDocs === 1 && + (!data.roles || + data.roles.length === 0 || + (data.roles.length === 1 && data.roles[0] === 'user')) + ) { + data.roles = ['admin'] + console.log('🔧 当前是唯一用户,自动升级为管理员') + } + } + + return data + }, + ], + afterRead: [ + async ({ doc, req, context }) => { + // 跳过已标记为处理过的请求 + if (context?.skipAutoAdmin) return doc + + // 检查是否为唯一用户且 roles 为空或不正确 + if ( + !doc.roles || + doc.roles.length === 0 || + (doc.roles.length === 1 && doc.roles[0] === 'user') + ) { + const { totalDocs } = await req.payload.count({ + collection: 'users', + }) + + // 如果只有一个用户,自动更新为 admin + if (totalDocs === 1) { + console.log('🔄 检测到唯一用户权限异常,正在修复...') + + try { + // 使用 overrideAccess 绕过权限检查,标记 context 避免循环 + await req.payload.update({ + collection: 'users', + id: doc.id, + data: { + roles: ['admin'], + }, + context: { + skipAutoAdmin: true, + }, + overrideAccess: true, + }) + + // 更新当前文档的 roles + doc.roles = ['admin'] + console.log('✅ 唯一用户权限已修复为管理员') + } catch (error) { + console.error('❌ 更新用户权限失败:', error) + } + } + } + + return doc + }, + ], + }, } diff --git a/src/components/fields/ThumbnailField.tsx b/src/components/fields/ThumbnailField.tsx index 0ca61d0..5c60a2e 100644 --- a/src/components/fields/ThumbnailField.tsx +++ b/src/components/fields/ThumbnailField.tsx @@ -1,45 +1,225 @@ 'use client' -import { useFormFields } from '@payloadcms/ui' +import React, { useState, useCallback } from 'react' +import { useField, Button } from '@payloadcms/ui' import type { TextFieldClientComponent } from 'payload' -// 只显示缩略图封面 -export const ThumbnailField: TextFieldClientComponent = ({ path }) => { - const fields = useFormFields(([fields]) => fields) - const thumbnail = fields.thumbnail?.value +/** + * 自定义 Thumbnail 字段组件 + * - 显示图片预览 + * - 支持上传到 Media collection + * - 存储为 URL 字符串 + */ +export const ThumbnailField: TextFieldClientComponent = (props) => { + const { path, field } = props + const { value, setValue } = useField({ path }) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState('') - const isImage = typeof thumbnail === 'string' && thumbnail.match(/^https?:\/\/.+/) + const label = typeof field.label === 'string' ? field.label : '商品封面' + const required = field.required || false + + // 处理文件上传 + const handleFileUpload = useCallback( + async (file: File) => { + if (!file) return + + setUploading(true) + setUploadError('') + + try { + // 创建 FormData + const formData = new FormData() + formData.append('file', file) + formData.append('alt', file.name) + + // 上传到自定义 API + const response = await fetch('/api/upload-media', { + method: 'POST', + body: formData, + credentials: 'include', + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMsg = `上传失败 (${response.status})` + try { + const errorData = JSON.parse(errorText) + errorMsg = errorData.message || errorData.error || errorMsg + } catch { + errorMsg = errorText || errorMsg + } + throw new Error(errorMsg) + } + + const data = await response.json() + + // 获取上传后的图片 URL + if (data.doc?.url) { + setValue(data.doc.url) + setUploadError('') // 清除之前的错误 + } else { + throw new Error('服务器返回数据中没有图片 URL') + } + } catch (error) { + console.error('Upload error:', error) + setUploadError(error instanceof Error ? error.message : '上传失败,请重试') + } finally { + setUploading(false) + } + }, + [setValue], + ) + + // 处理 URL 输入 + const handleUrlChange = (e: React.ChangeEvent) => { + setValue(e.target.value) + } + + // 清除图片 + const handleClear = () => { + setValue('') + setUploadError('') + } return ( -
- -
- {isImage ? ( +
+
+ +
+ + {/* 图片预览 */} + {value && ( +
商品封面 { + e.currentTarget.style.display = 'none' }} /> - ) : ( - 无封面图片 +
+ )} + + {/* URL 输入框 */} +
+ +
+ + {/* 文件上传按钮 */} +
+ + + {value && ( + )}
+ + {/* 错误提示 */} + {uploadError && ( +
+ {uploadError} +
+ )} + + {/* 说明文本 */} +
+ 支持上传图片或直接输入图片 URL +
) } diff --git a/src/components/nav/AdminPanelNavLink.tsx b/src/components/nav/AdminPanelNavLink.tsx new file mode 100644 index 0000000..b9e22b1 --- /dev/null +++ b/src/components/nav/AdminPanelNavLink.tsx @@ -0,0 +1,45 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +/** + * 管理员面板导航链接 + */ +export function AdminPanelNavLink() { + const pathname = usePathname() + const isActive = pathname === '/admin/admin-panel' + + return ( + { + if (!isActive) { + e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)' + } + }} + onMouseLeave={(e) => { + if (!isActive) { + e.currentTarget.style.backgroundColor = 'transparent' + } + }} + > +
+ 🛠️ + + 管理员面板 + +
+ + ) +} diff --git a/src/components/sync/ClearDataButton.tsx b/src/components/sync/ClearDataButton.tsx new file mode 100644 index 0000000..3495c9f --- /dev/null +++ b/src/components/sync/ClearDataButton.tsx @@ -0,0 +1,107 @@ +'use client' +import { useState } from 'react' +import { Button } from '@payloadcms/ui' + +/** + * 清理数据库按钮 + * 用于清除 Products, Announcements, Articles 的所有数据 + */ +export function ClearDataButton() { + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const [showConfirm, setShowConfirm] = useState(false) + + const handleClearData = () => { + setShowConfirm(true) + setMessage('') + } + + const handleConfirm = async () => { + setLoading(true) + setMessage('') + setShowConfirm(false) + + try { + const response = await fetch('/api/clear-data?confirm=true', { + method: 'GET', + }) + + const data = await response.json() + + if (data.success) { + setMessage(data.message || '数据清理成功!') + setTimeout(() => window.location.reload(), 2000) + } else { + setMessage(`清理失败: ${data.error}`) + } + } catch (error) { + setMessage(`清理出错: ${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setLoading(false) + } + } + + const handleCancel = () => { + setShowConfirm(false) + setMessage('') + } + + return ( +
+

🗑️ 清理数据库

+

+ 清除所有商品、公告、文章数据(保留用户和媒体文件) +

+ + {showConfirm ? ( +
+
+

+ ⚠️ 确认清理所有数据? +

+

+ 此操作不可撤销!将删除所有商品、公告和文章。 +

+
+ +
+ + +
+
+ ) : ( + + )} + + {message && ( +
+ {message} +
+ )} +
+ ) +} diff --git a/src/components/sync/SyncMedusaButton.tsx b/src/components/sync/SyncMedusaButton.tsx index 51f41ca..8d6bdeb 100644 --- a/src/components/sync/SyncMedusaButton.tsx +++ b/src/components/sync/SyncMedusaButton.tsx @@ -24,7 +24,7 @@ export function SyncMedusaButton() { // 刷新页面显示新商品 setTimeout(() => window.location.reload(), 1500) } else { - setMessage(`同步失败: ${data.error}`) + setMessage(`同步失败: ${data.error || data.message || '未知错误'}`) } } catch (error) { setMessage(`同步出错: ${error instanceof Error ? error.message : '未知错误'}`) @@ -60,7 +60,7 @@ export function SyncMedusaButton() { setMessage(data.message || '强制更新成功!') setTimeout(() => window.location.reload(), 1500) } else { - setMessage(`同步失败: ${data.error}`) + setMessage(`同步失败: ${data.error || data.message || '未知错误'}`) } } catch (error) { setMessage(`同步出错: ${error instanceof Error ? error.message : '未知错误'}`) diff --git a/src/components/views/AdminPanel.tsx b/src/components/views/AdminPanel.tsx new file mode 100644 index 0000000..daa276c --- /dev/null +++ b/src/components/views/AdminPanel.tsx @@ -0,0 +1,268 @@ +'use client' + +import React, { useState } from 'react' +import { Button } from '@payloadcms/ui' + +/** + * 管理员控制面板 + * 用于执行管理操作:清理数据、系统维护等 + */ +export default function AdminPanel() { + const [clearLoading, setClearLoading] = useState(false) + const [clearMessage, setClearMessage] = useState('') + const [showClearConfirm, setShowClearConfirm] = useState(false) + + const [fixLoading, setFixLoading] = useState(false) + const [fixMessage, setFixMessage] = useState('') + + const handleClearData = () => { + setShowClearConfirm(true) + setClearMessage('') + } + + const handleConfirmClear = async () => { + setClearLoading(true) + setClearMessage('') + setShowClearConfirm(false) + + try { + const response = await fetch('/api/clear-data?confirm=true', { + method: 'GET', + }) + + const data = await response.json() + + if (data.success) { + setClearMessage(data.message || '数据清理成功!') + } else { + setClearMessage(`清理失败: ${data.error}`) + } + } catch (error) { + setClearMessage(`清理出错: ${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setClearLoading(false) + } + } + + const handleCancelClear = () => { + setShowClearConfirm(false) + setClearMessage('') + } + + const handleFixDatabase = async () => { + if (!confirm('确定要修复数据库字段类型吗?这会修改 products 表的 thumbnail 字段。')) { + return + } + + setFixLoading(true) + setFixMessage('') + + try { + const response = await fetch('/api/fix-database', { + method: 'GET', + }) + + const data = await response.json() + + if (data.success) { + setFixMessage(data.message || '数据库修复成功!') + } else { + setFixMessage(`修复失败: ${data.error}`) + } + } catch (error) { + setFixMessage(`修复出错: ${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setFixLoading(false) + } + } + + return ( +
+

+ 🛠️ 管理员控制面板 +

+ + {/* 数据管理区域 */} +
+

+ 📦 数据管理 +

+ +
+

+ 🗑️ 清理数据库 +

+

+ 清除所有商品、公告、文章数据(保留用户和媒体文件) +

+ + {showClearConfirm ? ( +
+
+

+ ⚠️ 确认清理所有数据? +

+

+ 此操作不可撤销!将删除所有商品、公告和文章。 +

+
+ +
+ + +
+
+ ) : ( + + )} + + {clearMessage && ( +
+ {clearMessage} +
+ )} +
+ + {/* 修复数据库区域 */} +
+

+ 🔧 修复数据库 +

+

+ 修复数据库字段类型(将 products.thumbnail 从 integer 改为 varchar) +

+ + + + {fixMessage && ( +
+ {fixMessage} +
+ )} +
+
+ + {/* 系统信息区域 */} +
+

+ ℹ️ 系统信息 +

+ +
+
+ Payload CMS: v3.75.0 +
+
+ 数据库: PostgreSQL +
+
+ 存储: Cloudflare R2 (S3 API) +
+
+
+
+ ) +} diff --git a/src/globals/AdminSettings.ts b/src/globals/AdminSettings.ts new file mode 100644 index 0000000..7a35673 --- /dev/null +++ b/src/globals/AdminSettings.ts @@ -0,0 +1,41 @@ +import type { GlobalConfig } from 'payload' + +export const AdminSettings: GlobalConfig = { + slug: 'admin-settings', + access: { + read: ({ req: { user } }) => { + // 只有 admin 可以访问 + if (!user) return false + return user.roles?.includes('admin') || false + }, + update: ({ req: { user } }) => { + // 只有 admin 可以更新 + if (!user) return false + return user.roles?.includes('admin') || false + }, + }, + admin: { + group: '系统', + description: '管理员控制面板 - 数据管理和系统维护', + components: { + views: { + edit: { + default: { + Component: '/components/views/AdminPanel', + }, + }, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + defaultValue: '管理员设置', + admin: { + readOnly: true, + hidden: true, + }, + }, + ], +} diff --git a/src/lib/medusa.ts b/src/lib/medusa.ts index 3867399..187bdc4 100644 --- a/src/lib/medusa.ts +++ b/src/lib/medusa.ts @@ -194,3 +194,41 @@ export function transformMedusaProductToPayload(product: MedusaProduct) { lastSyncedAt: new Date().toISOString(), } } + +/** + * 转换 Medusa 商品数据到 Payload 格式(异步版本) + * 将图片上传到 Media 集合并返回 Media ID + */ +export async function transformMedusaProductToPayloadAsync( + product: MedusaProduct, + payload: any, +): Promise<{ + medusaId: string + title: string + handle: string + thumbnail: string | null + status: string + lastSyncedAt: string +}> { + // 优先使用 thumbnail,如果没有则使用第一张图片的 URL + let thumbnailUrl = product.thumbnail + + if (!thumbnailUrl && product.images && product.images.length > 0) { + thumbnailUrl = product.images[0].url + } + + // 上传图片到 Media 集合 + let thumbnailId: string | null = null + if (thumbnailUrl) { + thumbnailId = await uploadImageFromUrl(thumbnailUrl, payload) + } + + return { + medusaId: product.id, + title: product.title, + handle: product.handle, + thumbnail: thumbnailId, + status: 'draft', + lastSyncedAt: new Date().toISOString(), + } +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 935e859..de493e1 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,4 +1,6 @@ import * as migration_20260208_171142 from './20260208_171142' +import * as migration_20260211_180407 from './20260211_180407' +import * as migration_20260211_180500_fix_thumbnail from './20260211_180500_fix_thumbnail' export const migrations = [ { @@ -6,4 +8,14 @@ export const migrations = [ down: migration_20260208_171142.down, name: '20260208_171142', }, + { + up: migration_20260211_180407.up, + down: migration_20260211_180407.down, + name: '20260211_180407', + }, + { + up: migration_20260211_180500_fix_thumbnail.up, + down: migration_20260211_180500_fix_thumbnail.down, + name: '20260211_180500_fix_thumbnail', + }, ] diff --git a/src/payload-types.ts b/src/payload-types.ts index cffc97e..c490171 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -70,7 +70,10 @@ export interface Config { users: User; media: Media; products: Product; + announcements: Announcement; + articles: Article; 'payload-kv': PayloadKv; + 'payload-jobs': PayloadJob; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -80,7 +83,10 @@ export interface Config { users: UsersSelect | UsersSelect; media: MediaSelect | MediaSelect; products: ProductsSelect | ProductsSelect; + announcements: AnnouncementsSelect | AnnouncementsSelect; + articles: ArticlesSelect | ArticlesSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; + 'payload-jobs': PayloadJobsSelect | PayloadJobsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -89,12 +95,22 @@ export interface Config { defaultIDType: number; }; fallbackLocale: null; - globals: {}; - globalsSelect: {}; + globals: { + 'admin-settings': AdminSetting; + }; + globalsSelect: { + 'admin-settings': AdminSettingsSelect | AdminSettingsSelect; + }; locale: null; user: User; jobs: { - tasks: unknown; + tasks: { + schedulePublish: TaskSchedulePublish; + inline: { + input: unknown; + output: unknown; + }; + }; workflows: unknown; }; } @@ -122,6 +138,7 @@ export interface UserAuthOperations { */ export interface User { id: number; + roles: ('admin' | 'editor' | 'user')[]; updatedAt: string; createdAt: string; email: string; @@ -180,12 +197,17 @@ export interface Product { * 商品标题(从 Medusa 同步) */ title: string; + handle?: string | null; /** - * 商品封面 URL(从 Medusa 同步) + * 商品封面 URL(支持上传或输入 URL) */ thumbnail?: string | null; /** - * 商品详细内容 + * 最后同步时间 + */ + lastSyncedAt?: string | null; + /** + * 商品详细内容(支持图文混排) */ content?: { root: { @@ -202,21 +224,168 @@ export interface Product { }; [k: string]: unknown; } | null; - /** - * 商品 URL slug(从 Medusa 同步) - */ - handle?: string | null; /** * 相关商品,支持搜索联想 */ relatedProducts?: (number | Product)[] | null; - /** - * 最后同步时间 - */ - lastSyncedAt?: string | null; updatedAt: string; createdAt: string; } +/** + * 管理系统公告和通知 + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "announcements". + */ +export interface Announcement { + id: number; + /** + * 公告标题 + */ + title: string; + /** + * 公告类型 + */ + type: 'info' | 'warning' | 'important' | 'urgent'; + /** + * 发布状态 + */ + status: 'draft' | 'published' | 'archived'; + /** + * 优先级(数字越大越靠前) + */ + priority?: number | null; + /** + * 公告摘要(显示在列表页) + */ + summary?: string | null; + /** + * 公告详细内容 + */ + content: { + 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; + }; + /** + * 发布时间 + */ + publishedAt?: string | null; + /** + * 过期时间(可选) + */ + expiresAt?: string | null; + /** + * 在首页显示此公告 + */ + showOnHomepage?: boolean | null; + /** + * 发布者 + */ + author?: (number | null) | User; + updatedAt: string; + createdAt: string; +} +/** + * 管理文章内容 + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "articles". + */ +export interface Article { + id: number; + /** + * 文章标题 + */ + title: string; + /** + * 发布状态 + */ + status: 'draft' | 'published'; + /** + * 文章 URL 路径(用于 SEO 友好的 URL) + */ + slug: string; + /** + * 文章特色图片(封面) + */ + featuredImage?: (number | null) | Media; + /** + * 文章摘要(显示在列表页和 SEO) + */ + excerpt?: string | null; + /** + * 文章详细内容 + */ + content: { + 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; + }; + /** + * 文章分类 + */ + category: 'news' | 'tutorial' | 'tech' | 'review' | 'industry' | 'other'; + /** + * 标记为推荐文章 + */ + featured?: boolean | null; + /** + * 文章标签(用于搜索和分类) + */ + tags?: string[] | null; + /** + * 文章作者 + */ + author: number | User; + /** + * 发布时间 + */ + publishedAt?: string | null; + /** + * 相关文章 + */ + relatedArticles?: (number | Article)[] | null; + /** + * 相关商品 + */ + relatedProducts?: (number | Product)[] | null; + /** + * SEO 标题(留空则使用文章标题) + */ + metaTitle?: string | null; + /** + * SEO 描述(留空则使用摘要) + */ + metaDescription?: string | null; + /** + * SEO 关键词 + */ + metaKeywords?: string[] | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". @@ -234,6 +403,98 @@ export interface PayloadKv { | boolean | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs". + */ +export interface PayloadJob { + id: number; + /** + * Input data provided to the job + */ + input?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + taskStatus?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + completedAt?: string | null; + totalTried?: number | null; + /** + * If hasError is true this job will not be retried + */ + hasError?: boolean | null; + /** + * If hasError is true, this is the error that caused it + */ + error?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * Task execution log + */ + log?: + | { + executedAt: string; + completedAt: string; + taskSlug: 'inline' | 'schedulePublish'; + taskID: string; + input?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + output?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + state: 'failed' | 'succeeded'; + error?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + id?: string | null; + }[] + | null; + taskSlug?: ('inline' | 'schedulePublish') | null; + queue?: string | null; + waitUntil?: string | null; + processing?: boolean | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -252,6 +513,14 @@ export interface PayloadLockedDocument { | ({ relationTo: 'products'; value: number | Product; + } | null) + | ({ + relationTo: 'announcements'; + value: number | Announcement; + } | null) + | ({ + relationTo: 'articles'; + value: number | Article; } | null); globalSlug?: string | null; user: { @@ -300,6 +569,7 @@ export interface PayloadMigration { * via the `definition` "users_select". */ export interface UsersSelect { + roles?: T; updatedAt?: T; createdAt?: T; email?: T; @@ -343,14 +613,57 @@ export interface ProductsSelect { medusaId?: T; status?: T; title?: T; - thumbnail?: T; - content?: T; handle?: T; - relatedProducts?: T; + thumbnail?: T; lastSyncedAt?: T; + content?: T; + relatedProducts?: T; updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "announcements_select". + */ +export interface AnnouncementsSelect { + title?: T; + type?: T; + status?: T; + priority?: T; + summary?: T; + content?: T; + publishedAt?: T; + expiresAt?: T; + showOnHomepage?: T; + author?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "articles_select". + */ +export interface ArticlesSelect { + title?: T; + status?: T; + slug?: T; + featuredImage?: T; + excerpt?: T; + content?: T; + category?: T; + featured?: T; + tags?: T; + author?: T; + publishedAt?: T; + relatedArticles?: T; + relatedProducts?: T; + metaTitle?: T; + metaDescription?: T; + metaKeywords?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". @@ -359,6 +672,37 @@ export interface PayloadKvSelect { key?: T; data?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs_select". + */ +export interface PayloadJobsSelect { + input?: T; + taskStatus?: T; + completedAt?: T; + totalTried?: T; + hasError?: T; + error?: T; + log?: + | T + | { + executedAt?: T; + completedAt?: T; + taskSlug?: T; + taskID?: T; + input?: T; + output?: T; + state?: T; + error?: T; + id?: T; + }; + taskSlug?: T; + queue?: T; + waitUntil?: T; + processing?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". @@ -391,6 +735,45 @@ export interface PayloadMigrationsSelect { updatedAt?: T; createdAt?: T; } +/** + * 管理员控制面板 - 数据管理和系统维护 + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "admin-settings". + */ +export interface AdminSetting { + id: number; + title?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "admin-settings_select". + */ +export interface AdminSettingsSelect { + title?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "TaskSchedulePublish". + */ +export interface TaskSchedulePublish { + input: { + type?: ('publish' | 'unpublish') | null; + locale?: string | null; + doc?: { + relationTo: 'articles'; + value: number | Article; + } | null; + global?: string | null; + user?: (number | null) | User; + }; + output?: unknown; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/src/payload.config.ts b/src/payload.config.ts index a7e4c3b..c5577ff 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -8,6 +8,9 @@ import sharp from 'sharp' import { Users } from './collections/Users' import { Media } from './collections/Media' import { Products } from './collections/Products' +import { Announcements } from './collections/Announcements' +import { Articles } from './collections/Articles' +import { AdminSettings } from './globals/AdminSettings' import { s3Storage } from '@payloadcms/storage-s3' import { en } from '@payloadcms/translations/languages/en' import { zh } from '@payloadcms/translations/languages/zh' @@ -39,7 +42,8 @@ export default buildConfig({ }, fallbackLanguage: 'zh', }, - collections: [Users, Media, Products], + collections: [Users, Media, Products, Announcements, Articles], + globals: [AdminSettings], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET || '', typescript: { diff --git a/tests/helpers/seedUser.ts b/tests/helpers/seedUser.ts index f0f5c86..63fb820 100644 --- a/tests/helpers/seedUser.ts +++ b/tests/helpers/seedUser.ts @@ -4,6 +4,7 @@ import config from '../../src/payload.config.js' export const testUser = { email: 'dev@payloadcms.com', password: 'test', + roles: ['admin'] as ('admin' | 'editor' | 'user')[], } /**