内容库优化 + 内容库导入功能

This commit is contained in:
wong
2025-12-09 14:59:24 +08:00
parent 9fe15b1bec
commit c9a5d3091f
8 changed files with 601 additions and 11 deletions

View File

@@ -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");
}

View File

@@ -14,7 +14,7 @@ export function getContentLibraryDetail(id: string): Promise<any> {
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
}
// 更新内容库

View File

@@ -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");

View File

@@ -12,7 +12,7 @@ export function getContentLibraryList(params: {
keyword?: string;
sourceType?: number;
}): Promise<any> {
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<any> {
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
}
// 更新内容库

View File

@@ -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");
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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<string>("");
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={
<>
<Button
type="default"
onClick={() => setShowImportPopup(true)}
style={{ marginRight: 8 }}
>
<UploadOutlined />
</Button>
<Button type="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
</>
}
/>
{/* 搜索栏 */}
@@ -586,6 +660,71 @@ const MaterialsList: React.FC = () => {
</div>
</div>
</Popup>
{/* 导入弹窗 */}
<Popup
visible={showImportPopup}
onMaskClick={closeImportPopup}
bodyStyle={{
borderRadius: "16px 16px 0 0",
maxHeight: "90vh",
}}
>
<div className={style["import-popup-content"]}>
<div className={style["import-popup-header"]}>
<h3></h3>
<Button
size="small"
onClick={closeImportPopup}
>
</Button>
</div>
<div className={style["import-form"]}>
<div className={style["import-form-item"]}>
<div className={style["import-form-label"]}>Excel文件</div>
<div className={style["import-form-control"]}>
<FileUpload
value={importFileUrl}
onChange={(url) => {
const fileUrl = Array.isArray(url) ? url[0] : url;
setImportFileUrl(fileUrl || "");
}}
acceptTypes={["excel"]}
maxSize={50}
maxCount={1}
showPreview={false}
/>
<div className={style["import-tip"]}>
Excel格式的文件50MB
</div>
</div>
</div>
<div className={style["import-actions"]}>
<Button
block
color="primary"
onClick={handleImport}
loading={importLoading}
disabled={importLoading || !importFileUrl}
>
{importLoading ? "导入中..." : "确认导入"}
</Button>
<Button
block
color="danger"
fill="outline"
onClick={closeImportPopup}
style={{ marginTop: 12 }}
>
</Button>
</div>
</div>
</div>
</Popup>
</Layout>
);
};

View File

@@ -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;
}
}