新增: Agent 大纲协议(outline/gen_page 动作)与设置弹窗统一(UnifiedSettingsModal 归并 Settings/OSS/Agent 三弹窗)
This commit is contained in:
@@ -1,197 +0,0 @@
|
||||
<!-- =====================================================================
|
||||
OssSettingsModal.vue — 云存储(OSS)设置弹窗
|
||||
所有媒体资产上云,避免撑爆本地存储;离线暂存本地,联网自动同步
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { OssCfg } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { syncState, syncPending, canUploadNow } from '../../core/assets'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
const form = ref<OssCfg>({
|
||||
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
||||
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
||||
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
|
||||
viewerBase: ''
|
||||
})
|
||||
|
||||
function loadFromStore() {
|
||||
form.value = { ...form.value, ...store.getOssCfg() }
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) loadFromStore()
|
||||
}, { immediate: true })
|
||||
|
||||
function save() {
|
||||
const c = form.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 ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
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">
|
||||
<h3>云存储(OSS)设置</h3>
|
||||
<p class="modal-tip">
|
||||
开启后,图片/视频等媒体自动上传对象存储,避免撑爆本地。离线时先暂存本地,联网后自动同步云端。密钥仅保存在本地。
|
||||
</p>
|
||||
|
||||
<div class="form-row">
|
||||
<label>启用云存储</label>
|
||||
<label class="switch-line">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
<span>{{ form.enabled ? '开' : '关(维持本地内嵌)' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>服务商</label>
|
||||
<select v-model="form.provider">
|
||||
<option value="aliyun">阿里云 OSS</option>
|
||||
<option value="qiniu">七牛云 Kodo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>所属目录</label>
|
||||
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>查看器地址</label>
|
||||
<input type="text" v-model="form.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
|
||||
</div>
|
||||
|
||||
<!-- 阿里云 -->
|
||||
<template v-if="form.provider === 'aliyun'">
|
||||
<div class="section-title">阿里云 OSS</div>
|
||||
<div class="form-row">
|
||||
<label>AccessKeyId</label>
|
||||
<input type="text" v-model="form.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>AccessKeySecret</label>
|
||||
<input type="password" v-model="form.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Endpoint</label>
|
||||
<input type="text" v-model="form.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bucket</label>
|
||||
<input type="text" v-model="form.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="form.qiniuAccessKey" placeholder="AK" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SecretKey</label>
|
||||
<input type="password" v-model="form.qiniuSecretKey" placeholder="SK" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bucket</label>
|
||||
<input type="text" v-model="form.qiniuBucket" placeholder="空间名称" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>加速域名</label>
|
||||
<input type="text" v-model="form.qiniuDomain" placeholder="https://cdn.example.com" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>上传域名(可选)</label>
|
||||
<input type="text" v-model="form.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>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">取消</button>
|
||||
<button class="btn primary" @click="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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);
|
||||
}
|
||||
.switch-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
.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>
|
||||
@@ -1,216 +0,0 @@
|
||||
<!-- =====================================================================
|
||||
SettingsModal.vue — AI 设置弹窗
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { AiCfg } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
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 form = ref<AiCfg>({
|
||||
preset: 'zhipu', protocol: 'openai',
|
||||
base: '', key: '', model: '', proxy: '',
|
||||
imgBase: '', imgKey: '', imgModel: '',
|
||||
relayUrl: '', relayToken: '', relayDeviceId: ''
|
||||
})
|
||||
|
||||
/** 从 store 读取并填充表单 */
|
||||
function loadFromStore() {
|
||||
const c = store.getCfg()
|
||||
form.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 || ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) loadFromStore()
|
||||
}, { immediate: true })
|
||||
|
||||
function applyPreset(key: string) {
|
||||
const p = PRESETS[key]
|
||||
if (!p) return
|
||||
form.value.protocol = p.protocol
|
||||
form.value.base = p.base
|
||||
form.value.model = p.model
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
|
||||
}
|
||||
|
||||
function applyProtoDefaults(p: string) {
|
||||
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
|
||||
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
|
||||
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
|
||||
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
|
||||
}
|
||||
|
||||
function onProtocolChange() {
|
||||
applyProtoDefaults(form.value.protocol)
|
||||
}
|
||||
|
||||
function save() {
|
||||
store.setCfg({
|
||||
preset: form.value.preset,
|
||||
protocol: form.value.protocol,
|
||||
base: form.value.base.trim(),
|
||||
key: form.value.key.trim(),
|
||||
model: form.value.model.trim(),
|
||||
proxy: form.value.proxy.trim(),
|
||||
imgBase: (form.value.imgBase || '').trim(),
|
||||
imgKey: (form.value.imgKey || '').trim(),
|
||||
imgModel: (form.value.imgModel || '').trim(),
|
||||
relayUrl: (form.value.relayUrl || '').trim(),
|
||||
relayToken: (form.value.relayToken || '').trim(),
|
||||
relayDeviceId: (form.value.relayDeviceId || '').trim()
|
||||
})
|
||||
const label = form.value.preset === 'custom'
|
||||
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
|
||||
: (PRESETS[form.value.preset]?.label || form.value.preset)
|
||||
emit('toast', '已保存 AI 设置(' + label + ')')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<h3>AI 设置</h3>
|
||||
<p class="modal-tip">配置大模型服务商。数据仅保存在本地浏览器。</p>
|
||||
|
||||
<div class="form-row">
|
||||
<label>服务商</label>
|
||||
<select v-model="form.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="form.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="form.base" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>API Key</label>
|
||||
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>模型</label>
|
||||
<input type="text" v-model="form.model" placeholder="模型名称" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>代理 URL(可选)</label>
|
||||
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
|
||||
</div>
|
||||
|
||||
<div class="section-title">图像模型(可选,用于 AI 配图)</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像 Base URL</label>
|
||||
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像 API Key</label>
|
||||
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像模型</label>
|
||||
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
|
||||
</div>
|
||||
|
||||
<div class="section-title">Agent 中继(可选,三项齐备即启用 Agent 模式)</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>中继 URL</label>
|
||||
<input type="text" v-model="form.relayUrl" placeholder="wss://..." />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>中继 Token</label>
|
||||
<input type="password" v-model="form.relayToken" placeholder="设备配对 Token" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>设备 ID</label>
|
||||
<input type="text" v-model="form.relayDeviceId" placeholder="device_id" />
|
||||
</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>
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,466 @@
|
||||
<!-- =====================================================================
|
||||
UnifiedSettingsModal.vue — 统一设置弹窗
|
||||
合并原 SettingsModal(AI/中继)与 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>
|
||||
Reference in New Issue
Block a user