This commit is contained in:
root
2026-05-17 05:22:53 +00:00
parent 07aa02bca6
commit 03dc47a569
24 changed files with 941 additions and 93 deletions

View File

@@ -21,7 +21,9 @@ import {
loadYoutubeProChannels,
newChannelId,
pushYoutubeProChannelsRemote,
resolveYoutubeProChannelPm2,
saveYoutubeProChannels,
sanitizeYoutubePm2Name,
type YoutubeProChannel,
} from "@/lib/youtubeProChannels";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
@@ -120,14 +122,32 @@ function preferredSingleProcessPm2FromRuntime(entries: ProcessEntry[], liveRows:
}
function resolveChannelPm2(channel: YoutubeProChannel): string {
return (channel.pm2Name ?? "").trim() || defaultPm2NameForChannel(channel.id);
return resolveYoutubeProChannelPm2(channel);
}
const MODE_KEY = "live-hub-youtube-mode-v1";
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;
pm2Name: string;
notes: string;
};
function laneDraftFromChannel(channel: YoutubeProChannel): ProLaneDraft {
return {
name: channel.name,
streamKey: channel.streamKey ?? "",
pm2Name: channel.pm2Name ?? defaultPm2NameForChannel(channel.id),
notes: channel.notes ?? "",
};
}
type LiveProcRow = {
pm2: string;
script?: string;
@@ -171,6 +191,17 @@ function businessStatusBadgeClass(row: LiveProcRow | undefined): string {
return "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25";
}
function nonCommentUrlLinesOf(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
}
function shouldPreserveLocalUrlDraft(localText: string, remoteText: string): boolean {
return nonCommentUrlLinesOf(localText).length > 0 && nonCommentUrlLinesOf(remoteText).length === 0;
}
function parseProcBusy(busy: string | null): { action: string; pm2: string } | null {
if (!busy?.startsWith("proc:")) return null;
const rest = busy.slice(5);
@@ -281,6 +312,7 @@ type Props = {
liveProcesses: LiveProcRow[];
notify: (msg: string) => void;
onNavigate?: (target: PortalNavTarget) => void;
quickLinks?: QuickLinkMap;
};
export function LiveYoutubeUnmannedView({
@@ -297,6 +329,7 @@ export function LiveYoutubeUnmannedView({
liveProcesses,
notify,
onNavigate,
quickLinks,
}: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
const staticYoutubeEntries = useMemo(() => ytEntries.filter((entry) => isStaticYoutubeControlEntry(entry)), [ytEntries]);
@@ -326,6 +359,9 @@ export function LiveYoutubeUnmannedView({
const [urlReloading, setUrlReloading] = useState(false);
const [urlOptimizing, setUrlOptimizing] = useState(false);
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
const [laneDrafts, setLaneDrafts] = useState<Record<string, ProLaneDraft>>({});
const [laneSaving, setLaneSaving] = useState(false);
const [laneStatus, setLaneStatus] = useState<string | null>(null);
const urlListDirtyRef = useRef(false);
useEffect(() => {
@@ -342,6 +378,11 @@ export function LiveYoutubeUnmannedView({
if (urlListDirtyRef.current) return;
if (mode === "single") setUrlConfig(c);
else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
setProUrlDraft(localDraft);
return;
}
setProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
@@ -354,7 +395,7 @@ export function LiveYoutubeUnmannedView({
} catch {
/* ignore */
}
}, [activeCh, currentProc, fetchJson, mode, setUrlConfig]);
}, [activeCh, channels, currentProc, fetchJson, mode, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
@@ -381,6 +422,12 @@ export function LiveYoutubeUnmannedView({
}
if (mode === "single") setUrlConfig(c);
else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
setProUrlDraft(localDraft);
setUrlFetchNote(t("服务器暂无已保存 URL已保留本地草稿", "Server has no saved URLs, kept the local draft"));
return;
}
setProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
@@ -480,21 +527,134 @@ export function LiveYoutubeUnmannedView({
}
}, [currentProc, mode, preferredSingleProc, rowForPm2, setCurrentProc, singleProcessOptions]);
const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
}, []);
const syncChannelsRemote = useCallback(
async (next: YoutubeProChannel[]) => {
await pushYoutubeProChannelsRemote(fetchJson, next);
},
[fetchJson],
);
const persistChannels = useCallback(
(next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
void pushYoutubeProChannelsRemote(fetchJson, next).catch((e) => {
applyChannelsLocal(next);
void syncChannelsRemote(next).catch((e) => {
notify(
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
);
});
},
[fetchJson, notify, t],
[applyChannelsLocal, notify, syncChannelsRemote, t],
);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const activeSavedUrlDraft = useMemo(() => (active?.urlLines ?? "").trim(), [active?.urlLines]);
const activeLaneDraft = useMemo(
() => (active ? laneDrafts[active.id] ?? laneDraftFromChannel(active) : null),
[active, laneDrafts],
);
useEffect(() => {
if (!active) return;
setLaneDrafts((prev) => {
if (prev[active.id]) return prev;
return { ...prev, [active.id]: laneDraftFromChannel(active) };
});
}, [active]);
useEffect(() => {
setLaneStatus(null);
}, [activeCh]);
const setLaneDraftField = useCallback(
(key: keyof ProLaneDraft, value: string) => {
if (!active) return;
setLaneDrafts((prev) => ({
...prev,
[active.id]: {
...(prev[active.id] ?? laneDraftFromChannel(active)),
[key]: value,
},
}));
setLaneStatus(null);
},
[active],
);
const activeSavedPm2 = active ? resolveChannelPm2(active) : "";
const activeDraftPm2 = active && activeLaneDraft
? resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name })
: "";
const activePm2PreviewChanged = !!active && !!activeLaneDraft && activeDraftPm2 !== activeSavedPm2;
const activeLaneValidation = useMemo(() => {
if (!active || !activeLaneDraft) {
return {
ok: false,
message: t("请选择线路", "Pick a lane"),
normalizedName: "",
normalizedPm2: "",
normalizedStreamKey: "",
normalizedNotes: "",
};
}
const normalizedName = activeLaneDraft.name.trim() || `${t("线路", "Ch")} ${channels.findIndex((row) => row.id === active.id) + 1}`;
const normalizedPm2 = resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name });
if (!normalizedPm2) {
return {
ok: false,
message: t("进程名不能为空", "PM2 name is required"),
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}
if (PRO_PM2_RESERVED.has(normalizedPm2)) {
return {
ok: false,
message: t("该进程名保留给系统模块,请换一个", "This PM2 name is reserved"),
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}
const duplicate = channels.find((row) => row.id !== active.id && resolveChannelPm2(row) === normalizedPm2);
if (duplicate) {
return {
ok: false,
message: t("该进程名已被其它线路占用", "Another lane already uses this PM2 name"),
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}
return {
ok: true,
message: "",
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}, [active, activeLaneDraft, channels, t]);
const activeLaneDirty = useMemo(() => {
if (!active || !activeLaneDraft) return false;
const savedDraft = laneDraftFromChannel(active);
return (
activeLaneDraft.name !== savedDraft.name ||
activeLaneDraft.streamKey !== savedDraft.streamKey ||
sanitizeYoutubePm2Name(activeLaneDraft.pm2Name) !== sanitizeYoutubePm2Name(savedDraft.pm2Name) ||
activeLaneDraft.notes !== savedDraft.notes
);
}, [active, activeLaneDraft]);
const setModePersist = useCallback(
(m: "single" | "pro") => {
@@ -574,6 +734,11 @@ export function LiveYoutubeUnmannedView({
);
if (cancelled || urlListDirtyRef.current) return;
const c = data.content ?? "";
const localDraft = channels.find((row) => row.id === targetChannelId)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
setProUrlDraft(localDraft);
return;
}
setProUrlDraft(c);
urlListDirtyRef.current = false;
setChannels((prev) => {
@@ -590,7 +755,7 @@ export function LiveYoutubeUnmannedView({
return () => {
cancelled = true;
};
}, [activeCh, activeSavedUrlDraft, currentProc, fetchJson, mode]);
}, [activeCh, activeSavedUrlDraft, channels, currentProc, fetchJson, mode]);
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
@@ -615,10 +780,80 @@ export function LiveYoutubeUnmannedView({
void loadYoutubeIni();
}, [currentProc, loadYoutubeIni]);
const updateActive = (patch: Partial<YoutubeProChannel>) => {
const persistActiveLaneDraft = useCallback(
async () => {
if (!active || !activeLaneDraft) {
throw new Error(t("请选择线路", "Pick a lane"));
}
if (!activeLaneValidation.ok) {
throw new Error(activeLaneValidation.message);
}
const next = channels.map((row) =>
row.id === active.id
? {
...row,
name: activeLaneValidation.normalizedName,
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
urlLines: proUrlDraft,
}
: row,
);
applyChannelsLocal(next);
await syncChannelsRemote(next);
setLaneDrafts((prev) => ({
...prev,
[active.id]: {
name: activeLaneValidation.normalizedName,
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
},
}));
return {
next,
savedLane:
next.find((row) => row.id === active.id) ??
({
...active,
name: activeLaneValidation.normalizedName,
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
urlLines: proUrlDraft,
} satisfies YoutubeProChannel),
pm2: activeLaneValidation.normalizedPm2,
};
},
[
active,
activeLaneDraft,
activeLaneValidation,
applyChannelsLocal,
channels,
proUrlDraft,
syncChannelsRemote,
t,
],
);
const saveProLaneSettings = useCallback(async () => {
if (!active) return;
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
};
setLaneSaving(true);
setLaneStatus(null);
try {
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
setLaneStatus(t("线路设置已保存", "Lane settings saved"));
} catch (e) {
const message = (e as Error).message;
setLaneStatus(message);
notify(message);
} finally {
setLaneSaving(false);
}
}, [active, notify, persistActiveLaneDraft, setCurrentProc, t]);
const saveYoutubeIniToServer = async (content: string, processPm2 = currentProc) => {
const targetPm2 = processPm2.trim();
@@ -682,18 +917,24 @@ export function LiveYoutubeUnmannedView({
const keySuffixUi = useMemo(() => {
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
const k = active?.streamKey ?? "";
const k = activeLaneDraft?.streamKey ?? active?.streamKey ?? "";
return parseYoutubeStreamKeySuffix(`key = ${k}`);
}, [mode, ytFields.key, active?.streamKey]);
}, [mode, ytFields.key, active?.streamKey, activeLaneDraft?.streamKey]);
const saveProUrlAndServer = async () => {
if (!active) return;
updateActive({ urlLines: proUrlDraft });
try {
await Promise.resolve(onSaveUrlConfig(proUrlDraft, resolveChannelPm2(active)));
setLaneSaving(true);
setLaneStatus(null);
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
await Promise.resolve(onSaveUrlConfig(proUrlDraft, saved.pm2));
urlListDirtyRef.current = false;
setLaneStatus(t("线路与地址已保存", "Lane settings and URLs saved"));
} catch {
/* parent 已 toast */
} finally {
setLaneSaving(false);
}
};
@@ -725,7 +966,6 @@ export function LiveYoutubeUnmannedView({
if (mode === "single") setUrlConfig(next);
else {
setProUrlDraft(next);
if (active) updateActive({ urlLines: next });
}
urlListDirtyRef.current = true;
setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list"));
@@ -737,76 +977,102 @@ export function LiveYoutubeUnmannedView({
};
const pushProStreamKeyToServer = async () => {
if (!active) return;
if (!active || !activeLaneDraft) return;
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
const f: YoutubeIniFields = {
key: (active.streamKey ?? "").trim(),
key: activeLaneDraft.streamKey.trim(),
rtmps: base.rtmps,
bitrate: base.bitrate,
fastAudio: base.fastAudio,
};
const body = buildYoutubeIniFromFields(f);
await saveYoutubeIniToServer(body, resolveChannelPm2(active));
updateActive({ streamKey: f.key });
setLaneSaving(true);
setLaneStatus(null);
try {
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
await saveYoutubeIniToServer(body, saved.pm2);
setLaneStatus(t("线路设置与 youtube.ini 已保存", "Lane settings and youtube.ini saved"));
} catch (e) {
const message = (e as Error).message;
setLaneStatus(message);
notify(message);
} finally {
setLaneSaving(false);
}
};
const nonCommentUrlLines = useCallback(
(text: string) =>
text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#")),
[],
const currentUrlDraft = mode === "single" ? urlConfig : proUrlDraft;
const currentUrlCount = useMemo(() => nonCommentUrlLinesOf(currentUrlDraft).length, [currentUrlDraft]);
const currentDouyinCount = useMemo(() => extractDouyinLiveUrls(currentUrlDraft).length, [currentUrlDraft]);
const laneQuickLinks = useMemo(
() =>
[
quickLinks?.neko_browser,
quickLinks?.chromium_remote_debug,
quickLinks?.srs_api,
].filter((value): value is string => !!value),
[quickLinks],
);
const flushProChannelsBeforeRun = useCallback(
async (targetPm2: string) => {
let next = channels;
const next = channels;
const activePm2 = active ? resolveChannelPm2(active) : "";
if (active && targetPm2 === activePm2) {
next = channels.map((row) => (row.id === active.id ? { ...row, urlLines: proUrlDraft } : row));
const saved = await persistActiveLaneDraft();
return saved;
}
setChannels(next);
saveYoutubeProChannels(next);
await pushYoutubeProChannelsRemote(fetchJson, next);
return next;
applyChannelsLocal(next);
await syncChannelsRemote(next);
return {
next,
savedLane: active ?? null,
pm2: targetPm2,
};
},
[active, channels, fetchJson, proUrlDraft],
[active, applyChannelsLocal, channels, persistActiveLaneDraft, syncChannelsRemote],
);
const runProProcess = useCallback(
async (action: "start" | "stop" | "restart", targetPm2: string) => {
const targetMatchesActiveLane = !!active && resolveChannelPm2(active) === targetPm2;
if (action !== "stop") {
let next = channels;
let effectivePm2 = targetPm2;
try {
next = await flushProChannelsBeforeRun(targetPm2);
const saved = await flushProChannelsBeforeRun(targetPm2);
next = saved.next;
effectivePm2 = saved.pm2;
} catch (e) {
notify(
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
`${t("当前线路保存失败", "Failed to save the active lane")}: ${(e as Error).message}`,
);
return;
}
const target = next.find(
(row) => resolveChannelPm2(row) === targetPm2,
(row) => resolveChannelPm2(row) === effectivePm2,
);
const activePm2 = active ? resolveChannelPm2(active) : "";
const urlText = targetPm2 === activePm2 ? proUrlDraft : target?.urlLines ?? "";
const urlText = target?.urlLines ?? "";
if (!((target?.streamKey ?? "").trim())) {
notify(t("请先填写当前线路的推流密钥", "Fill the stream key before starting"));
return;
}
if (!nonCommentUrlLines(urlText).length) {
if (!nonCommentUrlLinesOf(urlText).length) {
notify(t("请先填写当前线路的直播地址", "Fill the source URLs before starting"));
return;
}
targetPm2 = effectivePm2;
}
if (targetMatchesActiveLane) {
setCurrentProc(targetPm2);
}
setCurrentProc(targetPm2);
onRunProcess(action, targetPm2);
},
[active, channels, flushProChannelsBeforeRun, nonCommentUrlLines, notify, onRunProcess, proUrlDraft, setCurrentProc, t],
[active, channels, flushProChannelsBeforeRun, notify, onRunProcess, setCurrentProc, t],
);
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing;
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing || laneSaving;
const singleRow = rowForPm2(currentProc);
const singleOnline = pm2StatusOnline(statusForPm2(currentProc));
@@ -869,7 +1135,7 @@ export function LiveYoutubeUnmannedView({
label: "Neko",
sub: t("Chromium 工作室", "Chromium"),
gradient: "from-emerald-400 to-teal-500",
openUrl: normalizeBrowserReachableHttpUrl("http://live.local:9200/"),
openUrl: normalizeBrowserReachableHttpUrl(quickLinks?.neko_browser || "http://live.local:9200/"),
},
]}
/>
@@ -920,6 +1186,41 @@ export function LiveYoutubeUnmannedView({
</div>
</div>
{laneQuickLinks.length ? (
<div className="flex flex-wrap gap-2">
{quickLinks?.neko_browser ? (
<a
href={normalizeBrowserReachableHttpUrl(quickLinks.neko_browser)}
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")}
</a>
) : null}
{quickLinks?.chromium_remote_debug ? (
<a
href={normalizeBrowserReachableHttpUrl(quickLinks.chromium_remote_debug)}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100 ring-1 ring-cyan-500/20"
>
{t("打开 Chromium 调试", "Open Chromium debug")}
</a>
) : null}
{quickLinks?.srs_api ? (
<a
href={normalizeBrowserReachableHttpUrl(quickLinks.srs_api)}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-violet-500/20 bg-violet-500/10 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/20"
>
{t("打开 SRS API", "Open SRS API")}
</a>
) : null}
</div>
) : null}
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
<div className="flex items-center gap-2 text-amber-200/90">
<Radio className="h-4 w-4" />
@@ -1001,7 +1302,9 @@ export function LiveYoutubeUnmannedView({
const pm = resolveChannelPm2(c);
const row = rowForPm2(pm);
const st = statusForPm2(pm);
const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0];
const laneUrls = nonCommentUrlLinesOf((c.urlLines ?? "").trim());
const douyinUrls = extractDouyinLiveUrls((c.urlLines ?? "").trim());
const dUrl = douyinUrls[0];
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
const rowOnline = pm2StatusOnline(st);
@@ -1035,6 +1338,14 @@ export function LiveYoutubeUnmannedView({
<span className="truncate" title={pm}>
{pm}
</span>
<span className="text-zinc-600">·</span>
<span>
URL {laneUrls.length}
</span>
<span className="text-zinc-600">·</span>
<span>
DY {douyinUrls.length}
</span>
</div>
{dUrl ? (
<a
@@ -1222,10 +1533,18 @@ export function LiveYoutubeUnmannedView({
{active ? (
<span className="ml-2 font-normal text-zinc-400">· {active.name}</span>
) : null}
{activeLaneDirty ? (
<span className="ml-2 rounded-md bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-100 ring-1 ring-amber-500/25">
{t("有未保存修改", "Unsaved")}
</span>
) : null}
</h3>
{active ? (
<p className="mt-0.5 font-mono text-[11px] text-zinc-500">
PM2: {(active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id)} · key {keySuffixUi || "—"}
PM2: {activeSavedPm2}
{activePm2PreviewChanged ? `${activeDraftPm2}` : ""}
{" · "}
key {keySuffixUi || "—"}
</p>
) : null}
</div>
@@ -1258,50 +1577,73 @@ export function LiveYoutubeUnmannedView({
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
value={active.name}
onChange={(e) => updateActive({ name: e.target.value })}
value={activeLaneDraft?.name ?? ""}
onChange={(e) => setLaneDraftField("name", e.target.value)}
placeholder={t("线路名称", "Lane name")}
/>
<label className="block text-xs text-zinc-500">{t("串流密钥", "Stream key")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
spellCheck={false}
value={active.streamKey ?? ""}
onChange={(e) => updateActive({ streamKey: e.target.value })}
value={activeLaneDraft?.streamKey ?? ""}
onChange={(e) => setLaneDraftField("streamKey", e.target.value)}
placeholder="xxxx-xxxx… / rtmp(s)://…"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void saveProLaneSettings()}
className="rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-100"
>
{laneSaving ? t("保存中...", "Saving...") : t("保存线路设置", "Save lane")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void pushProStreamKeyToServer()}
className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{t("保存", "Save")}
</button>
>
{t("写入 youtube.ini", "Write youtube.ini")}
</button>
</div>
{!activeLaneValidation.ok ? (
<p className="text-xs text-rose-300">{activeLaneValidation.message}</p>
) : null}
<details className="rounded-xl border border-white/5 bg-black/20 p-3">
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级:进程名", "Advanced: process name")}</summary>
<input
className="mt-2 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
onChange={(e) => updateActive({ pm2Name: e.target.value })}
value={activeLaneDraft?.pm2Name ?? defaultPm2NameForChannel(active.id)}
onChange={(e) => setLaneDraftField("pm2Name", e.target.value)}
spellCheck={false}
placeholder="youtube2__…"
/>
<p className="mt-2 font-mono text-[11px] text-zinc-500">
{t("实际生效", "Effective")}: {activeDraftPm2 || "—"}
</p>
{activeLaneValidation.message && !activeLaneValidation.ok ? (
<p className="mt-2 text-[11px] text-rose-300">{activeLaneValidation.message}</p>
) : null}
</details>
<textarea
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
value={active.notes ?? ""}
onChange={(e) => updateActive({ notes: e.target.value })}
value={activeLaneDraft?.notes ?? ""}
onChange={(e) => setLaneDraftField("notes", e.target.value)}
placeholder={t("备注(可选)", "Notes (optional)")}
/>
{laneStatus ? <p className="text-xs text-zinc-400">{laneStatus}</p> : null}
<button
type="button"
disabled={lock || channels.length < 2}
className="inline-flex items-center gap-1 text-xs text-rose-300 hover:text-rose-200 disabled:opacity-30"
onClick={() => {
const next = channels.filter((c) => c.id !== active.id);
setLaneDrafts((prev) => {
const clone = { ...prev };
delete clone[active.id];
return clone;
});
persistChannels(next);
setActiveCh(next[0]?.id || "");
}}
@@ -1326,6 +1668,14 @@ export function LiveYoutubeUnmannedView({
? managedUrlConfigRelPathForPm2(currentProc)
: "URL_config.ini"}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-cyan-100">
{t("有效地址", "Valid URLs")}: {currentUrlCount}
</span>
<span className="rounded-full border border-violet-500/20 bg-violet-500/10 px-2.5 py-1 text-violet-100">
Douyin: {currentDouyinCount}
</span>
</div>
{urlToolbar}
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea
@@ -1358,6 +1708,14 @@ export function LiveYoutubeUnmannedView({
<p className="mt-1 text-xs text-zinc-500">
{t("一行一个地址;可自动刷新或手动重新读取。", "One URL per line; auto-refresh or reload.")}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-cyan-100">
{t("有效地址", "Valid URLs")}: {currentUrlCount}
</span>
<span className="rounded-full border border-violet-500/20 bg-violet-500/10 px-2.5 py-1 text-violet-100">
Douyin: {currentDouyinCount}
</span>
</div>
{urlToolbar}
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea