商品列表、分类管理、客户等级
This commit is contained in:
@@ -34,7 +34,7 @@ class ProductCategoryController extends BaseController
|
||||
#[GetRoute('/tree', 'query')]
|
||||
public function tree(): JsonResponse
|
||||
{
|
||||
return $this->success(ProductCategoryModel::getTreeData(onlyEnabled: true));
|
||||
return $this->success(ProductCategoryModel::getTreeData(['id', 'name', 'parent_id'], true));
|
||||
}
|
||||
|
||||
/** 上传商品分类图片文件 */
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
@@ -20,6 +21,7 @@ use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 商品档案管理
|
||||
@@ -53,6 +55,21 @@ class ProductController extends BaseController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 上传商品分类图片文件 */
|
||||
#[PostRoute('/upload', 'create')]
|
||||
public function uploadImage(Request $request, SysFileService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file']);
|
||||
$result = $service->upload(
|
||||
$data['file'],
|
||||
10,
|
||||
20,
|
||||
Auth::id()
|
||||
);
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
|
||||
/** 创建商品(事务内建商品 + 同步等级价格) */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(ProductFormRequest $request): JsonResponse
|
||||
|
||||
@@ -25,7 +25,7 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
||||
'name' => ['required', 'string', 'max:50', $unique],
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'icon' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
||||
'name.max' => '等级名称最长 50 个字符',
|
||||
'name.unique' => '等级名称已存在',
|
||||
'status.in' => '状态值不正确',
|
||||
'icon.exists' => '请重新上传图片'
|
||||
'icon_id.exists' => '请重新上传图片'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品分类 创建/编辑 验证
|
||||
@@ -22,6 +24,7 @@ class ProductCategoryFormRequest extends BaseFormRequest
|
||||
'name' => 'required|string|max:50',
|
||||
'parent_id' => 'required|integer|min:0',
|
||||
'sort' => 'nullable|integer',
|
||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
];
|
||||
}
|
||||
@@ -33,6 +36,7 @@ class ProductCategoryFormRequest extends BaseFormRequest
|
||||
'name.max' => '分类名称最长 50 个字符',
|
||||
'parent_id.min' => '父级分类ID不正确',
|
||||
'status.in' => '状态值不正确',
|
||||
'icon_id.exists' => '请重新上传图片'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品档案 创建/编辑 验证(含多等级价格 prices 数组)
|
||||
@@ -14,14 +18,17 @@ class ProductFormRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['required','integer', new Exists(ProductCategoryModel::class,'id')],
|
||||
'supplier_id' => ['nullable','integer', 'exclude_if:supplier_id,0', new Exists(SupplierModel::class,'id')],
|
||||
'name' => 'required|string|max:100',
|
||||
'spec' => 'nullable|string|max:100',
|
||||
'grade' => 'nullable|string|max:50',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'category_id' => 'required|integer|exists:product_category,id',
|
||||
'supplier_id' => 'nullable|integer|exclude_if:supplier_id,0|exists:supplier,id',
|
||||
'image' => 'nullable|string|max:255',
|
||||
'image_ids' => 'nullable|array|max:255',
|
||||
'image_ids.*' => ['integer', new Exists(SysFileModel::class, 'id')],
|
||||
'content' => 'nullable|string',
|
||||
'sort' => 'nullable|integer',
|
||||
'shelf_life' => 'nullable|integer|min:0',
|
||||
'stock' => 'nullable|integer|min:0',
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
'prices' => 'nullable|array',
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -28,7 +27,7 @@ class CustomerLevelModel extends Model
|
||||
'name',
|
||||
'sort',
|
||||
'status',
|
||||
'icon'
|
||||
'icon_id'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -45,15 +44,16 @@ class CustomerLevelModel extends Model
|
||||
*/
|
||||
public function icon(): HasOne
|
||||
{
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'icon');
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'icon_id');
|
||||
}
|
||||
|
||||
// 图标链接
|
||||
public function icon_url(): Attribute
|
||||
public function getIconUrlAttribute()
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->icon->preview_url,
|
||||
);
|
||||
if($this->icon) {
|
||||
return $this->icon->preview_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -27,34 +26,37 @@ class ProductCategoryModel extends Model
|
||||
'name',
|
||||
'sort',
|
||||
'status',
|
||||
'icon'
|
||||
'icon_id'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'icon' => 'integer',
|
||||
'icon_id' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $appends = ['icon_url'];
|
||||
|
||||
protected $with = ['icon'];
|
||||
|
||||
/**
|
||||
* 关联图标
|
||||
*/
|
||||
public function icon(): HasOne
|
||||
{
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'icon');
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'icon_id');
|
||||
}
|
||||
|
||||
// 图标链接
|
||||
public function icon_url(): Attribute
|
||||
public function getIconUrlAttribute()
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->icon->preview_url,
|
||||
);
|
||||
if($this->icon) {
|
||||
return $this->icon->preview_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,14 +89,14 @@ class ProductCategoryModel extends Model
|
||||
* @param bool $onlyEnabled 是否仅返回启用分类
|
||||
* @return array<int, array{id: int, name: string, parent_id: int, sort: int, status: int, children?: array}>
|
||||
*/
|
||||
public static function getTreeData(bool $onlyEnabled = false): array
|
||||
public static function getTreeData($columns = ['*'], bool $onlyEnabled = false): array
|
||||
{
|
||||
$query = static::query()->orderBy('sort')->orderBy('id');
|
||||
if ($onlyEnabled) {
|
||||
$query->where('status', self::STATUS_NORMAL);
|
||||
}
|
||||
|
||||
return static::buildTree($query->get()->toArray());
|
||||
return static::buildTree($query->get($columns)->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,7 @@ class ProductModel extends Model
|
||||
'name',
|
||||
'spec',
|
||||
'unit',
|
||||
'images',
|
||||
'image_ids',
|
||||
'content',
|
||||
'sort',
|
||||
'shelf_life',
|
||||
@@ -47,22 +47,32 @@ class ProductModel extends Model
|
||||
'stock' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
public function images(): Attribute
|
||||
protected $appends = ['images_arr'];
|
||||
|
||||
/**
|
||||
* 封面图片
|
||||
*/
|
||||
public function imageIds(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function($value){
|
||||
if (empty($value)) {
|
||||
return collect();
|
||||
}
|
||||
$ids = explode(',', $value);
|
||||
return SysFileModel::whereIn('id', $ids)->get();
|
||||
},
|
||||
get: fn ($value) => explode(',', $value),
|
||||
set: fn ($value) => is_array($value) ? implode(',', $value) : $value,
|
||||
);
|
||||
}
|
||||
|
||||
// 获取封面图片关联数据
|
||||
public function getImagesArrAttribute(): array
|
||||
{
|
||||
if( $this->image_ids ) {
|
||||
return SysFileModel::whereIn('id', $this->image_ids )->get()->toArray();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属分类
|
||||
*/
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 客户等级工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<CustomerLevelModel>
|
||||
*/
|
||||
class CustomerLevelModelFactory extends Factory
|
||||
{
|
||||
protected $model = CustomerLevelModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'name' => '客户等级' . $seq,
|
||||
'sort' => $seq,
|
||||
'status' => CustomerLevelModel::STATUS_NORMAL,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProductModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 商品工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<ProductModel>
|
||||
*/
|
||||
class ProductModelFactory extends Factory
|
||||
{
|
||||
protected $model = ProductModel::class;
|
||||
|
||||
private const NAMES = ['大白菜', '土豆', '西红柿', '黄瓜', '苹果', '香蕉'];
|
||||
private const SPECS = ['500g/袋', '10斤/箱', '散装', '25斤/袋'];
|
||||
private const GRADES = ['特级', '一级', '二级'];
|
||||
private const UNITS = ['斤', '箱', '袋'];
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'category_id' => 0,
|
||||
'supplier_id' => 0,
|
||||
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
|
||||
'spec' => self::SPECS[$seq % count(self::SPECS)],
|
||||
'grade' => self::GRADES[$seq % count(self::GRADES)],
|
||||
'unit' => self::UNITS[$seq % count(self::UNITS)],
|
||||
'image' => '',
|
||||
'sort' => $seq,
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下架商品
|
||||
*/
|
||||
public function off(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => ProductModel::STATUS_OFF]);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProductPriceModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 商品等级价格工厂(product_id / level_id 需调用方指定;无需 Faker)
|
||||
*
|
||||
* @extends Factory<ProductPriceModel>
|
||||
*/
|
||||
class ProductPriceModelFactory extends Factory
|
||||
{
|
||||
protected $model = ProductPriceModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'product_id' => 0,
|
||||
'level_id' => 0,
|
||||
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 采购单工厂(无需 Faker)
|
||||
*
|
||||
* @extends Factory<PurchaseOrderModel>
|
||||
*/
|
||||
class PurchaseOrderModelFactory extends Factory
|
||||
{
|
||||
protected $model = PurchaseOrderModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'purchase_no' => 'PO' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
|
||||
'purchase_date' => now()->toDateString(),
|
||||
'status' => PurchaseOrderModel::STATUS_PENDING,
|
||||
'total_quantity' => 0,
|
||||
'total_weight' => 0,
|
||||
'estimate_amount' => 0,
|
||||
'actual_amount' => 0,
|
||||
'operator_id' => 0,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定采购日期
|
||||
*/
|
||||
public function onDate(string $date): static
|
||||
{
|
||||
return $this->state(fn () => ['purchase_date' => $date]);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoreModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 门店工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<StoreModel>
|
||||
*/
|
||||
class StoreModelFactory extends Factory
|
||||
{
|
||||
protected $model = StoreModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'name' => '测试门店' . $seq,
|
||||
'code' => 'S' . str_pad((string) $seq, 6, '0', STR_PAD_LEFT),
|
||||
'level_id' => 0,
|
||||
'contact' => '联系人' . $seq,
|
||||
'phone' => '138' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
|
||||
'address' => '测试地址' . $seq . '号',
|
||||
'payment_cycle_days' => $seq % 8,
|
||||
'status' => StoreModel::STATUS_NORMAL,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用门店
|
||||
*/
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => StoreModel::STATUS_DISABLED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定回款周期(天)
|
||||
*/
|
||||
public function paymentCycle(int $days): static
|
||||
{
|
||||
return $this->state(fn () => ['payment_cycle_days' => $days]);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 门店订单明细工厂(order_id / store_id / product_id 需调用方指定;
|
||||
* amount 未显式指定时按 price × quantity 自动计算;无需 Faker)
|
||||
*
|
||||
* @extends Factory<StoreOrderItemModel>
|
||||
*/
|
||||
class StoreOrderItemModelFactory extends Factory
|
||||
{
|
||||
protected $model = StoreOrderItemModel::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'order_id' => 0,
|
||||
'store_id' => 0,
|
||||
'product_id' => 0,
|
||||
'product_name' => '测试商品',
|
||||
'product_spec' => '500g/袋',
|
||||
'price' => number_format(random_int(100, 5000) / 100, 2, '.', ''),
|
||||
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
|
||||
'weight' => 0,
|
||||
'amount' => 0,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterMaking(function (StoreOrderItemModel $item): void {
|
||||
if ((float) $item->amount === 0.0 && (float) $item->price > 0 && (float) $item->quantity > 0) {
|
||||
$item->amount = bcmul((string) $item->price, (string) $item->quantity, 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 门店订单工厂(store_id 需调用方指定;无需 Faker)
|
||||
*
|
||||
* @extends Factory<StoreOrderModel>
|
||||
*/
|
||||
class StoreOrderModelFactory extends Factory
|
||||
{
|
||||
protected $model = StoreOrderModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'order_no' => 'SO' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
|
||||
'store_id' => 0,
|
||||
'order_date' => now()->toDateString(),
|
||||
'total_quantity' => 0,
|
||||
'total_weight' => 0,
|
||||
'total_amount' => 0,
|
||||
'status' => StoreOrderModel::STATUS_PENDING,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定订货日期
|
||||
*/
|
||||
public function onDate(string $date): static
|
||||
{
|
||||
return $this->state(fn () => ['order_date' => $date]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已汇总(已被采购单归集)
|
||||
*/
|
||||
public function summarized(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 供应商工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<SupplierModel>
|
||||
*/
|
||||
class SupplierModelFactory extends Factory
|
||||
{
|
||||
protected $model = SupplierModel::class;
|
||||
|
||||
private const MAIN_PRODUCTS = ['蔬菜', '水果', '蔬菜/水果', '肉禽蛋'];
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'name' => '测试供应商' . $seq,
|
||||
'contact' => '联系人' . $seq,
|
||||
'phone' => '139' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
|
||||
'address' => '供应商地址' . $seq . '号',
|
||||
'main_products' => self::MAIN_PRODUCTS[$seq % count(self::MAIN_PRODUCTS)],
|
||||
'status' => SupplierModel::STATUS_NORMAL,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用供应商
|
||||
*/
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => SupplierModel::STATUS_DISABLED]);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 小程序用户工厂(微信登录自动生成,供测试使用;无需 Faker)
|
||||
*
|
||||
* @extends Factory<UserModel>
|
||||
*/
|
||||
class UserModelFactory extends Factory
|
||||
{
|
||||
protected $model = UserModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'username' => null,
|
||||
'password' => null,
|
||||
'nickname' => '微信用户' . $seq,
|
||||
'email' => '',
|
||||
'openid' => 'openid_' . str_pad((string) $seq, 16, '0', STR_PAD_LEFT),
|
||||
'unionid' => '',
|
||||
'phone' => '',
|
||||
'avatar' => '',
|
||||
'type' => UserModel::TYPE_PENDING,
|
||||
'store_id' => 0,
|
||||
'supplier_id' => 0,
|
||||
'status' => UserModel::STATUS_NORMAL,
|
||||
'last_login_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定门店的门店用户
|
||||
*/
|
||||
public function forStore(int $storeId): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'type' => UserModel::TYPE_STORE,
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定供应商的供应商用户
|
||||
*/
|
||||
public function forSupplier(int $supplierId): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'type' => UserModel::TYPE_SUPPLIER,
|
||||
'supplier_id' => $supplierId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用账号
|
||||
*/
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => UserModel::STATUS_DISABLED]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ return new class extends Migration
|
||||
if (! Schema::hasTable('customer_level')) {
|
||||
Schema::create('customer_level', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('等级ID');
|
||||
$table->integer('icon')->nullable()->comment('等级图标ID');
|
||||
$table->integer('icon_id')->nullable()->comment('等级图标ID');
|
||||
$table->string('name', 50)->comment('等级名称(如:一级客户、二级客户)');
|
||||
$table->integer('sort')->default(0)->comment('排序');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
|
||||
@@ -17,7 +17,7 @@ return new class extends Migration
|
||||
Schema::create('product_category', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('分类ID');
|
||||
$table->integer('parent_id')->default(0)->comment('父级分类ID(0为顶级)');
|
||||
$table->integer('icon')->nullable()->comment('商品图标');
|
||||
$table->integer('icon_id')->nullable()->comment('商品图标');
|
||||
$table->string('name', 50)->comment('分类名称');
|
||||
$table->integer('sort')->default(0)->comment('排序(采购单导出按此排序)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
@@ -36,8 +36,8 @@ return new class extends Migration
|
||||
$table->string('name', 100)->comment('品名');
|
||||
$table->string('spec', 100)->default('')->comment('规格/包规');
|
||||
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
||||
$table->string('images', 255)->default('')->comment('商品图片');
|
||||
$table->text('content')->default('')->comment('商品图文详情');
|
||||
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
||||
$table->text('content')->comment('商品图文详情');
|
||||
$table->integer('sort')->default(0)->comment('排序');
|
||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||
$table->integer('stock')->default(0)->comment('库存');
|
||||
|
||||
@@ -47,6 +47,7 @@ return new class extends Migration
|
||||
$table->integer('sort')->default(0)->comment('排序(导出用)');
|
||||
$table->integer('is_sent')->default(0)->comment('是否已发送供应商(1已发送 0未发送)');
|
||||
$table->timestamp('sent_at')->nullable()->comment('发送时间');
|
||||
$table->timestamp('supplier_confirmed_at')->nullable()->comment('供应商确认接单时间(NULL未确认)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->index(['purchase_id'], 'purchase_order_item_purchase_index');
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 待确认事项 #7(已批准):供应商小程序确认接单状态落库
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('purchase_order_item', 'supplier_confirmed_at')) {
|
||||
Schema::table('purchase_order_item', function (Blueprint $table) {
|
||||
$table->timestamp('supplier_confirmed_at')->nullable()->after('sent_at')->comment('供应商确认接单时间(NULL未确认)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('purchase_order_item', 'supplier_confirmed_at')) {
|
||||
Schema::table('purchase_order_item', function (Blueprint $table) {
|
||||
$table->dropColumn('supplier_confirmed_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -368,6 +368,7 @@ class PermissionSeeder extends Seeder
|
||||
['type' => 'rule', 'name' => '编辑文件夹', 'key' => 'system.file.group.update'],
|
||||
['type' => 'rule', 'name' => '删除文件夹', 'key' => 'system.file.group.delete'],
|
||||
['type' => 'rule', 'name' => '查询文件列表', 'key' => 'system.file.list.query'],
|
||||
['type' => 'rule', 'name' => '查看文件详情', 'key' => 'system.file.list.show'],
|
||||
['type' => 'rule', 'name' => '上传文件', 'key' => 'system.file.list.upload'],
|
||||
['type' => 'rule', 'name' => '下载文件', 'key' => 'system.file.list.download'],
|
||||
['type' => 'rule', 'name' => '删除文件', 'key' => 'system.file.list.delete'],
|
||||
|
||||
@@ -56,8 +56,10 @@ class SysDataSeeder extends Seeder
|
||||
['id' => 5, 'name' => '系统附件', 'sort' => 4, 'describe' => '系统附件分组', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 6, 'name' => '其他文件', 'sort' => 5, 'describe' => '其他文件分组', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 7, 'name' => '临时文件', 'sort' => 6, 'describe' => '临时文件分组,用于存放临时上传的文件', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 8, 'name' => '等级图片', 'sort' => 7, 'describe' => '存放等级图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 9, 'name' => '分类图片', 'sort' => 8, 'describe' => '存放商品分类图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 8, 'name' => '等级图片', 'sort' => 7, 'describe' => '存放等级图标', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 9, 'name' => '分类图片', 'sort' => 8, 'describe' => '存放商品分类图标', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 10, 'name' => '商品封面图片', 'sort' => 9, 'describe' => '存放商品封面图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 11, 'name' => '商品详情图片', 'sort' => 10, 'describe' => '存放商品详情图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ class SysFileController extends BaseController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 获取文件详情 */
|
||||
#[GetRoute('/show/{id}', 'show', where: ['id' => '[0-9]+'])]
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$file = SysFileModel::findOrFail($id)->toArray();
|
||||
return $this->success($file);
|
||||
}
|
||||
|
||||
/** 上传文件 */
|
||||
#[PostRoute('/upload', 'upload')]
|
||||
public function uploadImage(Request $request): JsonResponse
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type { IProductCategoryTree } from '@/domain/iProductCategory.ts';
|
||||
|
||||
/** 分类级联树(商品表单分类下拉、对账筛选用,仅启用分类) */
|
||||
export async function getCategoryTree() {
|
||||
return createAxios<IProductCategory[]>({
|
||||
return createAxios<IProductCategoryTree[]>({
|
||||
url: '/product/category/tree',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
@@ -19,6 +19,17 @@ export function getFileList(params: FileListParams) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件详情
|
||||
* @param id 文件ID
|
||||
*/
|
||||
export function getFileInfo(id: number) {
|
||||
return createAxios<ISysFileInfo>({
|
||||
url: `/system/file/list/show/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回收站文件列表
|
||||
* @param params 查询参数
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UploadFile, UploadProps } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
import type { ImageUploaderProps } from './typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { getFileInfo } from '@/api/system/sysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
@@ -51,8 +52,29 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
return;
|
||||
}
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
const newFileList: UploadFile[] = valueArray.map(fileToList);
|
||||
setFileList(newFileList);
|
||||
// changeType='id' 时表单值为文件 id:拉取文件信息回显;
|
||||
if (changeType === 'id') {
|
||||
const ids = valueArray as number[];
|
||||
|
||||
const idSet = new Set(fileList.map(obj => Number(obj.uid)));
|
||||
|
||||
// 过滤出不在 Set 中的数字
|
||||
const missing = ids.filter(num => !idSet.has(Number(num)));
|
||||
|
||||
if (missing.length > 0) {
|
||||
const fetchers = missing.map((id) => getFileInfo(id));
|
||||
Promise.all(fetchers).then((resList) => {
|
||||
const files = resList
|
||||
.map((res) => res.data.data)
|
||||
.filter(i => !!i)
|
||||
.map(fileToList);
|
||||
setFileList([...files, ...fileList]);
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList);
|
||||
setFileList(newFileList);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 上传前校验
|
||||
@@ -106,8 +128,16 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
|
||||
// 处理文件列表变化
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||
// 如果所有文件都被删除
|
||||
if (newFileList.length === 0) return;
|
||||
// 所有文件都被删除时,同步清空表单值,避免残留旧 id 被提交
|
||||
if (newFileList.length === 0) {
|
||||
setFileList([]);
|
||||
if (mode === 'single') {
|
||||
onChange?.(null);
|
||||
} else {
|
||||
onChange?.([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFileList(newFileList);
|
||||
// 全部上传完成格式化图片列表
|
||||
if (newFileList.every((file) => file.status === 'done' || file.status === 'error')) {
|
||||
@@ -130,6 +160,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
}
|
||||
// 上传失败的文件
|
||||
const errorFiles = newFileList.filter((file) => file.status === 'error');
|
||||
|
||||
setFileList([...uploadedFiles.map(fileToList), ...errorFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ImageUploaderProps {
|
||||
* - 单选模式:ISysFileInfo | null
|
||||
* - 多选模式:ISysFileInfo[]
|
||||
*/
|
||||
value?: ISysFileInfo | ISysFileInfo[] | null;
|
||||
value?: ISysFileInfo | ISysFileInfo[] | number | number[] | null;
|
||||
|
||||
/**
|
||||
* 赋值类型
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { message, theme } from 'antd';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import { uploadFile } from '@/api/system/sysFile';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { RichTextEditorProps } from './typings';
|
||||
|
||||
/**
|
||||
* 富文本编辑器(wangEditor v5)
|
||||
* - 自定义图片上传:复用系统文件上传接口,校验参照 ImageUploader
|
||||
* - 隐藏视频上传、表情、附件等复杂功能,仅保留基础排版与图片
|
||||
*/
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
height = 400,
|
||||
placeholder,
|
||||
groupId = 0,
|
||||
maxSize = 5,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
||||
|
||||
// 自定义图片上传(校验逻辑参照 ImageUploader 组件)
|
||||
const customUpload = useCallback(
|
||||
async (file: File, insertFn: (src: string, alt: string, href: string) => void) => {
|
||||
// 1. 文件类型校验
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error(t('xin.form.richText.error.notImage'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 文件大小校验
|
||||
if (file.size / 1024 / 1024 > maxSize) {
|
||||
message.error(t('xin.form.richText.error.sizeExceeded', { maxSize }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 调用系统文件上传接口
|
||||
try {
|
||||
const res = await uploadFile(file, groupId);
|
||||
const info = res.data.data as ISysFileInfo;
|
||||
insertFn(info.preview_url || info.file_url || '', info.file_name || '', '');
|
||||
} catch {
|
||||
message.error(t('xin.form.richText.error.uploadFailed'));
|
||||
}
|
||||
},
|
||||
[t, groupId, maxSize]
|
||||
);
|
||||
|
||||
// 工具栏配置:只保留基础排版 + 图片,隐藏视频/表情/附件等复杂功能
|
||||
const toolbarConfig: Partial<IToolbarConfig> = useMemo(
|
||||
() => ({
|
||||
toolbarKeys: [
|
||||
'headerSelect',
|
||||
'blockquote',
|
||||
'|',
|
||||
'bold',
|
||||
'underline',
|
||||
'italic',
|
||||
'through',
|
||||
'|',
|
||||
'color',
|
||||
'bgColor',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'todo',
|
||||
'|',
|
||||
'justifyLeft',
|
||||
'justifyCenter',
|
||||
'justifyRight',
|
||||
'|',
|
||||
'insertLink',
|
||||
'uploadImage',
|
||||
'insertTable',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
'fullScreen',
|
||||
],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig: Partial<IEditorConfig> = useMemo(
|
||||
() => ({
|
||||
placeholder,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
// 自定义上传:覆盖默认的服务端上传方式
|
||||
customUpload,
|
||||
allowedFileTypes: ['image/*'],
|
||||
maxFileSize: maxSize * 1024 * 1024,
|
||||
// 禁用 base64 插入,粘贴/拖拽图片一律走自定义上传
|
||||
base64LimitSize: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[placeholder, customUpload, maxSize]
|
||||
);
|
||||
|
||||
// 禁用/启用编辑器
|
||||
useEffect(() => {
|
||||
if (editor == null) return;
|
||||
if (disabled) {
|
||||
editor.disable();
|
||||
} else {
|
||||
editor.enable();
|
||||
}
|
||||
}, [editor, disabled]);
|
||||
|
||||
// 组件卸载时销毁编辑器实例
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
setEditor(null);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
borderRadius: token.borderRadius,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{!disabled && (
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
defaultConfig={toolbarConfig}
|
||||
mode="default"
|
||||
style={{ borderBottom: `1px solid ${token.colorBorder}` }}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
defaultConfig={editorConfig}
|
||||
value={value}
|
||||
onCreated={setEditor}
|
||||
onChange={(ed) => onChange?.(ed.getHtml())}
|
||||
mode="default"
|
||||
style={{
|
||||
height,
|
||||
overflowY: 'hidden',
|
||||
background: disabled ? token.colorBgLayout : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 富文本编辑器组件属性
|
||||
*/
|
||||
export interface RichTextEditorProps {
|
||||
/**
|
||||
* 当前值(HTML 字符串)
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* 值变化回调
|
||||
* @param value HTML 字符串
|
||||
*/
|
||||
onChange?: (value?: string) => void;
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 编辑器高度 (px)
|
||||
* @default 400
|
||||
*/
|
||||
height?: number;
|
||||
|
||||
/**
|
||||
* 占位符
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* 上传文件分组 ID
|
||||
* @default 0
|
||||
*/
|
||||
groupId?: number;
|
||||
|
||||
/**
|
||||
* 图片大小限制 (MB)
|
||||
* @default 5
|
||||
*/
|
||||
maxSize?: number;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 客户等级 */
|
||||
export default interface ICustomerLevel {
|
||||
id?: number;
|
||||
@@ -5,7 +7,8 @@ export default interface ICustomerLevel {
|
||||
name?: string;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
icon?: number;
|
||||
icon_id?: number;
|
||||
icon?: ISysFileInfo;
|
||||
icon_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
+20
-3
@@ -1,5 +1,6 @@
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 商品等级价格行 */
|
||||
export interface IProductPrice {
|
||||
@@ -12,24 +13,40 @@ export interface IProductPrice {
|
||||
|
||||
/** 商品档案 */
|
||||
export default interface IProduct {
|
||||
/** 商品ID */
|
||||
id?: number;
|
||||
/** 分类ID */
|
||||
category_id?: number;
|
||||
/** 供应商ID */
|
||||
supplier_id?: number;
|
||||
/** 商品名称 */
|
||||
name?: string;
|
||||
/** 规格/包规 */
|
||||
spec?: string;
|
||||
/** 商品等级 */
|
||||
grade?: string;
|
||||
/** 计价单位 */
|
||||
unit?: string;
|
||||
image?: string;
|
||||
/** 封面 */
|
||||
image_ids?: string;
|
||||
images_arr?: ISysFileInfo[];
|
||||
/** 商品图文详情(富文本 HTML) */
|
||||
content?: string;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 保质期 */
|
||||
shelf_life?: number;
|
||||
/** 库存 */
|
||||
stock?: number;
|
||||
/** 状态 */
|
||||
status?: number;
|
||||
/** 描述 */
|
||||
remark?: string;
|
||||
/** 分类关联数据 */
|
||||
category?: IProductCategory;
|
||||
/** 供应商关联数据 */
|
||||
supplier?: ISupplier;
|
||||
/** 多等级价格 */
|
||||
prices?: IProductPrice[];
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 商品分类(多级,children 由后端组装) */
|
||||
export default interface IProductCategory {
|
||||
id?: number;
|
||||
@@ -5,12 +7,21 @@ export default interface IProductCategory {
|
||||
name?: string;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
icon?: number;
|
||||
icon_id?: number;
|
||||
icon?: ISysFileInfo;
|
||||
icon_url?: string;
|
||||
children?: IProductCategory[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 商品分类(多级,children 由后端组装) */
|
||||
export interface IProductCategoryTree {
|
||||
id?: number;
|
||||
parent_id?: number;
|
||||
name?: string;
|
||||
children?: IProductCategoryTree[];
|
||||
}
|
||||
|
||||
export const CATEGORY_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '停用', color: 'error' },
|
||||
1: { text: '正常', color: 'success' },
|
||||
|
||||
@@ -38,7 +38,7 @@ const CustomerLevelPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '图片等级',
|
||||
dataIndex: 'icon',
|
||||
dataIndex: 'icon_id',
|
||||
valueType: 'image',
|
||||
fieldProps: {
|
||||
action: '/customer/level/upload',
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Button, Image, Tag, Typography} from 'antd';
|
||||
import {NodeExpandOutlined} from '@ant-design/icons';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type { IProductCategoryTree } from '@/domain/iProductCategory.ts';
|
||||
import { CATEGORY_STATUS_MAP } from '@/domain/iProductCategory.ts';
|
||||
import { getCategoryTable } from '@/api/product/category.ts';
|
||||
import {getCategoryTable, getCategoryTree} from '@/api/product/category.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -34,7 +35,11 @@ function collectAllIds(nodes: IProductCategory[]): number[] {
|
||||
const ProductCategoryPage: React.FC = () => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<number[]>([]);
|
||||
const [allIds, setAllIds] = useState<number[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const columns: XinTableColumn<IProductCategory>[] = [
|
||||
{
|
||||
@@ -63,6 +68,7 @@ const ProductCategoryPage: React.FC = () => {
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: { min: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
@@ -86,7 +92,7 @@ const ProductCategoryPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '分类图标',
|
||||
dataIndex: 'icon',
|
||||
dataIndex: 'icon_id',
|
||||
valueType: 'image',
|
||||
fieldProps: {
|
||||
action: '/product/category/upload',
|
||||
@@ -100,8 +106,8 @@ const ProductCategoryPage: React.FC = () => {
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={60}
|
||||
height={60}
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
@@ -134,7 +140,6 @@ const ProductCategoryPage: React.FC = () => {
|
||||
handleRequest: async () => {
|
||||
const res = await getCategoryTable();
|
||||
const tree = res.data.data ?? [];
|
||||
setCategoryTree(tree);
|
||||
setAllIds(collectAllIds(tree));
|
||||
return { data: tree, total: tree.length };
|
||||
},
|
||||
|
||||
+78
-20
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Form, Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
@@ -21,7 +21,7 @@ import type IProduct from '@/domain/iProduct.ts';
|
||||
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
|
||||
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
||||
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
|
||||
import { getLevelOptions } from '@/api/customer/level.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
@@ -36,7 +36,7 @@ const { Title, Text } = Typography;
|
||||
const ProductGoodsPage: React.FC = () => {
|
||||
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
||||
|
||||
// ===== 价格矩阵抽屉 =====
|
||||
const [matrixOpen, setMatrixOpen] = useState(false);
|
||||
@@ -161,22 +161,61 @@ const ProductGoodsPage: React.FC = () => {
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '商品图片',
|
||||
dataIndex: 'image_ids',
|
||||
valueType: 'image',
|
||||
fieldProps: {
|
||||
action: '/product/goods/upload',
|
||||
maxWidth: 3000,
|
||||
maxHeight: 3000,
|
||||
maxSize: 10,
|
||||
mode: 'multiple',
|
||||
maxCount: 5,
|
||||
changeType: "id"
|
||||
},
|
||||
render: (_, record) => {
|
||||
if (!record.images_arr || record.images_arr?.length <= 0) return '-';
|
||||
return (
|
||||
<Space wrap={false}>
|
||||
{record.images_arr.map(i => (
|
||||
<Image
|
||||
key={i.id}
|
||||
src={i.preview_url}
|
||||
width={60}
|
||||
height={60}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
colProps: { span: 24 },
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
colProps: { span: 24 },
|
||||
fieldProps: {
|
||||
styles: { input: { height: 52, fontSize: 22 } }
|
||||
},
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入商品名称' }],
|
||||
},
|
||||
{
|
||||
title: '商品描述',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
colProps: { span: 24 },
|
||||
},
|
||||
{
|
||||
title: '规格/包规',
|
||||
dataIndex: 'spec',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
@@ -184,12 +223,14 @@ const ProductGoodsPage: React.FC = () => {
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
initialValue: '斤',
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category_id',
|
||||
valueType: 'treeSelect',
|
||||
required: true,
|
||||
align: "center",
|
||||
rules: [{ required: true, message: '请选择分类' }],
|
||||
fieldProps: {
|
||||
treeData: categoryTree,
|
||||
@@ -207,6 +248,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
dataIndex: 'supplier_id',
|
||||
valueType: 'select',
|
||||
hideInSearch: true,
|
||||
align: "center",
|
||||
fieldProps: {
|
||||
options: suppliers.map((s) => ({ label: s.name, value: s.id })),
|
||||
showSearch: true,
|
||||
@@ -216,17 +258,33 @@ const ProductGoodsPage: React.FC = () => {
|
||||
},
|
||||
render: (_, record) => record.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '图文详情',
|
||||
dataIndex: 'content',
|
||||
valueType: 'richText',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
colProps: { span: 24 },
|
||||
fieldProps: {
|
||||
height: 400,
|
||||
groupId: 11,
|
||||
placeholder: '输入商品图文详情,支持插入图片',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '等级价格',
|
||||
dataIndex: 'prices',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 370,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
<Space wrap>
|
||||
{record.prices?.length
|
||||
? record.prices.map((p) => (
|
||||
<Tag key={p.level_id} color="geekblue">
|
||||
{p.level?.name ?? `等级${p.level_id}`} ¥{p.price}
|
||||
<Tag key={p.id} color="geekblue">
|
||||
{p.level?.name ?? `等级${p.level_id}`}
|
||||
<span style={{color: 'red', marginLeft: 5 }}>¥{p.price ?? '未设定'}</span>
|
||||
</Tag>
|
||||
))
|
||||
: '-'}
|
||||
@@ -258,19 +316,12 @@ const ProductGoodsPage: React.FC = () => {
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '等级价格设置',
|
||||
dataIndex: 'prices',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
colProps: { span: 24 },
|
||||
fieldRender: () => (
|
||||
<Form.List name="prices">
|
||||
{(fields, { add, remove }) => (
|
||||
@@ -281,7 +332,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
{...restField}
|
||||
name={[name, 'level_id']}
|
||||
rules={[{ required: true, message: '请选择等级' }]}
|
||||
className="!mb-0"
|
||||
className="mb-0!"
|
||||
>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
@@ -293,7 +344,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
{...restField}
|
||||
name={[name, 'price']}
|
||||
rules={[{ required: true, message: '请输入单价' }]}
|
||||
className="!mb-0"
|
||||
className="mb-0!"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
@@ -324,6 +375,13 @@ const ProductGoodsPage: React.FC = () => {
|
||||
</Form.List>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IProduct> = {
|
||||
@@ -347,7 +405,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
},
|
||||
modalProps: {
|
||||
width: 800,
|
||||
styles: {container: {height: '80vh', overflowY: "auto" }}
|
||||
classNames: { body: "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden h-[80vh]" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user