mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user