merge: 二期前端可视化(分账/追责/确权/授权)
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Tabs, Card, Button, Space, Tag, Table, Descriptions, Statistic, Row, Col,
|
||||
Form, Input, DatePicker, message, Timeline, Typography, Alert, Result,
|
||||
} from 'antd'
|
||||
import { api } from './api.js'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
const yuan = (cent) => '¥' + (cent / 100).toFixed(2)
|
||||
|
||||
const nodeLabel = {
|
||||
cp_submit: 'CP送审', csps_review: 'CSPS审核', ma_issue: '发码签发',
|
||||
transcode: '转码', media_ingest: '媒资入库', cdn_inject: 'CDN注入',
|
||||
}
|
||||
|
||||
// 分账面板
|
||||
function SettlementPanel({ maCode }) {
|
||||
const [sum, setSum] = useState(null)
|
||||
const [st, setSt] = useState(null)
|
||||
async function load() {
|
||||
const s = await api.playbackSummary(maCode)
|
||||
setSum(s.data?.data)
|
||||
const r = await api.settlement(maCode, '2026-06')
|
||||
if (r.ok) setSt(r.data.data)
|
||||
}
|
||||
useEffect(() => { load() }, [maCode])
|
||||
if (!sum) return null
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 12 }}>
|
||||
<Col span={6}><Card size="small"><Statistic title="总播放" value={sum.total_plays} /></Card></Col>
|
||||
<Col span={6}><Card size="small"><Statistic title="完播" value={sum.total_complete} /></Card></Col>
|
||||
<Col span={6}><Card size="small"><Statistic title="总收益" value={yuan(sum.total_revenue_cent || 0)} /></Card></Col>
|
||||
</Row>
|
||||
{st ? (
|
||||
<Card size="small" title="分账结算(依据:链上可信播放数据)">
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="总收益">{yuan(st.total_revenue_cent)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算周期">{st.period}</Descriptions.Item>
|
||||
<Descriptions.Item label="内容方(CP) 60%"><Text strong>{yuan(st.cp_share_cent)}</Text></Descriptions.Item>
|
||||
<Descriptions.Item label="平台/运营商 34%">{yuan(st.platform_share_cent)}</Descriptions.Item>
|
||||
<Descriptions.Item label="运营服务费 6%">{yuan(st.hub_fee_cent)}</Descriptions.Item>
|
||||
<Descriptions.Item label="数据来源"><Tag color="blue">{st.data_source}</Tag></Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : <Alert type="info" message="暂无收益数据,可在运营商工作台回传播放/购买事件" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 追责取证面板
|
||||
function AccountabilityPanel({ maCode }) {
|
||||
const [rep, setRep] = useState(null)
|
||||
useEffect(() => { api.accountability(maCode).then((r) => setRep(r.data?.data)) }, [maCode])
|
||||
if (!rep) return null
|
||||
return (
|
||||
<div>
|
||||
{rep.consistent
|
||||
? <Result status="success" title="审播一致" subTitle={rep.conclusion} style={{ padding: 12 }} />
|
||||
: <Result status="error" title="检出哈希不一致" subTitle={rep.conclusion} style={{ padding: 12 }} />}
|
||||
<Card size="small" title={'全链路存证(基准哈希 ' + (rep.baseline_hash || '-') + ')'}>
|
||||
<Timeline items={(rep.trail || []).map((e) => ({
|
||||
color: rep.first_change && e.node === rep.first_change.node && e.hash_value === rep.first_change.hash_value ? 'red' : 'green',
|
||||
children: (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space><Tag>{nodeLabel[e.node] || e.node}</Tag><Text strong>{e.operator}</Text></Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{e.detail} {e.hash_value ? '· hash=' + e.hash_value : ''}</Text>
|
||||
</Space>
|
||||
),
|
||||
}))} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 确权举证面板
|
||||
function EvidencePanel({ maCode }) {
|
||||
const [ev, setEv] = useState(null)
|
||||
useEffect(() => { api.evidence(maCode).then((r) => setEv(r.data?.data)) }, [maCode])
|
||||
if (!ev) return null
|
||||
return (
|
||||
<Card size="small" title="版权确权证据链">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="作品">{ev.title}</Descriptions.Item>
|
||||
<Descriptions.Item label="MA 码">{ev.ma_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="内容哈希">{ev.content_hash || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="签发方">{ev.issuer}</Descriptions.Item>
|
||||
<Descriptions.Item label="链上锚定">{ev.chain_anchor}</Descriptions.Item>
|
||||
<Descriptions.Item label="最早登记时间">{ev.first_seen_at}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Alert style={{ marginTop: 12 }} type="success" showIcon message="确权声明" description={ev.statement} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 授权管理面板
|
||||
function AuthPanel({ maCode }) {
|
||||
const [form] = Form.useForm()
|
||||
const [checkResult, setCheckResult] = useState(null)
|
||||
async function grant() {
|
||||
const v = await form.validateFields()
|
||||
const regions = v.regions ? v.regions.split(',').map((s) => s.trim()).filter(Boolean) : []
|
||||
const platforms = v.platforms ? v.platforms.split(',').map((s) => s.trim()).filter(Boolean) : []
|
||||
const expiry = v.expiry ? v.expiry.toISOString() : ''
|
||||
const res = await api.authorize(maCode, regions, platforms, expiry)
|
||||
if (res.ok) message.success('授权已登记')
|
||||
else message.error(res.data.message)
|
||||
}
|
||||
async function check() {
|
||||
const v = form.getFieldsValue()
|
||||
const res = await api.authCheck(maCode, v.checkRegion || '', v.checkPlatform || '')
|
||||
setCheckResult(res.data?.data)
|
||||
}
|
||||
return (
|
||||
<Form form={form} layout="vertical" size="small">
|
||||
<Card size="small" title="登记授权范围" style={{ marginBottom: 12 }}>
|
||||
<Form.Item label="授权地域(省码,逗号分隔,空=全国)" name="regions"><Input placeholder="610000,440000" /></Form.Item>
|
||||
<Form.Item label="授权平台(逗号分隔,空=不限)" name="platforms"><Input placeholder="CT-SX-IPTV,CM-SX-IPTV" /></Form.Item>
|
||||
<Form.Item label="到期时间(空=长期)" name="expiry"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||
<Button type="primary" onClick={grant}>登记授权</Button>
|
||||
</Card>
|
||||
<Card size="small" title="授权核验">
|
||||
<Space>
|
||||
<Form.Item name="checkRegion" noStyle><Input placeholder="地域省码" style={{ width: 130 }} /></Form.Item>
|
||||
<Form.Item name="checkPlatform" noStyle><Input placeholder="平台编码" style={{ width: 150 }} /></Form.Item>
|
||||
<Button onClick={check}>核验</Button>
|
||||
</Space>
|
||||
{checkResult && (
|
||||
<Alert style={{ marginTop: 12 }} type={checkResult.allowed ? 'success' : 'error'}
|
||||
message={checkResult.allowed ? '在授权范围内' : '拦截:' + checkResult.reason} />
|
||||
)}
|
||||
</Card>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GovernancePanel({ maCode }) {
|
||||
return (
|
||||
<Tabs size="small" items={[
|
||||
{ key: 'settle', label: '💰 分账', children: <SettlementPanel maCode={maCode} /> },
|
||||
{ key: 'account', label: '⚖️ 追责取证', children: <AccountabilityPanel maCode={maCode} /> },
|
||||
{ key: 'evidence', label: '📜 确权举证', children: <EvidencePanel maCode={maCode} /> },
|
||||
{ key: 'auth', label: '🔑 授权管理', children: <AuthPanel maCode={maCode} /> },
|
||||
]} />
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'antd'
|
||||
import { ReloadOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
|
||||
import { call, api } from './api.js'
|
||||
import GovernancePanel from './GovernancePanel.jsx'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -189,6 +190,16 @@ function OperatorDesk({ tick, 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' },
|
||||
@@ -196,6 +207,7 @@ function OperatorDesk({ tick, onChanged }) {
|
||||
<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>
|
||||
) },
|
||||
]
|
||||
@@ -366,9 +378,11 @@ function LibraryDesk({ tick, onChanged }) {
|
||||
pagination={{ pageSize: 8 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="片库暂无内容" /> }} />
|
||||
|
||||
<Modal open={!!detail} onCancel={() => setDetail(null)} footer={null} width={760}
|
||||
<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>
|
||||
@@ -405,6 +419,9 @@ function LibraryDesk({ tick, onChanged }) {
|
||||
]} />
|
||||
</Card>
|
||||
</>
|
||||
) },
|
||||
{ key: 'gov', label: '权益与治理', children: <GovernancePanel maCode={detail.content.ma_code} /> },
|
||||
]} />
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
|
||||
@@ -65,4 +65,13 @@ export const api = {
|
||||
// 工作队列(多角色工作台)
|
||||
reviews: (role, status) => request(role, 'GET', '/content/reviews?status=' + status),
|
||||
list: (role, status) => request(role, 'GET', '/content/list?status=' + status),
|
||||
// 二期:分账/追责/确权/授权/跨省/追更/回传
|
||||
playback: (platformId, batch) => request('operator', 'POST', '/data/playback', { platform_id: platformId, batch }),
|
||||
playbackSummary: (maCode) => request('regulator', 'GET', '/data/playback-summary?ma_code=' + encodeURIComponent(maCode)),
|
||||
settlement: (maCode, period) => request('regulator', 'POST', '/settlement/compute', { ma_code: maCode, period }),
|
||||
accountability: (maCode) => request('regulator', 'GET', '/content/accountability?ma_code=' + encodeURIComponent(maCode)),
|
||||
evidence: (maCode) => request('regulator', 'GET', '/content/evidence?ma_code=' + encodeURIComponent(maCode)),
|
||||
authorize: (maCode, regions, platforms, expiryAt) => request('regulator', 'POST', '/content/authorize', { ma_code: maCode, regions, platforms, expiry_at: expiryAt }),
|
||||
authCheck: (maCode, region, platform) => request('regulator', 'POST', '/content/auth-check', { ma_code: maCode, region, platform }),
|
||||
crossProvince: (maCode, fileHash, province) => request('regulator', 'POST', '/content/cross-province', { ma_code: maCode, file_sha256: fileHash, province }),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user