Files
teamspeak-music-bot/web/src/views/Settings.vue
T
saopig1andClaude Opus 4.7 6b60792277 feat(web): custom avatar field in edit-bot modal
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:23:21 +08:00

1471 lines
41 KiB
Vue
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="settings-page">
<button class="back-btn" @click="$router.back()">
<Icon icon="mdi:arrow-left" />
返回
</button>
<h1 class="page-title">设置</h1>
<!-- Theme Toggle -->
<section class="settings-section">
<h2 class="section-title">外观</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:theme-light-dark" class="setting-icon" />
主题模式
</div>
<button class="theme-toggle" @click="store.toggleTheme()">
<Icon :icon="store.theme === 'dark' ? 'mdi:weather-night' : 'mdi:weather-sunny'" />
{{ store.theme === 'dark' ? '深色' : '浅色' }}
</button>
</div>
</section>
<!-- Bot Management -->
<section class="settings-section">
<h2 class="section-title">机器人管理</h2>
<div class="bot-list">
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
<div class="bot-info">
<div class="bot-name">{{ bot.name }}</div>
<div class="bot-status" :class="botStatusClass(bot)">
{{ botStatusText(bot) }}
</div>
</div>
<div class="bot-actions">
<button class="btn-sm" @click="toggleBot(bot.id, bot.connected)">
{{ bot.connected ? '停止' : '启动' }}
</button>
<button class="btn-sm btn-edit" @click="openEditBot(bot)">
<Icon icon="mdi:pencil" />
</button>
<button class="btn-sm btn-delete" @click="deleteBot(bot.id, bot.name)">
<Icon icon="mdi:delete" />
</button>
</div>
</div>
</div>
<!-- Edit Bot Modal -->
<div v-if="editingBot" class="edit-modal-overlay" @click.self="editingBot = null">
<div class="edit-modal">
<h3 class="modal-title">编辑机器人</h3>
<div class="form-group">
<label>名称</label>
<input v-model="editForm.name" class="input" />
</div>
<div class="form-group">
<label>服务器地址</label>
<input v-model="editForm.serverAddress" class="input" placeholder="ts.example.com" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>端口</label>
<input v-model.number="editForm.serverPort" type="number" class="input" />
</div>
<div class="form-group" style="flex:2">
<label>昵称</label>
<input v-model="editForm.nickname" class="input" />
</div>
</div>
<div class="form-group">
<label>默认频道(可选)</label>
<input v-model="editForm.defaultChannel" class="input" placeholder="音乐频道" />
</div>
<div class="form-group">
<label>频道密码(可选)</label>
<input v-model="editForm.channelPassword" class="input" type="password" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="form-group">
<label>自定义头像</label>
<CustomAvatarRow :bot-id="editingBot" />
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="editingBot = null">取消</button>
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
</div>
</div>
</div>
<!-- Create Bot -->
<div class="create-bot">
<h3 class="subsection-title">创建新实例</h3>
<div class="form-group">
<label>名称</label>
<input v-model="newBotName" class="input" placeholder="我的音乐机器人" />
</div>
<div class="form-row">
<div class="form-group" style="flex:2">
<label>服务器地址</label>
<input v-model="newBotServer" class="input" placeholder="localhost 或 ts.example.com" />
</div>
<div class="form-group" style="flex:1">
<label>端口</label>
<input v-model.number="newBotPort" type="number" class="input" placeholder="9987" />
</div>
</div>
<div class="form-group">
<label>昵称</label>
<input v-model="newBotNickname" class="input" placeholder="MusicBot" />
</div>
<div class="form-group">
<label>默认频道(可选)</label>
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="form-group">
<label>自定义头像(可选)</label>
<AvatarUpload v-model="newBotAvatar" />
</div>
<button class="btn-primary" @click="createBot">创建</button>
</div>
</section>
<!-- Music Account - QR Code Login -->
<section class="settings-section">
<h2 class="section-title">音乐账号</h2>
<!-- NetEase -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:cloud-outline" class="account-icon" />
<div class="account-info">
<div class="account-name">网易云音乐</div>
<div class="account-status" :class="{ logged: neteaseAuth.loggedIn }">
{{ neteaseAuth.loggedIn ? `已登录: ${neteaseAuth.nickname}` : '未登录' }}
</div>
</div>
</div>
<div class="login-methods">
<button
class="login-btn"
:class="{ active: neteaseLoginMode === 'qr' }"
@click="startQrLogin('netease')"
:disabled="neteaseQr.loading"
>
<Icon icon="mdi:qrcode" />
扫码登录
</button>
<button
class="login-btn"
:class="{ active: neteaseLoginMode === 'cookie' }"
@click="neteaseLoginMode = 'cookie'"
>
<Icon icon="mdi:cookie" />
Cookie登录
</button>
</div>
<!-- QR Code -->
<div v-if="neteaseLoginMode === 'qr'" class="qr-section">
<div v-if="neteaseQr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="neteaseQr.dataUrl" class="qr-wrap">
<img :src="neteaseQr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="neteaseQr.status">
<template v-if="neteaseQr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用网易云音乐APP扫码
</template>
<template v-else-if="neteaseQr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="neteaseQr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 登录成功!
</template>
<template v-else-if="neteaseQr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin('netease')">重新生成</button>
</template>
</div>
</div>
</div>
<!-- Cookie -->
<div v-if="neteaseLoginMode === 'cookie'" class="cookie-section">
<textarea
v-model="neteaseCookie"
class="textarea"
placeholder="粘贴网易云音乐Cookie..."
rows="3"
/>
<button class="btn-primary btn-save" @click="saveCookie('netease')">保存Cookie</button>
</div>
</div>
<!-- QQ Music -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:music-circle-outline" class="account-icon" />
<div class="account-info">
<div class="account-name">QQ音乐</div>
<div class="account-status" :class="{ logged: qqAuth.loggedIn }">
{{ qqAuth.loggedIn ? `已登录: ${qqAuth.nickname}` : '未登录' }}
</div>
</div>
</div>
<div class="login-methods">
<button
class="login-btn"
:class="{ active: qqLoginMode === 'qr' }"
@click="startQrLogin('qq')"
:disabled="qqQr.loading"
>
<Icon icon="mdi:qrcode" />
扫码登录
</button>
<button
class="login-btn"
:class="{ active: qqLoginMode === 'cookie' }"
@click="qqLoginMode = 'cookie'"
>
<Icon icon="mdi:cookie" />
Cookie登录
</button>
</div>
<!-- QR Code -->
<div v-if="qqLoginMode === 'qr'" class="qr-section">
<div v-if="qqQr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="qqQr.dataUrl" class="qr-wrap">
<img :src="qqQr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="qqQr.status">
<template v-if="qqQr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用QQ音乐APP扫码
</template>
<template v-else-if="qqQr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="qqQr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 登录成功!
</template>
<template v-else-if="qqQr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin('qq')">重新生成</button>
</template>
</div>
</div>
</div>
<!-- Cookie -->
<div v-if="qqLoginMode === 'cookie'" class="cookie-section">
<textarea
v-model="qqCookie"
class="textarea"
placeholder="粘贴QQ音乐Cookie..."
rows="3"
/>
<button class="btn-primary btn-save" @click="saveCookie('qq')">保存Cookie</button>
</div>
</div>
<!-- BiliBili -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:video-outline" class="account-icon bilibili-icon" />
<div class="account-info">
<div class="account-name">哔哩哔哩</div>
<div class="account-status" :class="{ logged: bilibiliAuth.loggedIn }">
{{ bilibiliAuth.loggedIn ? `已登录: ${bilibiliAuth.nickname}` : '未登录' }}
</div>
</div>
</div>
<div class="login-methods">
<button
class="login-btn"
:class="{ active: bilibiliLoginMode === 'qr' }"
@click="startQrLogin('bilibili')"
:disabled="bilibiliQr.loading"
>
<Icon icon="mdi:qrcode" />
扫码登录
</button>
<button
class="login-btn"
:class="{ active: bilibiliLoginMode === 'cookie' }"
@click="bilibiliLoginMode = 'cookie'"
>
<Icon icon="mdi:cookie" />
Cookie登录
</button>
</div>
<!-- QR Code -->
<div v-if="bilibiliLoginMode === 'qr'" class="qr-section">
<div v-if="bilibiliQr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="bilibiliQr.dataUrl" class="qr-wrap">
<img :src="bilibiliQr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="bilibiliQr.status">
<template v-if="bilibiliQr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用哔哩哔哩APP扫码
</template>
<template v-else-if="bilibiliQr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="bilibiliQr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 登录成功!
</template>
<template v-else-if="bilibiliQr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin('bilibili')">重新生成</button>
</template>
</div>
</div>
</div>
<!-- Cookie -->
<div v-if="bilibiliLoginMode === 'cookie'" class="cookie-section">
<textarea
v-model="bilibiliCookie"
class="textarea"
placeholder="粘贴哔哩哔哩Cookie..."
rows="3"
/>
<button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button>
</div>
</div>
</section>
<!-- Audio Quality -->
<section class="settings-section">
<h2 class="section-title">音质设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:music-note-eighth" class="setting-icon" />
音源质量
</div>
<div class="quality-options">
<button
v-for="q in qualityLevels"
:key="q.value"
class="quality-btn"
:class="{ active: currentQuality === q.value }"
@click="setQuality(q.value)"
>
<div class="quality-name">{{ q.label }}</div>
<div class="quality-desc">{{ q.desc }}</div>
</button>
</div>
</div>
</section>
<!-- Command Prefix -->
<section class="settings-section">
<h2 class="section-title">命令设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:console" class="setting-icon" />
命令前缀
</div>
<div class="prefix-input-wrap">
<input v-model="commandPrefix" class="input input-sm" placeholder="!" />
<button class="btn-primary" @click="savePrefix">保存</button>
</div>
</div>
</section>
<!-- Idle Timeout -->
<section class="settings-section">
<h2 class="section-title">行为设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
<div>
<div>闲置自动退出</div>
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
</div>
</div>
<div class="prefix-input-wrap">
<input
v-model.number="idleTimeout"
type="number"
min="0"
class="input input-sm"
style="max-width:80px"
placeholder="0"
/>
<span style="font-size:13px; opacity:0.7">分钟</span>
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
</div>
</div>
</section>
<!-- Bot Profile (TeamSpeak Behavior) -->
<section class="settings-section">
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
<div v-else class="profile-bot-list">
<div v-for="bot in store.bots" :key="bot.id" class="profile-bot">
<button
class="profile-bot-header"
:class="{ expanded: profileExpanded[bot.id] }"
@click="toggleProfileExpanded(bot.id)"
>
<Icon :icon="profileExpanded[bot.id] ? 'mdi:chevron-down' : 'mdi:chevron-right'" />
<span class="profile-bot-name">{{ bot.name }}</span>
</button>
<div v-if="profileExpanded[bot.id]" class="profile-toggles">
<div v-if="profileLoadError[bot.id]" class="profile-loading profile-error">
{{ profileLoadError[bot.id] }}
<button class="btn-link" @click="loadProfileConfig(bot.id)">重试</button>
</div>
<div v-else-if="!profileConfigs[bot.id]" class="profile-loading">加载中...</div>
<label
v-else
v-for="t in PROFILE_TOGGLES"
:key="t.key"
class="profile-toggle"
>
<div class="profile-toggle-text">
<div class="profile-toggle-label">
{{ t.label }}
<span v-if="t.warning" class="profile-warn-tag">⚠️ {{ t.warning }}</span>
</div>
<div class="profile-toggle-hint">{{ t.hint }}</div>
</div>
<input
type="checkbox"
class="profile-toggle-switch"
:checked="profileConfigs[bot.id][t.key]"
@change="updateProfile(bot.id, t.key, ($event.target as HTMLInputElement).checked)"
/>
</label>
<div v-if="profileConfigs[bot.id]" class="profile-toggle profile-toggle-static">
<div class="profile-toggle-text">
<div class="profile-toggle-label">自定义头像</div>
<div class="profile-toggle-hint">无论封面同步是否开启,停播时都会回到这张图</div>
</div>
<CustomAvatarRow :bot-id="bot.id" />
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import AvatarUpload from '../components/AvatarUpload.vue';
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
import QRCode from 'qrcode';
import { usePlayerStore } from '../stores/player.js';
const store = usePlayerStore();
function botStatusClass(bot: any) {
if (!bot.connected) return 'offline';
if (bot.playing) return 'playing';
if (bot.paused) return 'paused';
return 'online';
}
function botStatusText(bot: any) {
if (!bot.connected) return '离线';
if (bot.playing) return '播放中';
if (bot.paused) return '已暂停';
return '在线';
}
const newBotName = ref('');
const newBotServer = ref('');
const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot');
const newBotChannel = ref('');
const newBotServerPassword = ref('');
const newBotAvatar = ref<string | null>(null);
// Edit bot
const editingBot = ref<string | null>(null);
const editForm = reactive({
name: '',
serverAddress: '',
serverPort: 9987,
nickname: '',
defaultChannel: '',
channelPassword: '',
serverPassword: '',
});
const neteaseCookie = ref('');
const qqCookie = ref('');
const bilibiliCookie = ref('');
const commandPrefix = ref('!');
// Audio quality
const currentQuality = ref('exhigh');
const qualityLevels = [
{ value: 'standard', label: '标准', desc: '128kbps MP3' },
{ value: 'higher', label: '较高', desc: '192kbps MP3' },
{ value: 'exhigh', label: '极高', desc: '320kbps MP3' },
{ value: 'lossless', label: '无损', desc: 'FLAC' },
{ value: 'hires', label: 'Hi-Res', desc: '高解析度' },
{ value: 'jymaster', label: '超清母带', desc: '最高质量' },
];
async function loadQuality() {
try {
const res = await axios.get('/api/music/quality');
currentQuality.value = res.data.netease || 'exhigh';
} catch { /* ignore */ }
}
async function setQuality(q: string) {
currentQuality.value = q;
try {
await axios.post('/api/music/quality', { quality: q });
} catch { /* ignore */ }
}
// Login mode: 'qr' | 'cookie' | null
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null);
// Auth status
const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
// QR state
interface QrState {
loading: boolean;
dataUrl: string;
key: string;
status: 'waiting' | 'scanned' | 'confirmed' | 'expired';
pollTimer: ReturnType<typeof setInterval> | null;
}
const neteaseQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
});
const qqQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
});
const bilibiliQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
});
function getQrState(platform: string): QrState {
if (platform === 'bilibili') return bilibiliQr;
return platform === 'netease' ? neteaseQr : qqQr;
}
async function checkAuthStatus() {
try {
const [nRes, qRes, bRes] = await Promise.all([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
]);
Object.assign(neteaseAuth, nRes.data);
Object.assign(qqAuth, qRes.data);
Object.assign(bilibiliAuth, bRes.data);
} catch {
// API not ready
}
}
async function startQrLogin(platform: string) {
const qr = getQrState(platform);
if (platform === 'netease') neteaseLoginMode.value = 'qr';
else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr';
else qqLoginMode.value = 'qr';
// Stop existing poll
if (qr.pollTimer) clearInterval(qr.pollTimer);
qr.loading = true;
qr.dataUrl = '';
qr.status = 'waiting';
try {
const res = await axios.post('/api/auth/qrcode', { platform });
const { qrUrl, qrImg, key } = res.data;
qr.key = key;
// Use server-generated QR image if available, otherwise generate client-side
if (qrImg) {
qr.dataUrl = qrImg;
} else {
qr.dataUrl = await QRCode.toDataURL(qrUrl, {
width: 200,
margin: 2,
color: {
dark: store.theme === 'dark' ? '#ffffff' : '#000000',
light: store.theme === 'dark' ? '#2a2a2a' : '#ffffff',
},
});
}
qr.loading = false;
// Start polling
qr.pollTimer = setInterval(() => pollQrStatus(platform), 2000);
} catch (err) {
qr.loading = false;
console.error('QR generation failed:', err);
}
}
async function pollQrStatus(platform: string) {
const qr = getQrState(platform);
if (!qr.key) return;
try {
const res = await axios.get('/api/auth/qrcode/status', {
params: { key: qr.key, platform },
});
qr.status = res.data.status;
if (qr.status === 'confirmed') {
if (qr.pollTimer) clearInterval(qr.pollTimer);
qr.pollTimer = null;
// Refresh auth status
await checkAuthStatus();
} else if (qr.status === 'expired') {
if (qr.pollTimer) clearInterval(qr.pollTimer);
qr.pollTimer = null;
}
} catch {
// Ignore poll errors
}
}
async function createBot() {
if (!newBotName.value || !newBotServer.value) return;
try {
const res = await axios.post('/api/bot', {
name: newBotName.value,
serverAddress: newBotServer.value,
serverPort: newBotPort.value || 9987,
nickname: newBotNickname.value || newBotName.value,
defaultChannel: newBotChannel.value || undefined,
serverPassword: newBotServerPassword.value || undefined,
autoStart: false,
});
if (newBotAvatar.value && res.data?.id) {
try {
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
} catch (err) {
console.warn('failed to set avatar on new bot', err);
}
}
newBotName.value = '';
newBotServer.value = '';
newBotPort.value = 9987;
newBotNickname.value = 'MusicBot';
newBotChannel.value = '';
newBotServerPassword.value = '';
newBotAvatar.value = null;
await store.fetchBots();
} catch {
// Ignore
}
}
async function deleteBot(botId: string, botName: string) {
if (!confirm(`确认删除机器人 "${botName}"?此操作不可撤销。`)) return;
try {
await axios.delete(`/api/bot/${botId}`);
// If deleted bot was the active one, reset activeBotId
if (store.activeBotId === botId) {
store.activeBotId = null;
}
store.removeBotStatus(botId);
await store.fetchBots();
} catch {
// Ignore
}
}
async function openEditBot(bot: any) {
editingBot.value = bot.id;
editForm.name = bot.name;
// Fetch saved config to fill all fields
try {
const res = await axios.get(`/api/bot/${bot.id}/config`);
editForm.serverAddress = res.data.serverAddress ?? '';
editForm.serverPort = res.data.serverPort ?? 9987;
editForm.nickname = res.data.nickname ?? '';
editForm.defaultChannel = res.data.defaultChannel ?? '';
editForm.channelPassword = res.data.channelPassword ?? '';
editForm.serverPassword = res.data.serverPassword ?? '';
} catch {
// Config not found — use defaults
editForm.serverAddress = '';
editForm.serverPort = 9987;
editForm.nickname = bot.name;
editForm.defaultChannel = '';
editForm.channelPassword = '';
editForm.serverPassword = '';
}
}
async function saveEditBot() {
if (!editingBot.value) return;
try {
await axios.put(`/api/bot/${editingBot.value}`, editForm);
editingBot.value = null;
await store.fetchBots();
} catch {
// Ignore
}
}
async function toggleBot(botId: string, connected: boolean) {
try {
if (connected) {
await axios.post(`/api/bot/${botId}/stop`);
} else {
await axios.post(`/api/bot/${botId}/start`);
}
await store.fetchBots();
} catch {
// Ignore
}
}
async function saveCookie(platform: string) {
const cookie = platform === 'bilibili' ? bilibiliCookie.value : platform === 'netease' ? neteaseCookie.value : qqCookie.value;
if (!cookie) return;
try {
await axios.post('/api/auth/cookie', { platform, cookie });
await checkAuthStatus();
} catch {
// Ignore
}
}
async function savePrefix() {
// Prefix is saved client-side for now
}
// Idle timeout
const idleTimeout = ref(0);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
} catch { /* ignore */ }
}
async function saveIdleTimeout() {
try {
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
} catch { /* ignore */ }
}
// --- Bot Profile config ---
interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
const PROFILE_TOGGLES: ReadonlyArray<{
key: keyof ProfileConfig;
label: string;
hint: string;
warning: string | null;
}> = [
{ key: 'avatarEnabled', label: '同步头像', hint: '使用专辑封面作为 bot 头像', warning: null },
{ key: 'descriptionEnabled', label: '同步个人描述', hint: '在 bot 简介里显示当前播放的歌曲', warning: null },
{ key: 'nicknameEnabled', label: '同步昵称', hint: 'bot 昵称跟着歌名变化', warning: null },
{ key: 'awayStatusEnabled', label: '走开状态', hint: '停止播放时把 bot 设为"走开"', warning: null },
{ key: 'channelDescEnabled', label: '更新频道描述', hint: '把"正在播放"信息写入频道描述', warning: '频道编辑提示音' },
{ key: 'nowPlayingMsgEnabled',label: '推送"正在播放"消息', hint: '切歌时在频道里发一条文字消息', warning: '新消息提示音' },
];
const profileConfigs = reactive<Record<string, ProfileConfig>>({});
const profileExpanded = reactive<Record<string, boolean>>({});
const profileLoadError = reactive<Record<string, string | null>>({});
async function loadProfileConfig(botId: string) {
if (profileConfigs[botId]) return;
profileLoadError[botId] = null;
try {
const res = await axios.get(`/api/player/${botId}/profile`);
// Defensive: a 200 response with non-object body (empty / proxy
// injection / etc.) would otherwise leave the row stuck on
// "加载中..." because profileConfigs[botId] would be falsy.
if (!res.data || typeof res.data !== 'object' || typeof res.data.avatarEnabled !== 'boolean') {
profileLoadError[botId] = '响应格式异常';
return;
}
profileConfigs[botId] = res.data;
} catch (err: any) {
profileLoadError[botId] = err?.response?.status === 404
? '机器人未加载'
: '加载失败,请重试';
}
}
function toggleProfileExpanded(botId: string) {
profileExpanded[botId] = !profileExpanded[botId];
if (profileExpanded[botId]) loadProfileConfig(botId);
}
async function updateProfile(botId: string, key: keyof ProfileConfig, value: boolean) {
const cfg = profileConfigs[botId];
if (!cfg) return;
const prev = cfg[key];
cfg[key] = value; // optimistic
try {
const res = await axios.put(`/api/player/${botId}/profile`, { [key]: value });
profileConfigs[botId] = res.data;
} catch {
cfg[key] = prev; // revert
}
}
onMounted(() => {
store.fetchBots(); // Refresh bot status on page visit
checkAuthStatus();
loadQuality();
loadIdleTimeout();
});
onUnmounted(() => {
if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer);
if (qqQr.pollTimer) clearInterval(qqQr.pollTimer);
if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer);
});
</script>
<style lang="scss" scoped>
.back-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
opacity: 0.7;
margin-bottom: 16px;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
.page-title {
font-size: 28px;
font-weight: 800;
margin-bottom: 32px;
}
.settings-section {
margin-bottom: 36px;
padding: 24px;
background: var(--bg-card);
border-radius: var(--radius-lg);
}
.section-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
.subsection-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
margin-top: 16px;
}
.setting-row {
margin-bottom: 16px;
}
.setting-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.setting-icon {
font-size: 18px;
opacity: 0.6;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: var(--hover-bg);
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 600;
transition: all var(--transition-fast);
&:hover { background: var(--color-primary); color: white; }
}
// Bot management
.bot-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bot-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--hover-bg);
border-radius: var(--radius-md);
}
.bot-info {
display: flex;
align-items: center;
gap: 12px;
}
.bot-name {
font-size: 14px;
font-weight: 500;
}
.bot-status {
font-size: 12px;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--border-color);
color: var(--text-tertiary);
&.online {
background: var(--color-primary-15);
color: var(--color-primary);
}
&.playing {
background: var(--color-online-15);
color: var(--color-online);
}
&.paused {
background: var(--color-paused-15);
color: var(--color-paused);
}
}
// Account cards
.account-card {
margin-bottom: 20px;
padding: 20px;
background: var(--hover-bg);
border-radius: var(--radius-md);
}
.account-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.account-icon {
font-size: 28px;
color: var(--color-primary);
&.bilibili-icon {
color: var(--brand-bilibili);
}
}
.account-name {
font-size: 15px;
font-weight: 600;
}
.account-status {
font-size: 12px;
color: var(--text-tertiary);
&.logged { color: var(--color-online); }
}
.login-methods {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.login-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
transition: all var(--transition-fast);
&:hover { border-color: var(--color-primary); color: var(--color-primary); }
&.active {
background: var(--color-primary-10);
border-color: var(--color-primary);
color: var(--color-primary);
}
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
// QR code
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
.qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
}
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.qr-image {
width: 200px;
height: 200px;
border-radius: var(--radius-md);
border: 2px solid var(--border-color);
}
.qr-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
padding: 8px 16px;
border-radius: var(--radius-sm);
background: var(--bg-card);
&.scanned { color: #ff9800; background: rgba(255, 152, 0, 0.1); }
&.confirmed { color: #4caf50; background: rgba(76, 175, 80, 0.1); }
&.expired { color: #f44336; background: rgba(244, 67, 54, 0.1); }
}
.btn-link {
color: var(--color-primary);
font-size: 13px;
font-weight: 600;
text-decoration: underline;
margin-left: 8px;
}
// Cookie section
.cookie-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.btn-save {
align-self: flex-end;
}
// Shared
.form-row {
display: flex;
gap: 8px;
}
.input {
flex: 1;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 13px;
outline: none;
&:focus { border-color: var(--color-primary); }
}
.input-sm { max-width: 80px; }
.textarea {
width: 100%;
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 13px;
outline: none;
resize: vertical;
&:focus { border-color: var(--color-primary); }
}
.quality-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.quality-btn {
padding: 12px;
background: var(--hover-bg);
border: 2px solid transparent;
border-radius: var(--radius-md);
text-align: center;
transition: all var(--transition-fast);
cursor: pointer;
&:hover { border-color: var(--border-color); }
&.active {
border-color: var(--color-primary);
background: var(--color-primary-10);
}
}
.quality-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.quality-desc {
font-size: 11px;
color: var(--text-tertiary);
}
.prefix-input-wrap {
display: flex;
gap: 8px;
align-items: center;
}
.btn-primary {
padding: 10px 20px;
background: var(--color-primary);
color: white;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
transition: transform var(--transition-fast);
&:hover { transform: scale(1.02); }
&:active { transform: scale(0.98); }
}
.btn-sm {
padding: 6px 14px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
transition: all var(--transition-fast);
&:hover { background: var(--color-primary); color: white; }
}
.btn-edit {
padding: 6px 8px;
font-size: 14px;
}
.btn-delete {
padding: 6px 8px;
font-size: 14px;
&:hover { background: #f44336; color: white; }
}
.btn-secondary {
padding: 10px 20px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
}
.bot-actions {
display: flex;
gap: 6px;
align-items: center;
}
.create-bot {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.form-group {
margin-bottom: 12px;
label {
display: block;
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
opacity: 0.7;
}
}
.edit-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.edit-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 28px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
}
.modal-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// --- Bot Profile section ---
.profile-section-hint {
font-size: 13px;
color: var(--text-secondary);
margin: -8px 0 16px;
line-height: 1.5;
}
.empty-hint {
font-size: 13px;
color: var(--text-tertiary);
padding: 12px;
}
.profile-bot-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.profile-bot {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.profile-bot-header {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: transparent;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
transition: background var(--transition-fast);
&:hover { background: var(--hover-bg); }
&.expanded { background: var(--hover-bg); }
}
.profile-bot-name {
flex: 1;
text-align: left;
}
.profile-toggles {
display: flex;
flex-direction: column;
padding: 0 16px 8px;
border-top: 1px solid var(--border-color);
}
.profile-loading {
padding: 16px 0;
font-size: 13px;
color: var(--text-tertiary);
text-align: center;
}
.profile-error {
color: var(--brand-netease); // re-uses red brand color for error state
.btn-link {
margin-left: 8px;
font-size: 13px;
color: var(--color-primary);
text-decoration: underline;
background: transparent;
cursor: pointer;
}
}
.profile-toggle {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
&:last-child { border-bottom: none; }
}
.profile-toggle-text {
flex: 1;
min-width: 0;
}
.profile-toggle-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
line-height: 1.3;
}
.profile-warn-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
background: var(--color-paused-15);
color: var(--color-paused);
border-radius: var(--radius-xs);
white-space: nowrap;
}
.profile-toggle-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
line-height: 1.4;
}
.profile-toggle-switch {
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
position: relative;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
margin: 0;
&::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-secondary);
transition: transform var(--transition-fast), background var(--transition-fast);
}
&:checked {
background: var(--color-primary);
border-color: var(--color-primary);
&::before {
transform: translateX(18px);
background: white;
}
}
}
.profile-toggle-static {
cursor: default;
align-items: flex-start;
}
@media (max-width: 768px) {
.profile-bot-header {
padding: 14px 12px;
font-size: 15px;
}
.profile-toggles {
padding: 0 12px 6px;
}
.profile-toggle {
gap: 12px;
padding: 14px 0;
}
.profile-toggle-switch {
width: 44px;
height: 24px;
&::before {
width: 18px;
height: 18px;
}
&:checked::before {
transform: translateX(20px);
}
}
}
</style>