Files
u-ppt/src/components/modals/UnifiedSettingsModal.vue
T

467 lines
17 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.
<!-- =====================================================================
UnifiedSettingsModal.vue 统一设置弹窗
合并原 SettingsModalAI/中继 OssSettingsModal云存储),tab 分区切换
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg, OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
export type SettingsTab = 'ai' | 'oss' | 'relay'
const props = defineProps<{ visible: boolean; initialTab?: SettingsTab }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const tab = ref<SettingsTab>('ai')
watch(() => props.visible, (v) => {
if (v) { tab.value = props.initialTab || 'ai'; loadFromStore() }
}, { immediate: true })
/* ==================== AI 模型 ==================== */
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const aiForm = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/* ==================== 云存储(OSS ==================== */
const ossForm = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
/** 从 store 读取并填充两份表单 */
function loadFromStore() {
const c = store.getCfg()
aiForm.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
ossForm.value = { ...ossForm.value, ...store.getOssCfg() }
}
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
aiForm.value.protocol = p.protocol
aiForm.value.base = p.base
aiForm.value.model = p.model
}
function onProviderChange() {
if (aiForm.value.preset !== 'custom') applyPreset(aiForm.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!aiForm.value.base.trim() || aiForm.value.base.trim() === other.base) aiForm.value.base = d.base
if (!aiForm.value.model.trim() || aiForm.value.model.trim() === other.model) aiForm.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(aiForm.value.protocol)
}
/* ==================== 保存 ==================== */
function save() {
if (tab.value === 'ai') {
const f = aiForm.value
store.setCfg({
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
const label = f.preset === 'custom'
? (f.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[f.preset]?.label || f.preset)
emit('toast', '已保存 AI 设置(' + label + '')
} else if (tab.value === 'oss') {
saveOss()
} else {
// 中继 tab:只更新中继三项,AI 其余字段原样回写
const f = aiForm.value
const c = store.getCfg()
store.setCfg({
...c,
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
emit('toast', '已保存 Agent 中继设置')
}
emit('close')
}
function saveOss() {
const c = ossForm.value
store.setOssCfg({
enabled: c.enabled,
provider: c.provider,
dir: c.dir.trim(),
aliAccessKeyId: c.aliAccessKeyId.trim(),
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
aliEndpoint: c.aliEndpoint.trim(),
aliBucket: c.aliBucket.trim(),
qiniuAccessKey: c.qiniuAccessKey.trim(),
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
}
async function manualSync() {
if (!canUploadNow()) {
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
return
}
const r = await syncPending()
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal unified-modal">
<h3>设置</h3>
<!-- tab -->
<div class="tab-bar">
<button class="tab-btn" :class="{ active: tab === 'ai' }" @click="tab = 'ai'">AI 模型</button>
<button class="tab-btn" :class="{ active: tab === 'oss' }" @click="tab = 'oss'">云存储</button>
<button class="tab-btn" :class="{ active: tab === 'relay' }" @click="tab = 'relay'">Agent 中继</button>
</div>
<!-- 内容区统一 min-height避免切 tab 时弹窗高度跳动 -->
<div class="settings-body">
<!-- ==================== AI 模型 ==================== -->
<template v-if="tab === 'ai'">
<p class="modal-tip">配置大模型服务商数据仅保存在本地浏览器</p>
<div class="form-row">
<label>服务商</label>
<select v-model="aiForm.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="aiForm.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="aiForm.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="aiForm.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="aiForm.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL可选</label>
<input type="text" v-model="aiForm.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型可选用于 AI 配图</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="aiForm.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="aiForm.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="aiForm.imgModel" placeholder="dall-e-3" />
</div>
</template>
<!-- ==================== 云存储 ==================== -->
<template v-else-if="tab === 'oss'">
<p class="modal-tip">
开启后图片/视频等媒体自动上传对象存储避免撑爆本地离线时先暂存本地联网后自动同步云端密钥仅保存在本地
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="ossForm.enabled" />
<span>{{ ossForm.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="ossForm.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="ossForm.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="ossForm.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="ossForm.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="ossForm.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="ossForm.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="ossForm.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.aliBucket" placeholder="bucket 名称" />
</div>
</template>
<!-- 七牛云 -->
<template v-else>
<div class="section-title">七牛云 Kodo</div>
<div class="form-row">
<label>AccessKey</label>
<input type="text" v-model="ossForm.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="ossForm.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="ossForm.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名可选</label>
<input type="text" v-model="ossForm.qiniuUpHost" placeholder="留空自动探测区域" />
</div>
</template>
<div class="sync-bar">
<div class="sync-info">
<div>
待同步 {{ syncState.pending }} <template v-if="syncState.syncing">同步中…)</template>
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
</div>
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
</div>
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
</div>
</template>
<!-- ==================== Agent 中继 ==================== -->
<template v-else>
<p class="modal-tip">通过 u-relay 中继接收远端 Agent 指令可选功能三项配置齐备即自动启用</p>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="aiForm.relayUrl" placeholder="wss://u-work.1216.top(可省略 /ws/miniapp" />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="aiForm.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="aiForm.relayDeviceId" placeholder="device_id" />
</div>
</template>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
/* 弹窗加宽(仅本组件) */
.unified-modal { width: 520px; display: flex; flex-direction: column; }
/* 内容区统一最小高度:以最高 tab(云存储)为基准,切 tab 不再跳动 */
.settings-body { min-height: 440px; }
/* tab 栏:下划线高亮 */
.tab-bar {
display: flex;
gap: 18px;
margin-bottom: 16px;
border-bottom: 1px solid var(--ui-border);
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 2px;
font-size: 13.5px;
color: var(--ui-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn.active { color: var(--ui-primary, #5b5bd6); border-bottom-color: var(--ui-primary, #5b5bd6); font-weight: 600; }
/* 紧凑化:label 与输入同行 */
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.form-row > label {
display: block;
width: 110px;
flex-shrink: 0;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
.form-row > select,
.form-row > input { flex: 1; min-width: 0; }
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
.section-title.first { margin-top: 0; padding-top: 0; border-top: none; }
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
flex: 1;
}
.switch-line input { width: auto; }
.sync-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--ui-border);
}
.sync-info { font-size: 12px; color: var(--ui-muted); }
.sync-err { color: #e5484d; }
.sync-err-detail {
margin-top: 4px;
max-width: 420px;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: #e5484d;
}
</style>