feat(web): 删除冗余「监管大屏」tab,新增「大小屏融合」tab

- 监管大屏功能(查映射/验真/下架)已被角色工作台·监管片库覆盖,移除该tab及RegulatorConsole
- 新增 ScreenFusion.jsx「大小屏融合」tab:四期能力可视化
  - 跨域解析网关(C.1/C.2):六段式+集级子标识解析、流通状态、三屏可用
  - 扫码验真(B.2):真伪/合规结果卡片,防盗版
  - 跨屏权益通兑(D.1):一屏购买→换屏核验通看不重复付费
- api.js: 新增 resolve/scanVerify/purchase/verifyRights
- seed_demo.sh: 更新查看入口提示
- 前端 build 通过
This commit is contained in:
selfrelease
2026-06-14 19:42:29 +08:00
parent 9db0a8a4d4
commit a287c52000
4 changed files with 235 additions and 128 deletions
+2 -1
View File
@@ -79,4 +79,5 @@ echo ""
echo "=== 已生成 MA 码(可复制到监管大屏查询)==="
cat /tmp/tcs_demo_macodes.txt
echo ""
echo "提示:在 http://localhost:5174 输入上述任一 MA 码查询全链路三方映射"
echo "提示:在 http://localhost:5174「角色工作台 → 监管片库」点详情查看全链路三方映射与集级哈希;"
echo " 或在「大小屏融合」tab 用上述 MA 码体验跨域解析 / 扫码验真 / 跨屏权益。"
+4 -127
View File
@@ -1,135 +1,12 @@
import React, { useState } from 'react'
import {
Layout, Typography, Card, Input, Button, Space, Table, Tag,
message, Modal, Descriptions, Alert, Row, Col, Statistic, Tabs,
} from 'antd'
import { SearchOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { api } from './api.js'
import React from 'react'
import { Layout, Typography, Alert, Tabs } from 'antd'
import FlowDemo from './FlowDemo.jsx'
import RoleDesk from './RoleDesk.jsx'
import ScreenFusion from './ScreenFusion.jsx'
const { Header, Content } = Layout
const { Title, Text } = Typography
const partyLabel = { cp: '内容提供商', reviewer: '审核和监管部门', operator: '运营商' }
const partyColor = { cp: 'green', reviewer: 'blue', operator: 'orange' }
function RegulatorConsole() {
const [maCode, setMaCode] = useState('')
const [loading, setLoading] = useState(false)
const [mappings, setMappings] = useState(null)
const [cdnEndpoints, setCdnEndpoints] = useState([])
const [verifyHash, setVerifyHash] = useState('')
const [verifyResult, setVerifyResult] = useState(null)
async function doQuery() {
if (!maCode) return message.warning('请输入 MA 码')
setLoading(true)
try {
const { status, data } = await api.mappings(maCode)
if (status === 200) {
setMappings(data.data.mappings || [])
setCdnEndpoints(data.data.cdn_endpoints || [])
message.success('查询成功')
} else {
setMappings(null); setCdnEndpoints([])
message.error(data.message || '查询失败')
}
} catch (e) { message.error('请求失败:' + e.message) }
setLoading(false)
}
async function doVerify() {
if (!maCode || !verifyHash) return message.warning('请输入 MA 码与文件哈希')
try {
const { status, data } = await api.verify(maCode, verifyHash)
setVerifyResult({ ok: status === 200, ...data })
} catch (e) { message.error('请求失败:' + e.message) }
}
function confirmTakedown() {
if (!maCode) return message.warning('请先输入 MA 码')
Modal.confirm({
title: '违规应急下架',
content: `确认对 ${maCode} 执行全网下架?该操作将解析三方编码并秒级同步。`,
okText: '确认下架', okType: 'danger', cancelText: '取消',
onOk: async () => {
const { status, data } = await api.takedown(maCode, '监管大屏手动下架')
if (status === 200) {
message.success('已下架,受影响 CDN: ' + (data.data.cdn_endpoints || []).join(', '))
doQuery()
} else {
message.error(data.message || '下架失败')
}
},
})
}
const columns = [
{ title: '角色', dataIndex: 'party', render: (p) => <Tag color={partyColor[p]}>{partyLabel[p] || p}</Tag> },
{ title: '本方编码', dataIndex: 'party_id' },
{ title: '名称', dataIndex: 'party_name', render: (v) => v || '-' },
{ title: 'CDN 端点', dataIndex: 'cdn_endpoint', render: (v) => v || '-' },
]
return (
<>
<Card title="按 MA 码查询全链路" style={{ marginBottom: 16 }}>
<Space.Compact style={{ width: '100%', maxWidth: 720 }}>
<Input
placeholder="如 MA.156.8531.6101/WD/20260000001"
value={maCode} onChange={(e) => setMaCode(e.target.value)}
onPressEnter={doQuery}
/>
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={doQuery}>
查询
</Button>
<Button danger icon={<StopOutlined />} onClick={confirmTakedown}>
应急下架
</Button>
</Space.Compact>
</Card>
{mappings && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}><Card><Statistic title="三方映射数" value={mappings.length} /></Card></Col>
<Col span={6}><Card><Statistic title="CDN 端点数" value={cdnEndpoints.length} /></Card></Col>
</Row>
)}
{mappings && (
<Card title="三方编码映射" style={{ marginBottom: 16 }}>
<Table rowKey={(r, i) => i} columns={columns} dataSource={mappings} pagination={false} size="middle" />
</Card>
)}
<Card title="哈希验真">
<Space direction="vertical" style={{ width: '100%', maxWidth: 720 }}>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="待校验文件哈希(file_sha256"
value={verifyHash} onChange={(e) => setVerifyHash(e.target.value)}
/>
<Button icon={<CheckCircleOutlined />} onClick={doVerify}>验真</Button>
</Space.Compact>
{verifyResult && (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="结果">
{verifyResult.ok && verifyResult.data?.match
? <Tag color="green">匹配正版过审内容</Tag>
: <Tag color="red">不匹配疑似版本替换</Tag>}
</Descriptions.Item>
<Descriptions.Item label="绑定哈希">{verifyResult.data?.bound_hash || '-'}</Descriptions.Item>
<Descriptions.Item label="提交哈希">{verifyResult.data?.submitted_hash || verifyHash}</Descriptions.Item>
<Descriptions.Item label="消息">{verifyResult.message || '-'}</Descriptions.Item>
</Descriptions>
)}
</Space>
</Card>
</>
)
}
export default function App() {
return (
<Layout style={{ minHeight: '100vh' }}>
@@ -149,7 +26,7 @@ export default function App() {
items={[
{ key: 'desk', label: '角色工作台(多方协作)', children: <RoleDesk /> },
{ key: 'flow', label: '全流程演示(一键)', children: <FlowDemo /> },
{ key: 'console', label: '监管大屏', children: <RegulatorConsole /> },
{ key: 'fusion', label: '大小屏融合(OTT/手机)', children: <ScreenFusion /> },
]}
/>
</Content>
+224
View File
@@ -0,0 +1,224 @@
import React, { useState } from 'react'
import {
Card, Input, Button, Space, Tag, message, Descriptions, Row, Col,
Segmented, Typography, Result, Divider,
} from 'antd'
import {
ScanOutlined, GlobalOutlined, MobileOutlined, DesktopOutlined,
ShoppingOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'
import { api } from './api.js'
const { Text, Paragraph } = Typography
const screenMeta = {
iptv: { label: 'IPTV 大屏', color: 'blue', icon: <DesktopOutlined /> },
ott: { label: 'OTT/智能电视', color: 'geekblue', icon: <DesktopOutlined /> },
app: { label: '手机 APP', color: 'green', icon: <MobileOutlined /> },
}
function ScreenTags({ screens }) {
if (!screens || screens.length === 0) return <Tag>暂不可用</Tag>
return (
<Space>
{screens.map((s) => (
<Tag key={s} color={screenMeta[s]?.color} icon={screenMeta[s]?.icon}>
{screenMeta[s]?.label || s}
</Tag>
))}
</Space>
)
}
// ============ 跨域解析网关(C.1/C.2============
function ResolvePanel() {
const [maCode, setMaCode] = useState('')
const [res, setRes] = useState(null)
const [loading, setLoading] = useState(false)
async function doResolve() {
if (!maCode) return message.warning('请输入 MA 码(支持集级子标识 #E03)')
setLoading(true)
const r = await api.resolve(maCode.trim())
setLoading(false)
if (r.ok) setRes(r.data.data)
else { setRes(null); message.error(r.data.message || '解析失败') }
}
const p = res?.parsed
return (
<Card size="small" title={<Space><GlobalOutlined />MA 跨域解析网关 · 同一码三屏统一解析</Space>}>
<Space.Compact style={{ width: '100%', maxWidth: 640 }}>
<Input placeholder="如 MA.156.8531.6101/WD/20260000021 或 ...#E03"
value={maCode} onChange={(e) => setMaCode(e.target.value)} onPressEnter={doResolve} />
<Button type="primary" loading={loading} onClick={doResolve}>解析</Button>
</Space.Compact>
{res && (
<div style={{ marginTop: 16 }}>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="解析结果">
{res.resolved ? <Tag color="green">解析成功</Tag> : <Tag color="red">未解析/未登记</Tag>}
</Descriptions.Item>
<Descriptions.Item label="流通状态">
{res.in_circulation ? <Tag color="blue">流通中</Tag> : <Tag color="orange">{res.status || '不可用'}</Tag>}
</Descriptions.Item>
<Descriptions.Item label="作品">{res.title || '-'}</Descriptions.Item>
<Descriptions.Item label="发证主体">{res.issuer || '-'}</Descriptions.Item>
<Descriptions.Item label="跨屏可用" span={2}><ScreenTags screens={res.screens} /></Descriptions.Item>
<Descriptions.Item label="结构解析" span={2}>
{p?.valid ? (
<Space wrap>
<Tag>国家码 {p.country_code}</Tag>
<Tag>行业 {p.industry_node}</Tag>
<Tag>机构 {p.org_node}</Tag>
<Tag color="purple">类目 {p.category}</Tag>
<Tag>{p.year} </Tag>
<Tag>序列 {p.sequence}</Tag>
{p.episode > 0 && <Tag color="magenta"> {p.episode} </Tag>}
</Space>
) : <Tag color="red">结构非法</Tag>}
</Descriptions.Item>
<Descriptions.Item label="说明" span={2}><Text type="secondary">{res.message}</Text></Descriptions.Item>
</Descriptions>
</div>
)}
</Card>
)
}
// ============ 扫码验真(B.2============
function ScanVerifyPanel() {
const [maCode, setMaCode] = useState('')
const [res, setRes] = useState(null)
const [loading, setLoading] = useState(false)
async function doScan() {
if (!maCode) return message.warning('请输入/扫描 MA 码')
setLoading(true)
const r = await api.scanVerify(maCode.trim())
setLoading(false)
if (r.ok) setRes(r.data.data)
else { setRes(null); message.error(r.data.message || '验真失败') }
}
let status = 'info', title = '请扫码验真'
if (res) {
if (res.authentic && res.compliant) { status = 'success'; title = '正版内容 · 合规流通' }
else if (res.authentic && !res.compliant) { status = 'warning'; title = '真码 · 但不合规(已下架/未流通)' }
else { status = 'error'; title = '验真失败 · 疑似盗版/伪造' }
}
return (
<Card size="small" title={<Space><ScanOutlined />用户扫码验真 · 防盗版</Space>}>
<Space.Compact style={{ width: '100%', maxWidth: 640 }}>
<Input placeholder="模拟扫码:粘贴 MA 码" value={maCode}
onChange={(e) => setMaCode(e.target.value)} onPressEnter={doScan} />
<Button type="primary" icon={<ScanOutlined />} loading={loading} onClick={doScan}>扫码验真</Button>
</Space.Compact>
{res && (
<Result style={{ paddingTop: 16, paddingBottom: 8 }}
status={status} title={title}
subTitle={
<Space direction="vertical">
<Space>
<Tag color={res.authentic ? 'green' : 'red'} icon={<SafetyCertificateOutlined />}>
{res.authentic ? '真码' : '假码/未登记'}
</Tag>
<Tag color={res.compliant ? 'blue' : 'orange'}>{res.compliant ? '合规流通' : '不合规'}</Tag>
{res.title && <Text>{res.title}</Text>}
</Space>
<ScreenTags screens={res.screens} />
<Text type="secondary">{res.message}</Text>
</Space>
} />
)}
</Card>
)
}
// ============ 跨屏权益通兑(D.1============
function RightsPanel() {
const [maCode, setMaCode] = useState('')
const [userHash, setUserHash] = useState('user-demo-001')
const [buyScreen, setBuyScreen] = useState('iptv')
const [verifyScreen, setVerifyScreen] = useState('app')
const [buyRes, setBuyRes] = useState(null)
const [verifyRes, setVerifyRes] = useState(null)
async function doBuy() {
if (!maCode) return message.warning('请输入 MA 码')
const r = await api.purchase(maCode.trim(), userHash, buyScreen)
if (r.ok) { setBuyRes(r.data.data); message.success(`已在「${screenMeta[buyScreen].label}」购买`) }
else message.error(r.data.message || '购买失败')
}
async function doVerify() {
if (!maCode) return message.warning('请输入 MA 码')
const r = await api.verifyRights(maCode.trim(), userHash, verifyScreen)
if (r.ok) setVerifyRes(r.data.data)
else message.error(r.data.message || '核验失败')
}
const opts = Object.entries(screenMeta).map(([v, m]) => ({ label: m.label, value: v }))
return (
<Card size="small" title={<Space><ShoppingOutlined />跨屏权益通兑 · 一次购买全屏通看</Space>}>
<Space direction="vertical" style={{ width: '100%' }}>
<Space wrap>
<Input addonBefore="MA 码" style={{ width: 380 }} value={maCode}
onChange={(e) => setMaCode(e.target.value)} placeholder="已发布的 MA 码" />
<Input addonBefore="用户" style={{ width: 220 }} value={userHash}
onChange={(e) => setUserHash(e.target.value)} />
</Space>
<Row gutter={16}>
<Col span={12}>
<Card size="small" title="① 购买(任一屏)" type="inner">
<Space direction="vertical" style={{ width: '100%' }}>
<Segmented value={buyScreen} onChange={setBuyScreen} options={opts} />
<Button type="primary" icon={<ShoppingOutlined />} onClick={doBuy}>购买</Button>
{buyRes && (
<Text type="success">
已购买{screenMeta[buyRes.screen]?.label}{new Date(buyRes.purchased_at).toLocaleString()}
</Text>
)}
</Space>
</Card>
</Col>
<Col span={12}>
<Card size="small" title="② 换一屏核验权益" type="inner">
<Space direction="vertical" style={{ width: '100%' }}>
<Segmented value={verifyScreen} onChange={setVerifyScreen} options={opts} />
<Button icon={<SafetyCertificateOutlined />} onClick={doVerify}>核验权益</Button>
{verifyRes && (
verifyRes.entitled
? <Tag color="green" style={{ whiteSpace: 'normal' }}> 有权益通兑{verifyRes.message}</Tag>
: <Tag color="red" style={{ whiteSpace: 'normal' }}> 无权益{verifyRes.message}</Tag>
)}
</Space>
</Card>
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12 }}>
演示IPTV 大屏购买后切到手机 APP核验应通兑通看且不重复付费权益归一到整剧 MA
</Text>
</Space>
</Card>
)
}
export default function ScreenFusion() {
return (
<div>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
四期·大小屏融合同一 MA 码贯通 IPTV / OTT / 手机 APP统一解析扫码验真一次购买全屏通看
</Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<ResolvePanel />
<ScanVerifyPanel />
<RightsPanel />
</Space>
</div>
)
}
+5
View File
@@ -74,4 +74,9 @@ export const api = {
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 }),
// 四期:大小屏融合(跨域解析/扫码验真/跨屏权益)
resolve: (maCode) => request('regulator', 'GET', '/content/resolve?ma_code=' + encodeURIComponent(maCode)),
scanVerify: (maCode) => request('operator', 'POST', '/content/scan-verify', { ma_code: maCode }),
purchase: (maCode, userHash, screen) => request('operator', 'POST', '/rights/purchase', { ma_code: maCode, user_hash: userHash, screen }),
verifyRights: (maCode, userHash, screen) => request('operator', 'POST', '/rights/verify', { ma_code: maCode, user_hash: userHash, screen }),
}