Initial commit: clean project structure
- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
This commit is contained in:
475
admin-frontend/src/views/AdminProviders.vue
Normal file
475
admin-frontend/src/views/AdminProviders.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<BaseCard v-if="!isLoggedIn" class="max-w-md mx-auto mt-20" padding="lg">
|
||||
<h1 class="text-3xl font-bold gradient-text mb-4 text-center">DreamWeaver 控制台</h1>
|
||||
<p class="text-sm text-gray-500 mb-8 text-center">请登录以管理 AI 计算引擎与策略</p>
|
||||
<form @submit.prevent="login" class="space-y-6">
|
||||
<BaseInput v-model="loginForm.username" label="管理员账号" required placeholder="admin" />
|
||||
<BaseInput v-model="loginForm.password" label="密钥密码" type="password" required />
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<span v-if="loginError" class="text-sm text-red-500 font-medium">{{ loginError }}</span>
|
||||
<BaseButton type="submit" class="w-full">进入控制台</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseCard>
|
||||
|
||||
<div v-else class="space-y-8">
|
||||
<!-- Header -->
|
||||
<header class="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text">引擎调度中心</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Provider Orchestration & Strategy</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
系统运行中
|
||||
</div>
|
||||
<BaseButton variant="ghost" @click="logout" size="sm">退出</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
|
||||
<!-- Left: Status & Defaults -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<BaseCard padding="md" title="出厂默认策略 (.env)">
|
||||
<div class="space-y-4">
|
||||
<div v-for="(providers, type) in defaults" :key="type" class="p-3 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ type }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="p in providers"
|
||||
:key="p"
|
||||
@click="cloneDefault(type, p)"
|
||||
class="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 font-mono hover:border-indigo-300 hover:text-indigo-600 hover:shadow-sm transition-all cursor-pointer"
|
||||
title="点击基于此默认配置创建"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2 px-1">
|
||||
* 当数据库中未配置或全部禁用时,系统将回退到上述出厂设置。
|
||||
</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard padding="md" title="可用驱动 (Adapters)">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="adapter in availableAdapters" :key="adapter"
|
||||
class="px-2 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-100">
|
||||
{{ adapter.split(':')[1] }} <span class="opacity-50 text-[10px]">({{ adapter.split(':')[0] }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
<!-- Right: Active Manager -->
|
||||
<div class="lg:col-span-3 space-y-6">
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex space-x-1 bg-gray-100 p-1 rounded-xl w-fit">
|
||||
<button
|
||||
v-for="tab in ['text', 'image', 'tts', 'storybook']"
|
||||
:key="tab"
|
||||
@click="activeTab = tab"
|
||||
class="px-6 py-2 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
:class="activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
>
|
||||
{{ tab.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Provider Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Add New Card -->
|
||||
<button @click="openCreateModal" class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl hover:border-indigo-300 hover:bg-indigo-50 transition-all group min-h-[200px]">
|
||||
<div class="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-6 h-6 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-600 group-hover:text-indigo-600">添加新的 {{ activeTab }} 引擎</span>
|
||||
</button>
|
||||
|
||||
<!-- Existing Cards -->
|
||||
<div v-for="p in filteredProviders" :key="p.id"
|
||||
class="relative p-6 bg-white rounded-2xl border transition-all duration-200 group hover:shadow-lg"
|
||||
:class="p.enabled ? 'border-gray-200' : 'border-gray-100 opacity-75 bg-gray-50'"
|
||||
>
|
||||
<!-- Enabled Toggle -->
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg text-gray-900">{{ p.name }}</h3>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-mono bg-gray-100 text-gray-600 border border-gray-200">
|
||||
{{ p.adapter }}
|
||||
</span>
|
||||
<span v-if="p.model" class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-100 truncate max-w-[120px]">
|
||||
{{ p.model }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-500 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<span>Priority:</span>
|
||||
<span class="font-medium text-gray-700">{{ p.priority }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>API Key:</span>
|
||||
<span :class="p.has_api_key ? 'text-green-600' : 'text-yellow-600'">
|
||||
{{ p.has_api_key ? '● Configured' : '○ Not Set (Env?)' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-4 border-t border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<BaseButton size="sm" variant="secondary" class="flex-1" @click="edit(p)">配置</BaseButton>
|
||||
<BaseButton size="sm" variant="ghost" class="text-red-500 hover:bg-red-50" @click="remove(p)">删除</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edit/Create Modal -->
|
||||
<div v-if="editing" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="reset">
|
||||
<BaseCard class="w-full max-w-2xl max-h-[90vh] overflow-y-auto" padding="lg" @click.stop>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold gradient-text">{{ form.id ? '编辑引擎配置' : '添加新引擎' }}</h2>
|
||||
<button @click="reset" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<BaseInput v-model="form.name" label="名称 (显示名)" placeholder="如: Pro GPT-4" required />
|
||||
|
||||
<BaseSelect
|
||||
v-model="form.adapter"
|
||||
label="驱动程序 (Adapter)"
|
||||
:options="adapterOptions"
|
||||
required
|
||||
description="选择底层的 API 驱动协议"
|
||||
/>
|
||||
|
||||
<BaseInput v-model="form.model" label="模型名称 (Model)" placeholder="如: gpt-4o, minimax-v2" description="具体调用的模型ID" />
|
||||
|
||||
<BaseInput v-model.number="form.priority" label="优先级 (0-100)" type="number" description="数字越大越优先" />
|
||||
|
||||
<div class="md:col-span-2 p-4 bg-gray-50 rounded-xl border border-gray-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-gray-700">密钥与连接</h3>
|
||||
<BaseInput v-model="form.api_key" label="API Key" type="password" placeholder="留空则使用 .env 配置" :required="!form.id && !form.config_ref" />
|
||||
<BaseInput v-model="form.api_base" label="API Endpoint / Group ID" placeholder="https://... 或 Group ID" />
|
||||
<BaseInput v-model="form.config_ref" label="Fallback Env Var" placeholder="如: OPENAI_API_KEY (高级)" />
|
||||
</div>
|
||||
|
||||
<!-- MiniMax Specific Config -->
|
||||
<div v-if="form.adapter === 'minimax'" class="md:col-span-2 p-4 bg-indigo-50 rounded-xl border border-indigo-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-indigo-700">MiniMax 语音参数</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseInput v-model="form.config_json.voice_id" label="音色 ID (Voice ID)" placeholder="male-qn-qingse" />
|
||||
<BaseInput v-model="form.config_json.emotion" label="情感 (Emotion)" placeholder="happy / sad / angry" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600">语速 (Speed)</label>
|
||||
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.speed || 1.0 }}x</span>
|
||||
</div>
|
||||
<input type="range" v-model.number="form.config_json.speed" min="0.5" max="2.0" step="0.1" class="w-full accent-indigo-600" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600">音量 (Volume)</label>
|
||||
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.vol || 1.0 }}</span>
|
||||
</div>
|
||||
<input type="range" v-model.number="form.config_json.vol" min="0.1" max="5.0" step="0.1" class="w-full accent-indigo-600" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600">音高 (Pitch)</label>
|
||||
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.pitch || 0 }}</span>
|
||||
</div>
|
||||
<input type="range" v-model.number="form.config_json.pitch" min="-12" max="12" step="1" class="w-full accent-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CQTAI Specific Config -->
|
||||
<div v-if="form.adapter === 'cqtai'" class="md:col-span-2 p-4 bg-purple-50 rounded-xl border border-purple-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-purple-700">CQTAI 画图参数</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseSelect
|
||||
v-model="form.config_json.aspect_ratio"
|
||||
label="画面比例 (Aspect Ratio)"
|
||||
:options="[
|
||||
{ value: '1:1', label: '1:1 (正方形)' },
|
||||
{ value: '16:9', label: '16:9 (横屏)' },
|
||||
{ value: '9:16', label: '9:16 (竖屏)' },
|
||||
{ value: '4:3', label: '4:3 (传统)' },
|
||||
{ value: '3:4', label: '3:4 (海报)' }
|
||||
]"
|
||||
placeholder="默认 1:1"
|
||||
/>
|
||||
|
||||
<BaseSelect
|
||||
v-model="form.config_json.resolution"
|
||||
label="分辨率 (Resolution)"
|
||||
:options="[
|
||||
{ value: '1K', label: '1K (标准)' },
|
||||
{ value: '2K', label: '2K (高清)' },
|
||||
{ value: '4K', label: '4K (超清)' }
|
||||
]"
|
||||
placeholder="默认 1K"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antigravity Specific Config -->
|
||||
<div v-if="form.adapter === 'antigravity'" class="md:col-span-2 p-4 bg-emerald-50 rounded-xl border border-emerald-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-emerald-700">Antigravity 画图参数 (OpenAI Compatible)</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseSelect
|
||||
v-model="form.config_json.size"
|
||||
label="图像尺寸 (Size)"
|
||||
:options="[
|
||||
{ value: '1024x1024', label: '1024x1024 (1:1 正方形)' },
|
||||
{ value: '1280x720', label: '1280x720 (16:9 横屏)' },
|
||||
{ value: '720x1280', label: '720x1280 (9:16 竖屏)' },
|
||||
{ value: '1216x896', label: '1216x896 (4:3 传统)' }
|
||||
]"
|
||||
placeholder="默认 1024x1024"
|
||||
/>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs text-emerald-600">
|
||||
💡 使用 Gemini 3 Pro Image 模型,通过 OpenAI 兼容接口生成图像
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<BaseButton variant="secondary" type="button" @click="reset">取消</BaseButton>
|
||||
<BaseButton type="submit">{{ form.id ? '保存变更' : '立即创建' }}</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import BaseInput from '../components/ui/BaseInput.vue'
|
||||
import BaseSelect from '../components/ui/BaseSelect.vue'
|
||||
|
||||
// Types
|
||||
type Provider = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
adapter: string
|
||||
model?: string
|
||||
api_base?: string
|
||||
has_api_key?: boolean
|
||||
priority: number
|
||||
enabled: boolean
|
||||
config_ref?: string
|
||||
weight?: number
|
||||
timeout_ms?: number
|
||||
max_retries?: number
|
||||
config_json: Record<string, any>
|
||||
}
|
||||
|
||||
// State
|
||||
// State
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
const loginError = ref('')
|
||||
const isLoggedIn = ref(!!sessionStorage.getItem('admin_auth'))
|
||||
const activeTab = ref('text')
|
||||
const providers = ref<Provider[]>([])
|
||||
const defaults = ref<Record<string, string[]>>({})
|
||||
const availableAdapters = ref<string[]>([])
|
||||
const editing = ref(false)
|
||||
const form = ref<Partial<Provider> & { api_key?: string; config_json: Record<string, any> }>({
|
||||
type: 'text',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
config_json: {}
|
||||
})
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
function getAuthHeader(): string {
|
||||
return sessionStorage.getItem('admin_auth') || ''
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function login() {
|
||||
loginError.value = ''
|
||||
const auth = 'Basic ' + btoa(loginForm.value.username + ':' + loginForm.value.password)
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/admin/providers`, {
|
||||
headers: { Authorization: auth },
|
||||
})
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('admin_auth', auth)
|
||||
isLoggedIn.value = true
|
||||
// 触发 NavBar 更新(如果在同一页面)- 实际上我们需要强制刷新或使用事件总线,但最简单的是 reload
|
||||
// 不过 AdminProviders 是视图组件,NavBar 是布局组件,它们状态不共享是个问题
|
||||
// 鉴于 NavBar 也依赖 sessionStorage 但不响应,这里的页面刷新是必要的
|
||||
window.location.reload()
|
||||
} else {
|
||||
loginError.value = '鉴权失败'
|
||||
}
|
||||
} catch {
|
||||
loginError.value = '网络不可达'
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('admin_auth')
|
||||
isLoggedIn.value = false
|
||||
providers.value = []
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
const headers = { Authorization: getAuthHeader() }
|
||||
|
||||
// Parallel fetch
|
||||
const [pRes, dRes, aRes] = await Promise.all([
|
||||
fetch(`${apiBase}/admin/providers`, { headers }),
|
||||
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
|
||||
fetch(`${apiBase}/admin/providers/adapters`, { headers })
|
||||
])
|
||||
|
||||
if (pRes.ok) providers.value = await pRes.json()
|
||||
if (dRes.ok) defaults.value = await dRes.json()
|
||||
if (aRes.ok) availableAdapters.value = await aRes.json()
|
||||
}
|
||||
|
||||
// Computed
|
||||
const filteredProviders = computed(() => {
|
||||
return providers.value
|
||||
.filter(p => p.type === activeTab.value)
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
})
|
||||
|
||||
const adapterOptions = computed(() => {
|
||||
return availableAdapters.value
|
||||
.filter(a => a.startsWith(activeTab.value + ':'))
|
||||
.map(a => {
|
||||
const name = a.split(':')[1]
|
||||
return { value: name, label: name } // e.g. 'gemini', 'openai'
|
||||
})
|
||||
})
|
||||
|
||||
// UI Actions
|
||||
function cloneDefault(type: string, name: string) {
|
||||
// 根据默认名称推断配置
|
||||
// 大多数默认 provider 的 name 就是 adapter name (如 gemini, openai, cqtai)
|
||||
// 如果未来有别名,这里可以做映射
|
||||
activeTab.value = type // 切换到对应 tab
|
||||
form.value = {
|
||||
type: type,
|
||||
name: name,
|
||||
adapter: name, // Default assumption
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
timeout_ms: 60000,
|
||||
max_retries: 1,
|
||||
config_json: {}
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
form.value = {
|
||||
type: activeTab.value,
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
timeout_ms: 60000,
|
||||
max_retries: 1,
|
||||
config_json: {}
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function edit(p: Provider) {
|
||||
const { has_api_key, ...rest } = p
|
||||
form.value = { ...rest, api_key: '', config_json: rest.config_json || {} } // Clear key for security, user re-enters if needed
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
editing.value = false
|
||||
form.value = { config_json: {} }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const method = form.value.id ? 'PUT' : 'POST'
|
||||
const url = form.value.id
|
||||
? `${apiBase}/admin/providers/${form.value.id}`
|
||||
: `${apiBase}/admin/providers`
|
||||
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getAuthHeader()
|
||||
},
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
|
||||
await loadData()
|
||||
reset()
|
||||
}
|
||||
|
||||
async function remove(p: Provider) {
|
||||
if(!confirm(`确认删除 ${p.name}?`)) return
|
||||
await fetch(`${apiBase}/admin/providers/${p.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: getAuthHeader() }
|
||||
})
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function toggleEnabled(p: Provider) {
|
||||
await fetch(`${apiBase}/admin/providers/${p.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getAuthHeader()
|
||||
},
|
||||
body: JSON.stringify({ enabled: !p.enabled })
|
||||
})
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoggedIn.value) loadData()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user