暂时完结
This commit is contained in:
parent
bdee359c4c
commit
249423d73d
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {}
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -44,6 +44,12 @@ export const Products: CollectionConfig = {
|
|||
access: {
|
||||
read: () => true, // 公开可读
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: '基本信息',
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
|
|
@ -57,7 +63,7 @@ export const Products: CollectionConfig = {
|
|||
admin: {
|
||||
description: 'Medusa 商品 ID',
|
||||
readOnly: true,
|
||||
width: '80%',
|
||||
width: '60%',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -77,14 +83,11 @@ export const Products: CollectionConfig = {
|
|||
],
|
||||
admin: {
|
||||
description: '商品详情状态',
|
||||
width: '20%',
|
||||
width: '40%',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
|
|
@ -92,29 +95,47 @@ export const Products: CollectionConfig = {
|
|||
admin: {
|
||||
description: '商品标题(从 Medusa 同步)',
|
||||
readOnly: true,
|
||||
width: '80%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'handle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
hidden: true, // 隐藏字段,但保留用于搜索
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'thumbnail',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '商品封面 URL(从 Medusa 同步)',
|
||||
readOnly: true,
|
||||
width: '20%',
|
||||
description: '商品封面 URL(支持上传或输入 URL)',
|
||||
components: {
|
||||
Cell: '/components/cells/ThumbnailCell#ThumbnailCell',
|
||||
Field: '/components/fields/ThumbnailField#ThumbnailField',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastSyncedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
description: '最后同步时间',
|
||||
readOnly: true,
|
||||
date: {
|
||||
displayFormat: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '商品详情',
|
||||
fields: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
admin: {
|
||||
description: '商品详细内容',
|
||||
description: '商品详细内容(支持图文混排)',
|
||||
},
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
|
|
@ -157,14 +178,11 @@ export const Products: CollectionConfig = {
|
|||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'handle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '商品 URL slug(从 Medusa 同步)',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
label: '关联信息',
|
||||
fields: [
|
||||
{
|
||||
name: 'relatedProducts',
|
||||
type: 'relationship',
|
||||
|
|
@ -177,13 +195,9 @@ export const Products: CollectionConfig = {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastSyncedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
description: '最后同步时间',
|
||||
readOnly: true,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,224 @@
|
|||
'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<string>({ 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<HTMLInputElement>) => {
|
||||
setValue(e.target.value)
|
||||
}
|
||||
|
||||
// 清除图片
|
||||
const handleClear = () => {
|
||||
setValue('')
|
||||
setUploadError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>商品封面</label>
|
||||
<div
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
height: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #eee',
|
||||
borderRadius: 8,
|
||||
background: '#fafbfc',
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{label || '商品封面'}
|
||||
{required && <span style={{ color: 'var(--theme-error-500)' }}> *</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
{value && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
src={value}
|
||||
alt="商品封面"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '300px',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: '#bbb' }}>无封面图片</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL 输入框 */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={handleUrlChange}
|
||||
placeholder="输入图片 URL 或上传图片"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
color: 'var(--theme-text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文件上传按钮 */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color: 'var(--theme-elevation-0)',
|
||||
backgroundColor: 'var(--theme-elevation-800)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
opacity: uploading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{uploading ? '上传中...' : '上传图片'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
}}
|
||||
disabled={uploading}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color: 'var(--theme-error-500)',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid var(--theme-error-500)',
|
||||
borderRadius: '4px',
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
opacity: uploading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{uploadError && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: 'var(--theme-error-50)',
|
||||
color: 'var(--theme-error-700)',
|
||||
fontSize: '0.75rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-error-500)',
|
||||
}}
|
||||
>
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 说明文本 */}
|
||||
<div
|
||||
style={{ marginTop: '0.5rem', fontSize: '0.75rem', color: 'var(--theme-elevation-600)' }}
|
||||
>
|
||||
支持上传图片或直接输入图片 URL
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link
|
||||
href="/admin/admin-panel"
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '0.75rem 1rem',
|
||||
textDecoration: 'none',
|
||||
color: isActive ? 'var(--theme-text)' : 'var(--theme-elevation-800)',
|
||||
backgroundColor: isActive ? 'var(--theme-elevation-100)' : 'transparent',
|
||||
borderRadius: '4px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '1.25rem' }}>🛠️</span>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: isActive ? '600' : '400' }}>
|
||||
管理员面板
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div style={{ padding: '1rem', borderBottom: '1px solid var(--theme-elevation-100)' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem' }}>🗑️ 清理数据库</h3>
|
||||
<p style={{ marginBottom: '1rem', fontSize: '0.875rem', color: 'var(--theme-elevation-500)' }}>
|
||||
清除所有商品、公告、文章数据(保留用户和媒体文件)
|
||||
</p>
|
||||
|
||||
{showConfirm ? (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'var(--theme-error-50)',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-error-500)',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: '0', fontWeight: 'bold', color: 'var(--theme-error-700)' }}>
|
||||
⚠️ 确认清理所有数据?
|
||||
</p>
|
||||
<p style={{ margin: '0.5rem 0 0 0', fontSize: '0.875rem' }}>
|
||||
此操作不可撤销!将删除所有商品、公告和文章。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Button onClick={handleConfirm} disabled={loading} buttonStyle="error">
|
||||
{loading ? '清理中...' : '确认清理'}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} disabled={loading} buttonStyle="secondary">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleClearData} disabled={loading} buttonStyle="error">
|
||||
清理数据
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: message.includes('失败') || message.includes('出错')
|
||||
? 'var(--theme-error-50)'
|
||||
: 'var(--theme-success-50)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 : '未知错误'}`)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
🛠️ 管理员控制面板
|
||||
</h1>
|
||||
|
||||
{/* 数据管理区域 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
📦 数据管理
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
borderRadius: '6px',
|
||||
padding: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
|
||||
🗑️ 清理数据库
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--theme-elevation-600)',
|
||||
}}
|
||||
>
|
||||
清除所有商品、公告、文章数据(保留用户和媒体文件)
|
||||
</p>
|
||||
|
||||
{showClearConfirm ? (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: 'var(--theme-error-50)',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--theme-error-500)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 0.5rem 0',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--theme-error-700)',
|
||||
}}
|
||||
>
|
||||
⚠️ 确认清理所有数据?
|
||||
</p>
|
||||
<p style={{ margin: '0', fontSize: '0.875rem', color: 'var(--theme-error-600)' }}>
|
||||
此操作不可撤销!将删除所有商品、公告和文章。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<Button onClick={handleConfirmClear} disabled={clearLoading} buttonStyle="error">
|
||||
{clearLoading ? '清理中...' : '确认清理'}
|
||||
</Button>
|
||||
<Button onClick={handleCancelClear} disabled={clearLoading} buttonStyle="secondary">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleClearData} disabled={clearLoading} buttonStyle="error">
|
||||
清理数据库
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{clearMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor:
|
||||
clearMessage.includes('失败') || clearMessage.includes('出错')
|
||||
? 'var(--theme-error-50)'
|
||||
: 'var(--theme-success-50)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
border: `1px solid ${
|
||||
clearMessage.includes('失败') || clearMessage.includes('出错')
|
||||
? 'var(--theme-error-500)'
|
||||
: 'var(--theme-success-500)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{clearMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 修复数据库区域 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
borderRadius: '6px',
|
||||
padding: '1.5rem',
|
||||
marginTop: '1rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
|
||||
🔧 修复数据库
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--theme-elevation-600)',
|
||||
}}
|
||||
>
|
||||
修复数据库字段类型(将 products.thumbnail 从 integer 改为 varchar)
|
||||
</p>
|
||||
|
||||
<Button onClick={handleFixDatabase} disabled={fixLoading} buttonStyle="primary">
|
||||
{fixLoading ? '修复中...' : '修复字段类型'}
|
||||
</Button>
|
||||
|
||||
{fixMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor:
|
||||
fixMessage.includes('失败') || fixMessage.includes('出错')
|
||||
? 'var(--theme-error-50)'
|
||||
: 'var(--theme-success-50)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
border: `1px solid ${
|
||||
fixMessage.includes('失败') || fixMessage.includes('出错')
|
||||
? 'var(--theme-error-500)'
|
||||
: 'var(--theme-success-500)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{fixMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统信息区域 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
ℹ️ 系统信息
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
borderRadius: '6px',
|
||||
padding: '1rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Payload CMS:</strong> v3.75.0
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>数据库:</strong> PostgreSQL
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>存储:</strong> Cloudflare R2 (S3 API)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
announcements: AnnouncementsSelect<false> | AnnouncementsSelect<true>;
|
||||
articles: ArticlesSelect<false> | ArticlesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
|
|
@ -89,12 +95,22 @@ export interface Config {
|
|||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
globals: {
|
||||
'admin-settings': AdminSetting;
|
||||
};
|
||||
globalsSelect: {
|
||||
'admin-settings': AdminSettingsSelect<false> | AdminSettingsSelect<true>;
|
||||
};
|
||||
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<T extends boolean = true> {
|
||||
roles?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
|
|
@ -343,14 +613,57 @@ export interface ProductsSelect<T extends boolean = true> {
|
|||
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<T extends boolean = true> {
|
||||
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<T extends boolean = true> {
|
||||
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<T extends boolean = true> {
|
|||
key?: T;
|
||||
data?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-jobs_select".
|
||||
*/
|
||||
export interface PayloadJobsSelect<T extends boolean = true> {
|
||||
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<T extends boolean = true> {
|
|||
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<T extends boolean = true> {
|
||||
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".
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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')[],
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue