mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-11 18:42:54 +00:00
feat: 添加AI整理工作汇报功能
This commit is contained in:
parent
e0443aa336
commit
7d98c5493e
@ -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 标记已读/未读
|
||||
*
|
||||
|
||||
@ -635,9 +635,8 @@ class AI
|
||||
return Base::retError("报告内容为空,无法进行分析");
|
||||
}
|
||||
|
||||
$model = "gpt-5-mini";
|
||||
$post = json_encode([
|
||||
"model" => $model,
|
||||
"model" => "gpt-5-mini",
|
||||
"reasoning_effort" => "minimal",
|
||||
"messages" => [
|
||||
[
|
||||
@ -679,7 +678,76 @@ class AI
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建工作汇报整理的提示词
|
||||
* @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 模型
|
||||
* @param $baseUrl
|
||||
|
||||
@ -1,50 +1,76 @@
|
||||
<template>
|
||||
<Form class="report-edit" v-bind="formOptions" @submit.native.prevent>
|
||||
<FormItem :label="$L('汇报类型')">
|
||||
<RadioGroup
|
||||
type="button"
|
||||
button-style="solid"
|
||||
v-model="reportData.type"
|
||||
@on-change="typeChange"
|
||||
class="report-radiogroup"
|
||||
:readonly="id > 0">
|
||||
<Radio label="weekly" :disabled="id > 0 && reportData.type =='daily'">{{ $L("周报") }}</Radio>
|
||||
<Radio label="daily" :disabled="id > 0 && reportData.type =='weekly'">{{ $L("日报") }}</Radio>
|
||||
</RadioGroup>
|
||||
<ButtonGroup v-if="id === 0" class="report-buttongroup">
|
||||
<ETooltip :disabled="$isEEUIApp || windowTouch" :content="prevCycleText" placement="bottom">
|
||||
<Button type="primary" @click="prevCycle">
|
||||
<Icon type="ios-arrow-back" />
|
||||
<div class="report-edit-wrapper">
|
||||
<Form class="report-edit" v-bind="formOptions" @submit.native.prevent>
|
||||
<FormItem :label="$L('汇报类型')">
|
||||
<RadioGroup
|
||||
type="button"
|
||||
button-style="solid"
|
||||
v-model="reportData.type"
|
||||
@on-change="typeChange"
|
||||
class="report-radiogroup"
|
||||
:readonly="id > 0">
|
||||
<Radio label="weekly" :disabled="id > 0 && reportData.type =='daily'">{{ $L("周报") }}</Radio>
|
||||
<Radio label="daily" :disabled="id > 0 && reportData.type =='weekly'">{{ $L("日报") }}</Radio>
|
||||
</RadioGroup>
|
||||
<ButtonGroup v-if="id === 0" class="report-buttongroup">
|
||||
<ETooltip :disabled="$isEEUIApp || windowTouch" :content="prevCycleText" placement="bottom">
|
||||
<Button type="primary" @click="prevCycle">
|
||||
<Icon type="ios-arrow-back" />
|
||||
</Button>
|
||||
</ETooltip>
|
||||
<div class="report-buttongroup-vertical"></div>
|
||||
<ETooltip :disabled="$isEEUIApp || windowTouch || reportData.offset >= 0" :content="nextCycleText" placement="bottom">
|
||||
<Button type="primary" @click="nextCycle" :disabled="reportData.offset >= 0">
|
||||
<Icon type="ios-arrow-forward" />
|
||||
</Button>
|
||||
</ETooltip>
|
||||
</ButtonGroup>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('汇报名称')">
|
||||
<Input v-model="reportData.title" disabled/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('汇报对象')">
|
||||
<div class="report-users">
|
||||
<UserSelect v-model="reportData.receive" :disabledChoice="[userId]" :title="$L('选择接收人')"/>
|
||||
<a class="report-user-link" href="javascript:void(0);" @click="getLastSubmitter">
|
||||
<Icon v-if="receiveLoad > 0" type="ios-loading" class="icon-loading"/>
|
||||
<Icon v-else type="ios-share-outline" />
|
||||
{{ $L("使用我上次的汇报对象") }}
|
||||
</a>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('汇报内容')" class="report-content-editor">
|
||||
<TEditor v-model="reportData.content" height="100%"/>
|
||||
</FormItem>
|
||||
<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="default"
|
||||
class="report-bottom"
|
||||
:loading="aiOrganizeLoading"
|
||||
@click="onOrganize">
|
||||
<Icon type="md-construct" />
|
||||
{{ $L("AI 整理汇报") }}
|
||||
</Button>
|
||||
</ETooltip>
|
||||
<div class="report-buttongroup-vertical"></div>
|
||||
<ETooltip :disabled="$isEEUIApp || windowTouch || reportData.offset >= 0" :content="nextCycleText" placement="bottom">
|
||||
<Button type="primary" @click="nextCycle" :disabled="reportData.offset >= 0">
|
||||
<Icon type="ios-arrow-forward" />
|
||||
</Button>
|
||||
</ETooltip>
|
||||
</ButtonGroup>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('汇报名称')">
|
||||
<Input v-model="reportData.title" disabled/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('汇报对象')">
|
||||
<div class="report-users">
|
||||
<UserSelect v-model="reportData.receive" :disabledChoice="[userId]" :title="$L('选择接收人')"/>
|
||||
<a class="report-user-link" href="javascript:void(0);" @click="getLastSubmitter">
|
||||
<Icon v-if="receiveLoad > 0" type="ios-loading" class="icon-loading"/>
|
||||
<Icon v-else type="ios-share-outline" />
|
||||
{{ $L("使用我上次的汇报对象") }}
|
||||
</a>
|
||||
</div>
|
||||
</FormItem>
|
||||
</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>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('汇报内容')" class="report-content-editor">
|
||||
<TEditor v-model="reportData.content" height="100%"/>
|
||||
</FormItem>
|
||||
<FormItem class="report-foot">
|
||||
<Button type="primary" @click="handleSubmit" :loading="loadIng > 0" class="report-bottom">{{$L(id > 0 ? '修改' : '提交')}}</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -67,6 +93,12 @@ export default {
|
||||
return {
|
||||
loadIng: 0,
|
||||
receiveLoad: 0,
|
||||
aiOrganizeLoading: false,
|
||||
organizePreviewVisible: false,
|
||||
organizeResult: {
|
||||
html: '',
|
||||
model: '',
|
||||
},
|
||||
|
||||
reportData: {
|
||||
sign: "",
|
||||
@ -242,7 +274,53 @@ export default {
|
||||
this.reportData.content = "";
|
||||
this.reportData.receive = [];
|
||||
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("已应用整理结果"));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
100
resources/assets/sass/components/report.scss
vendored
100
resources/assets/sass/components/report.scss
vendored
@ -109,62 +109,68 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report-content {
|
||||
border-top: 1px solid #eeeeee;
|
||||
padding-top: 24px;
|
||||
margin-top: 24px;
|
||||
.report-content {
|
||||
border-top: 1px solid #eeeeee;
|
||||
padding-top: 24px;
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
&.organize-preview {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
ul, ol, li {
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
|
||||
ul, ol, li {
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
th, td {
|
||||
line-height: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
th {
|
||||
background: #f8f8f8;
|
||||
padding: 10px 16px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
|
||||
th, td {
|
||||
line-height: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f8f8;
|
||||
padding: 10px 16px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td {
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
td {
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
margin-bottom: 20px;
|
||||
padding-left: 18px;
|
||||
ol {
|
||||
margin-bottom: 20px;
|
||||
padding-left: 18px;
|
||||
|
||||
li {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
li {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -307,6 +313,12 @@
|
||||
|
||||
.report-foot {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-bottoms {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.report-bottom {
|
||||
height: 38px;
|
||||
line-height: 36px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user