refactor(dashboard): update UI pages to glassmorphism style

- SubAgentPage.vue: rewrite with trace-page style (glassmorphism)
- CronJobPage.vue: rewrite with trace-page style (glassmorphism)
- SessionManagementPage.vue: update CSS variables to match
- ConversationPage.vue: update CSS variables to match

Changes:
- Add CSS variables for glassmorphism effect (blur, transparency)
- Update color scheme to use CSS custom properties
- Improve visual consistency across pages
- Add dark mode support with theme-aware variables
This commit is contained in:
LIghtJUNction
2026-04-01 00:38:32 +08:00
parent cbeb25fd2d
commit 9c9d4d6db9
4 changed files with 804 additions and 982 deletions

View File

@@ -1135,129 +1135,157 @@ export default {
</script>
<style>
.actions-wrapper {
display: flex;
justify-content: flex-end;
gap: 8px;
.conv-page {
--conv-page-bg: transparent;
--conv-panel-bg: rgba(var(--v-theme-surface), 0.78);
--conv-card-bg: rgba(var(--v-theme-surface), 0.9);
--conv-record-bg: rgba(var(--v-theme-surface-variant), 0.38);
--conv-empty-surface: rgba(var(--v-theme-surface), 0.68);
--conv-primary: rgb(var(--v-theme-primary));
--conv-primary-soft: rgba(var(--v-theme-primary), 0.08);
--conv-primary-soft-strong: rgba(var(--v-theme-primary), 0.14);
--conv-border: rgba(var(--v-theme-borderLight), 0.22);
--conv-border-strong: rgba(var(--v-theme-borderLight), 0.4);
--conv-border-active: rgba(var(--v-theme-primary), 0.24);
--conv-track: rgba(var(--v-theme-borderLight), 0.78);
--conv-track-active: rgba(var(--v-theme-primary), 0.22);
--conv-title: rgb(var(--v-theme-on-surface));
--conv-text: rgba(var(--v-theme-on-surface), 0.92);
--conv-muted: rgba(var(--v-theme-on-surface), 0.7);
--conv-subtle: rgba(var(--v-theme-on-surface), 0.54);
--conv-empty-icon-bg: rgba(var(--v-theme-primary), 0.1);
--conv-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
z-index: 1;
isolation: isolate;
gap: 16px;
padding: 16px;
}
.action-button {
border-radius: 8px;
font-weight: 500;
:global(.v-theme--bluebusinessdarktheme) .conv-page {
--conv-panel-bg: rgba(var(--v-theme-surface), 0.72);
--conv-card-bg: rgba(var(--v-theme-surface-variant), 0.74);
--conv-record-bg: rgba(var(--v-theme-surface), 0.52);
--conv-empty-surface: rgba(var(--v-theme-surface-variant), 0.56);
--conv-border: rgba(var(--v-theme-borderLight), 0.46);
--conv-border-strong: rgba(var(--v-theme-borderLight), 0.66);
--conv-track: rgba(var(--v-theme-borderLight), 0.9);
--conv-shadow: none;
}
.monaco-editor-container {
height: 500px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.conv-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--conv-panel-bg);
border: 1px solid var(--conv-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--conv-shadow);
flex-shrink: 0;
}
/* 聊天消息容器样式 */
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-title {
font-size: 18px;
font-weight: 700;
color: var(--conv-primary) !important;
-webkit-text-fill-color: var(--conv-primary);
letter-spacing: 0.01em;
}
.topbar-desc {
font-size: 13px;
color: var(--conv-muted) !important;
-webkit-text-fill-color: var(--conv-muted);
line-height: 1.5;
max-width: 60ch;
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.conv-filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding: 16px 20px;
background: var(--conv-panel-bg);
border: 1px solid var(--conv-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--conv-shadow);
}
.conv-filter-item { flex: 1; min-width: 200px; }
.conv-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
background: var(--conv-panel-bg);
border: 1px solid var(--conv-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--conv-shadow);
}
.conv-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--conv-panel-bg);
border-top: 1px solid var(--conv-border);
}
.conv-dialog-card { border: 1px solid var(--conv-border); border-radius: 18px; }
.conv-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.conv-form-grid--single { grid-template-columns: 1fr; }
/* Existing styles updated with new variables */
.conversation-messages-container {
max-height: 500px;
overflow-y: auto;
padding: 8px;
border-radius: 8px;
background-color: #f9f9f9;
max-height: 500px;
overflow-y: auto;
padding: 8px;
border-radius: 8px;
background-color: var(--conv-card-bg);
}
/* 暗色模式下的聊天消息容器 */
.v-theme--dark .conversation-messages-container {
background-color: #1e1e1e;
background-color: #1e1e1e;
}
/* 对话详情卡片 */
.conversation-detail-card {
max-height: 90vh;
display: flex;
flex-direction: column;
max-height: 90vh;
display: flex;
flex-direction: column;
border: 1px solid var(--conv-border);
border-radius: 12px;
}
.text-truncate {
display: inline-block;
/* max-width: 100px; */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-title-text { color: var(--conv-text); }
.conversation-title-meta { color: var(--conv-muted); }
.conversation-title-cell {
padding: 6px 0px;
min-width: 100px;
max-width: 145px;
}
.umo-source-content { color: var(--conv-text); }
.umo-session-id, .umo-raw-text { color: var(--conv-text); }
.umo-separator { color: var(--conv-muted); }
.conversation-title-text {
display: inline-block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-title-meta {
display: block;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.umo-header-cell {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.umo-header-toggle {
flex-shrink: 0;
}
.umo-source-cell {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.umo-source-content {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.umo-separator {
color: rgba(var(--v-theme-on-surface), 0.5);
flex-shrink: 0;
}
.umo-session-id,
.umo-raw-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.umo-copy-button {
flex-shrink: 0;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 900px) {
.conv-topbar { flex-direction: column; align-items: flex-start; padding: 16px; }
.topbar-right { width: 100%; }
.topbar-desc { max-width: none; }
.conv-page { gap: 12px; padding: 12px; }
.conv-filters { flex-direction: column; }
.conv-filter-item { min-width: 100%; }
}
</style>

View File

@@ -1,218 +1,153 @@
<template>
<div class="dashboard-page cron-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 class="cron-page">
<div class="cron-topbar">
<div class="topbar-left">
<div class="topbar-title">{{ tm('page.title') }}</div>
<div class="topbar-desc">{{ tm('page.subtitle') }}</div>
</div>
<div class="topbar-right">
<v-btn variant="text" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadJobs">
{{ tm('actions.refresh') }}
</v-btn>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-plus" @click="openCreate">
{{ tm('actions.create') }}
</v-btn>
</div>
</div>
<div class="cron-overview-grid">
<div v-for="card in overviewCards" :key="card.label" class="cron-overview-card">
<div class="cron-card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
<div class="cron-card-label">{{ card.label }}</div>
<div class="cron-card-value">{{ card.value }}</div>
<div class="cron-card-note">{{ card.note }}</div>
</div>
</div>
<div class="cron-section-head">
<div>
<div class="cron-section-title">{{ tm('section.platforms.title') }}</div>
<div class="cron-section-subtitle">
{{ proactivePlatforms.length ? tm('page.proactive.supported', { platforms: proactivePlatformText }) : tm('page.proactive.unsupported') }}
</div>
</div>
</div>
<section v-if="proactivePlatforms.length" class="platform-section">
<div class="platform-chip-wrap">
<v-chip v-for="platform in proactivePlatforms" :key="platform.id" size="small" variant="tonal" color="primary">
{{ platform.display_name || platform.name }} · {{ platform.id }}
</v-chip>
</div>
</section>
<div v-else class="cron-empty">{{ tm('page.proactive.unsupported') }}</div>
<div class="cron-section-head">
<div>
<div class="cron-section-title">{{ tm('table.title') }}</div>
<div class="cron-section-subtitle">{{ tm('table.subtitle') }}</div>
</div>
</div>
<div class="cron-content">
<div v-if="loading && !jobs.length" class="state-panel">
<v-progress-circular indeterminate size="22" width="2" color="primary" />
<span>{{ tm('actions.refresh') }}...</span>
</div>
<div v-else-if="!jobs.length" class="state-panel">
<v-icon size="20" color="primary">mdi-calendar-blank-outline</v-icon>
<span>{{ tm('table.empty') }}</span>
</div>
<div v-else class="task-table-wrap">
<table class="task-table">
<colgroup>
<col class="col-name" />
<col class="col-type" />
<col class="col-cron" />
<col class="col-session" />
<col class="col-next-run" />
<col class="col-last-run" />
<col class="col-actions" />
</colgroup>
<thead>
<tr>
<th>{{ tm('table.headers.name') }}</th>
<th>{{ tm('table.headers.type') }}</th>
<th>{{ tm('table.headers.cron') }}</th>
<th>{{ tm('table.headers.session') }}</th>
<th>{{ tm('table.headers.nextRun') }}</th>
<th>{{ tm('table.headers.lastRun') }}</th>
<th class="actions-col">{{ tm('table.headers.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in sortedJobs" :key="item.job_id">
<td class="name-col">
<div class="task-name">{{ item.name || tm('table.notAvailable') }}</div>
<div class="task-subline">{{ item.description || item.job_id }}</div>
</td>
<td>
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
{{ jobTypeLabel(item) }}
</v-chip>
</td>
<td>
<div class="task-text">{{ scheduleLabel(item) }}</div>
<div class="task-subline">{{ scheduleMeta(item) }}</div>
</td>
<td>
<div class="task-session">{{ item.session || tm('table.notAvailable') }}</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.next_run_time) }}</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.last_run_at) }}</div>
</td>
<td class="actions-col">
<div class="table-actions">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary" class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="2600">
{{ snackbar.message }}
</v-snackbar>
<v-dialog v-model="createDialog" max-width="640">
<v-card class="cron-dialog-card">
<v-card-title class="text-h6 pt-5 px-5">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">{{ tm('form.chatHint') }}</v-card-subtitle>
<v-card-text class="px-5 pb-2">
<div class="cron-form-grid cron-form-grid--single">
<v-switch v-model="newJob.run_once" :label="tm('form.runOnce')" inset color="primary" hide-details />
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field v-if="!newJob.run_once" v-model="newJob.cron_expression" :label="tm('form.cron')" :placeholder="tm('form.cronPlaceholder')" variant="outlined" density="comfortable" />
<v-text-field v-else v-model="newJob.run_at" :label="tm('form.runAt')" type="datetime-local" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined" density="comfortable" />
<v-switch v-model="newJob.enabled" :label="tm('form.enabled')" inset color="primary" hide-details />
</div>
<p class="dashboard-subtitle">
{{ tm('page.subtitle') }}
</p>
</div>
<div class="dashboard-header-actions">
<v-btn variant="text" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadJobs">
{{ tm('actions.refresh') }}
</v-btn>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-plus" @click="openCreate">
{{ tm('actions.create') }}
</v-btn>
</div>
</div>
<div class="dashboard-overview-grid">
<section
v-for="card in overviewCards"
:key="card.label"
class="dashboard-card dashboard-overview-card"
>
<div class="dashboard-card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
<div class="dashboard-card-label">{{ card.label }}</div>
<div class="dashboard-card-value">{{ card.value }}</div>
<div class="dashboard-card-note">{{ card.note }}</div>
</section>
</div>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('section.platforms.title') }}</div>
<div class="dashboard-section-subtitle">
{{
proactivePlatforms.length
? tm('page.proactive.supported', { platforms: proactivePlatformText })
: tm('page.proactive.unsupported')
}}
</div>
</div>
</div>
<section v-if="proactivePlatforms.length" class="platform-section">
<div class="platform-chip-wrap">
<v-chip
v-for="platform in proactivePlatforms"
:key="platform.id"
size="small"
variant="tonal"
color="primary"
>
{{ platform.display_name || platform.name }} · {{ platform.id }}
</v-chip>
</div>
</section>
<div v-else class="dashboard-empty platform-empty">
{{ tm('page.proactive.unsupported') }}
</div>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('table.title') }}</div>
<div class="dashboard-section-subtitle">{{ tm('table.subtitle') }}</div>
</div>
</div>
<section class="task-surface">
<div v-if="loading && !jobs.length" class="state-panel">
<v-progress-circular indeterminate size="22" width="2" color="primary" />
<span>{{ tm('actions.refresh') }}...</span>
</div>
<div v-else-if="!jobs.length" class="state-panel">
<v-icon size="20" color="primary">mdi-calendar-blank-outline</v-icon>
<span>{{ tm('table.empty') }}</span>
</div>
<div v-else class="task-table-wrap">
<table class="task-table">
<colgroup>
<col class="col-name" />
<col class="col-type" />
<col class="col-cron" />
<col class="col-session" />
<col class="col-next-run" />
<col class="col-last-run" />
<col class="col-actions" />
</colgroup>
<thead>
<tr>
<th>{{ tm('table.headers.name') }}</th>
<th>{{ tm('table.headers.type') }}</th>
<th>{{ tm('table.headers.cron') }}</th>
<th>{{ tm('table.headers.session') }}</th>
<th>{{ tm('table.headers.nextRun') }}</th>
<th>{{ tm('table.headers.lastRun') }}</th>
<th class="actions-col">{{ tm('table.headers.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in sortedJobs" :key="item.job_id">
<td class="name-col">
<div class="task-name">{{ item.name || tm('table.notAvailable') }}</div>
<div class="task-subline">{{ item.description || item.job_id }}</div>
</td>
<td>
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
{{ jobTypeLabel(item) }}
</v-chip>
</td>
<td>
<div class="task-text">{{ scheduleLabel(item) }}</div>
<div class="task-subline">{{ scheduleMeta(item) }}</div>
</td>
<td>
<div class="task-session">{{ item.session || tm('table.notAvailable') }}</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.next_run_time) }}</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.last_run_at) }}</div>
</td>
<td class="actions-col">
<div class="table-actions">
<v-switch
v-model="item.enabled"
inset
density="compact"
hide-details
color="primary"
class="mt-0"
@change="toggleJob(item)"
/>
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="2600">
{{ snackbar.message }}
</v-snackbar>
<v-dialog v-model="createDialog" max-width="640">
<v-card class="dashboard-dialog-card">
<v-card-title class="text-h6 pt-5 px-5">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">
{{ tm('form.chatHint') }}
</v-card-subtitle>
<v-card-text class="px-5 pb-2">
<div class="dashboard-form-grid dashboard-form-grid--single">
<v-switch
v-model="newJob.run_once"
:label="tm('form.runOnce')"
inset
color="primary"
hide-details
/>
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field
v-if="!newJob.run_once"
v-model="newJob.cron_expression"
:label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-else
v-model="newJob.run_at"
:label="tm('form.runAt')"
type="datetime-local"
variant="outlined"
density="comfortable"
/>
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined" density="comfortable" />
<v-switch
v-model="newJob.enabled"
:label="tm('form.enabled')"
inset
color="primary"
hide-details
/>
</div>
</v-card-text>
<v-card-actions class="justify-end px-5 pb-5">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">
{{ tm('actions.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</v-card-text>
<v-card-actions class="justify-end px-5 pb-5">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -254,36 +189,21 @@ const recurringCount = computed(() => jobs.value.filter((job) => !job.run_once).
const sortedJobs = computed(() =>
[...jobs.value].sort((a, b) => {
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1
}
if (a.enabled !== b.enabled) { return a.enabled ? -1 : 1 }
const nextA = parseTimeValue(a.next_run_time ?? a.run_at)
const nextB = parseTimeValue(b.next_run_time ?? b.run_at)
if (nextA !== nextB) {
if (!nextA) return 1
if (!nextB) return -1
return nextA - nextB
}
return String(a.name || '').localeCompare(String(b.name || ''))
})
)
const overviewCards = computed(() => [
{
label: tm('overview.totalTasks'),
value: String(jobs.value.length),
note: tm('overview.totalTasksNote'),
icon: 'mdi-calendar-multiple'
},
{
label: tm('overview.enabledTasks'),
value: String(enabledJobsCount.value),
note: tm('overview.enabledTasksNote'),
icon: 'mdi-check-circle-outline'
}
{ label: tm('overview.totalTasks'), value: String(jobs.value.length), note: tm('overview.totalTasksNote'), icon: 'mdi-calendar-multiple' },
{ label: tm('overview.enabledTasks'), value: String(enabledJobsCount.value), note: tm('overview.enabledTasksNote'), icon: 'mdi-check-circle-outline' }
])
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
@@ -298,34 +218,23 @@ function parseTimeValue(value: any): number {
function formatTime(val: any): string {
if (!val) return tm('table.notAvailable')
try {
return new Date(val).toLocaleString()
} catch {
return String(val)
}
try { return new Date(val).toLocaleString() } catch { return String(val) }
}
function jobTypeLabel(item: any): string {
if (item.run_once) return tm('table.type.once')
const type = item.job_type || 'active_agent'
const map: Record<string, string> = {
active_agent: tm('table.type.activeAgent'),
workflow: tm('table.type.workflow')
}
const map: Record<string, string> = { active_agent: tm('table.type.activeAgent'), workflow: tm('table.type.workflow') }
return map[type] || tm('table.type.unknown', { type })
}
function scheduleLabel(item: any): string {
if (item.run_once) {
return formatTime(item.run_at)
}
if (item.run_once) return formatTime(item.run_at)
return item.cron_expression || tm('table.notAvailable')
}
function scheduleMeta(item: any): string {
if (item.run_once) {
return tm('table.type.once')
}
if (item.run_once) return tm('table.type.once')
return item.timezone || tm('table.timezoneLocal')
}
@@ -335,252 +244,247 @@ async function loadJobs() {
const res = await axios.get('/api/cron/jobs')
if (res.data.status === 'ok') {
const data = Array.isArray(res.data.data) ? res.data.data : []
jobs.value = data.map((job: any) => ({
...job,
session: job?.payload?.session || job?.session || ''
}))
} else {
toast(res.data.message || tm('messages.loadFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.loadFailed'), 'error')
} finally {
loading.value = false
}
jobs.value = data.map((job: any) => ({ ...job, session: job?.payload?.session || job?.session || '' }))
} else { toast(res.data.message || tm('messages.loadFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.loadFailed'), 'error') }
finally { loading.value = false }
}
async function loadPlatforms() {
try {
const res = await axios.get('/api/platform/stats')
if (res.data.status === 'ok' && Array.isArray(res.data.data?.platforms)) {
proactivePlatforms.value = res.data.data.platforms
.filter((p: any) => p?.meta?.support_proactive_message)
.map((p: any) => ({
id: p?.id || p?.meta?.id || 'unknown',
name: p?.meta?.name || p?.type || '',
display_name: p?.meta?.display_name || p?.display_name
}))
proactivePlatforms.value = res.data.data.platforms.filter((p: any) => p?.meta?.support_proactive_message).map((p: any) => ({
id: p?.id || p?.meta?.id || 'unknown',
name: p?.meta?.name || p?.type || '',
display_name: p?.meta?.display_name || p?.display_name
}))
}
} catch {
// Ignore platform fetch failures and keep the fallback state.
}
} catch { /* Ignore */ }
}
async function toggleJob(job: any) {
try {
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, { enabled: job.enabled })
if (res.data.status !== 'ok') {
toast(res.data.message || tm('messages.updateFailed'), 'error')
await loadJobs()
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error')
await loadJobs()
}
if (res.data.status !== 'ok') { toast(res.data.message || tm('messages.updateFailed'), 'error'); await loadJobs() }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error'); await loadJobs() }
}
async function deleteJob(job: any) {
try {
const res = await axios.delete(`/api/cron/jobs/${job.job_id}`)
if (res.data.status === 'ok') {
toast(tm('messages.deleteSuccess'))
jobs.value = jobs.value.filter((item) => item.job_id !== job.job_id)
} else {
toast(res.data.message || tm('messages.deleteFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.deleteFailed'), 'error')
}
if (res.data.status === 'ok') { toast(tm('messages.deleteSuccess')); jobs.value = jobs.value.filter((item) => item.job_id !== job.job_id) }
else { toast(res.data.message || tm('messages.deleteFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.deleteFailed'), 'error') }
}
function openCreate() {
resetNewJob()
createDialog.value = true
}
function openCreate() { resetNewJob(); createDialog.value = true }
function resetNewJob() {
newJob.value = {
run_once: false,
name: '',
note: '',
cron_expression: '',
run_at: '',
session: '',
timezone: '',
enabled: true
}
newJob.value = { run_once: false, name: '', note: '', cron_expression: '', run_at: '', session: '', timezone: '', enabled: true }
}
async function createJob() {
if (!newJob.value.session) {
toast(tm('messages.sessionRequired'), 'warning')
return
}
if (!newJob.value.note) {
toast(tm('messages.noteRequired'), 'warning')
return
}
if (!newJob.value.run_once && !newJob.value.cron_expression) {
toast(tm('messages.cronRequired'), 'warning')
return
}
if (newJob.value.run_once && !newJob.value.run_at) {
toast(tm('messages.runAtRequired'), 'warning')
return
}
if (!newJob.value.session) { toast(tm('messages.sessionRequired'), 'warning'); return }
if (!newJob.value.note) { toast(tm('messages.noteRequired'), 'warning'); return }
if (!newJob.value.run_once && !newJob.value.cron_expression) { toast(tm('messages.cronRequired'), 'warning'); return }
if (newJob.value.run_once && !newJob.value.run_at) { toast(tm('messages.runAtRequired'), 'warning'); return }
creating.value = true
try {
const res = await axios.post('/api/cron/jobs', { ...newJob.value })
if (res.data.status === 'ok') {
toast(tm('messages.createSuccess'))
createDialog.value = false
resetNewJob()
await loadJobs()
} else {
toast(res.data.message || tm('messages.createFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.createFailed'), 'error')
} finally {
creating.value = false
}
if (res.data.status === 'ok') { toast(tm('messages.createSuccess')); createDialog.value = false; resetNewJob(); await loadJobs() }
else { toast(res.data.message || tm('messages.createFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.createFailed'), 'error') }
finally { creating.value = false }
}
onMounted(() => {
loadJobs()
loadPlatforms()
})
onMounted(() => { loadJobs(); loadPlatforms() })
</script>
<style scoped>
@import '@/styles/dashboard-shell.css';
.cron-page {
padding-bottom: 40px;
}
.task-surface {
min-width: 0;
}
.platform-section {
margin-bottom: 24px;
}
.platform-chip-wrap {
--cron-page-bg: transparent;
--cron-panel-bg: rgba(var(--v-theme-surface), 0.78);
--cron-card-bg: rgba(var(--v-theme-surface), 0.9);
--cron-record-bg: rgba(var(--v-theme-surface-variant), 0.38);
--cron-empty-surface: rgba(var(--v-theme-surface), 0.68);
--cron-primary: rgb(var(--v-theme-primary));
--cron-primary-soft: rgba(var(--v-theme-primary), 0.08);
--cron-primary-soft-strong: rgba(var(--v-theme-primary), 0.14);
--cron-border: rgba(var(--v-theme-borderLight), 0.22);
--cron-border-strong: rgba(var(--v-theme-borderLight), 0.4);
--cron-border-active: rgba(var(--v-theme-primary), 0.24);
--cron-track: rgba(var(--v-theme-borderLight), 0.78);
--cron-track-active: rgba(var(--v-theme-primary), 0.22);
--cron-title: rgb(var(--v-theme-on-surface));
--cron-text: rgba(var(--v-theme-on-surface), 0.92);
--cron-muted: rgba(var(--v-theme-on-surface), 0.7);
--cron-subtle: rgba(var(--v-theme-on-surface), 0.54);
--cron-empty-icon-bg: rgba(var(--v-theme-primary), 0.1);
--cron-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
z-index: 1;
isolation: isolate;
gap: 16px;
padding: 16px;
}
.platform-empty {
margin-bottom: 24px;
:global(.v-theme--bluebusinessdarktheme) .cron-page {
--cron-panel-bg: rgba(var(--v-theme-surface), 0.72);
--cron-card-bg: rgba(var(--v-theme-surface-variant), 0.74);
--cron-record-bg: rgba(var(--v-theme-surface), 0.52);
--cron-empty-surface: rgba(var(--v-theme-surface-variant), 0.56);
--cron-border: rgba(var(--v-theme-borderLight), 0.46);
--cron-border-strong: rgba(var(--v-theme-borderLight), 0.66);
--cron-track: rgba(var(--v-theme-borderLight), 0.9);
--cron-shadow: none;
}
.task-table-wrap {
border: 1px solid var(--dashboard-border);
border-radius: 14px;
overflow: auto;
background: var(--dashboard-surface);
.cron-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--cron-panel-bg);
border: 1px solid var(--cron-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--cron-shadow);
flex-shrink: 0;
}
.task-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-title {
font-size: 18px;
font-weight: 700;
color: var(--cron-primary) !important;
-webkit-text-fill-color: var(--cron-primary);
letter-spacing: 0.01em;
}
.task-table .col-name {
width: 220px;
.topbar-desc {
font-size: 13px;
color: var(--cron-muted) !important;
-webkit-text-fill-color: var(--cron-muted);
line-height: 1.5;
max-width: 60ch;
}
.task-table .col-type {
width: 120px;
.topbar-right { display: flex; align-items: center; gap: 10px; }
.cron-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
flex-shrink: 0;
}
.task-table .col-cron {
width: 260px;
.cron-overview-card {
padding: 20px 20px 18px;
background: var(--cron-panel-bg);
border: 1px solid var(--cron-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--cron-shadow);
}
.task-table .col-session {
width: 340px;
.cron-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: var(--cron-primary-soft);
color: var(--cron-primary);
}
.task-table .col-next-run,
.task-table .col-last-run {
width: 180px;
.cron-card-label { margin-top: 12px; color: var(--cron-muted); font-size: 13px; font-weight: 500; }
.cron-card-value {
margin-top: 8px;
font-size: clamp(24px, 2vw, 34px);
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
color: var(--cron-text);
}
.task-table .col-actions {
width: 170px;
.cron-card-note { margin-top: 8px; color: var(--cron-subtle); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.cron-section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; }
.cron-section-title { font-size: 19px; font-weight: 650; letter-spacing: -0.02em; line-height: 1.3; overflow-wrap: anywhere; }
.cron-section-subtitle { margin-top: 6px; color: var(--cron-muted); font-size: 13px; line-height: 1.6; }
.cron-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
background: var(--cron-panel-bg);
border: 1px solid var(--cron-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--cron-shadow);
}
.cron-empty { color: var(--cron-muted); font-size: 14px; padding: 16px 20px; background: var(--cron-panel-bg); border: 1px solid var(--cron-border); border-radius: 12px; }
.platform-section { padding: 16px 20px; background: var(--cron-panel-bg); border: 1px solid var(--cron-border); border-radius: 12px; }
.platform-chip-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
.task-surface { min-width: 0; }
.task-table-wrap { overflow: auto; background: var(--cron-card-bg); }
.task-table { width: 100%; min-width: 1120px; border-collapse: collapse; }
.task-table .col-name { width: 220px; }
.task-table .col-type { width: 120px; }
.task-table .col-cron { width: 260px; }
.task-table .col-session { width: 340px; }
.task-table .col-next-run, .task-table .col-last-run { width: 180px; }
.task-table .col-actions { width: 170px; }
.task-table th {
padding: 14px 16px;
text-align: left;
background: rgba(var(--v-theme-primary), 0.04);
color: var(--dashboard-muted);
background: var(--cron-primary-soft);
color: var(--cron-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 1px solid var(--dashboard-border);
border-bottom: 1px solid var(--cron-border);
white-space: nowrap;
}
.task-table td {
padding: 16px;
vertical-align: top;
border-bottom: 1px solid var(--dashboard-border);
}
.task-table td { padding: 16px; vertical-align: top; border-bottom: 1px solid var(--cron-border); color: var(--cron-text); }
.task-table tbody tr:last-child td { border-bottom: 0; }
.task-table tbody tr:last-child td {
border-bottom: 0;
}
.name-col { min-width: 220px; }
.name-col {
min-width: 220px;
}
.task-name, .task-text { color: var(--cron-text); font-size: 14px; line-height: 1.5; }
.task-name { font-weight: 600; }
.task-name,
.task-text {
color: var(--dashboard-text);
font-size: 14px;
line-height: 1.5;
}
.task-subline { margin-top: 6px; color: var(--cron-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.task-name {
font-weight: 600;
}
.task-session { max-width: 340px; color: var(--cron-text); font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
.task-subline {
margin-top: 6px;
color: var(--dashboard-muted);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.actions-col { width: 170px; }
.task-session {
max-width: 340px;
color: var(--dashboard-text);
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.actions-col {
width: 170px;
}
.table-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 140px;
}
.table-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 140px; }
.state-panel {
display: flex;
@@ -588,19 +492,27 @@ onMounted(() => {
justify-content: center;
gap: 12px;
min-height: 220px;
border: 1px dashed var(--dashboard-border-strong);
border-radius: 14px;
color: var(--dashboard-muted);
border: 1px dashed var(--cron-border-strong);
border-radius: 12px;
color: var(--cron-muted);
font-size: 14px;
margin: 16px;
}
.cron-page :deep(.dashboard-overview-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cron-dialog-card { border: 1px solid var(--cron-border); border-radius: 18px; }
.cron-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.cron-form-grid--single { grid-template-columns: 1fr; }
@media (max-width: 1280px) { .cron-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 900px) {
.table-actions {
justify-content: flex-start;
}
.cron-topbar { flex-direction: column; align-items: flex-start; padding: 16px; }
.topbar-right { width: 100%; }
.topbar-desc { max-width: none; }
.cron-page { gap: 12px; padding: 12px; }
.table-actions { justify-content: flex-start; }
}
@media (max-width: 640px) { .cron-overview-grid { grid-template-columns: 1fr; } }
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="session-management-page">
<div class="session-page">
<v-container fluid class="pa-0">
<v-card flat>
<v-card-title class="d-flex align-center py-3 px-4">

View File

@@ -1,212 +1,154 @@
<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 class="subagent-page">
<div class="subagent-topbar">
<div class="topbar-left">
<div class="topbar-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="topbar-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="topbar-desc">{{ tm('page.subtitle') }}</p>
</div>
<div class="topbar-right">
<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="subagent-section-head">
<div>
<div class="subagent-section-title">{{ tm('section.globalSettings') }}</div>
<div class="subagent-section-subtitle">{{ mainStateDescription }}</div>
</div>
</div>
<div class="subagent-settings-grid">
<div class="subagent-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>
<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>
<v-switch v-model="cfg.main_enable" color="primary" hide-details inset density="comfortable" />
</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 class="subagent-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-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 class="subagent-section-head">
<div>
<div class="subagent-section-title">{{ tm('section.title') }}</div>
<div class="subagent-section-subtitle">{{ tm('section.subtitle') }}</div>
</div>
<div class="subagent-section-actions">
<div class="subagent-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="subagent-content subagent-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="empty-subtitle 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="subagent-content 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>
<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 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"
/>
<v-expand-transition>
<div v-show="isAgentExpanded(agent.__key)" class="agent-edit-grid">
<section class="inner-card">
<div class="subagent-section-title section-mini-title">{{ tm('section.agentSetup') }}</div>
<div class="subagent-form-grid subagent-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 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 class="selector-wrap">
<div class="selector-label">{{ tm('form.personaLabel') }}</div>
<div class="selector-card">
<PersonaSelector v-model="agent.persona_id" />
</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-textarea v-model="agent.public_description" :label="tm('form.descriptionLabel')" variant="outlined" density="comfortable" auto-grow hide-details="auto" />
</div>
</section>
<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>
<section class="inner-card">
<div class="subagent-section-title section-mini-title">{{ tm('cards.personaPreview') }}</div>
<div class="subagent-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>
</div>
</template>
@@ -244,11 +186,7 @@ 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 snackbar = ref({ show: false, message: '', color: 'success' })
const expandedAgents = ref<Record<string, boolean>>({})
const initialSnapshot = ref('')
const hasLoaded = ref(false)
@@ -257,23 +195,15 @@ function toast(message: string, color: 'success' | 'error' | 'warning' = 'succes
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 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(),
@@ -282,7 +212,6 @@ function normalizeConfig(raw: any): SubAgentConfig {
enabled: a?.enabled !== false,
provider_id: (a?.provider_id ?? undefined) as string | undefined
}))
return { main_enable, remove_main_duplicate_tools, agents }
}
@@ -290,12 +219,9 @@ 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
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
}))
})
}
@@ -306,72 +232,39 @@ async function loadConfig() {
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]))
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
}
} 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
})
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]
}
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 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
}
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
}
if (!agent.persona_id) { toast(tm('messages.personaMissing', { name }), 'warning'); return false }
}
return true
}
@@ -382,246 +275,235 @@ async function save() {
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
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
}
} 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
}
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
)
if (!hasUnsavedChanges.value) return true
return askForConfirmation(tm('messages.unsavedChangesLeaveConfirm'), confirmDialog)
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedChanges.value) {
return
}
if (!hasUnsavedChanges.value) return
event.preventDefault()
event.returnValue = ''
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
reload()
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
return await confirmLeaveIfNeeded()
})
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;
--subagent-page-bg: transparent;
--subagent-panel-bg: rgba(var(--v-theme-surface), 0.78);
--subagent-card-bg: rgba(var(--v-theme-surface), 0.9);
--subagent-record-bg: rgba(var(--v-theme-surface-variant), 0.38);
--subagent-empty-surface: rgba(var(--v-theme-surface), 0.68);
--subagent-primary: rgb(var(--v-theme-primary));
--subagent-primary-soft: rgba(var(--v-theme-primary), 0.08);
--subagent-primary-soft-strong: rgba(var(--v-theme-primary), 0.14);
--subagent-border: rgba(var(--v-theme-borderLight), 0.22);
--subagent-border-strong: rgba(var(--v-theme-borderLight), 0.4);
--subagent-border-active: rgba(var(--v-theme-primary), 0.24);
--subagent-track: rgba(var(--v-theme-borderLight), 0.78);
--subagent-track-active: rgba(var(--v-theme-primary), 0.22);
--subagent-title: rgb(var(--v-theme-on-surface));
--subagent-text: rgba(var(--v-theme-on-surface), 0.92);
--subagent-muted: rgba(var(--v-theme-on-surface), 0.7);
--subagent-subtle: rgba(var(--v-theme-on-surface), 0.54);
--subagent-empty-icon-bg: rgba(var(--v-theme-primary), 0.1);
--subagent-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
z-index: 1;
isolation: isolate;
gap: 16px;
padding: 16px;
}
:global(.v-theme--bluebusinessdarktheme) .subagent-page {
--subagent-panel-bg: rgba(var(--v-theme-surface), 0.72);
--subagent-card-bg: rgba(var(--v-theme-surface-variant), 0.74);
--subagent-record-bg: rgba(var(--v-theme-surface), 0.52);
--subagent-empty-surface: rgba(var(--v-theme-surface-variant), 0.56);
--subagent-border: rgba(var(--v-theme-borderLight), 0.46);
--subagent-border-strong: rgba(var(--v-theme-borderLight), 0.66);
--subagent-track: rgba(var(--v-theme-borderLight), 0.9);
--subagent-shadow: none;
}
.subagent-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--subagent-panel-bg);
border: 1px solid var(--subagent-border);
border-radius: 12px;
backdrop-filter: blur(16px);
box-shadow: var(--subagent-shadow);
flex-shrink: 0;
}
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-eyebrow {
margin-bottom: 4px;
color: var(--subagent-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.topbar-title {
font-size: clamp(24px, 3vw, 32px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--subagent-primary) !important;
-webkit-text-fill-color: var(--subagent-primary);
}
.topbar-desc {
font-size: 14px;
color: var(--subagent-muted) !important;
-webkit-text-fill-color: var(--subagent-muted);
line-height: 1.5;
max-width: 60ch;
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.unsaved-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
margin-bottom: 18px;
padding: 12px 16px;
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);
color: var(--subagent-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);
}
.subagent-section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; }
.setting-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.subagent-section-title { font-size: 19px; font-weight: 650; letter-spacing: -0.02em; color: var(--subagent-text); }
.setting-title {
font-size: 15px;
font-weight: 600;
line-height: 1.5;
}
.subagent-section-subtitle { margin-top: 6px; color: var(--subagent-muted); font-size: 13px; }
.setting-subtitle {
margin-top: 6px;
color: var(--dashboard-muted);
font-size: 13px;
line-height: 1.6;
}
.subagent-section-actions { display: flex; align-items: center; gap: 10px; }
.empty-card {
min-height: 280px;
}
.empty-wrap {
height: 100%;
display: flex;
flex-direction: column;
.subagent-pill {
display: inline-flex;
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);
padding: 8px 14px;
border: 1px solid var(--subagent-border);
border-radius: 999px;
background: var(--subagent-panel-bg);
color: var(--subagent-muted);
font-size: 13px;
font-weight: 500;
}
.selector-card {
border: 1px solid var(--dashboard-border);
.subagent-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.subagent-setting-card {
padding: 18px;
background: var(--subagent-panel-bg);
border: 1px solid var(--subagent-border);
border-radius: 12px;
padding: 14px;
background: transparent;
backdrop-filter: blur(16px);
}
.persona-preview-wrap {
min-height: 320px;
.setting-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.setting-title { font-size: 15px; font-weight: 600; color: var(--subagent-text); }
.setting-subtitle { margin-top: 6px; color: var(--subagent-muted); font-size: 13px; }
.subagent-content {
background: var(--subagent-panel-bg);
border: 1px solid var(--subagent-border);
border-radius: 12px;
backdrop-filter: blur(16px);
}
@media (max-width: 1080px) {
.agent-edit-grid {
grid-template-columns: 1fr;
}
}
.subagent-empty-card { min-height: 280px; padding: 22px; }
.empty-wrap { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: var(--subagent-muted); }
.empty-title { font-size: 20px; font-weight: 650; color: var(--subagent-text); margin-bottom: 8px; }
.empty-subtitle { color: var(--subagent-muted); font-size: 14px; }
.subagent-list { display: grid; gap: 16px; }
.agent-panel { padding: 22px; 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; color: var(--subagent-text); }
.agent-summary-desc { margin-top: 8px; color: var(--subagent-muted); font-size: 13px; }
.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 { padding: 18px; background: var(--subagent-card-bg); border: 1px solid var(--subagent-border); border-radius: 12px; min-width: 0; }
.section-mini-title { margin-bottom: 10px; }
.subagent-form-grid { display: grid; gap: 16px; }
.subagent-form-grid--single { grid-template-columns: 1fr; }
.selector-wrap { display: grid; gap: 8px; }
.selector-label { color: var(--subagent-muted); font-size: 13px; font-weight: 500; }
.selector-card { border: 1px solid var(--subagent-border); border-radius: 12px; padding: 14px; background: transparent; }
.persona-preview-wrap { min-height: 320px; }
@media (max-width: 1280px) { .subagent-settings-grid { grid-template-columns: 1fr; } }
@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;
}
.subagent-topbar { flex-direction: column; align-items: flex-start; padding: 16px; }
.topbar-right { width: 100%; }
.subagent-page { gap: 12px; padding: 12px; }
.setting-card-head, .agent-summary { flex-direction: column; align-items: flex-start; }
}
</style>