feat: 添加AI整理工作汇报功能

This commit is contained in:
kuaifan 2025-11-05 04:02:29 +00:00
parent e0443aa336
commit 7d98c5493e
4 changed files with 354 additions and 92 deletions

View File

@ -618,6 +618,68 @@ class ReportController extends AbstractController
]); ]);
} }
/**
* @api {post} api/report/ai_organize 整理工作汇报内容
*
* @apiDescription 需要token身份根据当前草稿重新整理工作汇报结构
* @apiVersion 1.0.0
* @apiGroup report
* @apiName ai_organize
*
* @apiParam {String} content 汇报内容HTML
* @apiParam {String} [title] 汇报标题
* @apiParam {String} [type] 汇报类型weekly/daily
* @apiParam {Array|String} [focus] 整理关注点(可选)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.html 整理后的内容HTML
*/
public function ai_organize(): array
{
$user = User::auth();
$content = trim((string)Request::input("content", ""));
if ($content === '') {
return Base::retError("汇报内容不能为空");
}
$title = trim((string)Request::input("title", ""));
$type = trim((string)Request::input("type", ""));
$markdown = Base::html2markdown($content);
if ($markdown === '') {
return Base::retError("汇报内容解析失败");
}
$context = array_filter([
'title' => $title,
'type' => $type,
]);
$focus = Request::input('focus');
if (is_array($focus)) {
$context['focus'] = $focus;
} elseif (is_string($focus) && trim($focus) !== '') {
$context['focus'] = [trim($focus)];
}
$result = AI::organizeReportContent($markdown, $context);
if (Base::isError($result)) {
return Base::retError("整理汇报失败", $result);
}
$data = $result['data'];
$html = Base::markdown2html($data['text']);
if (trim($html) === '') {
return Base::retError("整理后的内容为空");
}
return Base::retSuccess("success", [
'html' => $html,
]);
}
/** /**
* @api {get} api/report/mark 标记已读/未读 * @api {get} api/report/mark 标记已读/未读
* *

View File

@ -635,9 +635,8 @@ class AI
return Base::retError("报告内容为空,无法进行分析"); return Base::retError("报告内容为空,无法进行分析");
} }
$model = "gpt-5-mini";
$post = json_encode([ $post = json_encode([
"model" => $model, "model" => "gpt-5-mini",
"reasoning_effort" => "minimal", "reasoning_effort" => "minimal",
"messages" => [ "messages" => [
[ [
@ -679,7 +678,76 @@ class AI
return Base::retSuccess("success", [ return Base::retSuccess("success", [
'text' => $content, 'text' => $content,
'model' => $model, ]);
}
/**
* 整理优化工作汇报内容
* @param string $markdown 用户当前的工作汇报Markdown
* @param array $context 上下文信息
* @return array
*/
public static function organizeReportContent(string $markdown, array $context = [])
{
$markdown = trim((string)$markdown);
if ($markdown === '') {
return Base::retError("工作汇报内容不能为空");
}
$prompt = self::buildReportOrganizePrompt($markdown, $context);
if ($prompt === '') {
return Base::retError("整理内容为空");
}
$post = json_encode([
"model" => "gpt-5-mini",
"reasoning_effort" => "minimal",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名资深的职场写作顾问,擅长根据已有的工作汇报草稿进行整理、结构化和措辞优化。
工作任务:
1. 保留草稿中的事实、数据和结论,确保信息准确无误
2. 重新组织结构,让内容清晰分段(如「重点进展」「成果亮点」「问题与风险」「后续计划」等),并按照草稿中的时间范围或类型进行表达
3. 用简洁、专业且积极的语气描述,并突出可复用的要点
4. 支持使用 Markdown 标题、列表、引用、表格等语法增强可读性,但不要返回 HTML 或代码块
5. 若草稿信息不完整,可合理推测缺失项并以占位符提示(如「待补充」),不要臆造细节
输出要求:
- 仅返回整理后的 Markdown 正文内容,并用于直接替换原草稿
- 不得输出任何汇报名称、汇报对象、汇报类型或其他元信息,即便草稿或上下文中存在这些字段
- 不加额外说明、指引、总结或前缀后缀
- 内容保持精炼、结构清晰
EOF
],
[
"role" => "user",
"content" => $prompt,
],
],
]);
$ai = new self($post);
$ai->setTimeout(60);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("汇报整理失败", $res);
}
$content = trim($res['data']);
$content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content);
$content = preg_replace('/\s*```\s*$/', '', $content);
$content = trim($content);
if ($content === '') {
return Base::retError("汇报整理结果为空");
}
return Base::retSuccess("success", [
'text' => $content,
]); ]);
} }
@ -1010,6 +1078,48 @@ class AI
return trim(implode("\n\n", array_filter($sections))); return trim(implode("\n\n", array_filter($sections)));
} }
/**
* 构建工作汇报整理的提示词
* @param string $markdown
* @param array $context
* @return string
*/
private static function buildReportOrganizePrompt(string $markdown, array $context = []): string
{
$sections = [];
$infoLines = [];
if (!empty($context['title'])) {
$infoLines[] = "汇报标题:" . trim($context['title']);
}
if (!empty($context['type'])) {
$typeLabel = match ($context['type']) {
Report::WEEKLY => "周报",
Report::DAILY => "日报",
default => $context['type'],
};
$infoLines[] = "汇报类型:" . $typeLabel;
}
if (!empty($context['focus']) && is_array($context['focus'])) {
$focusItems = array_filter(array_map('trim', $context['focus']));
if (!empty($focusItems)) {
$sections[] = "### 需要重点整理的方向\n" . implode("\n", array_map(function ($line) {
return "- " . $line;
}, $focusItems));
}
}
if (!empty($infoLines)) {
$sections[] = "### 汇报背景(仅供参考,请勿写入输出)\n" . implode("\n", array_map(function ($line) {
return "- " . $line;
}, $infoLines));
}
$sections[] = "### 原始汇报草稿(请整理后仅输出正文内容)\n" . $markdown;
return trim(implode("\n\n", array_filter($sections)));
}
/** /**
* 获取 ollama 模型 * 获取 ollama 模型
* @param $baseUrl * @param $baseUrl

View File

@ -1,4 +1,5 @@
<template> <template>
<div class="report-edit-wrapper">
<Form class="report-edit" v-bind="formOptions" @submit.native.prevent> <Form class="report-edit" v-bind="formOptions" @submit.native.prevent>
<FormItem :label="$L('汇报类型')"> <FormItem :label="$L('汇报类型')">
<RadioGroup <RadioGroup
@ -42,9 +43,34 @@
<TEditor v-model="reportData.content" height="100%"/> <TEditor v-model="reportData.content" height="100%"/>
</FormItem> </FormItem>
<FormItem class="report-foot"> <FormItem class="report-foot">
<div class="report-bottoms">
<Button type="primary" @click="handleSubmit" :loading="loadIng > 0" class="report-bottom">{{$L(id > 0 ? '修改' : '提交')}}</Button> <Button type="primary" @click="handleSubmit" :loading="loadIng > 0" class="report-bottom">{{$L(id > 0 ? '修改' : '提交')}}</Button>
<Button
type="default"
class="report-bottom"
:loading="aiOrganizeLoading"
@click="onOrganize">
<Icon type="md-construct" />
{{ $L("AI 整理汇报") }}
</Button>
</div>
</FormItem> </FormItem>
</Form> </Form>
<Modal
v-model="organizePreviewVisible"
:title="$L('整理结果预览')"
:mask-closable="false"
:styles="{
width: '90%',
maxWidth: '800px'
}">
<div class="report-content organize-preview user-select-auto" v-html="organizeResult.html"></div>
<div slot="footer" class="adaption">
<Button type="default" @click="closeOrganizePreview">{{ $L("取消") }}</Button>
<Button type="primary" @click="applyOrganize" :loading="aiOrganizeLoading">{{ $L("应用到汇报") }}</Button>
</div>
</Modal>
</div>
</template> </template>
<script> <script>
@ -67,6 +93,12 @@ export default {
return { return {
loadIng: 0, loadIng: 0,
receiveLoad: 0, receiveLoad: 0,
aiOrganizeLoading: false,
organizePreviewVisible: false,
organizeResult: {
html: '',
model: '',
},
reportData: { reportData: {
sign: "", sign: "",
@ -242,6 +274,52 @@ export default {
this.reportData.content = ""; this.reportData.content = "";
this.reportData.receive = []; this.reportData.receive = [];
this.reportData.id = 0; this.reportData.id = 0;
},
onOrganize() {
if (!this.reportData.content || !this.reportData.content.trim()) {
$A.messageWarning(this.$L("请先填写汇报内容"));
return;
}
if (this.aiOrganizeLoading) {
return;
}
this.aiOrganizeLoading = true;
this.$store.dispatch("call", {
url: 'report/ai_organize',
method: 'post',
data: {
content: this.reportData.content,
title: this.reportData.title,
type: this.reportData.type,
},
timeout: 60 * 1000,
}).then(({data}) => {
this.organizeResult = data || {html: '', model: ''};
if (!this.organizeResult.html) {
$A.messageWarning(this.$L("AI 未返回整理内容"));
return;
}
this.organizePreviewVisible = true;
}).catch(({msg}) => {
$A.messageError(msg);
}).finally(() => {
this.aiOrganizeLoading = false;
});
},
closeOrganizePreview() {
this.organizePreviewVisible = false;
},
applyOrganize() {
if (!this.organizeResult.html) {
$A.messageWarning(this.$L("没有可应用的内容"));
return;
}
this.reportData.content = this.organizeResult.html;
this.organizePreviewVisible = false;
$A.messageSuccess(this.$L("已应用整理结果"));
} }
} }
} }

View File

@ -109,6 +109,7 @@
} }
} }
} }
}
.report-content { .report-content {
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
@ -116,6 +117,12 @@
margin-top: 24px; margin-top: 24px;
width: 100%; width: 100%;
&.organize-preview {
border-top: none;
padding-top: 0;
margin-top: 0;
}
ul, ol, li { ul, ol, li {
margin: revert; margin: revert;
padding: revert; padding: revert;
@ -166,7 +173,6 @@
max-width: 100%; max-width: 100%;
} }
} }
}
.report-edit { .report-edit {
position: absolute; position: absolute;
@ -307,6 +313,12 @@
.report-foot { .report-foot {
margin-bottom: 0; margin-bottom: 0;
}
.report-bottoms {
display: flex;
align-items: center;
gap: 12px;
.report-bottom { .report-bottom {
height: 38px; height: 38px;
line-height: 36px; line-height: 36px;