627 lines
25 KiB
TypeScript
627 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import {
|
||
buildYoutubeIniFromFields,
|
||
extractDouyinLiveUrls,
|
||
parseYoutubeIniFields,
|
||
parseYoutubeStreamKeySuffix,
|
||
type YoutubeIniFields,
|
||
} from "@/lib/youtubeConfigUtils";
|
||
import {
|
||
defaultPm2NameForChannel,
|
||
loadYoutubeProChannels,
|
||
newChannelId,
|
||
saveYoutubeProChannels,
|
||
type YoutubeProChannel,
|
||
} from "@/lib/youtubeProChannels";
|
||
|
||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||
|
||
type TFn = (zh: string, en: string) => string;
|
||
|
||
function isYoutubeProcess(e: ProcessEntry) {
|
||
return /youtube/i.test(e.script) || /youtube/i.test(e.pm2);
|
||
}
|
||
|
||
const MODE_KEY = "live-hub-youtube-mode-v1";
|
||
|
||
type Props = {
|
||
t: TFn;
|
||
busy: boolean;
|
||
entries: ProcessEntry[];
|
||
currentProc: string;
|
||
setCurrentProc: (v: string) => void;
|
||
urlConfig: string;
|
||
setUrlConfig: (v: string) => void;
|
||
douyinUrl: string;
|
||
setDouyinUrl: (v: string) => void;
|
||
onRunProcess: (a: "start" | "stop" | "restart") => void;
|
||
onSaveUrlConfig: (content?: string) => void;
|
||
onSaveBusinessMeta: () => void;
|
||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||
};
|
||
|
||
export function LiveYoutubeUnmannedView({
|
||
t,
|
||
busy,
|
||
entries,
|
||
currentProc,
|
||
setCurrentProc,
|
||
urlConfig,
|
||
setUrlConfig,
|
||
douyinUrl,
|
||
setDouyinUrl,
|
||
onRunProcess,
|
||
onSaveUrlConfig,
|
||
onSaveBusinessMeta,
|
||
fetchJson,
|
||
}: Props) {
|
||
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
|
||
const processOptions = ytEntries.length ? ytEntries : entries;
|
||
|
||
const [mode, setMode] = useState<"single" | "pro">("single");
|
||
const [channels, setChannels] = useState<YoutubeProChannel[]>([]);
|
||
const [activeCh, setActiveCh] = useState<string>("");
|
||
const [youtubeIniText, setYoutubeIniText] = useState("");
|
||
const [ytFields, setYtFields] = useState<YoutubeIniFields>({
|
||
key: "",
|
||
rtmps: true,
|
||
bitrate: "",
|
||
fastAudio: false,
|
||
});
|
||
const [ytIniStatus, setYtIniStatus] = useState<string | null>(null);
|
||
const [ytIniLoading, setYtIniLoading] = useState(false);
|
||
const [proUrlDraft, setProUrlDraft] = useState("");
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const m = window.localStorage.getItem(MODE_KEY);
|
||
if (m === "pro" || m === "single") setMode(m);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, []);
|
||
|
||
const setModePersist = (m: "single" | "pro") => {
|
||
setMode(m);
|
||
if (m === "pro") {
|
||
const ch = channels.find((c) => c.id === activeCh) ?? channels[0];
|
||
if (ch) {
|
||
const u = (ch.urlLines ?? "").trim();
|
||
setProUrlDraft(u || urlConfig);
|
||
}
|
||
}
|
||
try {
|
||
window.localStorage.setItem(MODE_KEY, m);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const list = loadYoutubeProChannels();
|
||
const initial =
|
||
list.length > 0
|
||
? list
|
||
: (() => {
|
||
const id = newChannelId();
|
||
return [
|
||
{
|
||
id,
|
||
name: "线路 1",
|
||
streamKey: "",
|
||
urlLines: "",
|
||
pm2Name: defaultPm2NameForChannel(id),
|
||
notes: "",
|
||
},
|
||
];
|
||
})();
|
||
setChannels(initial);
|
||
if (!list.length) saveYoutubeProChannels(initial);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (channels.length && !activeCh) setActiveCh(channels[0].id);
|
||
}, [channels, activeCh]);
|
||
|
||
const persistChannels = useCallback((next: YoutubeProChannel[]) => {
|
||
setChannels(next);
|
||
saveYoutubeProChannels(next);
|
||
}, []);
|
||
|
||
const active = channels.find((c) => c.id === activeCh) || channels[0];
|
||
|
||
const loadYoutubeIni = useCallback(async () => {
|
||
if (!currentProc) return;
|
||
setYtIniLoading(true);
|
||
setYtIniStatus(null);
|
||
try {
|
||
const data = await fetchJson<{ content?: string }>(
|
||
`/get_config?process=${encodeURIComponent(currentProc)}`,
|
||
);
|
||
const text = data.content ?? "";
|
||
setYoutubeIniText(text);
|
||
setYtFields(parseYoutubeIniFields(text));
|
||
} catch (e) {
|
||
setYtIniStatus((e as Error).message);
|
||
} finally {
|
||
setYtIniLoading(false);
|
||
}
|
||
}, [currentProc, fetchJson]);
|
||
|
||
useEffect(() => {
|
||
if (!currentProc) return;
|
||
void loadYoutubeIni();
|
||
}, [currentProc, loadYoutubeIni]);
|
||
|
||
const updateActive = (patch: Partial<YoutubeProChannel>) => {
|
||
if (!active) return;
|
||
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
|
||
};
|
||
|
||
const saveYoutubeIniToServer = async (content: string) => {
|
||
if (!currentProc) {
|
||
setYtIniStatus(t("请选择进程", "Pick a process"));
|
||
return;
|
||
}
|
||
setYtIniLoading(true);
|
||
setYtIniStatus(null);
|
||
try {
|
||
await fetchJson("/save_config", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
setYoutubeIniText(content);
|
||
setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved"));
|
||
} catch (e) {
|
||
setYtIniStatus((e as Error).message);
|
||
} finally {
|
||
setYtIniLoading(false);
|
||
}
|
||
};
|
||
|
||
const applyFieldsAndSave = async () => {
|
||
const body = buildYoutubeIniFromFields(ytFields);
|
||
await saveYoutubeIniToServer(body);
|
||
};
|
||
|
||
const syncFieldsFromText = () => {
|
||
setYtFields(parseYoutubeIniFields(youtubeIniText));
|
||
setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw"));
|
||
};
|
||
|
||
const keySuffixUi = useMemo(() => {
|
||
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
|
||
const k = active?.streamKey ?? "";
|
||
return parseYoutubeStreamKeySuffix(`key = ${k}`);
|
||
}, [mode, ytFields.key, active?.streamKey]);
|
||
|
||
const urlTextForPreview = mode === "pro" ? proUrlDraft : urlConfig;
|
||
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlTextForPreview), [urlTextForPreview]);
|
||
|
||
const saveProUrlAndServer = async () => {
|
||
if (!active) return;
|
||
updateActive({ urlLines: proUrlDraft });
|
||
setUrlConfig(proUrlDraft);
|
||
onSaveUrlConfig(proUrlDraft);
|
||
};
|
||
|
||
const pushProStreamKeyToServer = async () => {
|
||
if (!active) return;
|
||
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
|
||
const f: YoutubeIniFields = {
|
||
key: (active.streamKey ?? "").trim(),
|
||
rtmps: base.rtmps,
|
||
bitrate: base.bitrate,
|
||
fastAudio: base.fastAudio,
|
||
};
|
||
const body = buildYoutubeIniFromFields(f);
|
||
await saveYoutubeIniToServer(body);
|
||
updateActive({ streamKey: f.key });
|
||
};
|
||
|
||
const lock = busy || ytIniLoading;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-4xl space-y-6">
|
||
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/80 to-cyan-500/5 p-6 shadow-xl shadow-violet-900/10">
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-red-500/30 to-violet-600/30 ring-1 ring-white/10">
|
||
<MonitorPlay className="h-5 w-5 text-red-200" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight text-white">
|
||
{t("YouTube 无人直播", "YouTube unmanned live")}
|
||
</h2>
|
||
<p className="mt-0.5 text-xs text-zinc-500">
|
||
{t(
|
||
"对齐桌面客户端「直播」页:单路配置与多路 Pro 编排,无账号登录。",
|
||
"Desktop-style live control: single or Pro lanes, no account login.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex rounded-xl border border-white/10 bg-black/30 p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setModePersist("single")}
|
||
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
|
||
mode === "single" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
{t("单频道", "Single")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setModePersist("pro")}
|
||
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
|
||
mode === "pro" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
{t("多频道 Pro", "Multi Pro")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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" />
|
||
<h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3>
|
||
</div>
|
||
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||
{t("进程(PM2)", "PM2 process")}
|
||
</label>
|
||
<select
|
||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||
value={currentProc}
|
||
onChange={(e) => setCurrentProc(e.target.value)}
|
||
>
|
||
{processOptions.map((e) => (
|
||
<option key={e.pm2} value={e.pm2}>
|
||
{e.label} ({e.pm2})
|
||
</option>
|
||
))}
|
||
</select>
|
||
{mode === "pro" && active ? (
|
||
<p className="mt-2 font-mono text-[11px] text-cyan-300/80">
|
||
Pro {t("建议独立进程名", "suggested")}: {active.pm2Name || defaultPm2NameForChannel(active.id)}
|
||
</p>
|
||
) : null}
|
||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{(
|
||
[
|
||
["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"],
|
||
["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"],
|
||
["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"],
|
||
] as const
|
||
).map(([a, label, cls]) => (
|
||
<button
|
||
key={a}
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => onRunProcess(a)}
|
||
className={`rounded-xl px-4 py-3 text-sm font-medium ring-1 ${cls}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
|
||
key {t("尾码(参考)", "suffix")}: {keySuffixUi || "—"}
|
||
</p>
|
||
</div>
|
||
|
||
{mode === "single" ? (
|
||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||
<h3 className="text-sm font-semibold text-white">{t("YouTube 相关设置", "YouTube settings")}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t("编辑 config/youtube.ini(与桌面端一致字段)。不使用 Google 登录。", "Edits config/youtube.ini. No Google OAuth.")}
|
||
</p>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
<label className="block text-xs text-zinc-400">{t("串流密钥", "Stream key")}</label>
|
||
<input
|
||
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm font-mono text-zinc-200"
|
||
spellCheck={false}
|
||
autoComplete="off"
|
||
value={ytFields.key}
|
||
onChange={(e) => setYtFields((f) => ({ ...f, key: e.target.value }))}
|
||
placeholder="xxxx-xxxx-xxxx-xxxx-xxxx"
|
||
/>
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<label className="flex flex-col gap-1 text-xs text-zinc-400">
|
||
RTMPS
|
||
<select
|
||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
|
||
value={ytFields.rtmps ? "1" : "0"}
|
||
onChange={(e) => setYtFields((f) => ({ ...f, rtmps: e.target.value === "1" }))}
|
||
>
|
||
<option value="1">{t("是(默认)", "Yes")}</option>
|
||
<option value="0">{t("否 → RTMP", "No")}</option>
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1 text-xs text-zinc-400">
|
||
{t("比特率 kbps", "Bitrate kbps")}
|
||
<input
|
||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
|
||
value={ytFields.bitrate}
|
||
onChange={(e) => setYtFields((f) => ({ ...f, bitrate: e.target.value }))}
|
||
placeholder="4000"
|
||
/>
|
||
</label>
|
||
<label className="flex cursor-pointer items-center gap-2 pt-6 text-xs text-zinc-300">
|
||
<input
|
||
type="checkbox"
|
||
className="rounded border-white/20 bg-black/40"
|
||
checked={ytFields.fastAudio}
|
||
onChange={(e) => setYtFields((f) => ({ ...f, fastAudio: e.target.checked }))}
|
||
/>
|
||
fast_audio({t("轻量音频", "lighter audio")})
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void loadYoutubeIni()}
|
||
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-200"
|
||
>
|
||
<RefreshCw className={`h-3.5 w-3.5 ${ytIniLoading ? "animate-spin" : ""}`} />
|
||
{t("重新读取", "Reload")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void applyFieldsAndSave()}
|
||
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
|
||
>
|
||
{ytIniLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
{t("保存 youtube.ini", "Save youtube.ini")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={syncFieldsFromText}
|
||
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-400"
|
||
>
|
||
{t("从原文同步表单", "Parse raw")}
|
||
</button>
|
||
</div>
|
||
|
||
<details className="mt-4 rounded-xl border border-white/5 bg-black/20 p-3">
|
||
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级:youtube.ini 原文", "Raw youtube.ini")}</summary>
|
||
<textarea
|
||
className="mt-2 h-36 w-full resize-y rounded-lg border border-white/10 bg-black/50 p-3 font-mono text-[11px] text-zinc-400"
|
||
value={youtubeIniText}
|
||
onChange={(e) => setYoutubeIniText(e.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
className="mt-2 rounded-lg bg-cyan-500/15 px-3 py-1.5 text-xs text-cyan-200"
|
||
onClick={() => void saveYoutubeIniToServer(youtubeIniText)}
|
||
>
|
||
{t("保存原文", "Save raw")}
|
||
</button>
|
||
</details>
|
||
|
||
{ytIniStatus ? <p className="mt-2 font-mono text-xs text-zinc-500">{ytIniStatus}</p> : null}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<h3 className="text-sm font-semibold text-white">{t("Pro 线路列表", "Pro channels")}</h3>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
className="inline-flex items-center gap-1 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs text-violet-100 ring-1 ring-violet-500/30"
|
||
onClick={() => {
|
||
const id = newChannelId();
|
||
const nc: YoutubeProChannel = {
|
||
id,
|
||
name: `${t("线路", "Ch")} ${channels.length + 1}`,
|
||
streamKey: "",
|
||
urlLines: "",
|
||
pm2Name: defaultPm2NameForChannel(id),
|
||
notes: "",
|
||
};
|
||
persistChannels([...channels, nc]);
|
||
setActiveCh(id);
|
||
setProUrlDraft("");
|
||
}}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
{t("新增线路", "Add")}
|
||
</button>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{channels.map((c) => (
|
||
<button
|
||
key={c.id}
|
||
type="button"
|
||
onClick={() => {
|
||
if (active && mode === "pro") {
|
||
persistChannels(
|
||
channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)),
|
||
);
|
||
}
|
||
const u = (c.urlLines ?? "").trim();
|
||
setProUrlDraft(u || urlConfig);
|
||
setActiveCh(c.id);
|
||
}}
|
||
className={`rounded-xl border px-3 py-2 text-left text-xs ${
|
||
activeCh === c.id ? "border-violet-500/50 bg-violet-500/10 text-white" : "border-white/10 bg-black/30 text-zinc-400"
|
||
}`}
|
||
>
|
||
<div className="font-medium text-zinc-200">{c.name}</div>
|
||
<div className="font-mono text-[10px] text-zinc-600">{c.pm2Name || defaultPm2NameForChannel(c.id)}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{active ? (
|
||
<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 })}
|
||
placeholder={t("线路显示名", "Channel name")}
|
||
/>
|
||
<input
|
||
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
|
||
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
|
||
onChange={(e) => updateActive({ pm2Name: e.target.value })}
|
||
placeholder="youtube2__…"
|
||
/>
|
||
<label className="block text-xs text-zinc-500">{t("串流密钥(本线路)", "Stream key for this lane")}</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 })}
|
||
placeholder="key / rtmp(s) URL"
|
||
/>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void pushProStreamKeyToServer()}
|
||
className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
|
||
>
|
||
{t("将本线路密钥写入服务器 youtube.ini", "Push key → server youtube.ini")}
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-amber-200/70">
|
||
{t(
|
||
"服务器仅一份 youtube.ini:推送会覆盖;多路并行请在板上为每路配置独立 PM2 与独立 config 目录,或轮流推送。",
|
||
"Server has one youtube.ini; overwrite. For true parallel lanes use separate PM2 + config dirs on the host.",
|
||
)}
|
||
</p>
|
||
<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 })}
|
||
placeholder={t("备注", "Notes")}
|
||
/>
|
||
<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);
|
||
persistChannels(next);
|
||
setActiveCh(next[0]?.id || "");
|
||
}}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
{t("删除线路", "Remove")}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||
<h3 className="text-sm font-semibold text-white">{t("当前线路 · 直播地址列表", "URL list (this lane)")}</h3>
|
||
<textarea
|
||
className="mt-4 min-h-[160px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||
value={proUrlDraft}
|
||
onChange={(e) => setProUrlDraft(e.target.value)}
|
||
spellCheck={false}
|
||
placeholder="https://live.douyin.com/..."
|
||
/>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => {
|
||
setProUrlDraft(urlConfig);
|
||
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
|
||
}}
|
||
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
|
||
>
|
||
{t("与全局 URL 同步", "Sync from global")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void saveProUrlAndServer()}
|
||
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
|
||
>
|
||
{t("保存到服务器并记入本线路", "Save to server + lane")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (metadata)")}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON,供运维脚本读取。", "business.json for automation.")}</p>
|
||
<input
|
||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||
value={douyinUrl}
|
||
onChange={(e) => setDouyinUrl(e.target.value)}
|
||
placeholder="https://live.douyin.com/..."
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void onSaveBusinessMeta()}
|
||
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
|
||
>
|
||
{t("保存到配置中心", "Save to hub")}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{mode === "pro"
|
||
? t("单频道模式在此编辑全局文件;Pro 线路请用上方「当前线路」块保存。", "Pro lanes use the lane editor above.")
|
||
: t("一行一个直播间地址。", "One URL per line.")}
|
||
</p>
|
||
{mode === "single" ? (
|
||
<>
|
||
<textarea
|
||
className="mt-4 min-h-[200px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||
value={urlConfig}
|
||
onChange={(e) => setUrlConfig(e.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void onSaveUrlConfig()}
|
||
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
|
||
>
|
||
{t("保存 URL 配置", "Save URL config")}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
|
||
)}
|
||
{douyinUrls.length > 0 ? (
|
||
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
|
||
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
|
||
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
|
||
{douyinUrls.map((u) => (
|
||
<li key={u}>{u}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|