后端: 1. deliver 收口上下文重构——历史折叠到工作区,仅基于本轮 execute 窗口诚实收口 - newAgent/prompt/deliver.go:BuildDeliverMessages 改为向 buildDeliverWorkspace 透传 ConversationContext - newAgent/prompt/deliver_context.go:deliver 的 msg1 改为轻量提示,不再回灌完整历史;msg2 追加本轮 execute 窗口与结果态信息 前端: 2. 品牌命名统一切换为 SmartMate - index.html:页面标题从 SmartFlow 改为 SmartMate - package.json:前端包名改为 smartmate-frontend - App.vue:布局类名从 smartflow-* 统一改为 smartmate-* - stores/auth.ts:access/refresh token 与 last username 的 localStorage key 全部切到 smartmate_* - utils/idempotency.ts:默认幂等键前缀从 smartflow 改为 smartmate - DashboardView.vue:首页默认问候名从 SmartFlow 用户改为 SmartMate 用户 3. 助手页体验重做——默认空会话、排程卡片懒加载、上下文统计刷新时机收口 - components/dashboard/AssistantPanel.vue:进入页面不再自动打开最后一次会话,改为展示居中欢迎空态 - components/dashboard/AssistantPanel.vue:schedule_completed 改为先展示占位卡片,点击后再拉取 schedule preview,避免预览未落库时并发 404 - components/dashboard/AssistantPanel.vue:tool done、schedule card、SSE block done、[DONE] 与整轮流结束后统一刷新 context stats - components/dashboard/AssistantPanel.vue:重构聊天区布局、空态欢迎内容、底部交互区与内外边距,整体视觉切到更轻的阅读式界面 - views/AssistantView.vue:移除外层白底卡片壳,交由 AssistantPanel 自己承接容器视觉 4. 排程微调保存链路补幂等保护,并修正请求头口径 - api/schedule_agent.ts:正式应用接口请求头从 Idempotency-Key 改为 X-Idempotency-Key - components/assistant/ScheduleFineTuneModal.vue:同一预览会话复用稳定幂等键,保存成功后再刷新新 key,避免重试或延迟导致重复落库 - components/assistant/ScheduleResultCard.vue:结果卡片样式、hover 与进场动效整体升级 5. 任务类选择器与侧边导航细节调整 - components/assistant/TaskClassPlanningPicker.vue:popover、骨架屏、列表项、选中态与按钮视觉整体重绘 - components/common/MainSidebar.vue:移除“任务”占位入口,侧栏只保留总览 / 日程 / 助手 6. 登录页与首页展示风格重做 - views/AuthView.vue:品牌文案切到 SmartMate,登录/注册从 tabs 改为自定义双态切换,重做背景、玻璃卡片、表单与动效 - views/DashboardView.vue:首页主区改为 auto + 1fr 布局,锁定顶部栏高度,避免缩放时形变 仓库: 7. README 全量更新到当前版本能力边界 - README.md:重写项目定位、功能描述、业务闭环图、newAgent graph 流程、工具定义、前端衔接边界、页面展示、部署方案与监控说明
543 lines
14 KiB
Vue
543 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import { getTaskClassList } from '@/api/scheduleCenter'
|
||
import type { TaskClassListItem } from '@/types/schedule'
|
||
|
||
interface SelectedTaskClassSummary {
|
||
id: number
|
||
name: string
|
||
}
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
modelValue: number[]
|
||
disabled?: boolean
|
||
}>(),
|
||
{
|
||
disabled: false,
|
||
},
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [taskClassIds: number[]]
|
||
applied: [taskClassIds: number[]]
|
||
}>()
|
||
|
||
const popoverVisible = ref(false)
|
||
const taskClassLoading = ref(false)
|
||
const taskClasses = ref<TaskClassListItem[]>([])
|
||
const draftSelectedIds = ref<number[]>([])
|
||
const taskClassListReady = ref(false)
|
||
|
||
const triggerLabel = computed(() => {
|
||
if (props.modelValue.length <= 0) {
|
||
return '智能编排'
|
||
}
|
||
return `编排 ${props.modelValue.length}`
|
||
})
|
||
|
||
const selectedTaskClasses = computed<SelectedTaskClassSummary[]>(() => {
|
||
const lookup = new Map(taskClasses.value.map((item) => [item.id, item]))
|
||
return props.modelValue.map((taskClassId) => {
|
||
const taskClass = lookup.get(taskClassId)
|
||
return {
|
||
id: taskClassId,
|
||
name: taskClass?.name || `任务类 #${taskClassId}`,
|
||
}
|
||
})
|
||
})
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(nextValue) => {
|
||
if (!popoverVisible.value) {
|
||
draftSelectedIds.value = [...nextValue]
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(popoverVisible, (visible) => {
|
||
if (!visible) {
|
||
return
|
||
}
|
||
draftSelectedIds.value = [...props.modelValue]
|
||
void ensureTaskClassListLoaded()
|
||
})
|
||
|
||
function normalizeTaskClassIds(taskClassIds: number[]) {
|
||
const seen = new Set<number>()
|
||
const normalized: number[] = []
|
||
|
||
for (const taskClassId of taskClassIds) {
|
||
if (!Number.isInteger(taskClassId) || taskClassId <= 0 || seen.has(taskClassId)) {
|
||
continue
|
||
}
|
||
seen.add(taskClassId)
|
||
normalized.push(taskClassId)
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
async function ensureTaskClassListLoaded() {
|
||
if (taskClassLoading.value || taskClassListReady.value) {
|
||
return
|
||
}
|
||
|
||
taskClassLoading.value = true
|
||
try {
|
||
taskClasses.value = await getTaskClassList()
|
||
taskClassListReady.value = true
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '任务类列表加载失败')
|
||
} finally {
|
||
taskClassLoading.value = false
|
||
}
|
||
}
|
||
|
||
function toggleDraftSelection(taskClassId: number) {
|
||
if (draftSelectedIds.value.includes(taskClassId)) {
|
||
draftSelectedIds.value = draftSelectedIds.value.filter((id) => id !== taskClassId)
|
||
return
|
||
}
|
||
draftSelectedIds.value = [...draftSelectedIds.value, taskClassId]
|
||
}
|
||
|
||
function applySelection() {
|
||
// 1. 先在前端做一次去重和非法值过滤,避免把脏 ID 直接发给后端。
|
||
// 2. 这里只负责提交“下一条消息要带的任务类上下文”,不负责直接触发发送。
|
||
// 3. 提交成功后关闭弹层,让用户回到输入区继续编辑本轮提示词。
|
||
const normalizedTaskClassIds = normalizeTaskClassIds(draftSelectedIds.value)
|
||
emit('update:modelValue', normalizedTaskClassIds)
|
||
emit('applied', normalizedTaskClassIds)
|
||
popoverVisible.value = false
|
||
}
|
||
|
||
function clearSelectionFromPanel() {
|
||
draftSelectedIds.value = []
|
||
emit('update:modelValue', [])
|
||
emit('applied', [])
|
||
popoverVisible.value = false
|
||
}
|
||
|
||
function removeSelectedTaskClass(taskClassId: number) {
|
||
emit(
|
||
'update:modelValue',
|
||
props.modelValue.filter((id) => id !== taskClassId),
|
||
)
|
||
}
|
||
|
||
function clearSelectedTaskClasses() {
|
||
emit('update:modelValue', [])
|
||
}
|
||
|
||
function formatDateRange(taskClass: TaskClassListItem) {
|
||
const startDate = formatDateLabel(taskClass.start_date)
|
||
const endDate = formatDateLabel(taskClass.end_date)
|
||
if (!startDate || !endDate) {
|
||
return '时间范围待补充'
|
||
}
|
||
return `${startDate} - ${endDate}`
|
||
}
|
||
|
||
function formatDateLabel(value: string) {
|
||
const parsedDate = new Date(value)
|
||
if (Number.isNaN(parsedDate.getTime())) {
|
||
return ''
|
||
}
|
||
const month = `${parsedDate.getMonth() + 1}`.padStart(2, '0')
|
||
const day = `${parsedDate.getDate()}`.padStart(2, '0')
|
||
return `${month}.${day}`
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="assistant-planning">
|
||
<el-popover
|
||
v-model:visible="popoverVisible"
|
||
placement="top-start"
|
||
trigger="click"
|
||
:width="360"
|
||
:teleported="true"
|
||
popper-class="assistant-planning-popover"
|
||
>
|
||
<template #reference>
|
||
<button
|
||
type="button"
|
||
class="assistant-planning__trigger"
|
||
:class="{ 'assistant-planning__trigger--active': modelValue.length > 0 }"
|
||
:disabled="disabled"
|
||
>
|
||
<span class="assistant-planning__trigger-icon" aria-hidden="true">
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M7 1.25L12.25 4.375L7 7.5L1.75 4.375L7 1.25Z" fill="currentColor" />
|
||
<path d="M1.75 6.5625L7 9.6875L12.25 6.5625" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" />
|
||
<path d="M1.75 8.75L7 11.875L12.25 8.75" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</span>
|
||
<span class="assistant-planning__trigger-text">{{ triggerLabel }}</span>
|
||
</button>
|
||
</template>
|
||
|
||
<div class="assistant-planning__panel">
|
||
<div class="assistant-planning__panel-header">
|
||
<div>
|
||
<strong>选择任务类</strong>
|
||
<p>本次发送将把所选任务类作为智能编排上下文带给后端。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="taskClassLoading" class="assistant-planning__loading">
|
||
<div v-for="index in 4" :key="index" class="assistant-planning__loading-item" />
|
||
</div>
|
||
|
||
<div v-else-if="taskClasses.length" class="assistant-planning__list">
|
||
<button
|
||
v-for="taskClass in taskClasses"
|
||
:key="taskClass.id"
|
||
type="button"
|
||
class="assistant-planning__item"
|
||
:class="{ 'assistant-planning__item--selected': draftSelectedIds.includes(taskClass.id) }"
|
||
@click="toggleDraftSelection(taskClass.id)"
|
||
>
|
||
<span
|
||
class="assistant-planning__item-check"
|
||
:class="{ 'assistant-planning__item-check--selected': draftSelectedIds.includes(taskClass.id) }"
|
||
aria-hidden="true"
|
||
/>
|
||
<span class="assistant-planning__item-body">
|
||
<strong>{{ taskClass.name }}</strong>
|
||
<small>{{ formatDateRange(taskClass) }}</small>
|
||
</span>
|
||
<span class="assistant-planning__item-slots">{{ taskClass.total_slots }} 节</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="assistant-planning__empty">
|
||
当前还没有可用于智能编排的任务类。
|
||
</div>
|
||
|
||
<div class="assistant-planning__panel-actions">
|
||
<button type="button" class="assistant-planning__panel-button assistant-planning__panel-button--ghost" @click="clearSelectionFromPanel">
|
||
清空
|
||
</button>
|
||
<button type="button" class="assistant-planning__panel-button assistant-planning__panel-button--primary" @click="applySelection">
|
||
应用选择
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
|
||
<div v-if="selectedTaskClasses.length" class="assistant-planning__summary">
|
||
<span class="assistant-planning__summary-label">已选任务类</span>
|
||
<div class="assistant-planning__tags">
|
||
<button
|
||
v-for="taskClass in selectedTaskClasses"
|
||
:key="taskClass.id"
|
||
type="button"
|
||
class="assistant-planning__tag"
|
||
:disabled="disabled"
|
||
@click="removeSelectedTaskClass(taskClass.id)"
|
||
>
|
||
<span>{{ taskClass.name }}</span>
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
<button type="button" class="assistant-planning__clear" :disabled="disabled" @click="clearSelectedTaskClasses">
|
||
清空全部
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.assistant-planning {
|
||
display: grid;
|
||
justify-items: start;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
padding: 10px 12px 0;
|
||
}
|
||
|
||
.assistant-planning__trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
width: 138px;
|
||
min-width: 138px;
|
||
max-width: 138px;
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
box-sizing: border-box;
|
||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
color: #1f2430;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||
}
|
||
|
||
.assistant-planning__trigger:hover:not(:disabled) {
|
||
border-color: rgba(57, 86, 178, 0.26);
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.assistant-planning__trigger:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.58;
|
||
}
|
||
|
||
.assistant-planning__trigger--active {
|
||
border-color: rgba(57, 86, 178, 0.24);
|
||
background: #eef3ff;
|
||
color: #3357c2;
|
||
}
|
||
|
||
.assistant-planning__trigger-icon {
|
||
display: inline-flex;
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.assistant-planning__trigger-text {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.assistant-planning__summary {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.assistant-planning__summary-label {
|
||
color: #5b6677;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.assistant-planning__tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.assistant-planning__tag,
|
||
.assistant-planning__clear {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(57, 86, 178, 0.16);
|
||
background: #f6f8ff;
|
||
color: #3559c3;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.assistant-planning__clear {
|
||
border-style: dashed;
|
||
background: #ffffff;
|
||
color: #64748b;
|
||
}
|
||
|
||
.assistant-planning__panel {
|
||
display: grid;
|
||
gap: 14px;
|
||
/* 弹出动画 */
|
||
animation: planning-panel-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||
}
|
||
|
||
@keyframes planning-panel-pop {
|
||
0% {
|
||
opacity: 0;
|
||
transform: translateY(8px) scale(0.98);
|
||
}
|
||
100% {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
.assistant-planning__panel-header strong {
|
||
display: block;
|
||
color: #0f172a;
|
||
font-size: 16px;
|
||
font-weight: 850;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.assistant-planning__panel-header p {
|
||
margin: 4px 0 0;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.assistant-planning__loading,
|
||
.assistant-planning__list {
|
||
display: grid;
|
||
gap: 6px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
padding: 2px;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.assistant-planning__loading-item {
|
||
height: 60px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
|
||
background-size: 200% 100%;
|
||
animation: shimmer 1.5s infinite linear;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
|
||
.assistant-planning__item {
|
||
width: 100%;
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 14px;
|
||
border: 1px solid rgba(15, 23, 42, 0.04);
|
||
border-radius: 14px;
|
||
background: #f8fafc;
|
||
text-align: left;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.assistant-planning__item:hover {
|
||
border-color: rgba(59, 130, 246, 0.2);
|
||
background: #ffffff;
|
||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.assistant-planning__item--selected {
|
||
border-color: #3b82f6;
|
||
background: #ffffff;
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.06);
|
||
}
|
||
|
||
.assistant-planning__item-check {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 6px;
|
||
border: 2px solid #e2e8f0;
|
||
background: #ffffff;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.assistant-planning__item-check--selected {
|
||
border-color: #3b82f6;
|
||
background: #3b82f6;
|
||
position: relative;
|
||
}
|
||
|
||
.assistant-planning__item-check--selected::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 6px;
|
||
height: 3px;
|
||
border-left: 2px solid #ffffff;
|
||
border-bottom: 2px solid #ffffff;
|
||
transform: translate(-50%, -65%) rotate(-45deg);
|
||
}
|
||
|
||
.assistant-planning__item-body {
|
||
min-width: 0;
|
||
display: grid;
|
||
}
|
||
|
||
.assistant-planning__item-body strong {
|
||
color: #1e293b;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.assistant-planning__item-body small {
|
||
color: #94a3b8;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.assistant-planning__item-slots {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
background: #f1f5f9;
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.assistant-planning__empty {
|
||
padding: 30px 20px;
|
||
text-align: center;
|
||
border-radius: 12px;
|
||
background: #f8fafc;
|
||
color: #94a3b8;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.assistant-planning__panel-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
.assistant-planning__panel-button {
|
||
height: 34px;
|
||
padding: 0 14px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.assistant-planning__panel-button--ghost {
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
}
|
||
|
||
.assistant-planning__panel-button--ghost:hover {
|
||
background: #e2e8f0;
|
||
color: #475569;
|
||
}
|
||
|
||
.assistant-planning__panel-button--primary {
|
||
background: #3b82f6;
|
||
color: #ffffff;
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||
}
|
||
|
||
.assistant-planning__panel-button--primary:hover {
|
||
background: #2563eb;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 15px rgba(59, 130, 246, 0.25);
|
||
}
|
||
|
||
:global(.assistant-planning-popover) {
|
||
padding: 18px !important;
|
||
border-radius: 20px !important;
|
||
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12) !important;
|
||
}
|
||
</style>
|