104 lines
11 KiB
TypeScript
104 lines
11 KiB
TypeScript
'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>
|
||
}
|