gongxiangp2/components/detail-generator/DetailProductForm.tsx

104 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { Upload, X } from 'lucide-react'
import type { ChangeEvent } from 'react'
import type { DetailAsset, DetailAssetKind, DetailConfig, DetailDocument } from '@/lib/detail-generator/types'
import { assetUrl } from './client'
export const detailFieldClass = 'min-h-11 w-full rounded-lg border border-[#cfd8e3] bg-white px-3 py-2 text-sm text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b]'
export const detailButtonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
export const detailPrimaryClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#1677ff] px-4 text-sm font-semibold text-white transition-colors hover:bg-[#0964de] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
export default function DetailProductForm({ document, config, assets, busy, onChange, onUpload, onRemoveAsset, onFindMaterials, onSaveBrand }: {
document: DetailDocument
config: DetailConfig | null
assets: DetailAsset[]
busy: boolean
onChange: (document: DetailDocument) => void
onUpload: (files: File[], kind: DetailAssetKind) => void
onRemoveAsset: (id: string) => void
onFindMaterials: () => void
onSaveBrand: () => void
}) {
const productChange = (key: keyof DetailDocument['product'], value: unknown) => onChange({ ...document, product: { ...document.product, [key]: value } })
const brandChange = (key: keyof DetailDocument['brand'], value: string) => onChange({ ...document, brand: { ...document.brand, [key]: value } })
const fileChange = (event: ChangeEvent<HTMLInputElement>, kind: DetailAssetKind) => {
const files = Array.from(event.target.files || [])
event.target.value = ''
if (files.length) onUpload(files, kind)
}
const collections: { kind: DetailAssetKind; label: string; ids: string[]; accept: string }[] = [
{ kind: 'product', label: '产品图', ids: document.product.assetIds, accept: 'image/png,image/jpeg,image/webp' },
{ kind: 'reference', label: '参考图', ids: document.product.referenceAssetIds, accept: 'image/png,image/jpeg,image/webp' },
{ kind: 'document', label: '产品资料', ids: document.product.documentAssetIds, accept: '.txt,.md,.pdf,.docx' },
{ kind: 'logo', label: '品牌标志', ids: document.brand.logoAssetId ? [document.brand.logoAssetId] : [], accept: 'image/png,image/jpeg,image/webp' },
]
return (
<div className="space-y-6">
<fieldset disabled={busy} className="space-y-4">
<legend className="mb-3 text-base font-semibold">产品资料</legend>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-1.5 text-sm font-medium">项目名称<input className={detailFieldClass} value={document.name} maxLength={100} onChange={(event) => onChange({ ...document, name: event.target.value })} placeholder="例如:橡木座椅详情页" /></label>
<label className="space-y-1.5 text-sm font-medium">产品名称<input required className={detailFieldClass} value={document.product.name} maxLength={120} onChange={(event) => productChange('name', event.target.value)} /></label>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="w-28 space-y-1.5 text-sm font-medium">标识类型<select className={detailFieldClass} value={document.product.identifierType} onChange={(event) => productChange('identifierType', event.target.value)}><option value="sku">SKU</option><option value="spu">SPU</option><option value="barcode">条码</option></select></label>
<label className="min-w-0 flex-1 space-y-1.5 text-sm font-medium">产品标识<input className={detailFieldClass} value={document.product.identifier} maxLength={120} onChange={(event) => productChange('identifier', event.target.value)} /></label>
<button type="button" className={detailButtonClass} disabled={!document.product.identifier.trim()} onClick={onFindMaterials}>查找素材</button>
</div>
<label className="block space-y-1.5 text-sm font-medium">具体款式/规格<input className={detailFieldClass} value={document.product.variant} maxLength={200} onChange={(event) => productChange('variant', event.target.value)} placeholder="颜色、尺寸或具体 SKU" /></label>
<label className="block space-y-1.5 text-sm font-medium">已确认的产品信息<textarea required className={`${detailFieldClass} min-h-32 resize-y`} value={document.product.facts} maxLength={30000} onChange={(event) => productChange('facts', event.target.value)} placeholder="材质、尺寸、功能等真实信息;也可上传资料" /></label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
{collections.map((collection) => <section key={collection.kind} aria-label={collection.label}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{collection.label}</h3>
<label className={`${detailButtonClass} cursor-pointer focus-within:ring-2 focus-within:ring-[#1677ff] ${busy ? 'pointer-events-none opacity-50' : ''}`}><Upload className="h-4 w-4" aria-hidden="true" /><span>上传</span><input type="file" aria-label={`上传${collection.label}`} className="sr-only" disabled={busy} accept={collection.accept} multiple={collection.kind !== 'logo'} onChange={(event) => fileChange(event, collection.kind)} /></label>
</div>
<div className="flex min-h-20 flex-wrap gap-2 rounded-lg bg-[#f8fafc] p-2">
{!collection.ids.length && <p className="self-center px-2 text-sm text-[#66758b]">尚未添加</p>}
{collection.ids.map((id) => {
const asset = assets.find((item) => item.id === id)
return <div key={id} className="relative flex w-24 flex-col gap-1 rounded-md bg-white p-1.5">
{asset?.mimeType.startsWith('image/') ? <img src={assetUrl(id)} alt={asset.name} className="h-16 w-full rounded object-contain" /> : <span className="flex h-16 items-center justify-center break-all text-xs text-[#4f6077]">{asset?.name || '产品素材'}</span>}
<span className="truncate text-xs text-[#4f6077]" title={asset?.name}>{asset?.name || '素材'}</span>
<button type="button" aria-label={`移除${asset?.name || '素材'}`} disabled={busy} onClick={() => onRemoveAsset(id)} className="absolute right-0 top-0 flex h-7 w-7 items-center justify-center rounded bg-white text-[#344054] hover:bg-[#fff0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-3.5 w-3.5" aria-hidden="true" /></button>
</div>
})}
</div>
</section>)}
</div>
<fieldset disabled={busy} className="space-y-4 border-t border-[#e2e8f0] pt-5">
<legend className="mb-3 text-base font-semibold">品牌风格 <span className="ml-2 text-xs font-normal text-[#66758b]">草案</span></legend>
<label className="block space-y-1.5 text-sm font-medium">品牌<input className={detailFieldClass} list="detail-generator-brands" value={document.brand.name} maxLength={100} onChange={(event) => {
const saved = config?.brandProfiles?.find((profile) => profile.profile.name === event.target.value)
if (saved) onChange({ ...document, brand: { ...saved.profile, logoAssetId: document.brand.logoAssetId } })
else brandChange('name', event.target.value)
}} /><datalist id="detail-generator-brands">{Array.from(new Set([...(config?.brands || []), ...(config?.brandProfiles?.map((profile) => profile.profile.name) || [])])).map((brand) => <option key={brand} value={brand} />)}</datalist></label>
<label className="block space-y-1.5 text-sm font-medium">调性<textarea className={`${detailFieldClass} min-h-24 resize-y`} value={document.brand.tone} maxLength={2000} onChange={(event) => brandChange('tone', event.target.value)} placeholder="例如:自然、克制,浅色背景与温暖光线" /></label>
<div className="flex flex-wrap gap-x-6 gap-y-3">{([{ key: 'primaryColor', label: '品牌色' }, { key: 'backgroundColor', label: '背景色' }, { key: 'textColor', label: '文字色' }] as const).map((color) => <label key={color.key} className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="color" aria-label={color.label} value={document.brand[color.key]} onChange={(event) => brandChange(color.key, event.target.value)} className="h-8 w-8 cursor-pointer rounded border border-[#cfd8e3] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" />{color.label}</label>)}</div>
<button type="button" className={detailButtonClass} disabled={!document.brand.name.trim()} onClick={onSaveBrand}>保存品牌草案</button>
</fieldset>
<DetailModelOptions document={document} config={config} busy={busy} onChange={onChange} />
</div>
)
}
export function DetailModelOptions({ document, config, busy, onChange }: {
document: DetailDocument
config: DetailConfig | null
busy: boolean
onChange: (document: DetailDocument) => void
}) {
return <details className="border-t border-[#e2e8f0] pt-4">
<summary className="cursor-pointer text-sm font-medium text-[#4f6077] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">模型选择</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-3">{([{ key: 'analysis', label: '资料分析' }, { key: 'copy', label: '卖点文案' }, { key: 'image', label: '图片生成' }] as const).map((stage) => {
const current = document.models[stage.key]
const options = config?.modelOptions?.[stage.key] || (config?.models[stage.key] || []).map((model) => ({ value: model, label: model, providerName: '' }))
const legacy = config?.legacyModels?.[stage.key]?.includes(current)
const unavailable = !!current && !options.some((option) => option.value === current) && !legacy
return <label key={stage.key} className="min-w-0 space-y-1.5 text-sm font-medium">{stage.label}<select className={detailFieldClass} disabled={busy} value={current} onChange={(event) => onChange({ ...document, models: { ...document.models, [stage.key]: event.target.value } })}>{!current && <option value="">{options.length ? '请选择模型' : '暂无开放模型'}</option>}{current && !options.some((option) => option.value === current) && <option value={current}>{current}{unavailable ? ' · 当前不可用' : ' · 原项目选择'}</option>}{options.map((option) => <option key={option.value} value={option.value}>{option.label}{option.providerName ? ` · ${option.providerName}` : ''}</option>)}</select>{unavailable && <span className="block text-xs font-normal text-[#854a0e]">请选择可用模型后再生成</span>}</label>
})}</div>
</details>
}