This commit is contained in:
eric
2026-03-25 09:40:29 -05:00
parent 23a8996114
commit 5996df8702
72 changed files with 1347 additions and 18322 deletions

View File

@@ -13,6 +13,7 @@
--bg-card: #22262e;
--bg-card-elevated: #282c36;
--bg-card-muted: #2a2f3a;
--bg-elevated: #2a2f38;
--bg-input: #1e222a;
--bg-log-0: #1e2128;
--bg-log-1: #14161c;
@@ -61,6 +62,7 @@
--bg-card: #ffffff;
--bg-card-elevated: #fafbfd;
--bg-card-muted: #f0f4fa;
--bg-elevated: #e8eef6;
--bg-input: #ffffff;
--bg-log-0: #f8fafc;
--bg-log-1: #eef2f7;
@@ -237,9 +239,20 @@ body.ready {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #3d7eff 0%, #5b9dff 40%, #7c5cff 100%);
box-shadow: 0 4px 16px rgba(91, 157, 255, 0.35);
flex-shrink: 0;
color: #fff;
}
.app-sidebar__logo-icon {
width: 22px;
height: 22px;
display: block;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.app-sidebar__app-name {
@@ -569,7 +582,7 @@ body.ready {
background-color: var(--bg-input);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238b929e' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"),
linear-gradient(180deg, #2a2f38 0%, #22262e 100%);
linear-gradient(180deg, var(--bg-input) 0%, var(--bg-card-muted) 100%);
background-repeat: no-repeat, no-repeat;
background-position: right 12px center, 0 0;
background-size: 16px 16px, 100% 100%;
@@ -590,6 +603,12 @@ body.ready {
box-shadow: var(--shadow-inset-top), 0 0 0 3px var(--accent-soft);
}
:root[data-theme="light"] .select-process {
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"),
linear-gradient(180deg, var(--bg-input) 0%, var(--bg-card-muted) 100%);
}
.select-process:disabled {
opacity: 0.55;
cursor: not-allowed;
@@ -765,6 +784,18 @@ body.ready {
.status-pill[data-variant='online'] .status-pill__dot {
animation: none;
}
.card--workspace {
animation: none;
}
.ws-card {
transition: none;
}
.ws-card:hover {
transform: none;
}
}
/* 空状态 */
@@ -928,6 +959,16 @@ body.ready {
0 0 0 3px var(--accent-soft);
}
:root[data-theme="light"] .input-area {
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06);
}
:root[data-theme="light"] .input-area:focus {
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.06),
0 0 0 3px var(--accent-soft);
}
.input-area--autosize {
min-height: 72px;
field-sizing: content;
@@ -967,6 +1008,17 @@ body.ready {
border-color: rgba(255, 255, 255, 0.18);
}
:root[data-theme="light"] .btn-secondary {
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
border-color: var(--border-strong);
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
}
:root[data-theme="light"] .btn-secondary:hover {
background: linear-gradient(180deg, #f8fafc 0%, #e8eef6 100%);
border-color: rgba(15, 23, 42, 0.18);
}
.btn-primary {
background: linear-gradient(180deg, #4a8eef 0%, #3d7eff 45%, #2f6fd6 100%);
color: #fff;
@@ -988,6 +1040,30 @@ body.ready {
transform: translateY(1px);
}
.btn-danger {
background: linear-gradient(180deg, #c44 0%, #a33 100%);
color: #fff;
border-color: rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(180deg, #d85555 0%, #b83838 100%);
border-color: rgba(255, 255, 255, 0.12);
}
:root[data-theme="light"] .btn-danger {
background: linear-gradient(180deg, #fee2e2 0%, #fecaca 100%);
color: #991b1b;
border-color: rgba(220, 38, 38, 0.35);
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
}
:root[data-theme="light"] .btn-danger:hover:not(:disabled) {
background: linear-gradient(180deg, #fecaca 0%, #fca5a5 100%);
border-color: rgba(220, 38, 38, 0.45);
}
.btn.loading {
opacity: 0.72;
pointer-events: none;
@@ -1325,6 +1401,14 @@ body.ready {
background: rgba(91, 157, 255, 0.06);
}
.pro-channel-new-draft-row {
padding: 8px 10px;
margin: 0 -10px;
border-radius: var(--radius-md);
border: 1px dashed rgba(91, 157, 255, 0.35);
background: var(--bg-card-muted);
}
.pro-channel-card-header {
display: flex;
flex-wrap: wrap;
@@ -1762,8 +1846,14 @@ a.live-log-detail__link {
.net-badge--pending {
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
background: var(--warn-soft);
border: 1px solid rgba(246, 173, 85, 0.35);
}
:root[data-theme="light"] .net-badge--pending {
background: rgba(251, 191, 36, 0.18);
border-color: rgba(217, 119, 6, 0.35);
color: #92400e;
}
.net-badge--ok {
@@ -1965,6 +2055,18 @@ button.net-badge {
.card--workspace {
border-color: rgba(110, 168, 254, 0.25);
background: linear-gradient(165deg, rgba(110, 168, 254, 0.06) 0%, var(--bg-card) 100%);
animation: cardWorkspaceIn 0.55s var(--ease) both;
}
@keyframes cardWorkspaceIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ws-hero {
@@ -1975,6 +2077,19 @@ button.net-badge {
min-height: 176px;
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-sm);
transition:
box-shadow 0.45s var(--ease),
border-color 0.35s var(--ease);
}
.ws-hero:hover {
border-color: rgba(91, 157, 255, 0.22);
box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 157, 255, 0.06);
}
:root[data-theme="light"] .ws-hero:hover {
border-color: rgba(37, 99, 235, 0.2);
box-shadow: var(--shadow-md), 0 0 0 1px rgba(37, 99, 235, 0.06);
}
.ws-desk-backdrop {
@@ -2012,39 +2127,6 @@ button.net-badge {
opacity: 0.7;
}
.ws-desk-backdrop__legend {
position: absolute;
left: 10px;
bottom: 8px;
z-index: 4;
max-width: min(520px, 94%);
font-size: 10px;
line-height: 1.5;
color: var(--text-muted);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65);
pointer-events: none;
}
.ws-desk-backdrop__legend strong {
display: block;
color: var(--text);
font-size: 11px;
margin-bottom: 4px;
}
.ws-desk-backdrop__legend span {
display: block;
}
.ws-desk-backdrop__legend-sub {
margin-top: 3px;
opacity: 0.95;
}
:root[data-theme="light"] .ws-desk-backdrop__legend {
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9);
}
.ws-head {
display: flex;
flex-wrap: wrap;
@@ -2073,6 +2155,10 @@ button.net-badge {
color: #c8cdd5;
}
:root[data-theme="light"] .ws-kv-list {
color: var(--text-on-dark);
}
.ws-kv-list li {
margin: 0.2rem 0;
display: flex;
@@ -2246,6 +2332,27 @@ button.net-badge {
border-radius: 10px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
transition:
transform 0.28s var(--ease),
box-shadow 0.35s var(--ease),
border-color 0.28s var(--ease),
background 0.35s var(--ease);
}
.ws-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22);
border-color: rgba(91, 157, 255, 0.18);
}
:root[data-theme="light"] .ws-card {
background: var(--bg-card-muted);
border-color: var(--border-card);
}
:root[data-theme="light"] .ws-card:hover {
box-shadow: var(--shadow-md);
border-color: rgba(37, 99, 235, 0.16);
}
.ws-card__title {
@@ -2255,6 +2362,10 @@ button.net-badge {
color: #e8eaed;
}
:root[data-theme="light"] .ws-card__title {
color: var(--text);
}
.ws-card__meta {
margin: 0 0 0.5rem;
font-size: 11px;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { apiFetch, apiUrl } from './api'
import NetworkDashboard from './NetworkDashboard'
import RemoteBinding from './RemoteBinding'
@@ -67,6 +67,18 @@ function validateStreamKeysUnique(keys: string[]): string | null {
return null
}
/** 仅含空行与 # 注释时视为空,避免默认模板占满输入框 */
function douyinUrlListDisplayFromServer(content: string): string {
const lines = String(content ?? '').split(/\r?\n/)
const hasReal = lines.some((line) => {
const t = line.trim()
if (!t) return false
if (t.startsWith('#')) return false
return true
})
return hasReal ? String(content) : ''
}
type DouyinPreviewRow = {
ok?: boolean
url?: string
@@ -148,6 +160,19 @@ function ThemeIconMoon() {
)
}
/** 侧栏品牌区:直播摄像机 + 红点LIVE 语义) */
function SidebarBrandLogoIcon() {
return (
<svg className="app-sidebar__logo-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"
/>
<circle cx="18" cy="6" r="2.8" fill="#ff5a5a" />
</svg>
)
}
function IconForbidden({ className }: { className?: string }) {
return (
<svg
@@ -199,7 +224,9 @@ function SidebarBrand({
}) {
return (
<div className="app-sidebar__brand">
<div className="app-sidebar__logo" aria-hidden />
<div className="app-sidebar__logo" aria-hidden>
<SidebarBrandLogoIcon />
</div>
<div className="app-sidebar__brand-main">
<div className="app-sidebar__brand-title-row">
<span className="app-sidebar__app-name"></span>
@@ -307,10 +334,13 @@ export default function App() {
const [saveProKeyLoadingId, setSaveProKeyLoadingId] = useState<string | null>(null)
const [saveUrlLoadingId, setSaveUrlLoadingId] = useState<string | null>(null)
const [highlightedChannelId, setHighlightedChannelId] = useState('')
const [highlightedKeyIndex, setHighlightedKeyIndex] = useState(0)
const highlightedChannelIdRef = useRef('')
/** 已保存到服务端的密钥列表(决定直播开关行数);本地草稿多出的行不会出现在开关区 */
const [committedChannelKeys, setCommittedChannelKeys] = useState<Record<string, string[]>>({})
const [addChannelBusy, setAddChannelBusy] = useState(false)
/** 点击「新增频道」后出现的草稿行:填写密钥后点保存再真正创建频道 */
const [newChannelDraft, setNewChannelDraft] = useState<{ keyText: string } | null>(null)
const [deleteChannelBusyId, setDeleteChannelBusyId] = useState<string | null>(null)
const [gpuCaps, setGpuCaps] = useState<GpuCaps | null>(null)
const [douyinPreviews, setDouyinPreviews] = useState<DouyinPreviewRow[]>([])
@@ -551,18 +581,15 @@ export default function App() {
}, [currentProcess, loadYoutubeIniModel, multiChannelPro])
const saveProChannelKeysFor = useCallback(
async (channelId: string, activeIndexOverride?: number): Promise<boolean> => {
async (channelId: string): Promise<boolean> => {
const ed = proChannelEditors[channelId]
if (!ed) return false
const dupMsg = validateStreamKeysUnique(ed.keys)
const keysOne = [(ed.keys[0] ?? '').trim()]
const dupMsg = validateStreamKeysUnique(keysOne)
if (dupMsg) {
setYoutubeStatus(dupMsg)
return false
}
const ai =
typeof activeIndexOverride === 'number'
? Math.max(0, Math.min(activeIndexOverride, Math.max(0, ed.keys.length - 1)))
: ed.activeIndex
setSaveProKeyLoadingId(channelId)
try {
const res = await apiFetch(apiUrl('/relay_pro/channel_keys'), {
@@ -570,8 +597,8 @@ export default function App() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId,
keys: ed.keys,
activeIndex: ai,
keys: keysOne,
activeIndex: 0,
}),
})
const data = (await res.json()) as { error?: string; message?: string }
@@ -579,9 +606,9 @@ export default function App() {
setProChannelEditors((prev) => {
const cur = prev[channelId]
if (!cur) return prev
return { ...prev, [channelId]: { ...cur, activeIndex: ai } }
return { ...prev, [channelId]: { ...cur, keys: keysOne, activeIndex: 0 } }
})
const cleaned = ed.keys.map((s) => String(s).trim()).filter(Boolean)
const cleaned = keysOne.map((s) => String(s).trim()).filter(Boolean)
setCommittedChannelKeys((prev) => ({ ...prev, [channelId]: cleaned }))
setYoutubeStatus(data.message || '密钥已保存')
void window.relayConfig?.importFromConfig?.()
@@ -763,6 +790,10 @@ export default function App() {
}
}, [multiChannelPro])
useLayoutEffect(() => {
highlightedChannelIdRef.current = highlightedChannelId
}, [highlightedChannelId])
useEffect(() => {
logUserAction('navigate', nav)
}, [nav])
@@ -840,74 +871,121 @@ export default function App() {
}, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro, loadYoutubeIniModel])
const reloadProChannelsList = useCallback(async () => {
if (!isYoutube || !multiChannelPro) return
if (!isYoutube || !multiChannelPro) return []
try {
const r = await apiFetch(apiUrl('/relay_pro/channels'))
const d = (await r.json()) as { ok?: boolean; error?: string; channels?: typeof proChannelsList }
if (!d.ok) {
setProChannelsError(d.error || '无法加载 Pro 频道')
setProChannelsList([])
return
return []
}
setProChannelsError(null)
setProChannelsList(d.channels || [])
const list = d.channels || []
setProChannelsList(list)
return list
} catch (e) {
setProChannelsError(e instanceof Error ? e.message : String(e))
setProChannelsList([])
return []
}
}, [isYoutube, multiChannelPro])
const addProChannel = useCallback(async () => {
setAddChannelBusy(true)
try {
const res = await apiFetch(apiUrl('/relay_pro/channel_add'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `频道 ${proChannelsList.length + 1}`,
youtubeKey: '',
}),
})
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
setYoutubeStatus(d.message || '已新增频道')
await reloadProChannelsList()
void window.relayConfig?.importFromConfig?.()
} catch (e) {
setYoutubeStatus(`新增失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
setAddChannelBusy(false)
}
}, [proChannelsList.length, reloadProChannelsList])
useEffect(() => {
if (!isYoutube || !multiChannelPro) return
void reloadProChannelsList()
}, [isYoutube, multiChannelPro, reloadProChannelsList])
const submitNewChannelDraft = useCallback(async () => {
if (!newChannelDraft) return
setAddChannelBusy(true)
try {
const key = newChannelDraft.keyText.trim()
const res = await apiFetch(apiUrl('/relay_pro/channel_add'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `频道 ${proChannelsList.length + 1}`,
youtubeKey: key,
}),
})
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
setYoutubeStatus(d.message || '已新增频道')
setNewChannelDraft(null)
await reloadProChannelsList()
void window.relayConfig?.importFromConfig?.()
const nid = (d.channelId || '').trim()
if (nid) {
highlightedChannelIdRef.current = nid
setHighlightedChannelId(nid)
}
} catch (e) {
setYoutubeStatus(`新增失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
setAddChannelBusy(false)
}
}, [newChannelDraft, proChannelsList.length, reloadProChannelsList])
const deleteProChannel = useCallback(
async (channelId: string) => {
if (proChannelsList.length <= 1) {
setYoutubeStatus('至少保留一条频道')
return
}
if (!window.confirm('确定删除该频道?将移除配置与独立地址文件,且不可恢复。')) return
setNewChannelDraft(null)
setDeleteChannelBusyId(channelId)
try {
const res = await apiFetch(apiUrl('/relay_pro/channel_delete'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId }),
})
const d = (await res.json()) as { error?: string; message?: string }
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
setYoutubeStatus(d.message || '已删除频道')
setProChannelEditors((prev) => {
const n = { ...prev }
delete n[channelId]
return n
})
setCommittedChannelKeys((prev) => {
const n = { ...prev }
delete n[channelId]
return n
})
const nextList = await reloadProChannelsList()
void window.relayConfig?.importFromConfig?.()
if (highlightedChannelIdRef.current === channelId && nextList && nextList.length > 0) {
const nid = nextList[0].id
highlightedChannelIdRef.current = nid
setHighlightedChannelId(nid)
}
} catch (e) {
setYoutubeStatus(`删除失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
setDeleteChannelBusyId(null)
}
},
[proChannelsList.length, reloadProChannelsList],
)
useEffect(() => {
if (!multiChannelPro) {
highlightedChannelIdRef.current = ''
setHighlightedChannelId('')
setHighlightedKeyIndex(0)
setCommittedChannelKeys({})
}
}, [multiChannelPro])
const commLenForHighlight = highlightedChannelId
? committedChannelKeys[highlightedChannelId]?.length ?? 0
: 0
useEffect(() => {
if (!multiChannelPro || !highlightedChannelId || commLenForHighlight === 0) return
setHighlightedKeyIndex((k) => (k >= 0 && k < commLenForHighlight ? k : 0))
}, [multiChannelPro, highlightedChannelId, commLenForHighlight])
const runProChannelStart = useCallback(
async (channelId: string, keyIndex: number) => {
async (channelId: string) => {
if (!channelId) return
const pm = proChannelPm2Process(channelId)
setLoadingAction('start')
try {
const saved = await saveProChannelKeysFor(channelId, keyIndex)
const saved = await saveProChannelKeysFor(channelId)
if (!saved) return
const res = await apiFetch(`${apiUrl('/start')}?process=${encodeURIComponent(pm)}`)
if (!res.ok) throw new Error('无法连接到本机服务')
@@ -925,12 +1003,10 @@ export default function App() {
eventType: 'control',
process: pm,
message: '开始直播 已执行',
detail: { action: 'start', channelId, keyIndex },
detail: { action: 'start', channelId },
})
const ed = proChannelEditors[channelId]
const cleaned = ed ? ed.keys.map((s) => String(s).trim()).filter(Boolean) : []
const ki = Math.min(Math.max(0, keyIndex), Math.max(0, cleaned.length - 1))
const keySrc = cleaned[ki] ?? ''
const keySrc = (ed?.keys[0] ?? '').trim()
const suffix = parseYoutubeStreamKeySuffix(`key = ${keySrc}`)
const rooms = extractDouyinRoomHints(ed?.urlText ?? '')
logLiveEvent({
@@ -942,7 +1018,6 @@ export default function App() {
keySuffix: suffix,
multiChannelPro: true,
channelId,
keyIndex,
pairingPolicy: 'one_douyin_one_youtube',
},
})
@@ -958,12 +1033,12 @@ export default function App() {
)
const runProChannelRestart = useCallback(
async (channelId: string, keyIndex: number) => {
async (channelId: string) => {
if (!channelId) return
const pm = proChannelPm2Process(channelId)
setLoadingAction('restart')
try {
const saved = await saveProChannelKeysFor(channelId, keyIndex)
const saved = await saveProChannelKeysFor(channelId)
if (!saved) return
const res = await apiFetch(`${apiUrl('/restart')}?process=${encodeURIComponent(pm)}`)
if (!res.ok) throw new Error('无法连接到本机服务')
@@ -981,7 +1056,7 @@ export default function App() {
eventType: 'control',
process: pm,
message: '重新开始 已执行',
detail: { action: 'restart', channelId, keyIndex },
detail: { action: 'restart', channelId },
})
setControlNotice(`已对「${who}」执行:重新开始\n${out}`)
setTimeout(() => void runPm2Action('status', pm), 1500)
@@ -1038,23 +1113,26 @@ export default function App() {
youtubeKeys?: string[]
youtubeActiveIndex?: number
}
const ks = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length
const ksRaw = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length
? cd.youtubeKeys.map(String)
: [cd.youtubeKey || '']
const ai =
typeof cd.youtubeActiveIndex === 'number'
? Math.max(0, Math.min(cd.youtubeActiveIndex, Math.max(0, ks.length - 1)))
: 0
const ur = await apiFetch(apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(c.id)}`))
const u = ur.ok ? ((await ur.json()) as { content?: string }).content || '' : ''
next[c.id] = { keys: ks.length ? ks : [''], activeIndex: ai, urlText: u }
committed[c.id] = ks.map((s) => String(s).trim()).filter(Boolean)
const k0 = (ksRaw[0] ?? '').trim()
/* Electron每频道仅一条密钥服务端若曾有多条只取第一条同步到 UI */
/* 不在此拉 url_config避免慢请求/竞态把 A 的地址写进 B。直播地址仅由「当前高亮」effect + 用户编辑维护 */
next[c.id] = { keys: [k0 || ''], activeIndex: 0, urlText: '' }
committed[c.id] = k0 ? [k0] : []
} catch {
/* skip */
}
}
if (!cancelled) {
setProChannelEditors(next)
setProChannelEditors((prev) => {
const merged: Record<string, ProChannelEditor> = {}
for (const id of Object.keys(next)) {
merged[id] = { ...next[id], urlText: prev[id]?.urlText ?? '' }
}
return merged
})
setCommittedChannelKeys(committed)
}
})()
@@ -1063,6 +1141,37 @@ export default function App() {
}
}, [isYoutube, multiChannelPro, channelIdsSig, proChannelsList])
/** 唯一从服务端填充「直播地址列表」的路径(与全量密钥同步解耦) */
useEffect(() => {
if (!isYoutube || !multiChannelPro || !highlightedChannelId) return
const cid = highlightedChannelId
let cancelled = false
void (async () => {
try {
const ur = await apiFetch(
apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`),
)
if (!ur.ok || cancelled) return
const j = (await ur.json()) as { content?: string }
const content = douyinUrlListDisplayFromServer(String(j.content ?? ''))
if (cid !== highlightedChannelIdRef.current) return
setProChannelEditors((prev) => {
const cur = prev[cid]
if (cur) {
return { ...prev, [cid]: { ...cur, urlText: content } }
}
return { ...prev, [cid]: { keys: [''], activeIndex: 0, urlText: content } }
})
setUrlStatus('已读取')
} catch {
if (!cancelled) setUrlStatus('读取失败')
}
})()
return () => {
cancelled = true
}
}, [highlightedChannelId, isYoutube, multiChannelPro])
useEffect(() => {
if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return
void loadYtOauthStatus()
@@ -1117,27 +1226,11 @@ export default function App() {
if (!isYoutube) return ''
if (multiChannelPro && highlightedChannelId) {
const saved = committedChannelKeys[highlightedChannelId]
if (saved?.length) {
const ki = Math.min(Math.max(highlightedKeyIndex, 0), saved.length - 1)
const k = (saved[ki] || '').trim()
return parseYoutubeStreamKeySuffix(`key = ${k}`)
}
const ed = proChannelEditors[highlightedChannelId]
if (!ed?.keys?.length) return ''
const ki = Math.min(Math.max(highlightedKeyIndex, 0), ed.keys.length - 1)
const k = (ed.keys[ki] || '').trim()
const k = ((saved?.[0] ?? proChannelEditors[highlightedChannelId]?.keys?.[0]) || '').trim()
return parseYoutubeStreamKeySuffix(`key = ${k}`)
}
return parseYoutubeStreamKeySuffix(`key = ${youtubeIniActiveKey(ytIniModel)}`)
}, [
isYoutube,
multiChannelPro,
highlightedChannelId,
highlightedKeyIndex,
committedChannelKeys,
proChannelEditors,
ytIniModel,
])
}, [isYoutube, multiChannelPro, highlightedChannelId, committedChannelKeys, proChannelEditors, ytIniModel])
const relayPs = liveDetail.processStatus
const isRelayProcessLive = relayPs === 'online'
@@ -1151,29 +1244,33 @@ export default function App() {
)
const rows: {
channelId: string
keyIndex: number
channelName: string
douyinUrl: string
live: boolean
}[] = []
for (const c of proChannelsList) {
const saved = committedChannelKeys[c.id]
const n = saved?.length ?? 0
if (n === 0) continue
const live = liveMap.get(c.id) ?? false
for (let i = 0; i < n; i++) {
rows.push({
channelId: c.id,
keyIndex: i,
channelName: c.name,
douyinUrl: c.douyinUrl ?? '',
live,
})
}
if (!saved?.length) continue
rows.push({
channelId: c.id,
channelName: c.name,
douyinUrl: c.douyinUrl ?? '',
live: liveMap.get(c.id) ?? false,
})
}
return rows
}, [proChannelsList, proRelayStatus, committedChannelKeys])
/** 多频道下若尚未选中线路,默认高亮直播开关第一行 */
useEffect(() => {
if (!isYoutube || !multiChannelPro) return
if (highlightedChannelId) return
if (proLiveRows.length === 0) return
const r0 = proLiveRows[0]
highlightedChannelIdRef.current = r0.channelId
setHighlightedChannelId(r0.channelId)
}, [isYoutube, multiChannelPro, highlightedChannelId, proLiveRows])
const liveDurationLabel = useMemo(() => {
if (liveStartedAtMs == null) return '—'
return formatDurationCn(Math.floor((Date.now() - liveStartedAtMs) / 1000))
@@ -1258,12 +1355,13 @@ export default function App() {
}
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
const firstId = d.channels[0].id
const keys = ytIniModel.keys?.length ? ytIniModel.keys.map((k) => String(k)) : ['']
const ai = Math.max(0, Math.min(ytIniModel.activeIndex, keys.length - 1))
const allKeys = ytIniModel.keys?.length ? ytIniModel.keys.map((k) => String(k)) : ['']
const ai = Math.max(0, Math.min(ytIniModel.activeIndex, Math.max(0, allKeys.length - 1)))
const keys = [(allKeys[ai] ?? allKeys[0] ?? '').trim() || '']
const keyRes = await apiFetch(apiUrl('/relay_pro/channel_keys'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId: firstId, keys, activeIndex: ai }),
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
})
const kj = (await keyRes.json()) as { error?: string }
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
@@ -1285,7 +1383,7 @@ export default function App() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
keys,
activeIndex: ai,
activeIndex: 0,
options: ytIniModel.options,
header: ytIniModel.header || '',
}),
@@ -1295,6 +1393,8 @@ export default function App() {
/* 选项同步失败不阻断进入多频道 */
}
void window.relayConfig?.importFromConfig?.()
highlightedChannelIdRef.current = firstId
setHighlightedChannelId(firstId)
setMultiChannelPro(true)
try {
localStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, '1')
@@ -1372,22 +1472,30 @@ export default function App() {
<div className="pro-live-switch-grid" role="list">
{proLiveRows.map((row) => {
const ed = proChannelEditors[row.channelId]
const slotKey = committedChannelKeys[row.channelId]?.[row.keyIndex] ?? ''
const slotKey = committedChannelKeys[row.channelId]?.[0] ?? ''
const activeKey = (slotKey || '').trim()
const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`)
const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '')
const stopDisabled = pmBusy || !row.live
const pm = proChannelPm2Process(row.channelId)
const rowSelected =
row.channelId === highlightedChannelId && row.keyIndex === highlightedKeyIndex
const rowSelected = row.channelId === highlightedChannelId
return (
<div
key={`${row.channelId}-${row.keyIndex}`}
key={row.channelId}
className={`pro-live-switch-row ${rowSelected ? 'is-selected' : ''}`}
role="listitem"
onClick={() => {
setHighlightedChannelId(row.channelId)
setHighlightedKeyIndex(row.keyIndex)
const fromId = highlightedChannelIdRef.current
const toId = row.channelId
highlightedChannelIdRef.current = toId
setHighlightedChannelId(toId)
if (toId !== fromId) {
setProChannelEditors((prev) => {
const cur = prev[toId]
if (!cur) return prev
return { ...prev, [toId]: { ...cur, urlText: '' } }
})
}
}}
>
<code className="pro-live-switch-key" title={activeKey || undefined}>
@@ -1407,11 +1515,11 @@ export default function App() {
openExternalUrl(href)
}}
>
{room} · {row.channelName} · #{row.keyIndex + 1}
{room} · {row.channelName}
</button>
) : (
<span className="pro-live-switch-static">
{room} · {row.channelName} · #{row.keyIndex + 1}
{room} · {row.channelName}
</span>
)}
</div>
@@ -1420,10 +1528,10 @@ export default function App() {
type="button"
className="pro-channel-live-btn pro-channel-live-btn--start"
disabled={pmBusy || row.live}
title={row.live ? '该频道已在播' : '使用该密钥槽位启动转播'}
title={row.live ? '该频道已在播' : '仅启动本频道的转播进程'}
onClick={() => {
if (pmBusy || row.live) return
void runProChannelStart(row.channelId, row.keyIndex)
void runProChannelStart(row.channelId)
}}
>
{(pmBusy || row.live) && <IconForbidden className="ctrl-forbidden-ico" />}
@@ -1446,7 +1554,7 @@ export default function App() {
type="button"
className="pro-channel-live-btn pro-channel-live-btn--restart"
disabled={pmBusy}
onClick={() => void runProChannelRestart(row.channelId, row.keyIndex)}
onClick={() => void runProChannelRestart(row.channelId)}
>
{pmBusy && <IconForbidden className="ctrl-forbidden-ico" />}
@@ -1459,7 +1567,7 @@ export default function App() {
) : isYoutube && multiChannelPro && proLiveRows.length === 0 ? (
<p className="card__desc">
{proChannelsList.length > 0
? '暂无已保存的线路密钥。请在「频道」中保存串流密钥后,对应行将显示于此。'
? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。'
: '暂无频道。'}
</p>
) : (
@@ -1531,17 +1639,7 @@ export default function App() {
</section>
)}
<section className="card card--pro-channel-block">
<div className="pro-channel-card-header">
<h2 className="card__title"></h2>
<button
type="button"
className={`btn btn-primary ${addChannelBusy ? 'loading' : ''}`}
disabled={addChannelBusy}
onClick={() => void addProChannel()}
>
</button>
</div>
<h2 className="card__title"></h2>
{!highlightedChannelId ? (
<p className="card__desc">线</p>
) : (
@@ -1551,107 +1649,101 @@ export default function App() {
const ed = proChannelEditors[ch.id]
return (
<div key={ch.id}>
<p className="pro-channel-current-line">
线<strong>{ch.name}</strong>
</p>
{!ed ? (
<p className="card__desc"></p>
) : (
<>
<div className="card__field">
<span className="card__field-label"></span>
<div className="pro-key-slot-rows">
{ed.keys.map((k, i) => (
<div
key={i}
className={`pro-key-slot-row ${
i === highlightedKeyIndex && ch.id === highlightedChannelId
? 'is-highlighted'
: ''
}`}
>
<span className="pro-key-slot-label">#{i + 1}</span>
<div className="pro-key-slot-row is-highlighted">
<input
type="text"
className="select-process pro-key-slot-input"
spellCheck={false}
autoComplete="off"
aria-label="串流密钥"
placeholder="粘贴或编辑串流密钥(本频道唯一)"
value={ed.keys[0] ?? ''}
onChange={(e) => {
const v = e.target.value
setProChannelEditors((prev) => {
const cur = prev[ch.id]
if (!cur) return prev
return { ...prev, [ch.id]: { ...cur, keys: [v], activeIndex: 0 } }
})
}}
/>
<div className="pro-key-slot-actions">
<button
type="button"
className={`btn btn-primary ${saveProKeyLoadingId === ch.id ? 'loading' : ''}`}
onClick={() => void saveProChannelKeysFor(ch.id)}
>
</button>
<button
type="button"
className={`btn btn-danger ${deleteChannelBusyId === ch.id ? 'loading' : ''}`}
disabled={proChannelsList.length <= 1 || deleteChannelBusyId !== null}
title={
proChannelsList.length <= 1 ? '至少保留一条频道' : '删除该频道及独立配置文件'
}
onClick={() => void deleteProChannel(ch.id)}
>
</button>
</div>
</div>
{newChannelDraft ? (
<div className="pro-key-slot-row pro-channel-new-draft-row">
<input
type="text"
className="select-process pro-key-slot-input"
spellCheck={false}
autoComplete="off"
placeholder="粘贴或编辑串流密钥"
value={k}
onChange={(e) => {
const v = e.target.value
setProChannelEditors((prev) => {
const cur = prev[ch.id]
if (!cur) return prev
const nextKeys = [...cur.keys]
nextKeys[i] = v
return { ...prev, [ch.id]: { ...cur, keys: nextKeys } }
})
}}
aria-label="新频道串流密钥"
placeholder="新频道串流密钥(可先填再保存创建)"
value={newChannelDraft.keyText}
onChange={(e) =>
setNewChannelDraft((d) =>
d ? { ...d, keyText: e.target.value } : d,
)
}
/>
<div className="pro-key-slot-actions">
<button
type="button"
className="btn btn-secondary"
disabled={ed.keys.length <= 1}
onClick={() =>
setProChannelEditors((prev) => {
const cur = prev[ch.id]
if (!cur || cur.keys.length <= 1) return prev
const nextKeys = cur.keys.filter((_, j) => j !== i)
let ai = cur.activeIndex
if (i === cur.activeIndex) {
ai = Math.min(cur.activeIndex, Math.max(0, nextKeys.length - 1))
} else if (i < cur.activeIndex) {
ai = cur.activeIndex - 1
}
ai = Math.max(0, Math.min(ai, nextKeys.length - 1))
return {
...prev,
[ch.id]: {
...cur,
keys: nextKeys.length ? nextKeys : [''],
activeIndex: ai,
},
}
})
}
disabled={addChannelBusy}
onClick={() => setNewChannelDraft(null)}
>
</button>
<button
type="button"
className={`btn btn-primary ${saveProKeyLoadingId === ch.id ? 'loading' : ''}`}
onClick={() => void saveProChannelKeysFor(ch.id, i)}
className={`btn btn-primary ${addChannelBusy ? 'loading' : ''}`}
disabled={addChannelBusy}
onClick={() => void submitNewChannelDraft()}
>
</button>
</div>
</div>
))}
</div>
<div className="btn-row pro-channel-append-slot">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
if (!highlightedChannelId) {
setYoutubeStatus('请先在直播开关中选择线路')
return
}
setProChannelEditors((prev) => {
const cur = prev[highlightedChannelId]
if (!cur) return prev
return {
...prev,
[highlightedChannelId]: { ...cur, keys: [...cur.keys, ''] },
}
})
}}
>
</button>
) : null}
</div>
{!newChannelDraft ? (
<div className="btn-row pro-channel-append-slot">
<button
type="button"
className="btn btn-secondary"
disabled={addChannelBusy}
title="新增一条频道(填写密钥后点保存)"
onClick={() => setNewChannelDraft({ keyText: '' })}
>
</button>
</div>
) : null}
</div>
</>
)}
@@ -1668,10 +1760,6 @@ export default function App() {
return (
<section className="card" key={`url-${ch.id}`}>
<h2 className="card__title"></h2>
<p className="card__desc">
线<strong>{ch.name}</strong>/线
SQLite 线
</p>
{!ed ? (
<p className="card__desc"></p>
) : (
@@ -1681,6 +1769,8 @@ export default function App() {
className="input-area input-area--autosize"
spellCheck={false}
autoComplete="off"
aria-label="抖音直播间地址列表"
placeholder="要转播的直播间地址写在这里,一行一个。"
rows={Math.min(
18,
Math.max(3, ed.urlText.split(/\r?\n/).length + 1),
@@ -1710,7 +1800,13 @@ export default function App() {
setProChannelEditors((prev) => {
const cur = prev[ch.id]
if (!cur) return prev
return { ...prev, [ch.id]: { ...cur, urlText: u.content || '' } }
return {
...prev,
[ch.id]: {
...cur,
urlText: douyinUrlListDisplayFromServer(u.content || ''),
},
}
})
setUrlStatus('已重新读取')
}

View File

@@ -202,7 +202,6 @@ export default function WorkspaceDashboard() {
const net = data?.snapshots?.network_status
const sys = data?.snapshots?.system_metrics
const w = overview?.wechat
return (
<section className="card card--workspace" aria-labelledby="ws-title">
@@ -217,7 +216,7 @@ export default function WorkspaceDashboard() {
<div className="ws-head">
<div>
<h2 className="card__title" id="ws-title">
</h2>
</div>
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
@@ -279,111 +278,6 @@ export default function WorkspaceDashboard() {
)}
<div className="ws-cards ws-cards--wide">
<div className="ws-card">
<div className="ws-card__title">线 YouTube</div>
{overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 && (
<div className="ws-card__errors" role="alert">
{overview.relayValidationErrors.map((e) => (
<p key={e}>{e}</p>
))}
</div>
)}
{overview?.youtubeChannels?.length ? (
<>
<p className="ws-card__meta">
{overview.youtubeChannelCount ?? overview.youtubeChannels.length} 线
</p>
<ul className="ws-kv-list ws-kv-list--stack">
{overview.youtubeChannels.map((c) => (
<li key={`${c.name}-${c.keyPreview}-${c.douyinPreview ?? ''}`}>
<div className="ws-kv-list__row">
<span className="ws-kv-list__k">{c.name}</span>
</div>
<div className="ws-kv-list__row">
<span className="ws-kv-list__sub"></span>
<span className="ws-kv-list__v" title={c.douyinPreview}>
{c.douyinPreview || '—'}
</span>
</div>
<div className="ws-kv-list__row">
<span className="ws-kv-list__sub">YouTube key</span>
<code className="ws-kv-list__v">{c.keyPreview || '—'}</code>
</div>
</li>
))}
</ul>
{overview.relayNotes && overview.relayNotes.length > 0 && (
<div className="ws-card__notes" role="note">
{overview.relayNotes.map((n) => (
<p key={n}>{n}</p>
))}
</div>
)}
</>
) : (
<p className="ws-card__empty">
{overview?.relayValidationErrors?.length ? '线路配置存在冲突,请在「录制」页调整后保存。' : '暂无转播摘要'}
</p>
)}
</div>
<div className="ws-card">
<div className="ws-card__title">EasyTier </div>
{peerStats?.ok ? (
<>
<p className="ws-peer-stats">
<strong>{peerStats.totalPeers ?? 0}</strong> 线{' '}
<strong>{peerStats.onlinePeers ?? 0}</strong>
</p>
</>
) : (
<p className="ws-card__empty">{peerStats?.error || '暂不可用'}</p>
)}
</div>
<div className="ws-card">
<div className="ws-card__title"> Chatbot</div>
{w ? (
<>
<p className="ws-peer-stats">
{w.configured ? (
<>
{' '}
<span className={w.online ? 'ws-tag ws-tag--on' : 'ws-tag ws-tag--off'}>
{w.online ? '会话在线' : '未确认在线'}
</span>
</>
) : (
'未配置 apiKey / proxyUrl'
)}
</p>
{(w.nickName || w.wcId) && (
<ul className="ws-kv-list">
{w.nickName && (
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{w.nickName}</span>
</li>
)}
{w.wcId && (
<li>
<span className="ws-kv-list__k">wcId</span>
<code className="ws-kv-list__v ws-mono">{String(w.wcId)}</code>
</li>
)}
</ul>
)}
{w.proxyError && (
<p className="remote-warn" role="status">
{w.proxyError}
</p>
)}
</>
) : (
<p className="ws-card__empty"></p>
)}
</div>
<div className="ws-card">
<div className="ws-card__title"></div>
{net ? (

View File

@@ -291,22 +291,11 @@ export default function WorkspaceDeskBackdrop({
}
}, [dataKey])
const peerHint =
peerOnline > 0
? `EasyTier 约 ${peerOnline} 节点在线 · 点云密度 ∝ 推流时长与配置完整度`
: '点云密度 ∝ 推流时长与配置完整度(无 EasyTier 数据时仅显示配置与事件强度)'
return (
<div className="ws-desk-backdrop">
<div className="ws-desk-backdrop__three" ref={threeMountRef} aria-hidden />
<svg className="ws-desk-backdrop__d3" ref={svgRef} width="100%" height="100%" aria-hidden />
<div className="ws-desk-backdrop__fade" aria-hidden />
<div className="ws-desk-backdrop__legend" role="region" aria-label="转播业务可视化说明">
<strong></strong>
<span>D3线+YouTube </span>
<span>Three线 RELAY 线</span>
<span className="ws-desk-backdrop__legend-sub">{peerHint}</span>
</div>
</div>
)
}

View File

@@ -40,5 +40,5 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
return fetch(input, { ...init, headers, cache: 'no-store' })
}