feat: 添加三个 service 文件和 RoleGuard 组件
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tabs, Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Space, Upload, Popconfirm } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { listEpisodes, createEpisode, updateEpisode, deleteEpisode } from '../../services/episodeService'
|
||||
import { listTargets, createTarget, updateTarget, deleteTarget } from '../../services/yearlyTargetService'
|
||||
import http from '../../services/http'
|
||||
|
||||
const { TabPane } = Tabs
|
||||
|
||||
// ============================================================
|
||||
// Tab1: 节目期次
|
||||
// ============================================================
|
||||
function EpisodesTab() {
|
||||
const [episodes, setEpisodes] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
loadEpisodes()
|
||||
}, [])
|
||||
|
||||
const loadEpisodes = () => {
|
||||
setLoading(true)
|
||||
listEpisodes().then(data => {
|
||||
setEpisodes((data || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)))
|
||||
setLoading(false)
|
||||
}).catch(() => {
|
||||
message.error('加载节目列表失败')
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
setEditing(record)
|
||||
form.setFieldsValue({
|
||||
episode_number: record.episode_number,
|
||||
program_name: record.program_name,
|
||||
air_date: record.air_date ? record.air_date : null,
|
||||
editor_name_snapshot: record.editor_name_snapshot || '',
|
||||
audience_share: record.audience_share,
|
||||
audience_rating: record.audience_rating,
|
||||
is_rerun: record.is_rerun,
|
||||
notes: record.notes,
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id) => {
|
||||
deleteEpisode(id).then(() => {
|
||||
message.success('删除成功')
|
||||
loadEpisodes()
|
||||
}).catch(err => {
|
||||
message.error(err.response?.data?.detail || '删除失败')
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.validateFields().then(values => {
|
||||
const payload = {
|
||||
...values,
|
||||
air_date: values.air_date ? values.air_date.format('YYYY-MM-DD') : null,
|
||||
}
|
||||
const promise = editing
|
||||
? updateEpisode(editing.id, payload)
|
||||
: createEpisode(payload)
|
||||
promise.then(() => {
|
||||
message.success(editing ? '更新成功' : '创建成功')
|
||||
setModalVisible(false)
|
||||
loadEpisodes()
|
||||
}).catch(err => {
|
||||
message.error(err.response?.data?.detail || '操作失败')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '期次号', dataIndex: 'episode_number', key: 'episode_number', width: 80 },
|
||||
{ title: '节目名', dataIndex: 'program_name', key: 'program_name' },
|
||||
{ title: '播出日期', dataIndex: 'air_date', key: 'air_date', render: d => d || '-' },
|
||||
{ title: '编导', dataIndex: 'editor_name_snapshot', key: 'editor_name_snapshot', render: v => v || '-' },
|
||||
{ title: '收视份额', dataIndex: 'audience_share', key: 'audience_share', render: v => v != null ? v.toFixed(4) : '-' },
|
||||
{ title: '收视率', dataIndex: 'audience_rating', key: 'audience_rating', render: v => v != null ? v.toFixed(2) : '-' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增期次</Button>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={episodes} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} />
|
||||
<Modal title={editing ? '编辑期次' : '新增期次'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存" width={520}>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="episode_number" label="期次号" rules={[{ required: true, message: '请输入期次号' }]}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="如 1248" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="program_name" label="节目名称" rules={[{ required: true, message: '请输入节目名称' }]}>
|
||||
<Input placeholder="如《军事科技》武器解析系列" />
|
||||
</Form.Item>
|
||||
<Form.Item name="air_date" label="播出日期" rules={[{ required: true, message: '请选择播出日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="editor_name_snapshot" label="编导姓名(快照)">
|
||||
<Input placeholder="可留空,离职编导只存姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="audience_share" label="收视份额(0-1)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.72" />
|
||||
</Form.Item>
|
||||
<Form.Item name="audience_rating" label="收视率">
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="3.5" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tab2: 年度目标
|
||||
// ============================================================
|
||||
function YearlyTargetsTab() {
|
||||
const [targets, setTargets] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
loadTargets()
|
||||
}, [])
|
||||
|
||||
const loadTargets = () => {
|
||||
setLoading(true)
|
||||
listTargets().then(data => {
|
||||
setTargets((data || []).sort((a, b) => b.year - a.year))
|
||||
setLoading(false)
|
||||
}).catch(() => {
|
||||
message.error('加载年度目标失败')
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
setEditing(record)
|
||||
form.setFieldsValue({
|
||||
year: record.year,
|
||||
base_target: record.base_target,
|
||||
stretch_target: record.stretch_target,
|
||||
notes: record.notes,
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id) => {
|
||||
deleteTarget(id).then(() => {
|
||||
message.success('删除成功')
|
||||
loadTargets()
|
||||
}).catch(err => {
|
||||
message.error(err.response?.data?.detail || '删除失败')
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.validateFields().then(values => {
|
||||
const promise = editing
|
||||
? updateTarget(editing.id, values)
|
||||
: createTarget(values)
|
||||
promise.then(() => {
|
||||
message.success(editing ? '更新成功' : '创建成功')
|
||||
setModalVisible(false)
|
||||
loadTargets()
|
||||
}).catch(err => {
|
||||
const detail = err.response?.data?.detail || ''
|
||||
if (detail.includes('已存在') || detail.includes('历史年份')) {
|
||||
message.error('该年目标已存在,历史年份不可覆盖')
|
||||
} else {
|
||||
message.error(detail || '操作失败')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '年份', dataIndex: 'year', key: 'year', width: 80 },
|
||||
{ title: '基础目标', dataIndex: 'base_target', key: 'base_target', render: v => v?.toFixed(4) || '-' },
|
||||
{ title: '摸高目标', dataIndex: 'stretch_target', key: 'stretch_target', render: v => v?.toFixed(4) || '-' },
|
||||
{ title: '备注', dataIndex: 'notes', key: 'notes', render: v => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增年度目标</Button>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={targets} rowKey="id" loading={loading} pagination={false} />
|
||||
<Modal title={editing ? '编辑年度目标' : '新增年度目标'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存">
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="year"
|
||||
label="年份"
|
||||
rules={[{ required: true, message: '请输入年份' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="如 2024"
|
||||
min={2000}
|
||||
max={2100}
|
||||
disabled={!!editing}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="base_target" label="基础目标" rules={[{ required: true, message: '请输入基础目标' }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.6448" />
|
||||
</Form.Item>
|
||||
<Form.Item name="stretch_target" label="摸高目标" rules={[{ required: true, message: '请输入摸高目标' }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.8989" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tab3: Excel 批量导入
|
||||
// ============================================================
|
||||
function ImportTab() {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
|
||||
const downloadTemplate = () => {
|
||||
window.open('/api/imports/template', '_blank')
|
||||
}
|
||||
|
||||
const uploadProps = {
|
||||
name: 'file',
|
||||
accept: '.xlsx',
|
||||
showUploadList: false,
|
||||
beforeUpload: (file) => {
|
||||
if (!file.filename.endsWith('.xlsx') && !file.name.endsWith('.xlsx')) {
|
||||
message.error('仅支持 .xlsx 格式')
|
||||
return false
|
||||
}
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
http.post('/imports/episodes', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then(res => {
|
||||
setResult(res.data)
|
||||
setUploading(false)
|
||||
if (res.data.errors?.length) {
|
||||
message.warning(`导入完成:成功 ${res.data.imported} 条,失败 ${res.data.errors.length} 条`)
|
||||
} else {
|
||||
message.success(`导入成功:${res.data.imported} 条`)
|
||||
}
|
||||
}).catch(err => {
|
||||
setUploading(false)
|
||||
message.error(err.response?.data?.detail || '上传失败')
|
||||
})
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
const downloadErrors = () => {
|
||||
if (!result?.batch_id) return
|
||||
window.open(`/api/imports/errors/${result.batch_id}`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>下载导入模板</Button>
|
||||
<Upload {...uploadProps}>
|
||||
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
|
||||
{uploading ? '上传中...' : '选择 Excel 上传'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div style={{ background: '#f6f8fa', padding: 16, borderRadius: 8 }}>
|
||||
<p style={{ margin: '0 0 8px', fontWeight: 600 }}>导入结果</p>
|
||||
<p style={{ margin: 0 }}>总计:{result.total} 条</p>
|
||||
<p style={{ margin: '4px 0', color: '#52c41a' }}>成功:{result.imported} 条</p>
|
||||
{result.errors?.length > 0 && (
|
||||
<>
|
||||
<p style={{ margin: '4px 0', color: '#cf1322' }}>失败:{result.errors.length} 条</p>
|
||||
<Button size="small" onClick={downloadErrors} style={{ marginTop: 8 }}>
|
||||
下载失败行
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24, color: '#999', fontSize: 12 }}>
|
||||
<p>支持批量导入节目期次,导入失败时可根据提示修正后重新上传。</p>
|
||||
<p>注意:Phase 2 不支持重播行导入,有重播行请留空。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主组件
|
||||
// ============================================================
|
||||
function EditorDesk() {
|
||||
return (
|
||||
<div style={{ padding: '0 0 24px' }}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 20, fontWeight: 600 }}>责编录入</h2>
|
||||
<Tabs defaultActiveKey="episodes">
|
||||
<TabPane tab="节目期次" key="episodes">
|
||||
<EpisodesTab />
|
||||
</TabPane>
|
||||
<TabPane tab="年度目标" key="targets">
|
||||
<YearlyTargetsTab />
|
||||
</TabPane>
|
||||
<TabPane tab="Excel 批量导入" key="import">
|
||||
<ImportTab />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorDesk
|
||||
Reference in New Issue
Block a user