ai-chat-ui/src/components/modals/SettingsModal.vue

1061 lines
26 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>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="settings-modal">
<!-- 头部 -->
<div class="modal-header">
<div class="header-title">
<Settings :size="22" />
<h3>设置</h3>
</div>
<button class="close-btn" @click="close">
<X :size="20" />
</button>
</div>
<!-- 标签页 -->
<div class="settings-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="18" />
<span>{{ tab.label }}</span>
</button>
</div>
<!-- 内容区域 -->
<div class="settings-content">
<!-- 外观设置 -->
<div v-show="activeTab === 'appearance'" class="settings-section">
<div class="section-title">外观</div>
<!-- 主题 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">主题</span>
<span class="setting-desc">选择界面主题</span>
</div>
<div class="theme-options">
<button
v-for="theme in themeOptions"
:key="theme.value"
class="theme-btn"
:class="{ active: settings.theme === theme.value }"
@click="setTheme(theme.value)"
>
<component :is="theme.icon" :size="18" />
<span>{{ theme.label }}</span>
</button>
</div>
</div>
<!-- 字体大小 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">字体大小</span>
<span class="setting-desc">调整界面文字大小</span>
</div>
<div class="font-size-options">
<button
v-for="size in fontSizeOptions"
:key="size.value"
class="size-btn"
:class="{ active: settings.fontSize === size.value }"
@click="setFontSize(size.value)"
>
{{ size.label }}
</button>
</div>
</div>
<!-- 语言 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">语言</span>
<span class="setting-desc">选择界面语言</span>
</div>
<FormSelect
:model-value="settings.language"
:options="languageOptions"
@update:model-value="
updateSettings({ language: $event as string })
"
/>
</div>
<!-- 紧凑模式 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">紧凑模式</span>
<span class="setting-desc">减少界面元素间距</span>
</div>
<FormSwitch
:model-value="settings.compactMode"
@update:model-value="updateSettings({ compactMode: $event })"
/>
</div>
<!-- 显示时间戳 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">显示时间戳</span>
<span class="setting-desc">在消息旁显示发送时间</span>
</div>
<FormSwitch
:model-value="settings.showTimestamp"
@update:model-value="
updateSettings({ showTimestamp: $event })
"
/>
</div>
</div>
<!-- 对话设置 -->
<div v-show="activeTab === 'chat'" class="settings-section">
<div class="section-title">对话</div>
<!-- 发送方式 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">Enter 发送</span>
<span class="setting-desc"
>按 Enter 直接发送消息(关闭后需要 Ctrl+Enter</span
>
</div>
<FormSwitch
:model-value="settings.sendOnEnter"
@update:model-value="updateSettings({ sendOnEnter: $event })"
/>
</div>
<!-- 默认模型 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">默认模型</span>
<span class="setting-desc">新对话使用的默认 AI 模型</span>
</div>
<FormSelect
:model-value="defaultModel"
:options="modelOptions"
@update:model-value="
defaultModel = $event as string;
updateLocalStorage('defaultModel', $event as string);
"
/>
</div>
<!-- 温度 -->
<div class="setting-item vertical">
<div class="setting-info">
<span class="setting-label">默认温度</span>
<span class="setting-desc"
>控制回复的随机性0 = 精确1 = 创造性)</span
>
</div>
<div class="slider-wrapper">
<FormSlider
:model-value="settings.defaultTemperature"
:min="0"
:max="1"
:step="0.1"
@update:model-value="
updateSettings({ defaultTemperature: $event })
"
/>
<span class="slider-value">{{
settings.defaultTemperature.toFixed(1)
}}</span>
</div>
</div>
<!-- 最大 Token -->
<div class="setting-item vertical">
<div class="setting-info">
<span class="setting-label">最大回复长度</span>
<span class="setting-desc">单次回复的最大 Token 数量</span>
</div>
<div class="slider-wrapper">
<FormSlider
:model-value="settings.defaultMaxTokens"
:min="256"
:max="8192"
:step="256"
@update:model-value="
updateSettings({ defaultMaxTokens: $event })
"
/>
<span class="slider-value">{{
settings.defaultMaxTokens
}}</span>
</div>
</div>
<!-- 系统提示词 -->
<div class="setting-item vertical">
<div class="setting-info">
<span class="setting-label">默认系统提示词</span>
<span class="setting-desc">设定 AI 助手的角色和行为</span>
</div>
<textarea
class="prompt-textarea"
:value="settings.defaultSystemPrompt"
rows="4"
placeholder="输入系统提示词..."
@input="
updateSettings({
defaultSystemPrompt: (
$event.target as HTMLTextAreaElement
).value,
})
"
/>
</div>
</div>
<!-- 功能设置 -->
<div v-show="activeTab === 'features'" class="settings-section">
<div class="section-title">功能</div>
<!-- 声音 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">声音提示</span>
<span class="setting-desc">收到消息时播放提示音</span>
</div>
<FormSwitch
:model-value="settings.enableSound"
@update:model-value="updateSettings({ enableSound: $event })"
/>
</div>
<!-- 通知 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">桌面通知</span>
<span class="setting-desc">在后台时显示桌面通知</span>
</div>
<FormSwitch
:model-value="settings.enableNotification"
@update:model-value="
updateSettings({ enableNotification: $event })
"
/>
</div>
<!-- 自动保存 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">自动保存间隔</span>
<span class="setting-desc">对话自动保存的时间间隔(秒)</span>
</div>
<FormSelect
:model-value="settings.autoSaveInterval"
:options="autoSaveOptions"
@update:model-value="
updateSettings({ autoSaveInterval: $event as number })
"
/>
</div>
</div>
<!-- 隐私设置 -->
<div v-show="activeTab === 'privacy'" class="settings-section">
<div class="section-title">隐私</div>
<!-- 保存历史 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">保存对话历史</span>
<span class="setting-desc">在本地存储对话记录</span>
</div>
<FormSwitch
:model-value="settings.saveHistory"
@update:model-value="updateSettings({ saveHistory: $event })"
/>
</div>
<!-- 分析数据 -->
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">分享使用数据</span>
<span class="setting-desc">帮助我们改进产品(匿名)</span>
</div>
<FormSwitch
:model-value="settings.shareAnalytics"
@update:model-value="
updateSettings({ shareAnalytics: $event })
"
/>
</div>
<!-- 数据管理 -->
<div class="section-title" style="margin-top: 24px">数据管理</div>
<div class="data-actions">
<button class="data-btn" @click="handleExportSettings">
<Download :size="18" />
<span>导出设置</span>
</button>
<button class="data-btn" @click="handleImportSettings">
<Upload :size="18" />
<span>导入设置</span>
</button>
<button class="data-btn danger" @click="handleClearData">
<Trash2 :size="18" />
<span>清除所有数据</span>
</button>
</div>
<input
ref="importInputRef"
type="file"
accept=".json"
hidden
@change="handleImportFile"
/>
</div>
<!-- 关于 -->
<div v-show="activeTab === 'about'" class="settings-section">
<div class="section-title">关于</div>
<div class="about-content">
<div class="app-info">
<div class="app-logo">
<Bot :size="40" />
</div>
<h4>Kexue AI Chat</h4>
<p class="version">版本 1.0.0</p>
<p class="desc">企业级 AI 对话聊天界面</p>
</div>
<div class="about-links">
<a href="#" class="about-link">
<FileText :size="18" />
<span>使用文档</span>
<ExternalLink :size="14" />
</a>
<a href="#" class="about-link">
<MessageSquare :size="18" />
<span>反馈建议</span>
<ExternalLink :size="14" />
</a>
<a href="#" class="about-link">
<Shield :size="18" />
<span>隐私政策</span>
<ExternalLink :size="14" />
</a>
</div>
<div class="copyright">
© 2024 Your Company. All rights reserved.
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="modal-footer">
<button class="reset-btn" @click="handleResetSettings">
<RotateCcw :size="16" />
<span>重置为默认</span>
</button>
<button class="done-btn" @click="close">完成</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useSettingsStore } from "@/stores/settings";
import FormSwitch from "@/components/ui/FormSwitch.vue";
import FormSlider from "@/components/ui/FormSlider.vue";
import FormSelect from "@/components/ui/FormSelect.vue";
import {
Settings,
X,
Sun,
Moon,
Monitor,
Palette,
MessageSquare,
Zap,
Shield,
Info,
Download,
Upload,
Trash2,
Bot,
FileText,
ExternalLink,
RotateCcw,
} from "@/components/icons";
import type { AppSettings } from "@/types/chat";
import { chatApi } from "@/services/api.ts";
const settingsStore = useSettingsStore();
const { showSettingsModal: visible, settings } = storeToRefs(settingsStore);
const availableModels: any = ref([]);
const defaultModel: any = ref(localStorage.getItem("defaultModel"));
onMounted(() => {
// chatApi.getModels().then((res: any) => {
// availableModels.value = res;
// if (!defaultModel.value) defaultModel.value = res[0].name;
// });
});
const activeTab = ref("appearance");
const importInputRef = ref<HTMLInputElement | null>(null);
// 标签页配置
const tabs = [
{ id: "appearance", label: "外观", icon: Palette },
{ id: "chat", label: "对话", icon: MessageSquare },
{ id: "features", label: "功能", icon: Zap },
{ id: "privacy", label: "隐私", icon: Shield },
{ id: "about", label: "关于", icon: Info },
];
// 主题选项
const themeOptions = [
{ value: "light" as const, label: "浅色", icon: Sun },
{ value: "dark" as const, label: "深色", icon: Moon },
{ value: "system" as const, label: "系统", icon: Monitor },
];
// 字体大小选项
const fontSizeOptions = [
{ value: "small" as const, label: "小" },
{ value: "medium" as const, label: "中" },
{ value: "large" as const, label: "大" },
];
// 语言选项
const languageOptions = [
{ value: "zh-CN", label: "简体中文" },
{ value: "zh-TW", label: "繁體中文" },
{ value: "en-US", label: "English" },
{ value: "ja-JP", label: "日本語" },
];
// 模型选项 - 添加安全检查
const modelOptions = computed(() => {
if (!availableModels.value || !Array.isArray(availableModels.value)) {
return [{ value: "gpt-4", label: "GPT-4", description: "默认模型" }];
}
return availableModels.value?.map((model: any) => ({
value: model.id,
label: model.name,
description: model.description,
}));
});
// 自动保存选项
const autoSaveOptions = [
{ value: 10, label: "10 秒" },
{ value: 30, label: "30 秒" },
{ value: 60, label: "1 分钟" },
{ value: 300, label: "5 分钟" },
];
function updateLocalStorage(name: string, data: string) {
localStorage.setItem(name, data);
}
function close() {
settingsStore.closeSettingsModal();
}
function setTheme(theme: AppSettings["theme"]) {
settingsStore.setTheme(theme);
}
function setFontSize(size: AppSettings["fontSize"]) {
settingsStore.setFontSize(size);
}
function updateSettings(updates: Partial<AppSettings>) {
settingsStore.updateSettings(updates);
}
function handleResetSettings() {
if (confirm("确定要将所有设置重置为默认值吗?")) {
settingsStore.resetSettings();
}
}
function handleExportSettings() {
const json = settingsStore.exportSettings();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chat-settings.json";
a.click();
URL.revokeObjectURL(url);
}
function handleImportSettings() {
importInputRef.value?.click();
}
function handleImportFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const json = e.target?.result as string;
if (settingsStore.importSettings(json)) {
alert("设置导入成功!");
} else {
alert("设置导入失败,请检查文件格式。");
}
};
reader.readAsText(file);
}
function handleClearData() {
if (
confirm(
"确定要清除所有数据吗?这将删除所有对话历史和设置。此操作不可恢复!",
)
) {
localStorage.clear();
location.reload();
}
}
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.settings-modal {
width: 640px;
max-width: 90vw;
max-height: 85vh;
background: white;
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
svg {
color: #3b82f6;
}
h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1f2937;
.dark & {
background: #374151;
color: #f3f4f6;
}
}
}
.settings-tabs {
display: flex;
gap: 4px;
padding: 12px 24px;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.settings-section {
animation: fadeIn 0.2s ease;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #f3f4f6;
.dark & {
border-bottom-color: #2d2d3d;
}
&:last-child {
border-bottom: none;
}
&.vertical {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
}
.setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label {
font-size: 14px;
font-weight: 500;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.setting-desc {
font-size: 12px;
color: #9ca3af;
}
.theme-options {
display: flex;
gap: 8px;
}
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #9ca3af;
}
&:hover {
border-color: #3b82f6;
color: #3b82f6;
}
&.active {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
color: #3b82f6;
}
}
.font-size-options {
display: flex;
gap: 4px;
background: #f3f4f6;
padding: 4px;
border-radius: 10px;
.dark & {
background: #2d2d3d;
}
}
.size-btn {
padding: 6px 16px;
border: none;
border-radius: 8px;
background: transparent;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #374151;
.dark & {
color: #e5e7eb;
}
}
&.active {
background: white;
color: #3b82f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.dark & {
background: #374151;
}
}
}
.slider-wrapper {
display: flex;
align-items: center;
gap: 16px;
.form-slider {
flex: 1;
}
}
.slider-value {
min-width: 48px;
text-align: right;
font-size: 14px;
font-weight: 500;
color: #3b82f6;
}
.prompt-textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
font-family: inherit;
font-size: 14px;
color: #1f2937;
resize: vertical;
min-height: 100px;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.data-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.data-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
color: #374151;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
color: #3b82f6;
}
&.danger {
border-color: #fecaca;
color: #ef4444;
&:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
}
}
}
.about-content {
text-align: center;
padding: 20px 0;
}
.app-info {
margin-bottom: 32px;
.app-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
border-radius: 20px;
color: white;
margin-bottom: 16px;
}
h4 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.version {
margin: 0 0 8px;
font-size: 14px;
color: #3b82f6;
}
.desc {
margin: 0;
font-size: 14px;
color: #6b7280;
}
}
.about-links {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 280px;
margin: 0 auto 32px;
}
.about-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #374151;
text-decoration: none;
transition: all 0.2s ease;
.dark & {
border-color: #374151;
color: #e5e7eb;
}
span {
flex: 1;
text-align: left;
}
&:hover {
border-color: #3b82f6;
color: #3b82f6;
}
}
.copyright {
font-size: 12px;
color: #9ca3af;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
.dark & {
border-top-color: #2d2d3d;
}
}
.reset-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
border-color: #374151;
}
&:hover {
border-color: #f59e0b;
color: #f59e0b;
}
}
.done-btn {
padding: 10px 24px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
}
// 动画
.modal-enter-active,
.modal-leave-active {
transition: all 0.25s ease;
.settings-modal {
transition: all 0.25s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.settings-modal {
transform: scale(0.9);
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>