feat: 添加三个 service 文件和 RoleGuard 组件

This commit is contained in:
simonkoson
2026-05-21 19:46:40 +08:00
parent 7cc0bf21ff
commit b7560a6cf6
6 changed files with 455 additions and 1 deletions
@@ -0,0 +1,20 @@
/**
* 角色路由守卫 — 拦截非授权角色访问受保护路由
* 用法: <Route path="/users" element={<RoleGuard roles={['zhipianren']}><UserManage /></RoleGuard>} />
*/
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 <Navigate to="/dashboard" replace />
}
return children
}
export default RoleGuard
@@ -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
+3 -1
View File
@@ -13,4 +13,6 @@ export async function logout() {
export async function me() {
const response = await http.get('/auth/me')
return response.data
}
}
export default { login, logout, me }
+21
View File
@@ -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
}
+21
View File
@@ -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
}
@@ -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
}