Files
bsh_sample/app/admin/controller/SkuController.php
T
2025-12-16 15:37:34 +08:00

699 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\model\PostCategory;
use app\common\model\Sku;
use app\common\model\SkuAttribute;
use app\common\service\OssService;
use app\common\service\UploadService;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\SkuService;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpWord\IOFactory as WordIOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
use support\Db;
use support\Request;
use support\Response;
use ZipArchive;
class SkuController extends BaseController
{
use CrudControllerTrait;
protected $noNeedLogin = ['template'];
protected $noNeedHeaderData = ['template'];
protected $service;
public function __construct()
{
$this->service = new SkuService();
}
/**
* 创建
*
* @param Request $request 请求对象
* @return mixed 返回创建成功的数据或信息
*/
public function store(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'store');
$res = $this->service->serviceStore($request, $data);
return $this->success($res, '创建成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['spec_json'])){
$data['spec'] = json_decode($data['spec_json'], true);
foreach ($data['spec'] as $k=>$v){
$data['spec'][$k]['attr_type'] = SkuAttribute::where('id', $v['attr_id'])->value('attr_type');
}
}
if (isset($data['category_id']))
$data['category_name'] = PostCategory::where('id', $data['category_id'])->value('name');
$ossservice = new OssService();
if (!empty($data['image'])){
$data['image'] = explode(',',$data['image']);
foreach ($data['image'] as $key => $image){
$data['image_pull'][$key] = $ossservice->generatePrivateUrl($image);
}
}else{
$data['image'] = [];
$data['image_pull'] = [];
}
if (!empty($data['main_image'])){
$data['main_image_pull'] = $ossservice->generatePrivateUrl($data['main_image']);
} else {
$data['main_image_pull'] = [];
}
return $data;
}
public function import(Request $request)
{
$file = $request->file('file');
if (!$file) return $this->error('未上传文件');;
$zipPath = runtime_path('upload/' . uniqid() . '.zip');
$file->move($zipPath);
$extractTo = runtime_path('upload/import/');
clearstatcache(true, $zipPath);
$this->unzipPackage($zipPath,$extractTo);
$path = runtime_path('upload/import/product.xlsx');
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
// 提取图片(按行号)
//$rowImages = $this->extractImages($sheet);
$highestRow = $sheet->getHighestRow();
$highestCol = $sheet->getHighestColumn();
$header = $sheet->rangeToArray("A1:{$highestCol}1")[0];
// $header = array_map('trim', $header);
$allowFields = [
'product_name' => '产品名称',
'model' => '产品型号',
'brand' => '品牌',
'category_id' => '分类',
'price' => '价格',
//'spec_json' => '商品属性'
];
$fieldsName = array_flip($allowFields);
$insertData = [];
$now = time();
Db::beginTransaction();
$uploadService = new UploadService();
try {
for ($row = 2; $row <= $highestRow; $row++) {
$rowData = $sheet->rangeToArray(
"A{$row}:{$highestCol}{$row}", null, true, false
)[0];
$data = [];
foreach ($header as $i => $field) {
if (in_array($field, $allowFields)) {
$data[$fieldsName[$field]] = trim((string)($rowData[$i] ?? ''));
}
}
if (empty($data['product_name'])) {
continue;
}
$data['category_id'] = PostCategory::where('name', $rowData[3])->value('id')??0;
//商品属性
if(!empty($rowData[5])){
$speci = explode(';', $rowData[5]);
$spec = [];
foreach ($speci as $k => $v){
$item = explode(':', $v);
if (count($item) != 2) {
continue;
}
$attr = ['attr_name'=>$item[0],'attr_value'=>$item[1]];
$skuAttribute = SkuAttribute::where('attr_name', $item[0])
->where('category_id', $data['category_id'])->first();
if (empty($skuAttribute)) {
$skuAttribute = SkuAttribute::create([
'attr_name' => $item[0],
'attr_value' => $item[1],
'category_id' => $data['category_id'],
'attr_type' => 'text'
]);
}
$attr['attr_id'] = $skuAttribute->id;
$attr['category_id'] = $data['category_id'];
$spec[] = $attr;
$data['spec_json'] = json_encode($spec);
}
}
//主图
if(!empty($rowData[6])){
$tmpPath = runtime_path('upload/import/pic/'.$rowData[6]);
$res = $uploadService->uploadLocalFile(['path' => $tmpPath, 'scene' => 'sku']);
$data['main_image'] = $res['file_name'];
}
//详情
if(!empty($rowData[7])){
$docxFullPath = runtime_path('upload/import/docs/'.$rowData[7]);
if (filesize($docxFullPath) !== 0 && file_exists($zipPath)) {
$data['desc'] =$this->docxToHtmlWithImages($docxFullPath);
}
//$data['desc'] =$this->docxToHtmlWithImages($docxFullPath);
}
$data['code'] = $this->service->generateUniqueCode();
$data['brand'] = $data['brand'] ?: '西门子';
$data['price'] = $data['price'] ?: 0;
$data['status'] = $data['status'] ?? 1;
// 图片
//$data['main_image'] = $rowImages[$row] ?? null;
$data['created_at'] = $now;
$data['updated_at'] = $now;
$insertData[] = $data;
if (count($insertData) >= 200) {
Sku::insert($insertData);
// Db::table('sku')->insert($insertData);
$insertData = [];
}
@unlink($zipPath);
}
if ($insertData) {
Sku::insert($insertData);
// Db::table('sku')->insert($insertData);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollBack();
return $this->error($e->getMessage().$e->getLine().$e->getFile());
} finally {
@unlink($path);
}
return $this->success([], '导入成功');
}
/**
* 解压 zip 文件
*
* @param string $zipPath zip 文件绝对路径
* @param string $extractTo 解压目录
* @return bool
*/
function unzipPackage(string $zipPath, string $extractTo): bool
{
if (!file_exists($zipPath)) {
throw new \Exception('ZIP 文件不存在');
}
if (!is_dir($extractTo)) {
mkdir($extractTo, 0755, true);
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new \Exception('ZIP 文件打开失败');
}
$zip->extractTo($extractTo);
$zip->close();
return true;
}
/**
* docx 转 HTML(自动上传图片到 OSS)
*
* @param string $docxPath docx 文件绝对路径
* @return string HTML
*/
function docxToHtmlWithImages(string $docxPath): string
{
if (!is_file($docxPath)) {
return '';
}
try {
// 1. 创建临时目录
$tmpDir = runtime_path('tmp/' . uniqid('docx_', true));
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
// 2. 解压 docx 文件(docx 本质上是 zip 文件)
$zip = new ZipArchive();
if ($zip->open($docxPath) === true) {
$zip->extractTo($tmpDir);
$zip->close();
} else {
throw new Exception('无法解压 docx 文件');
}
// 3. 读取文档内容
$documentXml = $tmpDir . '/word/document.xml';
if (!is_file($documentXml)) {
throw new Exception('文档内容文件不存在');
}
// 4. 创建 DOM 对象
$dom = new DOMDocument();
$dom->load($documentXml);
// 5. 处理图片
$uploadService = new UploadService();
$images = $dom->getElementsByTagName('drawing');
foreach ($images as $image) {
// 查找图片引用
$blip = $image->getElementsByTagName('blip');
if ($blip->length > 0) {
$embedId = $blip->item(0)->getAttribute('r:embed');
if ($embedId) {
// 查找关系文件中的图片路径
$relationshipsXml = $tmpDir . '/word/_rels/document.xml.rels';
if (is_file($relationshipsXml)) {
$relsDom = new DOMDocument();
$relsDom->load($relationshipsXml);
$xpath = new DOMXPath($relsDom);
$xpath->registerNamespace('r', 'http://schemas.openxmlformats.org/package/2006/relationships');
$query = "//r:Relationship[@Id='$embedId']";
$nodes = $xpath->query($query);
if ($nodes->length > 0) {
$target = $nodes->item(0)->getAttribute('Target');
$imagePath = $tmpDir . '/word/' . $target;
if (is_file($imagePath)) {
// 上传到 OSS
$ossUrl = $uploadService->uploadLocalFile([
'path' => $imagePath,
'scene' => 'desc'
]);
// 创建 img 标签
$imgNode = $dom->createElement('img');
$imgNode->setAttribute('src', $ossUrl);
$imgNode->setAttribute('alt', '图片');
// 替换 drawing 标签
$image->parentNode->replaceChild($imgNode, $image);
}
}
}
}
}
}
// 6. 将文档内容转换为 HTML
$htmlContent = $this->convertDocumentXmlToHtml($dom, $tmpDir);
// 7. 清理 HTML
$htmlContent = $this->cleanHtmlContent($htmlContent);
// 8. 清理临时目录
$this->deleteDirectory($tmpDir);
return $htmlContent;
} catch (\Exception $e) {
// 清理临时目录
if (isset($tmpDir) && is_dir($tmpDir)) {
$this->deleteDirectory($tmpDir);
}
error_log("docx转HTML失败: " . $e->getMessage());
return '';
}
}
/**
* 将 document.xml 转换为 HTML
*/
private function convertDocumentXmlToHtml(DOMDocument $dom, string $tmpDir): string
{
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$html = '';
$bodyNodes = $xpath->query('//w:body/w:*');
foreach ($bodyNodes as $node) {
$html .= $this->convertNodeToHtml($node, $xpath);
}
return $html;
}
/**
* 将 XML 节点转换为 HTML
*/
private function convertNodeToHtml(DOMNode $node, DOMXPath $xpath): string
{
$html = '';
$nodeName = $node->nodeName;
switch ($nodeName) {
case 'w:p': // 段落
$html .= '<p>';
$textNodes = $xpath->query('.//w:t', $node);
foreach ($textNodes as $textNode) {
$html .= htmlspecialchars($textNode->nodeValue);
}
$html .= '</p>';
break;
case 'w:tbl': // 表格
$html .= '<table border="1" style="border-collapse: collapse; width: 100%;">';
$rows = $xpath->query('.//w:tr', $node);
foreach ($rows as $row) {
$html .= '<tr>';
$cells = $xpath->query('.//w:tc', $row);
foreach ($cells as $cell) {
$html .= '<td>';
$cellTextNodes = $xpath->query('.//w:t', $cell);
foreach ($cellTextNodes as $cellText) {
$html .= htmlspecialchars($cellText->nodeValue);
}
$html .= '</td>';
}
$html .= '</tr>';
}
$html .= '</table>';
break;
case 'w:r': // 文本运行(可能是加粗等格式)
$bold = $xpath->query('.//w:b', $node)->length > 0;
$italic = $xpath->query('.//w:i', $node)->length > 0;
$underline = $xpath->query('.//w:u', $node)->length > 0;
$text = '';
$textNodes = $xpath->query('.//w:t', $node);
foreach ($textNodes as $textNode) {
$text .= htmlspecialchars($textNode->nodeValue);
}
if ($bold) {
$text = '<strong>' . $text . '</strong>';
}
if ($italic) {
$text = '<em>' . $text . '</em>';
}
if ($underline) {
$text = '<u>' . $text . '</u>';
}
$html .= $text;
break;
default:
// 处理其他节点
break;
}
return $html;
}
/**
* 清理 HTML 内容
*/
private function cleanHtmlContent(string $html): string
{
// 1. 清理多余的空格和换行
$html = preg_replace('/\s+/', ' ', $html);
$html = preg_replace('/>\s+</', '><', $html);
// 2. 清理空的段落
$html = preg_replace('/<p>\s*<\/p>/', '', $html);
// 3. 确保图片有正确的样式
$html = preg_replace_callback(
'/<img([^>]+)>/',
function($matches) {
$attrs = $matches[1];
// 检查是否有样式
if (!str_contains($attrs, 'style=')) {
return '<img' . $attrs . ' style="max-width: 100%; height: auto;">';
}
return '<img' . $attrs . '>';
},
$html
);
return trim($html);
}
/**
* 删除目录
*/
private function deleteDirectory(string $dir): bool
{
if (!is_dir($dir)) {
return false;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : @unlink($path);
}
return @rmdir($dir);
}
/**
* docx 转 HTML(富文本可用,图片上传 OSS)
*/
/*function docxToHtmlWithImages(string $docxPath): string
{
if (!is_file($docxPath)) {
return '';
}
$phpWord = \PhpOffice\PhpWord\IOFactory::load($docxPath);
$tmpDir = runtime_path('tmp/' . uniqid());
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
$tmpHtml = $tmpDir . '/index.html';
$writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'HTML');
$writer->save($tmpHtml);
$html = file_get_contents($tmpHtml);
// 1️⃣ 上传图片到 OSS
$uploadService = new UploadService();
$html = preg_replace_callback(
'/<img[^>]+src="([^"]+)"[^>]*>/i',
function ($m) use ($tmpDir, $uploadService) {
$src = $m[1];
if (!str_starts_with($src, 'http')) {
$localImg = $tmpDir . '/' . ltrim($src, './');
if (is_file($localImg)) {
$ossUrl = $uploadService->uploadLocalFile([
'path' => $localImg,
'scene' => 'desc'
]);
return preg_replace('/src="[^"]+"/', 'src="'.$ossUrl['file_url'].'"', $m[0]);
}
}
return $m[0];
},
$html
);
// // 2️⃣ 关键:HTML 清洗(适配 wangEditor)
$html = $this->cleanWordHtmlForWangEditor($html);
return $html;
}*/
function deleteDir(string $dir): void
{
if (!is_dir($dir)) return;
foreach (scandir($dir) as $file) {
if ($file === '.' || $file === '..') continue;
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDir($path) : @unlink($path);
}
@rmdir($dir);
}
function cleanWordHtmlForWangEditor(string $html): string
{
// 1. 移除 Word 专属标签
$html = preg_replace('/<o:p>.*?<\/o:p>/is', '', $html);
$html = preg_replace('/<\/?xml[^>]*>/i', '', $html);
$html = preg_replace('/<\/?v:[^>]*>/i', '', $html);
// 2. 移除 class / style
$html = preg_replace('/\sclass="[^"]*"/i', '', $html);
$html = preg_replace('/\sstyle="[^"]*"/i', '', $html);
// 3. 清理 span(保留内容)
while (preg_match('/<span[^>]*>(.*?)<\/span>/is', $html)) {
$html = preg_replace('/<span[^>]*>(.*?)<\/span>/is', '$1', $html);
}
// 4. 转换 div → p
$html = preg_replace('/<div[^>]*>/i', '<p>', $html);
$html = preg_replace('/<\/div>/i', '</p>', $html);
// 5. img 只保留 src
$html = preg_replace('/<img[^>]*src="([^"]+)"[^>]*>/i', '<img src="$1">', $html);
// 6. table 清洗
$html = preg_replace('/<table[^>]*>/i', '<table>', $html);
$html = preg_replace('/<td[^>]*>/i', '<td>', $html);
$html = preg_replace('/<th[^>]*>/i', '<th>', $html);
// 7. 去除多余空白
$html = preg_replace('/\s+/u', ' ', $html);
$html = str_replace(['<p></p>', '<p> </p>'], '', $html);
return trim($html);
}
/**
* docx 转 HTML(自动上传图片到 OSS)
*
* @param string $docxPath docx 文件绝对路径
* @return string HTML
*/
/*function docxToHtmlWithImages(string $docxPath): string
{
if (!is_file($docxPath)) {
return '';
}
// 1. 读取 docx
$phpWord = WordIOFactory::load($docxPath);
// 2. 输出 HTML 到临时文件
$tmpHtml = runtime_path('tmp/' . uniqid() . '.html');
if (!is_dir(dirname($tmpHtml))) {
mkdir(dirname($tmpHtml), 0755, true);
}
$writer = WordIOFactory::createWriter($phpWord, 'HTML');
$writer->save($tmpHtml);
$html = file_get_contents($tmpHtml);
$uploadService = new UploadService();
// 3. 处理 HTML 中的图片
$html = preg_replace_callback(
'/<img[^>]+src="([^"]+)"[^>]*>/i',
function ($matches) use ($tmpHtml,$uploadService) {
$src = $matches[1];
// docx 导出的图片一般是相对路径
if (!str_starts_with($src, 'http')) {
$localImg = dirname($tmpHtml) . '/' . $src;
if (is_file($localImg)) {
// 上传到 OSS
$ossUrl = $uploadService->uploadLocalFile(['path' => $localImg, 'scene' => 'desc']);
return str_replace($src, $ossUrl, $matches[0]);
}
}
return $matches[0];
},
$html
);
// 清理临时文件
@unlink($tmpHtml);
return $html;
}*/
private function extractImages($sheet): array
{
$images = [];
$uploadService = new UploadService();
foreach ($sheet->getDrawingCollection() as $drawing) {
if ($drawing instanceof Drawing) {
// 取行号
preg_match('/\d+/', $drawing->getCoordinates(), $m);
$row = (int)$m[0];
// Excel 临时图片路径
$tmpPath = $drawing->getPath();
if (!is_file($tmpPath)) {
continue;
}
// 上传到 OSS
$ossUrl = $uploadService->uploadLocalFile(['path' => $tmpPath, 'scene' => 'sku']);
// 一行只保留一张主图
$images[$row] = $ossUrl['file_url'];
}
}
return $images;
}
/**
* 下载 Excel 示例模板
*/
public function template(Request $request): Response
{
$filePath = runtime_path() . '/upload/import.xlsx';
if (!file_exists($filePath)) {
return response('文件不存在', 404);
}
return response()->download(
$filePath,
'导入示例.xlsx' // 下载时显示的文件名
);
}
}