feat(supply-chain): add supplier recommended products

This commit is contained in:
WuYongJie 2026-08-29 13:31:45 +08:00
parent 934b073eea
commit 474f245ba7
26 changed files with 774 additions and 0 deletions

View File

@ -0,0 +1,48 @@
CREATE TABLE "SupplierRecommendedProduct" (
"id" TEXT NOT NULL,
"recommendationDate" DATE,
"supplier" TEXT NOT NULL DEFAULT '',
"purchaser" TEXT NOT NULL DEFAULT '',
"productCode" TEXT NOT NULL DEFAULT '',
"imageUrl" TEXT,
"sampleRequest" TEXT NOT NULL DEFAULT '',
"dimensions" TEXT NOT NULL DEFAULT '',
"price" TEXT NOT NULL DEFAULT '',
"material" TEXT NOT NULL DEFAULT '',
"boardGrade" TEXT NOT NULL DEFAULT '',
"weight" TEXT NOT NULL DEFAULT '',
"orderQuantity" INTEGER NOT NULL DEFAULT 0,
"productionCycle" TEXT NOT NULL DEFAULT '',
"shippingMode" TEXT NOT NULL DEFAULT '',
"dropShippingFee" TEXT NOT NULL DEFAULT '',
"createdById" TEXT,
"createdByName" TEXT NOT NULL DEFAULT '',
"updatedById" TEXT,
"updatedByName" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplierRecommendedProduct_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SupplierRecommendedProductChangeLog" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"actorId" TEXT,
"actorName" TEXT NOT NULL,
"changes" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplierRecommendedProductChangeLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SupplierRecommendedProduct_recommendationDate_idx" ON "SupplierRecommendedProduct"("recommendationDate");
CREATE INDEX "SupplierRecommendedProduct_supplier_idx" ON "SupplierRecommendedProduct"("supplier");
CREATE INDEX "SupplierRecommendedProduct_purchaser_idx" ON "SupplierRecommendedProduct"("purchaser");
CREATE INDEX "SupplierRecommendedProduct_productCode_idx" ON "SupplierRecommendedProduct"("productCode");
CREATE INDEX "SupplierRecommendedProduct_shippingMode_idx" ON "SupplierRecommendedProduct"("shippingMode");
CREATE INDEX "SupplierRecommendedProductChangeLog_itemId_createdAt_idx" ON "SupplierRecommendedProductChangeLog"("itemId", "createdAt");
CREATE INDEX "SupplierRecommendedProductChangeLog_createdAt_idx" ON "SupplierRecommendedProductChangeLog"("createdAt");
ALTER TABLE "SupplierRecommendedProductChangeLog" ADD CONSTRAINT "SupplierRecommendedProductChangeLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "SupplierRecommendedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -164,6 +164,53 @@ model FinishedProductDeliveryRecord {
@@index([itemId, createdAt])
}
model SupplierRecommendedProduct {
id String @id @default(cuid())
recommendationDate DateTime? @db.Date
supplier String @default("")
purchaser String @default("")
productCode String @default("")
imageUrl String? @db.Text
sampleRequest String @default("")
dimensions String @default("")
price String @default("")
material String @default("")
boardGrade String @default("")
weight String @default("")
orderQuantity Int @default(0)
productionCycle String @default("")
shippingMode String @default("")
dropShippingFee String @default("")
createdById String?
createdByName String @default("")
updatedById String?
updatedByName String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
changeLogs SupplierRecommendedProductChangeLog[]
@@index([recommendationDate])
@@index([supplier])
@@index([purchaser])
@@index([productCode])
@@index([shippingMode])
}
model SupplierRecommendedProductChangeLog {
id String @id @default(cuid())
itemId String
item SupplierRecommendedProduct @relation(fields: [itemId], references: [id], onDelete: Cascade)
operation String
actorId String?
actorName String
changes Json
createdAt DateTime @default(now())
@@index([itemId, createdAt])
@@index([createdAt])
}
// ============================================
// 产品分类
// ============================================

View File

@ -0,0 +1,12 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
type Context = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => {
const { id } = await context.params
return jsonSuccess(await supplierRecommendedProductsService.listChangeLogs(id, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,13 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
type Context = { params: Promise<{ id: string }> }
export async function PATCH(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => {
const { id } = await context.params
const body = await request.json().catch(() => null)
return jsonSuccess(await supplierRecommendedProductsService.update(id, body, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { SUPPLIER_RECOMMENDED_PRODUCT_IMPORT_HEADERS, type SupplierRecommendedProductImportProgress, type SupplierRecommendedProductImportStreamEvent } from '@/lib/supplier-recommended-products'
import { ErrorCode, isAppError } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: true })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
const key = SUPPLIER_RECOMMENDED_PRODUCT_IMPORT_HEADERS[header.trim()]
return key ? [[key, value]] : []
})))
if (!rows.length) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
if (rows.length > 5_000) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false
let progress: SupplierRecommendedProductImportProgress = { processed: 0, total: rows.length, created: 0, updated: 0 }
const send = (event: SupplierRecommendedProductImportStreamEvent) => { if (!closed) { try { controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) } catch { closed = true } } }
const close = () => { if (!closed) { closed = true; controller.close() } }
send({ type: 'progress', progress })
void supplierRecommendedProductsService.importRows(rows, ctx.user, (next) => { progress = next; send({ type: 'progress', progress }) }).then((result) => { progress = { processed: rows.length, total: rows.length, ...result }; send({ type: 'complete', progress }); close() }).catch((error: unknown) => { console.error(`[${ctx.requestId}] Supplier recommended products import failed:`, error); send({ type: 'error', progress, message: isAppError(error) ? error.message : '导入过程中发生错误' }); close() })
},
})
return new NextResponse(stream, { status: 200, headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no' } })
}, { requireAuth: true })
}

View File

@ -0,0 +1,33 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS } from '@/lib/supplier-recommended-products'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
search: z.string().trim().max(200).default(''),
supplier: z.string().trim().max(200).default(''),
purchaser: z.string().trim().max(200).default(''),
shippingMode: z.string().trim().max(200).default(''),
dateFrom: z.string().trim().max(20).default(''),
dateTo: z.string().trim().max(20).default(''),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = listSchema.safeParse({ ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS, ...Object.fromEntries(request.nextUrl.searchParams.entries()) })
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '供应商新品推荐筛选条件不正确', 400, ctx.requestId)
const { page, pageSize, ...filters } = parsed.data
return jsonSuccess(await supplierRecommendedProductsService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
return jsonSuccess(await supplierRecommendedProductsService.create(body, ctx.user), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { SUPPLIER_RECOMMENDED_PRODUCT_FIELDS } from '@/lib/supplier-recommended-products'
import { handleRequest } from '@/server/core/response'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await supplierRecommendedProductsService.requireTemplateAccess(ctx.user)
const headers = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.map((field) => field.label)
const sheet = XLSX.utils.aoa_to_sheet([headers])
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 4) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, '供应商新品推荐导入')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), { status: 200, headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition': "attachment; filename*=UTF-8''%E4%BE%9B%E5%BA%94%E5%95%86%E6%96%B0%E5%93%81%E6%8E%A8%E8%8D%90%E5%AF%BC%E5%85%A5%E6%A8%A1%E6%9D%BF.xlsx", 'Cache-Control': 'no-store' } })
}, { requireAuth: true })
}

View File

@ -101,6 +101,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/new-products/unified-table': '新品开发进度总表',
'/supply-chain/new-products/sample-progress': '打样进度明细',
'/supply-chain/purchase/finished-product-progress': '成品进度明细',
'/supply-chain/purchase/supplier-recommended-products': '供应商新品推荐',
'/supply-chain/purchase/supplier-information': '供应商信息',
'/supply-chain/purchase/daily-affairs': '日常事项',
'/supply-chain/purchase/price-change': '产品变价',

View File

@ -69,6 +69,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
items: [
{ label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' },
{ label: '成品进度明细', route: '/supply-chain/purchase/finished-product-progress', targetKey: 'newProductSampleProgress' },
{ label: '供应商新品推荐', route: '/supply-chain/purchase/supplier-recommended-products', targetKey: 'purchaseSupplierRecommendedProducts' },
{ label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' },
{ label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' },
{ label: '产品变价', route: '/supply-chain/purchase/price-change', targetKey: 'purchasePriceChange' },

View File

@ -0,0 +1,27 @@
'use client'
import Image from 'next/image'
import { FileDown, Plus, RotateCcw, Search, Upload, X } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS, type SupplierRecommendedProductChangeLogEntry, type SupplierRecommendedProductFilters, type SupplierRecommendedProductPage, type SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products'
import { SupplierRecommendedProductColumnSettings } from './components/SupplierRecommendedProductColumnSettings'
import { SupplierRecommendedProductEditor } from './components/SupplierRecommendedProductEditor'
import { SupplierRecommendedProductHistoryDialog } from './components/SupplierRecommendedProductHistoryDialog'
import { SupplierRecommendedProductImportProgress } from './components/SupplierRecommendedProductImportProgress'
import { SupplierRecommendedProductPagination } from './components/SupplierRecommendedProductPagination'
import { SupplierRecommendedProductTable } from './components/SupplierRecommendedProductTable'
import { supplierRecommendedProductsStyles } from './supplierRecommendedProductsStyles'
import { useSupplierRecommendedProductColumns } from './useSupplierRecommendedProductColumns'
import { useSupplierRecommendedProductImport } from './useSupplierRecommendedProductImport'
async function readResponse(response: Response) { const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败'); return payload.data }
export function SupplierRecommendedProductsClient({ initialData, canEdit, canDownload }: { initialData: SupplierRecommendedProductPage; canEdit: boolean; canDownload: boolean }) {
const [data, setData] = useState(initialData); const [filters, setFilters] = useState<SupplierRecommendedProductFilters>({ ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS }); const [editor, setEditor] = useState<SupplierRecommendedProductRow | 'new' | null>(null); const [logs, setLogs] = useState<SupplierRecommendedProductChangeLogEntry[] | null>(null); const [preview, setPreview] = useState<SupplierRecommendedProductRow | null>(null); const [loading, setLoading] = useState(false); const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page)); const fileRef = useRef<HTMLInputElement>(null); const columns = useSupplierRecommendedProductColumns()
const load = useCallback(async (nextFilters: SupplierRecommendedProductFilters, page: number, pageSize: number) => { setLoading(true); try { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); Object.entries(nextFilters).forEach(([key, value]) => { if (value) params.set(key, value) }); const next = await readResponse(await fetch(`/api/supply-chain/supplier-recommended-products?${params}`, { cache: 'no-store' })) as SupplierRecommendedProductPage; setData(next); setFilters(nextFilters); setPageInput(String(next.pageInfo.page)) } catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) } }, [])
const afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load]); const importTask = useSupplierRecommendedProductImport(afterImport)
const reset = () => { const next = { ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS }; setFilters(next); void load(next, 1, data.pageInfo.pageSize) }
const openHistory = async (row: SupplierRecommendedProductRow) => { try { setLogs(await readResponse(await fetch(`/api/supply-chain/supplier-recommended-products/${row.id}/history`, { cache: 'no-store' }))) } catch (error) { window.alert(error instanceof Error ? error.message : '日志加载失败') } }
const commitPage = () => { const parsed = Number(pageInput); if (!Number.isFinite(parsed)) return setPageInput(String(data.pageInfo.page)); const page = Math.min(Math.max(1, Math.trunc(parsed)), data.pageInfo.totalPages); setPageInput(String(page)); if (page !== data.pageInfo.page) void load(filters, page, data.pageInfo.pageSize) }
return <main className="recommended-products-page"><header className="recommended-products-toolbar"><div><h1>供应商新品推荐</h1><p>维护供应商自荐产品资料、寄样和发货信息</p></div><div className="recommended-products-actions"><SupplierRecommendedProductColumnSettings visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onShowAll={columns.showAll} />{canDownload && <button type="button" className="recommended-control-button" onClick={() => { window.location.href = '/api/supply-chain/supplier-recommended-products/template' }}><FileDown size={15} />下载模板</button>}{canEdit && <><button type="button" disabled={importTask.isImporting} onClick={() => fileRef.current?.click()} className="recommended-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} /><button type="button" onClick={() => setEditor('new')} className="recommended-primary-button"><Plus size={15} />新增</button></>}</div></header><form className="recommended-products-filters" onSubmit={(event) => { event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}><input value={filters.search} onChange={(event) => setFilters({ ...filters, search: event.target.value })} placeholder="搜索供应商、采购、产品代码、材质或发货模式" /><select value={filters.supplier} onChange={(event) => setFilters({ ...filters, supplier: event.target.value })}><option value="">全部供应商</option>{data.options.suppliers.map((value) => <option key={value}>{value}</option>)}</select><select value={filters.purchaser} onChange={(event) => setFilters({ ...filters, purchaser: event.target.value })}><option value="">全部采购</option>{data.options.purchasers.map((value) => <option key={value}>{value}</option>)}</select><select value={filters.shippingMode} onChange={(event) => setFilters({ ...filters, shippingMode: event.target.value })}><option value="">全部发货模式</option>{data.options.shippingModes.map((value) => <option key={value}>{value}</option>)}</select><input type="date" title="日期开始" aria-label="日期开始" value={filters.dateFrom} onChange={(event) => setFilters({ ...filters, dateFrom: event.target.value })} /><input type="date" title="日期结束" aria-label="日期结束" value={filters.dateTo} onChange={(event) => setFilters({ ...filters, dateTo: event.target.value })} /><button type="submit" className="recommended-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="recommended-control-button"><RotateCcw size={15} /></button></form><section className="recommended-products-table-shell"><SupplierRecommendedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} onEdit={setEditor} onHistory={(row) => void openHistory(row)} onPreview={setPreview} /><SupplierRecommendedProductPagination {...data.pageInfo} loading={loading} pageInput={pageInput} onPageInput={setPageInput} onCommitPage={commitPage} onPage={(page) => void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} /></section>{editor && <SupplierRecommendedProductEditor item={editor === 'new' ? null : editor} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}{logs && <SupplierRecommendedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}{importTask.state.status !== 'idle' && <SupplierRecommendedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />}{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productCode || '新品图片'} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}<style jsx global>{supplierRecommendedProductsStyles}</style></main>
}

View File

@ -0,0 +1,8 @@
'use client'
import { Columns3 } from 'lucide-react'
import { SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS, type SupplierRecommendedProductColumnKey } from '../supplierRecommendedProductColumns'
export function SupplierRecommendedProductColumnSettings({ visibleKeys, hiddenCount, onToggle, onShowAll }: { visibleKeys: ReadonlySet<SupplierRecommendedProductColumnKey>; hiddenCount: number; onToggle: (key: SupplierRecommendedProductColumnKey) => void; onShowAll: () => void }) {
return <details className="recommended-column-settings"><summary className="recommended-control-button"><Columns3 size={15} />列设置{hiddenCount > 0 && <span className="recommended-badge">{hiddenCount}</span>}</summary><div className="recommended-column-menu"><div className="recommended-column-menu-header"><span>显示字段</span><button type="button" onClick={onShowAll}>全部显示</button></div>{SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.map((column) => <label key={column.key} className="recommended-column-option"><input type="checkbox" checked={visibleKeys.has(column.key)} disabled={column.locked} onChange={() => onToggle(column.key)} /><span>{column.label}</span>{column.locked && <em>固定</em>}</label>)}</div></details>
}

View File

@ -0,0 +1,16 @@
'use client'
import { useState } from 'react'
import { Save, X } from 'lucide-react'
import type { SupplierRecommendedProductInput, SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products'
const EMPTY: SupplierRecommendedProductInput = { recommendationDate: null, supplier: '', purchaser: '', productCode: '', imageUrl: '', sampleRequest: '', dimensions: '', price: '', material: '', boardGrade: '', weight: '', orderQuantity: 0, productionCycle: '', shippingMode: '', dropShippingFee: '' }
function toDraft(row: SupplierRecommendedProductRow | null): SupplierRecommendedProductInput { if (!row) return { ...EMPTY }; const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, updatedByName: _updatedByName, ...input } = row; return input }
export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { item: SupplierRecommendedProductRow | null; onClose: () => void; onSaved: () => void }) {
const [draft, setDraft] = useState(() => toDraft(item)); const [saving, setSaving] = useState(false)
const set = <K extends keyof SupplierRecommendedProductInput>(key: K, value: SupplierRecommendedProductInput[K]) => setDraft((current) => ({ ...current, [key]: value }))
const save = async () => { setSaving(true); try { const response = await fetch(item ? `/api/supply-chain/supplier-recommended-products/${item.id}` : '/api/supply-chain/supplier-recommended-products', { method: item ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '保存失败'); onSaved() } catch (error) { window.alert(error instanceof Error ? error.message : '保存失败') } finally { setSaving(false) } }
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}><section className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl"><header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}</h2><p className="mt-0.5 text-xs text-zinc-500">供应商推荐信息人工维护,空白字段可保存</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-6"><div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2"><label className="recommended-field"><span>日期</span><input type="date" value={draft.recommendationDate ?? ''} onChange={(event) => set('recommendationDate', event.target.value || null)} /></label><Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} /><Field label="采购" value={draft.purchaser} onChange={(value) => set('purchaser', value)} /><Field label="产品代码(供应商自己的)" value={draft.productCode} onChange={(value) => set('productCode', value)} /><Field label="申请寄样" value={draft.sampleRequest} onChange={(value) => set('sampleRequest', value)} /><Field label="尺寸" value={draft.dimensions} onChange={(value) => set('dimensions', value)} /><Field label="价格(含税价+专票)" value={draft.price} onChange={(value) => set('price', value)} /><Field label="材质" value={draft.material} onChange={(value) => set('material', value)} /><Field label="板材等级" value={draft.boardGrade} onChange={(value) => set('boardGrade', value)} /><Field label="重量" value={draft.weight} onChange={(value) => set('weight', value)} /><label className="recommended-field"><span>下单量</span><input type="number" min={0} step={1} value={draft.orderQuantity} onChange={(event) => set('orderQuantity', Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label><Field label="生产周期" value={draft.productionCycle} onChange={(value) => set('productionCycle', value)} /><Field label="发货模式" value={draft.shippingMode} onChange={(value) => set('shippingMode', value)} /><Field label="代发运费(代发情况下)" value={draft.dropShippingFee} onChange={(value) => set('dropShippingFee', value)} /><label className="recommended-field sm:col-span-2"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://..." /></label></div></div><footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="recommended-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="recommended-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer></section></div>
}
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return <label className="recommended-field"><span>{label}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label> }

View File

@ -0,0 +1,8 @@
'use client'
import { X } from 'lucide-react'
import type { SupplierRecommendedProductChangeLogEntry } from '@/lib/supplier-recommended-products'
export function SupplierRecommendedProductHistoryDialog({ logs, onClose }: { logs: SupplierRecommendedProductChangeLogEntry[]; onClose: () => void }) {
return <div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/30 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}><section className="flex max-h-[80vh] w-full max-w-[760px] flex-col overflow-hidden rounded-[8px] bg-white shadow-2xl"><header className="flex items-center justify-between border-b border-zinc-200 px-5 py-4"><div><h2 className="font-bold text-zinc-900">修改日志</h2><p className="mt-1 text-xs text-zinc-500">记录每次新增和编辑的操作人及前后值</p></div><button type="button" title="关闭" onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-5">{logs.length === 0 ? <p className="py-10 text-center text-sm text-zinc-500">暂无修改日志</p> : <div className="space-y-4">{logs.map((log) => <article key={log.id} className="rounded-[6px] border border-zinc-200 p-4"><div className="flex justify-between text-xs text-zinc-500"><span className="font-semibold text-zinc-800">{log.operation === 'CREATE' || log.operation === 'IMPORT_CREATE' ? '新增' : '编辑'} · {log.actorName}</span><time>{new Date(log.createdAt).toLocaleString('zh-CN')}</time></div><div className="mt-3 overflow-x-auto"><table className="w-full text-left text-xs"><thead><tr className="text-zinc-500"><th className="pb-2 pr-4">字段</th><th className="pb-2 pr-4">修改前</th><th className="pb-2">修改后</th></tr></thead><tbody>{log.changes.map((change) => <tr key={change.field} className="border-t border-zinc-100"><td className="py-2 pr-4 font-medium text-zinc-700">{change.label}</td><td className="py-2 pr-4 text-zinc-500">{String(change.before ?? '空白')}</td><td className="py-2 text-zinc-800">{String(change.after ?? '空白')}</td></tr>)}</tbody></table></div></article>)}</div>}</div></section></div>
}

View File

@ -0,0 +1,10 @@
'use client'
import type { SupplierRecommendedProductImportProgress as Progress } from '@/lib/supplier-recommended-products'
export function SupplierRecommendedProductImportProgress({ state, onDismiss }: { state: ({ status: 'idle' } | { status: 'importing' | 'complete' | 'error'; progress: Progress; message?: string }); onDismiss: () => void }) {
if (state.status === 'idle') return null
const { progress } = state
const percent = progress.total ? Math.round((progress.processed / progress.total) * 100) : 0
return <div className="fixed bottom-5 right-5 z-[90] w-[min(380px,calc(100vw-40px))] rounded-[8px] border border-zinc-200 bg-white p-4 shadow-xl"><div className="flex items-center justify-between"><strong className="text-sm text-zinc-900">{state.status === 'importing' ? '正在导入供应商新品推荐' : state.status === 'complete' ? '导入完成' : '导入失败'}</strong>{state.status !== 'importing' && <button type="button" onClick={onDismiss} className="text-xs text-zinc-500 hover:text-zinc-900">关闭</button>}</div><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-zinc-200"><span className={`block h-full rounded-full ${state.status === 'error' ? 'bg-red-700' : state.status === 'complete' ? 'bg-emerald-700' : 'bg-zinc-800'}`} style={{ width: `${state.status === 'importing' ? Math.max(4, percent) : 100}%` }} /></div><p className="mt-2 text-xs text-zinc-500">{state.status === 'error' ? state.message : `${progress.processed}/${progress.total} 行 · 新增 ${progress.created} 条`}</p></div>
}

View File

@ -0,0 +1,7 @@
'use client'
import { ArrowLeft, ArrowRight } from 'lucide-react'
export function SupplierRecommendedProductPagination({ page, pageSize, total, totalPages, loading, pageInput, onPageInput, onCommitPage, onPage, onPageSize }: { page: number; pageSize: number; total: number; totalPages: number; loading: boolean; pageInput: string; onPageInput: (value: string) => void; onCommitPage: () => void; onPage: (page: number) => void; onPageSize: (pageSize: number) => void }) {
return <nav aria-label="供应商新品推荐分页" className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-2.5 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-4 py-2 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl max-sm:gap-1 max-sm:px-2 max-sm:text-xs"><span className="mr-1 text-zinc-600">{loading ? '正在统计...' : `共 ${total} 条`}</span><button type="button" disabled={loading || page <= 1} onClick={() => onPage(page - 1)} className="recommended-page-button"><ArrowLeft size={12} />上一页</button><span className="inline-flex items-center gap-1 text-zinc-600"><input type="text" inputMode="numeric" pattern="[0-9]*" aria-label="跳转页码" value={pageInput} disabled={loading} onChange={(event) => onPageInput(event.target.value.replace(/\D/g, ''))} onBlur={onCommitPage} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); onCommitPage() } }} className="h-7 w-[52px] rounded-[4px] border border-zinc-200 text-center font-semibold outline-none focus:border-zinc-500" /><span className="text-zinc-400">/</span><span>{totalPages} 页</span></span><button type="button" disabled={loading || page >= totalPages} onClick={() => onPage(page + 1)} className="recommended-page-button">下一页<ArrowRight size={12} /></button><span className="ml-1 text-zinc-500">每页<select value={pageSize} onChange={(event) => onPageSize(Number(event.target.value))} className="mx-1 h-7 rounded-[4px] border border-zinc-200 bg-white px-1"><option value={50}>50</option><option value={100}>100</option><option value={200}>200</option></select>条</span></nav>
}

View File

@ -0,0 +1,20 @@
'use client'
import Image from 'next/image'
import { History, Pencil } from 'lucide-react'
import type { SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products'
import type { SupplierRecommendedProductColumn, SupplierRecommendedProductColumnKey } from '../supplierRecommendedProductColumns'
export function SupplierRecommendedProductTable({ rows, columns, canEdit, onEdit, onHistory, onPreview }: { rows: SupplierRecommendedProductRow[]; columns: readonly SupplierRecommendedProductColumn[]; canEdit: boolean; onEdit: (row: SupplierRecommendedProductRow) => void; onHistory: (row: SupplierRecommendedProductRow) => void; onPreview: (row: SupplierRecommendedProductRow) => void }) {
const minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain"><table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth }}><thead><tr>{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${column.key === 'recommendationDate' ? 'left-0 z-40' : column.key === 'actions' ? 'right-0 z-40' : ''}`}>{column.label}</th>)}</tr></thead><tbody>{rows.map((row) => <tr key={row.id} className="group hover:bg-zinc-50">{columns.map((column) => <SupplierRecommendedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onHistory={onHistory} onPreview={onPreview} />)}</tr>)}</tbody></table>{rows.length === 0 && <div className="py-20 text-center text-sm text-zinc-500">暂无匹配的供应商新品推荐</div>}</div>
}
function SupplierRecommendedProductCell({ column, row, canEdit, onEdit, onHistory, onPreview }: { column: SupplierRecommendedProductColumnKey; row: SupplierRecommendedProductRow; canEdit: boolean; onEdit: (row: SupplierRecommendedProductRow) => void; onHistory: (row: SupplierRecommendedProductRow) => void; onPreview: (row: SupplierRecommendedProductRow) => void }) {
const base = 'border-b border-zinc-100 bg-white px-3 py-3 text-zinc-700 group-hover:bg-zinc-50'
if (column === 'recommendationDate') return <td className={`${base} sticky left-0 z-10 whitespace-nowrap`}>{row.recommendationDate || '-'}</td>
if (column === 'imageUrl') return <td className={base}>{row.imageUrl ? <button type="button" onClick={() => onPreview(row)} className="relative h-12 w-12 overflow-hidden rounded-[5px] border border-zinc-200 bg-zinc-50"><Image unoptimized src={row.imageUrl} alt={row.productCode || '新品图片'} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
if (column === 'actions') return <td className={`${base} sticky right-0 z-10 border-l border-zinc-200`}><div className="flex flex-nowrap justify-center gap-1 whitespace-nowrap">{canEdit && <button type="button" title="编辑" onClick={() => onEdit(row)} className="recommended-row-button"><Pencil size={14} />编辑</button>}<button type="button" title="修改日志" onClick={() => onHistory(row)} className="recommended-row-button"><History size={14} />日志</button></div></td>
if (column === 'orderQuantity') return <td className={`${base} tabular-nums`}>{row.orderQuantity.toLocaleString('zh-CN')}</td>
return <td className={`${base} max-w-[260px] whitespace-pre-wrap break-words`} title={String(row[column] || '')}>{String(row[column] || '-')}</td>
}

View File

@ -0,0 +1,15 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
import { SupplierRecommendedProductsClient } from './SupplierRecommendedProductsClient'
export const dynamic = 'force-dynamic'
export const metadata = { title: '供应商新品推荐 · 供应链中心' }
export default async function SupplierRecommendedProductsPage() {
const { authUser } = await requireSession('/login')
const access = await supplierRecommendedProductsService.getAccess(authUser)
if (!access.view) redirect('/supply-chain')
const initialData = await supplierRecommendedProductsService.listPage({ page: 1, pageSize: 50, user: authUser })
return <SupplierRecommendedProductsClient initialData={initialData} canEdit={access.edit} canDownload={access.download} />
}

View File

@ -0,0 +1,25 @@
import type { SupplierRecommendedProductFieldKey } from '@/lib/supplier-recommended-products'
export type SupplierRecommendedProductColumnKey = SupplierRecommendedProductFieldKey | 'actions'
export type SupplierRecommendedProductColumn = { key: SupplierRecommendedProductColumnKey; label: string; minWidth: number; locked?: boolean }
export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedProductColumn[] = [
{ key: 'recommendationDate', label: '日期', minWidth: 112 },
{ key: 'supplier', label: '供应商', minWidth: 170 },
{ key: 'purchaser', label: '采购', minWidth: 110 },
{ key: 'productCode', label: '产品代码(供应商自己的)', minWidth: 190 },
{ key: 'imageUrl', label: '图片', minWidth: 86 },
{ key: 'sampleRequest', label: '申请寄样', minWidth: 120 },
{ key: 'dimensions', label: '尺寸', minWidth: 150 },
{ key: 'price', label: '价格(含税价+专票)', minWidth: 170 },
{ key: 'material', label: '材质', minWidth: 140 },
{ key: 'boardGrade', label: '板材等级', minWidth: 120 },
{ key: 'weight', label: '重量', minWidth: 100 },
{ key: 'orderQuantity', label: '下单量', minWidth: 90 },
{ key: 'productionCycle', label: '生产周期', minWidth: 120 },
{ key: 'shippingMode', label: '发货模式', minWidth: 120 },
{ key: 'dropShippingFee', label: '代发运费(代发情况下)', minWidth: 190 },
{ key: 'actions', label: '操作', minWidth: 150, locked: true },
]
export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMN_KEYS = new Set(SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.map((column) => column.key))

View File

@ -0,0 +1,10 @@
export const supplierRecommendedProductsStyles = String.raw`
.recommended-products-page{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:#f5f6f6;color:#27272a}
.recommended-products-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;padding:18px 24px;border-bottom:1px solid #e4e4e7;background:#fff}
.recommended-products-toolbar h1{margin:0;font-size:18px;font-weight:750}.recommended-products-toolbar p{margin:4px 0 0;color:#71717a;font-size:12px}.recommended-products-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}
.recommended-control-button,.recommended-primary-button,.recommended-secondary-button,.recommended-page-button,.recommended-row-button,.recommended-icon-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s}.recommended-control-button,.recommended-primary-button,.recommended-secondary-button{height:38px;padding:0 13px}.recommended-primary-button{border-color:#27272a;background:#27272a;color:#fff}.recommended-primary-button:hover{background:#18181b}.recommended-secondary-button:hover,.recommended-control-button:hover,.recommended-row-button:hover,.recommended-page-button:hover{background:#f4f4f5;border-color:#a1a1aa}.recommended-control-button:disabled,.recommended-primary-button:disabled,.recommended-secondary-button:disabled,.recommended-row-button:disabled,.recommended-page-button:disabled{cursor:not-allowed;opacity:.45}.recommended-icon-button{width:30px;height:30px;padding:0;border:0;background:transparent}.recommended-icon-button:hover{background:#f4f4f5}.recommended-row-button{height:29px;padding:0 8px;font-size:11px}.recommended-page-button{height:30px;padding:0 12px}
.recommended-products-filters{display:grid;grid-template-columns:minmax(240px,2fr) repeat(3,minmax(130px,1fr)) 150px 150px auto auto;gap:8px;padding:12px 24px;border-bottom:1px solid #e4e4e7;background:#fff}.recommended-products-filters input,.recommended-products-filters select{min-width:0;height:38px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;outline:none}.recommended-products-filters input:focus,.recommended-products-filters select:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
.recommended-products-table-shell{position:relative;display:flex;min-height:0;flex:1;flex-direction:column;margin:16px 20px 20px;overflow:hidden;border:1px solid #e4e4e7;border-radius:7px;background:#fff;box-shadow:0 8px 24px rgba(24,24,27,.05)}.recommended-products-table-shell>div:first-child{padding-bottom:76px}.recommended-field{display:flex;flex-direction:column;gap:6px}.recommended-field>span{font-size:12px;font-weight:650;color:#52525b}.recommended-field input{width:100%;box-sizing:border-box;height:40px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:9px 10px;color:#27272a;font-size:13px;outline:none}.recommended-field input:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
.recommended-column-settings{position:relative}.recommended-column-settings summary{list-style:none}.recommended-column-settings summary::-webkit-details-marker{display:none}.recommended-column-menu{position:absolute;right:0;top:44px;z-index:60;width:250px;max-height:420px;overflow-y:auto;border:1px solid #d4d4d8;border-radius:7px;background:#fff;padding:10px;box-shadow:0 12px 30px rgba(24,24,27,.12)}.recommended-column-menu-header{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px;border-bottom:1px solid #f4f4f5;font-size:12px;font-weight:700}.recommended-column-menu-header button{border:0;background:none;color:#52525b;font-size:11px;cursor:pointer}.recommended-column-option{display:flex;align-items:center;gap:8px;padding:8px 4px;font-size:12px}.recommended-column-option input{accent-color:#27272a}.recommended-column-option em{margin-left:auto;color:#a1a1aa;font-size:10px;font-style:normal}.recommended-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;border-radius:99px;background:#e4e4e7;color:#52525b;font-size:10px}
@media(max-width:1280px){.recommended-products-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.recommended-products-filters>input:first-child{grid-column:span 2}}@media(max-width:760px){.recommended-products-toolbar{align-items:flex-start;padding:14px}.recommended-products-actions{justify-content:flex-start}.recommended-products-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.recommended-products-filters>input:first-child{grid-column:1/-1}.recommended-products-table-shell{margin:10px}.recommended-products-table-shell>div:first-child{padding-bottom:72px}.recommended-page-button{padding:0 7px}.recommended-products-toolbar p{display:none}}
`

View File

@ -0,0 +1,21 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS, type SupplierRecommendedProductColumnKey } from './supplierRecommendedProductColumns'
const STORAGE_KEY = 'supply-chain:supplier-recommended-products:columns'
export function useSupplierRecommendedProductColumns() {
const [hidden, setHidden] = useState<Set<SupplierRecommendedProductColumnKey>>(() => {
try {
const values = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as string[]
return new Set(values.filter((value): value is SupplierRecommendedProductColumnKey => SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.some((column) => column.key === value && !column.locked)))
} catch { return new Set() }
})
const toggle = useCallback((key: SupplierRecommendedProductColumnKey) => {
setHidden((current) => { const next = new Set(current); const column = SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.find((item) => item.key === key); if (column?.locked) return current; if (next.has(key)) next.delete(key); else next.add(key); window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])); return next })
}, [])
const showAll = useCallback(() => { setHidden(new Set()); window.localStorage.removeItem(STORAGE_KEY) }, [])
const visibleColumns = useMemo(() => SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.filter((column) => !hidden.has(column.key)), [hidden])
return { visibleColumns, visibleKeySet: new Set(visibleColumns.map((column) => column.key)), hiddenCount: hidden.size, toggle, showAll }
}

View File

@ -0,0 +1,21 @@
'use client'
import { useCallback, useState } from 'react'
import type { SupplierRecommendedProductImportProgress, SupplierRecommendedProductImportStreamEvent } from '@/lib/supplier-recommended-products'
type State = { status: 'idle' } | { status: 'importing' | 'complete' | 'error'; progress: SupplierRecommendedProductImportProgress; message?: string }
export function useSupplierRecommendedProductImport(onComplete: () => void) {
const [state, setState] = useState<State>({ status: 'idle' })
const importFile = useCallback(async (file: File) => {
setState({ status: 'importing', progress: { processed: 0, total: 0, created: 0, updated: 0 } })
try {
const response = await fetch('/api/supply-chain/supplier-recommended-products/import', { method: 'POST', body: (() => { const form = new FormData(); form.append('file', file); return form })() })
if (!response.ok || !response.body) { const payload = await response.json().catch(() => null); throw new Error(payload?.error?.message || '导入失败') }
const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''
while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; const event = JSON.parse(line) as SupplierRecommendedProductImportStreamEvent; if (event.type === 'error') throw new Error(event.message); setState({ status: event.type === 'complete' ? 'complete' : 'importing', progress: event.progress }); if (event.type === 'complete') onComplete() } if (done) break }
} catch (error) { setState((current) => ({ status: 'error', progress: current.status === 'idle' ? { processed: 0, total: 0, created: 0, updated: 0 } : current.progress, message: error instanceof Error ? error.message : '导入失败' })) }
}, [onComplete])
const dismiss = useCallback(() => setState({ status: 'idle' }), [])
return { state, importFile, dismiss, isImporting: state.status === 'importing' }
}

View File

@ -0,0 +1,123 @@
import { z } from 'zod'
export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [
{ key: 'recommendationDate', label: '日期' },
{ key: 'supplier', label: '供应商' },
{ key: 'purchaser', label: '采购' },
{ key: 'productCode', label: '产品代码(供应商自己的)' },
{ key: 'imageUrl', label: '图片' },
{ key: 'sampleRequest', label: '申请寄样' },
{ key: 'dimensions', label: '尺寸' },
{ key: 'price', label: '价格(含税价+专票)' },
{ key: 'material', label: '材质' },
{ key: 'boardGrade', label: '板材等级' },
{ key: 'weight', label: '重量' },
{ key: 'orderQuantity', label: '下单量' },
{ key: 'productionCycle', label: '生产周期' },
{ key: 'shippingMode', label: '发货模式' },
{ key: 'dropShippingFee', label: '代发运费(代发情况下)' },
] as const
export type SupplierRecommendedProductFieldKey = typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS[number]['key']
const cleanString = (max: number) => z.preprocess(
(value) => String(value ?? '').trim(),
z.string().max(max),
)
const nonnegativeInteger = z.preprocess((value) => {
if (value === '' || value === null || value === undefined) return 0
return typeof value === 'number' ? value : Number(String(value).replace(/,/g, '').trim())
}, z.number({ message: '下单量必须是数字' }).int('下单量必须是整数').min(0, '下单量不能小于0').max(2_000_000_000, '下单量超出允许范围'))
function normalizeDate(value: unknown) {
if (value === '' || value === null || value === undefined) return null
if (value instanceof Date) return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(0, 10)
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(Date.UTC(1899, 11, 30) + Math.trunc(value) * 86_400_000).toISOString().slice(0, 10)
}
return String(value).trim()
}
const optionalDate = z.preprocess(
normalizeDate,
z.string().refine((value) => !Number.isNaN(new Date(`${value}T00:00:00Z`).getTime()), '日期格式不正确').nullable(),
)
export const supplierRecommendedProductInputSchema = z.object({
recommendationDate: optionalDate,
supplier: cleanString(240),
purchaser: cleanString(160),
productCode: cleanString(200),
imageUrl: cleanString(2_000),
sampleRequest: cleanString(120),
dimensions: cleanString(240),
price: cleanString(120),
material: cleanString(240),
boardGrade: cleanString(120),
weight: cleanString(120),
orderQuantity: nonnegativeInteger,
productionCycle: cleanString(120),
shippingMode: cleanString(120),
dropShippingFee: cleanString(120),
}).strict()
export type SupplierRecommendedProductInput = z.infer<typeof supplierRecommendedProductInputSchema>
export type SupplierRecommendedProductRow = SupplierRecommendedProductInput & {
id: string
createdAt: string
updatedAt: string
updatedByName: string
}
export type SupplierRecommendedProductPage = {
rows: SupplierRecommendedProductRow[]
pageInfo: { page: number; pageSize: number; total: number; totalPages: number }
options: { suppliers: string[]; purchasers: string[]; shippingModes: string[] }
}
export const EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS = {
search: '',
supplier: '',
purchaser: '',
shippingMode: '',
dateFrom: '',
dateTo: '',
} as const
export type SupplierRecommendedProductFilters = {
[K in keyof typeof EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS]: string
}
export type SupplierRecommendedProductChange = {
field: SupplierRecommendedProductFieldKey
label: string
before: string | number | null
after: string | number | null
}
export type SupplierRecommendedProductChangeLogEntry = {
id: string
operation: string
actorName: string
changes: SupplierRecommendedProductChange[]
createdAt: string
}
export type SupplierRecommendedProductImportProgress = {
processed: number
total: number
created: number
updated: number
}
export type SupplierRecommendedProductImportStreamEvent =
| { type: 'progress' | 'complete'; progress: SupplierRecommendedProductImportProgress }
| { type: 'error'; progress: SupplierRecommendedProductImportProgress; message: string }
export const SUPPLIER_RECOMMENDED_PRODUCT_IMPORT_HEADERS = Object.fromEntries(
SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.flatMap((field) => [
[field.label, field.key],
[field.key, field.key],
]),
) as Record<string, SupplierRecommendedProductFieldKey>

View File

@ -30,6 +30,7 @@ export type SupplyChainPermissionTargetKey =
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseSupplierInformation'
| 'purchaseSupplierRecommendedProducts'
| 'purchaseDailyAffairs'
| 'purchasePriceChange'
| 'scriptMonitor'
@ -84,6 +85,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' },
{ key: 'purchaseSupplierRecommendedProducts', label: '供应商新品推荐', description: '供应商推荐新品维护' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
{ key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' },
{ key: 'scriptMonitor', label: '脚本监控', description: '跨机器脚本任务、状态和运行结果' },
@ -108,6 +110,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
newProductUnifiedTable: '/supply-chain/new-products/unified-table',
newProductSampleProgress: '/supply-chain/new-products/sample-progress',
purchaseSupplierInformation: '/supply-chain/purchase/supplier-information',
purchaseSupplierRecommendedProducts: '/supply-chain/purchase/supplier-recommended-products',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
purchasePriceChange: '/supply-chain/purchase/price-change',
scriptMonitor: '/supply-chain/script-monitor',
@ -323,6 +326,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/finished-product-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const
if (pathname === '/supply-chain/purchase/supplier-recommended-products') return 'purchaseSupplierRecommendedProducts' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const
if (pathname === '/supply-chain/script-monitor') return 'scriptMonitor' as const
@ -364,6 +368,9 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
if (pathname.startsWith('/api/supply-chain/supplier-information')) {
return { targetKey: 'purchaseSupplierInformation' as const, action }
}
if (pathname.startsWith('/api/supply-chain/supplier-recommended-products')) {
return { targetKey: 'purchaseSupplierRecommendedProducts' as const, action: pathname.endsWith('/template') ? 'download' as const : action }
}
if (pathname.startsWith('/api/supply-chain/script-monitor')) {
return { targetKey: 'scriptMonitor' as const, action }
}

View File

@ -0,0 +1,106 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { SupplierRecommendedProductFilters, SupplierRecommendedProductInput } from '@/lib/supplier-recommended-products'
const ITEM_SELECT = {
id: true,
recommendationDate: true,
supplier: true,
purchaser: true,
productCode: true,
imageUrl: true,
sampleRequest: true,
dimensions: true,
price: true,
material: true,
boardGrade: true,
weight: true,
orderQuantity: true,
productionCycle: true,
shippingMode: true,
dropShippingFee: true,
createdAt: true,
updatedAt: true,
updatedByName: true,
} as const
export type SupplierRecommendedProductRecord = Prisma.SupplierRecommendedProductGetPayload<{ select: typeof ITEM_SELECT }>
function contains(value: string) {
return { contains: value, mode: 'insensitive' as const }
}
function buildWhere(filters: SupplierRecommendedProductFilters): Prisma.SupplierRecommendedProductWhereInput | undefined {
const and: Prisma.SupplierRecommendedProductWhereInput[] = []
const tokens = [...new Set(filters.search.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean))].slice(0, 100)
tokens.forEach((token) => and.push({ OR: [
{ supplier: contains(token) }, { purchaser: contains(token) }, { productCode: contains(token) },
{ sampleRequest: contains(token) }, { dimensions: contains(token) }, { price: contains(token) },
{ material: contains(token) }, { boardGrade: contains(token) }, { weight: contains(token) },
{ productionCycle: contains(token) }, { shippingMode: contains(token) }, { dropShippingFee: contains(token) },
] }))
if (filters.supplier) and.push({ supplier: contains(filters.supplier) })
if (filters.purchaser) and.push({ purchaser: contains(filters.purchaser) })
if (filters.shippingMode) and.push({ shippingMode: contains(filters.shippingMode) })
if (filters.dateFrom || filters.dateTo) {
and.push({ recommendationDate: {
...(filters.dateFrom ? { gte: new Date(`${filters.dateFrom}T00:00:00Z`) } : {}),
...(filters.dateTo ? { lte: new Date(`${filters.dateTo}T00:00:00Z`) } : {}),
} })
}
return and.length ? { AND: and } : undefined
}
function dataValues(input: SupplierRecommendedProductInput) {
return {
...input,
recommendationDate: input.recommendationDate ? new Date(`${input.recommendationDate}T00:00:00Z`) : null,
imageUrl: input.imageUrl || null,
}
}
export class SupplierRecommendedProductsRepository {
async listPage(params: { skip: number; take: number; filters: SupplierRecommendedProductFilters }) {
const where = buildWhere(params.filters)
const [rows, total] = await Promise.all([
prisma.supplierRecommendedProduct.findMany({ where, select: ITEM_SELECT, orderBy: [{ recommendationDate: 'desc' }, { createdAt: 'desc' }], skip: params.skip, take: params.take }),
prisma.supplierRecommendedProduct.count({ where }),
])
return { rows, total }
}
async listOptions() {
const [suppliers, purchasers, shippingModes] = await Promise.all([
prisma.supplierRecommendedProduct.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }),
prisma.supplierRecommendedProduct.findMany({ select: { purchaser: true }, distinct: ['purchaser'], orderBy: { purchaser: 'asc' } }),
prisma.supplierRecommendedProduct.findMany({ select: { shippingMode: true }, distinct: ['shippingMode'], orderBy: { shippingMode: 'asc' } }),
])
return {
suppliers: suppliers.map((row) => row.supplier.trim()).filter(Boolean),
purchasers: purchasers.map((row) => row.purchaser.trim()).filter(Boolean),
shippingModes: shippingModes.map((row) => row.shippingMode.trim()).filter(Boolean),
}
}
async findById(id: string) {
return prisma.supplierRecommendedProduct.findUnique({ where: { id }, select: ITEM_SELECT })
}
async create(input: SupplierRecommendedProductInput, actor: { id: string; name: string }) {
return prisma.supplierRecommendedProduct.create({ data: { ...dataValues(input), createdById: actor.id, createdByName: actor.name, updatedById: actor.id, updatedByName: actor.name }, select: ITEM_SELECT })
}
async update(id: string, input: SupplierRecommendedProductInput, actor: { id: string; name: string }) {
return prisma.supplierRecommendedProduct.update({ where: { id }, data: { ...dataValues(input), updatedById: actor.id, updatedByName: actor.name }, select: ITEM_SELECT })
}
async createLog(data: { itemId: string; operation: string; actorId: string; actorName: string; changes: unknown }) {
await prisma.supplierRecommendedProductChangeLog.create({ data: { ...data, changes: data.changes as Prisma.InputJsonValue } })
}
async listLogs(itemId: string, take = 200) {
return prisma.supplierRecommendedProductChangeLog.findMany({ where: { itemId }, select: { id: true, operation: true, actorName: true, changes: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take })
}
}
export const supplierRecommendedProductsRepository = new SupplierRecommendedProductsRepository()

View File

@ -0,0 +1,138 @@
import {
EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS,
SUPPLIER_RECOMMENDED_PRODUCT_FIELDS,
supplierRecommendedProductInputSchema,
type SupplierRecommendedProductChange,
type SupplierRecommendedProductFilters,
type SupplierRecommendedProductImportProgress,
type SupplierRecommendedProductInput,
} from '@/lib/supplier-recommended-products'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { supplierRecommendedProductsRepository, type SupplierRecommendedProductRecord, type SupplierRecommendedProductsRepository } from '@/server/repositories/supplier-recommended-products.repository'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
const MAX_PAGE_SIZE = 200
function dateText(value: Date | string | null | undefined) {
if (!value) return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10)
}
function cleanFilters(input?: Partial<SupplierRecommendedProductFilters>): SupplierRecommendedProductFilters {
return Object.fromEntries(Object.keys(EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS).map((key) => [key, String(input?.[key as keyof SupplierRecommendedProductFilters] ?? '').trim().slice(0, 200)])) as SupplierRecommendedProductFilters
}
function comparable(input: SupplierRecommendedProductInput | SupplierRecommendedProductRecord) {
return {
recommendationDate: dateText(input.recommendationDate), supplier: input.supplier, purchaser: input.purchaser,
productCode: input.productCode, imageUrl: input.imageUrl ?? '', sampleRequest: input.sampleRequest,
dimensions: input.dimensions, price: input.price, material: input.material, boardGrade: input.boardGrade,
weight: input.weight, orderQuantity: input.orderQuantity, productionCycle: input.productionCycle,
shippingMode: input.shippingMode, dropShippingFee: input.dropShippingFee,
} satisfies Record<string, string | number | null>
}
function changeSet(before: SupplierRecommendedProductInput | SupplierRecommendedProductRecord | null, after: SupplierRecommendedProductInput): SupplierRecommendedProductChange[] {
const previous = before ? comparable(before) : {}
const next = comparable(after)
return SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.flatMap((field) => {
const beforeValue = previous[field.key as keyof typeof previous] ?? null
const afterValue = next[field.key]
return beforeValue === afterValue ? [] : [{ field: field.key, label: field.label, before: beforeValue, after: afterValue }]
})
}
function toRow(record: SupplierRecommendedProductRecord) {
return {
id: record.id,
recommendationDate: dateText(record.recommendationDate), supplier: record.supplier, purchaser: record.purchaser,
productCode: record.productCode, imageUrl: record.imageUrl ?? '', sampleRequest: record.sampleRequest,
dimensions: record.dimensions, price: record.price, material: record.material, boardGrade: record.boardGrade,
weight: record.weight, orderQuantity: record.orderQuantity, productionCycle: record.productionCycle,
shippingMode: record.shippingMode, dropShippingFee: record.dropShippingFee,
createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), updatedByName: record.updatedByName,
}
}
function parseInput(input: unknown, rowNumber?: number) {
const parsed = supplierRecommendedProductInputSchema.safeParse(input)
if (parsed.success) return parsed.data
const message = parsed.error.issues[0]?.message ?? '数据格式不正确'
throw new AppError(ErrorCode.VALIDATION_FAILED, rowNumber ? `第${rowNumber}行:${message}` : message)
}
export class SupplierRecommendedProductsService {
constructor(private readonly repository: SupplierRecommendedProductsRepository = supplierRecommendedProductsRepository) {}
async getAccess(user: AuthUser) {
return supplyChainPermissionSettingsService.getUserAccess(user, 'purchaseSupplierRecommendedProducts')
}
private async requireAccess(user: AuthUser, action: 'view' | 'download' | 'edit') {
const access = await this.getAccess(user)
if (!access[action]) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有供应商新品推荐权限')
return access
}
async listPage(params: { page?: number; pageSize?: number; filters?: Partial<SupplierRecommendedProductFilters>; user: AuthUser }) {
await this.requireAccess(params.user, 'view')
const page = Math.max(1, Math.trunc(params.page || 1))
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(params.pageSize || 50)))
const filters = cleanFilters(params.filters)
const [result, options] = await Promise.all([
this.repository.listPage({ skip: (page - 1) * pageSize, take: pageSize, filters }),
this.repository.listOptions(),
])
return { rows: result.rows.map(toRow), pageInfo: { page, pageSize, total: result.total, totalPages: Math.max(1, Math.ceil(result.total / pageSize)) }, options }
}
async create(input: unknown, user: AuthUser) {
await this.requireAccess(user, 'edit')
const values = parseInput(input)
const record = await this.repository.create(values, user)
await this.repository.createLog({ itemId: record.id, operation: 'CREATE', actorId: user.id, actorName: user.name, changes: changeSet(null, values) })
return toRow(record)
}
async update(id: string, input: unknown, user: AuthUser) {
await this.requireAccess(user, 'edit')
const current = await this.repository.findById(id.trim())
if (!current) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商新品推荐记录不存在')
const values = parseInput(input)
const changes = changeSet(current, values)
if (!changes.length) return toRow(current)
const record = await this.repository.update(current.id, values, user)
await this.repository.createLog({ itemId: record.id, operation: 'UPDATE', actorId: user.id, actorName: user.name, changes })
return toRow(record)
}
async importRows(rows: Array<Record<string, unknown>>, user: AuthUser, onProgress?: (progress: SupplierRecommendedProductImportProgress) => void) {
await this.requireAccess(user, 'edit')
if (!rows.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据')
if (rows.length > 5_000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行')
let created = 0
for (let index = 0; index < rows.length; index += 1) {
const values = parseInput(rows[index], index + 2)
const record = await this.repository.create(values, user)
await this.repository.createLog({ itemId: record.id, operation: 'IMPORT_CREATE', actorId: user.id, actorName: user.name, changes: changeSet(null, values) })
created += 1
onProgress?.({ processed: index + 1, total: rows.length, created, updated: 0 })
}
return { created, updated: 0 }
}
async listChangeLogs(itemId: string, user: AuthUser) {
await this.requireAccess(user, 'view')
if (!await this.repository.findById(itemId.trim())) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商新品推荐记录不存在')
const logs = await this.repository.listLogs(itemId.trim())
return logs.map((log) => ({ id: log.id, operation: log.operation, actorName: log.actorName, changes: log.changes as unknown as SupplierRecommendedProductChange[], createdAt: log.createdAt.toISOString() }))
}
async requireTemplateAccess(user: AuthUser) {
await this.requireAccess(user, 'download')
}
}
export const supplierRecommendedProductsService = new SupplierRecommendedProductsService()

View File

@ -117,6 +117,7 @@ class SupplyChainPermissionSettingsService {
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseSupplierInformation',
'purchaseSupplierRecommendedProducts',
'purchaseDailyAffairs',
'purchasePriceChange',
'scriptMonitor',
@ -139,6 +140,7 @@ class SupplyChainPermissionSettingsService {
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseSupplierInformation',
'purchaseSupplierRecommendedProducts',
'purchaseDailyAffairs',
'purchasePriceChange',
'scriptMonitor',