mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-17 09:50:31 +08:00
* perf: enhance layout responsiveness and text handling in stats page * perf: enhance subagent, future task UI - Updated the overall structure of the SubAgentPage component for better readability and maintainability. - Introduced a new dashboard layout with improved header and action buttons. - Replaced the old settings card with a more visually appealing setting card design. - Enhanced the agent list section with a more user-friendly display and added empty state handling. - Implemented unsaved changes notification and confirmation dialogs for better user interaction. - Refactored code for better clarity and organization, including the use of computed properties for state management.
628 lines
18 KiB
Vue
628 lines
18 KiB
Vue
<template>
|
|
<div class="dashboard-page subagent-page" :class="{ 'is-dark': isDark }">
|
|
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
|
|
<div class="dashboard-header">
|
|
<div class="dashboard-header-main">
|
|
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
|
|
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
|
|
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
|
|
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>
|
|
{{ tm('page.beta') }}
|
|
</v-chip>
|
|
</div>
|
|
<p class="dashboard-subtitle">{{ tm('page.subtitle') }}</p>
|
|
</div>
|
|
|
|
<div class="dashboard-header-actions">
|
|
<v-btn variant="text" color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="reload">
|
|
{{ tm('actions.refresh') }}
|
|
</v-btn>
|
|
<v-btn variant="tonal" color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">
|
|
{{ tm('actions.save') }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="hasUnsavedChanges" class="unsaved-banner">
|
|
<v-icon size="18" color="warning">mdi-alert-circle-outline</v-icon>
|
|
<span>{{ tm('messages.unsavedChangesNotice') }}</span>
|
|
</div>
|
|
|
|
<div class="dashboard-section-head">
|
|
<div>
|
|
<div class="dashboard-section-title">{{ tm('section.globalSettings') }}</div>
|
|
<div class="dashboard-section-subtitle">{{ mainStateDescription }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dashboard-form-grid global-settings-grid mb-5">
|
|
<div class="setting-card">
|
|
<div class="setting-card-head">
|
|
<div>
|
|
<div class="setting-title">{{ tm('switches.enable') }}</div>
|
|
<div class="setting-subtitle">{{ tm('switches.enableHint') }}</div>
|
|
</div>
|
|
<v-switch
|
|
v-model="cfg.main_enable"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
density="comfortable"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-card">
|
|
<div class="setting-card-head">
|
|
<div>
|
|
<div class="setting-title">{{ tm('switches.dedupe') }}</div>
|
|
<div class="setting-subtitle">{{ tm('switches.dedupeHint') }}</div>
|
|
</div>
|
|
<v-switch
|
|
v-model="cfg.remove_main_duplicate_tools"
|
|
:disabled="!cfg.main_enable"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
density="comfortable"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dashboard-section-head">
|
|
<div>
|
|
<div class="dashboard-section-title">{{ tm('section.title') }}</div>
|
|
<div class="dashboard-section-subtitle">{{ tm('section.subtitle') }}</div>
|
|
</div>
|
|
<div class="dashboard-section-actions">
|
|
<div class="dashboard-pill">
|
|
<v-icon size="16">mdi-robot-outline</v-icon>
|
|
<span>{{ cfg.agents.length }}</span>
|
|
</div>
|
|
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="addAgent">
|
|
{{ tm('actions.add') }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="cfg.agents.length === 0" class="dashboard-card dashboard-card--padded empty-card">
|
|
<div class="empty-wrap">
|
|
<v-icon icon="mdi-robot-off" size="60" class="mb-4" />
|
|
<div class="empty-title">{{ tm('empty.title') }}</div>
|
|
<div class="dashboard-empty mb-4">{{ tm('empty.subtitle') }}</div>
|
|
<v-btn color="primary" variant="tonal" @click="addAgent">
|
|
{{ tm('empty.action') }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="subagent-list">
|
|
<section
|
|
v-for="(agent, idx) in cfg.agents"
|
|
:key="agent.__key"
|
|
class="dashboard-card dashboard-card--padded agent-panel"
|
|
>
|
|
<div class="agent-summary">
|
|
<div class="agent-summary-main">
|
|
<div class="agent-summary-top">
|
|
<v-badge dot :color="agent.enabled ? 'success' : 'grey'" inline />
|
|
<span class="agent-name">{{ agent.name || tm('cards.unnamed') }}</span>
|
|
<v-chip size="x-small" variant="tonal" :color="agent.enabled ? 'success' : 'default'">
|
|
{{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
|
|
</v-chip>
|
|
</div>
|
|
<div class="agent-summary-desc">
|
|
{{ agent.public_description || tm('cards.noDescription') }}
|
|
</div>
|
|
</div>
|
|
<div class="agent-summary-actions">
|
|
<v-btn
|
|
:append-icon="isAgentExpanded(agent.__key) ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
|
variant="text"
|
|
color="default"
|
|
density="comfortable"
|
|
@click="toggleAgentExpanded(agent.__key)"
|
|
>
|
|
{{ isAgentExpanded(agent.__key) ? tm('actions.collapse') : tm('actions.expand') }}
|
|
</v-btn>
|
|
<v-switch
|
|
v-model="agent.enabled"
|
|
color="success"
|
|
hide-details
|
|
inset
|
|
density="compact"
|
|
/>
|
|
<v-btn
|
|
icon="mdi-delete-outline"
|
|
variant="text"
|
|
color="error"
|
|
density="comfortable"
|
|
@click="removeAgent(idx)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<v-expand-transition>
|
|
<div v-show="isAgentExpanded(agent.__key)" class="agent-edit-grid">
|
|
<section class="dashboard-card dashboard-card--padded inner-card">
|
|
<div class="dashboard-section-title section-mini-title">{{ tm('section.agentSetup') }}</div>
|
|
<div class="dashboard-form-grid dashboard-form-grid--single">
|
|
<v-text-field
|
|
v-model="agent.name"
|
|
:label="tm('form.nameLabel')"
|
|
:rules="[v => !!v || tm('messages.nameRequired'), v => /^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern')]"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
hide-details="auto"
|
|
/>
|
|
|
|
<div class="selector-wrap">
|
|
<div class="selector-label">{{ tm('form.providerLabel') }}</div>
|
|
<div class="selector-card">
|
|
<ProviderSelector
|
|
v-model="agent.provider_id"
|
|
provider-type="chat_completion"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
clearable
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="selector-wrap">
|
|
<div class="selector-label">{{ tm('form.personaLabel') }}</div>
|
|
<div class="selector-card">
|
|
<PersonaSelector v-model="agent.persona_id" />
|
|
</div>
|
|
</div>
|
|
|
|
<v-textarea
|
|
v-model="agent.public_description"
|
|
:label="tm('form.descriptionLabel')"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
auto-grow
|
|
hide-details="auto"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dashboard-card dashboard-card--padded inner-card">
|
|
<div class="dashboard-section-title section-mini-title">{{ tm('cards.personaPreview') }}</div>
|
|
<div class="dashboard-section-subtitle">{{ tm('cards.previewHint') }}</div>
|
|
<div class="persona-preview-wrap">
|
|
<PersonaQuickPreview :model-value="agent.persona_id" class="h-100" />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</v-expand-transition>
|
|
</section>
|
|
</div>
|
|
|
|
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
|
|
{{ snackbar.message }}
|
|
<template #actions>
|
|
<v-btn variant="text" @click="snackbar.show = false">{{ tm('actions.close') }}</v-btn>
|
|
</template>
|
|
</v-snackbar>
|
|
</v-container>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import axios from 'axios'
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { onBeforeRouteLeave } from 'vue-router'
|
|
import { useTheme } from 'vuetify'
|
|
import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue'
|
|
import PersonaSelector from '@/components/shared/PersonaSelector.vue'
|
|
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
|
|
import { useModuleI18n } from '@/i18n/composables'
|
|
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
|
|
|
|
type SubAgentItem = {
|
|
__key: string
|
|
name: string
|
|
persona_id: string
|
|
public_description: string
|
|
enabled: boolean
|
|
provider_id?: string
|
|
}
|
|
|
|
type SubAgentConfig = {
|
|
main_enable: boolean
|
|
remove_main_duplicate_tools: boolean
|
|
agents: SubAgentItem[]
|
|
}
|
|
|
|
const { tm } = useModuleI18n('features/subagent')
|
|
const theme = useTheme()
|
|
const confirmDialog = useConfirmDialog()
|
|
|
|
const loading = ref(false)
|
|
const saving = ref(false)
|
|
const isDark = computed(() => theme.global.current.value.dark)
|
|
|
|
const snackbar = ref({
|
|
show: false,
|
|
message: '',
|
|
color: 'success'
|
|
})
|
|
const expandedAgents = ref<Record<string, boolean>>({})
|
|
const initialSnapshot = ref('')
|
|
const hasLoaded = ref(false)
|
|
|
|
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
|
|
snackbar.value = { show: true, message, color }
|
|
}
|
|
|
|
const cfg = ref<SubAgentConfig>({
|
|
main_enable: false,
|
|
remove_main_duplicate_tools: false,
|
|
agents: []
|
|
})
|
|
|
|
const mainStateDescription = computed(() =>
|
|
cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled')
|
|
)
|
|
|
|
const hasUnsavedChanges = computed(() => hasLoaded.value && serializeConfig(cfg.value) !== initialSnapshot.value)
|
|
|
|
function normalizeConfig(raw: any): SubAgentConfig {
|
|
const main_enable = !!raw?.main_enable
|
|
const remove_main_duplicate_tools = !!raw?.remove_main_duplicate_tools
|
|
const agentsRaw = Array.isArray(raw?.agents) ? raw.agents : []
|
|
|
|
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => ({
|
|
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
|
|
name: (a?.name ?? '').toString(),
|
|
persona_id: (a?.persona_id ?? '').toString(),
|
|
public_description: (a?.public_description ?? '').toString(),
|
|
enabled: a?.enabled !== false,
|
|
provider_id: (a?.provider_id ?? undefined) as string | undefined
|
|
}))
|
|
|
|
return { main_enable, remove_main_duplicate_tools, agents }
|
|
}
|
|
|
|
function serializeConfig(config: SubAgentConfig): string {
|
|
return JSON.stringify({
|
|
main_enable: config.main_enable,
|
|
remove_main_duplicate_tools: config.remove_main_duplicate_tools,
|
|
agents: config.agents.map((agent) => ({
|
|
name: agent.name,
|
|
persona_id: agent.persona_id,
|
|
public_description: agent.public_description,
|
|
enabled: agent.enabled,
|
|
provider_id: agent.provider_id ?? null
|
|
}))
|
|
})
|
|
}
|
|
|
|
async function loadConfig() {
|
|
loading.value = true
|
|
try {
|
|
const res = await axios.get('/api/subagent/config')
|
|
if (res.data.status === 'ok') {
|
|
cfg.value = normalizeConfig(res.data.data)
|
|
expandedAgents.value = Object.fromEntries(cfg.value.agents.map((agent) => [agent.__key, false]))
|
|
initialSnapshot.value = serializeConfig(cfg.value)
|
|
hasLoaded.value = true
|
|
} else {
|
|
toast(res.data.message || tm('messages.loadConfigFailed'), 'error')
|
|
}
|
|
} catch (e: any) {
|
|
toast(e?.response?.data?.message || tm('messages.loadConfigFailed'), 'error')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function addAgent() {
|
|
const key = `${Date.now()}_${Math.random().toString(16).slice(2)}`
|
|
cfg.value.agents.push({
|
|
__key: key,
|
|
name: '',
|
|
persona_id: '',
|
|
public_description: '',
|
|
enabled: true,
|
|
provider_id: undefined
|
|
})
|
|
expandedAgents.value[key] = false
|
|
}
|
|
|
|
function removeAgent(idx: number) {
|
|
const [removed] = cfg.value.agents.splice(idx, 1)
|
|
if (removed) {
|
|
delete expandedAgents.value[removed.__key]
|
|
}
|
|
}
|
|
|
|
function isAgentExpanded(key: string): boolean {
|
|
return expandedAgents.value[key] !== false
|
|
}
|
|
|
|
function toggleAgentExpanded(key: string) {
|
|
expandedAgents.value[key] = !isAgentExpanded(key)
|
|
}
|
|
|
|
function validateBeforeSave(): boolean {
|
|
const nameRe = /^[a-z][a-z0-9_]{0,63}$/
|
|
const seen = new Set<string>()
|
|
|
|
for (const agent of cfg.value.agents) {
|
|
const name = (agent.name || '').trim()
|
|
if (!name) {
|
|
toast(tm('messages.nameMissing'), 'warning')
|
|
return false
|
|
}
|
|
if (!nameRe.test(name)) {
|
|
toast(tm('messages.nameInvalid'), 'warning')
|
|
return false
|
|
}
|
|
if (seen.has(name)) {
|
|
toast(tm('messages.nameDuplicate', { name }), 'warning')
|
|
return false
|
|
}
|
|
seen.add(name)
|
|
if (!agent.persona_id) {
|
|
toast(tm('messages.personaMissing', { name }), 'warning')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
async function save() {
|
|
if (!validateBeforeSave()) return
|
|
saving.value = true
|
|
try {
|
|
const payload = {
|
|
main_enable: cfg.value.main_enable,
|
|
remove_main_duplicate_tools: cfg.value.remove_main_duplicate_tools,
|
|
agents: cfg.value.agents.map((agent) => ({
|
|
name: agent.name,
|
|
persona_id: agent.persona_id,
|
|
public_description: agent.public_description,
|
|
enabled: agent.enabled,
|
|
provider_id: agent.provider_id
|
|
}))
|
|
}
|
|
|
|
const res = await axios.post('/api/subagent/config', payload)
|
|
if (res.data.status === 'ok') {
|
|
initialSnapshot.value = serializeConfig(cfg.value)
|
|
hasLoaded.value = true
|
|
toast(res.data.message || tm('messages.saveSuccess'), 'success')
|
|
} else {
|
|
toast(res.data.message || tm('messages.saveFailed'), 'error')
|
|
}
|
|
} catch (e: any) {
|
|
toast(e?.response?.data?.message || tm('messages.saveFailed'), 'error')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function reload() {
|
|
if (hasUnsavedChanges.value) {
|
|
const confirmed = await askForConfirmation(
|
|
tm('messages.unsavedChangesReloadConfirm'),
|
|
confirmDialog
|
|
)
|
|
if (!confirmed) {
|
|
return
|
|
}
|
|
}
|
|
await loadConfig()
|
|
}
|
|
|
|
async function confirmLeaveIfNeeded(): Promise<boolean> {
|
|
if (!hasUnsavedChanges.value) {
|
|
return true
|
|
}
|
|
|
|
return askForConfirmation(
|
|
tm('messages.unsavedChangesLeaveConfirm'),
|
|
confirmDialog
|
|
)
|
|
}
|
|
|
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
|
if (!hasUnsavedChanges.value) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
reload()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
})
|
|
|
|
onBeforeRouteLeave(async () => {
|
|
return await confirmLeaveIfNeeded()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
@import '@/styles/dashboard-shell.css';
|
|
|
|
.subagent-page {
|
|
padding-bottom: 40px;
|
|
}
|
|
|
|
.unsaved-banner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 14px;
|
|
margin-bottom: 18px;
|
|
border: 1px solid rgba(var(--v-theme-warning), 0.22);
|
|
border-radius: 12px;
|
|
background: rgba(var(--v-theme-warning), 0.08);
|
|
color: var(--dashboard-text);
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.setting-card {
|
|
border: 1px solid var(--dashboard-border);
|
|
border-radius: 14px;
|
|
padding: 18px;
|
|
background: rgba(var(--v-theme-primary), 0.02);
|
|
}
|
|
|
|
.setting-card-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
}
|
|
|
|
.setting-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.setting-subtitle {
|
|
margin-top: 6px;
|
|
color: var(--dashboard-muted);
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.empty-card {
|
|
min-height: 280px;
|
|
}
|
|
|
|
.empty-wrap {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
color: var(--dashboard-muted);
|
|
}
|
|
|
|
.empty-title {
|
|
font-size: 20px;
|
|
font-weight: 650;
|
|
color: var(--dashboard-text);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.subagent-list {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
|
|
.agent-panel {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.agent-summary {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
width: 100%;
|
|
}
|
|
|
|
.agent-summary-main {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.agent-summary-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.agent-name {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 18px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.agent-summary-desc {
|
|
margin-top: 8px;
|
|
color: var(--dashboard-muted);
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.agent-summary-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.agent-edit-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 18px;
|
|
}
|
|
|
|
.inner-card {
|
|
min-width: 0;
|
|
}
|
|
|
|
.section-mini-title {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.selector-wrap {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.selector-label {
|
|
color: var(--dashboard-muted);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.selector-card {
|
|
border: 1px solid var(--dashboard-border);
|
|
border-radius: 12px;
|
|
padding: 14px;
|
|
background: transparent;
|
|
}
|
|
|
|
.persona-preview-wrap {
|
|
min-height: 320px;
|
|
}
|
|
|
|
@media (max-width: 1080px) {
|
|
.agent-edit-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.setting-card-head,
|
|
.agent-summary {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|