Add async AI generation tasks

This commit is contained in:
王鹏
2026-05-24 13:55:14 +08:00
parent 6682a44a43
commit bb7291f7d1
29 changed files with 2304 additions and 30 deletions

View File

@@ -76,6 +76,49 @@ export function generateBusinessBlueprint(projectId, data) {
}).then(unwrap)
}
export function createAiTask(projectId, data) {
return request({
url: `/front/project/${projectId}/ai-tasks`,
method: 'post',
data
}).then(unwrap)
}
export function getAiTask(projectId, taskId) {
return request({
url: `/front/project/${projectId}/ai-tasks/${taskId}`,
method: 'get'
}).then(unwrap)
}
export function retryAiTask(projectId, taskId) {
return request({
url: `/front/project/${projectId}/ai-tasks/${taskId}/retry`,
method: 'post'
}).then(unwrap)
}
export function cancelAiTask(projectId, taskId) {
return request({
url: `/front/project/${projectId}/ai-tasks/${taskId}/cancel`,
method: 'post'
}).then(unwrap)
}
export function getAiQuota() {
return request({
url: '/front/project/ai-quota',
method: 'get'
}).then(unwrap)
}
export function listGenerations(projectId) {
return request({
url: `/front/project/${projectId}/generations`,
method: 'get'
}).then(unwrap)
}
export function getDatabase(projectId) {
return request({
url: `/front/project/${projectId}/database`,

View File

@@ -105,6 +105,31 @@
预览项目
</el-button>
</div>
<div v-if="activeAiTask || aiQuota" class="ai-task-strip">
<div v-if="activeAiTask" class="ai-task-card">
<div class="ai-task-card-header">
<strong>{{ taskTypeLabel(activeAiTask.generateType) }}</strong>
<el-tag size="small" effect="plain">{{ taskStatusLabel(activeAiTask.status) }}</el-tag>
</div>
<el-progress :percentage="Number(activeAiTask.progress || 0)" :stroke-width="8" />
<p class="muted">{{ activeAiTask.currentStep || '-' }}</p>
<p v-if="activeAiTask.errorMessage" class="task-error">{{ activeAiTask.errorMessage }}</p>
<el-button
v-if="activeAiTask.status === 'FAILED' || activeAiTask.status === 'RETRY_WAITING'"
size="small"
type="primary"
@click="handleRetryActiveTask"
>
重试
</el-button>
</div>
<div v-if="aiQuota" class="ai-quota-card">
<span>今日 {{ aiQuota.dailyTaskUsed || 0 }}/{{ aiQuota.dailyTaskLimit || 0 }}</span>
<span>运行中 {{ aiQuota.runningCount || 0 }}/{{ aiQuota.runningLimit || 0 }}</span>
<span>本月成本 {{ aiQuota.monthlyCostUsedCents || 0 }}/{{ aiQuota.monthlyCostLimitCents || 0 }}</span>
</div>
</div>
</div>
</div>
@@ -220,14 +245,14 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DataAnalysis, View } from '@element-plus/icons-vue'
import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'
import DatabaseDesigner from '@/components/DatabaseDesigner.vue'
import ErDiagramView from '@/components/ErDiagramView.vue'
import { createProject, generateAppBlueprint, generateBusinessBlueprint, generateDatabase, generateProject, getDatabase, getProject, listAvailableModules, saveDatabase, updateProject } from '@/api/project'
import { createAiTask, createProject, generateProject, getAiQuota, getAiTask, getDatabase, getProject, listAvailableModules, retryAiTask, saveDatabase, updateProject } from '@/api/project'
const route = useRoute()
const router = useRouter()
@@ -247,6 +272,9 @@ const appBlueprint = ref(emptyAppBlueprint())
const appBlueprintJson = ref(formatJson(emptyAppBlueprint()))
const database = ref({ tables: [], sql: '', erDiagram: emptyErDiagram(), businessActions: [] })
const availableModules = ref([])
const activeAiTask = ref(null)
const aiQuota = ref(null)
let stopAiPolling = false
const projectForm = reactive({
projectName: '',
keyword: String(route.query.keyword || ''),
@@ -332,6 +360,51 @@ function normalizeBusinessActions(payload) {
return Array.isArray(data) ? data : []
}
function parseTaskResult(task) {
if (!task?.resultPayload) return null
try {
return JSON.parse(task.resultPayload)
} catch (error) {
return task.resultPayload
}
}
function taskTypeLabel(type) {
const labels = {
app_blueprint: '应用蓝图',
database: '数据库',
business_blueprint: '业务蓝图'
}
return labels[type] || type || 'AI 生成'
}
function taskStatusLabel(status) {
const labels = {
QUEUED: '排队中',
RUNNING: '生成中',
RETRY_WAITING: '等待重试',
SUCCEEDED: '已完成',
FAILED: '失败',
CANCELED: '已取消'
}
return labels[status] || status || '-'
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function aiTaskPayload(generateType) {
return {
generateType,
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword,
appBlueprint: appBlueprintJson.value
}
}
function normalizeProjectId(value) {
const raw = Array.isArray(value) ? value[0] : value
const id = Number(raw)
@@ -533,6 +606,57 @@ function validateRequirementInput() {
return true
}
async function refreshAiQuota() {
try {
aiQuota.value = await getAiQuota()
} catch (error) {
aiQuota.value = null
}
}
function applyAiTaskResult(task) {
const payload = parseTaskResult(task)
if (task.generateType === 'app_blueprint') {
appBlueprint.value = normalizeAppBlueprint(payload)
syncAppBlueprintJson()
return
}
if (task.generateType === 'database') {
database.value = normalizeDatabase(payload)
return
}
if (task.generateType === 'business_blueprint') {
database.value = {
...database.value,
businessActions: normalizeBusinessActions(payload)
}
}
}
async function waitForAiTask(id, initialTask) {
let task = initialTask
activeAiTask.value = task
while (!stopAiPolling && task && !['SUCCEEDED', 'FAILED', 'CANCELED'].includes(task.status)) {
await delay(2000)
task = await getAiTask(id, task.taskId)
activeAiTask.value = task
}
if (task?.status === 'SUCCEEDED') {
applyAiTaskResult(task)
await refreshAiQuota()
return task
}
throw new Error(task?.errorMessage || 'AI 生成任务未完成')
}
async function startAiTask(generateType) {
const id = await ensureProject()
const task = await createAiTask(id, aiTaskPayload(generateType))
return waitForAiTask(id, task)
}
async function handleGenerateAppBlueprint() {
if (!validateRequirementInput()) {
return
@@ -541,15 +665,7 @@ async function handleGenerateAppBlueprint() {
generatingAppBlueprint.value = true
errorMessage.value = ''
try {
const id = await ensureProject()
const result = await generateAppBlueprint(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword
})
appBlueprint.value = normalizeAppBlueprint(result)
syncAppBlueprintJson()
await startAiTask('app_blueprint')
ElMessage.success('应用蓝图已生成')
} catch (error) {
errorMessage.value = error.message || '生成应用蓝图失败,请稍后重试。'
@@ -581,15 +697,7 @@ async function handleGenerateDatabase() {
generating.value = true
errorMessage.value = ''
try {
const id = await ensureProject()
const result = await generateDatabase(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword,
appBlueprint: appBlueprintJson.value
})
database.value = normalizeDatabase(result)
await startAiTask('database')
if (!database.value.tables.length) {
errorMessage.value = '后端暂未返回表结构,请稍后重试或手动添加表。'
@@ -618,16 +726,7 @@ async function handleGenerateBusinessBlueprint() {
try {
const id = await ensureProject()
await saveDatabase(id, database.value)
const result = await generateBusinessBlueprint(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword
})
database.value = {
...database.value,
businessActions: normalizeBusinessActions(result)
}
await waitForAiTask(id, await createAiTask(id, aiTaskPayload('business_blueprint')))
ElMessage.success('业务蓝图已生成')
} catch (error) {
errorMessage.value = error.message || '生成业务蓝图失败,请确认表结构已保存后重试。'
@@ -636,6 +735,27 @@ async function handleGenerateBusinessBlueprint() {
}
}
async function handleRetryActiveTask() {
if (!projectId.value || !activeAiTask.value?.taskId) return
errorMessage.value = ''
const type = activeAiTask.value.generateType
if (type === 'app_blueprint') generatingAppBlueprint.value = true
if (type === 'database') generating.value = true
if (type === 'business_blueprint') generatingBusinessBlueprint.value = true
try {
const task = await retryAiTask(projectId.value, activeAiTask.value.taskId)
await waitForAiTask(projectId.value, task)
ElMessage.success(`${taskTypeLabel(type)}已生成`)
} catch (error) {
errorMessage.value = error.message || 'AI 生成任务重试失败'
} finally {
if (type === 'app_blueprint') generatingAppBlueprint.value = false
if (type === 'database') generating.value = false
if (type === 'business_blueprint') generatingBusinessBlueprint.value = false
}
}
async function handleSaveDatabase() {
if (!projectId.value) return
@@ -674,11 +794,16 @@ watch(
)
onMounted(() => {
refreshAiQuota()
loadModuleOptions()
if (projectId.value) {
loadProjectDraft(projectId.value)
}
})
onBeforeUnmount(() => {
stopAiPolling = true
})
</script>
<style scoped lang="scss">
@@ -818,6 +943,34 @@ onMounted(() => {
gap: 18px;
}
.ai-task-strip {
display: grid;
gap: 12px;
max-width: 980px;
}
.ai-task-card {
border: 1px solid var(--border-color);
border-radius: 8px;
display: grid;
gap: 8px;
padding: 12px;
}
.ai-task-card-header,
.ai-quota-card {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.task-error {
color: #c2410c;
font-size: 13px;
margin: 0;
}
@media (max-width: 860px) {
.panel-header {
align-items: stretch;