diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index da8497b..5d46930 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -47,8 +47,13 @@ import WechatBindingsPage from './pages/WechatBindingsPage'; import HqPermissionsPage from './pages/HqPermissionsPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; import WecomBotsPage from './pages/WecomBotsPage'; +import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; +import WecomBotLogsPage from './pages/WecomBotLogsPage'; import LlmConfigsPage from './pages/LlmConfigsPage'; import KnowledgeBasesPage from './pages/KnowledgeBasesPage'; +import DevPlanVersionsPage from './pages/DevPlanVersionsPage'; +import DevPlanTasksPage from './pages/DevPlanTasksPage'; +import DevPlanSettingsPage from './pages/DevPlanSettingsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -75,9 +80,15 @@ export default function App() { } /> } /> - } /> + } /> + } /> + } /> + } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index c7d0fac..337fecf 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -18,6 +18,7 @@ import { LockOutlined, SettingOutlined, AccountBookOutlined, + ProjectOutlined, } from '@ant-design/icons'; import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; import { clearAuth, request, type HqProfile } from '../lib/api'; @@ -42,7 +43,15 @@ const MENU_ITEMS: MenuProps['items'] = [ }, { key: '/orders', icon: , label: '订单' }, { key: '/promo-codes', icon: , label: '推广码' }, - { key: '/wecom-bots', icon: , label: '企微机器人' }, + { + key: 'wecom-group', + icon: , + label: '企微机器人', + children: [ + { key: '/wecom/bots', label: '智能机器人' }, + { key: '/wecom/pushes', label: '消息推送' }, + ], + }, { key: '/llm-configs', icon: , label: '语言模型' }, { key: '/knowledge-bases', icon: , label: '知识库' }, { @@ -110,6 +119,16 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/tickets/support', label: '技术支持' }, ], }, + { + key: 'dev-plan-group', + icon: , + label: '开发计划', + children: [ + { key: '/dev-plan/versions', label: '版本列表' }, + { key: '/dev-plan/tasks', label: '任务列表' }, + { key: '/dev-plan/settings', label: '开发设置' }, + ], + }, { key: '/invoices', icon: , label: '发票管理' }, { key: '/resources', icon: , label: 'OSS 资源库' }, { @@ -123,6 +142,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/logs/hq', label: 'HQ 操作日志' }, { key: '/logs/third-party', label: '第三方日志' }, { key: '/logs/domain-events', label: '领域事件' }, + { key: '/logs/wecom-bots', label: '智能机器人日志' }, ], }, { key: '/hq-permissions', icon: , label: '权限分配' }, @@ -134,6 +154,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { if (key === 'tickets-group') { return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support'); } + if (key === 'dev-plan-group') { + return permissionKeys.includes('dev_plan'); + } const map: Record = { '/': 'dashboard', '/users': 'users', @@ -143,7 +166,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/product-detail-templates': 'products', '/orders': 'orders', '/promo-codes': 'promo_codes', - '/wecom-bots': 'wecom_bots', + '/wecom/bots': 'wecom_bots', + '/wecom/pushes': 'wecom_bots', + 'wecom-group': 'wecom_bots', '/llm-configs': 'llm_configs', '/knowledge-bases': 'knowledge_bases', 'stores-group': 'stores', @@ -175,10 +200,15 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/deliveries/xiaofeixia': 'deliveries', '/tickets': 'tickets', '/tickets/support': 'tech_support', + 'dev-plan-group': 'dev_plan', + '/dev-plan/versions': 'dev_plan', + '/dev-plan/tasks': 'dev_plan', + '/dev-plan/settings': 'dev_plan', '/invoices': 'invoices', '/resources': 'resources', 'logs-group': 'logs', '/logs/users': 'logs', + '/logs/wecom-bots': 'logs', '/logs/stores': 'logs', '/logs/partners': 'logs', '/logs/hq': 'logs', diff --git a/apps/admin-web/src/pages/DevPlanSettingsPage.tsx b/apps/admin-web/src/pages/DevPlanSettingsPage.tsx new file mode 100644 index 0000000..ba21da3 --- /dev/null +++ b/apps/admin-web/src/pages/DevPlanSettingsPage.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { Button, Collapse, Form, Input, Select, Typography, message } from 'antd'; +import type { + DevPlanSettingsDto, + KnowledgeBaseOptionDto, + LlmApiConfigOptionDto, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; + +export default function DevPlanSettingsPage() { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [llmOptions, setLlmOptions] = useState([]); + const [kbOptions, setKbOptions] = useState([]); + const [settingsForm] = Form.useForm(); + + async function loadAll() { + setLoading(true); + try { + const [s, llm, kb] = await Promise.all([ + request('/admin/dev-plan/settings'), + request('/admin/llm-configs/options'), + request('/admin/knowledge-bases/options'), + ]); + setLlmOptions(llm); + setKbOptions(kb); + settingsForm.setFieldsValue({ + reviewAssistantLlmConfigId: s.reviewAssistantLlmConfigId, + reviewAssistantKnowledgeBaseId: s.reviewAssistantKnowledgeBaseId, + reviewAssistantPrompt: s.reviewAssistantPrompt, + }); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadAll(); + }, []); + + async function saveSettings() { + const values = await settingsForm.validateFields(); + setSaving(true); + try { + await request('/admin/dev-plan/settings', { + method: 'PUT', + body: JSON.stringify(values), + }); + message.success('设置已保存'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + return ( +
+ 开发计划 · 开发设置 + + 任务评审派发 Webhook 已迁移至「企微机器人 → 消息推送」,勾选「开发任务评审派发」条件即可。 + + + + + 仅用于技术支持批量预审(只读知识库 + LLM),preview 阶段不写库。 + + + ({ value: o.id, label: o.name }))} + /> + + + + + + + ), + }, + ]} + /> +
+ ); +} diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx new file mode 100644 index 0000000..618b472 --- /dev/null +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -0,0 +1,285 @@ +import { useState } from 'react'; +import { + Button, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + DEV_PLAN_TASK_STATUS_LABELS, + DEV_PLAN_TASK_TYPE_LABELS, + type DevPlanTaskDto, + type DevPlanTaskStatusDto, + type DevPlanTaskTypeDto, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({ + value: v, + label: DEV_PLAN_TASK_TYPE_LABELS[v], +})); + +const STATUS_OPTIONS = (Object.keys(DEV_PLAN_TASK_STATUS_LABELS) as DevPlanTaskStatusDto[]).map( + (v) => ({ value: v, label: DEV_PLAN_TASK_STATUS_LABELS[v] }), +); + +const STATUS_COLOR: Record = { + TODO: 'default', + DEVELOPED: 'blue', + RELEASED: 'green', +}; + +const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程'; + +export default function DevPlanTasksPage() { + const [filters, setFilters] = useState>({}); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [dispatchOpen, setDispatchOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [dispatching, setDispatching] = useState(false); + const [form] = Form.useForm(); + const [dispatchForm] = Form.useForm<{ supplement?: string }>(); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/dev-plan/tasks', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.type) qs.set('type', filters.type); + if (filters.keyword) qs.set('keyword', filters.keyword); + return qs; + }, + [filters], + ); + + function openCreate() { + setEditing(null); + form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' }); + setModalOpen(true); + } + + function openEdit(row: DevPlanTaskDto) { + setEditing(row); + form.setFieldsValue({ content: row.content, type: row.type, status: row.status }); + setModalOpen(true); + } + + async function save() { + const values = await form.validateFields(); + setSaving(true); + try { + if (editing) { + await request(`/admin/dev-plan/tasks/${editing.id}`, { + method: 'PUT', + body: JSON.stringify(values), + }); + message.success('已更新'); + } else { + await request('/admin/dev-plan/tasks', { + method: 'POST', + body: JSON.stringify({ content: values.content, type: values.type }), + }); + message.success('已创建'); + } + setModalOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function remove(id: string) { + try { + await request(`/admin/dev-plan/tasks/${id}`, { method: 'DELETE' }); + message.success('已删除'); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } + } + + function openDispatch() { + dispatchForm.setFieldsValue({ + supplement: DEFAULT_DISPATCH_SUPPLEMENT, + }); + setDispatchOpen(true); + } + + async function submitDispatch() { + const values = await dispatchForm.validateFields(); + setDispatching(true); + try { + await request('/admin/dev-plan/tasks/dispatch', { + method: 'POST', + body: JSON.stringify({ + taskIds: selectedRowKeys, + supplement: values.supplement?.trim() || undefined, + }), + }); + message.success('已派发到企微群'); + setDispatchOpen(false); + setSelectedRowKeys([]); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '派发失败'); + } finally { + setDispatching(false); + } + } + + const columns: ColumnsType = [ + { title: '任务号', dataIndex: 'taskNo', width: 160 }, + { + title: '类型', + dataIndex: 'type', + width: 80, + render: (t: DevPlanTaskTypeDto) => DEV_PLAN_TASK_TYPE_LABELS[t], + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: DevPlanTaskStatusDto) => ( + {DEV_PLAN_TASK_STATUS_LABELS[s]} + ), + }, + { title: '内容', dataIndex: 'content', ellipsis: true }, + { title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' }, + { title: '创建人', dataIndex: 'creatorName', width: 90 }, + { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + + void remove(row.id)}> + + + + ), + }, + ]; + + return ( +
+
+ + 开发计划 · 任务列表 + + + + + +
+ +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + +
+ + setSelectedRowKeys(keys as string[]), + }} + scroll={{ x: 1100 }} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setModalOpen(false)} + onOk={() => void save()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + ) : null} + +
+ + setDispatchOpen(false)} + onOk={() => void submitDispatch()} + confirmLoading={dispatching} + destroyOnClose + > + + 已选 {selectedRowKeys.length} 条任务,将通过「企微机器人 → 消息推送」中勾选「开发任务评审派发」的实例推送到开发群(@ + userid 由各推送配置)。 + +
+ + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/DevPlanVersionsPage.tsx b/apps/admin-web/src/pages/DevPlanVersionsPage.tsx new file mode 100644 index 0000000..7b24b7b --- /dev/null +++ b/apps/admin-web/src/pages/DevPlanVersionsPage.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + DEV_PLAN_VERSION_STATUS_LABELS, + type DevPlanTaskDto, + type DevPlanVersionDto, + type DevPlanVersionStatusDto, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + + +const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map( + (v) => ({ value: v, label: DEV_PLAN_VERSION_STATUS_LABELS[v] }), +); + +const STATUS_COLOR: Record = { + PENDING: 'default', + IN_PROGRESS: 'processing', + TESTING: 'purple', + RELEASED: 'green', +}; + +function formatDuration(minutes?: number | null) { + if (minutes == null) return '—'; + if (minutes < 60) return `${minutes} 分钟`; + return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`; +} + +export default function DevPlanVersionsPage() { + const [filters, setFilters] = useState>({}); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [allTasks, setAllTasks] = useState([]); + const [form] = Form.useForm(); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = + useAdminList('/admin/dev-plan/versions', () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + return qs; + }, [filters]); + + useEffect(() => { + void request<{ items: DevPlanTaskDto[] }>('/admin/dev-plan/tasks?page=1&pageSize=500') + .then((res) => setAllTasks(res.items ?? [])) + .catch(() => setAllTasks([])); + }, []); + + async function openEdit(row: DevPlanVersionDto) { + const detail = await request(`/admin/dev-plan/versions/${row.id}`); + setEditing(detail); + form.setFieldsValue({ + versionNo: detail.versionNo, + content: detail.content ?? '', + status: detail.status, + taskIds: detail.taskIds ?? [], + }); + setModalOpen(true); + } + + function openCreate() { + setEditing(null); + form.setFieldsValue({ versionNo: '', content: '', status: 'PENDING', taskIds: [] }); + setModalOpen(true); + } + + async function save() { + const values = await form.validateFields(); + setSaving(true); + try { + const body = { + versionNo: values.versionNo.trim(), + content: values.content?.trim() || undefined, + status: values.status, + taskIds: values.taskIds ?? [], + }; + if (editing) { + await request(`/admin/dev-plan/versions/${editing.id}`, { + method: 'PUT', + body: JSON.stringify(body), + }); + message.success('已更新'); + } else { + await request('/admin/dev-plan/versions', { method: 'POST', body: JSON.stringify(body) }); + message.success('已创建'); + } + setModalOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function remove(id: string) { + try { + await request(`/admin/dev-plan/versions/${id}`, { method: 'DELETE' }); + message.success('已删除'); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } + } + + const columns: ColumnsType = [ + { title: '版本号', dataIndex: 'versionNo', width: 120 }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (s: DevPlanVersionStatusDto) => ( + {DEV_PLAN_VERSION_STATUS_LABELS[s]} + ), + }, + { title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' }, + { title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, + { title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, + { title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, + { + title: '用时', + dataIndex: 'durationMinutes', + width: 100, + render: (v) => formatDuration(v), + }, + { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + + void remove(row.id)}> + + + + ), + }, + ]; + + return ( +
+
+ + 开发计划 · 版本列表 + + +
+ +
{ + setFilters(v); + setPage(1); + }} + > + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setModalOpen(false)} + onOk={() => void save()} + confirmLoading={saving} + destroyOnClose + width={640} + > + + + + + + ({ + value: t.id, + label: `${t.taskNo} · ${t.content.slice(0, 40)}`, + }))} + /> + + + + + ); +} diff --git a/apps/admin-web/src/pages/StoreRatingsPage.tsx b/apps/admin-web/src/pages/StoreRatingsPage.tsx index ab7a6a3..8dee0e9 100644 --- a/apps/admin-web/src/pages/StoreRatingsPage.tsx +++ b/apps/admin-web/src/pages/StoreRatingsPage.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useState } from 'react'; import { Button, Form, Input, Table, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useSearchParams } from 'react-router-dom'; diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index 663db62..1e6a141 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -1,23 +1,39 @@ import { useEffect, useState } from 'react'; import { Button, - Descriptions, + Card, Drawer, Form, Input, Modal, + Radio, Select, Space, Table, Tag, + Timeline, Typography, message, } from 'antd'; +import { + CheckCircleFilled, + CloseCircleFilled, + ClockCircleFilled, + MinusCircleOutlined, + PlusOutlined, + SyncOutlined, +} from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { + DEV_PLAN_TASK_TYPE_LABELS, SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_TYPE_LABELS, + mapSupportTicketTypeToDevPlanTask, + type BatchReviewPreviewItem, + type BatchReviewPreviewResponse, + type DevPlanTaskTypeDto, type SupportTicketDto, + type SupportTicketLinkedTaskDto, type SupportTicketStatusDto, type SupportTicketTypeDto, } from '@dukang/shared-types'; @@ -41,30 +57,43 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), ); +const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( + (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), +); + +type TicketRow = SupportTicketDto & { linkedTasks?: SupportTicketLinkedTaskDto[] }; + export default function SupportTicketsPage() { const [profile, setProfile] = useState(null); const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = - useAdminList('/admin/support-tickets', () => { + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/support-tickets', + () => { const qs = new URLSearchParams(); if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.status) qs.set('status', filters.status); return qs; - }, [filters]); + }, + [filters], + ); - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [creating, setCreating] = useState(false); const [acting, setActing] = useState(false); - const [rejectOpen, setRejectOpen] = useState(false); - const [createForm] = Form.useForm<{ - ticketType: SupportTicketTypeDto; - title: string; - content?: string; - remark?: string; - }>(); - const [rejectForm] = Form.useForm<{ rejectReason: string }>(); + const [reviewOpen, setReviewOpen] = useState(false); + const [createTasksOpen, setCreateTasksOpen] = useState(false); + const [batchPreviewOpen, setBatchPreviewOpen] = useState(false); + const [batchPreview, setBatchPreview] = useState([]); + const [batchConfirming, setBatchConfirming] = useState(false); + const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE'); + + const [createForm] = Form.useForm(); + const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>(); + const [tasksForm] = Form.useForm<{ tasks: Array<{ content: string; type: DevPlanTaskTypeDto }> }>(); + const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>(); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; @@ -73,7 +102,7 @@ export default function SupportTicketsPage() { }, []); async function openDetail(id: string) { - setDetail(await request(`/admin/support-tickets/${id}`)); + setDetail(await request(`/admin/support-tickets/${id}`)); setDrawerOpen(true); } @@ -101,15 +130,61 @@ export default function SupportTicketsPage() { } } - async function approve() { + function openReview() { if (!detail) return; + reviewForm.setFieldsValue({ decision: 'APPROVE', note: '', rejectReason: '' }); + setReviewDecision('APPROVE'); + setReviewOpen(true); + } + + async function submitReviewStep1() { + const values = await reviewForm.validateFields(); + if (values.decision === 'REJECT') { + if (!values.rejectReason?.trim()) { + message.error('请填写驳回理由'); + return; + } + setActing(true); + try { + await request(`/admin/support-tickets/${detail!.id}/review`, { + method: 'POST', + body: JSON.stringify({ decision: 'REJECT', rejectReason: values.rejectReason.trim() }), + }); + message.success('已驳回'); + setReviewOpen(false); + setDrawerOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setActing(false); + } + return; + } + + setReviewOpen(false); + const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500); + tasksForm.setFieldsValue({ + tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }], + }); + setCreateTasksOpen(true); + } + + async function submitCreateTasks() { + const values = await tasksForm.validateFields(); + const note = reviewForm.getFieldValue('note') as string | undefined; setActing(true); try { - await request(`/admin/support-tickets/${detail.id}/approve`, { + await request(`/admin/support-tickets/${detail!.id}/review`, { method: 'POST', - body: JSON.stringify({}), + body: JSON.stringify({ + decision: 'APPROVE', + note: note?.trim() || undefined, + tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })), + }), }); - message.success('评审通过,已进入开发'); + message.success('审批通过,已创建开发任务'); + setCreateTasksOpen(false); setDrawerOpen(false); reload(); } catch (e) { @@ -119,27 +194,51 @@ export default function SupportTicketsPage() { } } - async function submitReject() { - if (!detail) return; - const values = await rejectForm.validateFields(); + async function startBatchPreview() { + if (!selectedRowKeys.length) return; setActing(true); try { - await request(`/admin/support-tickets/${detail.id}/reject`, { + const res = await request('/admin/support-tickets/batch-review/preview', { method: 'POST', - body: JSON.stringify({ rejectReason: values.rejectReason.trim() }), + body: JSON.stringify({ ticketIds: selectedRowKeys }), }); - message.success('已驳回'); - setRejectOpen(false); - rejectForm.resetFields(); - setDrawerOpen(false); - reload(); + setBatchPreview(res.items); + batchForm.setFieldsValue({ items: res.items }); + setBatchPreviewOpen(true); } catch (e) { - message.error(e instanceof Error ? e.message : '操作失败'); + message.error(e instanceof Error ? e.message : 'AI 预审失败'); } finally { setActing(false); } } + async function submitBatchConfirm() { + const values = await batchForm.validateFields(); + setBatchConfirming(true); + try { + await request('/admin/support-tickets/batch-review/confirm', { + method: 'POST', + body: JSON.stringify({ + items: values.items.map((item) => ({ + ticketId: item.ticketId, + decision: item.decision, + rejectReason: item.rejectReason, + note: item.note, + tasks: item.decision === 'APPROVE' ? item.suggestedTasks : undefined, + })), + }), + }); + message.success('批量审批已完成'); + setBatchPreviewOpen(false); + setSelectedRowKeys([]); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '批量确认失败'); + } finally { + setBatchConfirming(false); + } + } + async function startTesting() { if (!detail) return; setActing(true); @@ -176,7 +275,7 @@ export default function SupportTicketsPage() { } } - const columns: ColumnsType = [ + const columns: ColumnsType = [ { title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '类型', @@ -210,14 +309,9 @@ export default function SupportTicketsPage() { if (!detail) return null; if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) { return ( - - - - + ); } if (detail.status === 'DEVELOPING') { @@ -250,9 +344,16 @@ export default function SupportTicketsPage() { 技术支持 - + + {isSuperAdmin ? ( + + ) : null} + +
{ + const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id)); + setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending); + }, + getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }), + } + : undefined + } pagination={{ current: page, pageSize, @@ -293,90 +406,393 @@ export default function SupportTicketsPage() { /> + 技术支持详情 + {detail && ( + <> + + {detail.ticketNo} + + {SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} + + )} + + } + width={680} open={drawerOpen} onClose={() => setDrawerOpen(false)} extra={drawerExtra} > {detail && ( - - {detail.ticketNo} - - {SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} - - - - {SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status} - - - {detail.title} - -
{detail.content || '—'}
-
- {detail.creatorName} - {fmtTime(detail.createdAt)} - {detail.reviewerName || '—'} - - {detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'} - - {detail.rejectReason || '—'} - {detail.remark || '—'} -
+
+ {/* 状态 + 标签 */} + +
+ + + {SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status} + + {detail.status === 'REJECTED' && ( + 此工单已被驳回 + )} + {detail.status === 'PASSED' && ( + 此工单已测试通过 + )} + + + 创建于 {fmtTime(detail.createdAt)} + +
+
+ + {/* 状态流转时间线 */} + 状态流转} + styles={{ body: { padding: '12px 16px' } }} + > + { + const isRejected = detail.status === 'REJECTED'; + const flowSteps: Array<{ key: SupportTicketStatusDto; time?: string | null; label: string }> = [ + { key: 'PENDING_REVIEW', label: '待评审', time: detail.createdAt }, + ...(!isRejected + ? [ + { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, + { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, + { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, + ] + : [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), + ]; + + const statusIdx = flowSteps.findIndex((s) => s.key === detail.status); + + return flowSteps.map((step, idx) => { + const isCurrent = step.key === detail.status; + const isPast = !isRejected + ? idx < statusIdx + : step.key === 'PENDING_REVIEW' && statusIdx >= 1; + const isRejectedStep = step.key === 'REJECTED'; + + let dot: React.ReactNode; + let color: string | undefined; + if (isRejectedStep) { + dot = ; + color = 'red'; + } else if (isCurrent) { + if (step.key === 'TESTING') { + dot = ; + } else { + dot = ; + } + } else if (isPast) { + dot = ; + color = 'green'; + } + + return { + dot, + color, + children: ( +
+ + {step.label} + + {step.time && ( + + {fmtTime(step.time)} + + )} +
+ ), + }; + }); + })()} + /> +
+ + {/* 标题 & 内容 */} + + + {detail.title} + +
+ {detail.content || ( + 暂无详细说明 + )} +
+
+ + {/* 基本信息 */} + 基本信息} + styles={{ body: { padding: '12px 16px' } }} + > +
+
+ 创建人 +
{detail.creatorName}
+
+
+ 创建时间 +
{fmtTime(detail.createdAt)}
+
+
+ 评审人 +
{detail.reviewerName || '—'}
+
+
+ 评审时间 +
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
+
+
+ {detail.rejectReason && ( +
+ + 驳回理由 + +
+ {detail.rejectReason} +
+
+ )} + {detail.remark && ( +
+ + 备注 + +
+ {detail.remark} +
+
+ )} +
+ + {/* 关联开发任务 */} + {detail.linkedTasks?.length ? ( + + 关联开发任务 + + {detail.linkedTasks.length} + + + } + styles={{ body: { padding: 0 } }} + > + {detail.linkedTasks.map((t, idx) => ( +
+ + {t.taskNo} + + + {t.content} + + {t.status} +
+ ))} +
+ ) : null} +
)}
- setCreateOpen(false)} - onOk={() => void submitCreate()} - confirmLoading={creating} - destroyOnClose - okText="提交" - > + setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交"> - + + + - + - + - setRejectOpen(false)} - onOk={() => void submitReject()} - confirmLoading={acting} - destroyOnClose - okText="确认驳回" - okButtonProps={{ danger: true }} - > -
- - + setReviewOpen(false)} onOk={() => void submitReviewStep1()} confirmLoading={acting} destroyOnClose> + + + setReviewDecision(e.target.value as 'APPROVE' | 'REJECT')} + options={[ + { value: 'APPROVE', label: '通过' }, + { value: 'REJECT', label: '驳回' }, + ]} + /> + {reviewDecision === 'REJECT' ? ( + + + + ) : ( + + + + )} + + + + setCreateTasksOpen(false)} onOk={() => void submitCreateTasks()} confirmLoading={acting} destroyOnClose width={640}> + 审批通过需至少创建 1 条开发计划任务。 +
+ + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...rest }) => ( + + + + + + + + remove(tn)} /> + + ))} + + + )} + + + ); + })} + + )} +
diff --git a/apps/admin-web/src/pages/SystemSettingsPage.tsx b/apps/admin-web/src/pages/SystemSettingsPage.tsx index b5df976..a4ed925 100644 --- a/apps/admin-web/src/pages/SystemSettingsPage.tsx +++ b/apps/admin-web/src/pages/SystemSettingsPage.tsx @@ -61,34 +61,6 @@ function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loadin ); } -function WecomAlertTestButton() { - const [testing, setTesting] = useState(false); - async function onTest() { - setTesting(true); - try { - const res = await request<{ ok: boolean; message: string }>( - '/admin/system-config/wecom-alert/test', - { method: 'POST', body: '{}' }, - ); - message.success(res.message || '已发送测试告警'); - } catch (e) { - message.error(e instanceof Error ? e.message : '发送失败'); - } finally { - setTesting(false); - } - } - return ( -
- - - 需已配置 WECOM_ALERT_WEBHOOK_URL 并开启上方开关 - -
- ); -} - function renderField( field: SystemConfigFieldMeta, configuredSecrets: string[], @@ -291,8 +263,6 @@ export default function SystemSettingsPage() { meta.configuredSecrets, f.key === 'MOCK_SMS' && mockSmsEnabled ? ( - ) : f.key === 'WECOM_ALERT_ENABLED' ? ( - ) : undefined, ), )} diff --git a/apps/admin-web/src/pages/WecomBotLogsPage.tsx b/apps/admin-web/src/pages/WecomBotLogsPage.tsx new file mode 100644 index 0000000..b98886a --- /dev/null +++ b/apps/admin-web/src/pages/WecomBotLogsPage.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useAdminList } from '../lib/useAdminList'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import type { WecomBotLogDto } from '@dukang/shared-types'; + +export default function WecomBotLogsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [detail, setDetail] = useState(null); + + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + '/admin/logs/wecom-bots', + () => { + const qs = new URLSearchParams(); + if (filters.botId) qs.set('botId', filters.botId); + if (filters.wecomUserId) qs.set('wecomUserId', filters.wecomUserId); + if (filters.action) qs.set('action', filters.action); + if (filters.success) qs.set('success', filters.success); + return qs; + }, + [filters], + ); + + const columns: ColumnsType = [ + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' }, + { title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true }, + { title: '动作', dataIndex: 'action', width: 160, ellipsis: true }, + { title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' }, + { + title: '结果', + dataIndex: 'success', + width: 80, + render: (v: boolean) => {v ? '成功' : '失败'}, + }, + { title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' }, + { title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ 企微机器人日志 + + 记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。 + + +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDetail(null)}> + {detail && ( + + {fmtTime(detail.createdAt)} + {detail.botName || detail.botKey || '—'} + {detail.wecomUserId} + {detail.action} + {detail.permission || '—'} + {detail.inputSummary || '—'} + {detail.success ? '成功' : '失败'} + {detail.errorMessage || '—'} + {detail.latencyMs != null ? `${detail.latencyMs} ms` : '—'} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx index da5e000..1ea6632 100644 --- a/apps/admin-web/src/pages/WecomBotsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotsPage.tsx @@ -20,13 +20,14 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { - WECOM_BOT_PERMISSIONS, + WECOM_BOT_PERMISSION_GROUPS, WECOM_BOT_PERMISSION_LABELS, WECOM_BOT_ROLE_DEFAULT_PERMISSIONS, WECOM_BOT_ROLE_LABELS, WECOM_BOT_ROLES, type LlmApiConfigOptionDto, type KnowledgeBaseOptionDto, + type WecomAibotRuntimeDto, type WecomBotDto, type WecomBotPermission, type WecomBotRole, @@ -36,17 +37,6 @@ import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; -type ListRes = { - items: WecomBotDto[]; - total: number; - page: number; - pageSize: number; - runtime?: { - masterEnabled: boolean; - bots: Array<{ id: string; connected: boolean; lastError: string | null }>; - }; -}; - type FormValues = { name: string; role: WecomBotRole; @@ -55,6 +45,7 @@ type FormValues = { avatarUrl?: string; welcome?: string; permissions: WecomBotPermission[]; + reviewSuperAdminWecomUserIds?: string; aiEnabled: boolean; llmConfigId?: string | null; knowledgeBaseId?: string | null; @@ -62,18 +53,70 @@ type FormValues = { sortOrder: number; }; +function parseUserIdLines(text?: string): string[] { + if (!text?.trim()) return []; + return [...new Set(text.split(/[,,\s\n]+/).map((s) => s.trim()).filter(Boolean))]; +} + +function formatUserIdLines(ids?: string[]): string { + return (ids ?? []).join('\n'); +} + +function mapDtoToForm(row: WecomBotDto): FormValues { + return { + name: row.name, + role: row.role, + botId: row.botId, + secret: '', + avatarUrl: row.avatarUrl || '', + welcome: row.welcome || '', + permissions: [...row.permissions], + reviewSuperAdminWecomUserIds: formatUserIdLines(row.reviewSuperAdminWecomUserIds), + aiEnabled: row.aiEnabled, + llmConfigId: row.llmConfigId, + knowledgeBaseId: row.knowledgeBaseId, + enabled: row.enabled, + sortOrder: row.sortOrder, + }; +} + +function samePermissionSet(a: WecomBotPermission[], b: WecomBotPermission[]): boolean { + if (a.length !== b.length) return false; + const setB = new Set(b); + return a.every((p) => setB.has(p)); +} + +const CREATE_DEFAULTS: FormValues = { + name: '', + role: 'CUSTOMER_SERVICE', + botId: '', + secret: '', + avatarUrl: '', + welcome: '', + permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE], + reviewSuperAdminWecomUserIds: '', + aiEnabled: false, + llmConfigId: null, + knowledgeBaseId: null, + enabled: true, + sortOrder: 0, +}; + export default function WecomBotsPage() { const [filterForm] = Form.useForm(); const [form] = Form.useForm(); const [filters, setFilters] = useState>({}); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); + const [formInitial, setFormInitial] = useState(null); + const [formReady, setFormReady] = useState(false); const [saving, setSaving] = useState(false); const [detail, setDetail] = useState(null); - const [runtime, setRuntime] = useState(); + const [runtime, setRuntime] = useState(); const [llmOptions, setLlmOptions] = useState([]); const [kbOptions, setKbOptions] = useState([]); const roleWatch = Form.useWatch('role', form); + const permissionsWatch = Form.useWatch('permissions', form) as WecomBotPermission[] | undefined; const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/wecom-bots', @@ -87,11 +130,13 @@ export default function WecomBotsPage() { [filters], ); - useEffect(() => { - // useAdminList returns items; runtime comes from same API — fetch once for banner - void request('/admin/wecom-bots?page=1&pageSize=1') - .then((res) => setRuntime(res.runtime)) + const loadRuntime = () => + request('/admin/wecom-bots/runtime') + .then(setRuntime) .catch(() => {}); + + useEffect(() => { + void loadRuntime(); }, [data]); useEffect(() => { @@ -109,46 +154,51 @@ export default function WecomBotsPage() { }); }, []); + useEffect(() => { + if (!modalOpen || !formReady || !formInitial) return; + form.resetFields(); + form.setFieldsValue(formInitial); + }, [modalOpen, formReady, formInitial, form]); + + function closeModal() { + setModalOpen(false); + setFormReady(false); + setFormInitial(null); + setEditing(null); + form.resetFields(); + } + function openCreate() { setEditing(null); - form.setFieldsValue({ - name: '', - role: 'CUSTOMER_SERVICE', - botId: '', - secret: '', - avatarUrl: '', - welcome: '', - permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE], - aiEnabled: false, - llmConfigId: null, - knowledgeBaseId: null, - enabled: true, - sortOrder: 0, - }); + setFormInitial({ ...CREATE_DEFAULTS }); + setFormReady(true); setModalOpen(true); } - function openEdit(row: WecomBotDto) { + async function openEdit(row: WecomBotDto) { setEditing(row); - form.setFieldsValue({ - name: row.name, - role: row.role, - botId: row.botId, - secret: '', - avatarUrl: row.avatarUrl || '', - welcome: row.welcome || '', - permissions: row.permissions, - aiEnabled: row.aiEnabled, - llmConfigId: row.llmConfigId, - knowledgeBaseId: row.knowledgeBaseId, - enabled: row.enabled, - sortOrder: row.sortOrder, - }); + setFormInitial(null); + setFormReady(false); setModalOpen(true); + try { + const detail = await request(`/admin/wecom-bots/${row.id}`); + setEditing(detail); + setFormInitial(mapDtoToForm(detail)); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载机器人详情失败'); + setFormInitial(mapDtoToForm(row)); + } finally { + setFormReady(true); + } } async function submit() { const values = await form.validateFields(); + const permissions = (form.getFieldValue('permissions') ?? values.permissions) as WecomBotPermission[]; + if (!permissions?.length) { + message.error('请至少选择一项权限'); + return; + } setSaving(true); try { const payload = { @@ -157,7 +207,8 @@ export default function WecomBotsPage() { botId: values.botId.trim(), avatarUrl: values.avatarUrl?.trim() || null, welcome: values.welcome?.trim() || null, - permissions: values.permissions, + permissions, + reviewSuperAdminWecomUserIds: parseUserIdLines(values.reviewSuperAdminWecomUserIds), aiEnabled: values.aiEnabled, llmConfigId: values.llmConfigId || null, knowledgeBaseId: values.knowledgeBaseId || null, @@ -165,13 +216,20 @@ export default function WecomBotsPage() { sortOrder: values.sortOrder, }; if (editing) { - await request(`/admin/wecom-bots/${editing.id}`, { + const saved = await request(`/admin/wecom-bots/${editing.id}`, { method: 'PUT', body: JSON.stringify({ ...payload, secret: values.secret?.trim() || undefined, }), }); + if (!samePermissionSet(permissions, saved.permissions)) { + message.warning('权限与保存结果不一致,已用服务端数据刷新表单'); + setFormInitial(mapDtoToForm(saved)); + setEditing(saved); + reload(); + return; + } message.success('已更新'); } else { if (!values.secret?.trim()) { @@ -187,7 +245,7 @@ export default function WecomBotsPage() { }); message.success('已创建'); } - setModalOpen(false); + closeModal(); reload(); } catch (e) { message.error(e instanceof Error ? e.message : '保存失败'); @@ -208,10 +266,10 @@ export default function WecomBotsPage() { async function reloadConnections() { try { - const st = await request<{ - masterEnabled: boolean; - bots: Array<{ id: string; connected: boolean; lastError: string | null }>; - }>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' }); + const st = await request('/admin/wecom-bots/reload', { + method: 'POST', + body: '{}', + }); message.success('已重载长连接'); setRuntime(st); reload(); @@ -287,7 +345,7 @@ export default function WecomBotsPage() { fixed: 'right', render: (_, row) => ( -