diff --git a/Cunkebao/src/components/ContentSelection/api.ts b/Cunkebao/src/components/ContentSelection/api.ts index a4d4bf3e..f7919df0 100644 --- a/Cunkebao/src/components/ContentSelection/api.ts +++ b/Cunkebao/src/components/ContentSelection/api.ts @@ -1,5 +1,5 @@ import request from "@/api/request"; export function getContentLibraryList(params: any) { - return request("/v1/content/library/list", params, "GET"); + return request("/v1/content/library/list", { ...params, formType: 0 }, "GET"); } diff --git a/Cunkebao/src/pages/mobile/mine/content/form/api.ts b/Cunkebao/src/pages/mobile/mine/content/form/api.ts index bc136d88..c00414d4 100644 --- a/Cunkebao/src/pages/mobile/mine/content/form/api.ts +++ b/Cunkebao/src/pages/mobile/mine/content/form/api.ts @@ -14,7 +14,7 @@ export function getContentLibraryDetail(id: string): Promise { export function createContentLibrary( params: CreateContentLibraryParams, ): Promise { - return request("/v1/content/library/create", params, "POST"); + return request("/v1/content/library/create", { ...params, formType: 0 }, "POST"); } // 更新内容库 diff --git a/Cunkebao/src/pages/mobile/mine/content/form/index.tsx b/Cunkebao/src/pages/mobile/mine/content/form/index.tsx index 8d55e3f7..98527516 100644 --- a/Cunkebao/src/pages/mobile/mine/content/form/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/content/form/index.tsx @@ -162,7 +162,7 @@ export default function ContentForm() { await updateContentLibrary({ id, ...payload }); Toast.show({ content: "保存成功", position: "top" }); } else { - await request("/v1/content/library/create", payload, "POST"); + await request("/v1/content/library/create", { ...payload, formType: 0 }, "POST"); Toast.show({ content: "创建成功", position: "top" }); } navigate("/mine/content"); diff --git a/Cunkebao/src/pages/mobile/mine/content/list/api.ts b/Cunkebao/src/pages/mobile/mine/content/list/api.ts index 51821b6f..f1ca83db 100644 --- a/Cunkebao/src/pages/mobile/mine/content/list/api.ts +++ b/Cunkebao/src/pages/mobile/mine/content/list/api.ts @@ -12,7 +12,7 @@ export function getContentLibraryList(params: { keyword?: string; sourceType?: number; }): Promise { - return request("/v1/content/library/list", params, "GET"); + return request("/v1/content/library/list", { ...params, formType: 0 }, "GET"); } // 获取内容库详情 @@ -24,7 +24,7 @@ export function getContentLibraryDetail(id: string): Promise { export function createContentLibrary( params: CreateContentLibraryParams, ): Promise { - return request("/v1/content/library/create", params, "POST"); + return request("/v1/content/library/create", { ...params, formType: 0 }, "POST"); } // 更新内容库 diff --git a/Cunkebao/src/pages/mobile/mine/content/materials/list/api.ts b/Cunkebao/src/pages/mobile/mine/content/materials/list/api.ts index e1d6edea..037a90e6 100644 --- a/Cunkebao/src/pages/mobile/mine/content/materials/list/api.ts +++ b/Cunkebao/src/pages/mobile/mine/content/materials/list/api.ts @@ -47,3 +47,11 @@ export function aiRewriteContent(params: AIRewriteParams) { export function replaceContent(params: ReplaceContentParams) { return request("/v1/content/library/aiEditContent", params, "POST"); } + +// 导入Excel素材 +export function importMaterialsFromExcel(params: { + id: string; + fileUrl: string; +}) { + return request("/v1/content/library/import-excel", params, "POST"); +} diff --git a/Cunkebao/src/pages/mobile/mine/content/materials/list/index.module.scss b/Cunkebao/src/pages/mobile/mine/content/materials/list/index.module.scss index 29c91883..834b4e26 100644 --- a/Cunkebao/src/pages/mobile/mine/content/materials/list/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/content/materials/list/index.module.scss @@ -776,3 +776,87 @@ } } } + +// 导入弹窗样式 +.import-popup-content { + padding: 20px; + max-height: 90vh; + overflow-y: auto; + background: #ffffff; + + .import-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid #e8f4ff; + + h3 { + font-size: 18px; + font-weight: 600; + color: #1677ff; + margin: 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + width: 4px; + height: 18px; + background: #1677ff; + margin-right: 8px; + border-radius: 2px; + } + } + } + + .import-form { + .import-form-item { + margin-bottom: 20px; + + .import-form-label { + font-size: 15px; + font-weight: 500; + color: #333; + margin-bottom: 8px; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + width: 3px; + height: 14px; + background: #1677ff; + margin-right: 6px; + border-radius: 2px; + } + } + + .import-form-control { + .import-tip { + font-size: 12px; + color: #999; + margin-top: 8px; + line-height: 1.5; + } + } + } + + .import-actions { + margin-top: 24px; + + button { + height: 44px; + font-size: 16px; + border-radius: 8px; + + &:first-child { + box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2); + } + } + } + } +} diff --git a/Cunkebao/src/pages/mobile/mine/content/materials/list/index.tsx b/Cunkebao/src/pages/mobile/mine/content/materials/list/index.tsx index aef8c2a8..77d0a864 100644 --- a/Cunkebao/src/pages/mobile/mine/content/materials/list/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/content/materials/list/index.tsx @@ -15,10 +15,12 @@ import { VideoCameraOutlined, FileTextOutlined, AppstoreOutlined, + UploadOutlined, } from "@ant-design/icons"; import Layout from "@/components/Layout/Layout"; import NavCommon from "@/components/NavCommon"; -import { getContentItemList, deleteContentItem, aiRewriteContent, replaceContent } from "./api"; +import { getContentItemList, deleteContentItem, aiRewriteContent, replaceContent, importMaterialsFromExcel } from "./api"; +import FileUpload from "@/components/Upload/FileUpload"; import { ContentItem } from "./data"; import style from "./index.module.scss"; @@ -50,6 +52,11 @@ const MaterialsList: React.FC = () => { const [aiLoading, setAiLoading] = useState(false); const [replaceLoading, setReplaceLoading] = useState(false); + // 导入相关状态 + const [showImportPopup, setShowImportPopup] = useState(false); + const [importFileUrl, setImportFileUrl] = useState(""); + const [importLoading, setImportLoading] = useState(false); + // 获取素材列表 const fetchMaterials = useCallback(async () => { if (!id) return; @@ -187,6 +194,64 @@ const MaterialsList: React.FC = () => { fetchMaterials(); }; + // 处理导入文件上传 + const handleImportFileChange = (fileInfo: { fileName: string; fileUrl: string }) => { + setImportFileUrl(fileInfo.fileUrl); + }; + + // 执行导入 + const handleImport = async () => { + if (!id) { + Toast.show({ + content: "内容库ID不存在", + position: "top", + }); + return; + } + + if (!importFileUrl) { + Toast.show({ + content: "请先上传Excel文件", + position: "top", + }); + return; + } + + try { + setImportLoading(true); + await importMaterialsFromExcel({ + id: id, + fileUrl: importFileUrl, + }); + + Toast.show({ + content: "导入成功", + position: "top", + }); + + // 关闭弹窗并重置状态 + setShowImportPopup(false); + setImportFileUrl(""); + + // 刷新素材列表 + fetchMaterials(); + } catch (error: unknown) { + console.error("导入失败:", error); + Toast.show({ + content: error instanceof Error ? error.message : "导入失败,请重试", + position: "top", + }); + } finally { + setImportLoading(false); + } + }; + + // 关闭导入弹窗 + const closeImportPopup = () => { + setShowImportPopup(false); + setImportFileUrl(""); + }; + const handlePageChange = (page: number) => { setCurrentPage(page); }; @@ -354,9 +419,18 @@ const MaterialsList: React.FC = () => { title="素材管理" backFn={() => navigate("/mine/content")} right={ - + <> + + + } /> {/* 搜索栏 */} @@ -586,6 +660,71 @@ const MaterialsList: React.FC = () => { + + {/* 导入弹窗 */} + +
+
+

导入素材

+ +
+ +
+
+
选择Excel文件
+
+ { + const fileUrl = Array.isArray(url) ? url[0] : url; + setImportFileUrl(fileUrl || ""); + }} + acceptTypes={["excel"]} + maxSize={50} + maxCount={1} + showPreview={false} + /> +
+ 请上传Excel格式的文件,文件大小不超过50MB +
+
+
+ +
+ + +
+
+
+
); }; diff --git a/Server/application/cunkebao/controller/ContentLibraryController.php b/Server/application/cunkebao/controller/ContentLibraryController.php index 93d13a32..8ca3ebcd 100644 --- a/Server/application/cunkebao/controller/ContentLibraryController.php +++ b/Server/application/cunkebao/controller/ContentLibraryController.php @@ -15,6 +15,8 @@ use think\facade\Cache; use think\facade\Env; use app\api\controller\AutomaticAssign; use think\facade\Request; +use PHPExcel_IOFactory; +use app\common\util\AliyunOSS; /** * 内容库控制器 @@ -92,6 +94,8 @@ class ContentLibraryController extends Controller 'timeEnd' => isset($param['endTime']) ? strtotime($param['endTime']) : 0, // 结束时间(转换为时间戳) // 来源类型 'sourceType' => $sourceType, // 1=好友,2=群,3=好友和群 + // 表单类型 + 'formType' => isset($param['formType']) ? intval($param['formType']) : 0, // 表单类型,默认为0 // 基础信息 'status' => isset($param['status']) ? $param['status'] : 0, // 状态:0=禁用,1=启用 'userId' => $this->request->userInfo['id'], @@ -127,6 +131,7 @@ class ContentLibraryController extends Controller $limit = $this->request->param('limit', 10); $keyword = $this->request->param('keyword', ''); $sourceType = $this->request->param('sourceType', ''); // 来源类型,1=好友,2=群 + $formType = $this->request->param('formType', ''); // 表单类型筛选 $companyId = $this->request->userInfo['companyId']; $userId = $this->request->userInfo['id']; $isAdmin = !empty($this->request->userInfo['isAdmin']); @@ -152,12 +157,17 @@ class ContentLibraryController extends Controller $where[] = ['sourceType', '=', $sourceType]; } + // 添加表单类型筛选 + if ($formType !== '') { + $where[] = ['formType', '=', $formType]; + } + // 获取总记录数 $total = ContentLibrary::where($where)->count(); // 获取分页数据 $list = ContentLibrary::where($where) - ->field('id,name,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,sourceType,userId,createTime,updateTime') + ->field('id,name,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,sourceType,formType,userId,createTime,updateTime') ->with(['user' => function ($query) { $query->field('id,username'); }]) @@ -319,7 +329,7 @@ class ContentLibraryController extends Controller // 查询内容库信息 $library = ContentLibrary::where($where) - ->field('id,name,sourceType,devices ,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,userId,companyId,createTime,updateTime,groupMembers,catchType') + ->field('id,name,sourceType,formType,devices ,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,userId,companyId,createTime,updateTime,groupMembers,catchType') ->find(); if (empty($library)) { @@ -470,6 +480,7 @@ class ContentLibraryController extends Controller $library->timeEnabled = isset($param['timeEnabled']) ? $param['timeEnabled'] : 0; $library->timeStart = isset($param['startTime']) ? strtotime($param['startTime']) : 0; $library->timeEnd = isset($param['endTime']) ? strtotime($param['endTime']) : 0; + $library->formType = isset($param['formType']) ? intval($param['formType']) : $library->formType; // 表单类型,如果未传则保持原值 $library->status = isset($param['status']) ? $param['status'] : 0; $library->updateTime = time(); $library->save(); @@ -2384,4 +2395,352 @@ class ContentLibraryController extends Controller return false; } } + + /** + * 导入Excel表格(支持图片导入) + * @return \think\response\Json + */ + public function importExcel() + { + try { + $libraryId = $this->request->param('id', 0); + $companyId = $this->request->userInfo['companyId']; + $userId = $this->request->userInfo['id']; + $isAdmin = !empty($this->request->userInfo['isAdmin']); + + if (empty($libraryId)) { + return json(['code' => 400, 'msg' => '内容库ID不能为空']); + } + + // 验证内容库权限 + $libraryWhere = [ + ['id', '=', $libraryId], + ['companyId', '=', $companyId], + ['isDel', '=', 0] + ]; + + if (!$isAdmin) { + $libraryWhere[] = ['userId', '=', $userId]; + } + + $library = ContentLibrary::where($libraryWhere)->find(); + if (empty($library)) { + return json(['code' => 500, 'msg' => '内容库不存在或无权限访问']); + } + + // 获取文件(可能是上传的文件或远程URL) + $fileUrl = $this->request->param('fileUrl', ''); + $file = Request::file('file'); + $tmpFile = ''; + + if (!empty($fileUrl)) { + // 处理远程URL + if (!preg_match('/^https?:\/\//i', $fileUrl)) { + return json(['code' => 400, 'msg' => '无效的文件URL']); + } + + // 验证文件扩展名 + $urlExt = strtolower(pathinfo(parse_url($fileUrl, PHP_URL_PATH), PATHINFO_EXTENSION)); + if (!in_array($urlExt, ['xls', 'xlsx'])) { + return json(['code' => 400, 'msg' => '只支持Excel文件(.xls, .xlsx)']); + } + + // 下载远程文件到临时目录 + $tmpFile = tempnam(sys_get_temp_dir(), 'excel_import_') . '.' . $urlExt; + $fileContent = @file_get_contents($fileUrl); + + if ($fileContent === false) { + return json(['code' => 400, 'msg' => '下载远程文件失败,请检查URL是否可访问']); + } + + file_put_contents($tmpFile, $fileContent); + + } elseif ($file) { + // 处理上传的文件 + $ext = strtolower($file->getExtension()); + if (!in_array($ext, ['xls', 'xlsx'])) { + return json(['code' => 400, 'msg' => '只支持Excel文件(.xls, .xlsx)']); + } + + // 保存临时文件 + $tmpFile = $file->getRealPath(); + if (empty($tmpFile)) { + $savePath = $file->move(sys_get_temp_dir()); + $tmpFile = $savePath->getRealPath(); + } + } else { + return json(['code' => 400, 'msg' => '请上传Excel文件或提供文件URL']); + } + + if (empty($tmpFile) || !file_exists($tmpFile)) { + return json(['code' => 400, 'msg' => '文件不存在或无法访问']); + } + + // 加载Excel文件 + $excel = PHPExcel_IOFactory::load($tmpFile); + $sheet = $excel->getActiveSheet(); + + // 获取所有图片 + $images = []; + try { + $drawings = $sheet->getDrawingCollection(); + foreach ($drawings as $drawing) { + if ($drawing instanceof \PHPExcel_Worksheet_Drawing) { + $coordinates = $drawing->getCoordinates(); + $imagePath = $drawing->getPath(); + + // 如果是嵌入的图片(zip://格式),提取到临时文件 + if (strpos($imagePath, 'zip://') === 0) { + $zipEntry = str_replace('zip://', '', $imagePath); + $zipEntry = explode('#', $zipEntry); + $zipFile = $zipEntry[0]; + $imageEntry = isset($zipEntry[1]) ? $zipEntry[1] : ''; + + if (!empty($imageEntry)) { + $zip = new \ZipArchive(); + if ($zip->open($zipFile) === true) { + $imageContent = $zip->getFromName($imageEntry); + if ($imageContent !== false) { + $tempImageFile = tempnam(sys_get_temp_dir(), 'excel_img_'); + file_put_contents($tempImageFile, $imageContent); + $images[$coordinates] = $tempImageFile; + } + $zip->close(); + } + } + } elseif (file_exists($imagePath)) { + // 如果是外部文件路径 + $images[$coordinates] = $imagePath; + } + } elseif ($drawing instanceof \PHPExcel_Worksheet_MemoryDrawing) { + // 处理内存中的图片 + $coordinates = $drawing->getCoordinates(); + $imageResource = $drawing->getImageResource(); + + if ($imageResource) { + $tempImageFile = tempnam(sys_get_temp_dir(), 'excel_img_') . '.png'; + $imageType = $drawing->getMimeType(); + + switch ($imageType) { + case 'image/png': + imagepng($imageResource, $tempImageFile); + break; + case 'image/jpeg': + case 'image/jpg': + imagejpeg($imageResource, $tempImageFile); + break; + case 'image/gif': + imagegif($imageResource, $tempImageFile); + break; + default: + imagepng($imageResource, $tempImageFile); + } + + $images[$coordinates] = $tempImageFile; + } + } + } + } catch (\Exception $e) { + \think\facade\Log::error('提取Excel图片失败:' . $e->getMessage()); + } + + // 读取数据(实际内容从第三行开始,前两行是标题和说明) + $data = $sheet->toArray(); + if (count($data) < 3) { + return json(['code' => 400, 'msg' => 'Excel文件数据为空']); + } + + // 移除前两行(标题行和说明行) + array_shift($data); // 移除第1行 + array_shift($data); // 移除第2行 + + $successCount = 0; + $failCount = 0; + $errors = []; + + Db::startTrans(); + try { + foreach ($data as $rowIndex => $row) { + $rowNum = $rowIndex + 3; // Excel行号(从3开始,因为前两行是标题和说明) + + // 跳过空行 + if (empty(array_filter($row))) { + continue; + } + + try { + // 解析数据(根据图片中的表格结构) + // A:日期, B:投放时间, C:作用分类, D:朋友圈文案, E:自回评内容, F:朋友圈展示形式, G-O:配图1-9 + $date = isset($row[0]) ? trim($row[0]) : ''; + $placementTime = isset($row[1]) ? trim($row[1]) : ''; + $functionCategory = isset($row[2]) ? trim($row[2]) : ''; + $content = isset($row[3]) ? trim($row[3]) : ''; + $selfReply = isset($row[4]) ? trim($row[4]) : ''; + $displayForm = isset($row[5]) ? trim($row[5]) : ''; + + // 如果没有朋友圈文案,跳过 + if (empty($content)) { + continue; + } + + // 提取配图(G-O列,索引6-14) + $imageUrls = []; + for ($colIndex = 6; $colIndex <= 14; $colIndex++) { + $columnLetter = $this->columnLetter($colIndex); + $cellCoordinate = $columnLetter . $rowNum; + + // 检查是否有图片 + if (isset($images[$cellCoordinate])) { + $imagePath = $images[$cellCoordinate]; + + // 上传图片到OSS + $imageExt = 'jpg'; + if (file_exists($imagePath)) { + $imageInfo = @getimagesize($imagePath); + if ($imageInfo) { + $imageExt = image_type_to_extension($imageInfo[2], false); + if ($imageExt === 'jpeg') { + $imageExt = 'jpg'; + } + } + } + + $objectName = AliyunOSS::generateObjectName('excel_img_' . $rowNum . '_' . ($colIndex - 5) . '.' . $imageExt); + $uploadResult = AliyunOSS::uploadFile($imagePath, $objectName); + + if ($uploadResult['success']) { + $imageUrls[] = $uploadResult['url']; + } + } + } + + // 解析日期和时间 + $createMomentTime = 0; + if (!empty($date)) { + // 尝试解析日期格式:2025年11月25日 或 2025-11-25 + $dateStr = $date; + if (preg_match('/(\d{4})[年\-](\d{1,2})[月\-](\d{1,2})/', $dateStr, $matches)) { + $year = $matches[1]; + $month = str_pad($matches[2], 2, '0', STR_PAD_LEFT); + $day = str_pad($matches[3], 2, '0', STR_PAD_LEFT); + + // 解析时间 + $hour = 0; + $minute = 0; + if (!empty($placementTime) && preg_match('/(\d{1,2}):(\d{2})/', $placementTime, $timeMatches)) { + $hour = intval($timeMatches[1]); + $minute = intval($timeMatches[2]); + } + + $createMomentTime = strtotime("{$year}-{$month}-{$day} {$hour}:{$minute}:00"); + } + } + + if ($createMomentTime == 0) { + $createMomentTime = time(); + } + + // 判断内容类型 + $contentType = 4; // 默认文本 + if (!empty($imageUrls)) { + $contentType = 1; // 图文 + } + + // 创建内容项 + $item = new ContentItem(); + $item->libraryId = $libraryId; + $item->type = 'diy'; // 自定义类型 + $item->title = !empty($date) ? $date . ' ' . $placementTime : '导入的内容'; + $item->content = $content; + $item->comment = $selfReply; // 自回评内容 + $item->contentType = $contentType; + $item->resUrls = json_encode($imageUrls, JSON_UNESCAPED_UNICODE); + $item->urls = json_encode([], JSON_UNESCAPED_UNICODE); + $item->createMomentTime = $createMomentTime; + $item->createTime = time(); + + // 设置封面图片 + if (!empty($imageUrls[0])) { + $item->coverImage = $imageUrls[0]; + } + + // 保存其他信息到contentData + $contentData = [ + 'date' => $date, + 'placementTime' => $placementTime, + 'functionCategory' => $functionCategory, + 'displayForm' => $displayForm, + 'selfReply' => $selfReply + ]; + $item->contentData = json_encode($contentData, JSON_UNESCAPED_UNICODE); + + $item->save(); + $successCount++; + + } catch (\Exception $e) { + $failCount++; + $errors[] = "第{$rowNum}行处理失败:" . $e->getMessage(); + \think\facade\Log::error('导入Excel第' . $rowNum . '行失败:' . $e->getMessage()); + } + } + + Db::commit(); + + // 清理临时图片文件 + foreach ($images as $imagePath) { + if (file_exists($imagePath) && strpos($imagePath, sys_get_temp_dir()) === 0) { + @unlink($imagePath); + } + } + + // 清理临时Excel文件 + if (file_exists($tmpFile) && strpos($tmpFile, sys_get_temp_dir()) === 0) { + @unlink($tmpFile); + } + + return json([ + 'code' => 200, + 'msg' => '导入完成', + 'data' => [ + 'success' => $successCount, + 'fail' => $failCount, + 'errors' => $errors + ] + ]); + + } catch (\Exception $e) { + Db::rollback(); + + // 清理临时文件 + foreach ($images as $imagePath) { + if (file_exists($imagePath) && strpos($imagePath, sys_get_temp_dir()) === 0) { + @unlink($imagePath); + } + } + if (file_exists($tmpFile) && strpos($tmpFile, sys_get_temp_dir()) === 0) { + @unlink($tmpFile); + } + + return json(['code' => 500, 'msg' => '导入失败:' . $e->getMessage()]); + } + + } catch (\Exception $e) { + return json(['code' => 500, 'msg' => '导入失败:' . $e->getMessage()]); + } + } + + /** + * 根据列序号生成Excel列字母 + * @param int $index 列索引(从0开始) + * @return string 列字母(如A, B, C, ..., Z, AA, AB等) + */ + private function columnLetter($index) + { + $letters = ''; + do { + $letters = chr($index % 26 + 65) . $letters; + $index = intval($index / 26) - 1; + } while ($index >= 0); + return $letters; + } } \ No newline at end of file