diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 5c2a86087..9d93dd041 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -2651,6 +2651,55 @@ class ProjectController extends AbstractController return Base::retSuccess('生成任务成功', $result['data']); } + /** + * @api {post} api/project/ai/generate 41. 使用 AI 助手生成项目 + * + * @apiDescription 需要token身份,根据需求说明自动生成项目名称及任务列表 + * @apiVersion 1.0.0 + * @apiGroup project + * @apiName ai__generate + * + * @apiParam {String} content 项目需求或背景描述(必填) + * @apiParam {String} [current_name] 当前草拟的项目名称 + * @apiParam {Array|String} [current_columns] 已有任务列表(数组或以逗号/换行分隔的字符串) + * @apiParam {Array} [template_examples] 可参考的模板示例,格式:[ {name: 模板名, columns: [列表...] }, ... ] + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + * @apiSuccess {String} data.name AI 生成的项目名称 + * @apiSuccess {Array} data.columns AI 生成的任务列表名称数组 + */ + public function ai__generate() + { + User::auth(); + + $content = trim((string)Request::input('content', '')); + if ($content === '') { + return Base::retError('项目需求描述不能为空'); + } + + $templateExamples = Request::input('template_examples', []); + if (!is_array($templateExamples)) { + $templateExamples = []; + } else { + $templateExamples = array_slice($templateExamples, 0, 6); + } + + $context = [ + 'current_name' => Request::input('current_name', ''), + 'current_columns' => Request::input('current_columns', []), + 'template_examples' => $templateExamples, + ]; + + $result = AI::generateProject($content, $context); + if (Base::isError($result)) { + return Base::retError('生成项目失败', $result); + } + + return Base::retSuccess('生成项目成功', $result['data']); + } + /** * @api {get} api/project/flow/list 40. 工作流列表 * diff --git a/app/Module/AI.php b/app/Module/AI.php index eac63fd16..acba3c5a0 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -440,6 +440,117 @@ class AI ]); } + /** + * 通过 openAI 生成项目名称与任务列表 + * @param string $text 项目需求或描述 + * @param array $context 上下文信息 + * @return array + */ + public static function generateProject($text, $context = []) + { + $text = trim((string)$text); + if ($text === '') { + return Base::retError("项目描述不能为空"); + } + + $context['current_name'] = trim($context['current_name'] ?? ''); + $context['current_columns'] = self::normalizeProjectColumns($context['current_columns'] ?? []); + + if (!empty($context['template_examples']) && is_array($context['template_examples'])) { + $examples = []; + foreach ($context['template_examples'] as $item) { + $name = trim($item['name'] ?? ''); + $columns = self::normalizeProjectColumns($item['columns'] ?? []); + if (empty($columns)) { + continue; + } + $examples[] = [ + 'name' => $name, + 'columns' => $columns, + ]; + if (count($examples) >= 6) { + break; + } + } + $context['template_examples'] = $examples; + } else { + $context['template_examples'] = []; + } + + $contextPrompt = self::buildProjectContextPrompt($context); + + $post = json_encode([ + "model" => "gpt-5-nano", + "messages" => [ + [ + "role" => "system", + "content" => << "user", + "content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,为以下需求生成适合的项目名称和任务列表:\n\n" . $text + ], + ], + ]); + + $ai = new self($post); + $ai->setTimeout(45); + + $res = $ai->request(); + if (Base::isError($res)) { + return Base::retError("项目生成失败", $res); + } + + $content = $res['data']; + $content = preg_replace('/^\s*```json\s*/', '', $content); + $content = preg_replace('/\s*```\s*$/', '', $content); + + if (empty($content)) { + return Base::retError("项目生成结果为空"); + } + + $parsedData = Base::json2array($content); + if (!$parsedData || !isset($parsedData['name'])) { + return Base::retError("项目生成格式错误", $content); + } + + $name = trim($parsedData['name']); + $columns = self::normalizeProjectColumns($parsedData['columns'] ?? []); + + if ($name === '') { + return Base::retError("生成的项目名称为空", $parsedData); + } + + if (empty($columns)) { + $columns = $context['current_columns']; + } + + return Base::retSuccess("success", [ + 'name' => $name, + 'columns' => $columns, + ]); + } + /** * 构建任务生成的上下文提示信息 * @param array $context 上下文信息 @@ -494,6 +605,66 @@ class AI return empty($prompts) ? "" : implode("\n", $prompts); } + private static function buildProjectContextPrompt($context) + { + $prompts = []; + + if (!empty($context['current_name']) || !empty($context['current_columns'])) { + $prompts[] = "## 当前项目草稿"; + if (!empty($context['current_name'])) { + $prompts[] = "已有名称:" . $context['current_name']; + } + if (!empty($context['current_columns'])) { + $prompts[] = "现有任务列表:" . implode("、", $context['current_columns']); + } + $prompts[] = "请在此基础上进行优化和补充。"; + } + + if (!empty($context['template_examples'])) { + $prompts[] = "## 常用模板示例"; + foreach ($context['template_examples'] as $example) { + $line = ''; + if (!empty($example['name'])) { + $line .= $example['name'] . ":"; + } + $line .= implode("、", $example['columns']); + $prompts[] = "- " . $line; + } + $prompts[] = "可以借鉴以上结构,但要结合用户需求生成更贴合的方案。"; + } + + return empty($prompts) ? "" : implode("\n", $prompts); + } + + private static function normalizeProjectColumns($columns) + { + if (is_string($columns)) { + $columns = preg_split('/[\n\r,,;;|]/u', $columns); + } + + $normalized = []; + if (is_array($columns)) { + foreach ($columns as $item) { + if (is_array($item)) { + $item = $item['name'] ?? $item['title'] ?? reset($item); + } + $item = trim((string)$item); + if ($item === '') { + continue; + } + $item = mb_substr($item, 0, 30); + if (!in_array($item, $normalized)) { + $normalized[] = $item; + } + if (count($normalized) >= 8) { + break; + } + } + } + + return $normalized; + } + /** * 通过 openAI 生成职场笑话、心灵鸡汤 * @param bool $noCache 是否禁用缓存 diff --git a/resources/assets/js/pages/manage.vue b/resources/assets/js/pages/manage.vue index 60586e235..4ce7ce44d 100644 --- a/resources/assets/js/pages/manage.vue +++ b/resources/assets/js/pages/manage.vue @@ -245,7 +245,16 @@ v-bind="formOptions" @submit.native.prevent> - +
+ +
+ +
+
@@ -455,6 +464,7 @@ export default { columns: '', flow: 'open', }, + projectAiLoading: false, addRule: { name: [ { required: true, message: this.$L('请填写项目名称!'), trigger: 'change' }, @@ -989,12 +999,82 @@ export default { onAddShow() { this.$store.dispatch("getColumnTemplate").catch(() => {}) + this.projectAiLoading = false; this.addShow = true; this.$nextTick(() => { this.$refs.projectName.focus(); }) }, + onProjectAI() { + if (this.projectAiLoading) { + return; + } + $A.modalInput({ + title: 'AI 生成', + placeholder: '请简要描述项目目标、范围或关键里程碑,AI 将生成名称和任务列表', + inputProps: { + type: 'textarea', + rows: 2, + autosize: {minRows: 2, maxRows: 6}, + maxlength: 500, + }, + onOk: (value) => { + if (!value) { + return '请输入项目需求'; + } + return new Promise((resolve, reject) => { + this.projectAiLoading = true; + const parseColumns = (cols) => { + if (Array.isArray(cols)) { + return cols; + } + if (typeof cols === 'string') { + return cols.split(/[\n\r,,;;|]/).map(item => item.trim()).filter(item => item); + } + return []; + }; + const templateExamples = this.columns + .filter((item, index) => index > 0 && item && item.columns && String(item.columns).trim() !== '') + .slice(0, 6) + .map(item => ({ + name: item.name, + columns: parseColumns(item.columns) + })); + + const finish = () => { + this.projectAiLoading = false; + }; + + this.$store.dispatch("call", { + url: 'project/ai/generate', + data: { + content: value, + current_name: this.addData.name || '', + current_columns: this.addData.columns || '', + template_examples: templateExamples, + }, + timeout: 45 * 1000, + }).then(({data}) => { + const columns = Array.isArray(data.columns) ? data.columns : parseColumns(data.columns); + this.$set(this.addData, 'name', data.name || ''); + this.$set(this.addData, 'columns', columns.length > 0 ? columns.join(',') : ''); + this.$nextTick(() => { + if (this.$refs.projectName) { + this.$refs.projectName.focus(); + } + }); + finish(); + resolve(); + }).catch(({msg}) => { + finish(); + reject(msg); + }); + }); + } + }) + }, + onAddProject() { this.$refs.addProject.validate((valid) => { if (valid) { diff --git a/resources/assets/js/pages/manage/components/TaskAdd.vue b/resources/assets/js/pages/manage/components/TaskAdd.vue index 4e3b9833d..b64663006 100644 --- a/resources/assets/js/pages/manage/components/TaskAdd.vue +++ b/resources/assets/js/pages/manage/components/TaskAdd.vue @@ -627,7 +627,7 @@ export default { onAI() { $A.modalInput({ title: 'AI 生成', - placeholder: `请输入任务需求,AI 将自动生成标题和详细描述`, + placeholder: '请简要描述任务目标、背景或预期交付,AI 将生成标题、详细说明和子任务', inputProps: { type: 'textarea', rows: 2, diff --git a/resources/assets/sass/pages/page-manage.scss b/resources/assets/sass/pages/page-manage.scss index aa88b5769..0aed79e86 100644 --- a/resources/assets/sass/pages/page-manage.scss +++ b/resources/assets/sass/pages/page-manage.scss @@ -473,6 +473,35 @@ } } +.page-manage-project-ai-wrapper { + position: relative; + + .ivu-input-wrapper { + flex: 1; + } + + .project-ai-button { + position: absolute; + right: 0; + top: 50%; + height: 32px; + transform: translateY(-50%); + padding: 0 8px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + transition: opacity 0.2s; + cursor: pointer; + .taskfont { + font-size: 18px; + } + &:hover { + opacity: 1; + } + } +} + @media (height <= 640px) { .page-manage { .manage-box-menu {