1631 lines
64 KiB
Vue
1631 lines
64 KiB
Vue
<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>
|