Files
smartmate/frontend/src/components/assistant/TaskClassPlanningPicker.vue
Losita b309a32a98 Version: 0.9.33.dev.260419
后端:
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 流程、工具定义、前端衔接边界、页面展示、部署方案与监控说明
2026-04-19 23:54:48 +08:00

543 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>