Files
tps-dashboard/frontend/src/pages/EditorDesk/EditorDesk.jsx
T

369 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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