diff --git a/frontend/src/components/app/AppField.tsx b/frontend/src/components/app/AppField.tsx index f1d324f..03c3f94 100644 --- a/frontend/src/components/app/AppField.tsx +++ b/frontend/src/components/app/AppField.tsx @@ -1,6 +1,7 @@ import type * as React from 'react' import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' +import { cn } from '@/lib/utils' type AppFieldProps = { label: string @@ -8,12 +9,13 @@ type AppFieldProps = { description?: string invalid?: boolean required?: boolean + className?: string children: React.ReactNode } -export function AppField({ label, htmlFor, description, invalid, required, children }: AppFieldProps) { +export function AppField({ label, htmlFor, description, invalid, required, className, children }: AppFieldProps) { return ( - + {label} {required ? * : null} diff --git a/frontend/src/features/modelhub/ModelProviderPage.tsx b/frontend/src/features/modelhub/ModelProviderPage.tsx index 862720b..fd82d8f 100644 --- a/frontend/src/features/modelhub/ModelProviderPage.tsx +++ b/frontend/src/features/modelhub/ModelProviderPage.tsx @@ -26,8 +26,8 @@ import { formatDateTime } from '@/lib/format' import { createDeployment, createProvider, getModelTest, listDeployments, listProviders, testDeployment, updateDeployment, updateProvider } from './api' import type { ModelDeployment, ModelInvocation, ModelProvider } from './types' -const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_seconds: '60', enabled: true } -const deploymentInitial = { provider_id: '', display_name: '', model_name: '', default_temperature: '0.2', max_output_tokens: '2048' } +const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_seconds: '60', enabled: true, image_egress_approved: false } +const deploymentInitial = { provider_id: '', display_name: '', model_name: '', capability: 'text_chat', default_temperature: '0.2', max_output_tokens: '2048' } export function ModelProviderPage() { const mobile = useIsMobile() @@ -110,7 +110,7 @@ export function ModelProviderPage() { if (!editing) return try { const keyInput = document.querySelector('#edit-provider-api-key')?.value ?? '' - await updateProvider(editing.id, { name: editing.name, base_url: editing.base_url, default_timeout_seconds: editing.default_timeout_seconds, enabled: editing.enabled, api_key: keyInput }) + await updateProvider(editing.id, { name: editing.name, base_url: editing.base_url, default_timeout_seconds: editing.default_timeout_seconds, enabled: editing.enabled, image_egress_approved: editing.image_egress_approved, api_key: keyInput }) setEditing(null) toast.success('渠道配置已保存') await load() @@ -140,6 +140,7 @@ export function ModelProviderPage() { await updateDeployment(editingDeployment.id, { display_name: editingDeployment.display_name, model_name: editingDeployment.model_name, + capability: editingDeployment.capability, default_temperature: editingDeployment.default_temperature, max_output_tokens: editingDeployment.max_output_tokens, enabled: editingDeployment.enabled @@ -159,6 +160,7 @@ export function ModelProviderPage() { { key: 'name', header: '渠道', cell: (item) =>

{item.name}

{item.base_url}

}, { key: 'credential', header: '凭证', cell: (item) => {item.credential_set ? item.credential_masked : '未配置'} }, { key: 'timeout', header: '超时', cell: (item) => `${item.default_timeout_seconds} 秒` }, + { key: 'image-egress', header: '图片出境', cell: (item) => {item.image_egress_approved ? '已授权' : '未授权'} }, { key: 'status', header: '状态', cell: (item) => {item.enabled ? '已启用' : '已停用'} }, { key: 'updated', header: '更新时间', cell: (item) => {formatDateTime(item.updated_at)} }, { key: 'action', header: '操作', cell: (item) => } @@ -166,6 +168,7 @@ export function ModelProviderPage() { const deploymentColumns: AppDataTableColumn[] = [ { key: 'name', header: '模型', cell: (item) =>

{item.display_name}

{item.model_name}

}, { key: 'provider', header: '渠道', cell: (item) => item.provider_name }, + { key: 'capability', header: '能力', cell: (item) => {item.capability === 'vision_chat' ? '文本 + 视觉' : '纯文本'} }, { key: 'params', header: '默认参数', cell: (item) => T {item.default_temperature} · {item.max_output_tokens} tokens }, { key: 'status', header: '状态', cell: (item) => {item.enabled ? '可用' : '停用'} }, { key: 'action', header: '操作', cell: (item) =>
} @@ -223,6 +226,7 @@ export function ModelProviderPage() { setProviderForm({ ...providerForm, default_timeout_seconds: event.target.value })} /> +
setProviderForm({ ...providerForm, image_egress_approved: checked })} />{providerForm.image_egress_approved ? '已确认授权' : '不允许发送图片'}
@@ -250,6 +254,7 @@ export function ModelProviderPage() { setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} placeholder="例如:gpt-4.1-mini" /> + setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} /> @@ -265,11 +270,13 @@ export function ModelProviderPage() { setEditing({ ...editing, default_timeout_seconds: Number(e.target.value) })} />
setEditing({ ...editing, enabled: checked })} />{editing.enabled ? '已启用' : '已停用'}
+
setEditing({ ...editing, image_egress_approved: checked })} />{editing.image_egress_approved ? '已授权' : '未授权'}
: null} !open && setEditingDeployment(null)}>编辑模型配置{editingDeployment ? setEditingDeployment({ ...editingDeployment, display_name: e.target.value })} /> setEditingDeployment({ ...editingDeployment, model_name: e.target.value })} /> + setEditingDeployment({ ...editingDeployment, default_temperature: Number(e.target.value) })} /> setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(e.target.value) })} />
setEditingDeployment({ ...editingDeployment, enabled: checked })} />{editingDeployment.enabled ? '已启用' : '已停用'}
diff --git a/frontend/src/features/modelhub/types.ts b/frontend/src/features/modelhub/types.ts index 2440a3b..30db273 100644 --- a/frontend/src/features/modelhub/types.ts +++ b/frontend/src/features/modelhub/types.ts @@ -6,6 +6,9 @@ export type ModelProvider = { default_timeout_seconds: number credential_set: boolean credential_masked: string + image_egress_approved: boolean + image_egress_approved_by: string | null + image_egress_approved_at: string | null created_at: string updated_at: string } diff --git a/frontend/src/features/tickets/TicketCreatePage.test.tsx b/frontend/src/features/tickets/TicketCreatePage.test.tsx new file mode 100644 index 0000000..521f1dd --- /dev/null +++ b/frontend/src/features/tickets/TicketCreatePage.test.tsx @@ -0,0 +1,116 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { TicketDraft } from './draft-types' +import { TicketCreatePage } from './TicketCreatePage' + +const mocks = vi.hoisted(() => ({ + mobile: false, + createTicket: vi.fn(), + availability: vi.fn(), + listDrafts: vi.fn(), + createDraft: vi.fn(), + analyzeDraft: vi.fn(), + getDraft: vi.fn(), + updateDraft: vi.fn(), + answerDraft: vi.fn(), + confirmDraft: vi.fn(), + rejectDraft: vi.fn(), + uploadImage: vi.fn(), + deleteImage: vi.fn() +})) + +vi.mock('@/hooks/use-mobile', () => ({ useIsMobile: () => mocks.mobile })) +vi.mock('./api', () => ({ createTicket: mocks.createTicket })) +vi.mock('./draft-api', () => ({ + getTicketDraftAvailability: mocks.availability, + listTicketDrafts: mocks.listDrafts, + createTicketDraft: mocks.createDraft, + analyzeTicketDraft: mocks.analyzeDraft, + getTicketDraft: mocks.getDraft, + updateTicketDraft: mocks.updateDraft, + answerTicketDraft: mocks.answerDraft, + confirmTicketDraft: mocks.confirmDraft, + rejectTicketDraft: mocks.rejectDraft, + uploadTicketDraftImage: mocks.uploadImage, + deleteTicketDraftImage: mocks.deleteImage +})) + +function draft(status: TicketDraft['status'], revision: number): TicketDraft { + return { + public_id: 'draft-1', + status, + revision, + source_text: '生产采集任务未更新', + fields: status === 'ready' ? { + title: '生产采集任务未更新', + description: '生产环境采集任务两小时没有更新', + category_code: 'technical_issue', + priority: 'high', + problem_performance: '采集进度停滞', + problem_object: '任务 A', + submitter_name: '张三', + submitter_org: '华东分公司', + impact_scope: '单个任务', + environment: '生产环境', + related_system: '数据中台' + } : {}, + last_ai_fields: {}, + missing_fields: [], + questions: [], + guided_rounds: 0, + agent_run_public_id: null, + images: [], + messages: [], + decision: '', + error_code: '', + error_message: '', + confirmed_ticket: null, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + expires_at: '2026-07-17T00:00:00Z' + } +} + +describe('可信 AI 建单页', () => { + afterEach(() => cleanup()) + + beforeEach(() => { + vi.clearAllMocks() + mocks.mobile = false + mocks.availability.mockResolvedValue({ available: true, reason: '', vision_available: false, vision_reason: '当前模型不支持图片' }) + mocks.listDrafts.mockResolvedValue([]) + mocks.createDraft.mockResolvedValue(draft('editing', 1)) + mocks.analyzeDraft.mockResolvedValue(draft('ready', 2)) + mocks.confirmDraft.mockResolvedValue({ + ...draft('confirmed', 3), + decision: 'accepted', + confirmed_ticket: { id: 19 } + }) + }) + + it('必须经过结构化审核和显式确认才创建工单', async () => { + const user = userEvent.setup() + render() + + const source = await screen.findByPlaceholderText(/生产环境中/) + await user.type(source, '生产采集任务未更新') + await user.click(screen.getByRole('button', { name: '生成结构化草稿' })) + + expect(await screen.findByText('最终确认')).toBeInTheDocument() + expect(mocks.confirmDraft).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: '确认创建工单' })) + await user.click(screen.getByRole('button', { name: '确认创建' })) + + await waitFor(() => expect(mocks.confirmDraft).toHaveBeenCalledWith('draft-1', 2)) + expect(mocks.createTicket).not.toHaveBeenCalled() + }) + + it('移动端只显示桌面端提示', () => { + mocks.mobile = true + render() + expect(screen.getByText('请使用桌面端创建工单')).toBeInTheDocument() + expect(screen.queryByText('生成结构化草稿')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/tickets/TicketCreatePage.tsx b/frontend/src/features/tickets/TicketCreatePage.tsx index 273cc61..589c993 100644 --- a/frontend/src/features/tickets/TicketCreatePage.tsx +++ b/frontend/src/features/tickets/TicketCreatePage.tsx @@ -1,19 +1,71 @@ import * as React from 'react' -import { AlertCircle, CheckCircle2, Lightbulb, Send } from 'lucide-react' +import { + AlertCircle, + ArrowRight, + Bot, + Check, + CheckCircle2, + FileImage, + LoaderCircle, + MessageSquareMore, + RotateCcw, + Send, + Sparkles, + Trash2 +} from 'lucide-react' +import { toast } from 'sonner' import { AppField } from '@/components/app/AppField' import { AppPage } from '@/components/app/AppPage' import { AppPageHeader } from '@/components/app/AppPageHeader' import { AppSection } from '@/components/app/AppSection' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' +import { Bubble, BubbleContent } from '@/components/ui/bubble' import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { FieldGroup } from '@/components/ui/field' import { Input } from '@/components/ui/input' +import { Message, MessageContent, MessageGroup } from '@/components/ui/message' +import { + MessageScroller, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerViewport +} from '@/components/ui/message-scroller' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' -import { toast } from 'sonner' +import { useIsMobile } from '@/hooks/use-mobile' +import { cn } from '@/lib/utils' import { createTicket } from './api' +import { + analyzeTicketDraft, + answerTicketDraft, + confirmTicketDraft, + createTicketDraft, + deleteTicketDraftImage, + getTicketDraft, + getTicketDraftAvailability, + listTicketDrafts, + rejectTicketDraft, + updateTicketDraft, + uploadTicketDraftImage +} from './draft-api' +import type { TicketDraft, TicketDraftAvailability } from './draft-types' import { TICKET_CATEGORIES } from './ticketCategories' import type { TicketInput } from './types' @@ -32,160 +84,241 @@ const initialForm: TicketInput = { } const ENVIRONMENT_OPTIONS = ['生产环境', '测试环境', '预发布环境', '本地环境'] +const FIELD_LABELS: Record = { + title: '工单标题', + description: '问题描述', + category_code: '问题分类', + priority: '优先级', + problem_performance: '问题表现', + problem_object: '问题对象', + submitter_name: '提出人', + submitter_org: '提出机构', + impact_scope: '影响范围', + environment: '环境', + related_system: '关联系统' +} + +type ConversationItem = { role: 'assistant' | 'user'; content: string } + +function mergeForm(fields: Partial): TicketInput { + return { ...initialForm, ...fields } +} + +function DraftSpine({ draft }: { draft: TicketDraft | null }) { + const active = draft?.status === 'analyzing' ? 1 : draft?.status === 'ready' || draft?.status === 'failed' ? 2 : draft?.status === 'confirmed' ? 3 : 0 + return ( +
+ {['起草', '补齐', '审核', '确认'].map((label, index) => ( +
+ + {index < active ? : index + 1} + + {label} + {index < 3 ? : null} +
+ ))} +
+ ) +} export function TicketCreatePage() { + const mobile = useIsMobile() + const [availability, setAvailability] = React.useState(null) + const [loading, setLoading] = React.useState(true) + const [mode, setMode] = React.useState<'ai' | 'manual'>('manual') + const [sourceText, setSourceText] = React.useState('') + const [pendingFiles, setPendingFiles] = React.useState([]) + const [draft, setDraft] = React.useState(null) + const [recentDraft, setRecentDraft] = React.useState(null) const [form, setForm] = React.useState(initialForm) + const [changedFields, setChangedFields] = React.useState>(new Set()) + const [dirty, setDirty] = React.useState(false) + const [guided, setGuided] = React.useState(false) + const [answer, setAnswer] = React.useState('') + const [conversation, setConversation] = React.useState([]) const [error, setError] = React.useState('') - const [submitting, setSubmitting] = React.useState(false) + const [busy, setBusy] = React.useState(false) + + const acceptDraft = React.useCallback((next: TicketDraft) => { + setDraft(next) + setConversation(next.messages.map((item) => ({ role: item.role, content: item.content }))) + if (next.status === 'ready') { + setForm((current) => { + const merged = mergeForm(next.fields) + const changed = new Set(Object.keys(merged).filter((key) => merged[key as keyof TicketInput] !== current[key as keyof TicketInput])) + setChangedFields(changed) + return merged + }) + setDirty(false) + } + }, []) + + React.useEffect(() => { + let cancelled = false + Promise.all([getTicketDraftAvailability(), listTicketDrafts()]) + .then(([nextAvailability, drafts]) => { + if (cancelled) return + setAvailability(nextAvailability) + setMode(nextAvailability.available ? 'ai' : 'manual') + setRecentDraft(drafts[0] ?? null) + }) + .catch(() => { + if (!cancelled) setMode('manual') + }) + .finally(() => !cancelled && setLoading(false)) + return () => { cancelled = true } + }, []) + + React.useEffect(() => { + if (!draft || draft.status !== 'analyzing') return + const timer = window.setTimeout(async () => { + try { acceptDraft(await getTicketDraft(draft.public_id)) } catch (reason) { setError(reason instanceof Error ? reason.message : '查询 AI 草稿失败') } + }, 1200) + return () => window.clearTimeout(timer) + }, [acceptDraft, draft]) function updateField(field: Key, value: TicketInput[Key]) { setForm((current) => ({ ...current, [field]: value })) + setDirty(Boolean(draft)) + setChangedFields((current) => new Set(current).add(field)) } - async function handleSubmit(event: React.FormEvent) { - event.preventDefault() - setError('') - if (!form.environment || !form.problem_performance || !form.problem_object || !form.submitter_name || !form.submitter_org) { - setError('请完整填写问题表现、问题对象、提出人、提出机构和环境。') - return + function validateForm() { + const required: (keyof TicketInput)[] = ['title', 'description', 'category_code', 'priority', 'problem_performance', 'problem_object', 'submitter_name', 'submitter_org', 'environment'] + const missing = required.filter((key) => !String(form[key] ?? '').trim()) + if (missing.length) { + setError(`请补齐:${missing.map((key) => FIELD_LABELS[key]).join('、')}`) + return false } - setSubmitting(true) + return true + } + + async function startAi() { + if (!sourceText.trim()) { setError('请先输入问题描述'); return } + setBusy(true); setError('') try { - const ticket = await createTicket(form) - window.location.hash = `/tickets/${ticket.id}` - } catch (reason) { - setError(reason instanceof Error ? reason.message : '创建工单失败,请检查必填信息或稍后重试。') - } finally { - setSubmitting(false) - } + let next = await createTicketDraft(sourceText) + for (const file of pendingFiles) next = await uploadTicketDraftImage(next.public_id, file) + next = await analyzeTicketDraft(next.public_id, next.revision) + acceptDraft(next) + setRecentDraft(null) + } catch (reason) { setError(reason instanceof Error ? reason.message : 'AI 分析启动失败') } finally { setBusy(false) } } + async function saveIfDirty(current: TicketDraft) { + if (!dirty) return current + const saved = await updateTicketDraft(current.public_id, current.revision, form) + acceptDraft(saved) + return saved + } + + async function retryAnalysis() { + if (!draft) return + setBusy(true); setError('') + try { + const saved = await saveIfDirty(draft) + acceptDraft(await analyzeTicketDraft(saved.public_id, saved.revision)) + } catch (reason) { setError(reason instanceof Error ? reason.message : '重新分析失败') } finally { setBusy(false) } + } + + async function submitAnswer() { + if (!draft || !answer.trim()) return + const question = draft.questions[0] ?? '请补充缺失信息' + setBusy(true); setError('') + try { + const saved = await saveIfDirty(draft) + acceptDraft(await answerTicketDraft(saved.public_id, saved.revision, question, answer.trim())) + setAnswer('') + } catch (reason) { setError(reason instanceof Error ? reason.message : '提交回答失败') } finally { setBusy(false) } + } + + async function confirmDraft() { + if (!draft || !validateForm()) return + setBusy(true); setError('') + try { + const saved = await saveIfDirty(draft) + const confirmed = await confirmTicketDraft(saved.public_id, saved.revision) + acceptDraft(confirmed) + toast.success(confirmed.decision === 'accepted' ? '已按 AI 草稿创建工单' : '已按修改后的草稿创建工单') + if (confirmed.confirmed_ticket) window.location.hash = `/tickets/${confirmed.confirmed_ticket.id}` + } catch (reason) { setError(reason instanceof Error ? reason.message : '确认创建失败') } finally { setBusy(false) } + } + + async function handleManualSubmit(event: React.FormEvent) { + event.preventDefault() + if (!validateForm()) return + setBusy(true); setError('') + try { const ticket = await createTicket(form); window.location.hash = `/tickets/${ticket.id}` } catch (reason) { setError(reason instanceof Error ? reason.message : '创建工单失败') } finally { setBusy(false) } + } + + async function discardDraft() { + if (!draft) return + try { await rejectTicketDraft(draft.public_id, draft.revision) } catch { /* dismissal remains local */ } + setDraft(null); setForm(initialForm); setSourceText(''); setConversation([]); setMode('manual') + } + + if (mobile) { + return }>请使用桌面端创建工单移动端当前仅支持查看。输入、图片上传、逐问补齐和最终确认需要在桌面端完成。 + } + + if (loading) return }>
+ + const aiReady = mode === 'ai' && availability?.available + const reviewReady = draft?.status === 'ready' return ( } />} + contentClassName="grid gap-4" + header={} />} > -
+ {aiReady ? : null} + {error ? 当前操作未完成{error} : null} + {!availability?.available && availability?.reason ? AI 起草暂不可用{availability.reason},仍可使用普通表单建单。 : null} + {recentDraft && !draft && aiReady ? 发现 24 小时内未完成草稿{recentDraft.source_text || '未命名草稿'} : null} + + {aiReady && !draft ? ( +
+ +