add sku mapping

This commit is contained in:
2026-04-14 13:45:28 +08:00
parent b647680576
commit b1cd4ea0eb
16 changed files with 3176 additions and 0 deletions
@@ -0,0 +1,567 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\SkuMapping;
use App\Model\SkuOrigin;
use App\Model\Store;
use App\Service\OperationLogService;
use App\Service\SkuService;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
/**
* SKU 平台映射管理接口
*/
#[OA\Tag(name: 'SkuMappings', description: 'SKU 平台映射管理')]
#[Controller(prefix: "/api/v1/sku-mappings")]
#[Middleware(AuthMiddleware::class)]
#[Middleware(PermissionMiddleware::class)]
class SkuMappingController extends AbstractController
{
public function __construct(
protected readonly SkuService $skuService,
) {}
/**
* 映射列表
*/
#[OA\Get(
path: '/sku-mappings',
summary: 'SKU 映射列表',
description: '获取 SKU 平台映射列表,支持分页和多维度筛选',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15, maximum: 100)),
new OA\Parameter(name: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'platform_id', in: 'query', required: false, description: '平台 ID 精确筛选', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'store_id', in: 'query', required: false, description: '店铺 ID 精确筛选', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'origin_sku', in: 'query', required: false, description: '原始 SKU 模糊搜索', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'platform_outer_sku', in: 'query', required: false, description: '平台侧 SKU 模糊搜索', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'enabled', in: 'query', required: false, description: '启用状态', schema: new OA\Schema(type: 'boolean')),
],
responses: [
new OA\Response(
response: 200,
description: '获取成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '获取成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: '#/components/schemas/SkuMapping')),
new OA\Property(property: 'total', type: 'integer', example: 100),
new OA\Property(property: 'page', type: 'integer', example: 1),
new OA\Property(property: 'per_page', type: 'integer', example: 15),
], type: 'object'),
])
),
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "", methods: "GET")]
public function index(): array
{
$query = SkuMapping::query()->select([
'id', 'company_id', 'platform_id', 'store_id', 'origin_sku',
'origin_sku_id', 'platform_outer_sku', 'platform_product_id',
'enabled', 'note', 'created_at', 'updated_at',
]);
$this->applyDataScope($query);
// 筛选条件
$filters = [
'company_id' => 'exact',
'platform_id' => 'exact',
'store_id' => 'exact',
];
foreach ($filters as $field => $type) {
$value = $this->request->input($field);
if ($value !== null && $value !== '') {
$query->where($field, (int) $value);
}
}
$origin_sku = $this->request->input('origin_sku');
if ($origin_sku !== null && $origin_sku !== '') {
$query->where('origin_sku', 'ilike', "%{$origin_sku}%");
}
$platform_outer_sku = $this->request->input('platform_outer_sku');
if ($platform_outer_sku !== null && $platform_outer_sku !== '') {
$query->where('platform_outer_sku', 'ilike', "%{$platform_outer_sku}%");
}
$enabled = $this->request->input('enabled');
if ($enabled !== null && $enabled !== '') {
$query->where('enabled', filter_var($enabled, FILTER_VALIDATE_BOOLEAN));
}
$query->orderBy('created_at', 'desc');
$per_page = min(max((int) $this->request->input('per_page', 15), 1), 100);
$page = max((int) $this->request->input('page', 1), 1);
$total = $query->count();
$items = $query->offset(($page - 1) * $per_page)->limit($per_page)->get();
return [
'code' => 0,
'message' => '获取成功',
'data' => [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
],
];
}
/**
* 检查重复映射
*/
#[OA\Get(
path: '/sku-mappings/check-duplicate',
summary: '检查同平台重复映射',
description: '检查指定 origin_sku 在某平台下是否已存在映射记录,帮助用户决定是否复用已有编码',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
parameters: [
new OA\Parameter(name: 'origin_sku_id', in: 'query', required: true, description: '内部 SKU ID', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'platform_id', in: 'query', required: true, description: '平台 ID', schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: '检查完成',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'has_duplicate', type: 'boolean', example: true),
new OA\Property(property: 'existing_mappings', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'message', type: 'string'),
], type: 'object'),
])
),
new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "check-duplicate", methods: "GET")]
public function checkDuplicate(): ResponseInterface|array
{
$origin_sku_id = $this->request->input('origin_sku_id');
$platform_id = $this->request->input('platform_id');
if (!$origin_sku_id || !$platform_id) {
return $this->response->json([
'code' => 422,
'message' => '缺少必填参数: origin_sku_id, platform_id',
])->withStatus(422);
}
$result = $this->skuService->checkDuplicate((int) $origin_sku_id, (int) $platform_id);
return [
'code' => 0,
'message' => 'success',
'data' => $result,
];
}
/**
* 自动生成 platform_outer_sku
*/
#[OA\Post(
path: '/sku-mappings/generate-sku',
summary: '自动生成平台侧 SKU',
description: '根据指定策略自动生成 platform_outer_sku,同时返回重复检测结果',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['origin_sku_id', 'platform_id', 'strategy'],
properties: [
new OA\Property(property: 'origin_sku_id', type: 'integer', example: 1),
new OA\Property(property: 'platform_id', type: 'integer', example: 1),
new OA\Property(property: 'strategy', type: 'string', enum: ['prefix', 'prefix_random', 'manual'], example: 'prefix'),
new OA\Property(property: 'prefix', type: 'string', example: 'C03'),
new OA\Property(property: 'random_length', type: 'integer', example: 4),
new OA\Property(property: 'manual_value', type: 'string', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 200,
description: '生成成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'generated_sku', type: 'string', example: 'C03_0032'),
new OA\Property(property: 'strategy_record', type: 'string', example: 'prefix:C03'),
new OA\Property(property: 'has_duplicate', type: 'boolean'),
new OA\Property(property: 'existing_mappings', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'message', type: 'string'),
], type: 'object'),
])
),
new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "generate-sku", methods: "POST")]
public function generateSku(): ResponseInterface|array
{
$data = $this->request->all();
$required = ['origin_sku_id', 'platform_id', 'strategy'];
foreach ($required as $field) {
if (!isset($data[$field]) || $data[$field] === '') {
return $this->response->json([
'code' => 422,
'message' => "缺少必填参数: {$field}",
])->withStatus(422);
}
}
$origin_sku_id = (int) $data['origin_sku_id'];
$platform_id = (int) $data['platform_id'];
$strategy = $data['strategy'];
$prefix = $data['prefix'] ?? '';
$random_length = (int) ($data['random_length'] ?? 4);
$manual_value = $data['manual_value'] ?? null;
// 查找 origin_sku 记录
$sku_origin = SkuOrigin::query()->find($origin_sku_id);
if (!$sku_origin) {
return $this->response->json([
'code' => 422,
'message' => '指定的内部 SKU 不存在',
])->withStatus(422);
}
// 生成 platform_outer_sku
$generated_sku = $this->skuService->generatePlatformOuterSku(
strategy: $strategy,
origin_sku: $sku_origin->sku,
prefix: $prefix,
random_length: $random_length,
manual_value: $manual_value,
);
$strategy_record = $this->skuService->buildStrategyRecord($strategy, $prefix, $random_length);
// 检查重复
$duplicate_check = $this->skuService->checkDuplicate($origin_sku_id, $platform_id);
return [
'code' => 0,
'message' => 'success',
'data' => [
'generated_sku' => $generated_sku,
'strategy_record' => $strategy_record,
'has_duplicate' => $duplicate_check['has_duplicate'],
'existing_mappings' => $duplicate_check['existing_mappings'],
'message' => $duplicate_check['message'],
],
];
}
/**
* 映射详情
*/
#[OA\Get(
path: '/sku-mappings/{id}',
summary: 'SKU 映射详情',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: '获取成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '获取成功'),
new OA\Property(property: 'data', ref: '#/components/schemas/SkuMapping'),
])
),
new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "{id}", methods: "GET")]
public function show(int $id): ResponseInterface|array
{
$query = SkuMapping::query();
$this->applyDataScope($query);
$record = $query->where('id', $id)->first();
if (!$record) {
return $this->response->json([
'code' => 404,
'message' => '数据不存在',
])->withStatus(404);
}
return [
'code' => 0,
'message' => '获取成功',
'data' => $record,
];
}
/**
* 创建映射
*/
#[OA\Post(
path: '/sku-mappings',
summary: '创建 SKU 映射',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['company_id', 'platform_id', 'origin_sku', 'platform_product_id'],
properties: [
new OA\Property(property: 'company_id', type: 'integer', example: 3),
new OA\Property(property: 'platform_id', type: 'integer', example: 1),
new OA\Property(property: 'store_id', type: 'integer', nullable: true, example: 101),
new OA\Property(property: 'origin_sku', type: 'string', example: '0032'),
new OA\Property(property: 'origin_sku_id', type: 'integer', nullable: true, example: 1),
new OA\Property(property: 'platform_product_id', type: 'string', example: 'ITEM-001'),
new OA\Property(property: 'platform_outer_sku', type: 'string', nullable: true, example: 'C03_0032'),
new OA\Property(property: 'generation_strategy', type: 'string', nullable: true, example: 'prefix:C03'),
new OA\Property(property: 'warehouse_id', type: 'integer', nullable: true),
new OA\Property(property: 'enabled', type: 'boolean', example: true),
new OA\Property(property: 'note', type: 'string', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: '创建成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '创建成功'),
new OA\Property(property: 'data', ref: '#/components/schemas/SkuMapping'),
])
),
new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "", methods: "POST")]
public function store(): ResponseInterface|array
{
$data = $this->request->all();
$required_fields = ['company_id', 'platform_id', 'origin_sku', 'platform_product_id'];
foreach ($required_fields as $field) {
if (!isset($data[$field]) || $data[$field] === '') {
return $this->response->json([
'code' => 422,
'message' => "缺少必填字段: {$field}",
])->withStatus(422);
}
}
$record = SkuMapping::query()->create([
'company_id' => (int) $data['company_id'],
'platform_id' => (int) $data['platform_id'],
'store_id' => isset($data['store_id']) ? (int) $data['store_id'] : null,
'origin_sku' => $data['origin_sku'],
'origin_sku_id' => isset($data['origin_sku_id']) ? (int) $data['origin_sku_id'] : null,
'platform_product_id' => $data['platform_product_id'],
'platform_outer_sku' => $data['platform_outer_sku'] ?? null,
'generation_strategy' => $data['generation_strategy'] ?? null,
'warehouse_id' => isset($data['warehouse_id']) ? (int) $data['warehouse_id'] : null,
'enabled' => $data['enabled'] ?? true,
'note' => $data['note'] ?? null,
]);
$user_id = OperationLogService::getCurrentUserId();
if ($user_id) {
OperationLogService::log(
$user_id,
'create',
'sku_mapping',
$record->id,
'创建 SKU 映射: ' . $data['origin_sku'] . ' → ' . ($data['platform_outer_sku'] ?? 'N/A'),
ip: $this->request->getHeaderLine('x-real-ip') ?: null,
);
}
return $this->response->json([
'code' => 0,
'message' => '创建成功',
'data' => $record,
])->withStatus(201);
}
/**
* 更新映射
*/
#[OA\Put(
path: '/sku-mappings/{id}',
summary: '更新 SKU 映射',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(properties: [
new OA\Property(property: 'origin_sku', type: 'string'),
new OA\Property(property: 'origin_sku_id', type: 'integer', nullable: true),
new OA\Property(property: 'platform_product_id', type: 'string'),
new OA\Property(property: 'platform_outer_sku', type: 'string', nullable: true),
new OA\Property(property: 'generation_strategy', type: 'string', nullable: true),
new OA\Property(property: 'store_id', type: 'integer', nullable: true),
new OA\Property(property: 'warehouse_id', type: 'integer', nullable: true),
new OA\Property(property: 'enabled', type: 'boolean'),
new OA\Property(property: 'note', type: 'string', nullable: true),
])
),
responses: [
new OA\Response(
response: 200,
description: '更新成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '更新成功'),
new OA\Property(property: 'data', ref: '#/components/schemas/SkuMapping'),
])
),
new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "{id}", methods: "PUT")]
public function update(int $id): ResponseInterface|array
{
$query = SkuMapping::query();
$this->applyDataScope($query);
$record = $query->where('id', $id)->first();
if (!$record) {
return $this->response->json([
'code' => 404,
'message' => '数据不存在',
])->withStatus(404);
}
$data = $this->request->all();
$allowed_fields = [
'origin_sku', 'origin_sku_id', 'platform_product_id', 'platform_outer_sku',
'generation_strategy', 'store_id', 'warehouse_id', 'enabled', 'note',
];
$update_data = array_intersect_key($data, array_flip($allowed_fields));
$record->update($update_data);
$user_id = OperationLogService::getCurrentUserId();
if ($user_id) {
OperationLogService::log(
$user_id,
'update',
'sku_mapping',
$record->id,
"更新 SKU 映射 #{$record->id}",
detail: ['updated_fields' => array_keys($update_data)],
ip: $this->request->getHeaderLine('x-real-ip') ?: null,
);
}
return [
'code' => 0,
'message' => '更新成功',
'data' => $record->fresh(),
];
}
/**
* 删除映射
*/
#[OA\Delete(
path: '/sku-mappings/{id}',
summary: '删除 SKU 映射',
security: [['bearerAuth' => []]],
tags: ['SkuMappings'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: '删除成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '删除成功'),
])
),
new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "{id}", methods: "DELETE")]
public function destroy(int $id): ResponseInterface|array
{
$query = SkuMapping::query();
$this->applyDataScope($query);
$record = $query->where('id', $id)->first();
if (!$record) {
return $this->response->json([
'code' => 404,
'message' => '数据不存在',
])->withStatus(404);
}
$record->delete();
$user_id = OperationLogService::getCurrentUserId();
if ($user_id) {
OperationLogService::log(
$user_id,
'delete',
'sku_mapping',
$id,
"删除 SKU 映射 #{$id}",
ip: $this->request->getHeaderLine('x-real-ip') ?: null,
);
}
return [
'code' => 0,
'message' => '删除成功',
];
}
/**
* DataScope 过滤
*/
private function applyDataScope(\Hyperf\Database\Model\Builder $query): void
{
$scope_type = $this->request->getAttribute('scope_type');
$scope_ids = $this->request->getAttribute('scope_ids', []);
if ($scope_type === 'store') {
$query->whereIn('store_id', $scope_ids);
} elseif ($scope_type === 'platform') {
$query->whereIn('platform_id', $scope_ids);
}
}
}
@@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\SkuOrigin;
use App\Service\OperationLogService;
use App\Service\SkuService;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
/**
* 客户内部 SKU 管理接口
*/
#[OA\Tag(name: 'SkuOrigins', description: '客户内部 SKU 管理')]
#[Controller(prefix: "/api/v1/sku-origins")]
#[Middleware(AuthMiddleware::class)]
#[Middleware(PermissionMiddleware::class)]
class SkuOriginController extends AbstractController
{
public function __construct(
protected readonly SkuService $skuService,
) {}
/**
* SKU 列表
*/
#[OA\Get(
path: '/sku-origins',
summary: '客户内部 SKU 列表',
description: '获取客户内部 SKU 列表,支持分页和筛选',
security: [['bearerAuth' => []]],
tags: ['SkuOrigins'],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15, maximum: 100)),
new OA\Parameter(name: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'sku', in: 'query', required: false, description: 'SKU 编码模糊搜索', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'name', in: 'query', required: false, description: '产品名称模糊搜索', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'barcode', in: 'query', required: false, description: '条形码模糊搜索', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: '获取成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '获取成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: '#/components/schemas/SkuOrigin')),
new OA\Property(property: 'total', type: 'integer', example: 100),
new OA\Property(property: 'page', type: 'integer', example: 1),
new OA\Property(property: 'per_page', type: 'integer', example: 15),
], type: 'object'),
])
),
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "", methods: "GET")]
public function index(): array
{
$query = SkuOrigin::query()->select([
'id', 'company_id', 'sku', 'barcode', 'name', 'label', 'hs', 'created_at', 'updated_at',
]);
// DataScope 过滤
$this->applyDataScope($query);
// 筛选条件
$company_id = $this->request->input('company_id');
if ($company_id !== null && $company_id !== '') {
$query->where('company_id', (int) $company_id);
}
$sku = $this->request->input('sku');
if ($sku !== null && $sku !== '') {
$query->where('sku', 'ilike', "%{$sku}%");
}
$name = $this->request->input('name');
if ($name !== null && $name !== '') {
$query->where(function ($q) use ($name): void {
$q->where('name', 'ilike', "%{$name}%")
->orWhere('label', 'ilike', "%{$name}%");
});
}
$barcode = $this->request->input('barcode');
if ($barcode !== null && $barcode !== '') {
$query->where('barcode', 'ilike', "%{$barcode}%");
}
$query->orderBy('created_at', 'desc');
// 分页
$per_page = min(max((int) $this->request->input('per_page', 15), 1), 100);
$page = max((int) $this->request->input('page', 1), 1);
$total = $query->count();
$items = $query->offset(($page - 1) * $per_page)->limit($per_page)->get();
return [
'code' => 0,
'message' => '获取成功',
'data' => [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
],
];
}
/**
* SKU 详情
*/
#[OA\Get(
path: '/sku-origins/{id}',
summary: '客户内部 SKU 详情',
security: [['bearerAuth' => []]],
tags: ['SkuOrigins'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: '获取成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '获取成功'),
new OA\Property(property: 'data', ref: '#/components/schemas/SkuOrigin'),
])
),
new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "{id}", methods: "GET")]
public function show(int $id): ResponseInterface|array
{
$query = SkuOrigin::query();
$this->applyDataScope($query);
$record = $query->where('id', $id)->first();
if (!$record) {
return $this->response->json([
'code' => 404,
'message' => '数据不存在',
])->withStatus(404);
}
return [
'code' => 0,
'message' => '获取成功',
'data' => $record,
];
}
/**
* 创建 SKU
*/
#[OA\Post(
path: '/sku-origins',
summary: '创建客户内部 SKU',
security: [['bearerAuth' => []]],
tags: ['SkuOrigins'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['company_id', 'sku', 'barcode', 'name'],
properties: [
new OA\Property(property: 'company_id', type: 'integer', example: 3),
new OA\Property(property: 'sku', type: 'string', example: '0032'),
new OA\Property(property: 'barcode', type: 'string', example: '6901234567890'),
new OA\Property(property: 'name', type: 'string', example: 'Running Shoes Model A'),
new OA\Property(property: 'label', type: 'string', nullable: true, example: '跑步鞋 A 款'),
new OA\Property(property: 'hs', type: 'string', nullable: true, example: '6403990090'),
new OA\Property(property: 'ledger', type: 'string', nullable: true),
new OA\Property(property: 'ext', type: 'object', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: '创建成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '创建成功'),
new OA\Property(property: 'data', ref: '#/components/schemas/SkuOrigin'),
])
),
new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "", methods: "POST")]
public function store(): ResponseInterface|array
{
$data = $this->request->all();
// 参数校验
$required_fields = ['company_id', 'sku', 'barcode', 'name'];
foreach ($required_fields as $field) {
if (!isset($data[$field]) || $data[$field] === '') {
return $this->response->json([
'code' => 422,
'message' => "缺少必填字段: {$field}",
])->withStatus(422);
}
}
// 唯一性校验 (company_id + sku)
$exists = SkuOrigin::query()
->where('company_id', (int) $data['company_id'])
->where('sku', $data['sku'])
->exists();
if ($exists) {
return $this->response->json([
'code' => 422,
'message' => "该公司下 SKU '{$data['sku']}' 已存在",
])->withStatus(422);
}
$record = SkuOrigin::query()->create([
'company_id' => (int) $data['company_id'],
'sku' => $data['sku'],
'hs' => $data['hs'] ?? null,
'barcode' => $data['barcode'],
'name' => $data['name'],
'label' => $data['label'] ?? null,
'ledger' => $data['ledger'] ?? null,
'ext' => $data['ext'] ?? null,
]);
// 记录操作日志
$user_id = OperationLogService::getCurrentUserId();
if ($user_id) {
OperationLogService::log(
$user_id,
'create',
'sku_origin',
$record->id,
"创建内部 SKU: {$record->sku}",
ip: $this->request->getHeaderLine('x-real-ip') ?: null,
);
}
return $this->response->json([
'code' => 0,
'message' => '创建成功',
'data' => $record,
])->withStatus(201);
}
/**
* 更新 SKU
*/
#[OA\Put(
path: '/sku-origins/{id}',
summary: '更新客户内部 SKU',
security: [['bearerAuth' => []]],
tags: ['SkuOrigins'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(properties: [
new OA\Property(property: 'sku', type: 'string'),
new OA\Property(property: 'barcode', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'label', type: 'string', nullable: true),
new OA\Property(property: 'hs', type: 'string', nullable: true),
new OA\Property(property: 'ledger', type: 'string', nullable: true),
new OA\Property(property: 'ext', type: 'object', nullable: true),
])
),
responses: [
new OA\Response(
response: 200,
description: '更新成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '更新成功'),
new OA\Property(property: 'data', ref: '#/components/schemas/SkuOrigin'),
])
),
new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "{id}", methods: "PUT")]
public function update(int $id): ResponseInterface|array
{
$query = SkuOrigin::query();
$this->applyDataScope($query);
$record = $query->where('id', $id)->first();
if (!$record) {
return $this->response->json([
'code' => 404,
'message' => '数据不存在',
])->withStatus(404);
}
$data = $this->request->all();
$allowed_fields = ['sku', 'barcode', 'name', 'label', 'hs', 'ledger', 'ext'];
$update_data = array_intersect_key($data, array_flip($allowed_fields));
// 如果修改了 sku,检查唯一性
if (isset($update_data['sku']) && $update_data['sku'] !== $record->sku) {
$exists = SkuOrigin::query()
->where('company_id', $record->company_id)
->where('sku', $update_data['sku'])
->where('id', '!=', $id)
->exists();
if ($exists) {
return $this->response->json([
'code' => 422,
'message' => "该公司下 SKU '{$update_data['sku']}' 已存在",
])->withStatus(422);
}
}
$record->update($update_data);
$user_id = OperationLogService::getCurrentUserId();
if ($user_id) {
OperationLogService::log(
$user_id,
'update',
'sku_origin',
$record->id,
"更新内部 SKU: {$record->sku}",
detail: ['updated_fields' => array_keys($update_data)],
ip: $this->request->getHeaderLine('x-real-ip') ?: null,
);
}
return [
'code' => 0,
'message' => '更新成功',
'data' => $record->fresh(),
];
}
/**
* 删除 SKU
*/
#[OA\Delete(
path: '/sku-origins/{id}',
summary: '删除客户内部 SKU',
description: '删除前检查是否有映射引用,有引用时禁止删除',
security: [['bearerAuth' => []]],
tags: ['SkuOrigins'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: '删除成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '删除成功'),
])
),
new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 409, description: '存在映射引用', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "{id}", methods: "DELETE")]
public function destroy(int $id): ResponseInterface|array
{
$query = SkuOrigin::query();
$this->applyDataScope($query);
$record = $query->where('id', $id)->first();
if (!$record) {
return $this->response->json([
'code' => 404,
'message' => '数据不存在',
])->withStatus(404);
}
// 检查映射引用
if ($this->skuService->hasReferences($id)) {
return $this->response->json([
'code' => 409,
'message' => '该 SKU 存在映射引用,请先删除相关映射记录',
])->withStatus(409);
}
$sku_value = $record->sku;
$record->delete();
$user_id = OperationLogService::getCurrentUserId();
if ($user_id) {
OperationLogService::log(
$user_id,
'delete',
'sku_origin',
$id,
"删除内部 SKU: {$sku_value}",
ip: $this->request->getHeaderLine('x-real-ip') ?: null,
);
}
return [
'code' => 0,
'message' => '删除成功',
];
}
/**
* DataScope 过滤(基于 company_id
*/
private function applyDataScope(\Hyperf\Database\Model\Builder $query): void
{
$scope_type = $this->request->getAttribute('scope_type');
$scope_ids = $this->request->getAttribute('scope_ids', []);
if ($scope_type === 'store') {
$company_ids = \App\Model\Store::query()
->whereIn('id', $scope_ids)
->distinct()
->pluck('company_id')
->toArray();
$query->whereIn('company_id', $company_ids);
} elseif ($scope_type === 'platform') {
$company_ids = \App\Model\Store::query()
->whereIn('platform_id', $scope_ids)
->distinct()
->pluck('company_id')
->toArray();
$query->whereIn('company_id', $company_ids);
}
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Hyperf\Database\Model\Relations\BelongsTo;
use Hyperf\DbConnection\Model\Model;
use OpenApi\Attributes as OA;
/**
* @property int $id 主键
* @property int $company_id 公司 ID
* @property int $platform_id 平台 ID
* @property string $platform_product_id 平台商品 ID
* @property string $origin_sku 客户侧匹配的 SKU 编码
* @property int|null $origin_sku_id 关联 skus_origin.id
* @property string|null $platform_outer_sku 平台侧使用的 SKU 编码
* @property string|null $generation_strategy 生成策略记录
* @property int|null $store_id 店铺 ID
* @property int|null $warehouse_id 仓库 ID
* @property boolean $enabled 是否启用
* @property string|null $note 备注
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
#[OA\Schema(
schema: 'SkuMapping',
description: 'SKU 平台映射',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'company_id', type: 'integer', example: 3),
new OA\Property(property: 'platform_id', type: 'integer', example: 1),
new OA\Property(property: 'platform_product_id', type: 'string', example: 'ITEM-001'),
new OA\Property(property: 'origin_sku', type: 'string', example: '0032'),
new OA\Property(property: 'origin_sku_id', type: 'integer', nullable: true, example: 1),
new OA\Property(property: 'platform_outer_sku', type: 'string', nullable: true, example: 'C03_0032'),
new OA\Property(property: 'generation_strategy', type: 'string', nullable: true, example: 'prefix:C03'),
new OA\Property(property: 'store_id', type: 'integer', nullable: true, example: 101),
new OA\Property(property: 'warehouse_id', type: 'integer', nullable: true, example: 1),
new OA\Property(property: 'enabled', type: 'boolean', example: true),
new OA\Property(property: 'note', type: 'string', nullable: true, example: '用于 Shopee 马来站'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
]
)]
class SkuMapping extends Model
{
/**
* The table associated with the model.
*/
protected ?string $table = 'skus_mapping';
/**
* The attributes that are mass assignable.
*/
protected array $fillable = [
'company_id',
'platform_id',
'platform_product_id',
'origin_sku',
'origin_sku_id',
'platform_outer_sku',
'generation_strategy',
'store_id',
'warehouse_id',
'enabled',
'note',
];
/**
* The attributes that should be cast to native types.
*/
protected array $casts = [
'id' => 'integer',
'company_id' => 'integer',
'platform_id' => 'integer',
'origin_sku_id' => 'integer',
'store_id' => 'integer',
'warehouse_id' => 'integer',
'enabled' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function company(): BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
public function platform(): BelongsTo
{
return $this->belongsTo(Platform::class, 'platform_id');
}
public function store(): BelongsTo
{
return $this->belongsTo(Store::class, 'store_id');
}
public function skuOrigin(): BelongsTo
{
return $this->belongsTo(SkuOrigin::class, 'origin_sku_id');
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Hyperf\Database\Model\Relations\BelongsTo;
use Hyperf\Database\Model\Relations\HasMany;
use Hyperf\DbConnection\Model\Model;
use OpenApi\Attributes as OA;
/**
* @property int $id 主键
* @property int $company_id 公司 ID
* @property string $sku 客户侧提供的 SKU
* @property string|null $hs 海关商品分类编码
* @property string $barcode 条形码/GTIN
* @property string $name 产品名称(英文)
* @property string|null $label 产品名称(中文)
* @property string|null $ledger 账册与批次备注
* @property array|null $ext 扩展字段
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
#[OA\Schema(
schema: 'SkuOrigin',
description: '客户内部 SKU',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'company_id', type: 'integer', example: 3),
new OA\Property(property: 'sku', type: 'string', example: '0032'),
new OA\Property(property: 'hs', type: 'string', nullable: true, example: '6403990090'),
new OA\Property(property: 'barcode', type: 'string', example: '6901234567890'),
new OA\Property(property: 'name', type: 'string', example: 'Running Shoes Model A'),
new OA\Property(property: 'label', type: 'string', nullable: true, example: '跑步鞋 A 款'),
new OA\Property(property: 'ledger', type: 'string', nullable: true, example: '批次 2026-Q1'),
new OA\Property(property: 'ext', type: 'object', nullable: true, description: '扩展字段'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
]
)]
class SkuOrigin extends Model
{
/**
* The table associated with the model.
*/
protected ?string $table = 'skus_origin';
/**
* The attributes that are mass assignable.
*/
protected array $fillable = [
'company_id',
'sku',
'hs',
'barcode',
'name',
'label',
'ledger',
'ext',
];
/**
* The attributes that should be cast to native types.
*/
protected array $casts = [
'id' => 'integer',
'company_id' => 'integer',
'ext' => 'json',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function company(): BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
public function mappings(): HasMany
{
return $this->hasMany(SkuMapping::class, 'origin_sku_id');
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Model\SkuMapping;
use App\Model\SkuOrigin;
use Hyperf\Stringable\Str;
/**
* SKU 业务逻辑服务
*
* 负责 platform_outer_sku 的自动生成、重复检测等业务逻辑
*/
class SkuService
{
/**
* 生成 platform_outer_sku
*
* @param string $strategy 生成策略:prefix | prefix_random | manual
* @param string $origin_sku 原始 SKU 编码
* @param string $prefix 前缀
* @param int $random_length 随机部分长度(仅 prefix_random 模式)
* @param string|null $manual_value 手动输入值(仅 manual 模式)
* @return string 生成的 platform_outer_sku
*/
public function generatePlatformOuterSku(
string $strategy,
string $origin_sku,
string $prefix = '',
int $random_length = 4,
?string $manual_value = null,
): string {
return match ($strategy) {
'prefix' => $this->generatePrefixSku($prefix, $origin_sku),
'prefix_random' => $this->generatePrefixRandomSku($prefix, $random_length),
'manual' => $manual_value ?? '',
default => throw new \InvalidArgumentException("不支持的生成策略: {$strategy}"),
};
}
/**
* 前缀模式:{prefix}_{origin_sku}
*/
private function generatePrefixSku(string $prefix, string $origin_sku): string
{
if ($prefix === '') {
return $origin_sku;
}
return "{$prefix}_{$origin_sku}";
}
/**
* 前缀+随机模式:{prefix}_{random_string}
*/
private function generatePrefixRandomSku(string $prefix, int $random_length): string
{
$random_part = Str::upper(Str::random($random_length));
if ($prefix === '') {
return $random_part;
}
return "{$prefix}_{$random_part}";
}
/**
* 构建生成策略记录字符串
*/
public function buildStrategyRecord(string $strategy, string $prefix = '', int $random_length = 4): string
{
return match ($strategy) {
'prefix' => "prefix:{$prefix}",
'prefix_random' => "prefix_random:{$prefix}:{$random_length}",
'manual' => 'manual',
default => $strategy,
};
}
/**
* 检查某个 origin_sku 在指定平台下是否已存在映射记录
*
* @return array{has_duplicate: bool, existing_mappings: array, message: string}
*/
public function checkDuplicate(int $origin_sku_id, int $platform_id): array
{
$existing = SkuMapping::query()
->where('origin_sku_id', $origin_sku_id)
->where('platform_id', $platform_id)
->where('enabled', true)
->get(['id', 'store_id', 'platform_outer_sku', 'note']);
$has_duplicate = $existing->isNotEmpty();
$message = $has_duplicate
? "该 origin_sku 在当前平台下已有 {$existing->count()} 条映射记录,可直接复用已有的 platform_outer_sku"
: '';
return [
'has_duplicate' => $has_duplicate,
'existing_mappings' => $existing->toArray(),
'message' => $message,
];
}
/**
* 检查 origin_sku 是否有映射引用(用于删除前检查)
*/
public function hasReferences(int $origin_sku_id): bool
{
return SkuMapping::query()
->where('origin_sku_id', $origin_sku_id)
->exists();
}
/**
* 检查 platform_outer_sku 在指定平台内是否唯一
*/
public function isPlatformOuterSkuUnique(string $platform_outer_sku, int $platform_id, ?int $exclude_id = null): bool
{
$query = SkuMapping::query()
->where('platform_outer_sku', $platform_outer_sku)
->where('platform_id', $platform_id);
if ($exclude_id !== null) {
$query->where('id', '!=', $exclude_id);
}
return !$query->exists();
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Hyperf\Database\Schema\Schema;
use Hyperf\Database\Schema\Blueprint;
use Hyperf\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('skus_mapping', function (Blueprint $table) {
$table->bigInteger('origin_sku_id')->nullable()->comment('关联 skus_origin.idFK');
$table->text('platform_outer_sku')->nullable()->comment('平台侧使用的 SKU 编码');
$table->text('generation_strategy')->nullable()->comment('生成策略记录,如 prefix:C03、prefix_random:C03:4、manual');
$table->index(['company_id', 'origin_sku_id', 'platform_id'], 'idx_company_origin_platform');
$table->index(['platform_outer_sku', 'platform_id'], 'idx_outer_sku_platform');
$table->foreign('origin_sku_id')
->references('id')
->on('skus_origin')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('skus_mapping', function (Blueprint $table) {
$table->dropForeign(['origin_sku_id']);
$table->dropIndex('idx_company_origin_platform');
$table->dropIndex('idx_outer_sku_platform');
$table->dropColumn(['origin_sku_id', 'platform_outer_sku', 'generation_strategy']);
});
}
};
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace HyperfTest\Cases\Unit\Service;
use App\Service\SkuService;
use HyperfTest\TestCase;
/**
* SkuService 单元测试
*
* 验证 SKU 自动生成策略和辅助方法
*
* @internal
* @coversNothing
*/
class SkuServiceTest extends TestCase
{
private SkuService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new SkuService();
}
public function test_generate_prefix_sku(): void
{
$result = $this->service->generatePlatformOuterSku(
strategy: 'prefix',
origin_sku: '0032',
prefix: 'C03',
);
$this->assertSame('C03_0032', $result);
}
public function test_generate_prefix_sku_without_prefix(): void
{
$result = $this->service->generatePlatformOuterSku(
strategy: 'prefix',
origin_sku: '0032',
prefix: '',
);
$this->assertSame('0032', $result);
}
public function test_generate_prefix_random_sku(): void
{
$result = $this->service->generatePlatformOuterSku(
strategy: 'prefix_random',
origin_sku: '0032',
prefix: 'C03',
random_length: 4,
);
$this->assertStringStartsWith('C03_', $result);
// prefix(3) + _(1) + random(4) = 8
$this->assertSame(8, strlen($result));
}
public function test_generate_prefix_random_sku_without_prefix(): void
{
$result = $this->service->generatePlatformOuterSku(
strategy: 'prefix_random',
origin_sku: '0032',
prefix: '',
random_length: 6,
);
// 纯随机部分,6 个字符
$this->assertSame(6, strlen($result));
}
public function test_generate_manual_sku(): void
{
$result = $this->service->generatePlatformOuterSku(
strategy: 'manual',
origin_sku: '0032',
manual_value: 'CUSTOM_SKU_001',
);
$this->assertSame('CUSTOM_SKU_001', $result);
}
public function test_generate_manual_sku_with_null_value(): void
{
$result = $this->service->generatePlatformOuterSku(
strategy: 'manual',
origin_sku: '0032',
manual_value: null,
);
$this->assertSame('', $result);
}
public function test_generate_invalid_strategy_throws_exception(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('不支持的生成策略: unknown');
$this->service->generatePlatformOuterSku(
strategy: 'unknown',
origin_sku: '0032',
);
}
public function test_build_strategy_record_prefix(): void
{
$result = $this->service->buildStrategyRecord('prefix', 'C03');
$this->assertSame('prefix:C03', $result);
}
public function test_build_strategy_record_prefix_random(): void
{
$result = $this->service->buildStrategyRecord('prefix_random', 'C03', 4);
$this->assertSame('prefix_random:C03:4', $result);
}
public function test_build_strategy_record_manual(): void
{
$result = $this->service->buildStrategyRecord('manual');
$this->assertSame('manual', $result);
}
}
+291
View File
@@ -0,0 +1,291 @@
<script setup lang="ts">
import { useApiKeyStore, MAX_KEYS_PER_USER } from '@/stores/api-key'
import { useUserStore } from '@/stores/user'
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import type { ApiKeyRecord } from '@/types/api'
import dayjs from 'dayjs'
const apiKeyStore = useApiKeyStore()
const userStore = useUserStore()
const apiKeyEnabled = computed(() => userStore.user?.api_key_enabled ?? false)
const hasKeys = computed(() => apiKeyStore.keys.length > 0)
// Create modal
const createModalOpen = ref(false)
const createLoading = ref(false)
const createForm = reactive({
name: '',
expires_at: null as string | null,
})
// Plain key modal
const plainKeyModalOpen = ref(false)
const plainKey = ref('')
function isExpired(record: ApiKeyRecord): boolean {
return !!record.expires_at && dayjs(record.expires_at).isBefore(dayjs())
}
function formatTime(time: string | null): string {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 19)
}
const columns = [
{ title: '名称', dataIndex: 'name', width: 150 },
{ title: '前缀', dataIndex: 'key_prefix', width: 120 },
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{ title: '过期时间', key: 'expires_at', width: 170 },
{ title: '最后使用', dataIndex: 'last_used_at', width: 170 },
{ title: '状态', key: 'enabled', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const readonlyColumns = columns.filter((c) => c.key !== 'enabled' && c.key !== 'action')
function showCreateModal() {
createForm.name = ''
createForm.expires_at = null
createModalOpen.value = true
}
async function handleCreate() {
if (!createForm.name.trim()) {
message.warning('请输入 API Key 名称')
return
}
createLoading.value = true
try {
const result = await apiKeyStore.createKey({
name: createForm.name.trim(),
expires_at: createForm.expires_at || undefined,
})
if (result) {
createModalOpen.value = false
plainKey.value = result.plain_key
plainKeyModalOpen.value = true
}
} finally {
createLoading.value = false
}
}
async function handleToggle(record: ApiKeyRecord, checked: boolean) {
await apiKeyStore.toggleKey(record.id, checked)
}
async function handleDelete(record: ApiKeyRecord) {
await apiKeyStore.deleteKey(record.id)
}
function handleCopyKey() {
navigator.clipboard.writeText(plainKey.value).then(() => {
message.success('已复制')
})
}
function handlePlainKeyModalClose() {
plainKey.value = ''
plainKeyModalOpen.value = false
}
onMounted(() => {
apiKeyStore.fetchMyKeys()
})
</script>
<template>
<!-- Mode 3: api_key_enabled=false 且无 Key -->
<a-empty
v-if="!apiKeyEnabled && !hasKeys"
description="API Key 功能未启用,请联系管理员开启"
/>
<!-- Mode 4: api_key_enabled=false 且有 Key 警告 + 只读表格 -->
<div v-else-if="!apiKeyEnabled && hasKeys">
<a-alert
type="warning"
show-icon
class="mb-4"
message="API Key 功能已被管理员关闭。您的所有 API Key 当前无法用于认证。已有 Key 不会被删除,功能重新开启后将自动恢复。如需开启,请联系管理员。"
/>
<a-table
:columns="readonlyColumns"
:data-source="apiKeyStore.keys"
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'key_prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
</template>
</template>
</a-table>
</div>
<!-- Mode 2: api_key_enabled=true 且无 Key — 引导空状态 -->
<div v-else-if="apiKeyEnabled && !hasKeys && !apiKeyStore.loading">
<a-empty description="还没有 API Key">
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
生成第一个 API Key
</a-button>
</a-empty>
</div>
<!-- Mode 1: api_key_enabled=true 且有 Key — 完整面板 -->
<div v-else-if="apiKeyEnabled">
<div class="flex justify-between items-center mb-3">
<a-tag>{{ apiKeyStore.keyCount }}/{{ MAX_KEYS_PER_USER }}</a-tag>
<a-tooltip
v-if="!apiKeyStore.canCreate"
title="已达上限请删除不需要的 Key 后再生成"
>
<a-button type="primary" disabled>
<template #icon><PlusOutlined /></template>
生成 API Key
</a-button>
</a-tooltip>
<a-button
v-else
type="primary"
@click="showCreateModal"
>
<template #icon><PlusOutlined /></template>
生成 API Key
</a-button>
</div>
<a-table
:columns="columns"
:data-source="apiKeyStore.keys"
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'key_prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
</template>
<template v-else-if="column.key === 'enabled'">
<a-switch
:checked="record.enabled"
:disabled="isExpired(record)"
size="small"
@change="(checked: boolean) => handleToggle(record, checked)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
title="确定要删除此 API Key "
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</div>
<!-- Loading state -->
<a-spin v-if="apiKeyStore.loading && !hasKeys && apiKeyEnabled" class="mt-4" />
<!-- Create Modal -->
<a-modal
v-model:open="createModalOpen"
title="生成 API Key"
:confirm-loading="createLoading"
@ok="handleCreate"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input
v-model:value="createForm.name"
placeholder="例如数据同步脚本监控系统"
:maxlength="100"
@press-enter="handleCreate"
/>
</a-form-item>
<a-form-item label="过期时间">
<a-date-picker
v-model:value="createForm.expires_at"
value-format="YYYY-MM-DD HH:mm:ss"
show-time
placeholder="留空表示永不过期"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Plain Key Modal -->
<a-modal
:open="plainKeyModalOpen"
title="API Key 已生成"
:footer="null"
:mask-closable="false"
@cancel="handlePlainKeyModalClose"
>
<a-alert
type="warning"
show-icon
class="mb-4"
message="此密钥仅显示一次关闭后无法再次查看请妥善保存"
/>
<div class="bg-gray-100 p-3 rounded font-mono text-sm break-all select-all mb-3">
{{ plainKey }}
</div>
<a-button type="primary" block @click="handleCopyKey">
<template #icon><CopyOutlined /></template>
复制 API Key
</a-button>
</a-modal>
</template>
<style scoped>
:deep(.expired-row) {
opacity: 0.5;
}
</style>
@@ -20,6 +20,8 @@ import {
FileSearchOutlined, FileSearchOutlined,
ApiOutlined, ApiOutlined,
HistoryOutlined, HistoryOutlined,
BarcodeOutlined,
LinkOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import type { Component } from 'vue' import type { Component } from 'vue'
@@ -53,6 +55,7 @@ const initOpenKeys = () => {
const path = route.path const path = route.path
if (path.startsWith('/order')) openKeys.value = ['orders-group'] if (path.startsWith('/order')) openKeys.value = ['orders-group']
else if (path.startsWith('/refund')) openKeys.value = ['refunds-group'] else if (path.startsWith('/refund')) openKeys.value = ['refunds-group']
else if (path.startsWith('/sku-')) openKeys.value = ['sku-group']
else if (path.startsWith('/logs')) openKeys.value = ['logs-group'] else if (path.startsWith('/logs')) openKeys.value = ['logs-group']
} }
@@ -64,6 +67,15 @@ const menuItems: MenuItem[] = [
{ key: '/', icon: DashboardOutlined, label: '首页' }, { key: '/', icon: DashboardOutlined, label: '首页' },
{ key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true }, { key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true },
{ key: '/products', icon: ShoppingOutlined, label: '产品管理' }, { key: '/products', icon: ShoppingOutlined, label: '产品管理' },
{
key: 'sku-group',
icon: BarcodeOutlined,
label: 'SKU 管理',
children: [
{ key: '/sku-origins', icon: BarcodeOutlined, label: '内部 SKU' },
{ key: '/sku-mappings', icon: LinkOutlined, label: 'SKU 映射' },
],
},
{ {
key: 'orders-group', key: 'orders-group',
icon: FileTextOutlined, icon: FileTextOutlined,
@@ -126,6 +138,8 @@ const breadcrumbItems = computed(() => {
'/order-items': '订单子项', '/order-items': '订单子项',
'/refunds': '退款列表', '/refunds': '退款列表',
'/refund-items': '退款子项', '/refund-items': '退款子项',
'/sku-origins': '内部 SKU',
'/sku-mappings': 'SKU 映射',
'/roles': '角色管理', '/roles': '角色管理',
'/route-groups': '路由组管理', '/route-groups': '路由组管理',
'/mq-status': '队列监控', '/mq-status': '队列监控',
+574
View File
@@ -0,0 +1,574 @@
<script setup lang="ts">
import {
useSkuMappingStore,
type SkuMappingForm,
type GenerateSkuParams,
} from '@/stores/sku-mapping'
import CascadeFilter from '@/components/CascadeFilter.vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
ThunderboltOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons-vue'
import type { Rule } from 'ant-design-vue/es/form'
const store = useSkuMappingStore()
const columns = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '公司', key: 'company', width: 120 },
{ title: '平台', key: 'platform', width: 100 },
{ title: '店铺', key: 'store', width: 140 },
{ title: '内部 SKU', dataIndex: 'origin_sku', width: 140 },
{ title: '平台 SKU', dataIndex: 'platform_outer_sku', width: 160 },
{ title: '平台商品ID', dataIndex: 'platform_product_id', width: 160, ellipsis: true },
{ title: '状态', key: 'enabled', width: 80 },
{ title: '更新时间', key: 'updated_at', width: 170 },
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
]
// Modal state
const modalVisible = ref(false)
const modalTitle = ref('新建映射')
const editingId = ref<number | null>(null)
const formRef = ref()
const saving = ref(false)
const defaultForm = (): SkuMappingForm => ({
company_id: undefined,
platform_id: undefined,
store_id: undefined,
origin_sku: '',
origin_sku_id: undefined,
platform_product_id: '',
platform_outer_sku: '',
generation_strategy: 'prefix',
warehouse_id: undefined,
enabled: true,
note: '',
})
const form = reactive<SkuMappingForm>(defaultForm())
const rules: Record<string, Rule[]> = {
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
platform_id: [{ required: true, message: '请选择平台', trigger: 'change' }],
origin_sku: [{ required: true, message: '请输入内部 SKU', trigger: 'blur' }],
platform_outer_sku: [{ required: true, message: '请输入或生成平台 SKU', trigger: 'blur' }],
}
// Generation modal
const genModalVisible = ref(false)
const genLoading = ref(false)
const genForm = reactive({
strategy: 'prefix' as 'prefix' | 'prefix_random' | 'manual',
prefix: '',
random_length: 4,
manual_value: '',
})
const genResult = ref('')
// Filtered store options based on cascade
const filteredStoreOptions = computed(() => {
let filtered = store.stores
if (form.company_id) {
filtered = filtered.filter((s) => s.company_id === form.company_id)
}
if (form.platform_id) {
filtered = filtered.filter((s) => s.platform_id === form.platform_id)
}
return filtered.map((s) => ({
value: s.id,
label: s.label && s.label !== 'null' ? s.label : s.name,
}))
})
// Filtered SKU origins by company
const filteredSkuOrigins = computed(() => {
if (!form.company_id) return store.skuOrigins
return store.skuOrigins.filter((s) => s.company_id === form.company_id)
})
onMounted(() => {
store.loadLookups()
store.loadSkuOrigins()
store.fetchItems()
})
function handleSearch() {
store.pagination.page = 1
store.fetchItems()
}
function handleReset() {
store.resetFilters()
store.fetchItems()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchItems()
}
function formatTime(time: string | null) {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 16)
}
function openCreate() {
Object.assign(form, defaultForm())
editingId.value = null
modalTitle.value = '新建映射'
modalVisible.value = true
}
function openEdit(record: SkuMappingForm & { id: number }) {
editingId.value = record.id
modalTitle.value = '编辑映射'
Object.assign(form, {
company_id: record.company_id,
platform_id: record.platform_id,
store_id: record.store_id || undefined,
origin_sku: record.origin_sku || '',
origin_sku_id: record.origin_sku_id || undefined,
platform_product_id: record.platform_product_id || '',
platform_outer_sku: record.platform_outer_sku || '',
generation_strategy: record.generation_strategy || 'prefix',
warehouse_id: record.warehouse_id || undefined,
enabled: record.enabled ?? true,
note: record.note || '',
})
modalVisible.value = true
}
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
if (editingId.value) {
await store.updateItem(editingId.value, { ...form })
} else {
await store.createItem({ ...form })
}
modalVisible.value = false
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
} finally {
saving.value = false
}
}
function handleDelete(record: { id: number; origin_sku: string; platform_outer_sku: string | null }) {
Modal.confirm({
title: '确认删除',
content: `确定删除映射「${record.origin_sku}${record.platform_outer_sku || '-'}」?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await store.deleteItem(record.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
message.error(msg)
}
},
})
}
function openGenerate() {
if (!form.origin_sku_id) {
message.warning('请先选择内部 SKU')
return
}
if (!form.platform_id) {
message.warning('请先选择平台')
return
}
genForm.strategy = 'prefix'
genForm.prefix = ''
genForm.random_length = 4
genForm.manual_value = ''
genResult.value = ''
genModalVisible.value = true
}
async function handleGenerate() {
if (genForm.strategy === 'manual' && !genForm.manual_value) {
message.warning('请输入手动 SKU 值')
return
}
if ((genForm.strategy === 'prefix' || genForm.strategy === 'prefix_random') && !genForm.prefix) {
message.warning('请输入前缀')
return
}
genLoading.value = true
try {
const params: GenerateSkuParams = {
origin_sku_id: form.origin_sku_id!,
platform_id: form.platform_id!,
strategy: genForm.strategy,
prefix: genForm.prefix,
random_length: genForm.random_length,
manual_value: genForm.manual_value,
}
const result = await store.generateSku(params)
genResult.value = result.generated_sku
if (result.has_duplicate) {
message.warning(result.message || '该平台已存在相同 SKU 映射')
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '生成失败'
message.error(msg)
} finally {
genLoading.value = false
}
}
function applyGenerated() {
if (genResult.value) {
form.platform_outer_sku = genResult.value
form.generation_strategy = genForm.strategy
genModalVisible.value = false
message.success('已填入生成的平台 SKU')
}
}
// Watch company change to reload SKU origins
watch(
() => form.company_id,
(val) => {
store.loadSkuOrigins(val)
},
)
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">SKU 映射管理</h2>
<!-- Filter area -->
<a-card class="mb-6">
<a-form layout="inline" class="filter-form" @submit.prevent="handleSearch">
<a-form-item>
<CascadeFilter v-model="store.cascadeValue" />
</a-form-item>
<a-form-item label="内部 SKU">
<a-input
v-model:value="store.filters.origin_sku"
placeholder="内部 SKU"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="平台 SKU">
<a-input
v-model:value="store.filters.platform_outer_sku"
placeholder="平台侧 SKU"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="store.filters.enabled"
placeholder="全部"
allow-clear
style="width: 100px"
>
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- Table -->
<a-card>
<div class="mb-4 flex justify-between items-center">
<span class="text-gray-500"> {{ store.pagination.total }} 条记录</span>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新建映射
</a-button>
</div>
<a-table
:columns="columns"
:data-source="store.items"
:loading="store.loading"
:pagination="false"
row-key="id"
:scroll="{ x: 1400 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'company'">
{{ store.companyMap.get(record.company_id) || record.company_id }}
</template>
<template v-else-if="column.key === 'platform'">
{{ store.platformMap.get(record.platform_id) || record.platform_id }}
</template>
<template v-else-if="column.key === 'store'">
<template v-if="record.store_id">
{{ store.storeMap.get(record.store_id) || record.store_id }}
</template>
<span v-else class="text-gray-400">平台默认</span>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatTime(record.updated_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="link" danger size="small" @click="handleDelete(record)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="mt-4 flex justify-end">
<a-pagination
:current="store.pagination.page"
:page-size="store.pagination.per_page"
:total="store.pagination.total"
show-size-changer
show-quick-jumper
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div>
</a-card>
<!-- Create/Edit Modal -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="saving"
@ok="handleSubmit"
@cancel="modalVisible = false"
:width="640"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
class="mt-4"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="公司" name="company_id">
<a-select
v-model:value="form.company_id"
placeholder="请选择公司"
:options="store.companies.map(c => ({
value: c.id,
label: (c.label && c.label !== 'null') ? c.label : c.name
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="平台" name="platform_id">
<a-select
v-model:value="form.platform_id"
placeholder="请选择平台"
:options="store.platforms.map(p => ({
value: p.id,
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="店铺" name="store_id">
<a-select
v-model:value="form.store_id"
placeholder="留空表示平台默认"
:options="filteredStoreOptions"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="内部 SKU" name="origin_sku">
<a-select
v-model:value="form.origin_sku_id"
placeholder="选择已有 SKU"
:options="filteredSkuOrigins.map(s => ({
value: s.id,
label: `${s.sku} - ${s.name}`
}))"
allow-clear
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
@change="(val: number | undefined) => {
const found = store.skuOrigins.find(s => s.id === val)
form.origin_sku = found?.sku || ''
}"
/>
<a-input
v-if="!form.origin_sku_id"
v-model:value="form.origin_sku"
placeholder="或手动输入内部 SKU"
class="mt-2"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="16">
<a-form-item name="platform_outer_sku">
<template #label>
<span>
平台 SKU
<a-tooltip title="电商平台侧使用的 SKU 编码需符合平台填写规则如不能以 0 开头不能含特殊字符等">
<QuestionCircleOutlined class="ml-1 text-gray-400" />
</a-tooltip>
</span>
</template>
<a-input v-model:value="form.platform_outer_sku" placeholder="平台侧 SKU 编码">
<template #addonAfter>
<a-button type="link" size="small" @click="openGenerate" :style="{ padding: 0 }">
<ThunderboltOutlined /> 自动生成
</a-button>
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="状态">
<a-switch v-model:checked="form.enabled" checked-children="启用" un-checked-children="禁用" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="平台商品 ID" name="platform_product_id">
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
</a-form-item>
<a-form-item label="备注" name="note">
<a-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
</a-form-item>
</a-form>
</a-modal>
<!-- SKU Generation Modal -->
<a-modal
v-model:open="genModalVisible"
title="自动生成平台 SKU"
:width="480"
@cancel="genModalVisible = false"
>
<div class="mt-4">
<a-form layout="vertical">
<a-form-item label="生成策略">
<a-radio-group v-model:value="genForm.strategy">
<a-radio-button value="prefix">前缀模式</a-radio-button>
<a-radio-button value="prefix_random">前缀+随机</a-radio-button>
<a-radio-button value="manual">手动输入</a-radio-button>
</a-radio-group>
</a-form-item>
<template v-if="genForm.strategy === 'prefix' || genForm.strategy === 'prefix_random'">
<a-form-item label="前缀">
<a-input v-model:value="genForm.prefix" placeholder=" C03" />
<div class="text-xs text-gray-400 mt-1">
前缀模式:生成 {前缀}_{内部SKU},如 C03_0032
</div>
</a-form-item>
</template>
<template v-if="genForm.strategy === 'prefix_random'">
<a-form-item label="随机部分长度">
<a-input-number v-model:value="genForm.random_length" :min="2" :max="8" />
<div class="text-xs text-gray-400 mt-1">
前缀+随机模式:生成 {前缀}_{随机字母数字},如 C03_A7K2
</div>
</a-form-item>
</template>
<template v-if="genForm.strategy === 'manual'">
<a-form-item label="手动输入">
<a-input v-model:value="genForm.manual_value" placeholder="手动输入 SKU " />
</a-form-item>
</template>
<a-form-item>
<a-button type="primary" :loading="genLoading" @click="handleGenerate">
<ThunderboltOutlined /> 生成预览
</a-button>
</a-form-item>
<a-form-item v-if="genResult" label="生成结果">
<a-alert type="success" show-icon>
<template #message>
<span class="font-mono text-base">{{ genResult }}</span>
</template>
</a-alert>
</a-form-item>
</a-form>
</div>
<template #footer>
<a-button @click="genModalVisible = false">取消</a-button>
<a-button type="primary" :disabled="!genResult" @click="applyGenerated">
使用此 SKU
</a-button>
</template>
</a-modal>
</div>
</template>
<style scoped>
.filter-form :deep(.ant-form-item) {
margin-bottom: 12px;
}
</style>
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
import CascadeFilter from '@/components/CascadeFilter.vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import type { Rule } from 'ant-design-vue/es/form'
const store = useSkuOriginStore()
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '公司', key: 'company', width: 140 },
{ title: 'SKU', dataIndex: 'sku', width: 160 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '条形码', dataIndex: 'barcode', width: 160 },
{ title: '标签', dataIndex: 'label', width: 120 },
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
{ title: '更新时间', key: 'updated_at', width: 170 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
]
// Modal state
const modalVisible = ref(false)
const modalTitle = ref('新建内部 SKU')
const editingId = ref<number | null>(null)
const formRef = ref()
const saving = ref(false)
const defaultForm = (): SkuOriginForm => ({
company_id: undefined,
sku: '',
barcode: '',
name: '',
label: '',
hs: '',
ledger: '',
})
const form = reactive<SkuOriginForm>(defaultForm())
const rules: Record<string, Rule[]> = {
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
}
onMounted(() => {
store.loadLookups()
store.fetchItems()
})
function handleSearch() {
store.pagination.page = 1
store.fetchItems()
}
function handleReset() {
store.resetFilters()
store.fetchItems()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchItems()
}
function formatTime(time: string | null) {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 16)
}
function openCreate() {
Object.assign(form, defaultForm())
editingId.value = null
modalTitle.value = '新建内部 SKU'
modalVisible.value = true
}
function openEdit(record: { id: number; company_id: number; sku: string; barcode: string; name: string; label: string | null; hs: string | null }) {
editingId.value = record.id
modalTitle.value = '编辑内部 SKU'
Object.assign(form, {
company_id: record.company_id,
sku: record.sku,
barcode: record.barcode || '',
name: record.name || '',
label: record.label || '',
hs: record.hs || '',
ledger: '',
})
modalVisible.value = true
}
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
if (editingId.value) {
await store.updateItem(editingId.value, { ...form })
} else {
await store.createItem({ ...form })
}
modalVisible.value = false
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
} finally {
saving.value = false
}
}
function handleDelete(record: { id: number; sku: string }) {
Modal.confirm({
title: '确认删除',
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被映射引用将无法删除。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await store.deleteItem(record.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
message.error(msg)
}
},
})
}
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">内部 SKU 管理</h2>
<!-- Filter area -->
<a-card class="mb-6">
<a-form layout="inline" class="filter-form" @submit.prevent="handleSearch">
<a-form-item>
<CascadeFilter v-model="store.cascadeValue" />
</a-form-item>
<a-form-item label="SKU">
<a-input
v-model:value="store.filters.sku"
placeholder="SKU 编码"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="名称">
<a-input
v-model:value="store.filters.name"
placeholder="模糊搜索"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="条形码">
<a-input
v-model:value="store.filters.barcode"
placeholder="条形码"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- Table -->
<a-card>
<div class="mb-4 flex justify-between items-center">
<span class="text-gray-500"> {{ store.pagination.total }} 条记录</span>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新建 SKU
</a-button>
</div>
<a-table
:columns="columns"
:data-source="store.items"
:loading="store.loading"
:pagination="false"
row-key="id"
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'company'">
{{ store.companyMap.get(record.company_id) || record.company_id }}
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatTime(record.updated_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="link" danger size="small" @click="handleDelete(record)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="mt-4 flex justify-end">
<a-pagination
:current="store.pagination.page"
:page-size="store.pagination.per_page"
:total="store.pagination.total"
show-size-changer
show-quick-jumper
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div>
</a-card>
<!-- Create/Edit Modal -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="saving"
@ok="handleSubmit"
@cancel="modalVisible = false"
:width="560"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
class="mt-4"
>
<a-form-item label="公司" name="company_id">
<a-select
v-model:value="form.company_id"
placeholder="请选择公司"
:options="store.companies.map(c => ({
value: c.id,
label: (c.label && c.label !== 'null') ? c.label : c.name
}))"
show-search
:filter-option="(input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="SKU 编码" name="sku">
<a-input v-model:value="form.sku" placeholder="客户内部 SKU 编码" />
</a-form-item>
<a-form-item label="名称" name="name">
<a-input v-model:value="form.name" placeholder="SKU 名称/描述" />
</a-form-item>
<a-form-item label="条形码" name="barcode">
<a-input v-model:value="form.barcode" placeholder="可选" />
</a-form-item>
<a-form-item label="标签" name="label">
<a-input v-model:value="form.label" placeholder="可选别名" />
</a-form-item>
<a-form-item label="HS 编码" name="hs">
<a-input v-model:value="form.hs" placeholder="可选" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.filter-form :deep(.ant-form-item) {
margin-bottom: 12px;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
import { api } from '@/utils/request'
import type { ApiKeyRecord, ApiKeyCreateParams, ApiKeyCreateResult } from '@/types/api'
export const MAX_KEYS_PER_USER = 10
export const useApiKeyStore = defineStore('api-key', () => {
const keys = ref<ApiKeyRecord[]>([])
const loading = ref(false)
const canCreate = computed(() => keys.value.length < MAX_KEYS_PER_USER)
const keyCount = computed(() => keys.value.length)
async function fetchMyKeys() {
loading.value = true
try {
const data = await api.get<ApiKeyRecord[]>('/api/v1/me/api-keys')
keys.value = data
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '获取 API Key 列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
async function createKey(params: ApiKeyCreateParams): Promise<ApiKeyCreateResult | null> {
try {
const data = await api.post<ApiKeyCreateResult>('/api/v1/me/api-keys', params)
await fetchMyKeys()
return data
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '创建 API Key 失败'
message.error(msg)
return null
}
}
async function toggleKey(id: number, enabled: boolean) {
try {
await api.patch(`/api/v1/me/api-keys/${id}/toggle`, { enabled })
await fetchMyKeys()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
}
}
async function deleteKey(id: number) {
try {
await api.delete(`/api/v1/me/api-keys/${id}`)
await fetchMyKeys()
message.success('已删除')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
message.error(msg)
}
}
return {
keys,
loading,
canCreate,
keyCount,
MAX_KEYS_PER_USER,
fetchMyKeys,
createKey,
toggleKey,
deleteKey,
}
})
+251
View File
@@ -0,0 +1,251 @@
import { api } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
// ─── Types ───
export interface SkuMappingRecord {
id: number
company_id: number
platform_id: number
store_id: number | null
origin_sku: string
origin_sku_id: number | null
platform_outer_sku: string | null
platform_product_id: string
enabled: boolean
note: string | null
created_at: string
updated_at: string
}
export interface SkuMappingFilters {
origin_sku: string
platform_outer_sku: string
enabled: boolean | undefined
}
export interface SkuMappingForm {
company_id: number | undefined
platform_id: number | undefined
store_id: number | undefined
origin_sku: string
origin_sku_id: number | undefined
platform_product_id: string
platform_outer_sku: string
generation_strategy: string
warehouse_id: number | undefined
enabled: boolean
note: string
}
export interface GenerateSkuParams {
origin_sku_id: number
platform_id: number
strategy: 'prefix' | 'prefix_random' | 'manual'
prefix: string
random_length: number
manual_value: string
}
export interface GenerateSkuResult {
generated_sku: string
strategy_record: string
has_duplicate: boolean
existing_mappings: Array<{
id: number
store_id: number | null
platform_outer_sku: string | null
note: string | null
}>
message: string
}
export interface DuplicateCheckResult {
has_duplicate: boolean
existing_mappings: Array<{
id: number
store_id: number | null
platform_outer_sku: string | null
note: string | null
}>
message: string
}
// ─── Store ───
export const useSkuMappingStore = defineStore('skuMapping', () => {
const items = ref<SkuMappingRecord[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
per_page: 15,
total: 0,
})
const cascadeValue = reactive({
company_id: undefined as number | undefined,
platform_id: undefined as number | undefined,
store_id: undefined as number | undefined,
})
const filters = reactive<SkuMappingFilters>({
origin_sku: '',
platform_outer_sku: '',
enabled: undefined,
})
// Lookups
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
const platforms = ref<{ id: number; name: string; label: string | null }[]>([])
const stores = ref<
{ id: number; company_id: number; platform_id: number; name: string; label: string | null }[]
>([])
const companyMap = computed(
() =>
new Map(
companies.value.map((c) => [
c.id,
c.label && c.label !== 'null' ? c.label : c.name,
]),
),
)
const platformMap = computed(
() =>
new Map(
platforms.value.map((p) => [
p.id,
p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`,
]),
),
)
const storeMap = computed(
() =>
new Map(
stores.value.map((s) => [
s.id,
s.label && s.label !== 'null' ? s.label : s.name,
]),
),
)
// SKU Origin lookups for select
const skuOrigins = ref<{ id: number; company_id: number; sku: string; name: string }[]>([])
async function loadLookups() {
try {
const [c, p, s] = await Promise.all([
api.get<typeof companies.value>('/api/v1/companies'),
api.get<typeof platforms.value>('/api/v1/platforms'),
api.get<typeof stores.value>('/api/v1/stores'),
])
companies.value = c
platforms.value = p
stores.value = s
} catch (err) {
console.warn('加载查找表数据失败', err)
}
}
async function loadSkuOrigins(company_id?: number) {
try {
const data = await api.get<PaginatedData<{ id: number; company_id: number; sku: string; name: string }>>('/api/v1/sku-origins', {
company_id,
per_page: 100,
})
skuOrigins.value = data.items
} catch (err) {
console.warn('加载 SKU Origin 列表失败', err)
}
}
async function fetchItems() {
loading.value = true
try {
const data = await api.get<PaginatedData<SkuMappingRecord>>('/api/v1/sku-mappings', {
page: pagination.page,
per_page: pagination.per_page,
company_id: cascadeValue.company_id,
platform_id: cascadeValue.platform_id,
store_id: cascadeValue.store_id,
origin_sku: filters.origin_sku || undefined,
platform_outer_sku: filters.platform_outer_sku || undefined,
enabled: filters.enabled,
})
items.value = data.items
pagination.total = data.total
pagination.page = data.page
} catch (err: unknown) {
items.value = []
pagination.total = 0
const msg = err instanceof Error ? err.message : '获取映射列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.origin_sku = ''
filters.platform_outer_sku = ''
filters.enabled = undefined
cascadeValue.company_id = undefined
cascadeValue.platform_id = undefined
cascadeValue.store_id = undefined
pagination.page = 1
}
async function createItem(form: SkuMappingForm) {
await api.post('/api/v1/sku-mappings', form)
message.success('创建成功')
await fetchItems()
}
async function updateItem(id: number, form: Partial<SkuMappingForm>) {
await api.put(`/api/v1/sku-mappings/${id}`, form)
message.success('更新成功')
await fetchItems()
}
async function deleteItem(id: number) {
await api.delete(`/api/v1/sku-mappings/${id}`)
message.success('删除成功')
await fetchItems()
}
async function generateSku(params: GenerateSkuParams): Promise<GenerateSkuResult> {
return await api.post<GenerateSkuResult>('/api/v1/sku-mappings/generate-sku', params)
}
async function checkDuplicate(
origin_sku_id: number,
platform_id: number,
): Promise<DuplicateCheckResult> {
return await api.get<DuplicateCheckResult>('/api/v1/sku-mappings/check-duplicate', {
origin_sku_id,
platform_id,
})
}
return {
items,
loading,
pagination,
cascadeValue,
filters,
companies,
platforms,
stores,
companyMap,
platformMap,
storeMap,
skuOrigins,
loadLookups,
loadSkuOrigins,
fetchItems,
resetFilters,
createItem,
updateItem,
deleteItem,
generateSku,
checkDuplicate,
}
})
+149
View File
@@ -0,0 +1,149 @@
import { api } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
// ─── Types ───
export interface SkuOriginRecord {
id: number
company_id: number
sku: string
barcode: string
name: string
label: string | null
hs: string | null
created_at: string
updated_at: string
}
export interface SkuOriginDetail extends SkuOriginRecord {
ledger: string | null
ext: Record<string, unknown> | null
}
export interface SkuOriginFilters {
sku: string
name: string
barcode: string
}
export interface SkuOriginForm {
company_id: number | undefined
sku: string
barcode: string
name: string
label: string
hs: string
ledger: string
}
// ─── Store ───
export const useSkuOriginStore = defineStore('skuOrigin', () => {
const items = ref<SkuOriginRecord[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
per_page: 15,
total: 0,
})
const cascadeValue = reactive({
company_id: undefined as number | undefined,
platform_id: undefined as number | undefined,
store_id: undefined as number | undefined,
})
const filters = reactive<SkuOriginFilters>({
sku: '',
name: '',
barcode: '',
})
// Lookup maps
const companies = ref<{ id: number; name: string; label: string | null }[]>([])
const companyMap = computed(
() =>
new Map(
companies.value.map((c) => [
c.id,
c.label && c.label !== 'null' ? c.label : c.name,
]),
),
)
async function loadLookups() {
try {
companies.value = await api.get<{ id: number; name: string; label: string | null }[]>(
'/api/v1/companies',
)
} catch (err) {
console.warn('加载查找表数据失败', err)
}
}
async function fetchItems() {
loading.value = true
try {
const data = await api.get<PaginatedData<SkuOriginRecord>>('/api/v1/sku-origins', {
page: pagination.page,
per_page: pagination.per_page,
company_id: cascadeValue.company_id,
sku: filters.sku || undefined,
name: filters.name || undefined,
barcode: filters.barcode || undefined,
})
items.value = data.items
pagination.total = data.total
pagination.page = data.page
} catch (err: unknown) {
items.value = []
pagination.total = 0
const msg = err instanceof Error ? err.message : '获取 SKU 列表失败'
message.error(msg)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.sku = ''
filters.name = ''
filters.barcode = ''
cascadeValue.company_id = undefined
cascadeValue.platform_id = undefined
cascadeValue.store_id = undefined
pagination.page = 1
}
async function createItem(form: SkuOriginForm) {
await api.post('/api/v1/sku-origins', form)
message.success('创建成功')
await fetchItems()
}
async function updateItem(id: number, form: Partial<SkuOriginForm>) {
await api.put(`/api/v1/sku-origins/${id}`, form)
message.success('更新成功')
await fetchItems()
}
async function deleteItem(id: number) {
await api.delete(`/api/v1/sku-origins/${id}`)
message.success('删除成功')
await fetchItems()
}
return {
items,
loading,
pagination,
cascadeValue,
filters,
companies,
companyMap,
loadLookups,
fetchItems,
resetFilters,
createItem,
updateItem,
deleteItem,
}
})
+1
View File
@@ -7,6 +7,7 @@ export interface UserInfo {
email: string email: string
role: string role: string
status: number status: number
api_key_enabled?: boolean
ext?: Record<string, unknown> | null ext?: Record<string, unknown> | null
} }
+24
View File
@@ -304,6 +304,30 @@ export interface OperationLogFilters {
created_at_range: [string, string] | null created_at_range: [string, string] | null
} }
/** ─── API Key ─── */
export interface ApiKeyRecord {
id: number
user_id: number
name: string
key_prefix: string
last_used_at: string | null
expires_at: string | null
enabled: boolean
created_at: string
user?: { id: number; username: string; api_key_enabled?: boolean }
}
export interface ApiKeyCreateParams {
name: string
expires_at?: string | null
}
export interface ApiKeyCreateResult {
plain_key: string
api_key: ApiKeyRecord
}
/** 业务异常 */ /** 业务异常 */
export class ApiError extends Error { export class ApiError extends Error {
code: number code: number