diff --git a/app/admin/common.php b/app/admin/common.php index 3152587f..0ea57aa7 100644 --- a/app/admin/common.php +++ b/app/admin/common.php @@ -9,9 +9,13 @@ if (!function_exists('xlsx_reader')) { * @param array $keys_map 列 -> 键 例:['A' => 'name', 'B' => 'age', 'C' => 'sex'] * @return array */ - function xlsx_reader(string $file, int $initial_row = 1, array $keys_map = []): array + function xlsx_reader(string $file, int $initial_row = 1, array $keys_map = [], bool $read_only = false): array { $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader('Xlsx'); + if ($read_only) { + // 只读模式,不读取公式,样式等等,减少内存消耗 + $reader->setReadDataOnly(true); + } // 读取文件 $spreadsheet = $reader->load($file); @@ -42,6 +46,54 @@ if (!function_exists('xlsx_reader')) { } } +if (!function_exists('xlsx_stream_reader')) { + /** + * 读取Excel数据 + * @param string $file Excel文件路径 + * @param int $initial_row 初始行 + * @param array $keys_map 列 -> 键 例:['A' => 'name', 'B' => 'age', 'C' => 'sex'] + * @return Generator + */ + function xlsx_stream_reader(string $file, int $initial_row = 1, array $keys_map = [], bool $read_only = false): Generator + { + $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader('Xlsx'); + if ($read_only) { + // 只读模式,不读取公式,样式等等,减少内存消耗 + $reader->setReadDataOnly(true); + } + + // 读取文件 + $spreadsheet = $reader->load($file); + + // 获取活动sheet + $sheet = $spreadsheet->getActiveSheet(); + + // 获取最大行 + $max_row = $sheet->getHighestRow(); + + // 获取最大列 + $max_col = $sheet->getHighestColumn(); + + $xlsx_row = []; + $first_col = ord('A'); + $last_col = ord($max_col); + for ($row = $initial_row; $row <= $max_row; $row++) { + for ($col = $first_col; $col <= $last_col; $col++) { + $key = $col_chr = chr($col); + if (!empty($keys_map)) { + $key = $keys_map[$col_chr] ?? $col_chr; + } + $xlsx_row[$key] = $sheet->getCell($col_chr . $row)->getValue(); + } + yield $xlsx_row; + } + + // 主动销毁对象以释放内存 + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); + } +} + if (!function_exists('xlsx_writer')) { /** * 构建Excel数据 @@ -119,17 +171,17 @@ if (!function_exists('strtobytes')) { case 'kb': $size *= 1024; break; - case 'mb': + case 'mb': $size *= 1024 * 1024; break; - case 'gb': + case 'gb': $size *= 1024 * 1024 * 1024; break; case 'tb': $size *= 1024 * 1024 * 1024 * 1024; - default: + default: throw new \Exception('不支持[' . $unit . ']单位'); } return $size; } -} \ No newline at end of file +} diff --git a/app/admin/controller/v1/ProductCompat.php b/app/admin/controller/v1/ProductCompat.php new file mode 100644 index 00000000..4c36ea2f --- /dev/null +++ b/app/admin/controller/v1/ProductCompat.php @@ -0,0 +1,312 @@ +param([ + 'label_name', + 'model', + 'page/d' => 1, + 'size/d' => 10 + ]); + + $list = ProductCompatModel::withoutField([ + 'language_id', + 'deleted_at' + ]) + ->with(['parts' => function ($query) { + $query->field(['id', 'name']); + }]) + ->language(request()->lang_id) + ->withSearch(['label_name', 'model'], [ + 'label_name' => $params['label_name'] ?? null, + 'model' => $params['model'] ?? null, + ]) + ->order(['updated_at' => 'desc']) + ->paginate([ + 'list_rows' => $params['size'], + 'page' => $params['page'], + ]) + ?->bindAttr('parts', ['part_name' => 'name']) + ?->hidden(['parts']); + + return success("获取成功", $list); + } + + /** + * 导入产品兼容性数据 + */ + public function import() + { + // 获取上传文件 + $file = request()->file('file'); + if (empty($file)) { + return error('请上传文件'); + } + $lang_id = request()->lang_id; + + // 读取文件 + $keys_map = [ + 'A' => 'label_name', + 'B' => 'part_name', + 'C' => 'brand_name', + 'D' => 'level_name', + 'E' => 'class_name', + 'F' => 'series_name', + 'G' => 'model', + 'H' => 'capacity', + 'I' => 'interface_type', + 'J' => 'compat_status', + ]; + + $chunk = 500; // 每批次处理的条数 + $items = 0; // 已处理的条数 + $xlsx_data = []; + $xlsx_reader = xlsx_stream_reader($file->getRealPath(), 2, $keys_map, true); + + \think\facade\Db::startTrans(); + try { + foreach ($xlsx_reader as $row) { + $items++; + $row['seq_no'] = $items; // 记录行序号,防止后续顺序打乱 + $xlsx_data[] = $row; + if ($items % $chunk == 0) { + // 每500条,为一批次进行处理 + $this->handleImport($xlsx_data, $lang_id); + $xlsx_data = []; + } + } + + if (!empty($xlsx_data)) { + // 处理剩余的 + $this->handleImport($xlsx_data, $lang_id); + } + } catch (\Throwable $th) { + \think\facade\Db::rollback(); + return error($th->getMessage()); + } + \think\facade\Db::commit(); + + return success('操作成功'); + } + private function handleImport(array $xlsx_data, int $lang_id): void + { + $compat_datas = $this->matchExistCompatData($xlsx_data, $lang_id); + + // 校验数据并组装sql语句 + $raw_sql = $this->validateAndBuildSql($compat_datas); + if (false === \think\facade\Db::execute($raw_sql)) { + throw new \Exception(sprintf('第【%s】行执行导入 SQL 失败', implode(',', array_column($xlsx_data, 'seq_no')))); + } + } + private function matchExistCompatData(array $datas, int $lang_id) + { + $models = array_column($datas, 'model'); + $compat_map = ProductCompatModel::language($lang_id) + ->modelName($models) + ->column('id', 'model'); + + $part_names = array_unique(array_column($datas, 'part_name')); + $part_map = ProductCompatPartsModel::language($lang_id) + ->partName($part_names) + ->column('id', 'name'); + + // 匹配已存在数据的主键、配件id + foreach ($datas as &$d) { + $d['id'] = $compat_map[$d['model']] ?? null; + $d['language_id'] = $lang_id; + $d['part_id'] = $part_map[$d['part_name']] ?? 0; + } + unset($d); + + return $datas; + } + private function validateAndBuildSql(array $compat_datas) + { + $sql_values = []; + $validate = new ProductCompatValidate; + foreach ($compat_datas as $compat) { + // 校验数据 + $scene = is_null($compat['id']) ? 'add' : 'edit'; + if (!$validate->scene($scene)->check($compat)) { + throw new \Exception(sprintf('第【%s】行%s', $compat['seq_no'], $validate->getError())); + } + + // 组装 sql values + $sql_values[] = $this->buildSqlValue($compat); + } + + return $this->buildRawSql($sql_values); + } + private function buildSqlValue(array $compat) + { + $pdo = \think\facade\Db::connect()->getPdo(); + return sprintf( + '(%d, %d, %d, %s, %s, %s, %s, %s, %s, %s, %s, %s)', + $compat['id'], + $compat['language_id'], + $compat['part_id'], + is_string($compat['label_name']) ? $pdo->quote($compat['label_name']) : $compat['label_name'], + is_string($compat['brand_name']) ? $pdo->quote($compat['brand_name']) : $compat['brand_name'], + is_string($compat['level_name']) ? $pdo->quote($compat['level_name']) : $compat['level_name'], + is_string($compat['class_name']) ? $pdo->quote($compat['class_name']) : $compat['class_name'], + is_string($compat['series_name']) ? $pdo->quote($compat['series_name']) : $compat['series_name'], + is_string($compat['model']) ? $pdo->quote($compat['model']) : $compat['model'], + is_string($compat['capacity']) ? $pdo->quote($compat['capacity']) : $compat['capacity'], + is_string($compat['interface_type']) ? $pdo->quote($compat['interface_type']) : $compat['interface_type'], + is_string($compat['compat_status']) ? $pdo->quote($compat['compat_status']) : $compat['compat_status'] + ); + } + private function buildRawSql(array $values) + { + return sprintf( + 'INSERT INTO %s ( + `id`, + `language_id`, + `part_id`, + `label_name`, + `brand_name`, + `level_name`, + `class_name`, + `series_name`, + `model`, + `capacity`, + `interface_type`, + `compat_status` + ) VALUES %s + ON DUPLICATE KEY UPDATE + `part_id` = VALUES(`part_id`), + `label_name` = VALUES(`label_name`), + `brand_name` = VALUES(`brand_name`), + `level_name` = VALUES(`level_name`), + `class_name` = VALUES(`class_name`), + `series_name` = VALUES(`series_name`), + `model` = VALUES(`model`), + `capacity` = VALUES(`capacity`), + `interface_type` = VALUES(`interface_type`), + `compat_status` = VALUES(`compat_status`) + ', + (new ProductCompatModel)->getTable(), + implode(',', $values) + ); + } + + /** + * 产品兼容性数据添加 + */ + public function save() + { + $post = request()->post([ + 'part_id', + 'label_name', + 'brand_name', + 'level_name', + 'class_name', + 'series_name', + 'model', + 'capacity', + 'interface_type', + 'compat_status', + 'sort', + 'disabled' + ]); + $data = array_merge($post, ['language_id' => request()->lang_id]); + + // 校验输入 + $validate = new ProductCompatValidate; + if (!$validate->scene('add')->check($data)) { + return error($validate->getError()); + } + + $compat = ProductCompatModel::create($data); + if ($compat->isEmpty()) { + return error('操作失败'); + } + + return success('操作成功'); + } + + /** + * 产品兼容性数据详细 + */ + public function read() + { + $id = request()->param('id'); + $data = ProductCompatModel::withoutField(['language_id', 'deleted_at']) + ->bypk($id) + ->find(); + if (empty($data)) { + return error('获取失败'); + } + + return success("获取成功", $data); + } + + /** + * 产品兼容性数据更新 + */ + public function update() + { + $id = request()->param('id'); + $put = request()->put([ + 'part_id', + 'label_name', + 'brand_name', + 'level_name', + 'class_name', + 'series_name', + 'model', + 'capacity', + 'interface_type', + 'compat_status', + 'sort', + 'disabled' + ]); + $data = array_merge($put, ['id' => $id, 'language_id' => request()->lang_id]); + + // 校验输入 + $validate = new ProductCompatValidate; + if (!$validate->scene('edit')->check($data)) { + return error($validate->getError()); + } + + $compat = ProductCompatModel::bypk($id)->find(); + if (empty($compat)) { + return error('请确认操作对象是否正确'); + } + if (!$compat->save($data)) { + return error('操作失败'); + } + + return success('操作成功'); + } + + /** + * 产品兼容性数据删除 + */ + public function delete() + { + $id = request()->param('id'); + $data = ProductCompatModel::bypk($id)->find(); + if (empty($data)) { + return error("请确认操作对象是否正确"); + } + + if (!$data->delete()) { + return error("操作失败"); + } + + return success("操作成功"); + } +} diff --git a/app/admin/controller/v1/ProductCompatParts.php b/app/admin/controller/v1/ProductCompatParts.php new file mode 100644 index 00000000..73be4167 --- /dev/null +++ b/app/admin/controller/v1/ProductCompatParts.php @@ -0,0 +1,133 @@ +param([ + 'name', + 'page/d' => 1, + 'size/d' => 10 + ]); + + $list = ProductCompatPartsModel::withoutField([ + 'language_id', + 'deleted_at' + ]) + ->language(request()->lang_id) + ->withSearch('name', [ + 'name' => $params['name']??null + ]) + ->order(['id' => 'desc']) + ->paginate([ + 'list_rows' => $params['size'], + 'page' => $params['page'], + ]); + + return success('获取成功', $list); + } + + /** + * 添加兼容配件 + */ + public function save() + { + $post = request()->post([ + 'name', + 'sort', + 'disabled' + ]); + $data = array_merge($post, ['language_id' => request()->lang_id]); + + // 校验输入 + $validate = new ProductCompatPartsValidate; + if (!$validate->scene('add')->check($data)) { + return error($validate->getError()); + } + + $parts = ProductCompatPartsModel::create($data); + if ($parts->isEmpty()) { + return error('操作失败'); + } + + return success('操作成功'); + } + + /** + * 兼容配件详细 + */ + public function read() + { + $id = request()->param('id'); + + $parts = ProductCompatPartsModel::withoutField([ + 'language_id', + 'deleted_at' + ]) + ->bypk($id) + ->find(); + if (empty($parts)) { + return error('获取失败'); + } + + return success('获取成功', $parts); + } + + /** + * 兼容配件更新 + */ + public function update() + { + $id = request()->param('id'); + $put = request()->put([ + 'name', + 'sort', + 'disabled' + ]); + $data = array_merge($put, ['id' => $id, 'language_id' => request()->lang_id]); + + $validate = new ProductCompatPartsValidate; + if (!$validate->scene('edit')->check($data)) { + return error($validate->getError()); + } + + $parts = ProductCompatPartsModel::bypk($id)->find(); + if (empty($parts)) { + return error('请确认操作对象是否正确'); + } + + if (!$parts->save($put)) { + return error('操作失败'); + } + + return success('操作成功'); + } + + /** + * 兼容配件删除 + */ + public function delete() + { + $id = request()->param('id'); + $parts = ProductCompatPartsModel::bypk($id)->find(); + if (empty($parts)) { + return error('请确认操作对象是否正确'); + } + + if (!$parts->delete()) { + return error("操作失败"); + } + + return success("操作成功"); + } +} diff --git a/app/admin/model/v1/ProductCompatModel.php b/app/admin/model/v1/ProductCompatModel.php new file mode 100644 index 00000000..6c5dc919 --- /dev/null +++ b/app/admin/model/v1/ProductCompatModel.php @@ -0,0 +1,55 @@ +hasOne(ProductCompatPartsModel::class, 'id', 'part_id'); + } + + // 按所属语言查询 + public function scopeLanguage(\think\db\Query $query, int $value) + { + $query->where('language_id', $value); + } + + // 按型号查询 + public function scopeModelName(\think\db\Query $query, string|array $value) + { + if (is_array($value)) { + $query->where('model', 'in', $value); + return; + } + + $query->where('model', '=', $value); + } + + // 按标签名称搜索 + public function searchLabelNameAttr(\think\db\Query $query, string|null $value, array $data) + { + if (is_null($value)) return; + $query->where('label_name', 'like', '%' . $value . '%'); + } + + // 按型号搜索 + public function searchModelAttr(\think\db\Query $query, string|null $value, array $data) + { + if (is_null($value)) return; + $query->where('model', 'like', '%' . $value . '%'); + } +} diff --git a/app/admin/model/v1/ProductCompatPartsModel.php b/app/admin/model/v1/ProductCompatPartsModel.php new file mode 100644 index 00000000..13b139bb --- /dev/null +++ b/app/admin/model/v1/ProductCompatPartsModel.php @@ -0,0 +1,48 @@ +where('language_id', $value); + } + + // 按类型名称查询 + public function scopePartName(\think\db\Query $query, string|array $value) + { + if (is_array($value)) { + $query->where('name', 'in', $value); + return; + } + $query->where('name', '=', $value); + } + + // 查询配件类型名称搜索 + public function searchNameAttr(\think\db\Query $query, string|null $value, array $data) + { + if (is_null($value)) return; + $query->where('name', 'like', '%' . $value . '%'); + } +} diff --git a/app/admin/model/v1/ProductCompatRelModel.php b/app/admin/model/v1/ProductCompatRelModel.php new file mode 100644 index 00000000..97282dda --- /dev/null +++ b/app/admin/model/v1/ProductCompatRelModel.php @@ -0,0 +1,14 @@ + ['规则1','规则2'...] + * + * @var array + */ + protected $rule = [ + 'language_id' => 'require|integer', + 'name' => 'max:128' + ]; + + /** + * 定义错误信息 + * 格式:'字段名.规则名' => '错误信息' + * + * @var array + */ + protected $message = [ + 'id.require' => 'ID不能为空', + 'id.integer' => 'ID必须为整数', + 'language_id.require' => '语言ID不能为空', + 'language_id.integer' => '语言ID必须是整数', + 'name.max' => '类型名称不能超过:rule个字符' + ]; + + // 新增场景 + protected function sceneAdd() + { + return $this->remove("id", "require|integer"); + } + + // 编辑场景 + protected function sceneEdit() + { + return $this->append("id", "require|integer"); + } +} diff --git a/app/admin/validate/v1/ProductCompatValidate.php b/app/admin/validate/v1/ProductCompatValidate.php new file mode 100644 index 00000000..c2ccdc73 --- /dev/null +++ b/app/admin/validate/v1/ProductCompatValidate.php @@ -0,0 +1,66 @@ + ['规则1','规则2'...] + * + * @var array + */ + protected $rule = [ + 'language_id' => 'require|integer', + 'part_id' => 'require|integer', + 'label_name' =>'max: 128', + 'brand_name' => 'max:128', + 'level_name' => 'max:32', + 'class_name' => 'max:128', + 'series_name' => 'max:128', + 'model' => 'require|max:128', + 'capacity' => 'max:32', + 'interface_type' => 'max:64', + 'compat_status' => 'max:64' + ]; + + /** + * 定义错误信息 + * 格式:'字段名.规则名' => '错误信息' + * + * @var array + */ + protected $message = [ + 'id.require' => 'ID不能为空', + 'id.integer' => 'ID必须为整数', + 'language_id.require' => '语言ID必须', + 'language_id.integer' => '语言ID必须为整数', + 'part_id.require' => '零件ID必须', + 'part_id.integer' => '零件ID必须为整数', + 'label_name.max' => '标签名称不能超过:rule个字符', + 'brand_name.max' => '品牌名称不能超过:rule个字符', + 'level_name.max' => '等级名称不能超过:rule个字符', + 'class_name.max' => '类型名称不能超过:rule个字符', + 'series_name.max' => '系列名称不能超过:rule个字符', + 'model.require' => '型号必须', + 'model.max' => '型号不能超过:rule个字符', + 'capacity.max' => '容量不能超过:rule个字符', + 'interface_type.max' => '接口类型不能超过:rule个字符', + 'compat_status.max' => '兼容状态不能超过:rule个字符', + ]; + + // 新增场景 + protected function sceneAdd() + { + return $this->remove("id", "require|integer"); + } + + // 编辑场景 + protected function sceneEdit() + { + return $this->append("id", "require|integer"); + } +} diff --git a/app/common/model/ProductCompatBaseModel.php b/app/common/model/ProductCompatBaseModel.php new file mode 100644 index 00000000..63cf07bf --- /dev/null +++ b/app/common/model/ProductCompatBaseModel.php @@ -0,0 +1,37 @@ + "int", + "language_id" => "int", + "part_id" => "int", + "label_name" => "string", + "brand_name" => "string", + "level_name" => "string", + "class_name" => "string", + "series_name" => "string", + "model" => "string", + "capacity" => "string", + "interface_type" => "string", + "compat_status" => "string", + "sort" => "int", + "disabled" => "int", + "created_at" => "datetime", + "updated_at" => "datetime", + "deleted_at" => "datetime", + ]; +} diff --git a/app/common/model/ProductCompatPartsBaseModel.php b/app/common/model/ProductCompatPartsBaseModel.php new file mode 100644 index 00000000..0dc6f619 --- /dev/null +++ b/app/common/model/ProductCompatPartsBaseModel.php @@ -0,0 +1,28 @@ + "int", + "language_id" => "int", + "name" => "string", + "sort" => "int", + "disabled" => "int", + "created_at" => "datetime", + "updated_at" => "datetime", + "deleted_at" => "datetime", + ]; +} diff --git a/app/common/model/ProductCompatRelBaseModel.php b/app/common/model/ProductCompatRelBaseModel.php new file mode 100644 index 00000000..b2500641 --- /dev/null +++ b/app/common/model/ProductCompatRelBaseModel.php @@ -0,0 +1,29 @@ + "int", + "language_id" => "int", + "product_id" => "int", + "compat_id" => "int", + "series_name" => "string", + "show_series_name" => "int", + "remark" => "int", + "created_at" => "datetime", + "updated_at" => "datetime", + ]; +}