Files
plugin-comment-ai-autopilot/ui/src/views/SettingsView.vue
T

1631 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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">
<VPageHeader title="插件设置">
<template #icon>
<IconPlug class="mr-2 self-center" />
</template>
<template #actions>
<VSpace spacing="sm">
<VButton size="sm" @click="exportConfig">
<template #icon>
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</template>
导出
</VButton>
<label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors">
<svg style="width:14px;height:14px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
导入
<input type="file" accept=".json" class="hidden" @change="handleImportFile" />
</label>
<VButton size="sm" @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-4">
<VLoading v-if="loading" />
<div v-if="!loading">
<!-- Tab Navigation (full width, above the grid) -->
<div class="settings-tabs">
<button
v-for="tab in tabItems"
:key="tab.value"
class="settings-tab"
:class="{ 'settings-tab--active': activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
</button>
</div>
<!-- Settings Grid: sections (left) + sidebar (right) -->
<div class="settings-container">
<!-- Left: Settings Sections -->
<div class="settings-sections space-y-5">
<!-- ========== 基本设置 ========== -->
<section v-if="activeTab === 'basic'" class="settings-section">
<div class="section-header section-header--blue">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<div class="section-header__text">
<h3>基本设置</h3>
<p>控制AI回复的基本行为</p>
</div>
</div>
<div class="section-body">
<!-- Switch Row: 自动回复 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">自动回复</span>
<span class="form-hint">启用后AI将自动回复新评论</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.basic.autoReply" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Switch Row: 自动发布 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">自动发布</span>
<span class="form-hint">关闭后AI回复将存为草稿需手动审核发布</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.basic.autoPublish" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Slider: 最大对话轮次 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">最大对话轮次</span>
<span class="form-badge">{{ settings.basic.maxConversationRounds }}</span>
</div>
<span class="form-hint">同一评论线程中AI最多自动回复的轮次</span>
<div class="slider">
<input type="range" v-model.number="settings.basic.maxConversationRounds" min="1" max="100" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>50</span><span>100</span>
</div>
</div>
</div>
<!-- Input: 速率限制 -->
<div class="form-field">
<label class="form-label">速率限制</label>
<span class="form-hint">每分钟最大AI回复数量</span>
<input type="number" v-model.number="settings.basic.rateLimitPerMinute" min="1" max="100" class="form-input" placeholder="10" />
</div>
<!-- Slider: 最大重试次数 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">最大重试次数</span>
<span class="form-badge">{{ settings.basic.maxRetryCount }}</span>
</div>
<span class="form-hint">AI生成失败时的最大重试次数采用指数退避策略</span>
<div class="slider">
<input type="range" v-model.number="settings.basic.maxRetryCount" min="1" max="10" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>5</span><span>10</span>
</div>
</div>
</div>
<!-- Textarea: 评论者黑名单 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">评论者黑名单</span>
<button class="btn-link" @click="openCommenterDialog">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
添加评论者
</button>
</div>
<span class="form-hint">支持名称邮箱和正则表达式正则以 regex: 开头 regex:^spam.*</span>
<textarea v-model="settings.basic.blockedCommenters" rows="2" class="form-textarea" placeholder="例如:张三, spam@example.com, 李四"></textarea>
</div>
</div>
</section>
<!-- ========== AI角色设置 ========== -->
<section v-if="activeTab === 'persona'" class="settings-section">
<div class="section-header section-header--purple">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div class="section-header__text">
<h3>AI角色设置</h3>
<p>定义AI虚拟评论者的身份和风格</p>
</div>
</div>
<div class="section-body">
<!-- Persona List -->
<VLoading v-if="personasLoading" />
<div v-else-if="personas.length === 0" class="persona-empty">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>请添加至少一个AI角色</span>
</div>
<div v-else class="persona-list">
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
<div class="persona-card__avatar">
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
</div>
<div class="persona-card__info">
<div class="persona-card__name">
{{ p.spec.displayName || '未命名' }}
<span v-if="p.spec.gender === 'female'" class="persona-card__badge persona-card__badge--female"></span>
<span v-else-if="p.spec.gender === 'male'" class="persona-card__badge persona-card__badge--male"></span>
<span v-if="p.spec.neutralVoice" class="persona-card__badge persona-card__badge--neutral-voice">中性语气</span>
<span v-if="p.spec.wakeWord" class="persona-card__badge persona-card__badge--wake-word">唤醒: {{ p.spec.wakeWord }}</span>
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
</div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
</div>
<div class="persona-card__actions">
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
</button>
</div>
</div>
</div>
<VButton type="secondary" @click="openPersonaDialog(null)">
<svg style="width:16px;height:16px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
添加角色
</VButton>
</div>
</section>
<!-- ========== 模型设置 ========== -->
<section v-if="activeTab === 'model'" class="settings-section">
<div class="section-header section-header--green">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</div>
<div class="section-header__text">
<h3>模型设置</h3>
<p>配置AI Foundation提供的模型</p>
</div>
</div>
<div class="section-body">
<div class="form-field">
<label class="form-label">AI模型名称</label>
<span class="form-hint">留空使用AI Foundation默认模型填写AiModel资源名称可指定模型</span>
<input type="text" v-model="settings.model.modelName" class="form-input" placeholder="留空使用默认模型" />
</div>
</div>
</section>
<!-- ========== Prompt设置 ========== -->
<section v-if="activeTab === 'prompt'" class="settings-section">
<div class="section-header section-header--amber">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="section-header__text">
<h3>Prompt设置</h3>
<p>自定义AI回复的提示词模板</p>
</div>
</div>
<div class="section-body">
<!-- Preset Selection -->
<div class="form-field">
<label class="form-label">Prompt预设</label>
<span class="form-hint">选择预设风格可多选</span>
<div class="preset-grid">
<label v-for="p in promptPresets" :key="p.key" class="preset-item" :class="{ 'preset-item--active': isPresetEnabled(p.key) }">
<input type="checkbox" :checked="isPresetEnabled(p.key)" @change="togglePreset(p.key)" class="preset-checkbox" />
<div class="preset-item__content">
<span class="preset-item__label">{{ p.label }}</span>
<span class="preset-item__desc">{{ p.desc }}</span>
</div>
</label>
</div>
</div>
<div class="form-field">
<div class="form-field__header">
<label class="form-label">自定义Prompt模板</label>
<span class="form-hint--inline">留空使用默认模板</span>
</div>
<div class="var-tags">
<span v-for="v in promptVariables" :key="v.name" class="var-tag">
<code>{{ v.name }}</code> {{ v.desc }}
</span>
</div>
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义Prompt模板"></textarea>
</div>
</div>
</section>
<!-- ========== 数据清理 ========== -->
<section v-if="activeTab === 'cleanup'" class="settings-section">
<div class="section-header section-header--red">
<div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</div>
<div class="section-header__text">
<h3>数据清理</h3>
<p>自动清理过期的AI回复记录</p>
</div>
</div>
<div class="section-body">
<!-- Switch: 启用自动清理 -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">启用自动清理</span>
<span class="form-hint">每天自动清理超过保留天数的记录</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Slider: 保留天数 -->
<div class="form-field">
<div class="form-field__header">
<span class="form-label">保留天数</span>
<span class="form-badge">{{ settings.cleanup.retentionDays }} </span>
</div>
<span class="form-hint">超过此天数的AI回复记录将被自动清理</span>
<div class="slider">
<input type="range" v-model.number="settings.cleanup.retentionDays" min="1" max="365" class="slider__input" />
<div class="slider__marks">
<span>1</span><span>180</span><span>365</span>
</div>
</div>
</div>
<!-- Manual Cleanup -->
<div class="form-row form-row--bordered">
<div class="form-row__label">
<span class="form-label">手动清理</span>
<span class="form-hint">立即执行一次清理操作</span>
</div>
<div class="form-row__action">
<VButton size="sm" type="secondary" @click="performCleanup" :disabled="cleanupLoading">
{{ cleanupLoading ? '清理中...' : '立即清理' }}
</VButton>
</div>
</div>
<div v-if="cleanupResult !== null" class="cleanup-result">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
清理完成共删除 {{ cleanupResult }} 条记录
</div>
</div>
</section>
</div>
<!-- Right: Sticky Save Bar -->
<div class="settings-sidebar">
<div class="settings-sidebar__inner">
<div class="sidebar-card sidebar-card--primary">
<div class="sidebar-card__header">
<h4 class="sidebar-card__title">操作</h4>
<span v-if="hasUnsavedChanges" class="sidebar-card__badge">未保存</span>
</div>
<div class="sidebar-card__actions">
<VButton block type="primary" @click="saveSettings" :disabled="saving">
<template v-if="!saving">
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</template>
{{ saving ? '保存中...' : '保存设置' }}
</VButton>
<VButton block @click="fetchSettings" :disabled="saving">
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
重置为当前值
</VButton>
</div>
</div>
<div v-if="activeTab === 'prompt'" class="sidebar-card">
<h4 class="sidebar-card__title">可用模板变量</h4>
<div class="sidebar-card__vars">
<div v-for="v in promptVariables" :key="v.name" class="sidebar-var">
<code>{{ v.name }}</code>
<span>{{ v.desc }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Commenter Selection Dialog -->
<div v-if="showCommenterDialog" class="dialog-overlay" @click.self="showCommenterDialog = false">
<div class="dialog">
<div class="dialog__header">
<h3>选择评论者</h3>
<button class="dialog__close" @click="showCommenterDialog = false">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="dialog__search">
<svg class="dialog__search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input v-model="commenterSearch" type="text" class="dialog__search-input" placeholder="搜索评论者名称或邮箱..." />
</div>
<div class="dialog__body">
<VLoading v-if="commenterLoading" />
<div v-else-if="filteredCommenters.length === 0" class="dialog__empty">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>暂无评论者数据</span>
</div>
<div v-else class="dialog__list">
<button v-for="c in filteredCommenters" :key="c.displayName + c.email" class="dialog__item" @click="addCommenter(c)">
<div class="dialog__item-avatar">
<img v-if="c.avatarUrl" :src="c.avatarUrl" alt="" class="w-full h-full object-cover rounded-full" />
<span v-else>{{ c.displayName?.charAt(0) || '?' }}</span>
</div>
<div class="dialog__item-info">
<div class="dialog__item-name">{{ c.displayName }}</div>
<div v-if="c.email" class="dialog__item-email">{{ c.email }}</div>
</div>
<svg class="dialog__item-add" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Persona Edit Dialog -->
<div v-if="showPersonaDialog" class="dialog-overlay" @click.self="showPersonaDialog = false">
<div class="dialog" style="max-width:520px">
<div class="dialog__header">
<h3>{{ personaEditing ? '编辑角色' : '添加角色' }}</h3>
<button class="dialog__close" @click="showPersonaDialog = false">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="dialog__body" style="max-height:none;padding:20px 24px;display:flex;flex-direction:column;gap:16px">
<!-- Avatar Preview -->
<div class="persona-dialog-preview">
<div class="persona-card__avatar">
<img v-if="personaDialogAvatar" :src="personaDialogAvatar" alt="Gravatar头像" />
<span v-else class="persona-card__avatar-fallback">{{ (personaForm.displayName || '?').charAt(0) }}</span>
</div>
<div style="font-size:13px;color:#6b7280">{{ personaDialogAvatar ? 'Gravatar头像预览' : '未设置邮箱,将使用默认头像' }}</div>
</div>
<!-- Nickname -->
<div class="form-field">
<label class="form-label">昵称</label>
<input type="text" v-model="personaForm.displayName" class="form-input" placeholder="小回" />
</div>
<!-- Email -->
<div class="form-field">
<label class="form-label">邮箱</label>
<span class="form-hint">用于Gravatar头像服务留空使用默认头像</span>
<input type="email" v-model="personaForm.email" class="form-input" placeholder="ai@example.com" />
</div>
<!-- Gender & Neutral Voice -->
<div class="form-field">
<label class="form-label">性别与语气</label>
<div class="gender-voice-row">
<div class="gender-select">
<label class="gender-option" :class="{ 'gender-option--active': personaForm.gender === 'female' }">
<input type="radio" v-model="personaForm.gender" value="female" class="gender-radio" />
<span class="gender-option__label"></span>
</label>
<label class="gender-option" :class="{ 'gender-option--active': personaForm.gender === 'male' }">
<input type="radio" v-model="personaForm.gender" value="male" class="gender-radio" />
<span class="gender-option__label"></span>
</label>
</div>
<div class="voice-toggle">
<input type="checkbox" v-model="personaForm.neutralVoice" class="form-checkbox" id="neutralVoice" />
<label for="neutralVoice" class="text-sm text-gray-600 cursor-pointer">中性语气</label>
</div>
</div>
<span class="form-hint">勾选中性语气则使用中性语气取消勾选则跟随性别语气</span>
</div>
<!-- Wake Word -->
<div class="form-field">
<label class="form-label">唤醒词</label>
<span class="form-hint">评论以此词开头则唤醒该角色回复留空不启用</span>
<input type="text" v-model="personaForm.wakeWord" class="form-input" placeholder="如:小回小回" />
</div>
<!-- Prompt -->
<div class="form-field">
<label class="form-label">人格提示词</label>
<span class="form-hint">定义AI角色的性格语气和回复风格</span>
<textarea v-model="personaForm.prompt" rows="3" class="form-textarea" placeholder="例如:你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。"></textarea>
</div>
<!-- Is Default -->
<div class="form-row">
<div class="form-row__label">
<span class="form-label">设为默认角色</span>
<span class="form-hint">默认角色将用于没有指定角色的场景</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="personaForm.isDefault" />
<span class="toggle__track"><span class="toggle__thumb"></span></span>
</label>
</div>
<!-- Actions -->
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px">
<VButton @click="showPersonaDialog = false">取消</VButton>
<VButton type="primary" @click="savePersona" :disabled="personaSaving">
{{ personaSaving ? '保存中...' : '保存' }}
</VButton>
</div>
</div>
</div>
</div>
<!-- 导入确认对话框 -->
<VModal v-model:visible="showImportConfirm" title="确认导入配置">
<div class="space-y-3">
<p class="text-sm text-gray-600">导入将覆盖当前配置此操作不可撤销确定要继续吗</p>
<div v-if="importFileData" class="text-xs text-gray-500">
<p v-if="importFileData.configMap">- 包含插件设置</p>
<p v-if="importFileData.personas">- 包含 {{ importFileData.personas.length }} 个AI角色</p>
</div>
</div>
<template #footer>
<VSpace>
<VButton @click="showImportConfirm = false">取消</VButton>
<VButton type="primary" :loading="importLoading" @click="confirmImport">确认导入</VButton>
</VSpace>
</template>
</VModal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue"
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace, IconPlug } from "@halo-dev/components"
const activeTab = ref("basic")
const tabItems = [
{ label: "基本设置", value: "basic" },
{ label: "AI角色", value: "persona" },
{ label: "模型设置", value: "model" },
{ label: "Prompt", value: "prompt" },
{ label: "数据清理", value: "cleanup" },
]
const promptVariables = [
{ name: '{{persona_prompt}}', desc: '人格提示词' },
{ name: '{{safety_prompt}}', desc: '安全规范' },
{ name: '{{post_title}}', desc: '文章标题' },
{ name: '{{post_date}}', desc: '发布日期' },
{ name: '{{comment_count}}', desc: '评论数' },
{ name: '{{article}}', desc: '文章内容' },
{ name: '{{comment}}', desc: '评论内容' },
]
const promptPresets = [
{ key: 'friendly', label: '友好型', desc: '热情友好,像朋友聊天' },
{ key: 'professional', label: '专业型', desc: '严谨正式,有逻辑性' },
{ key: 'humorous', label: '幽默型', desc: '轻松诙谐,适当幽默' },
{ key: 'concise', label: '简洁型', desc: '一两句话,简洁明了' },
]
const enabledPresetKeys = computed({
get: () => {
const val: string | string[] = settings.prompt.enabledPresets as string | string[]
return Array.isArray(val) ? val : (val || '').split(',').map((s: string) => s.trim()).filter(Boolean)
},
set: (keys: string[]) => {
settings.prompt.enabledPresets = keys
}
})
const togglePreset = (key: string) => {
const keys = enabledPresetKeys.value.slice()
const idx = keys.indexOf(key)
if (idx >= 0) {
keys.splice(idx, 1)
} else {
keys.push(key)
}
enabledPresetKeys.value = keys
}
const isPresetEnabled = (key: string) => enabledPresetKeys.value.includes(key)
const loading = ref(false)
const saving = ref(false)
const showCommenterDialog = ref(false)
const commenterList = ref<{ displayName: string; email: string; avatarUrl: string }[]>([])
const commenterSearch = ref("")
const commenterLoading = ref(false)
const cleanupLoading = ref(false)
const cleanupResult = ref<number | null>(null)
// Persona management
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const personas = ref<any[]>([])
const personasLoading = ref(false)
const showPersonaDialog = ref(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const personaEditing = ref<any | null>(null)
const personaSaving = ref(false)
const personaForm = reactive({
displayName: '',
email: '',
gender: 'female',
neutralVoice: false,
wakeWord: '',
prompt: '',
isDefault: false,
})
const personaDialogAvatar = ref('')
const settings = reactive({
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", maxConversationRounds: 8, rateLimitPerMinute: 10 },
model: { modelName: "" },
prompt: { customPromptTemplate: "", enabledPresets: [] as string[] },
cleanup: { cleanupEnabled: true, retentionDays: 30 },
})
// Track unsaved changes: snapshot of settings after last fetch/save
const lastSavedSnapshot = ref("")
const hasUnsavedChanges = computed(() => {
return JSON.stringify(settings) !== lastSavedSnapshot.value
})
const configMapName = "comment-ai-autopilot-configmap"
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
// ===== Export / Import =====
const exportConfig = async () => {
try {
const { data } = await axiosInstance.get(`${apiBase}/export`)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `comment-ai-autopilot-config-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
Toast.success('配置已导出')
} catch (e) {
console.error('Failed to export config', e)
Toast.error('导出配置失败')
}
}
const importLoading = ref(false)
const showImportConfirm = ref(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const importFileData = ref<any>(null)
const handleImportFile = (event: Event) => {
const input = event.target as HTMLInputElement
if (!input.files || input.files.length === 0) return
const file = input.files[0]
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string)
if (!data.configMap && !data.personas) {
Toast.error('无效的配置文件格式')
return
}
importFileData.value = data
showImportConfirm.value = true
} catch {
Toast.error('无法解析配置文件')
}
}
reader.readAsText(file)
// 重置 input 以允许重复选择同一文件
input.value = ''
}
const confirmImport = async () => {
if (!importFileData.value) return
importLoading.value = true
try {
await axiosInstance.post(`${apiBase}/import`, importFileData.value)
Toast.success('配置已导入')
showImportConfirm.value = false
importFileData.value = null
// 刷新数据
await fetchPersonas()
await computePersonaAvatars()
} catch (e) {
console.error('Failed to import config', e)
Toast.error('导入配置失败')
} finally {
importLoading.value = false
}
}
const openCommenterDialog = async () => {
showCommenterDialog.value = true
commenterSearch.value = ""
commenterLoading.value = true
try {
const { data } = await axiosInstance.get("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/commenters")
commenterList.value = Array.isArray(data) ? data : (data.items || [])
} catch (e) {
console.error("Failed to fetch commenters", e)
Toast.error("获取评论者列表失败")
commenterList.value = []
} finally {
commenterLoading.value = false
}
}
const filteredCommenters = computed(() => {
const kw = commenterSearch.value.trim().toLowerCase()
if (!kw) return commenterList.value
return commenterList.value.filter(c => c.displayName.toLowerCase().includes(kw) || (c.email && c.email.toLowerCase().includes(kw)))
})
const addCommenter = (commenter: { displayName: string; email: string }) => {
const value = commenter.email || commenter.displayName
if (!value) return
const current = settings.basic.blockedCommenters.split(",").map(s => s.trim()).filter(Boolean)
if (current.includes(value)) { Toast.info("该评论者已在黑名单中"); return }
current.push(value)
settings.basic.blockedCommenters = current.join(",")
Toast.success("已添加到黑名单")
showCommenterDialog.value = false
}
const computeGravatarHash = async (email: string): Promise<string> => {
const normalized = email.trim().toLowerCase()
const encoder = new TextEncoder()
const data = encoder.encode(normalized)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
const performCleanup = async () => {
cleanupLoading.value = true
cleanupResult.value = null
try {
const { data } = await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/cleanup")
cleanupResult.value = data.deletedCount ?? data ?? 0
Toast.success(`清理完成,共删除 ${cleanupResult.value} 条记录`)
} catch { Toast.error("清理失败") }
finally { cleanupLoading.value = false }
}
// ===== Persona CRUD =====
const personasApiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas"
const fetchPersonas = async () => {
personasLoading.value = true
try {
const { data } = await axiosInstance.get(personasApiBase)
const list = Array.isArray(data) ? data : (data.items || [])
// 默认角色排到最上方
// eslint-disable-next-line @typescript-eslint/no-explicit-any
personas.value = list.sort((a: any, b: any) => {
if (a.spec?.isDefault && !b.spec?.isDefault) return -1
if (!a.spec?.isDefault && b.spec?.isDefault) return 1
return 0
})
} catch (e) {
console.error("Failed to fetch personas", e)
personas.value = []
} finally {
personasLoading.value = false
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getPersonaAvatar = (persona: any) => {
if (persona.spec?.email) {
return persona._avatarUrl || ''
}
return ''
}
// Compute avatar for each persona on list load
const computePersonaAvatars = async () => {
for (const p of personas.value) {
if (p.spec?.email) {
try {
const hash = await computeGravatarHash(p.spec.email)
p._avatarUrl = `https://cn.cravatar.com/avatar/${hash}`
} catch { p._avatarUrl = '' }
} else {
p._avatarUrl = ''
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const openPersonaDialog = async (persona: any | null) => {
personaEditing.value = persona
if (persona) {
personaForm.displayName = persona.spec.displayName || ''
personaForm.email = persona.spec.email || ''
personaForm.gender = persona.spec.gender || 'female'
personaForm.neutralVoice = persona.spec.neutralVoice || false
personaForm.wakeWord = persona.spec.wakeWord || ''
personaForm.prompt = persona.spec.prompt || ''
personaForm.isDefault = persona.spec.isDefault || false
} else {
personaForm.displayName = ''
personaForm.email = ''
personaForm.gender = 'female'
personaForm.neutralVoice = false
personaForm.wakeWord = ''
personaForm.prompt = ''
personaForm.isDefault = false
}
personaDialogAvatar.value = ''
if (personaForm.email) {
try {
const hash = await computeGravatarHash(personaForm.email)
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
} catch { personaDialogAvatar.value = '' }
}
showPersonaDialog.value = true
}
const savePersona = async () => {
if (!personaForm.displayName.trim()) {
Toast.warning('请输入角色昵称')
return
}
// 防止取消唯一默认角色的默认标记
if (!personaForm.isDefault && personaEditing.value?.spec?.isDefault) {
Toast.warning('必须保留至少一个默认角色,请先将其他角色设为默认')
return
}
personaSaving.value = true
try {
// 如果设为默认角色,先取消当前默认角色的标记
if (personaForm.isDefault) {
for (const p of personas.value) {
if (p.spec?.isDefault) {
// 编辑时跳过自身
if (personaEditing.value && p.metadata.name === personaEditing.value.metadata.name) continue
const { data: latest } = await axiosInstance.get(`${personasApiBase}/${p.metadata.name}`)
latest.spec.isDefault = false
await axiosInstance.put(`${personasApiBase}/${p.metadata.name}`, latest)
}
}
}
const payload = {
spec: {
displayName: personaForm.displayName,
email: personaForm.email,
gender: personaForm.gender,
neutralVoice: personaForm.neutralVoice,
wakeWord: personaForm.wakeWord,
prompt: personaForm.prompt,
isDefault: personaForm.isDefault,
},
apiVersion: 'comment-ai-autopilot.nxxy335.top/v1alpha1',
kind: 'AiPersona',
metadata: personaEditing.value
? { name: personaEditing.value.metadata.name }
: { generateName: 'persona-' },
}
if (personaEditing.value) {
// Update
const { data: existing } = await axiosInstance.get(`${personasApiBase}/${personaEditing.value.metadata.name}`)
const updated = { ...existing, spec: { ...existing.spec, ...payload.spec } }
await axiosInstance.put(`${personasApiBase}/${personaEditing.value.metadata.name}`, updated)
Toast.success('角色已更新')
} else {
// Create
await axiosInstance.post(personasApiBase, payload)
Toast.success('角色已添加')
}
showPersonaDialog.value = false
await fetchPersonas()
await computePersonaAvatars()
} catch (e) {
console.error('Failed to save persona', e)
Toast.error('保存角色失败')
} finally {
personaSaving.value = false
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deletePersona = async (persona: any) => {
if (persona.spec?.isDefault) {
Toast.warning('默认角色不可删除,请先将其他角色设为默认')
return
}
if (!confirm(`确定要删除角色「${persona.spec?.displayName || '未命名'}」吗?`)) return
try {
await axiosInstance.delete(`${personasApiBase}/${persona.metadata.name}`)
Toast.success('角色已删除')
await fetchPersonas()
await computePersonaAvatars()
} catch (e) {
console.error('Failed to delete persona', e)
Toast.error('删除角色失败')
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setDefaultPersona = async (persona: any) => {
try {
// 先取消当前默认角色:获取最新数据后更新
for (const p of personas.value) {
if (p.spec?.isDefault && p.metadata.name !== persona.metadata.name) {
const { data: latest } = await axiosInstance.get(`${personasApiBase}/${p.metadata.name}`)
latest.spec.isDefault = false
await axiosInstance.put(`${personasApiBase}/${p.metadata.name}`, latest)
}
}
// 设置新默认角色:获取最新数据后更新
const { data: latestTarget } = await axiosInstance.get(`${personasApiBase}/${persona.metadata.name}`)
latestTarget.spec.isDefault = true
await axiosInstance.put(`${personasApiBase}/${persona.metadata.name}`, latestTarget)
Toast.success('已设为默认角色')
await fetchPersonas()
} catch (e) {
console.error('Failed to set default persona', e)
Toast.error('设置默认角色失败')
}
}
// Watch persona dialog email for Gravatar preview
let emailDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => personaForm.email, (newEmail) => {
if (emailDebounceTimer) clearTimeout(emailDebounceTimer)
if (!newEmail || !newEmail.trim()) { personaDialogAvatar.value = ''; return }
emailDebounceTimer = setTimeout(async () => {
try {
const hash = await computeGravatarHash(newEmail)
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
} catch { personaDialogAvatar.value = '' }
}, 500)
})
const parseConfigSection = (data: Record<string, unknown>, key: string): Record<string, unknown> => {
const val = data[key]
if (!val) return {}
if (typeof val === 'string') {
try { return JSON.parse(val) } catch { return {} }
}
return val as Record<string, unknown>
}
const fetchSettings = async () => {
loading.value = true
try {
const { data } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
if (data.data) {
const d = data.data as Record<string, unknown>
const basic = parseConfigSection(d, 'basic') as Record<string, unknown>
const model = parseConfigSection(d, 'model') as Record<string, unknown>
const prompt = parseConfigSection(d, 'prompt') as Record<string, unknown>
const cleanup = parseConfigSection(d, 'cleanup') as Record<string, unknown>
if (Object.keys(basic).length) { settings.basic.autoReply = basic.autoReply !== false; settings.basic.autoPublish = basic.autoPublish !== false; settings.basic.maxRetryCount = (basic.maxRetryCount as number) || 3; settings.basic.blockedCommenters = (basic.blockedCommenters as string) || ""; settings.basic.maxConversationRounds = (basic.maxConversationRounds as number) || 8; settings.basic.rateLimitPerMinute = (basic.rateLimitPerMinute as number) || 10 }
if (Object.keys(model).length) { settings.model.modelName = (model.modelName as string) || "" }
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
}
} catch (e) {
console.error("Failed to fetch settings", e)
Toast.error("加载设置失败,请刷新页面重试")
}
finally {
loading.value = false
// Update snapshot after fetch to reset unsaved indicator
lastSavedSnapshot.value = JSON.stringify(settings)
}
}
const saveSettings = async () => {
saving.value = true
try {
const { data: latest } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
const updated = { ...latest }
updated.data = {
...updated.data,
basic: JSON.stringify(settings.basic),
model: JSON.stringify(settings.model),
prompt: JSON.stringify(settings.prompt),
cleanup: JSON.stringify(settings.cleanup),
}
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
Toast.success("设置已保存")
// Update snapshot after save to reset unsaved indicator
lastSavedSnapshot.value = JSON.stringify(settings)
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
finally { saving.value = false }
}
onMounted(async () => {
await fetchSettings()
await fetchPersonas()
await computePersonaAvatars()
})
</script>
<style scoped>
/* ===== Layout ===== */
.settings-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
padding: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow-x: auto;
}
.settings-tab {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-align: center;
}
.settings-tab:hover {
background: #f3f4f6;
color: #374151;
}
.settings-tab--active {
background: #3b82f6;
color: #fff;
}
.settings-tab--active:hover {
background: #2563eb;
color: #fff;
}
.settings-container {
display: grid;
grid-template-columns: 1fr 280px;
gap: 24px;
align-items: start;
}
@media (max-width: 1024px) {
.settings-container { grid-template-columns: 1fr; }
}
/* ===== Section ===== */
.settings-section {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-left: 4px solid transparent;
}
.section-header--blue { border-left-color: #3b82f6; background: linear-gradient(90deg, #eff6ff 0%, #fff 100%); }
.section-header--purple { border-left-color: #8b5cf6; background: linear-gradient(90deg, #f5f3ff 0%, #fff 100%); }
.section-header--green { border-left-color: #10b981; background: linear-gradient(90deg, #ecfdf5 0%, #fff 100%); }
.section-header--amber { border-left-color: #f59e0b; background: linear-gradient(90deg, #fffbeb 0%, #fff 100%); }
.section-header--red { border-left-color: #ef4444; background: linear-gradient(90deg, #fef2f2 0%, #fff 100%); }
.section-header__icon {
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.section-header--blue .section-header__icon { background: #dbeafe; color: #2563eb; }
.section-header--purple .section-header__icon { background: #ede9fe; color: #7c3aed; }
.section-header--green .section-header__icon { background: #d1fae5; color: #059669; }
.section-header--amber .section-header__icon { background: #fef3c7; color: #d97706; }
.section-header--red .section-header__icon { background: #fee2e2; color: #dc2626; }
.section-header__icon svg { width: 18px; height: 18px; }
.section-header__text h3 {
font-size: 15px; font-weight: 600; color: #111827; margin: 0; line-height: 1.3;
}
.section-header__text p {
font-size: 13px; color: #6b7280; margin: 2px 0 0; line-height: 1.4;
}
.section-body {
padding: 20px;
display: flex; flex-direction: column; gap: 20px;
}
/* ===== Form Row (Switch) ===== */
.form-row {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px;
background: #f9fafb;
border-radius: 10px;
border: 1px solid #f3f4f6;
}
.form-row--bordered {
border-top: 1px solid #e5e7eb;
border-radius: 0;
background: transparent;
margin: 0 -20px;
padding: 16px 20px;
}
.form-row__label { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.form-row__action { flex-shrink: 0; margin-left: 16px; }
/* ===== Form Field ===== */
.form-field {
display: flex; flex-direction: column; gap: 6px;
}
.form-field__header {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
/* ===== Form Typography ===== */
.form-label {
font-size: 14px; font-weight: 500; color: #1f2937; line-height: 1.4;
}
.form-hint {
font-size: 13px; color: #6b7280; line-height: 1.5;
}
.form-hint--inline {
font-size: 12px; color: #9ca3af;
}
.form-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 32px; padding: 2px 8px;
background: #eff6ff; color: #2563eb;
border-radius: 6px; font-size: 13px; font-weight: 600;
}
/* ===== Form Inputs ===== */
.form-input, .form-textarea {
width: 100%;
padding: 10px 14px;
font-size: 14px; line-height: 1.5;
color: #1f2937;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
outline: none;
transition: all 0.15s ease;
}
.form-input:focus, .form-textarea:focus {
background: #fff;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-input::placeholder, .form-textarea::placeholder {
color: #9ca3af;
}
.form-textarea--mono {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
/* ===== Toggle Switch ===== */
.toggle {
position: relative; display: inline-block; cursor: pointer;
flex-shrink: 0;
}
.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle__track {
display: block; width: 48px; height: 28px;
background: #d1d5db; border-radius: 14px;
transition: background 0.2s ease;
position: relative;
}
.toggle__thumb {
position: absolute; top: 3px; left: 3px;
width: 22px; height: 22px;
background: #fff; border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
transition: transform 0.2s ease;
}
.toggle input:checked + .toggle__track { background: #3b82f6; }
.toggle input:checked + .toggle__track .toggle__thumb { transform: translateX(20px); }
/* ===== Slider ===== */
.slider { padding: 4px 0; }
.slider__input {
width: 100%; height: 6px;
-webkit-appearance: none; appearance: none;
background: #e5e7eb; border-radius: 3px;
outline: none; cursor: pointer;
}
.slider__input::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 20px; height: 20px;
background: #3b82f6; border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
cursor: pointer;
}
.slider__marks {
display: flex; justify-content: space-between;
font-size: 11px; color: #9ca3af;
margin-top: 4px; padding: 0 2px;
}
/* ===== Variable Tags ===== */
.var-tags {
display: flex; flex-wrap: wrap; gap: 6px;
}
.var-tag {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px;
background: #f3f4f6; border-radius: 6px;
font-size: 12px; color: #4b5563;
}
.var-tag code {
font-family: 'Menlo', 'Monaco', monospace;
font-size: 11px;
background: #e5e7eb; padding: 1px 4px; border-radius: 3px;
color: #7c3aed;
}
/* ===== Preset Grid ===== */
.preset-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 4px;
}
.preset-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px 14px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.preset-item:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.preset-item--active {
border-color: #3b82f6;
background: #eff6ff;
}
.preset-checkbox {
width: 18px; height: 18px;
margin-top: 2px; flex-shrink: 0;
accent-color: #3b82f6;
cursor: pointer;
}
.preset-item__content {
display: flex; flex-direction: column; gap: 2px; min-width: 0;
}
.preset-item__label {
font-size: 14px; font-weight: 500; color: #1f2937;
}
.preset-item__desc {
font-size: 12px; color: #6b7280; line-height: 1.4;
}
/* ===== Persona Preview ===== */
.persona-preview {
display: flex; align-items: center; gap: 16px;
padding: 16px 20px;
background: linear-gradient(135deg, #faf5ff 0%, #f9fafb 100%);
border: 1px solid #ede9fe;
border-radius: 12px;
}
.persona-preview__avatar {
position: relative; width: 56px; height: 56px; flex-shrink: 0;
}
.persona-preview__avatar img {
width: 56px; height: 56px; border-radius: 50%;
object-fit: cover; border: 2px solid #e9d5ff;
}
.persona-preview__avatar-fallback {
width: 56px; height: 56px; border-radius: 50%;
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 20px; font-weight: 700;
}
.persona-preview__status {
position: absolute; bottom: 0; right: 0;
width: 14px; height: 14px;
background: #22c55e; border-radius: 50%;
border: 2.5px solid #fff;
}
.persona-preview__name {
font-size: 15px; font-weight: 600; color: #1f2937;
}
.persona-preview__role {
font-size: 13px; color: #6b7280; margin-top: 2px;
}
/* ===== Persona List & Card ===== */
.persona-empty {
padding: 32px 0; text-align: center; color: #9ca3af;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.persona-empty svg { width: 32px; height: 32px; }
.persona-empty span { font-size: 14px; }
.persona-list {
display: flex; flex-direction: column; gap: 10px;
}
.persona-card {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
transition: all 0.15s ease;
}
.persona-card:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.persona-card__avatar {
width: 44px; height: 44px; flex-shrink: 0;
position: relative;
}
.persona-card__avatar img {
width: 44px; height: 44px; border-radius: 50%;
object-fit: cover; border: 2px solid #e9d5ff;
}
.persona-card__avatar-fallback {
width: 44px; height: 44px; border-radius: 50%;
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; font-weight: 700;
}
.persona-card__info {
flex: 1; min-width: 0;
}
.persona-card__name {
font-size: 14px; font-weight: 600; color: #1f2937;
display: flex; align-items: center; gap: 6px;
}
.persona-card__badge {
display: inline-flex; align-items: center;
padding: 1px 8px; font-size: 11px; font-weight: 500;
background: #dbeafe; color: #2563eb; border-radius: 4px;
}
.persona-card__badge--female {
background: #fce7f3; color: #be185d;
}
.persona-card__badge--male {
background: #dbeafe; color: #1d4ed8;
}
.persona-card__badge--neutral-voice {
background: #f3f4f6; color: #4b5563;
}
.persona-card__badge--wake-word {
background: #fef3c7; color: #b45309;
}
.persona-card__prompt {
font-size: 12px; color: #6b7280; margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 400px;
}
.persona-card__actions {
display: flex; gap: 4px; flex-shrink: 0;
}
.btn-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
background: none; border: 1px solid transparent;
cursor: pointer; color: #6b7280; transition: all 0.15s;
}
.btn-icon:hover {
background: #f3f4f6; border-color: #e5e7eb; color: #374151;
}
.btn-icon svg { width: 16px; height: 16px; }
.btn-icon--danger:hover {
background: #fef2f2; border-color: #fecaca; color: #dc2626;
}
/* ===== Persona Dialog Preview ===== */
.persona-dialog-preview {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px;
background: linear-gradient(135deg, #faf5ff 0%, #f9fafb 100%);
border: 1px solid #ede9fe;
border-radius: 10px;
}
.form-checkbox {
width: 16px; height: 16px;
accent-color: #3b82f6;
cursor: pointer;
}
/* ===== Gender Select ===== */
.gender-voice-row {
display: flex;
align-items: center;
gap: 16px;
}
.gender-select {
display: flex; gap: 8px;
}
.gender-option {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
background: #f9fafb;
}
.gender-option:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.gender-option--active {
border-color: #3b82f6;
background: #eff6ff;
}
.gender-radio {
display: none;
}
.gender-option__label {
font-size: 14px; font-weight: 500; color: #374151;
}
.gender-option--active .gender-option__label {
color: #2563eb;
}
.voice-toggle {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ===== Button Link ===== */
.btn-link {
display: inline-flex; align-items: center; gap: 4px;
font-size: 13px; font-weight: 500; color: #3b82f6;
background: none; border: none; cursor: pointer;
padding: 0; transition: color 0.15s;
}
.btn-link:hover { color: #2563eb; }
.btn-link svg { width: 16px; height: 16px; }
/* ===== Cleanup Result ===== */
.cleanup-result {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px;
background: #ecfdf5; color: #059669;
border-radius: 8px; font-size: 13px; font-weight: 500;
}
.cleanup-result svg { width: 16px; height: 16px; }
/* ===== Sidebar ===== */
.settings-sidebar {
position: sticky; top: 80px;
}
.settings-sidebar__inner {
display: flex; flex-direction: column; gap: 16px;
}
.sidebar-card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
padding: 20px;
}
.sidebar-card--primary {
border-color: #dbeafe;
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
}
.sidebar-card__header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6;
}
.sidebar-card__badge {
display: inline-flex; align-items: center;
padding: 2px 8px; font-size: 11px; font-weight: 500;
background: #fef3c7; color: #d97706; border-radius: 4px;
}
.sidebar-card__title {
font-size: 14px; font-weight: 600; color: #1f2937;
margin: 0 0 14px; padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6;
}
.sidebar-card--primary .sidebar-card__title {
margin-bottom: 0; padding-bottom: 0; border-bottom: none;
}
.sidebar-card__actions {
display: flex; flex-direction: column; gap: 8px;
}
/* Align icon + text inside VButton (btn-content wraps the default slot) */
.sidebar-card__actions :deep(.btn-content),
.section-body :deep(.btn-content) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sidebar-card__vars {
display: flex; flex-direction: column; gap: 8px;
}
.sidebar-var {
display: flex; flex-direction: column; gap: 1px;
}
.sidebar-var code {
font-family: 'Menlo', 'Monaco', monospace;
font-size: 11px; background: #f3f4f6;
padding: 2px 6px; border-radius: 4px;
color: #7c3aed; display: inline-block; width: fit-content;
}
.sidebar-var span {
font-size: 12px; color: #6b7280;
}
/* ===== Dialog ===== */
.dialog-overlay {
position: fixed; inset: 0; z-index: 50;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);
}
.dialog {
width: 100%; max-width: 480px;
background: #fff; border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
overflow: hidden;
}
.dialog__header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px 0;
}
.dialog__header h3 { font-size: 16px; font-weight: 600; color: #111827; margin: 0; }
.dialog__close {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer;
color: #9ca3af; transition: all 0.15s;
}
.dialog__close:hover { background: #f3f4f6; color: #374151; }
.dialog__close svg { width: 20px; height: 20px; }
.dialog__search {
padding: 16px 24px; position: relative;
}
.dialog__search-icon {
position: absolute; left: 38px; top: 50%; transform: translateY(-50%);
width: 16px; height: 16px; color: #9ca3af;
}
.dialog__search-input {
width: 100%; padding: 10px 14px 10px 38px;
font-size: 14px; border: 1px solid #e5e7eb;
border-radius: 10px; outline: none; background: #f9fafb;
transition: all 0.15s;
}
.dialog__search-input:focus {
background: #fff; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.dialog__body {
max-height: 320px; overflow-y: auto; padding: 0 16px 16px;
}
.dialog__empty {
padding: 40px 0; text-align: center; color: #9ca3af;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.dialog__empty svg { width: 32px; height: 32px; }
.dialog__empty span { font-size: 14px; }
.dialog__list { display: flex; flex-direction: column; gap: 2px; }
.dialog__item {
display: flex; align-items: center; gap: 12px;
width: 100%; padding: 10px 12px;
background: none; border: none; border-radius: 10px;
cursor: pointer; text-align: left; transition: background 0.15s;
}
.dialog__item:hover { background: #eff6ff; }
.dialog__item-avatar {
width: 36px; height: 36px; border-radius: 50%;
background: #f3f4f6; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; color: #6b7280; flex-shrink: 0;
}
.dialog__item-info { min-width: 0; flex: 1; }
.dialog__item-name { font-size: 14px; font-weight: 500; color: #1f2937; }
.dialog__item-email { font-size: 12px; color: #9ca3af; margin-top: 1px; }
.dialog__item-add { width: 18px; height: 18px; color: #3b82f6; flex-shrink: 0; }
/* ===== Mobile Responsive ===== */
@media (max-width: 768px) {
.settings-page :deep(.page-header) {
flex-wrap: wrap;
gap: 8px;
}
.settings-page :deep(.page-header-actions) {
width: 100%;
overflow-x: auto;
}
.settings-page :deep(.page-header-actions .space-x-2) {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.settings-tabs {
gap: 2px;
padding: 3px;
border-radius: 10px;
}
.settings-tab {
padding: 8px 10px;
font-size: 13px;
}
.settings-container {
grid-template-columns: 1fr;
gap: 16px;
}
.settings-sidebar {
position: static;
order: -1;
}
.sidebar-card__actions {
flex-direction: row;
gap: 8px;
}
.sidebar-card__actions :deep(.btn) {
flex: 1;
}
.section-header {
padding: 12px 16px;
}
.section-body {
padding: 16px;
}
.preset-grid {
grid-template-columns: 1fr;
}
.form-row {
padding: 10px 12px;
}
.form-row--bordered {
margin: 0 -16px;
padding: 12px 16px;
}
.persona-card {
padding: 10px 12px;
}
.persona-card__prompt {
max-width: 100%;
}
.dialog {
max-width: calc(100vw - 32px);
margin: 16px;
border-radius: 12px;
}
.dialog__header {
padding: 16px 16px 0;
}
.dialog__search {
padding: 12px 16px;
}
.dialog__search-icon {
left: 28px;
}
.dialog__body {
max-height: 260px;
}
}
</style>