mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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>
1471 lines
41 KiB
Vue
Executable File
1471 lines
41 KiB
Vue
Executable File
<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>
|