diff --git a/frontend/src/components/AuthGuard/RoleGuard.jsx b/frontend/src/components/AuthGuard/RoleGuard.jsx new file mode 100644 index 0000000..c7c4cfb --- /dev/null +++ b/frontend/src/components/AuthGuard/RoleGuard.jsx @@ -0,0 +1,20 @@ +/** + * 角色路由守卫 — 拦截非授权角色访问受保护路由 + * 用法: } /> + */ +import { Navigate } from 'react-router-dom' +import useAuthStore from '../../stores/authStore' + +function RoleGuard({ children, roles }) { + const { user } = useAuthStore() + + if (!user) return null // AuthGuard 已处理未登录跳转 + + if (!roles.includes(user.role)) { + return + } + + return children +} + +export default RoleGuard \ No newline at end of file diff --git a/frontend/src/pages/EditorDesk/EditorDesk.jsx b/frontend/src/pages/EditorDesk/EditorDesk.jsx new file mode 100644 index 0000000..983b741 --- /dev/null +++ b/frontend/src/pages/EditorDesk/EditorDesk.jsx @@ -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) => ( + + + + + setModalVisible(false)} okText="保存" width={520}> +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + ) +} + +// ============================================================ +// 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) => ( + + + +
+ setModalVisible(false)} okText="保存"> +
+ + + + + + + + + + + + + +
+ + ) +} + +// ============================================================ +// 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 ( +
+
+ + + + +
+ + {result && ( +
+

导入结果

+

总计:{result.total} 条

+

成功:{result.imported} 条

+ {result.errors?.length > 0 && ( + <> +

失败:{result.errors.length} 条

+ + + )} +
+ )} + +
+

支持批量导入节目期次,导入失败时可根据提示修正后重新上传。

+

注意:Phase 2 不支持重播行导入,有重播行请留空。

+
+
+ ) +} + +// ============================================================ +// 主组件 +// ============================================================ +function EditorDesk() { + return ( +
+

责编录入

+ + + + + + + + + + + +
+ ) +} + +export default EditorDesk \ No newline at end of file diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index 629d606..d6e45b5 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -13,4 +13,6 @@ export async function logout() { export async function me() { const response = await http.get('/auth/me') return response.data -} \ No newline at end of file +} + +export default { login, logout, me } \ No newline at end of file diff --git a/frontend/src/services/episodeService.js b/frontend/src/services/episodeService.js new file mode 100644 index 0000000..41d6782 --- /dev/null +++ b/frontend/src/services/episodeService.js @@ -0,0 +1,21 @@ +import http from './http' + +export async function listEpisodes(limit = 50, offset = 0) { + const response = await http.get('/episodes', { params: { limit, offset } }) + return response.data +} + +export async function createEpisode(data) { + const response = await http.post('/episodes', data) + return response.data +} + +export async function updateEpisode(id, data) { + const response = await http.patch(`/episodes/${id}`, data) + return response.data +} + +export async function deleteEpisode(id) { + const response = await http.delete(`/episodes/${id}`) + return response.data +} \ No newline at end of file diff --git a/frontend/src/services/userService.js b/frontend/src/services/userService.js new file mode 100644 index 0000000..bcb186e --- /dev/null +++ b/frontend/src/services/userService.js @@ -0,0 +1,21 @@ +import http from './http' + +export async function listUsers() { + const response = await http.get('/users') + return response.data +} + +export async function createUser(data) { + const response = await http.post('/users', data) + return response.data +} + +export async function updateUser(id, data) { + const response = await http.patch(`/users/${id}`, data) + return response.data +} + +export async function deactivateUser(id) { + const response = await http.delete(`/users/${id}`) + return response.data +} \ No newline at end of file diff --git a/frontend/src/services/yearlyTargetService.js b/frontend/src/services/yearlyTargetService.js new file mode 100644 index 0000000..d06e7cc --- /dev/null +++ b/frontend/src/services/yearlyTargetService.js @@ -0,0 +1,21 @@ +import http from './http' + +export async function listTargets() { + const response = await http.get('/yearly_targets') + return response.data +} + +export async function createTarget(data) { + const response = await http.post('/yearly_targets', data) + return response.data +} + +export async function updateTarget(id, data) { + const response = await http.patch(`/yearly_targets/${id}`, data) + return response.data +} + +export async function deleteTarget(id) { + const response = await http.delete(`/yearly_targets/${id}`) + return response.data +} \ No newline at end of file