Compare commits
2 Commits
01e67e02b5
...
513cc568a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 513cc568a9 | |||
| 5d58075644 |
File diff suppressed because one or more lines are too long
@@ -166,6 +166,23 @@ class ProductController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 批量删除商品(软删除),复用 delete 权限点 */
|
||||
#[DeleteRoute('/batch', 'delete')]
|
||||
public function batchDelete(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|distinct',
|
||||
], [
|
||||
'ids.required' => '请选择要删除的商品',
|
||||
'ids.min' => '请选择要删除的商品',
|
||||
'ids.*.integer' => '商品 ID 格式错误',
|
||||
'ids.*.distinct' => '存在重复的商品',
|
||||
]);
|
||||
ProductModel::whereIn('id', $data['ids'])->delete();
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||||
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(){return e({url:`/customer/supplier/options`,method:`get`})}async function r(t){return e({url:`/product/goods/options`,method:`get`,params:t?{keyword:t}:{}})}async function i(e={}){return t(`/product/goods/export`,e,`商品列表.xlsx`)}async function a(){return t(`/product/goods/export`,{template:1},`商品导入模板.xlsx`)}async function o(t){let n=new FormData;return n.append(`file`,t),e({url:`/product/goods/import`,method:`post`,data:n,headers:{"Content-Type":`multipart/form-data`},timeout:6e4})}async function s(t){return e({url:`/product/goods/priceMatrix`,method:`get`,params:t})}async function c(t){return e({url:`/product/goods/batchPrice`,method:`put`,data:{updates:t}})}export{r as a,s as i,a as n,o,i as r,n as s,c as t};
|
||||
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(){return e({url:`/customer/supplier/options`,method:`get`})}async function r(t){return e({url:`/product/goods/options`,method:`get`,params:t?{keyword:t}:{}})}async function i(e={}){return t(`/product/goods/export`,e,`商品列表.xlsx`)}async function a(){return t(`/product/goods/export`,{template:1},`商品导入模板.xlsx`)}async function o(t){let n=new FormData;return n.append(`file`,t),e({url:`/product/goods/import`,method:`post`,data:n,headers:{"Content-Type":`multipart/form-data`},timeout:6e4})}async function s(t){return e({url:`/product/goods/batch`,method:`delete`,data:{ids:t}})}async function c(t){return e({url:`/product/goods/priceMatrix`,method:`get`,params:t})}async function l(t){return e({url:`/product/goods/batchPrice`,method:`put`,data:{updates:t}})}export{c as a,n as c,i,l as n,r as o,a as r,o as s,s as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>XinAdmin</title>
|
||||
<script type="module" crossorigin src="/assets/index-B-FuBiP5.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-nfGZXE-D.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ProductModel;
|
||||
|
||||
/**
|
||||
* 商品批量删除(软删除):正常批量删除、空 ids 校验失败、无 delete 权限点拦截
|
||||
*/
|
||||
class ProductBatchDeleteTest extends ProcurementTestCase
|
||||
{
|
||||
/** 批量删除成功:选中商品软删除,未选中的保留 */
|
||||
public function test_batch_delete_soft_deletes_selected_products(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$ids = ProductModel::factory()->count(3)->create()->pluck('id')->all();
|
||||
$keep = ProductModel::factory()->create();
|
||||
|
||||
$this->deleteJson('/product/goods/batch', ['ids' => $ids])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$this->assertSoftDeleted('product', ['id' => $id]);
|
||||
}
|
||||
$this->assertDatabaseHas('product', ['id' => $keep->id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
/** ids 为空数组 → 校验失败,不删除任何商品 */
|
||||
public function test_batch_delete_requires_non_empty_ids(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$product = ProductModel::factory()->create();
|
||||
|
||||
$this->deleteJson('/product/goods/batch', ['ids' => []])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '请选择要删除的商品');
|
||||
|
||||
$this->assertDatabaseHas('product', ['id' => $product->id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
/** 无 product.goods.delete 权限点 → 拦截 */
|
||||
public function test_batch_delete_forbidden_without_permission(): void
|
||||
{
|
||||
// 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验
|
||||
$this->actingAsSysUser();
|
||||
$this->actingAsSysUser(['product.goods.query']);
|
||||
|
||||
$product = ProductModel::factory()->create();
|
||||
|
||||
$this->deleteJson('/product/goods/batch', ['ids' => [$product->id]])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', 'No Permission');
|
||||
|
||||
$this->assertDatabaseHas('product', ['id' => $product->id, 'deleted_at' => null]);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,15 @@ export async function importProducts(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量删除商品(软删除) */
|
||||
export async function batchDeleteProducts(ids: number[]) {
|
||||
return createAxios({
|
||||
url: '/product/goods/batch',
|
||||
method: 'delete',
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null);
|
||||
* 仅成本价可编辑
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
@@ -28,7 +28,7 @@ import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { getCategoryTree } from '@/api/product/category.ts';
|
||||
import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
||||
import { batchDeleteProducts, batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
||||
import type { IImportError } from '@/api/product/goods.ts';
|
||||
import { downloadProductTemplate, exportProducts, importProducts } from '@/api/product/goods.ts';
|
||||
|
||||
@@ -70,7 +70,35 @@ const ProductGoodsPage: React.FC = () => {
|
||||
// ===== 分类侧栏 =====
|
||||
const [activeCategory, setActiveCategory] = useState<number | undefined>(undefined);
|
||||
const tableRef = useRef<XinTableInstance<IProduct> | null>(null);
|
||||
useEffect(() => { tableRef.current?.reset() }, [activeCategory]);
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
tableRef.current?.reset();
|
||||
}, [activeCategory]);
|
||||
|
||||
// ===== 批量删除 =====
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
|
||||
/** 批量删除(软删除,成功后清空勾选并刷新列表) */
|
||||
const handleBatchDelete = () => {
|
||||
window.$modal?.confirm({
|
||||
title: `确定要删除选中的 ${selectedRowKeys.length} 件商品吗?`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setBatchDeleting(true);
|
||||
try {
|
||||
await batchDeleteProducts(selectedRowKeys);
|
||||
message.success('批量删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 价格矩阵抽屉 =====
|
||||
const [matrixOpen, setMatrixOpen] = useState(false);
|
||||
@@ -489,6 +517,17 @@ const ProductGoodsPage: React.FC = () => {
|
||||
actionBarRender: (dom) => [
|
||||
dom.add,
|
||||
dom.search,
|
||||
<AuthButton key="batchDelete" auth="product.goods.delete">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchDeleting}
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
批量删除{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
||||
价格矩阵
|
||||
</Button>,
|
||||
@@ -514,6 +553,10 @@ const ProductGoodsPage: React.FC = () => {
|
||||
...params,
|
||||
category_id: activeCategory
|
||||
}),
|
||||
rowSelection: {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||
},
|
||||
modalProps: {
|
||||
width: 800,
|
||||
centered: true,
|
||||
|
||||
Reference in New Issue
Block a user