From 170d776aa6f8e44a3349b46ac6afdeb85b8f4b3d Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 15 Sep 2026 14:44:48 +0800 Subject: [PATCH] feat(supplier-recommendations): add guided editor options --- .../category-options/route.test.ts | 96 +++++++ .../category-options/route.ts | 33 +++ .../purchaser-options/route.test.ts | 67 +++++ .../purchaser-options/route.ts | 16 ++ .../supplier-options/route.test.ts | 77 +++++ .../supplier-options/route.ts | 21 ++ .../upload/route.test.ts | 269 ++++++++++++++++++ .../upload/route.ts | 73 +++++ .../SupplierRecommendedProductsClient.tsx | 2 +- .../SupplierRecommendedProductEditor.test.tsx | 114 +++++++- .../SupplierRecommendedProductEditor.tsx | 130 ++++++++- .../supplier-recommended-products.css | 8 + .../uploadImageToServer.ts | 51 ++++ .../supplier-recommended-products.service.ts | 10 + 14 files changed, 956 insertions(+), 11 deletions(-) create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/uploadImageToServer.ts diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.test.ts new file mode 100644 index 00000000..a4fd0668 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.test.ts @@ -0,0 +1,96 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest = jest.fn() +const mockList = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (data: unknown, status = 200, requestId?: string) => + new Response(JSON.stringify({ success: true, data, meta: { requestId } }), { + status, + headers: { 'content-type': 'application/json' }, + }), + jsonError: (code: string, message?: string, status = 400) => + new Response(JSON.stringify({ success: false, error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' }, + }), +})) + +jest.mock('@/server/services/supply-chain-product-archive-category-config.service', () => ({ + supplyChainProductArchiveCategoryConfigService: { + list: (...args: unknown[]) => mockList(...args), + }, +})) + +const { GET } = require('./route') +const { CATEGORY_CONFIG_ACTIVE } = require('@/lib/supply-chain-product-archive-category-config') + +function buildPage(items: Array<{ categoryL2: string }>, page: number, total: number) { + return { + items: items.map((item, index) => ({ + id: `${page}-${index}`, + categoryL2: item.categoryL2, + categoryL1: '一级类目', + status: 'ACTIVE', + createdAt: '2026-09-15T00:00:00.000Z', + updatedAt: '2026-09-15T00:00:00.000Z', + })), + pageInfo: { page, pageSize: 200, total, totalPages: Math.max(1, Math.ceil(total / 200)) }, + } +} + +describe('GET /api/supply-chain/supplier-recommended-products/category-options', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ requestId: 'req-category-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }), + ) + }) + + it('返回启用二级类目(去重、按中文排序)且只查启用状态', async () => { + mockList.mockResolvedValue( + buildPage( + [{ categoryL2: '床头柜' }, { categoryL2: '书架' }, { categoryL2: '床头柜' }, { categoryL2: '沙发' }], + 1, + 3, + ), + ) + + const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any) + const payload = await response.json() + + expect(payload).toEqual( + expect.objectContaining({ + success: true, + data: { categories: ['床头柜', '沙发', '书架'] }, + }), + ) + expect(mockList).toHaveBeenCalledTimes(1) + expect(mockList).toHaveBeenCalledWith({ page: 1, pageSize: 200, status: CATEGORY_CONFIG_ACTIVE }) + }) + + it('类目超过一页时继续翻页直到取完', async () => { + const firstPageItems = Array.from({ length: 200 }, (_, index) => ({ categoryL2: `类目${String(index).padStart(3, '0')}` })) + mockList + .mockResolvedValueOnce(buildPage(firstPageItems, 1, 201)) + .mockResolvedValueOnce(buildPage([{ categoryL2: '补充类目' }], 2, 201)) + + const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any) + const payload = await response.json() + + expect(mockList).toHaveBeenCalledTimes(2) + expect(mockList).toHaveBeenNthCalledWith(2, { page: 2, pageSize: 200, status: CATEGORY_CONFIG_ACTIVE }) + expect(payload.data.categories).toHaveLength(201) + expect(payload.data.categories).toContain('补充类目') + }) + + it('要求登录后访问', async () => { + const request = { nextUrl: { searchParams: new URLSearchParams() } } as any + await GET(request) + + expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true }) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.ts new file mode 100644 index 00000000..c3307ba6 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/category-options/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from 'next/server' +import { CATEGORY_CONFIG_ACTIVE } from '@/lib/supply-chain-product-archive-category-config' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { supplyChainProductArchiveCategoryConfigService } from '@/server/services/supply-chain-product-archive-category-config.service' + +const CATEGORY_OPTIONS_PAGE_SIZE = 200 +const CATEGORY_OPTIONS_MAX_PAGES = 10 + +// GET: 供应商新品推荐编辑器用的启用二级类目选项(登录即可,权限走页面自身映射 view)。 +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const categoryL2Set = new Set() + for (let page = 1; page <= CATEGORY_OPTIONS_MAX_PAGES; page++) { + const result = await supplyChainProductArchiveCategoryConfigService.list({ + page, + pageSize: CATEGORY_OPTIONS_PAGE_SIZE, + status: CATEGORY_CONFIG_ACTIVE, + }) + for (const item of result.items) { + const categoryL2 = item.categoryL2.trim() + if (categoryL2) categoryL2Set.add(categoryL2) + } + if (page * CATEGORY_OPTIONS_PAGE_SIZE >= result.pageInfo.total) break + } + + const categories = [...categoryL2Set].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')) + return jsonSuccess({ categories }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.test.ts new file mode 100644 index 00000000..b6daae12 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.test.ts @@ -0,0 +1,67 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest = jest.fn() +const mockListPurchaserOptions = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (data: unknown, status = 200, requestId?: string) => + new Response(JSON.stringify({ success: true, data, meta: { requestId } }), { + status, + headers: { 'content-type': 'application/json' }, + }), + jsonError: (code: string, message?: string, status = 400) => + new Response(JSON.stringify({ success: false, error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' }, + }), +})) + +jest.mock('@/server/services/supplier-recommended-products.service', () => ({ + supplierRecommendedProductsService: { + listPurchaserOptions: (...args: unknown[]) => mockListPurchaserOptions(...args), + }, +})) + +const { GET } = require('./route') + +describe('GET /api/supply-chain/supplier-recommended-products/purchaser-options', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ requestId: 'req-purchaser-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }), + ) + }) + + it('返回 PURCHASING 业务角色的在职采购用户列表', async () => { + mockListPurchaserOptions.mockResolvedValueOnce([ + { id: 'p1', name: '采购甲', departmentName: '采购部' }, + { id: 'p2', name: '采购乙', departmentName: '' }, + ]) + + const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any) + const payload = await response.json() + + expect(mockListPurchaserOptions).toHaveBeenCalledTimes(1) + expect(payload).toEqual( + expect.objectContaining({ + success: true, + data: { + purchasers: [ + { id: 'p1', name: '采购甲', departmentName: '采购部' }, + { id: 'p2', name: '采购乙', departmentName: '' }, + ], + }, + }), + ) + }) + + it('要求登录后访问(权限走页面自身 view 映射)', async () => { + const request = { nextUrl: { searchParams: new URLSearchParams() } } as any + await GET(request) + + expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true }) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.ts new file mode 100644 index 00000000..05ed03db --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/purchaser-options/route.ts @@ -0,0 +1,16 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +// GET: 供应商新品推荐编辑器的采购点选项(系统内 PURCHASING 业务角色的在职用户)。 +// 权限走页面自身映射(GET -> view@purchaseSupplierRecommendedProducts)。 +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const purchasers = await supplierRecommendedProductsService.listPurchaserOptions() + return jsonSuccess({ purchasers }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.test.ts new file mode 100644 index 00000000..35e269bd --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.test.ts @@ -0,0 +1,77 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest = jest.fn() +const mockSearchEnabledSuppliers = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (data: unknown, status = 200, requestId?: string) => + new Response(JSON.stringify({ success: true, data, meta: { requestId } }), { + status, + headers: { 'content-type': 'application/json' }, + }), + jsonError: (code: string, message?: string, status = 400) => + new Response(JSON.stringify({ success: false, error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' }, + }), +})) + +jest.mock('@/server/services/external-supplier-directory.service', () => ({ + externalSupplierDirectoryService: { + searchEnabledSuppliers: (...args: unknown[]) => mockSearchEnabledSuppliers(...args), + }, +})) + +const { GET } = require('./route') + +describe('GET /api/supply-chain/supplier-recommended-products/supplier-options', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ requestId: 'req-supplier-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }), + ) + }) + + it('按关键词搜索系统供应商目录', async () => { + mockSearchEnabledSuppliers.mockResolvedValueOnce([ + { supplierCode: 'S001', supplierName: '系统供应商甲' }, + { supplierCode: 'S002', supplierName: '系统供应商乙' }, + ]) + + const response = await GET({ + nextUrl: { searchParams: new URLSearchParams([['query', ' 供应商 ']]) }, + } as any) + const payload = await response.json() + + expect(mockSearchEnabledSuppliers).toHaveBeenCalledWith('供应商', 20) + expect(payload).toEqual( + expect.objectContaining({ + success: true, + data: { + suppliers: [ + { supplierCode: 'S001', supplierName: '系统供应商甲' }, + { supplierCode: 'S002', supplierName: '系统供应商乙' }, + ], + }, + }), + ) + }) + + it('关键词为空时不查询目录、直接返回空列表', async () => { + const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any) + const payload = await response.json() + + expect(mockSearchEnabledSuppliers).not.toHaveBeenCalled() + expect(payload).toEqual(expect.objectContaining({ success: true, data: { suppliers: [] } })) + }) + + it('要求登录后访问(权限走页面自身 view 映射)', async () => { + const request = { nextUrl: { searchParams: new URLSearchParams() } } as any + await GET(request) + + expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true }) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.ts new file mode 100644 index 00000000..0ed87748 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/supplier-options/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { externalSupplierDirectoryService } from '@/server/services/external-supplier-directory.service' + +// GET: 供应商新品推荐编辑器的供应商点选建议(系统供应商目录搜索)。 +// 系统里还没有的供应商允许手动填写,因此这里只提供搜索建议,不做强制校验。 +// 权限走页面自身映射(GET -> view@purchaseSupplierRecommendedProducts),采购员可查。 +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const query = request.nextUrl.searchParams.get('query')?.trim().slice(0, 100) ?? '' + const suppliers = query + ? await externalSupplierDirectoryService.searchEnabledSuppliers(query, 20) + : [] + + return jsonSuccess({ suppliers }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.test.ts new file mode 100644 index 00000000..914e785b --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.test.ts @@ -0,0 +1,269 @@ +export {} + +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' +import { AppError, ErrorCode } from '@/server/core/errors' + +const mockHandleRequest = jest.fn() +const mockUploadBufferToObjectStorage = jest.fn() +const uploadedObjectBodies = new Map() + +jest.mock('@/server/core/response', () => { + return { + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (data: unknown, status = 200, requestId?: string) => + new Response( + JSON.stringify({ + success: true, + data, + meta: { + requestId, + timestamp: '2026-09-14T00:00:00.000Z', + }, + }), + { + status, + headers: { 'content-type': 'application/json' }, + }, + ), + jsonError: (code: string, message?: string, status = 400, requestId?: string) => + new Response( + JSON.stringify({ + success: false, + error: { code, message }, + meta: { + requestId, + timestamp: '2026-09-14T00:00:00.000Z', + }, + }), + { + status, + headers: { 'content-type': 'application/json' }, + }, + ), + } +}) + +jest.mock('@/lib/storage', () => ({ + uploadBufferToObjectStorage: async (...args: unknown[]) => { + const url = await mockUploadBufferToObjectStorage(...args) + const key = String(args[1] ?? '') + uploadedObjectBodies.set(key, Buffer.from(args[0] as Buffer)) + return url + }, +})) + +const { POST } = require('./route') + +function createUploadFile( + bytes: Uint8Array, + fileName: string, + type: string, + sizeOverride?: number, +) { + const file = new File([bytes], fileName, { type }) + if (typeof sizeOverride === 'number') { + Object.defineProperty(file, 'size', { value: sizeOverride }) + } + Object.defineProperty(file, 'arrayBuffer', { + value: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ), + }) + return file +} + +describe('POST /api/supply-chain/supplier-recommended-products/upload', () => { + beforeEach(() => { + jest.clearAllMocks() + uploadedObjectBodies.clear() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-supplier-upload', + user: { + id: 'user-1', + email: 'buyer@example.com', + name: 'Buyer', + role: 'USER', + status: 'ACTIVE', + }, + }), + ) + mockUploadBufferToObjectStorage.mockResolvedValue( + '/api/storage/object?key=supplier-recommended-images%2Fmock', + ) + }) + + afterEach(() => { + uploadedObjectBodies.clear() + }) + + it('stores an uploaded image and returns the object storage url', async () => { + const imageBytes = new TextEncoder().encode('supplier-image-body') + + const response = await POST({ + formData: async () => { + const formData = new FormData() + formData.append('file', createUploadFile(imageBytes, 'photo.PNG', 'image/png')) + return formData + }, + } as any) + const payload = await response.json() + + expect(response.status).toBe(201) + expect(payload).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + fileName: 'photo.PNG', + mimeType: 'image/png', + size: imageBytes.length, + storageKey: expect.stringMatching(/^supplier-recommended-images\/.+\.(png|PNG)$/), + url: expect.stringContaining('/api/storage/object?key='), + }), + }), + ) + expect(mockUploadBufferToObjectStorage).toHaveBeenCalledWith( + Buffer.from(imageBytes), + expect.stringMatching(/^supplier-recommended-images\//), + 'image/png', + ) + expect( + uploadedObjectBodies.get(payload.data.storageKey)?.toString('utf8'), + ).toBe('supplier-image-body') + }) + + it('rejects the request when no file is provided', async () => { + const response = await POST({ + formData: async () => new FormData(), + } as any) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: ErrorCode.VALIDATION_REQUIRED_FIELD, + message: '请选择要上传的图片', + }), + }), + ) + expect(mockUploadBufferToObjectStorage).not.toHaveBeenCalled() + }) + + it('rejects empty files', async () => { + const response = await POST({ + formData: async () => { + const formData = new FormData() + formData.append('file', createUploadFile(new Uint8Array(0), 'empty.png', 'image/png')) + return formData + }, + } as any) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: ErrorCode.VALIDATION_FAILED, + message: '文件不能为空', + }), + }), + ) + }) + + it('rejects non-image files', async () => { + const response = await POST({ + formData: async () => { + const formData = new FormData() + formData.append('file', createUploadFile(new TextEncoder().encode('pdf'), 'doc.pdf', 'application/pdf')) + return formData + }, + } as any) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: ErrorCode.VALIDATION_FAILED, + message: '仅支持上传图片文件', + }), + }), + ) + expect(mockUploadBufferToObjectStorage).not.toHaveBeenCalled() + }) + + it('rejects images above the 25MB limit', async () => { + const response = await POST({ + formData: async () => { + const formData = new FormData() + formData.append( + 'file', + createUploadFile( + new TextEncoder().encode('too-large'), + 'too-large.png', + 'image/png', + 26 * 1024 * 1024, + ), + ) + return formData + }, + } as any) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: ErrorCode.FILE_TOO_LARGE, + message: '图片过大,最大允许 25MB。', + }), + }), + ) + expect(mockUploadBufferToObjectStorage).not.toHaveBeenCalled() + }) + + it('wraps object storage failures with a readable message', async () => { + mockUploadBufferToObjectStorage.mockRejectedValueOnce(new Error('bucket unreachable')) + + await expect( + POST({ + formData: async () => { + const formData = new FormData() + formData.append( + 'file', + createUploadFile(new TextEncoder().encode('image'), 'photo.png', 'image/png'), + ) + return formData + }, + } as any), + ).rejects.toMatchObject({ + message: '图片上传到对象存储失败:bucket unreachable', + }) + }) + + it('requires an authenticated user', async () => { + const request = { formData: async () => new FormData() } as any + await POST(request) + + expect(mockHandleRequest).toHaveBeenCalledWith( + request, + expect.any(Function), + { requireAuth: true }, + ) + }) + + it('propagates auth errors raised by handleRequest', async () => { + mockHandleRequest.mockImplementationOnce(async () => { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限') + }) + + await expect( + POST({ + formData: async () => new FormData(), + } as any), + ).rejects.toMatchObject({ message: '没有权限' }) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.ts new file mode 100644 index 00000000..4d403f0f --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/upload/route.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'crypto' +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { uploadBufferToObjectStorage } from '@/lib/storage' + +export const SUPPLIER_RECOMMENDED_IMAGE_STORAGE_PREFIX = 'supplier-recommended-images' + +const SUPPLIER_RECOMMENDED_IMAGE_MAX_BYTES = 25 * 1024 * 1024 + +function sanitizeImageExtension(fileName: string) { + const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')).trim().toLowerCase() : '' + if (!/^\.[a-z0-9.]+$/.test(extension)) return '' + return extension +} + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async ({ requestId }) => { + const formData = await request.formData() + const file = formData.get('file') + + if (!(file instanceof File)) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要上传的图片', 400, requestId) + } + + if (file.size <= 0) { + return jsonError(ErrorCode.VALIDATION_FAILED, '文件不能为空', 400, requestId) + } + + if (!file.type.startsWith('image/')) { + return jsonError(ErrorCode.VALIDATION_FAILED, '仅支持上传图片文件', 400, requestId) + } + + if (file.size > SUPPLIER_RECOMMENDED_IMAGE_MAX_BYTES) { + return jsonError(ErrorCode.FILE_TOO_LARGE, '图片过大,最大允许 25MB。', 400, requestId) + } + + const extension = sanitizeImageExtension(file.name) || '.png' + const storageKey = `${SUPPLIER_RECOMMENDED_IMAGE_STORAGE_PREFIX}/${Date.now()}-${randomUUID()}${extension}` + const bytes = await file.arrayBuffer() + + try { + const url = await uploadBufferToObjectStorage( + Buffer.from(bytes), + storageKey, + file.type || 'application/octet-stream', + ) + + return jsonSuccess( + { + url, + fileName: file.name, + mimeType: file.type || 'application/octet-stream', + size: file.size, + storageKey, + }, + 201, + requestId, + ) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + if (error instanceof Error) { + error.message = `图片上传到对象存储失败:${detail}` + throw error + } + throw new Error(`图片上传到对象存储失败:${detail}`) + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx index c9fd87ea..efc11ce3 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx @@ -283,7 +283,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} /> } - {editor && setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />} + {editor && setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />} {detail && setDetail(null)} onEdit={(row) => { setDetail(null); setEditor(row) }} onDelete={(row) => { setDetail(null); remove(row) }} onHistory={(row) => { setDetail(null); void openHistory(row) }} onPreview={(row) => setPreview(row)} />} {logs && setLogs(null)} />} {importTask.state.status !== 'idle' && } diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.test.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.test.tsx index d81a8830..d8f90e73 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.test.tsx @@ -1,14 +1,31 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { SupplierRecommendedProductEditor } from './SupplierRecommendedProductEditor' -jest.mock('@/app/intake/non-integrated/workspaces/documents/uploadRecordHelpers', () => ({ - uploadFileToServer: jest.fn(async () => ({ url: '/api/storage/object?key=supplier-recommended-images%2Fpaste.png', fileName: 'paste.png', mimeType: 'image/png' })), +jest.mock('../uploadImageToServer', () => ({ + uploadSupplierRecommendedImageToServer: jest.fn(async () => ({ url: '/api/storage/object?key=supplier-recommended-images%2Fpaste.png', fileName: 'paste.png', mimeType: 'image/png' })), })) const ITEM = { id: 'recommended-1', recommendationDate: '2026-09-02', category: '床头柜', supplier: '供应商A', purchaser: '采购员', productCode: 'A-001', imageUrl: '', sampleRequest: '', dimensions: '', price: '', material: '', boardGrade: '', weight: '', orderQuantity: 0, productionCycle: '', shippingMode: '', dropShippingFee: '', createdAt: '2026-09-02T01:00:00.000Z', updatedAt: '2026-09-02T01:00:00.000Z', updatedByName: '采购员', } +function jsonResponse(data: unknown, ok = true) { + return { ok, status: ok ? 200 : 500, json: async () => ({ success: ok, data }) } as unknown as Response +} + +const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/category-options')) return jsonResponse({ categories: ['床头柜', '书架', '意式沙发'] }) + if (url.includes('/supplier-options')) return jsonResponse({ suppliers: [{ supplierCode: 'S001', supplierName: '系统供应商甲' }] }) + if (url.includes('/purchaser-options')) return jsonResponse({ purchasers: [{ id: 'p1', name: '采购甲', departmentName: '采购部' }, { id: 'p2', name: '采购乙', departmentName: '' }] }) + return jsonResponse({}) +}) + +beforeEach(() => { + jest.clearAllMocks() + globalThis.fetch = fetchMock as unknown as typeof fetch +}) + describe('SupplierRecommendedProductEditor 图片粘贴上传', () => { it('在抽屉内粘贴截图后自动上传并回填图片 URL', async () => { render() @@ -32,8 +49,8 @@ describe('SupplierRecommendedProductEditor 图片粘贴上传', () => { }) it('上传失败时展示错误提示', async () => { - const { uploadFileToServer } = jest.requireMock('@/app/intake/non-integrated/workspaces/documents/uploadRecordHelpers') as { uploadFileToServer: jest.Mock } - uploadFileToServer.mockRejectedValueOnce(new Error('存储服务不可用')) + const { uploadSupplierRecommendedImageToServer } = jest.requireMock('../uploadImageToServer') as { uploadSupplierRecommendedImageToServer: jest.Mock } + uploadSupplierRecommendedImageToServer.mockRejectedValueOnce(new Error('存储服务不可用')) render() const urlInput = screen.getByPlaceholderText(/Ctrl\+V 粘贴截图/) @@ -43,3 +60,92 @@ describe('SupplierRecommendedProductEditor 图片粘贴上传', () => { await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('存储服务不可用')) }) }) + +describe('SupplierRecommendedProductEditor 点选字段', () => { + it('新增时日期自动填充当天、当前登录人是采购时默认选中', async () => { + render() + + const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()) + expect(screen.getByLabelText('日期(当天)')).toHaveValue(today) + await waitFor(() => expect(screen.getByLabelText('采购')).toHaveValue('采购甲')) + }) + + it('采购下拉展示系统 PURCHASING 用户的采购人列表', async () => { + render() + + const select = screen.getByLabelText('采购') as HTMLSelectElement + await waitFor(() => expect(Array.from(select.options).map((option) => option.value)).toEqual(expect.arrayContaining(['采购甲', '采购乙']))) + expect(select).toHaveValue('') + }) + + it('申请寄样不在表单中体现', () => { + render() + + expect(screen.queryByText('申请寄样')).not.toBeInTheDocument() + }) + + it('类目下拉展示系统类目配置的二级类目', async () => { + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/category-options'))) + const select = screen.getByLabelText('类目') as HTMLSelectElement + await waitFor(() => expect(Array.from(select.options).map((option) => option.value)).toEqual(expect.arrayContaining(['床头柜', '书架', '意式沙发']))) + }) + + it('编辑历史记录时类目下拉保留旧值', async () => { + render() + const select = screen.getByLabelText('类目') as HTMLSelectElement + + expect(select).toHaveValue('床头柜') + }) + + it('供应商输入为空时聚焦不弹出建议,避免历史脏值污染目录', () => { + render() + + const input = screen.getByLabelText('供应商') + fireEvent.focus(input) + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/supplier-options')) + }) + + it('供应商输入时展示系统供应商建议并支持点选回填', async () => { + render() + + const input = screen.getByLabelText('供应商') + fireEvent.change(input, { target: { value: '系统' } }) + + await waitFor(() => expect(screen.getByRole('option', { name: '系统供应商甲' })).toBeInTheDocument()) + fireEvent.mouseDown(screen.getByRole('option', { name: '系统供应商甲' })) + expect(input).toHaveValue('系统供应商甲') + }) + + it('供应商允许手动填写系统里没有的名称', () => { + render() + + const input = screen.getByLabelText('供应商') + fireEvent.change(input, { target: { value: '系统里没有的供应商' } }) + + expect(input).toHaveValue('系统里没有的供应商') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + }) + + it('发货模式下拉提供代发、入库、代发/入库', () => { + render() + + const select = screen.getByLabelText('发货模式') as HTMLSelectElement + const values = Array.from(select.options).map((option) => option.value) + expect(values).toEqual(expect.arrayContaining(['代发', '入库', '代发/入库'])) + }) + + it('板材等级支持点选并保留手写值', () => { + render() + + const input = screen.getByLabelText('板材等级') + expect(input).toHaveValue('少见等级') + const datalist = document.getElementById('recommended-board-grade-options') as HTMLDataListElement | null + expect(datalist).not.toBeNull() + const optionValues = Array.from(datalist?.options ?? []).map((option) => option.value) + expect(optionValues).toEqual(expect.arrayContaining(['ENF级', '非ENF级', 'E0级', '少见等级'])) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx index 87b446a6..a11fe9f3 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx @@ -2,22 +2,140 @@ import Image from 'next/image' import { Save, X } from 'lucide-react' -import { useState } from 'react' -import { uploadFileToServer } from '@/app/intake/non-integrated/workspaces/documents/uploadRecordHelpers' +import { useEffect, useMemo, useRef, useState } from 'react' +import { uploadSupplierRecommendedImageToServer } from '../uploadImageToServer' import type { SupplierRecommendedProductInput, SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products' +const BOARD_GRADE_OPTIONS = ['ENF级', '非ENF级', 'E0级'] +const SHIPPING_MODE_OPTIONS = ['代发', '入库', '代发/入库'] +const SUPPLIER_SUGGESTION_LIMIT = 20 +const CATEGORY_OPTIONS_ENDPOINT = '/api/supply-chain/supplier-recommended-products/category-options' +const SUPPLIER_OPTIONS_ENDPOINT = '/api/supply-chain/supplier-recommended-products/supplier-options' +const PURCHASER_OPTIONS_ENDPOINT = '/api/supply-chain/supplier-recommended-products/purchaser-options' + +// 日期就是当天(北京时间),新增时自动填充。 +function todayInBeijing() { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()) +} + const EMPTY: SupplierRecommendedProductInput = { recommendationDate: null, category: '', 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 } + +function toDraft(row: SupplierRecommendedProductRow | null): SupplierRecommendedProductInput { + if (!row) { + return { ...EMPTY, recommendationDate: todayInBeijing() } + } + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, updatedByName: _updatedByName, ...input } = row + return input +} + +// 点选字段兜底:编辑历史记录时,把旧值保留在下拉选项里,避免显示丢失。 +function withCurrentOption(options: string[], current: string) { + const trimmed = current.trim() + if (!trimmed || options.includes(trimmed)) return options + return [trimmed, ...options] +} function imageFilesFromClipboard(clipboardData: DataTransfer | null) { return Array.from(clipboardData?.files ?? []).filter((file) => file.type.startsWith('image/')) } -export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { item: SupplierRecommendedProductRow | null; onClose: () => void; onSaved: () => void }) { +export function SupplierRecommendedProductEditor({ item, supplierHistory = [], currentUser, onClose, onSaved }: { item: SupplierRecommendedProductRow | null; supplierHistory?: string[]; currentUser?: { id: string; name: string }; onClose: () => void; onSaved: () => void }) { const [draft, setDraft] = useState(() => toDraft(item)); const [saving, setSaving] = useState(false) const [uploadingImageName, setUploadingImageName] = useState('') const [imageError, setImageError] = useState('') + const [categoryOptions, setCategoryOptions] = useState([]) + const [purchaserOptions, setPurchaserOptions] = useState>([]) + const [supplierSuggestions, setSupplierSuggestions] = useState([]) + const [supplierPanelOpen, setSupplierPanelOpen] = useState(false) + const supplierRequestRef = useRef(0) const set = (key: K, value: SupplierRecommendedProductInput[K]) => setDraft((current) => ({ ...current, [key]: value })) + + // 类目选项来自系统类目配置(启用状态的二级类目),登录即可读。 + useEffect(() => { + let cancelled = false + void (async () => { + try { + const response = await fetch(CATEGORY_OPTIONS_ENDPOINT) + const payload = await response.json().catch(() => null) + if (cancelled || !response.ok || !payload?.success || !Array.isArray(payload.data?.categories)) return + const categories = (payload.data.categories as unknown[]).filter((value): value is string => typeof value === 'string' && value.trim() !== '') + if (!cancelled) setCategoryOptions(categories) + } catch { /* 类目选项拉取失败时保留当前值,不阻塞表单 */ } + })() + return () => { cancelled = true } + }, []) + + // 采购选项 = 系统内 PURCHASING 业务角色的在职用户;新增时当前登录人是采购则默认选中。 + useEffect(() => { + let cancelled = false + void (async () => { + try { + const response = await fetch(PURCHASER_OPTIONS_ENDPOINT) + const payload = await response.json().catch(() => null) + if (cancelled || !response.ok || !payload?.success || !Array.isArray(payload.data?.purchasers)) return + const purchasers = (payload.data.purchasers as unknown[]) + .filter((entry): entry is { id: string; name: string; departmentName?: string } => Boolean(entry) && typeof (entry as { name?: unknown }).name === 'string' && (entry as { name: string }).name.trim() !== '') + .map((entry) => ({ id: String(entry.id ?? ''), name: entry.name.trim(), departmentName: typeof entry.departmentName === 'string' ? entry.departmentName : '' })) + if (cancelled) return + setPurchaserOptions(purchasers) + if (!item && !draft.purchaser && currentUser?.name && purchasers.some((purchaser) => purchaser.name === currentUser.name)) { + setDraft((current) => (current.purchaser ? current : { ...current, purchaser: currentUser.name })) + } + } catch { /* 采购选项拉取失败时保留当前值,不阻塞表单 */ } + })() + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 供应商建议 = 系统供应商目录搜索 + 本表历史填写值;系统里没有的直接手动填写原样保存。 + useEffect(() => { + const query = draft.supplier.trim().slice(0, 100) + if (!supplierPanelOpen || !query) { + supplierRequestRef.current += 1 + setSupplierSuggestions([]) + return + } + const timer = setTimeout(() => { + const requestId = ++supplierRequestRef.current + void (async () => { + try { + const response = await fetch(`${SUPPLIER_OPTIONS_ENDPOINT}?query=${encodeURIComponent(query)}`) + const payload = await response.json().catch(() => null) + if (supplierRequestRef.current !== requestId) return + const names = response.ok && payload?.success && Array.isArray(payload.data?.suppliers) + ? (payload.data.suppliers as unknown[]) + .map((entry) => (entry && typeof entry === 'object' && typeof (entry as { supplierName?: unknown }).supplierName === 'string' ? (entry as { supplierName: string }).supplierName : '')) + .filter(Boolean) + : [] + setSupplierSuggestions(names) + } catch { + if (supplierRequestRef.current === requestId) setSupplierSuggestions([]) + } + })() + }, 250) + return () => clearTimeout(timer) + }, [draft.supplier, supplierPanelOpen]) + + const mergedSupplierSuggestions = useMemo(() => { + const query = draft.supplier.trim().toLowerCase() + // 空输入不展示任何建议,避免历史脏值污染系统供应商目录。 + if (!query) return [] + const merged: string[] = [] + for (const name of [...supplierSuggestions, ...supplierHistory]) { + const trimmed = name.trim() + if (!trimmed || merged.includes(trimmed)) continue + if (!trimmed.toLowerCase().includes(query)) continue + merged.push(trimmed) + if (merged.length >= SUPPLIER_SUGGESTION_LIMIT) break + } + return merged + }, [supplierSuggestions, supplierHistory, draft.supplier]) + + const categorySelectOptions = useMemo(() => withCurrentOption(categoryOptions, draft.category), [categoryOptions, draft.category]) + const purchaserSelectOptions = useMemo(() => withCurrentOption(purchaserOptions.map((purchaser) => purchaser.name), draft.purchaser), [purchaserOptions, draft.purchaser]) + const shippingModeSelectOptions = useMemo(() => withCurrentOption(SHIPPING_MODE_OPTIONS, draft.shippingMode), [draft.shippingMode]) + const boardGradeOptions = useMemo(() => withCurrentOption(BOARD_GRADE_OPTIONS, draft.boardGrade), [draft.boardGrade]) + 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) } } const uploadImageFiles = async (files: File[]) => { const image = files.find((file) => file.type.startsWith('image/')) @@ -25,7 +143,7 @@ export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { i setUploadingImageName(image.name) setImageError('') try { - const uploaded = await uploadFileToServer(image) + const uploaded = await uploadSupplierRecommendedImageToServer(image) set('imageUrl', uploaded.url) } catch (error) { setImageError(error instanceof Error ? error.message : '图片上传失败') @@ -39,6 +157,6 @@ export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { i event.preventDefault() void uploadImageFiles(files) } - return
{ if (event.target === event.currentTarget && !saving) onClose() }}>

{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}

供应商推荐信息人工维护,空白字段可保存

set('category', value)} /> set('supplier', value)} /> set('purchaser', value)} /> set('productCode', value)} /> set('dimensions', value)} /> set('price', value)} /> set('material', value)} /> set('boardGrade', value)} /> set('weight', value)} /> set('productionCycle', value)} /> set('shippingMode', value)} /> set('dropShippingFee', value)} /> set('sampleRequest', value)} />
{uploadingImageName && 上传中:{uploadingImageName}}{imageError && {imageError}}
{!uploadingImageName && draft.imageUrl && 图片预览}
+ return
{ if (event.target === event.currentTarget && !saving) onClose() }}>

{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}

供应商推荐信息人工维护,空白字段可保存

供应商 { set('supplier', event.target.value); setSupplierPanelOpen(true) }} onFocus={() => setSupplierPanelOpen(true)} onBlur={() => { setTimeout(() => setSupplierPanelOpen(false), 120) }} />{supplierPanelOpen && draft.supplier.trim() !== '' && mergedSupplierSuggestions.length > 0 &&
{mergedSupplierSuggestions.map((name) => )}
}
set('productCode', value)} /> set('dimensions', value)} /> set('price', value)} /> set('material', value)} /> set('weight', value)} /> set('productionCycle', value)} /> set('dropShippingFee', value)} />
{uploadingImageName && 上传中:{uploadingImageName}}{imageError && {imageError}}
{!uploadingImageName && draft.imageUrl && 图片预览}
} function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return } diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css index 7006d43f..11454eda 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplier-recommended-products.css @@ -51,3 +51,11 @@ .recommended-pk-config-add:disabled{opacity:.45;cursor:not-allowed} .recommended-pk-config-error{border:1px solid #fecaca;border-radius:6px;background:#fef2f2;color:#b91c1c;font-size:12px;padding:8px 12px} +/* ===== 编辑器点选字段(类目/采购/发货模式下拉、供应商建议面板) ===== */ +.recommended-field select{width:100%;box-sizing:border-box;height:40px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:0 10px;color:#27272a;font-size:13px;outline:none;cursor:pointer} +.recommended-field select:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)} +.recommended-supplier-field{position:relative} +.recommended-supplier-suggestions{position:absolute;left:0;right:0;top:100%;z-index:85;display:flex;max-height:220px;flex-direction:column;overflow-y:auto;border:1px solid #d4d4d8;border-radius:5px;background:#fff;box-shadow:0 12px 30px rgba(24,24,27,.14)} +.recommended-supplier-suggestions button{display:flex;align-items:center;border:0;background:none;padding:9px 12px;color:#27272a;font-size:12px;text-align:left;cursor:pointer} +.recommended-supplier-suggestions button:hover{background:#f4f4f5} + diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/uploadImageToServer.ts b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/uploadImageToServer.ts new file mode 100644 index 00000000..cd0c55d8 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/uploadImageToServer.ts @@ -0,0 +1,51 @@ +import { compressImageFileForUpload } from '@/lib/client-image-compression' +import { shouldUseRemoteAssetStorage } from '@/app/intake/non-integrated/workspaces/documents/uploadStorageHelpers' +import { readFileAsDataUrl } from '@/app/intake/non-integrated/workspaces/documents/uploadFilePreview' + +export type SupplierRecommendedImageUploadResult = { + url: string + fileName: string + mimeType: string +} + +const SUPPLIER_RECOMMENDED_IMAGE_TARGET_BYTES = 2 * 1024 * 1024 +const SUPPLIER_RECOMMENDED_IMAGE_HARD_LIMIT_BYTES = 20 * 1024 * 1024 +const SUPPLIER_RECOMMENDED_IMAGE_MAX_DIMENSION = 2400 + +export async function prepareSupplierRecommendedImageForUpload(file: File) { + return compressImageFileForUpload(file, { + targetBytes: SUPPLIER_RECOMMENDED_IMAGE_TARGET_BYTES, + hardLimitBytes: SUPPLIER_RECOMMENDED_IMAGE_HARD_LIMIT_BYTES, + maxDimension: SUPPLIER_RECOMMENDED_IMAGE_MAX_DIMENSION, + errorMessage: '图片压缩后仍超过当前上传入口限制,请换用更小的图片。', + }) +} + +export async function uploadSupplierRecommendedImageToServer(file: File): Promise { + if (!shouldUseRemoteAssetStorage()) { + return { + url: await readFileAsDataUrl(file), + fileName: file.name, + mimeType: file.type || 'application/octet-stream', + } + } + + const uploadFile = await prepareSupplierRecommendedImageForUpload(file) + const formData = new FormData() + formData.append('file', uploadFile) + + const response = await fetch('/api/supply-chain/supplier-recommended-products/upload', { + method: 'POST', + body: formData, + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) { + throw new Error(payload?.error?.message || '图片上传失败') + } + + return { + url: payload.data.url, + fileName: payload.data.fileName || uploadFile.name, + mimeType: payload.data.mimeType || uploadFile.type || 'application/octet-stream', + } +} diff --git a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts index 5b103ab3..b5ef41a9 100644 --- a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts +++ b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts @@ -290,6 +290,16 @@ export class SupplierRecommendedProductsService { })) } + // 采购字段点选项:系统内 PURCHASING 业务角色的在职用户(与选款人用 OPERATIONS 角色同一个口径)。 + async listPurchaserOptions() { + const users = await userService.listAll({ businessRole: 'PURCHASING', status: 'ACTIVE' }) + return users.map((purchaser) => ({ + id: purchaser.id, + name: purchaser.name, + departmentName: purchaser.department?.name ?? '', + })) + } + 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, '当前账号没有供应商新品推荐权限')