init: EasyScreen 电子菜单系统
- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EasyScreen 电子菜单后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3116
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "easyscreen-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.21.0",
|
||||
"axios": "^1.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import Layout from './components/Layout.jsx'
|
||||
import Login from './pages/Login.jsx'
|
||||
import Screens from './pages/Screens.jsx'
|
||||
import Assets from './pages/Assets.jsx'
|
||||
import Playlists from './pages/Playlists.jsx'
|
||||
|
||||
export default function App() {
|
||||
const authed = !!localStorage.getItem('easyscreen_token')
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
authed ? (
|
||||
<Layout />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/screens" replace />} />
|
||||
<Route path="screens" element={<Screens />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="playlists" element={<Playlists />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api', timeout: 60000 })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('easyscreen_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401 && !location.pathname.startsWith('/login')) {
|
||||
localStorage.removeItem('easyscreen_token')
|
||||
location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
// ---- 认证 ----
|
||||
export const login = (username, password) =>
|
||||
api.post('/auth/login', { username, password }).then((r) => r.data.token)
|
||||
|
||||
// ---- 屏幕 ----
|
||||
export const getScreens = () => api.get('/screens').then((r) => r.data)
|
||||
export const createScreen = (data) => api.post('/screens', data).then((r) => r.data)
|
||||
export const updateScreen = (id, data) => api.put(`/screens/${id}`, data).then((r) => r.data)
|
||||
export const deleteScreen = (id) => api.delete(`/screens/${id}`)
|
||||
export const bindPlaylist = (id, playlistId) =>
|
||||
api.post(`/screens/${id}/bind`, { playlist_id: playlistId }).then((r) => r.data)
|
||||
export const unbindPlaylist = (id) => api.delete(`/screens/${id}/bind`)
|
||||
|
||||
// ---- 素材 ----
|
||||
export const getAssets = () => api.get('/assets').then((r) => r.data)
|
||||
export const uploadAsset = (file, onProgress) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return api.post('/assets', fd, {
|
||||
onUploadProgress: (e) => onProgress?.(Math.round((e.loaded / e.total) * 100)),
|
||||
}).then((r) => r.data)
|
||||
}
|
||||
export const deleteAsset = (id) => api.delete(`/assets/${id}`)
|
||||
|
||||
// ---- 节目 ----
|
||||
export const getPlaylists = () => api.get('/playlists').then((r) => r.data)
|
||||
export const createPlaylist = (data) => api.post('/playlists', data).then((r) => r.data)
|
||||
export const updatePlaylist = (id, data) => api.put(`/playlists/${id}`, data).then((r) => r.data)
|
||||
export const deletePlaylist = (id) => api.delete(`/playlists/${id}`)
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Layout as AntLayout, Menu, Typography } from 'antd'
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
MonitorOutlined,
|
||||
PictureOutlined,
|
||||
LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
const { Sider, Content, Header } = AntLayout
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const selected = location.pathname.startsWith('/assets')
|
||||
? 'assets'
|
||||
: location.pathname.startsWith('/playlists')
|
||||
? 'playlists'
|
||||
: 'screens'
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('easyscreen_token')
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider theme="dark" width={200}>
|
||||
<div
|
||||
style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
EasyScreen
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selected]}
|
||||
items={[
|
||||
{ key: 'screens', icon: <MonitorOutlined />, label: '屏幕管理' },
|
||||
{ key: 'assets', icon: <PictureOutlined />, label: '素材管理' },
|
||||
{ key: 'playlists', icon: <AppstoreOutlined />, label: '节目编排' },
|
||||
]}
|
||||
onClick={({ key }) => navigate(`/${key}`)}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Header
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
电子菜单管理后台
|
||||
</Typography.Text>
|
||||
<a onClick={logout} style={{ cursor: 'pointer' }}>
|
||||
<LogoutOutlined /> 退出登录
|
||||
</a>
|
||||
</Header>
|
||||
<Content style={{ margin: 16 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Tag,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
InboxOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { deleteAsset, getAssets, uploadAsset } from '../api/client.js'
|
||||
|
||||
const { Dragger } = Upload
|
||||
|
||||
const fmtSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
export default function Assets() {
|
||||
const [assets, setAssets] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setAssets(await getAssets())
|
||||
} catch (e) {
|
||||
msg.error('加载失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [msg])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const customRequest = async ({ file, onSuccess, onError, onProgress }) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
await uploadAsset(file, (pct) => onProgress?.({ percent: pct }))
|
||||
onSuccess?.()
|
||||
msg.success(`「${file.name}」上传成功`)
|
||||
load()
|
||||
} catch (e) {
|
||||
const detail = e.response?.data?.detail
|
||||
onError?.(new Error(detail))
|
||||
msg.error(`上传失败: ${detail || e.message}`)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (asset) => {
|
||||
try {
|
||||
await deleteAsset(asset.id)
|
||||
msg.success('已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Card title="素材管理" style={{ marginBottom: 16 }}>
|
||||
<Dragger
|
||||
customRequest={customRequest}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
disabled={uploading}
|
||||
accept="image/*,video/mp4,video/webm"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading ? '正在上传...' : '点击或拖拽图片 / 视频到此区域上传'}
|
||||
</p>
|
||||
<p className="ant-upload-hint">支持 JPG / PNG / GIF / WebP / MP4 / WebM,单文件最大 500MB</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
<Card title={`素材列表(${assets.length})`} loading={loading}>
|
||||
{assets.length === 0 ? (
|
||||
<Empty description="暂无素材,请先上传" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{assets.map((a) => (
|
||||
<Col key={a.id} xs={12} sm={8} md={6} lg={4}>
|
||||
<Card
|
||||
size="small"
|
||||
cover={
|
||||
a.type === 'image' ? (
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
style={{ height: 140, objectFit: 'cover' }}
|
||||
onClick={() => window.open(a.url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 140,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="删除该素材?"
|
||||
description="被节目引用的素材需先从节目中移除"
|
||||
onConfirm={() => onDelete(a)}
|
||||
>
|
||||
<span style={{ color: '#ff4d4f' }}>删除</span>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={a.name}>
|
||||
{a.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>
|
||||
<Tag color={a.type === 'image' ? 'green' : 'purple'}>
|
||||
{a.type === 'image' ? '图片' : '视频'}
|
||||
</Tag>
|
||||
{fmtSize(a.size)}
|
||||
{a.type === 'image' && ` · 默认 ${a.duration}s`}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, Typography, message } from 'antd'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons'
|
||||
import { login } from '../api/client.js'
|
||||
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const token = await login(values.username, values.password)
|
||||
localStorage.setItem('easyscreen_token', token)
|
||||
// 整页跳转:让 App 重新初始化并读取登录态
|
||||
// (仅 React Router navigate 不会触发 App 重渲染,authed 判断仍是旧的 false)
|
||||
window.location.href = '/screens'
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #141e30, #243b55)',
|
||||
}}
|
||||
>
|
||||
{msgCtx}
|
||||
<Card style={{ width: 380 }} title={<Typography.Title level={3} style={{ margin: 0, textAlign: 'center' }}>EasyScreen 后台</Typography.Title>}>
|
||||
<Form onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登 录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
List,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
CheckCircleFilled,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAssets,
|
||||
getPlaylists,
|
||||
updatePlaylist,
|
||||
} from '../api/client.js'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
export default function Playlists() {
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [assets, setAssets] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [items, setItems] = useState([]) // [{asset_id, duration}]
|
||||
const [page, setPage] = useState(1)
|
||||
const [form] = Form.useForm()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setPlaylists(await getPlaylists())
|
||||
setAssets(await getAssets())
|
||||
} catch (e) {
|
||||
msg.error('加载失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [msg])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const assetMap = Object.fromEntries(assets.map((a) => [a.id, a]))
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
setItems([])
|
||||
setPage(1)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (playlist) => {
|
||||
setEditing(playlist)
|
||||
form.setFieldsValue({ name: playlist.name, description: playlist.description })
|
||||
setItems(playlist.items.map((it) => ({ asset_id: it.asset_id, duration: it.duration })))
|
||||
setPage(1)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
// 点击素材卡片:已选则移除,未选则添加(图片默认 10 秒,视频 0=按视频时长)
|
||||
const toggleAsset = (asset) => {
|
||||
setItems((prev) => {
|
||||
const exists = prev.some((it) => it.asset_id === asset.id)
|
||||
if (exists) return prev.filter((it) => it.asset_id !== asset.id)
|
||||
return [...prev, { asset_id: asset.id, duration: asset.type === 'video' ? 0 : 10 }]
|
||||
})
|
||||
}
|
||||
|
||||
const moveItem = (index, dir) => {
|
||||
const next = [...items]
|
||||
const target = index + dir
|
||||
if (target < 0 || target >= next.length) return
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
setItems(next)
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields()
|
||||
if (items.length === 0) {
|
||||
msg.warning('请先在左侧选择至少一个素材')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
description: values.description || '',
|
||||
items: items.map((it) => ({ asset_id: it.asset_id, duration: it.duration })),
|
||||
}
|
||||
if (editing) {
|
||||
await updatePlaylist(editing.id, payload)
|
||||
} else {
|
||||
await createPlaylist(payload)
|
||||
}
|
||||
msg.success('保存成功')
|
||||
setModalOpen(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (playlist) => {
|
||||
try {
|
||||
const r = await deletePlaylist(playlist.id)
|
||||
msg.success(r.data?.unbound_screens ? '已删除,并解除相关屏幕绑定' : '已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '描述', dataIndex: 'description', render: (v) => v || '—' },
|
||||
{ title: '素材数', width: 90, render: (_, row) => row.items.length },
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="删除该节目?"
|
||||
description="删除后相关屏幕将不再播放此节目"
|
||||
onConfirm={() => onDelete(row)}
|
||||
>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// 当前页素材
|
||||
const pageAssets = assets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
const selectedIds = new Set(items.map((it) => it.asset_id))
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Card
|
||||
title="节目编排"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建节目</Button>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={playlists}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑节目 - ${editing.name}` : '新建节目'}
|
||||
open={modalOpen}
|
||||
onOk={onSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
width={1100}
|
||||
destroyOnClose
|
||||
>
|
||||
<Row gutter={16}>
|
||||
{/* 左:素材库(分页网格,可视化选择) */}
|
||||
<Col span={14}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
素材库(点击选择,已选素材会在右侧显示)
|
||||
</div>
|
||||
{assets.length === 0 ? (
|
||||
<Empty description="暂无素材,请先到「素材管理」上传" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[12, 12]}>
|
||||
{pageAssets.map((a) => {
|
||||
const selected = selectedIds.has(a.id)
|
||||
return (
|
||||
<Col key={a.id} span={6}>
|
||||
<div
|
||||
onClick={() => toggleAsset(a)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
border: selected ? '2px solid #1677ff' : '2px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
{a.type === 'image' ? (
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={a.name}
|
||||
>
|
||||
<Tag color={a.type === 'image' ? 'green' : 'purple'} style={{ marginRight: 4 }}>
|
||||
{a.type === 'image' ? '图片' : '视频'}
|
||||
</Tag>
|
||||
{a.name}
|
||||
</div>
|
||||
{selected && (
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
fontSize: 18,
|
||||
color: '#1677ff',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={assets.length}
|
||||
onChange={setPage}
|
||||
showSizeChanger={false}
|
||||
showTotal={(t) => `共 ${t} 个素材`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* 右:已选素材 + 节目信息 */}
|
||||
<Col span={10}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
已选素材({items.length})— 按顺序轮播
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: 8,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
点击左侧素材添加
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
style={{ maxHeight: 280, overflowY: 'auto', marginBottom: 12 }}
|
||||
dataSource={items}
|
||||
renderItem={(it, index) => {
|
||||
const asset = assetMap[it.asset_id]
|
||||
return (
|
||||
<List.Item
|
||||
key={it.asset_id}
|
||||
actions={[
|
||||
<Button
|
||||
key="up" size="small" icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0} onClick={() => moveItem(index, -1)}
|
||||
/>,
|
||||
<Button
|
||||
key="down" size="small" icon={<ArrowDownOutlined />}
|
||||
disabled={index === items.length - 1} onClick={() => moveItem(index, 1)}
|
||||
/>,
|
||||
<Button
|
||||
key="del" size="small" danger icon={<DeleteOutlined />}
|
||||
onClick={() => setItems(items.filter((_, i) => i !== index))}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space size={4}>
|
||||
<Tag color={asset?.type === 'image' ? 'green' : 'purple'} style={{ marginRight: 0 }}>
|
||||
{asset?.type === 'image' ? '图' : '视'}
|
||||
</Tag>
|
||||
<span style={{ fontSize: 13 }}>{asset?.name || `素材 #${it.asset_id}`}</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Space size={4}>
|
||||
<span style={{ color: '#888', fontSize: 12 }}>时长(秒)</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
value={it.duration}
|
||||
onChange={(v) => {
|
||||
const next = [...items]
|
||||
next[index] = { ...it, duration: v }
|
||||
setItems(next)
|
||||
}}
|
||||
style={{ width: 72 }}
|
||||
/>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="节目名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:午餐套餐轮播" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述(可选)">
|
||||
<Input placeholder="节目用途说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
bindPlaylist,
|
||||
createScreen,
|
||||
deleteScreen,
|
||||
getPlaylists,
|
||||
getScreens,
|
||||
unbindPlaylist,
|
||||
updateScreen,
|
||||
} from '../api/client.js'
|
||||
|
||||
export default function Screens() {
|
||||
const [screens, setScreens] = useState([])
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState(null) // null=新增, 对象=编辑
|
||||
const [bindTarget, setBindTarget] = useState(null) // 绑定弹窗的目标屏幕
|
||||
const [bindForm] = Form.useForm()
|
||||
const [form] = Form.useForm()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setScreens(await getScreens())
|
||||
setPlaylists(await getPlaylists())
|
||||
} catch (e) {
|
||||
msg.error('加载失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [msg])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (screen) => {
|
||||
setEditing(screen)
|
||||
form.setFieldsValue({ name: screen.name, location: screen.location })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateScreen(editing.id, values)
|
||||
} else {
|
||||
await createScreen(values)
|
||||
}
|
||||
msg.success('保存成功')
|
||||
setModalOpen(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id) => {
|
||||
try {
|
||||
await deleteScreen(id)
|
||||
msg.success('已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onBind = async (playlistId) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await bindPlaylist(bindTarget.id, playlistId)
|
||||
msg.success(`已将「${bindTarget.name}」绑定节目`)
|
||||
setBindTarget(null)
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '绑定失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onUnbind = async (id) => {
|
||||
try {
|
||||
await unbindPlaylist(id)
|
||||
msg.success('已解除绑定')
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error('解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '设备 ID', dataIndex: 'device_id', render: (v) => (
|
||||
<Typography.Text code copyable={{ text: v }}>{v}</Typography.Text>
|
||||
) },
|
||||
{ title: '位置', dataIndex: 'location', render: (v) => v || '—' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (s === 'online' ? <Tag color="green">在线</Tag> : <Tag color="red">离线</Tag>),
|
||||
},
|
||||
{
|
||||
title: '最后在线',
|
||||
dataIndex: 'last_seen',
|
||||
width: 170,
|
||||
render: (v) => (v ? new Date(v).toLocaleString() : '从未'),
|
||||
},
|
||||
{
|
||||
title: '绑定节目',
|
||||
dataIndex: 'playlist',
|
||||
render: (p) => (p ? <Tag color="blue">{p.name}</Tag> : <Tag>未绑定</Tag>),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 230,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setBindTarget(row)}>绑定节目</Button>
|
||||
<Button size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.playlist && (
|
||||
<Popconfirm title="确定解除当前绑定?" onConfirm={() => onUnbind(row.id)}>
|
||||
<Button size="small" danger>解绑</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm title="删除该屏幕?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Card
|
||||
title="屏幕管理"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增屏幕</Button>}
|
||||
>
|
||||
<Table rowKey="id" columns={columns} dataSource={screens} loading={loading} pagination={false} />
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑 */}
|
||||
<Modal
|
||||
title={editing ? '编辑屏幕' : '新增屏幕'}
|
||||
open={modalOpen}
|
||||
onOk={onSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="屏幕名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:大厅菜单屏" />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" label="位置描述">
|
||||
<Input placeholder="如:1楼大厅" />
|
||||
</Form.Item>
|
||||
{!editing && (
|
||||
<Form.Item
|
||||
name="device_id"
|
||||
label="设备 ID(推荐填写)"
|
||||
tooltip="自定义标识(如 screen-001)。需与 App 端配置的『屏幕 ID』完全一致,App 才能关联到这块屏幕并播放它的节目。留空则生成 pre- 占位 ID。"
|
||||
>
|
||||
<Input placeholder="如 screen-001(需与 App 端屏幕 ID 一致)" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 绑定节目 */}
|
||||
<Modal
|
||||
title={`绑定节目 - ${bindTarget?.name || ''}`}
|
||||
open={!!bindTarget}
|
||||
onOk={() => bindForm.validateFields().then(({ playlist_id }) => onBind(playlist_id))}
|
||||
onCancel={() => setBindTarget(null)}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<p style={{ color: '#888' }}>
|
||||
当前绑定:{bindTarget?.playlist?.name || '无'}(绑定新节目将替换当前节目)
|
||||
</p>
|
||||
<Form form={bindForm} layout="vertical">
|
||||
<Form.Item name="playlist_id" label="选择节目" rules={[{ required: true, message: '请选择节目' }]}>
|
||||
<Select
|
||||
placeholder="选择要绑定的节目"
|
||||
options={playlists.map((p) => ({ label: p.name, value: p.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:5889',
|
||||
'/media': 'http://127.0.0.1:5889',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user