feat: M4-08 — release admin page (changelogs, ADR, checklist)
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 9s

- ReleaseAdmin page with 3 tabs: version logs, architecture decisions, release checklist
- CRUD modals for changelogs and decisions
- Checklist with check/uncheck toggle

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 18:15:42 +08:00
parent 8b6c957a30
commit adfeeaa657
3 changed files with 167 additions and 1 deletions

View File

@ -33,6 +33,7 @@ const ImportMonitorPage = lazy(() => import('./pages/ImportMonitor'))
const KnowledgeOpsPage = lazy(() => import('./pages/KnowledgeOps')) const KnowledgeOpsPage = lazy(() => import('./pages/KnowledgeOps'))
const ChatLogsPage = lazy(() => import('./pages/ChatLogs')) const ChatLogsPage = lazy(() => import('./pages/ChatLogs'))
const HermesSettings = lazy(() => import('./pages/HermesSettings')) const HermesSettings = lazy(() => import('./pages/HermesSettings'))
const ReleaseAdmin = lazy(() => import('./pages/ReleaseAdmin'))
const TaskAssistant = lazy(() => import('./pages/TaskAssistant')) const TaskAssistant = lazy(() => import('./pages/TaskAssistant'))
const Placeholder = lazy(() => import('./pages/Placeholder')) const Placeholder = lazy(() => import('./pages/Placeholder'))
const ForbiddenPage = lazy(() => import('./pages/403')) const ForbiddenPage = lazy(() => import('./pages/403'))
@ -182,6 +183,7 @@ function App() {
<Route path="learning-data" element={<Suspense fallback={<PageLoading />}><LearningData /></Suspense>} /> <Route path="learning-data" element={<Suspense fallback={<PageLoading />}><LearningData /></Suspense>} />
<Route path="backup" element={<Suspense fallback={<PageLoading />}><BackupAdmin /></Suspense>} /> <Route path="backup" element={<Suspense fallback={<PageLoading />}><BackupAdmin /></Suspense>} />
<Route path="reporting" element={<Suspense fallback={<PageLoading />}><ReportingAdmin /></Suspense>} /> <Route path="reporting" element={<Suspense fallback={<PageLoading />}><ReportingAdmin /></Suspense>} />
<Route path="release" element={<Suspense fallback={<PageLoading />}><ReleaseAdmin /></Suspense>} />
<Route path="notification-admin" element={<Suspense fallback={<PageLoading />}><NotificationAdmin /></Suspense>} /> <Route path="notification-admin" element={<Suspense fallback={<PageLoading />}><NotificationAdmin /></Suspense>} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>

View File

@ -1,5 +1,5 @@
import type React from 'react' import type React from 'react'
import { DashboardOutlined, RobotOutlined, UserOutlined, DollarOutlined, BookOutlined, ImportOutlined, FileOutlined, SafetyOutlined, CodeOutlined, CloudServerOutlined, SettingOutlined } from '@ant-design/icons' import { DashboardOutlined, RobotOutlined, UserOutlined, DollarOutlined, BookOutlined, ImportOutlined, FileOutlined, SafetyOutlined, CodeOutlined, CloudServerOutlined, SettingOutlined, RocketOutlined } from '@ant-design/icons'
import type { AdminRole } from '@/types/admin' import type { AdminRole } from '@/types/admin'
import { hasRole } from '@/constants/roles' import { hasRole } from '@/constants/roles'
@ -25,6 +25,7 @@ export const adminMenuItems: AdminMenuItem[] = [
{ path: '/knowledge/sources', name: '知识源列表' }, { path: '/knowledge/sources', name: '知识源列表' },
{ path: '/knowledge/ops', name: '知识运维' }, { path: '/knowledge/ops', name: '知识运维' },
]}, ]},
{ path: '/release', name: '发布决策', icon: <RocketOutlined />, requiredRole: 'ADMIN' },
{ path: '/imports', name: '文档导入', icon: <ImportOutlined /> }, { path: '/imports', name: '文档导入', icon: <ImportOutlined /> },
{ path: '/files', name: '文件与 COS', icon: <FileOutlined /> }, { path: '/files', name: '文件与 COS', icon: <FileOutlined /> },
{ path: '/reporting', name: '报表导出', icon: <FileOutlined />, requiredRole: 'ADMIN' }, { path: '/reporting', name: '报表导出', icon: <FileOutlined />, requiredRole: 'ADMIN' },

163
src/pages/ReleaseAdmin.tsx Normal file
View File

@ -0,0 +1,163 @@
import { useState } from 'react'
import { Table, Button, Modal, Form, Input, Checkbox, Tag, Space, Typography, Tabs, Card } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, RocketOutlined } from '@ant-design/icons'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/services/http-client'
import { message } from 'antd'
const { Title } = Typography
const { TextArea } = Input
export default function ReleaseAdmin() {
const [tab, setTab] = useState('changelogs')
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [form] = Form.useForm()
const qc = useQueryClient()
const { data: changelogs, isLoading: cLoading } = useQuery({
queryKey: ['release', 'changelogs'],
queryFn: () => api.get<any[]>('/admin-api/release/changelogs').then(d => d ?? []),
enabled: tab === 'changelogs',
})
const { data: decisions, isLoading: dLoading } = useQuery({
queryKey: ['release', 'decisions'],
queryFn: () => api.get<any[]>('/admin-api/release/decisions').then(d => d ?? []),
enabled: tab === 'decisions',
})
const { data: checklist } = useQuery({
queryKey: ['release', 'checklist'],
queryFn: () => api.get<any[]>('/admin-api/release/checklists/v1.0').then(d => d ?? []),
enabled: tab === 'checklist',
})
const saveMutation = useMutation({
mutationFn: (values: any) => {
const endpoint = tab === 'changelogs' ? 'changelogs' : 'decisions'
return editing
? api.patch(`/admin-api/release/${endpoint}/${editing.id}`, values)
: api.post(`/admin-api/release/${endpoint}`, values)
},
onSuccess: () => {
message.success(editing ? '已更新' : '已创建')
qc.invalidateQueries({ queryKey: ['release'] })
setModalOpen(false); setEditing(null); form.resetFields()
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/admin-api/release/${tab === 'changelogs' ? 'changelogs' : 'decisions'}/${id}`),
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['release'] }) },
})
const toggleCheck = useMutation({
mutationFn: ({ id, checked }: { id: string; checked: boolean }) =>
api.patch(`/admin-api/release/checklists/${id}`, { checked }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['release', 'checklist'] }),
})
const openCreate = () => { setEditing(null); form.resetFields(); setModalOpen(true) }
const openEdit = (r: any) => { setEditing(r); form.setFieldsValue(r); setModalOpen(true) }
const changelogColumns = [
{ title: '版本', dataIndex: 'version', width: 80 },
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
{ title: '内容', dataIndex: 'content', width: 300, ellipsis: true, render: (c: string) => c?.slice(0, 120) },
{ title: '平台', dataIndex: 'platform', width: 80, render: (p: string) => <Tag>{p}</Tag> },
{ title: '发布时间', dataIndex: 'publishedAt', width: 110, render: (d: string) => d ? new Date(d).toLocaleDateString() : '-' },
{
title: '操作', width: 100,
render: (_: any, r: any) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => deleteMutation.mutate(r.id)} />
</Space>
),
},
]
const decisionColumns = [
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
{ title: '上下文', dataIndex: 'context', width: 200, ellipsis: true, render: (c: string) => c?.slice(0, 100) || '-' },
{ title: '决策', dataIndex: 'decision', width: 250, ellipsis: true, render: (d: string) => d?.slice(0, 150) || '-' },
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => <Tag color={s === 'accepted' ? 'green' : s === 'proposed' ? 'blue' : 'default'}>{s}</Tag> },
{
title: '操作', width: 100,
render: (_: any, r: any) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => deleteMutation.mutate(r.id)} />
</Space>
),
},
]
return (
<div>
<Title level={4}><RocketOutlined /> </Title>
<Tabs activeKey={tab} onChange={setTab} items={[
{
key: 'changelogs', label: '版本日志',
children: (
<div>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ marginBottom: 16 }}></Button>
<Table dataSource={changelogs || []} columns={changelogColumns} rowKey="id" loading={cLoading} size="small" />
</div>
),
},
{
key: 'decisions', label: '架构决策 (ADR)',
children: (
<div>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ marginBottom: 16 }}></Button>
<Table dataSource={decisions || []} columns={decisionColumns} rowKey="id" loading={dLoading} size="small" />
</div>
),
},
{
key: 'checklist', label: '回归清单',
children: (
<Card size="small">
{(checklist || []).map((item: any) => (
<div key={item.id} style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
<Checkbox checked={item.checked} onChange={e => toggleCheck.mutate({ id: item.id, checked: e.target.checked })}>
<span style={item.checked ? { textDecoration: 'line-through', color: '#999' } : {}}>{item.item}</span>
</Checkbox>
</div>
))}
</Card>
),
},
]} />
<Modal
title={editing ? '编辑' : '新建'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditing(null) }}
onOk={() => form.submit()}
confirmLoading={saveMutation.isPending}
>
<Form form={form} layout="vertical" onFinish={saveMutation.mutate}>
{tab === 'changelogs' ? (
<>
<Form.Item name="version" label="版本" rules={[{ required: true }]}><Input placeholder="1.0.0" /></Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}><Input placeholder="版本标题" /></Form.Item>
<Form.Item name="content" label="内容"><TextArea rows={4} placeholder="更新内容" /></Form.Item>
<Form.Item name="platform" label="平台" initialValue="ios"><Input /></Form.Item>
</>
) : (
<>
<Form.Item name="title" label="标题" rules={[{ required: true }]}><Input placeholder="决策标题" /></Form.Item>
<Form.Item name="context" label="上下文"><TextArea rows={2} placeholder="背景和上下文" /></Form.Item>
<Form.Item name="decision" label="决策内容"><TextArea rows={3} placeholder="决策内容" /></Form.Item>
<Form.Item name="rationale" label="理由"><TextArea rows={2} placeholder="决策理由" /></Form.Item>
</>
)}
</Form>
</Modal>
</div>
)
}