's'
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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('已重新读取')
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user