432 lines
13 KiB
PHP
432 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Barryvdh\DomPDF\Facade\Pdf;
|
||
use Parsedown;
|
||
|
||
class WeekReportController extends Controller
|
||
{
|
||
/**
|
||
* 显示周报生成页面
|
||
*/
|
||
public function index()
|
||
{
|
||
return view('weekreport.index');
|
||
}
|
||
|
||
/**
|
||
* 上传任务图片
|
||
*/
|
||
public function uploadImage(Request $request)
|
||
{
|
||
$request->validate([
|
||
'image' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||
]);
|
||
|
||
try {
|
||
$file = $request->file('image');
|
||
$filename = time() . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||
|
||
// 存储到public目录
|
||
$path = $file->storeAs('uploads', $filename, 'public');
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'path' => $path,
|
||
'url' => asset('storage/' . $path),
|
||
]);
|
||
} catch (\Exception $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => '图片上传失败: ' . $e->getMessage(),
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成周报
|
||
*/
|
||
public function generate(Request $request)
|
||
{
|
||
// 设置较长的执行时间,因为AI生成可能需要较长时间
|
||
set_time_limit(300); // 5分钟
|
||
|
||
$request->validate([
|
||
'tasks' => 'required|array|min:1',
|
||
'tasks.*.description' => 'required|string|max:2000',
|
||
'tasks.*.image' => 'nullable|string',
|
||
'week_start' => 'nullable|date',
|
||
'week_end' => 'nullable|date',
|
||
'author' => 'nullable|string|max:100',
|
||
'next_week_tasks' => 'nullable|array',
|
||
'next_week_tasks.*' => 'nullable|string|max:2000',
|
||
]);
|
||
|
||
$tasks = $request->input('tasks');
|
||
$weekStart = $request->input('week_start') ?? date('Y-m-d', strtotime('monday this week'));
|
||
$weekEnd = $request->input('week_end') ?? date('Y-m-d', strtotime('sunday this week'));
|
||
$author = (string) ($request->input('author') ?? '');
|
||
$nextWeekTasks = $request->input('next_week_tasks', []);
|
||
|
||
// 构建提示词
|
||
$taskList = $this->buildTaskList($tasks);
|
||
$nextWeekList = $this->buildNextWeekTaskList($nextWeekTasks);
|
||
$prompt = $this->buildPrompt($taskList, $weekStart, $weekEnd, $author, $nextWeekList);
|
||
|
||
try {
|
||
// 调用千问API
|
||
$reportContent = $this->callQwenApi($prompt);
|
||
|
||
// 插入图片到报告中
|
||
$reportWithImages = $this->insertImagesToReport($reportContent, $tasks);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'report' => $reportWithImages,
|
||
'raw_report' => $reportContent,
|
||
]);
|
||
} catch (\Exception $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'message' => '生成周报失败: ' . $e->getMessage(),
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构建任务列表文本
|
||
*/
|
||
private function buildTaskList(array $tasks): string
|
||
{
|
||
$list = [];
|
||
foreach ($tasks as $index => $task) {
|
||
$num = $index + 1;
|
||
$desc = trim($task['description']);
|
||
$hasImage = !empty($task['image']) ? '(有截图)' : '';
|
||
$list[] = "{$num}. {$desc}{$hasImage}";
|
||
}
|
||
return implode("\n", $list);
|
||
}
|
||
|
||
/**
|
||
* 构建下周任务列表文本
|
||
*/
|
||
private function buildNextWeekTaskList(array $tasks): string
|
||
{
|
||
if (empty($tasks)) {
|
||
return '';
|
||
}
|
||
|
||
$list = [];
|
||
foreach ($tasks as $index => $desc) {
|
||
if (!empty(trim($desc))) {
|
||
$num = $index + 1;
|
||
$list[] = "{$num}. " . trim($desc);
|
||
}
|
||
}
|
||
return implode("\n", $list);
|
||
}
|
||
|
||
/**
|
||
* 构建完整提示词
|
||
*/
|
||
private function buildPrompt(string $taskList, string $weekStart, string $weekEnd, string $author, string $nextWeekList = ''): string
|
||
{
|
||
$authorInfo = $author ? "作者:{$author}" : "";
|
||
|
||
// 根据是否有下周计划来调整要求
|
||
$nextWeekSection = '';
|
||
$nextWeekRequirement = '';
|
||
|
||
if (!empty(trim($nextWeekList))) {
|
||
$nextWeekSection = "\n\n下周计划任务:\n{$nextWeekList}";
|
||
$nextWeekRequirement = "\n8. 添加\"下周工作计划\"部分,简要列出计划要做的事项";
|
||
} else {
|
||
$nextWeekRequirement = "\n8. 不要添加下周工作计划部分";
|
||
}
|
||
|
||
return <<<PROMPT
|
||
你是一位经验丰富的职场人士,擅长撰写简洁实用的工作周报。请根据以下任务信息,用自然、朴实的语言写一份周报。
|
||
|
||
时间:{$weekStart} 至 {$weekEnd}
|
||
{$authorInfo}
|
||
|
||
本周工作内容:
|
||
{$taskList}{$nextWeekSection}
|
||
|
||
写作要求:
|
||
1. 使用Markdown格式
|
||
2. 标题简洁,如"工作周报"或"本周工作汇报"
|
||
3. 语言要自然朴实,像正常人写的,避免过于华丽或模板化的表述
|
||
4. 不要使用"高效完成"、"圆满完成"、"取得显著成效"等套话
|
||
5. 每个任务用简短的一两句话描述即可,说清楚做了什么
|
||
6. 可以按项目或类型简单分组,但不要过度分类
|
||
7. 【重要】只有标注了"(有截图)"的任务才添加"[图片占位符-任务X]",其他任务不要添加{$nextWeekRequirement}
|
||
9. 总结部分一两句话概括即可,不要写得太官方
|
||
10. 不要出现具体的完成时间、耗时等信息
|
||
11. 整体篇幅适中,不要太长
|
||
|
||
直接输出周报内容:
|
||
PROMPT;
|
||
}
|
||
|
||
/**
|
||
* 调用千问API
|
||
*/
|
||
private function callQwenApi(string $prompt): string
|
||
{
|
||
$apiKey = config('services.qwen.api_key');
|
||
$apiUrl = config('services.qwen.api_url');
|
||
$model = config('services.qwen.model');
|
||
|
||
if (empty($apiKey)) {
|
||
throw new \Exception('API Key 未配置');
|
||
}
|
||
|
||
$response = Http::timeout(180)
|
||
->withoutVerifying()
|
||
->withHeaders([
|
||
'Authorization' => "Bearer {$apiKey}",
|
||
'Content-Type' => 'application/json',
|
||
])
|
||
->post($apiUrl, [
|
||
'model' => $model,
|
||
'messages' => [
|
||
[
|
||
'role' => 'system',
|
||
'content' => '你是一位专业的工作周报撰写助手,擅长将简单的任务描述转化为专业、详细的周报内容。'
|
||
],
|
||
[
|
||
'role' => 'user',
|
||
'content' => $prompt
|
||
]
|
||
],
|
||
'temperature' => 0.7,
|
||
'max_tokens' => 4000,
|
||
]);
|
||
|
||
if (!$response->successful()) {
|
||
$error = $response->json('error.message') ?? $response->body();
|
||
throw new \Exception("API调用失败: {$error}");
|
||
}
|
||
|
||
$data = $response->json();
|
||
|
||
if (!isset($data['choices'][0]['message']['content'])) {
|
||
throw new \Exception('API返回数据格式错误');
|
||
}
|
||
|
||
return $data['choices'][0]['message']['content'];
|
||
}
|
||
|
||
/**
|
||
* 在报告中插入图片
|
||
*/
|
||
private function insertImagesToReport(string $report, array $tasks): string
|
||
{
|
||
foreach ($tasks as $index => $task) {
|
||
$num = $index + 1;
|
||
|
||
// 支持多种占位符格式
|
||
$placeholders = [
|
||
"[图片占位符-任务{$num}]",
|
||
"[图片占位符-{$num}]",
|
||
"[图片占位符{$num}]",
|
||
"【图片占位符-任务{$num}】",
|
||
"【图片占位符-{$num}】",
|
||
"【图片占位符{$num}】",
|
||
];
|
||
|
||
if (!empty($task['image'])) {
|
||
$imageUrl = asset('storage/' . $task['image']);
|
||
$imageMarkdown = "\n\n\n";
|
||
foreach ($placeholders as $placeholder) {
|
||
$report = str_replace($placeholder, $imageMarkdown, $report);
|
||
}
|
||
} else {
|
||
// 没有图片时移除所有占位符
|
||
foreach ($placeholders as $placeholder) {
|
||
$report = str_replace($placeholder, '', $report);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理所有未使用的占位符(各种格式)
|
||
$report = preg_replace('/\[图片占位符[-]?任务?\d*\]/', '', $report);
|
||
$report = preg_replace('/【图片占位符[-]?任务?\d*】/', '', $report);
|
||
$report = preg_replace('/\[?图片占位符[-—]?\d+\]?/', '', $report);
|
||
|
||
// 清理可能产生的多余空行
|
||
$report = preg_replace('/\n{3,}/', "\n\n", $report);
|
||
|
||
return $report;
|
||
}
|
||
|
||
/**
|
||
* 下载Markdown格式
|
||
*/
|
||
public function downloadMarkdown(Request $request)
|
||
{
|
||
$request->validate([
|
||
'content' => 'required|string',
|
||
'filename' => 'nullable|string|max:100',
|
||
]);
|
||
|
||
$content = $request->input('content');
|
||
$filename = $request->input('filename', '周报_' . date('Y-m-d')) . '.md';
|
||
|
||
return response($content)
|
||
->header('Content-Type', 'text/markdown; charset=utf-8')
|
||
->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||
}
|
||
|
||
/**
|
||
* 下载PDF格式
|
||
*/
|
||
public function downloadPdf(Request $request)
|
||
{
|
||
$request->validate([
|
||
'content' => 'required|string',
|
||
'filename' => 'nullable|string|max:100',
|
||
]);
|
||
|
||
$markdownContent = $request->input('content');
|
||
$filename = $request->input('filename', '周报_' . date('Y-m-d')) . '.pdf';
|
||
|
||
// 将Markdown转换为HTML
|
||
$parsedown = new Parsedown();
|
||
$htmlContent = $parsedown->text($markdownContent);
|
||
|
||
// 添加样式
|
||
$styledHtml = $this->wrapHtmlWithStyle($htmlContent);
|
||
|
||
// 生成PDF - 启用远程字体和图片
|
||
$pdf = Pdf::loadHTML($styledHtml);
|
||
$pdf->setPaper('A4', 'portrait');
|
||
$pdf->setOption('isRemoteEnabled', true);
|
||
$pdf->setOption('defaultFont', 'sans-serif');
|
||
|
||
return $pdf->download($filename);
|
||
}
|
||
|
||
/**
|
||
* 为HTML添加样式
|
||
*/
|
||
private function wrapHtmlWithStyle(string $html): string
|
||
{
|
||
return <<<HTML
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||
<style>
|
||
@font-face {
|
||
font-family: 'NotoSansSC';
|
||
src: url('https://fonts.gstatic.com/s/notosanssc/v36/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYg.woff2') format('woff2');
|
||
font-weight: normal;
|
||
font-style: normal;
|
||
}
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
font-family: 'NotoSansSC', 'SimHei', 'Microsoft YaHei', sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
color: #333;
|
||
padding: 40px;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
h1 {
|
||
font-size: 24px;
|
||
color: #1a1a1a;
|
||
border-bottom: 3px solid #4a90d9;
|
||
padding-bottom: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
h2 {
|
||
font-size: 18px;
|
||
color: #2c3e50;
|
||
margin-top: 25px;
|
||
margin-bottom: 15px;
|
||
padding-left: 10px;
|
||
border-left: 4px solid #4a90d9;
|
||
}
|
||
h3 {
|
||
font-size: 16px;
|
||
color: #34495e;
|
||
margin-top: 20px;
|
||
margin-bottom: 10px;
|
||
}
|
||
p {
|
||
margin-bottom: 12px;
|
||
text-align: justify;
|
||
}
|
||
ul, ol {
|
||
margin-left: 25px;
|
||
margin-bottom: 15px;
|
||
}
|
||
li {
|
||
margin-bottom: 8px;
|
||
}
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
margin: 15px 0;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
}
|
||
code {
|
||
background-color: #f5f5f5;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-family: Consolas, monospace;
|
||
}
|
||
pre {
|
||
background-color: #f5f5f5;
|
||
padding: 15px;
|
||
border-radius: 5px;
|
||
overflow-x: auto;
|
||
margin-bottom: 15px;
|
||
}
|
||
blockquote {
|
||
border-left: 4px solid #ddd;
|
||
padding-left: 15px;
|
||
color: #666;
|
||
margin: 15px 0;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 15px 0;
|
||
}
|
||
th, td {
|
||
border: 1px solid #ddd;
|
||
padding: 10px;
|
||
text-align: left;
|
||
}
|
||
th {
|
||
background-color: #f5f5f5;
|
||
}
|
||
hr {
|
||
border: none;
|
||
border-top: 1px solid #ddd;
|
||
margin: 20px 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{$html}
|
||
</body>
|
||
</html>
|
||
HTML;
|
||
}
|
||
}
|