Files
MAcode/tcs-iptv/web-console/src/RoleDesk.jsx
T
selfrelease 73e22f79d2 feat(phase2-fe): 二期可视化(分账/追责/确权/授权/回传)
- GovernancePanel: 监管片库详情新增'权益与治理'标签(分账/追责取证/确权举证/授权管理)
- 分账面板: 播放聚合统计+CP60/平台34/服务费6分账展示
- 追责面板: 全链路存证Timeline+审播一致/篡改定位结果
- 确权面板: 证据链+谁先锁定谁有权声明
- 授权面板: 登记授权范围(地域/平台/期限)+核验
- 运营商台: 回传播放(含购买)按钮喂分账数据
- 前端build通过, HMR生效
2026-06-14 17:31:49 +08:00

448 lines
20 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 React, { useState, useEffect } from 'react'
import {
Card, Tabs, Table, Button, Space, Tag, Form, Input, Select, message,
Typography, Empty, Badge, Modal, Descriptions, Segmented,
} from 'antd'
import { ReloadOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
import { call, api } from './api.js'
import GovernancePanel from './GovernancePanel.jsx'
const { Text } = Typography
const statusMeta = {
approved: { label: '待入库', color: 'green' },
in_library: { label: '在库待发布', color: 'cyan' },
published: { label: '流通中', color: 'blue' },
revoked: { label: '已下架', color: 'red' },
}
const catLabel = { WD: '微短剧', WJ: '网络剧', DY: '网络电影', DH: '网络动画' }
// 各角色工作台共享的刷新钩子:通过 bump 触发全局重拉
function useTick() {
const [tick, setTick] = useState(0)
return [tick, () => setTick((t) => t + 1)]
}
// ============ CP 工作台 ============
function CPDesk({ onChanged }) {
const [form] = Form.useForm()
const [submitting, setSubmitting] = useState(false)
async function submit() {
const v = await form.validateFields()
setSubmitting(true)
const fh = 'fh-' + Date.now().toString(36)
const epCount = Number(v.episodes) || 1
const episodes = []
for (let i = 1; i <= epCount; i++) {
episodes.push({ episode: i, file_sha256: `${fh}-E${i}`, merkle_root: `mr-${fh}-E${i}` })
}
const res = await call('cp', 'POST', '/content/register', {
title: v.title, episode_count: epCount, category: v.category,
file_sha256: fh, merkle_root: 'mr-' + fh, perceptual_hash: 'ph-' + fh,
episodes, cp_media_id: v.cpId, cp_name: v.cpName,
})
setSubmitting(false)
if (res.ok) {
message.success(`送审成功,流水号 ${res.data.data.review_id}(原片走审核渠道,哈希上链)`)
onChanged()
} else message.error(res.data.message || '送审失败')
}
return (
<Card title="内容提供商工作台 · 送审申报" size="small">
<Form form={form} layout="inline" initialValues={{
title: '长安少年行', category: 'WD', episodes: 6,
cpId: 'XAQJSL-2026-001', cpName: '西安曲江丝路文化传播有限公司',
}}>
<Form.Item label="作品" name="title" rules={[{ required: true }]}><Input style={{ width: 160 }} /></Form.Item>
<Form.Item label="类目" name="category">
<Select style={{ width: 110 }} options={Object.entries(catLabel).map(([v, l]) => ({ value: v, label: l }))} />
</Form.Item>
<Form.Item label="集数" name="episodes"><Input type="number" style={{ width: 80 }} /></Form.Item>
<Form.Item label="CP" name="cpName"><Input style={{ width: 220 }} /></Form.Item>
<Form.Item name="cpId" hidden><Input /></Form.Item>
<Form.Item>
<Button type="primary" icon={<SendOutlined />} loading={submitting} onClick={submit}>
送审原片送审核 + 哈希上链
</Button>
</Form.Item>
</Form>
<Text type="secondary" style={{ fontSize: 12 }}>
送审后到审核监管工作台"待审队列"领取审核
</Text>
</Card>
)
}
// ============ 审核监管工作台 ============
function ReviewerDesk({ tick, onChanged }) {
const [pending, setPending] = useState([])
const [toIssue, setToIssue] = useState([])
const [toIngest, setToIngest] = useState([])
const [toPublish, setToPublish] = useState([])
async function load() {
const [p, i, ing, pub] = await Promise.all([
call('reviewer', 'GET', '/content/reviews?status=pending'),
call('regulator', 'GET', '/content/reviews?status=approved'),
call('reviewer', 'GET', '/content/list?status=approved'),
call('reviewer', 'GET', '/content/list?status=in_library'),
])
setPending(p.data?.data?.reviews || [])
setToIssue(i.data?.data?.reviews || [])
setToIngest(ing.data?.data?.contents || [])
setToPublish(pub.data?.data?.contents || [])
}
useEffect(() => { load() }, [tick])
async function review(reviewID, approved) {
const res = await call('reviewer', 'POST', '/content/csps-result', { review_id: reviewID, approved, reviewer_id: 'sxiptv-审核01' })
if (res.ok) { message.success(approved ? 'CSPS 审核通过' : '已驳回'); onChanged() }
else message.error(res.data.message)
}
async function issue(reviewID) {
const res = await call('regulator', 'POST', '/content/issue', { review_id: reviewID, issuer: '陕西IPTV运营公司' })
if (res.ok) { message.success(`发码成功:${res.data.data.ma_code}`); onChanged() }
else message.error(res.data.message)
}
async function ingest(r) {
const res = await call('reviewer', 'POST', '/content/ingest', { ma_code: r.ma_code, content_twin_id: r.content_twin_id, media_asset_id: 'SXMEDIA-' + r.ma_code.slice(-4), lib_name: '陕西IPTV媒体资源库' })
if (res.ok) { message.success('已入媒资库'); onChanged() }
else message.error(res.data.message)
}
async function publish(r) {
const cert = `CERT|${r.ma_code}|x|x` // 演示证书;真实证书在发码时返回
const res = await call('reviewer', 'POST', '/content/publish', { ma_code: r.ma_code, certificate: cert })
if (res.ok) { message.success('已发布给运营商'); onChanged() }
else message.error(res.data.message + '(提示:发布需发码时的原始证书,演示可用全流程页)')
}
const reviewCols = [
{ title: '流水号', dataIndex: 'review_id' },
{ title: '作品', dataIndex: 'title' },
{ title: '类目', dataIndex: 'category', render: (c) => <Tag>{catLabel[c] || c}</Tag> },
{ title: 'CP', dataIndex: 'cp_name', ellipsis: true },
{ title: '操作', render: (_, r) => (
<Space>
<Button size="small" type="primary" onClick={() => review(r.review_id, true)}>审核通过</Button>
<Button size="small" danger onClick={() => review(r.review_id, false)}>驳回</Button>
</Space>
) },
]
const issueCols = [
{ title: '流水号', dataIndex: 'review_id' },
{ title: '作品', dataIndex: 'title' },
{ title: '类目', dataIndex: 'category', render: (c) => <Tag color="blue">{catLabel[c] || c}</Tag> },
{ title: '操作', render: (_, r) => <Button size="small" type="primary" onClick={() => issue(r.review_id)}>发码签发</Button> },
]
const contentCols = (action, label, color) => [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code copyable>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{ title: '操作', render: (_, r) => <Button size="small" type="primary" onClick={() => action(r)}>{label}</Button> },
]
const queue = (title, count, table) => (
<Card size="small" title={<Space>{title}<Badge count={count} showZero color={count ? '#1677ff' : '#ccc'} /></Space>} style={{ marginBottom: 12 }}>
{table}
</Card>
)
return (
<div>
<Button icon={<ReloadOutlined />} size="small" onClick={load} style={{ marginBottom: 12 }}>刷新队列</Button>
{queue('① 待审队列(CSPS 合规审核)', pending.length,
<Table rowKey="review_id" size="small" pagination={false} columns={reviewCols} dataSource={pending}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待审" /> }} />)}
{queue('② 待发码队列(审核通过 → 发码)', toIssue.length,
<Table rowKey="review_id" size="small" pagination={false} columns={issueCols} dataSource={toIssue}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待发码" /> }} />)}
{queue('③ 待入库队列(已发码 → 入媒资库)', toIngest.length,
<Table rowKey="ma_code" size="small" pagination={false}
columns={contentCols(ingest, '入媒资库', 'green')} dataSource={toIngest}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待入库" /> }} />)}
{queue('④ 待发布队列(已入库 → 发布运营商)', toPublish.length,
<Table rowKey="ma_code" size="small" pagination={false}
columns={contentCols(publish, '发布', 'orange')} dataSource={toPublish}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待发布" /> }} />)}
</div>
)
}
// ============ 运营商工作台 ============
function OperatorDesk({ tick, onChanged }) {
const [toInject, setToInject] = useState([])
async function load() {
const res = await call('operator', 'GET', '/content/list?status=published')
setToInject(res.data?.data?.contents || [])
}
useEffect(() => { load() }, [tick])
async function inject(r, tamper) {
const fh = tamper ? 'TAMPERED-' + r.file_hash : r.file_hash
const res = await call('operator', 'POST', '/content/inject', {
content_twin_id: r.content_twin_id, ma_code: r.ma_code, file_sha256: fh,
operator_id: 'CT-SX-IPTV', cdn_endpoint: 'cdn://ct-sx/vod/' + r.ma_code.slice(-4),
})
if (res.ok) { message.success(`注入成功 ${res.data.data.distribution_id}`); onChanged() }
else message.warning('注入校验:' + (res.data.message || '哈希不匹配被拒'))
}
async function reportPlay(r) {
// 演示:回传 1 次播放 + 1 次购买(15元)
const res = await api.playback(r.operator_id || 'CT-SX-IPTV', [
{ ma_code: r.ma_code, event_type: 'play' },
{ ma_code: r.ma_code, event_type: 'purchase', revenue_cent: 1500 },
])
if (res.ok) message.success(`已回传播放数据(接收 ${res.data.data.accepted} 条),可在监管片库查看分账`)
else message.error(res.data.message)
}
const cols = [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
{ title: '操作', render: (_, r) => (
<Space>
<Button size="small" type="primary" onClick={() => inject(r, false)}>CDN 注入正确</Button>
<Button size="small" danger onClick={() => inject(r, true)}>模拟篡改注入</Button>
<Button size="small" onClick={() => reportPlay(r)}>回传播放(含购买)</Button>
</Space>
) },
]
return (
<Card size="small" title={<Space>运营商工作台 · 待注入队列<Badge count={toInject.length} showZero /></Space>}
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>}>
<Table rowKey="ma_code" size="small" pagination={false} columns={cols} dataSource={toInject}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待注入(需先在审核台发布)" /> }} />
</Card>
)
}
// ============ 总览看板 ============
function Overview({ tick }) {
const [counts, setCounts] = useState({})
async function load() {
const statuses = ['approved', 'in_library', 'published', 'revoked']
const results = await Promise.all([
call('regulator', 'GET', '/content/reviews?status=pending'),
call('regulator', 'GET', '/content/reviews?status=approved'),
...statuses.map((s) => call('regulator', 'GET', '/content/list?status=' + s)),
])
setCounts({
pending: results[0].data?.data?.reviews?.length || 0,
toIssue: results[1].data?.data?.reviews?.length || 0,
toIngest: results[2].data?.data?.contents?.length || 0,
toPublish: results[3].data?.data?.contents?.length || 0,
published: results[4].data?.data?.contents?.length || 0,
revoked: results[5].data?.data?.contents?.length || 0,
})
}
useEffect(() => { load() }, [tick])
const items = [
{ label: '待审', v: counts.pending, c: '#faad14' },
{ label: '待发码', v: counts.toIssue, c: '#1677ff' },
{ label: '待入库', v: counts.toIngest, c: '#52c41a' },
{ label: '待发布', v: counts.toPublish, c: '#fa8c16' },
{ label: '已发布(流通中)', v: counts.published, c: '#13c2c2' },
{ label: '已下架', v: counts.revoked, c: '#f5222d' },
]
return (
<Card size="small" title="全局看板(各环节在途数量)" extra={<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>}>
<Space size="large" wrap>
{items.map((it) => (
<Card key={it.label} size="small" style={{ width: 130, textAlign: 'center', borderTop: `3px solid ${it.c}` }}>
<div style={{ fontSize: 28, fontWeight: 700, color: it.c }}>{it.v ?? 0}</div>
<div style={{ color: '#888' }}>{it.label}</div>
</Card>
))}
</Space>
</Card>
)
}
// ============ 监管片库 ============
function LibraryDesk({ tick, onChanged }) {
const [filter, setFilter] = useState('all')
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState(null) // {maCode, mappings, episodes}
async function load() {
setLoading(true)
const status = filter === 'all' ? '' : filter
const res = await call('regulator', 'GET', '/content/list?status=' + status)
setRows(res.data?.data?.contents || [])
setLoading(false)
}
useEffect(() => { load() }, [tick, filter])
function confirmTakedown(r) {
Modal.confirm({
title: '违规应急下架',
content: `确认对《${r.title}》(${r.ma_code}) 执行全网下架?将解析三方编码秒级同步。`,
okText: '确认下架', okType: 'danger', cancelText: '取消',
onOk: async () => {
const res = await api.takedown(r.ma_code, '监管片库应急下架')
if (res.ok) {
message.success('已全网下架,受影响 CDN: ' + (res.data.data.cdn_endpoints || []).join(', '))
onChanged()
} else message.error(res.data.message || '下架失败')
},
})
}
async function viewDetail(r) {
const [m, e] = await Promise.all([api.mappings(r.ma_code), api.episodes(r.ma_code)])
setDetail({
content: r,
mappings: m.data?.data?.mappings || [],
cdn: m.data?.data?.cdn_endpoints || [],
episodes: e.data?.data?.episodes || [],
})
}
function takedownEpisode(maCode, episode) {
Modal.confirm({
title: `集级下架 · 第 ${episode}`,
content: `只下架《${maCode}#E${String(episode).padStart(2, '0')}》本集,整剧其他集继续流通。确认?`,
okText: '下架本集', okType: 'danger', cancelText: '取消',
onOk: async () => {
const res = await api.takedownEpisode(maCode, episode, '监管片库集级下架')
if (res.ok) {
message.success(`${episode} 集已下架`)
await viewDetail(detail.content) // 刷新弹窗
onChanged()
} else message.error(res.data.message || '下架失败')
},
})
}
async function doRestore(r) {
const res = await api.restore(r.ma_code)
if (res.ok) { message.success('《' + r.title + '》已恢复上架'); onChanged() }
else message.error(res.data.message || '恢复失败')
}
async function restoreEpisode(maCode, episode) {
const res = await api.restoreEpisode(maCode, episode)
if (res.ok) {
message.success('第 ' + episode + ' 集已恢复上架')
await viewDetail(detail.content)
onChanged()
} else message.error(res.data.message || '恢复失败')
}
const cols = [
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code copyable>{v}</Text> },
{ title: '作品', dataIndex: 'title' },
{ title: '集数', dataIndex: 'episode_count', width: 70 },
{ title: '状态', dataIndex: 'status', width: 110, render: (s) => {
const m = statusMeta[s] || { label: s, color: 'default' }
return <Tag color={m.color}>{m.label}</Tag>
} },
{ title: '操作', width: 240, render: (_, r) => (
<Space>
<Button size="small" onClick={() => viewDetail(r)}>详情</Button>
{r.status === 'revoked' ? (
<Button size="small" type="primary" ghost onClick={() => doRestore(r)}>恢复上架</Button>
) : (
<Button size="small" danger icon={<StopOutlined />} onClick={() => confirmTakedown(r)}>
应急下架
</Button>
)}
</Space>
) },
]
return (
<Card
size="small"
title={<Space>监管片库<Badge count={rows.length} showZero color="#1677ff" /></Space>}
extra={
<Space>
<Segmented value={filter} onChange={setFilter} options={[
{ label: '全部', value: 'all' },
{ label: '流通中', value: 'published' },
{ label: '在库', value: 'in_library' },
{ label: '待入库', value: 'approved' },
{ label: '已下架', value: 'revoked' },
]} />
<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>
</Space>
}
>
<Table rowKey="ma_code" size="small" loading={loading} columns={cols} dataSource={rows}
pagination={{ pageSize: 8 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="片库暂无内容" /> }} />
<Modal open={!!detail} onCancel={() => setDetail(null)} footer={null} width={820}
title={detail ? `片库详情 · ${detail.content.title}` : ''}>
{detail && (
<Tabs size="small" items={[
{ key: 'overview', label: '概览', children: (
<>
<Descriptions bordered size="small" column={1} style={{ marginBottom: 12 }}>
<Descriptions.Item label="MA 码">{detail.content.ma_code}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={(statusMeta[detail.content.status] || {}).color}>
{(statusMeta[detail.content.status] || {}).label || detail.content.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="整剧哈希">{detail.content.file_hash || '-'}</Descriptions.Item>
</Descriptions>
<Card size="small" title="三方编码映射" style={{ marginBottom: 12 }}>
<Table rowKey={(r, i) => i} size="small" pagination={false}
dataSource={detail.mappings}
columns={[
{ title: '角色', dataIndex: 'party' },
{ title: '编码', dataIndex: 'party_id' },
{ title: '名称', dataIndex: 'party_name', render: (v) => v || '-' },
{ title: 'CDN', dataIndex: 'cdn_endpoint', render: (v) => v || '-' },
]} />
</Card>
<Card size="small" title={`集级哈希(${detail.episodes.length} 集)· 可单集下架`}>
<Table rowKey="episode" size="small" pagination={false}
dataSource={detail.episodes}
columns={[
{ title: '集', dataIndex: 'episode', width: 50 },
{ title: '子标识', render: (_, r) => <Text code>{`${detail.content.ma_code}#E${String(r.episode).padStart(2, '0')}`}</Text> },
{ title: '哈希', dataIndex: 'hash_value', ellipsis: true },
{ title: '状态', width: 80, render: (_, r) => r.revoked ? <Tag color="red">已下架</Tag> : <Tag color="green">流通中</Tag> },
{ title: '操作', width: 110, render: (_, r) => (
r.revoked
? <Button size="small" type="primary" ghost onClick={() => restoreEpisode(detail.content.ma_code, r.episode)}>恢复本集</Button>
: <Button size="small" danger onClick={() => takedownEpisode(detail.content.ma_code, r.episode)}>下架本集</Button>
) },
]} />
</Card>
</>
) },
{ key: 'gov', label: '权益与治理', children: <GovernancePanel maCode={detail.content.ma_code} /> },
]} />
)}
</Modal>
</Card>
)
}
export default function RoleDesk() {
const [tick, bump] = useTick()
return (
<div>
<div style={{ marginBottom: 16 }}><Overview tick={tick} /></div>
<Tabs
type="card"
items={[
{ key: 'cp', label: '🟢 内容提供商', children: <CPDesk onChanged={bump} /> },
{ key: 'reviewer', label: '🔵 审核监管(CSPS/媒资)', children: <ReviewerDesk tick={tick} onChanged={bump} /> },
{ key: 'operator', label: '🟠 运营商', children: <OperatorDesk tick={tick} onChanged={bump} /> },
{ key: 'library', label: '🔴 监管片库(下架处置)', children: <LibraryDesk tick={tick} onChanged={bump} /> },
]}
/>
</div>
)
}