feat: 完善设置向导,添加平台选择和账号管理功能

This commit is contained in:
晴猫
2026-03-15 10:44:46 +09:00
parent bd0918b866
commit da7aafe665
4 changed files with 277 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
// 设置向导各步骤表单组件
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
@@ -8,6 +8,13 @@ import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { X, ExternalLink, Eye, EyeOff } from 'lucide-react'
import type {
BotBasicConfig,
@@ -18,12 +25,141 @@ import type {
} from './types'
// ====== 步骤1Bot基础配置 ======
const KNOWN_PLATFORMS: Record<string, string> = {
qq: 'qq',
telegram: 'telegram',
tg: 'telegram',
discord: 'discord',
kook: 'kook',
}
const PLATFORM_OPTIONS = [
{ value: 'qq', label: 'QQ' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'discord', label: 'Discord' },
{ value: 'kook', label: 'Kook' },
{ value: 'custom', label: '其他平台' },
]
function normalizePlatform(raw: string): string {
const key = raw.trim().toLowerCase()
return KNOWN_PLATFORMS[key] || key
}
function deriveSelectedPlatform(config: BotBasicConfig): { selected: string; customName: string } {
const platform = config.platform
// Legacy: no platform set but has QQ account
if (!platform && config.qq_account > 0) {
return { selected: 'qq', customName: '' }
}
if (!platform) {
return { selected: '', customName: '' }
}
const known = PLATFORM_OPTIONS.find((opt) => opt.value === platform && opt.value !== 'custom')
if (known) {
return { selected: platform, customName: '' }
}
return { selected: 'custom', customName: platform }
}
function upsertPlatformAccount(platforms: string[], platformName: string, accountId: string): string[] {
const normalized = normalizePlatform(platformName)
const filtered = platforms.filter((p) => {
const prefix = p.split(':')[0]
return normalizePlatform(prefix) !== normalized
})
if (accountId.trim()) {
filtered.push(`${normalized}:${accountId.trim()}`)
}
return filtered
}
function getPrimaryAccount(platforms: string[], platformName: string): string {
const normalized = normalizePlatform(platformName)
const entry = platforms.find((p) => {
const prefix = p.split(':')[0]
return normalizePlatform(prefix) === normalized
})
return entry ? entry.split(':').slice(1).join(':') : ''
}
interface BotBasicFormProps {
config: BotBasicConfig
onChange: (config: BotBasicConfig) => void
}
export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
const derived = deriveSelectedPlatform(config)
const [selectedPlatform, setSelectedPlatform] = useState(derived.selected)
const [customPlatformName, setCustomPlatformName] = useState(derived.customName)
const [primaryAccount, setPrimaryAccount] = useState(() => {
if (derived.selected === 'qq') {
return config.qq_account > 0 ? String(config.qq_account) : ''
}
if (config.platform) {
return getPrimaryAccount(config.platforms, config.platform)
}
return ''
})
// Re-derive when config loads from API (e.g. after initial fetch)
useEffect(() => {
const d = deriveSelectedPlatform(config)
setSelectedPlatform(d.selected)
setCustomPlatformName(d.customName)
if (d.selected === 'qq') {
setPrimaryAccount(config.qq_account > 0 ? String(config.qq_account) : '')
} else if (config.platform) {
setPrimaryAccount(getPrimaryAccount(config.platforms, config.platform))
}
}, [config.platform, config.qq_account, config.platforms])
const handlePlatformChange = (value: string) => {
setSelectedPlatform(value)
const realPlatform = value === 'custom' ? customPlatformName : value
setPrimaryAccount('')
onChange({
...config,
platform: normalizePlatform(realPlatform),
qq_account: value === 'qq' ? config.qq_account : config.qq_account, // preserve
})
}
const handleCustomNameChange = (name: string) => {
setCustomPlatformName(name)
const normalized = normalizePlatform(name)
// Move account to new platform name if we had one
const newPlatforms = primaryAccount
? upsertPlatformAccount(config.platforms, normalized, primaryAccount)
: config.platforms
onChange({
...config,
platform: normalized,
platforms: newPlatforms,
})
}
const handleAccountChange = (accountId: string) => {
setPrimaryAccount(accountId)
const realPlatform = selectedPlatform === 'custom' ? customPlatformName : selectedPlatform
const normalized = normalizePlatform(realPlatform)
if (normalized === 'qq') {
onChange({
...config,
qq_account: Number(accountId) || 0,
platform: 'qq',
})
} else {
onChange({
...config,
platform: normalized,
platforms: upsertPlatformAccount(config.platforms, normalized, accountId),
})
}
}
const handleAddAlias = (alias: string) => {
if (alias.trim() && !config.alias_names.includes(alias.trim())) {
onChange({
@@ -43,21 +179,67 @@ export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
return (
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="qq_account">QQ账号 *</Label>
<Input
id="qq_account"
type="number"
placeholder="请输入机器人的QQ账号"
value={config.qq_account || ''}
onChange={(e) =>
onChange({ ...config, qq_account: Number(e.target.value) })
}
/>
<Label htmlFor="platform"> *</Label>
<Select value={selectedPlatform} onValueChange={handlePlatformChange}>
<SelectTrigger id="platform">
<SelectValue placeholder="请选择平台" />
</SelectTrigger>
<SelectContent>
{PLATFORM_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
使QQ账号
</p>
</div>
{selectedPlatform === 'custom' && (
<div className="space-y-3">
<Label htmlFor="custom_platform_name"> *</Label>
<Input
id="custom_platform_name"
placeholder="请输入平台名称,如 matrix"
value={customPlatformName}
onChange={(e) => handleCustomNameChange(e.target.value)}
/>
</div>
)}
{selectedPlatform === 'qq' && (
<div className="space-y-3">
<Label htmlFor="qq_account">QQ账号 *</Label>
<Input
id="qq_account"
type="number"
placeholder="请输入机器人的QQ账号"
value={primaryAccount}
onChange={(e) => handleAccountChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
使QQ账号
</p>
</div>
)}
{selectedPlatform && selectedPlatform !== 'qq' && (selectedPlatform !== 'custom' || customPlatformName) && (
<div className="space-y-3">
<Label htmlFor="primary_account">ID *</Label>
<Input
id="primary_account"
placeholder="请输入机器人的账号ID"
value={primaryAccount}
onChange={(e) => handleAccountChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
<div className="space-y-3">
<Label htmlFor="nickname"> *</Label>
<Input

View File

@@ -27,7 +27,9 @@ export async function loadBotBasicConfig(): Promise<BotBasicConfig> {
const botConfig = (data.config.bot || {}) as Partial<BotBasicConfig>
return {
platform: botConfig.platform || (botConfig.qq_account ? 'qq' : ''),
qq_account: botConfig.qq_account || 0,
platforms: botConfig.platforms || [],
nickname: botConfig.nickname || '',
alias_names: botConfig.alias_names || [],
}

View File

@@ -0,0 +1,628 @@
import { useState, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Sparkles,
ArrowRight,
CheckCircle2,
SkipForward,
Bot,
User,
Smile,
Settings,
Key,
Globe,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { APP_NAME } from '@/lib/version'
import { useToast } from '@/hooks/use-toast'
import type {
SetupStep,
BotBasicConfig,
PersonalityConfig,
EmojiConfig,
OtherBasicConfig,
SiliconFlowConfig,
} from './types'
import {
BotBasicForm,
PersonalityForm,
EmojiForm,
OtherBasicForm,
SiliconFlowForm,
} from './StepForms'
import {
loadBotBasicConfig,
loadPersonalityConfig,
loadEmojiConfig,
loadOtherBasicConfig,
loadSiliconFlowConfig,
saveBotBasicConfig,
savePersonalityConfig,
saveEmojiConfig,
saveOtherBasicConfig,
saveSiliconFlowConfig,
completeSetup,
} from './api'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { RestartOverlay } from '@/components/restart-overlay'
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
const LANGUAGE_NAMES: Record<typeof LANGUAGE_CODES[number], string> = {
zh: '中文',
en: 'English',
ja: '日本語',
ko: '한국어',
}
// 主导出组件:包装 RestartProvider
export function SetupPage() {
return (
<RestartProvider>
<SetupPageContent />
</RestartProvider>
)
}
// 内部实现组件
function SetupPageContent() {
const navigate = useNavigate()
const { toast } = useToast()
const { triggerRestart } = useRestart()
const { i18n: i18nInstance } = useTranslation()
const currentLang = i18nInstance.language || 'zh'
const [currentStep, setCurrentStep] = useState(0)
const [isCompleting, setIsCompleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// 步骤1Bot基础信息
const [botBasic, setBotBasic] = useState<BotBasicConfig>({
platform: '',
qq_account: 0,
platforms: [],
nickname: '',
alias_names: [],
})
// 步骤2人格配置
const [personality, setPersonality] = useState<PersonalityConfig>({
personality: '是一个女大学生,现在在读大二,会刷贴吧。',
reply_style:
'请回复的平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧,知乎和微博的回复风格。',
interest:
'对技术相关话题,游戏和动漫相关话题感兴趣,也对日常话题感兴趣,不喜欢太过沉重严肃的话题',
plan_style:
'1.思考**所有**的可用的action中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.请控制你的发言频率,不要太过频繁的发言\n4.如果有人对你感到厌烦,请减少回复\n5.如果有人对你进行攻击,或者情绪激动,请你以合适的方法应对',
private_plan_style:
'1.思考**所有**的可用的action中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.某句话如果已经被回复过,不要重复回复',
})
// 步骤3表情包配置
const [emoji, setEmoji] = useState<EmojiConfig>({
emoji_chance: 0.4,
max_reg_num: 40,
do_replace: true,
check_interval: 10,
steal_emoji: true,
content_filtration: false,
filtration_prompt: '符合公序良俗',
})
// 步骤4其他基础配置
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
enable_tool: true,
all_global: true,
})
// 步骤5硅基流动API配置
const [siliconFlow, setSiliconFlow] = useState<SiliconFlowConfig>({
api_key: '',
})
const steps: SetupStep[] = [
{
id: 'bot-basic',
title: 'Bot基础',
description: '配置机器人的基本信息',
icon: Bot,
},
{
id: 'personality',
title: '人格配置',
description: '定义机器人的性格和说话风格',
icon: User,
},
{
id: 'emoji',
title: '表情包',
description: '配置表情包相关设置',
icon: Smile,
},
{
id: 'other',
title: '其他设置',
description: '工具、情绪系统等配置',
icon: Settings,
},
{
id: 'siliconflow',
title: 'API配置',
description: '配置硅基流动API密钥',
icon: Key,
},
]
const progress = ((currentStep + 1) / steps.length) * 100
// 加载现有配置
useEffect(() => {
const loadConfigs = async () => {
try {
setIsLoading(true)
// 并行加载所有配置
const [bot, personality, emoji, other, silicon] = await Promise.all([
loadBotBasicConfig(),
loadPersonalityConfig(),
loadEmojiConfig(),
loadOtherBasicConfig(),
loadSiliconFlowConfig(),
])
setBotBasic(bot)
setPersonality(personality)
setEmoji(emoji)
setOtherBasic(other)
setSiliconFlow(silicon)
} catch (error) {
toast({
title: '加载配置失败',
description:
error instanceof Error
? error.message
: '无法加载现有配置,将使用默认值',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
loadConfigs()
}, [toast])
// 保存当前步骤配置
const saveCurrentStep = async () => {
setIsSaving(true)
try {
switch (currentStep) {
case 0: // Bot基础
await saveBotBasicConfig(botBasic)
break
case 1: // 人格配置
await savePersonalityConfig(personality)
break
case 2: // 表情包
await saveEmojiConfig(emoji)
break
case 3: // 其他设置
await saveOtherBasicConfig(otherBasic)
break
case 4: // 硅基流动API
await saveSiliconFlowConfig(siliconFlow)
break
}
toast({
title: '保存成功',
description: `${steps[currentStep].title}配置已保存`,
})
return true
} catch (error) {
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
return false
} finally {
setIsSaving(false)
}
}
// Step 1 验证
function validateBotBasic(config: BotBasicConfig): string | null {
if (!config.platform) return '请选择平台'
if (!config.nickname.trim()) return '请输入昵称'
if (config.platform === 'qq') {
if (!config.qq_account || config.qq_account <= 0) return '请输入QQ账号'
} else {
const hasAccount = config.platforms.some(
(p) => p.startsWith(config.platform + ':') && p.split(':')[1]?.trim()
)
if (!hasAccount) return '请输入账号ID'
}
return null
}
const handleNext = async () => {
// Step 1 验证
if (currentStep === 0) {
const error = validateBotBasic(botBasic)
if (error) {
toast({ title: '验证失败', description: error, variant: 'destructive' })
return
}
}
// 保存当前步骤
const saved = await saveCurrentStep()
if (!saved) return
// 进入下一步
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1)
}
}
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleComplete = async () => {
setIsCompleting(true)
try {
// 1. 保存最后一步的配置(硅基流动API Key)
const saved = await saveCurrentStep()
if (!saved) {
setIsCompleting(false)
return
}
// 2. 标记设置完成
await completeSetup()
toast({
title: '配置完成',
description: '麦麦正在重启以应用新配置...',
})
// 3. 触发麦麦重启(使用新的重启组件)
await triggerRestart()
} catch (error) {
toast({
title: '配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setIsCompleting(false)
}
}
const handleSkip = async () => {
try {
await completeSetup()
navigate({ to: '/' })
} catch (error) {
toast({
title: '跳过失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
}
}
// 渲染当前步骤的表单
const renderStepForm = () => {
switch (currentStep) {
case 0:
return <BotBasicForm config={botBasic} onChange={setBotBasic} />
case 1:
return (
<PersonalityForm config={personality} onChange={setPersonality} />
)
case 2:
return <EmojiForm config={emoji} onChange={setEmoji} />
case 3:
return <OtherBasicForm config={otherBasic} onChange={setOtherBasic} />
case 4:
return <SiliconFlowForm config={siliconFlow} onChange={setSiliconFlow} />
default:
return null
}
}
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gradient-to-br from-primary/5 via-background to-secondary/5 p-4 md:p-6">
{/* 重启遮罩层 */}
<RestartOverlay />
{/* 语言切换 */}
<div className="absolute right-4 top-4 z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline text-xs">
{LANGUAGE_NAMES[currentLang.split('-')[0] as typeof LANGUAGE_CODES[number]] ?? currentLang}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGE_CODES.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => i18nInstance.changeLanguage(code)}
className={cn(
'cursor-pointer',
currentLang.split('-')[0] === code && 'font-semibold text-primary'
)}
>
{currentLang.split('-')[0] === code && (
<span className="mr-2"></span>
)}
{LANGUAGE_NAMES[code]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 背景装饰 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
</div>
{/* 加载状态 */}
{isLoading ? (
<div className="relative z-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
<p className="text-lg font-medium">...</p>
<p className="text-sm text-muted-foreground mt-2">
</p>
</div>
) : (
<>
{/* 主要内容 */}
<div className="relative z-10 w-full max-w-4xl">
{/* 头部 */}
<div className="mb-6 md:mb-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 md:h-16 md:w-16 items-center justify-center rounded-2xl bg-primary/10">
<Sparkles
className="h-6 w-6 md:h-8 md:w-8 text-primary"
strokeWidth={2}
fill="none"
/>
</div>
<h1 className="mb-2 text-2xl md:text-3xl font-bold">
</h1>
<p className="text-sm md:text-base text-muted-foreground">
{APP_NAME}
</p>
</div>
{/* 进度条 */}
<div className="mb-6 md:mb-8">
<div className="mb-2 flex items-center justify-between text-xs md:text-sm">
<span className="text-muted-foreground">
{currentStep + 1} / {steps.length}
</span>
<span className="font-medium text-primary">
{Math.round(progress)}%
</span>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* 步骤指示器 */}
<div className="mb-6 md:mb-8 flex justify-between">
{steps.map((step, index) => {
const Icon = step.icon
return (
<div
key={step.id}
className={cn(
'flex flex-1 flex-col items-center gap-1 md:gap-2',
index < steps.length - 1 && 'relative'
)}
>
{/* 连接线 */}
{index < steps.length - 1 && (
<div
className={cn(
'absolute left-1/2 top-3 md:top-4 h-0.5 w-full',
index < currentStep ? 'bg-primary' : 'bg-border'
)}
/>
)}
{/* 步骤圆圈 */}
<div
className={cn(
'relative z-10 flex h-6 w-6 md:h-8 md:w-8 items-center justify-center rounded-full border-2 transition-all',
index === currentStep
? 'border-primary bg-primary text-primary-foreground'
: index < currentStep
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground'
)}
>
{index < currentStep ? (
<CheckCircle2
className="h-3 w-3 md:h-4 md:w-4"
strokeWidth={2.5}
fill="none"
/>
) : (
<Icon className="h-3 w-3 md:h-4 md:w-4" />
)}
</div>
{/* 步骤标题 */}
<span
className={cn(
'text-[10px] md:text-xs text-center max-w-[60px] md:max-w-none truncate md:whitespace-normal',
index === currentStep
? 'font-medium text-foreground'
: 'text-muted-foreground'
)}
title={step.title}
>
{step.title}
</span>
</div>
)
})}
</div>
{/* 步骤内容卡片 */}
<Card className="mb-6 md:mb-8 shadow-lg">
<CardContent className="p-4 md:p-8">
<div className="min-h-[300px] md:min-h-[400px]">
<div className="mb-4 md:mb-6">
<h2 className="mb-2 text-xl md:text-2xl font-semibold">
{steps[currentStep].title}
</h2>
<p className="text-sm md:text-base text-muted-foreground">
{steps[currentStep].description}
</p>
</div>
{/* 表单内容 */}
<ScrollArea className="h-[400px] md:h-[500px]">
<div className="pr-2">
{renderStepForm()}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentStep === 0 || isSaving}
className="w-full sm:w-auto order-2 sm:order-1"
>
</Button>
<div className="flex gap-2 w-full sm:w-auto order-1 sm:order-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
className="flex-1 sm:flex-none gap-2"
disabled={isSaving || isCompleting}
>
<SkipForward className="h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleSkip}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{currentStep === steps.length - 1 ? (
<Button
onClick={handleComplete}
disabled={isCompleting || isSaving}
className="flex-1 sm:flex-none"
>
{isCompleting || isSaving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{isSaving ? '保存中...' : '完成中...'}
</>
) : (
<>
<CheckCircle2
className="ml-2 h-4 w-4"
strokeWidth={2}
fill="none"
/>
</>
)}
</Button>
) : (
<Button
onClick={handleNext}
disabled={isSaving}
className="flex-1 sm:flex-none"
>
{isSaving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
<>
<ArrowRight
className="ml-2 h-4 w-4"
strokeWidth={2}
fill="none"
/>
</>
)}
</Button>
)}
</div>
</div>
</div>
{/* 页脚提示 */}
<div className="relative z-10 mt-6 md:mt-8 text-center text-xs text-muted-foreground">
<p></p>
</div>
</>
)}
</div>
)
}

View File

@@ -9,7 +9,9 @@ export interface SetupStep {
// 步骤1Bot基础信息
export interface BotBasicConfig {
qq_account: number
platform: string // Primary platform name (normalized, lowercase)
qq_account: number // QQ account (preserved always for webui compat)
platforms: string[] // Other platform accounts "platform:account"
nickname: string
alias_names: string[]
}