s
This commit is contained in:
@@ -6,6 +6,7 @@ import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, use
|
||||
import OverviewCharts from "@/components/OverviewCharts";
|
||||
import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel";
|
||||
import { fetchJsonResponse, readErrorMessage } from "@/lib/fetchJson";
|
||||
import { extractNekoBrowserLinks, formatNekoPorts } from "@/lib/nekoLinks";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
@@ -302,6 +303,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
|
||||
}
|
||||
return out;
|
||||
}, [stack]);
|
||||
const nekoLinks = useMemo(() => extractNekoBrowserLinks(qlNorm), [qlNorm]);
|
||||
const nekoPortsLabel = useMemo(() => formatNekoPorts(nekoLinks), [nekoLinks]);
|
||||
|
||||
const rawAndroidPanelUrl = useMemo(() => {
|
||||
const baseUrl =
|
||||
@@ -1084,11 +1087,11 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
|
||||
🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
|
||||
</a>
|
||||
) : null}
|
||||
{qlNorm.neko_browser ? (
|
||||
<a className="link-tile link-tile-warn" href={qlNorm.neko_browser} target="_blank" rel="noreferrer">
|
||||
🐱 Neko / {tr("Docker Chromium 桌面", "Docker Chromium desktop")}
|
||||
{nekoLinks.map((link) => (
|
||||
<a key={link.key} className="link-tile link-tile-warn" href={link.url} target="_blank" rel="noreferrer">
|
||||
🐱 {link.label} / {tr("Docker Chromium 桌面", "Docker Chromium desktop")}
|
||||
</a>
|
||||
) : null}
|
||||
))}
|
||||
{qlNorm.webtty ? (
|
||||
<a className="link-tile" href={qlNorm.webtty} target="_blank" rel="noreferrer">
|
||||
⌨️ WebTTY
|
||||
@@ -1560,8 +1563,12 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-slate-500">
|
||||
{tr(
|
||||
"内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko(Chromium)在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。",
|
||||
"Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium is often :9200 when ENABLE_NEKO=1 and the container is up.",
|
||||
nekoPortsLabel
|
||||
? `内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko(Chromium)实例通常为 ${nekoPortsLabel}。`
|
||||
: "内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko(Chromium)在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。",
|
||||
nekoPortsLabel
|
||||
? `Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium instances are usually on ${nekoPortsLabel}.`
|
||||
: "Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium is often :9200 when ENABLE_NEKO=1 and the container is up.",
|
||||
)}
|
||||
</p>
|
||||
<iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="ws-scrcpy" />
|
||||
|
||||
@@ -34,6 +34,7 @@ import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView
|
||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
|
||||
import { fetchJsonResponse } from "@/lib/fetchJson";
|
||||
import { extractNekoBrowserLinks, formatNekoPorts, type NekoBrowserLink } from "@/lib/nekoLinks";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
@@ -72,6 +73,13 @@ type HubDashboard = {
|
||||
platform?: string;
|
||||
ips?: string[];
|
||||
quick_links?: Record<string, string>;
|
||||
neko_instances?: Array<{
|
||||
id?: number;
|
||||
label?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
quick_link_key?: string;
|
||||
}>;
|
||||
neko_login?: {
|
||||
user_login?: string;
|
||||
user_password?: string;
|
||||
@@ -141,10 +149,44 @@ type HubDashboard = {
|
||||
port?: number;
|
||||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
instances?: Array<{
|
||||
id?: number;
|
||||
port?: number;
|
||||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
}>;
|
||||
running_count?: number;
|
||||
required_count?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type DeploymentReport = {
|
||||
ready?: boolean;
|
||||
pass_count?: number;
|
||||
total_count?: number;
|
||||
required_total?: number;
|
||||
mdns_host?: string;
|
||||
primary_lan_ip?: string;
|
||||
port?: string;
|
||||
live_edge_enabled?: boolean;
|
||||
mdns_enabled?: boolean;
|
||||
checks?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
kind?: string;
|
||||
detail?: string;
|
||||
url?: string;
|
||||
target?: string;
|
||||
http_code?: number;
|
||||
reachable?: boolean;
|
||||
}>;
|
||||
failing_checks?: Array<{ id: string; label: string; ok: boolean; detail?: string }>;
|
||||
hints?: string[];
|
||||
urls?: Record<string, string>;
|
||||
};
|
||||
|
||||
type SrsProbe = NonNullable<HubDashboard["probes"]>["srs"];
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
@@ -223,6 +265,7 @@ export default function LiveControlApp() {
|
||||
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
|
||||
const [androidSerial, setAndroidSerial] = useState("");
|
||||
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
|
||||
const [deployCheck, setDeployCheck] = useState<DeploymentReport | null>(null);
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
|
||||
const [showStreamProbes, setShowStreamProbes] = useState(true);
|
||||
const dashboardInFlightRef = useRef<Promise<void> | null>(null);
|
||||
@@ -255,10 +298,22 @@ export default function LiveControlApp() {
|
||||
if (dashboardInFlightRef.current) return dashboardInFlightRef.current;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const d = await fetchJson<HubDashboard>("/hub/dashboard");
|
||||
const [dashResult, deployResult] = await Promise.allSettled([
|
||||
fetchJson<HubDashboard>("/hub/dashboard"),
|
||||
fetchJson<DeploymentReport>("/deploy/check"),
|
||||
] as const);
|
||||
if (dashResult.status !== "fulfilled") {
|
||||
throw dashResult.reason;
|
||||
}
|
||||
const d = dashResult.value;
|
||||
setDash(d);
|
||||
setDashErr(null);
|
||||
setLastSyncedAt(Date.now());
|
||||
if (deployResult.status === "fulfilled") {
|
||||
setDeployCheck(deployResult.value);
|
||||
} else {
|
||||
setDeployCheck(null);
|
||||
}
|
||||
const devs = d.android?.devices || [];
|
||||
if (devs.length && !androidSerial) {
|
||||
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
|
||||
@@ -266,6 +321,7 @@ export default function LiveControlApp() {
|
||||
}
|
||||
} catch (e) {
|
||||
setDashErr((e as Error).message);
|
||||
setDeployCheck(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dashboardInFlightRef.current = null;
|
||||
@@ -433,6 +489,8 @@ export default function LiveControlApp() {
|
||||
}
|
||||
return o;
|
||||
}, [dash?.stack?.quick_links]);
|
||||
const nekoLinks = useMemo<NekoBrowserLink[]>(() => extractNekoBrowserLinks(ql), [ql]);
|
||||
const nekoPortsLabel = useMemo(() => formatNekoPorts(nekoLinks), [nekoLinks]);
|
||||
|
||||
const runService = async (
|
||||
service: string,
|
||||
@@ -973,12 +1031,106 @@ export default function LiveControlApp() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deployCheck ? (
|
||||
<div
|
||||
className={`rounded-2xl border p-5 ${
|
||||
deployCheck.ready
|
||||
? "border-emerald-500/25 bg-gradient-to-br from-emerald-500/[0.08] via-zinc-950/50 to-cyan-500/[0.05]"
|
||||
: "border-amber-500/30 bg-gradient-to-br from-amber-500/[0.1] via-zinc-950/55 to-rose-500/[0.05]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{t("安装验收", "Deployment health")}</h3>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t(
|
||||
"一键脚本完成后,本机 API、局域网入口、反向代理与 mDNS 的可用性检查。",
|
||||
"Post-install checks for loopback API, LAN access, reverse proxy, and mDNS.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-[10px] font-semibold ${
|
||||
deployCheck.ready
|
||||
? "bg-emerald-500/20 text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
: "bg-amber-500/20 text-amber-100 ring-1 ring-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
{deployCheck.ready ? t("可交付", "Ready") : t("需处理", "Needs attention")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/[0.06] bg-black/30 p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{t("验收摘要", "Acceptance summary")}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-sm text-white">
|
||||
{deployCheck.pass_count ?? 0}/{deployCheck.total_count ?? 0}
|
||||
<span className="ml-2 text-[11px] text-zinc-500">
|
||||
{t("已通过", "checks passed")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t("必需项", "Required")}: {deployCheck.required_total ?? 0} · mDNS{" "}
|
||||
{deployCheck.mdns_enabled ? t("已启用", "enabled") : t("已关闭", "disabled")} · edge{" "}
|
||||
{deployCheck.live_edge_enabled ? t("已启用", "enabled") : t("已关闭", "disabled")}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(deployCheck.urls || {})
|
||||
.filter(([, url]) => !!url)
|
||||
.slice(0, 4)
|
||||
.map(([key, url]) => (
|
||||
<a
|
||||
key={key}
|
||||
href={normalizeBrowserReachableHttpUrl(url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-[11px] text-zinc-200 ring-1 ring-white/10"
|
||||
>
|
||||
{key}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/[0.06] bg-black/30 p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{deployCheck.failing_checks?.length ? t("失败项", "Failing checks") : t("当前状态", "Current state")}
|
||||
</p>
|
||||
{deployCheck.failing_checks?.length ? (
|
||||
<ul className="mt-2 space-y-2 text-[11px] text-zinc-300">
|
||||
{deployCheck.failing_checks.slice(0, 4).map((item) => (
|
||||
<li key={item.id} className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2">
|
||||
<span className="font-semibold text-amber-100">{item.label}</span>
|
||||
<span className="ml-2 text-zinc-400">{item.detail || "—"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-emerald-200/90">
|
||||
{t("必需检查已全部通过,可直接用浏览器面板交付。", "All required checks pass; browser control plane is ready.")}
|
||||
</p>
|
||||
)}
|
||||
{(deployCheck.hints || []).length ? (
|
||||
<ul className="mt-3 list-inside list-disc space-y-1 text-[10px] text-zinc-400">
|
||||
{(deployCheck.hints || []).slice(0, 3).map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dash?.stack?.neko_login ? (
|
||||
<div className="rounded-2xl border border-fuchsia-500/25 bg-gradient-to-r from-fuchsia-500/[0.08] to-violet-500/[0.06] p-5">
|
||||
<h3 className="text-sm font-semibold text-white">Neko {t("登录", "sign-in")}</h3>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{dash.stack.neko_login.note_zh ||
|
||||
t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
|
||||
t(
|
||||
nekoPortsLabel ? `Neko Chromium ${nekoPortsLabel} 使用下列账号策略。` : "Neko Chromium 多实例使用下列账号策略。",
|
||||
nekoPortsLabel ? `Neko Chromium ${nekoPortsLabel} uses the policy below.` : "Neko Chromium instances use the policy below.",
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-amber-200/85">
|
||||
{t(
|
||||
@@ -986,6 +1138,22 @@ export default function LiveControlApp() {
|
||||
"Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
|
||||
)}
|
||||
</p>
|
||||
{nekoLinks.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{nekoLinks.map((link) => (
|
||||
<a
|
||||
key={link.key}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-fuchsia-400/20 bg-black/25 px-3 py-2 text-[11px] text-fuchsia-100 ring-1 ring-fuchsia-400/20"
|
||||
>
|
||||
{link.label}
|
||||
{link.port ? ` :${link.port}` : ""}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
|
||||
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
|
||||
<p className="text-[10px] uppercase text-zinc-500">{t("昵称示例", "Display Name Example")}</p>
|
||||
@@ -1178,9 +1346,23 @@ export default function LiveControlApp() {
|
||||
const p = dash.probes?.neko;
|
||||
const tcpOk = p?.tcp?.reachable;
|
||||
const http = p?.http;
|
||||
const instances = p?.instances || [];
|
||||
const runningCount = p?.running_count ?? instances.filter((item) => item.tcp?.reachable).length;
|
||||
const requiredCount = p?.required_count ?? instances.length;
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
|
||||
<p className="text-xs font-semibold text-white">Neko Chromium</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-white">Neko Chromium</p>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
runningCount > 0
|
||||
? "bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-500/30"
|
||||
: "bg-rose-500/15 text-rose-200 ring-1 ring-rose-500/25"
|
||||
}`}
|
||||
>
|
||||
{runningCount}/{requiredCount || "—"} {t("在线", "online")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t(
|
||||
"Docker 内 Chromium(非 Chrome 品牌);arm64/x86 同一镜像。",
|
||||
@@ -1198,6 +1380,35 @@ export default function LiveControlApp() {
|
||||
? `${p.tcp.latency_ms} ms`
|
||||
: "—"}
|
||||
</p>
|
||||
{instances.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{instances.map((item) => (
|
||||
<div
|
||||
key={`${item.id}-${item.port}`}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-white/[0.05] bg-black/25 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-zinc-200">
|
||||
Neko {item.id ?? "?"}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-zinc-500">
|
||||
:{item.port ?? "—"} · TCP {item.tcp?.reachable ? "OK" : "—"} · HTTP{" "}
|
||||
{item.http?.http_code ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
item.tcp?.reachable
|
||||
? "bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-500/25"
|
||||
: "bg-zinc-500/15 text-zinc-400 ring-1 ring-zinc-500/25"
|
||||
}`}
|
||||
>
|
||||
{item.tcp?.reachable ? t("运行中", "Running") : t("未连通", "Offline")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!http?.reachable && http?.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
|
||||
) : null}
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
parseYoutubeStreamKeySuffix,
|
||||
type YoutubeIniFields,
|
||||
} from "@/lib/youtubeConfigUtils";
|
||||
import {
|
||||
extractNekoBrowserLinks,
|
||||
formatNekoPorts,
|
||||
resolveNekoBrowserLink,
|
||||
type QuickLinkMap,
|
||||
} from "@/lib/nekoLinks";
|
||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
|
||||
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
|
||||
@@ -131,7 +137,6 @@ const PRO_PM2_RESERVED = new Set(["tiktok", "obs", "web"]);
|
||||
/** UI 暂时隐藏「多频道 Pro」切换(DOM 与逻辑保留,改 false 即可恢复) */
|
||||
const HIDE_YOUTUBE_MULTI_PRO_TOGGLE = false;
|
||||
|
||||
type QuickLinkMap = Record<string, string>;
|
||||
type ProLaneDraft = {
|
||||
name: string;
|
||||
streamKey: string;
|
||||
@@ -1036,14 +1041,18 @@ export function LiveYoutubeUnmannedView({
|
||||
const currentUrlDraft = mode === "single" ? urlConfig : proUrlDraft;
|
||||
const currentUrlCount = useMemo(() => nonCommentUrlLinesOf(currentUrlDraft).length, [currentUrlDraft]);
|
||||
const currentDouyinCount = useMemo(() => extractDouyinLiveUrls(currentUrlDraft).length, [currentUrlDraft]);
|
||||
const nekoLinks = useMemo(() => extractNekoBrowserLinks(quickLinks), [quickLinks]);
|
||||
const selectedNeko = useMemo(
|
||||
() => resolveNekoBrowserLink(nekoLinks, mode === "pro" && active ? channels.findIndex((row) => row.id === active.id) : 0),
|
||||
[active, channels, mode, nekoLinks],
|
||||
);
|
||||
const selectedNekoUrl = selectedNeko.link
|
||||
? normalizeBrowserReachableHttpUrl(selectedNeko.link.url)
|
||||
: normalizeBrowserReachableHttpUrl("http://live.local:9200/");
|
||||
const nekoPortsLabel = useMemo(() => formatNekoPorts(nekoLinks), [nekoLinks]);
|
||||
const laneQuickLinks = useMemo(
|
||||
() =>
|
||||
[
|
||||
quickLinks?.neko_browser,
|
||||
quickLinks?.chromium_remote_debug,
|
||||
quickLinks?.srs_api,
|
||||
].filter((value): value is string => !!value),
|
||||
[quickLinks],
|
||||
() => [selectedNeko.link?.url, quickLinks?.chromium_remote_debug, quickLinks?.srs_api].filter((value): value is string => !!value),
|
||||
[quickLinks, selectedNeko.link?.url],
|
||||
);
|
||||
|
||||
const flushProChannelsBeforeRun = useCallback(
|
||||
@@ -1164,9 +1173,11 @@ export function LiveYoutubeUnmannedView({
|
||||
},
|
||||
{
|
||||
label: "Neko",
|
||||
sub: t("Chromium 工作室", "Chromium"),
|
||||
sub: selectedNeko.link
|
||||
? `${t("Chromium 工作室", "Chromium studio")} · ${selectedNeko.link.label}`
|
||||
: t("Chromium 工作室", "Chromium studio"),
|
||||
gradient: "from-emerald-400 to-teal-500",
|
||||
openUrl: normalizeBrowserReachableHttpUrl(quickLinks?.neko_browser || "http://live.local:9200/"),
|
||||
openUrl: selectedNekoUrl,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1219,16 +1230,36 @@ export function LiveYoutubeUnmannedView({
|
||||
|
||||
{laneQuickLinks.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickLinks?.neko_browser ? (
|
||||
{selectedNeko.link ? (
|
||||
<a
|
||||
href={normalizeBrowserReachableHttpUrl(quickLinks.neko_browser)}
|
||||
href={selectedNekoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/20"
|
||||
>
|
||||
{t("打开 Neko 工作室", "Open Neko studio")}
|
||||
{t("打开当前线路 Neko 工作室", "Open lane Neko studio")}
|
||||
</a>
|
||||
) : null}
|
||||
{nekoLinks.length > 1 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{nekoLinks.map((link) => (
|
||||
<a
|
||||
key={link.key}
|
||||
href={normalizeBrowserReachableHttpUrl(link.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`rounded-lg border px-3 py-2 text-xs ring-1 ${
|
||||
selectedNeko.link?.id === link.id
|
||||
? "border-emerald-400/30 bg-emerald-500/15 text-emerald-50 ring-emerald-400/25"
|
||||
: "border-white/10 bg-white/5 text-zinc-200 ring-white/10"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
{link.port ? ` :${link.port}` : ""}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{quickLinks?.chromium_remote_debug ? (
|
||||
<a
|
||||
href={normalizeBrowserReachableHttpUrl(quickLinks.chromium_remote_debug)}
|
||||
@@ -1338,6 +1369,11 @@ export function LiveYoutubeUnmannedView({
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t("对齐桌面端:每路独立 URL / youtube 配置文件;点左侧信息切换编辑,右侧控制进程。", "Per-lane config files like desktop; click row info to edit, use right-side controls for PM2.")}
|
||||
</p>
|
||||
{nekoPortsLabel ? (
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
{t("Neko 实例", "Neko instances")}: {nekoPortsLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3 max-h-[min(24rem,55vh)] space-y-2 overflow-y-auto pr-1">
|
||||
{channels.map((c) => {
|
||||
const pm = resolveChannelPm2(c);
|
||||
@@ -1349,6 +1385,8 @@ export function LiveYoutubeUnmannedView({
|
||||
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
|
||||
const sel = activeCh === c.id;
|
||||
const rowOnline = pm2StatusOnline(st);
|
||||
const laneIndex = channels.findIndex((row) => row.id === c.id);
|
||||
const laneNeko = resolveNekoBrowserLink(nekoLinks, laneIndex).link;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
@@ -1373,6 +1411,11 @@ export function LiveYoutubeUnmannedView({
|
||||
>
|
||||
{businessStatusLabel(row, t)}
|
||||
</span>
|
||||
{laneNeko ? (
|
||||
<span className="shrink-0 rounded-md bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-100 ring-1 ring-emerald-500/25">
|
||||
{laneNeko.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[10px] text-zinc-500">
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user