mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #35 from ZHANGTIANYAO1/claude/start-music-api-servers-ZIruv
Add shareable bot links with public URL configuration
This commit is contained in:
4 files changed
+206
-26
No files matched your search
+11
-1
@@ -13,7 +13,15 @@ export interface BotConfig {
|
|||||||
adminGroups: number[];
|
adminGroups: number[];
|
||||||
autoReturnDelay: number;
|
autoReturnDelay: number;
|
||||||
autoPauseOnEmpty: boolean;
|
autoPauseOnEmpty: boolean;
|
||||||
idleTimeoutMinutes: number;
|
idleTimeoutMinutes: number;
|
||||||
|
// Public base URL used when generating share links (e.g. the bot专属链接).
|
||||||
|
// Leave empty to use the browser's current origin. Example:
|
||||||
|
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||||
|
publicUrl: string;
|
||||||
|
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
|
||||||
|
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
|
||||||
|
// behind HTTPS-terminating proxies.
|
||||||
|
trustProxy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultConfig(): BotConfig {
|
export function getDefaultConfig(): BotConfig {
|
||||||
@@ -30,6 +38,8 @@ export function getDefaultConfig(): BotConfig {
|
|||||||
autoReturnDelay: 300,
|
autoReturnDelay: 300,
|
||||||
autoPauseOnEmpty: true,
|
autoPauseOnEmpty: true,
|
||||||
idleTimeoutMinutes: 0,
|
idleTimeoutMinutes: 0,
|
||||||
|
publicUrl: "",
|
||||||
|
trustProxy: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -79,7 +79,10 @@ async function main() {
|
|||||||
await webServer.start();
|
await webServer.start();
|
||||||
|
|
||||||
logger.info({ webPort: config.webPort }, "TSMusicBot started");
|
logger.info({ webPort: config.webPort }, "TSMusicBot started");
|
||||||
logger.info(`WebUI: http://localhost:${config.webPort}`);
|
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
|
||||||
|
logger.info(
|
||||||
|
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
|
||||||
|
);
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
logger.info("Shutting down...");
|
logger.info("Shutting down...");
|
||||||
|
|||||||
@@ -38,8 +38,18 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const logger = options.logger.child({ component: "web" });
|
const logger = options.logger.child({ component: "web" });
|
||||||
|
|
||||||
|
if (options.config.trustProxy) {
|
||||||
|
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
|
||||||
|
app.set("trust proxy", true);
|
||||||
|
}
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/api/config/public-url", (_req, res) => {
|
||||||
|
const raw = (options.config.publicUrl ?? "").trim();
|
||||||
|
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
||||||
|
});
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
"/api/bot",
|
"/api/bot",
|
||||||
createBotRouter(options.botManager, options.config, options.configPath, logger)
|
createBotRouter(options.botManager, options.config, options.configPath, logger)
|
||||||
|
|||||||
+181
-24
@@ -59,10 +59,30 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="linkDialog.open" class="link-dialog-backdrop" @click="closeLinkDialog">
|
||||||
|
<div class="link-dialog" @click.stop>
|
||||||
|
<div class="link-dialog-title">{{ linkDialog.name }} 的专属链接</div>
|
||||||
|
<div class="link-dialog-hint">选中文本并按 Ctrl/Cmd+C 复制,或点击下方按钮</div>
|
||||||
|
<input
|
||||||
|
ref="linkInputRef"
|
||||||
|
class="link-dialog-input"
|
||||||
|
:value="linkDialog.url"
|
||||||
|
readonly
|
||||||
|
@focus="($event.target as HTMLInputElement).select()"
|
||||||
|
/>
|
||||||
|
<div class="link-dialog-actions">
|
||||||
|
<button class="link-dialog-btn primary" @click="copyLinkFromDialog">
|
||||||
|
{{ linkDialog.copied ? '已复制' : '复制链接' }}
|
||||||
|
</button>
|
||||||
|
<button class="link-dialog-btn" @click="closeLinkDialog">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
@@ -71,38 +91,91 @@ const activeBot = computed(() => store.activeBot);
|
|||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const selectorRef = ref<HTMLElement | null>(null);
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
const togglingBots = ref<Record<string, boolean>>({});
|
const togglingBots = ref<Record<string, boolean>>({});
|
||||||
|
const linkInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const publicBaseUrl = ref<string | null>(null);
|
||||||
|
|
||||||
|
const linkDialog = reactive({
|
||||||
|
open: false,
|
||||||
|
url: '',
|
||||||
|
name: '',
|
||||||
|
copied: false,
|
||||||
|
});
|
||||||
|
|
||||||
function selectBot(id: string) {
|
function selectBot(id: string) {
|
||||||
store.setActiveBotId(id);
|
store.setActiveBotId(id);
|
||||||
dropdownOpen.value = false;
|
dropdownOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyBotLink(id: string) {
|
function resolveBaseUrl(): string {
|
||||||
const url = `${window.location.origin}/bot/${id}`;
|
const base = publicBaseUrl.value;
|
||||||
// navigator.clipboard requires a secure context (HTTPS or localhost).
|
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||||
// Fall back to a hidden textarea + execCommand('copy') for plain HTTP
|
return window.location.origin;
|
||||||
// access (e.g. when the bot is hosted on a remote IP).
|
}
|
||||||
|
|
||||||
|
async function tryClipboardWrite(text: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(text);
|
||||||
} else {
|
return true;
|
||||||
const ta = document.createElement('textarea');
|
|
||||||
ta.value = url;
|
|
||||||
ta.style.position = 'fixed';
|
|
||||||
ta.style.opacity = '0';
|
|
||||||
ta.style.pointerEvents = 'none';
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
const ok = document.execCommand('copy');
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
if (!ok) throw new Error('execCommand copy returned false');
|
|
||||||
}
|
}
|
||||||
dropdownOpen.value = false;
|
} catch {
|
||||||
} catch (err) {
|
// fall through
|
||||||
// Last-resort: prompt the user with the URL so they can copy manually
|
}
|
||||||
console.error('Failed to copy bot link', err);
|
try {
|
||||||
window.prompt('复制以下链接:', url);
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
ta.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyBotLink(id: string) {
|
||||||
|
const bot = store.bots.find((b) => b.id === id);
|
||||||
|
const url = `${resolveBaseUrl()}/bot/${id}`;
|
||||||
|
linkDialog.url = url;
|
||||||
|
linkDialog.name = bot?.name ?? '机器人';
|
||||||
|
linkDialog.copied = false;
|
||||||
|
linkDialog.open = true;
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
// Try to copy silently; user can still manually select if it fails.
|
||||||
|
const ok = await tryClipboardWrite(url);
|
||||||
|
if (ok) linkDialog.copied = true;
|
||||||
|
await nextTick();
|
||||||
|
linkInputRef.value?.focus();
|
||||||
|
linkInputRef.value?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLinkFromDialog() {
|
||||||
|
const ok = await tryClipboardWrite(linkDialog.url);
|
||||||
|
if (ok) {
|
||||||
|
linkDialog.copied = true;
|
||||||
|
} else {
|
||||||
|
linkInputRef.value?.focus();
|
||||||
|
linkInputRef.value?.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkDialog() {
|
||||||
|
linkDialog.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicBaseUrl() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config/public-url');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { publicUrl?: string | null };
|
||||||
|
if (data.publicUrl) publicBaseUrl.value = data.publicUrl;
|
||||||
|
} catch {
|
||||||
|
// ignore — fall back to window.location.origin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +203,7 @@ function onClickOutside(e: MouseEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onClickOutside);
|
document.addEventListener('click', onClickOutside);
|
||||||
|
loadPublicBaseUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -398,4 +472,87 @@ onUnmounted(() => {
|
|||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
&:hover { opacity: 1; }
|
&:hover { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: inherit;
|
||||||
|
user-select: all;
|
||||||
|
-webkit-user-select: all;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user