369 lines
14 KiB
React
369 lines
14 KiB
React
|
|
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
|