init: EasyScreen 电子菜单系统

- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管)
- android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报)
- web: React + Vite + antd 管理台(屏幕/素材/节目管理)
- 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
Tatta
2026-08-12 20:57:55 +08:00
commit 4e2775c8c3
58 changed files with 6451 additions and 0 deletions
+12
View File
@@ -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>
+3116
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+31
View File
@@ -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>
)
}
+50
View File
@@ -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}`)
+78
View File
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: #f0f2f5;
}
+17
View File
@@ -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>,
)
+152
View File
@@ -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>
</>
)
}
+53
View File
@@ -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>
)
}
+375
View File
@@ -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>
</>
)
}
+223
View File
@@ -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>
</>
)
}
+13
View File
@@ -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',
},
},
})