Version: 0.9.31.dev.260419
后端: 1. 日程暂存接口——前端拖拽调整后保存到 Redis 快照 - api/agent.go:新增 SaveScheduleState handler,解析绝对时间格式请求体,3 秒超时保护 - routers/routers.go:注册 POST /schedule-state - model/agent.go:新增 SaveScheduleStatePlacedItem / SaveScheduleStateRequest 结构体 - respond/respond.go:新增 5 个排程状态错误码(40058~40062) - 新增 service/agentsvc/agent_schedule_state.go:Load 快照 → ApplyPlacedItems → Save 回 Redis,校验归属 - 新增 newAgent/conv/schedule_state_apply.go:ApplyPlacedItems 绝对坐标→相对 day_index 转换,去重/坐标/嵌入关系校验 2. SchedulePersistor 持久化层全面下线 - 删除 newAgent/conv/schedule_persist.go(280 行,DiffScheduleState → applyChange → 事务写库整条链路) - model/state_store.go:移除 SchedulePersistor 接口 - model/graph_run_state.go / node/execute.go / node/agent_nodes.go / service/agent.go / service/agent_newagent.go / cmd/start.go:移除 SchedulePersistor 字段、参数、注入六处 3. schedule_completed 事件推送——deliver 节点排程完毕信号 - model/common_state.go:新增 HasScheduleChanges 标记,ResetForNextRun 清理 - node/execute.go / node/rough_build.go:写工具和粗排成功后置 HasScheduleChanges=true - node/deliver.go:IsCompleted && HasScheduleChanges 时调用 EmitScheduleCompleted - stream/emitter.go:新增 EmitScheduleCompleted 方法 - stream/openai.go:新增 StreamExtraKindScheduleCompleted + NewScheduleCompletedExtra 4. 预览接口补全 task_class_id - model/agent.go:GetSchedulePlanPreviewResponse 新增 TaskClassIDs - model/schedule.go:HybridScheduleEntry 新增 TaskClassID - conv/schedule_preview.go / service/agent_schedule_preview.go / service/schedule.go:三处透传填充 前端: 5. 排程完毕卡片 + 精排弹窗集成 - 新增 api/schedule_agent.ts:getSchedulePreview / saveScheduleState / applyBatchIntoSchedule - types/dashboard.ts:新增 HybridScheduleEntry / SchedulePreviewData / PlacedItem 类型 - components/dashboard/AssistantPanel.vue:监听 schedule_completed 事件异步拉取排程渲染卡片,集成 ScheduleResultCard + ScheduleFineTuneModal;confirm 交互从文本消息改为 resume 协议(approve/reject/cancel) 6. ToolTracePrototypeView 原型页新增日程小卡片 + 拖拽编排弹窗演示 7. DashboardView import 区域尺寸微调
This commit is contained in:
54
frontend/src/api/schedule_agent.ts
Normal file
54
frontend/src/api/schedule_agent.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||
import { extractErrorMessage } from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 获取排程预览数据
|
||||
*/
|
||||
export async function getSchedulePreview(conversationId: string): Promise<SchedulePreviewData> {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<SchedulePreviewData>>('/agent/schedule-preview', {
|
||||
params: { conversation_id: conversationId },
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '获取方案预览失败'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存排程状态到 Redis
|
||||
*/
|
||||
export async function saveScheduleState(conversationId: string, items: PlacedItem[]): Promise<void> {
|
||||
try {
|
||||
await http.post<ApiResponse<void>>('/agent/schedule-state', {
|
||||
conversation_id: conversationId,
|
||||
items,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '暂存方案失败'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将排程结果正式应用到数据库
|
||||
*/
|
||||
export async function applyBatchIntoSchedule(
|
||||
taskClassId: number,
|
||||
items: PlacedItem[],
|
||||
idempotencyKey: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.put<ApiResponse<void>>('/task-class/apply-batch-into-schedule', {
|
||||
task_class_id: taskClassId,
|
||||
items,
|
||||
}, {
|
||||
headers: {
|
||||
'Idempotency-Key': idempotencyKey
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '保存方案失败'))
|
||||
}
|
||||
}
|
||||
682
frontend/src/components/assistant/ScheduleFineTuneModal.vue
Normal file
682
frontend/src/components/assistant/ScheduleFineTuneModal.vue
Normal file
@@ -0,0 +1,682 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||
import { saveScheduleState, applyBatchIntoSchedule } from '@/api/schedule_agent'
|
||||
|
||||
const props = defineProps<{
|
||||
previewData: SchedulePreviewData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
// 计算数据中的起止周次
|
||||
const weekRange = computed(() => {
|
||||
const weeks = props.previewData.hybrid_entries.map(e => e.week)
|
||||
if (weeks.length === 0) return { min: 1, max: 20 }
|
||||
return {
|
||||
min: Math.min(...weeks),
|
||||
max: Math.max(...weeks)
|
||||
}
|
||||
})
|
||||
|
||||
const currentWeek = ref(weekRange.value.min)
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 内部维护一份可变的建议任务列表,用于拖拽更新
|
||||
const suggestedItems = ref<HybridScheduleEntry[]>(
|
||||
JSON.parse(JSON.stringify(props.previewData.hybrid_entries))
|
||||
)
|
||||
|
||||
const sectionSlots = [
|
||||
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
||||
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
|
||||
{ order: 3, title: '5-6', timeRange: '14:00\n15:40' },
|
||||
{ order: 4, title: '7-8', timeRange: '16:15\n17:55' },
|
||||
{ order: 5, title: '9-10', timeRange: '19:00\n20:40' },
|
||||
{ order: 6, title: '11-12', timeRange: '20:50\n22:30' },
|
||||
]
|
||||
|
||||
function prevWeek() {
|
||||
if (currentWeek.value > weekRange.value.min) currentWeek.value--
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
if (currentWeek.value < weekRange.value.max) currentWeek.value++
|
||||
}
|
||||
|
||||
// 转换当前状态为后端要求的 PlacedItem 数组
|
||||
function buildPlacedItems(): PlacedItem[] {
|
||||
return suggestedItems.value
|
||||
.filter(e => e.type === 'task' && e.status === 'suggested')
|
||||
.map(e => ({
|
||||
task_item_id: e.task_item_id,
|
||||
week: e.week,
|
||||
day_of_week: e.day_of_week,
|
||||
start_section: e.section_from,
|
||||
end_section: e.section_to,
|
||||
embed_course_event_id: e.event_id || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存至 State (Redis)
|
||||
*/
|
||||
async function handleSaveToState() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const items = buildPlacedItems()
|
||||
await saveScheduleState(props.previewData.conversation_id, items)
|
||||
ElMessage.success('方案已成功暂存')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '暂存失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正式保存到数据库 (MySQL)
|
||||
*/
|
||||
async function handleOfficialSave() {
|
||||
await ElMessageBox.confirm(
|
||||
'正式保存将把当前编排结果写入你的日程表。保存后本轮编排微调将终止,确认继续吗?',
|
||||
'正式保存确认',
|
||||
{
|
||||
confirmButtonText: '确认保存',
|
||||
cancelButtonText: '我再想想',
|
||||
type: 'warning',
|
||||
roundButton: true,
|
||||
customClass: 'premium-msg-box',
|
||||
}
|
||||
)
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const items = buildPlacedItems()
|
||||
// 按 task_class_id 分组
|
||||
const groups = new Map<number, PlacedItem[]>()
|
||||
suggestedItems.value.forEach(e => {
|
||||
if (e.type === 'task' && e.status === 'suggested' && e.task_class_id) {
|
||||
if (!groups.has(e.task_class_id)) groups.set(e.task_class_id, [])
|
||||
groups.get(e.task_class_id)!.push({
|
||||
task_item_id: e.task_item_id,
|
||||
week: e.week,
|
||||
day_of_week: e.day_of_week,
|
||||
start_section: e.section_from,
|
||||
end_section: e.section_to,
|
||||
embed_course_event_id: e.event_id || undefined,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
||||
applyBatchIntoSchedule(classId, groupItems, idempotencyKey)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
ElMessage.success('日程已正式保存到数据库')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽逻辑
|
||||
function onDragStart(event: DragEvent, item: HybridScheduleEntry) {
|
||||
if (item.status === 'existing') return // 不允许拖拽已有日程
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(item))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent, day: number, order: number) {
|
||||
const rawData = event.dataTransfer?.getData('application/json')
|
||||
if (!rawData) return
|
||||
|
||||
const draggedItem = JSON.parse(rawData) as HybridScheduleEntry
|
||||
const itemIndex = suggestedItems.value.findIndex(i => i.task_item_id === draggedItem.task_item_id)
|
||||
|
||||
if (itemIndex > -1) {
|
||||
// 转发为块起始节次
|
||||
const targetSectionFrom = (order - 1) * 2 + 1
|
||||
const targetSectionTo = targetSectionFrom + 1
|
||||
|
||||
// 检查目标位置是否有冲突(block_for_suggested === true 且不是自己)
|
||||
const conflict = suggestedItems.value.find(i =>
|
||||
i.week === currentWeek.value &&
|
||||
i.day_of_week === day &&
|
||||
getBlockIndex(i.section_from) === order &&
|
||||
i.block_for_suggested &&
|
||||
i.task_item_id !== draggedItem.task_item_id
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
if (conflict.can_be_embedded) {
|
||||
// 允许嵌入
|
||||
const item = suggestedItems.value[itemIndex]
|
||||
item.week = currentWeek.value
|
||||
item.day_of_week = day
|
||||
item.section_from = targetSectionFrom
|
||||
item.section_to = targetSectionTo
|
||||
item.event_id = conflict.event_id // 设置嵌入目标 ID
|
||||
} else {
|
||||
ElMessage.warning('该时段已有课程,无法安插任务')
|
||||
}
|
||||
} else {
|
||||
// 自由空位
|
||||
const item = suggestedItems.value[itemIndex]
|
||||
item.week = currentWeek.value
|
||||
item.day_of_week = day
|
||||
item.section_from = targetSectionFrom
|
||||
item.section_to = targetSectionTo
|
||||
item.event_id = 0 // 清除嵌入状态
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将后端节次 (1, 3, 5...) 映射为前端 6 个双节块索引 (1, 2, 3...)
|
||||
const getBlockIndex = (section: number) => Math.floor((section - 1) / 2) + 1
|
||||
|
||||
// 获取项的网格定位样式
|
||||
function getItemStyle(item: HybridScheduleEntry) {
|
||||
// 网格第 1 行是表头,所以 row 为 section + 1
|
||||
const rowStart = item.section_from + 1
|
||||
const rowEnd = item.section_to + 2
|
||||
return {
|
||||
gridColumn: item.day_of_week + 1,
|
||||
gridRow: `${rowStart} / ${rowEnd}`,
|
||||
zIndex: item.status === 'suggested' ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
const currentWeekEntries = computed(() =>
|
||||
suggestedItems.value.filter(e => e.week === currentWeek.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="previewData" class="schedule-modal-overlay" @click.self="emit('close')">
|
||||
<div class="schedule-modal">
|
||||
<header class="schedule-modal__header">
|
||||
<h3>日程预览与精排 (第 {{ currentWeek }} 周)</h3>
|
||||
<div class="schedule-modal__header-actions">
|
||||
<div class="week-switcher">
|
||||
<button
|
||||
class="week-switcher__btn"
|
||||
@click="prevWeek"
|
||||
:disabled="currentWeek <= weekRange.min"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="week-switcher__label">第 {{ currentWeek }} 周</span>
|
||||
<button
|
||||
class="week-switcher__btn"
|
||||
@click="nextWeek"
|
||||
:disabled="currentWeek >= weekRange.max"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="schedule-modal__close" @click="emit('close')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="schedule-modal__body">
|
||||
<div class="planning-board__grid">
|
||||
<div class="planning-board__corner" />
|
||||
|
||||
<!-- 表头:周一到周日 -->
|
||||
<div
|
||||
v-for="d in 7"
|
||||
:key="`h-${d}`"
|
||||
class="planning-board__day-head"
|
||||
:style="{ gridColumn: d + 1, gridRow: 1 }"
|
||||
>
|
||||
<span>{{ ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][d-1] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 时间侧栏与背景格:采用跨行方式,每个 Block 占 2 行 -->
|
||||
<template v-for="slot in sectionSlots" :key="`r-${slot.order}`">
|
||||
<div
|
||||
class="planning-board__time-cell"
|
||||
:style="{ gridColumn: 1, gridRow: `${(slot.order-1)*2 + 2} / span 2` }"
|
||||
>
|
||||
<strong>{{ slot.title }}</strong>
|
||||
<small>{{ slot.timeRange }}</small>
|
||||
</div>
|
||||
<!-- 空白背景格:跨 2 行以形成大块感 -->
|
||||
<div
|
||||
v-for="d in 7"
|
||||
:key="`bg-${d}-${slot.order}`"
|
||||
class="planning-board__cell planning-board__cell--empty"
|
||||
:style="{ gridColumn: d + 1, gridRow: `${(slot.order-1)*2 + 2} / span 2` }"
|
||||
@dragover.prevent
|
||||
@drop="onDrop($event, d, slot.order)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 实际的内容项:基于 12 行粒度精确展示 -->
|
||||
<div
|
||||
v-for="item in currentWeekEntries"
|
||||
:key="item.task_item_id || `existing-${item.event_id}`"
|
||||
class="planning-board__cell-main board-item-pop"
|
||||
:class="`planning-board__cell-main--${item.type}`"
|
||||
:style="getItemStyle(item)"
|
||||
:draggable="item.status === 'suggested'"
|
||||
@dragstart="onDragStart($event, item)"
|
||||
>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.type === 'course' ? (item.context_tag || '教学楼 A') : '个人任务' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="schedule-modal__footer">
|
||||
<p class="schedule-modal__hint">提示:拖动卡片可调整日程顺序</p>
|
||||
<div class="schedule-modal__actions">
|
||||
<button class="tool-btn tool-btn--ghost" @click="emit('close')">取消</button>
|
||||
<button
|
||||
class="tool-btn tool-btn--state"
|
||||
:disabled="isSaving"
|
||||
@click="handleSaveToState"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '暂存进state' }}
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn tool-btn--primary"
|
||||
:disabled="isSaving"
|
||||
@click="handleOfficialSave"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '正式保存日程' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 弹窗核心样式 */
|
||||
.schedule-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.schedule-modal {
|
||||
background: #ffffff;
|
||||
width: min(1400px, 92%);
|
||||
height: auto;
|
||||
max-height: 95vh;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-modal__header {
|
||||
padding: 20px 32px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.schedule-modal__header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.schedule-modal__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.week-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.week-switcher__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.week-switcher__btn:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.week-switcher__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.week-switcher__label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-modal__close {
|
||||
background: #f1f5f9;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.schedule-modal__close:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.schedule-modal__body {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schedule-modal__footer {
|
||||
padding: 20px 32px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.schedule-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.schedule-modal__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 按钮通用样式 */
|
||||
.tool-btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tool-btn--primary {
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.tool-btn--primary:hover:not(:disabled) {
|
||||
background: #1e293b;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.tool-btn--state {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: #dbeafe;
|
||||
}
|
||||
|
||||
.tool-btn--state:hover:not(:disabled) {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.tool-btn--ghost {
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.tool-btn--ghost:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tool-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 网格样式 */
|
||||
.planning-board__grid {
|
||||
--planning-grid-padding-x: 20px;
|
||||
--planning-grid-padding-y: 16px;
|
||||
--planning-grid-gap-x: 10px;
|
||||
--planning-grid-gap-y: 10px;
|
||||
--planning-time-column-width: 76px;
|
||||
--planning-day-column-min: 96px;
|
||||
--planning-cell-height: 82px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
|
||||
grid-template-rows: auto repeat(12, calc((var(--planning-cell-height) - var(--planning-grid-gap-y)) / 2));
|
||||
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
|
||||
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 32px;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.planning-board__corner {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.planning-board__day-head {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.planning-board__day-head span {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.planning-board__time-cell {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: end;
|
||||
color: #94a3b8;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.planning-board__time-cell strong {
|
||||
font-size: 15px;
|
||||
color: #475569;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.planning-board__time-cell small {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
white-space: pre-line;
|
||||
text-align: right;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.planning-board__cell {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.planning-board__cell--empty {
|
||||
background: #ffffff;
|
||||
border: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.planning-board__cell:hover:not(.planning-board__cell--empty) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.planning-board__cell-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
border-radius: 16px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.planning-board__cell-main:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.planning-board__cell-main--course {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.planning-board__cell-main--task {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.planning-board__cell-main strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.planning-board__cell-main span {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 进场动画 */
|
||||
@keyframes board-item-spring {
|
||||
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
|
||||
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.board-item-pop {
|
||||
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* 弹窗动画 */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .schedule-modal {
|
||||
animation: modal-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal-leave-active .schedule-modal {
|
||||
animation: modal-in 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
transform: scale(0.95) translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/components/assistant/ScheduleResultCard.vue
Normal file
97
frontend/src/components/assistant/ScheduleResultCard.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 排程结果展示卡片
|
||||
* 显示在 AI 助手的消息流中,告知用户排程已就绪。
|
||||
*/
|
||||
defineProps<{
|
||||
summary: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schedule-result-card" @click="emit('click')">
|
||||
<div class="schedule-result-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="schedule-result-card__content">
|
||||
<h4 class="schedule-result-card__summary">{{ summary }}</h4>
|
||||
<p class="schedule-result-card__detail">点击查看排程方案并进行微调</p>
|
||||
</div>
|
||||
<div class="schedule-result-card__arrow">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schedule-result-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.schedule-result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.schedule-result-card__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.schedule-result-card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-result-card__summary {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.schedule-result-card__detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.schedule-result-card__arrow {
|
||||
color: #cbd5e1;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.schedule-result-card:hover .schedule-result-card__arrow {
|
||||
transform: translateX(4px);
|
||||
color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getConversationMeta,
|
||||
type ConversationHistoryMessage,
|
||||
} from '@/api/agent'
|
||||
import { getSchedulePreview } from '@/api/schedule_agent'
|
||||
import { refreshToken } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
@@ -21,7 +22,10 @@ import type {
|
||||
ConversationListItem,
|
||||
ConversationMeta,
|
||||
ThinkingModeType,
|
||||
SchedulePreviewData,
|
||||
} from '@/types/dashboard'
|
||||
import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue'
|
||||
import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue'
|
||||
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
@@ -139,11 +143,12 @@ interface DisplayMessage {
|
||||
|
||||
interface DisplayAssistantBlock {
|
||||
id: string
|
||||
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator'
|
||||
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card'
|
||||
seq: number
|
||||
text?: string
|
||||
event?: ToolTraceEvent
|
||||
statusEvent?: StatusTraceEvent
|
||||
schedulePreview?: SchedulePreviewData
|
||||
}
|
||||
|
||||
interface AssistantContentBlock {
|
||||
@@ -218,6 +223,9 @@ const conversationContextStatsMap = reactive<Record<string, ConversationContextS
|
||||
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
||||
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
||||
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
||||
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
|
||||
const isFineTuneModalVisible = ref(false)
|
||||
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
||||
|
||||
const quickActions = [
|
||||
'帮我梳理今天最重要的三件事',
|
||||
@@ -464,6 +472,7 @@ function clearToolTraceState(messageId: string) {
|
||||
delete assistantReasoningSeqMap[messageId]
|
||||
delete assistantContentBlocksMap[messageId]
|
||||
delete assistantTimelineLastKindMap[messageId]
|
||||
delete scheduleResultMap[messageId]
|
||||
for (const key of Object.keys(toolTraceExpandedMap)) {
|
||||
if (key.startsWith(`${messageId}:tool:`)) {
|
||||
delete toolTraceExpandedMap[key]
|
||||
@@ -1208,6 +1217,16 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePreview = scheduleResultMap[dm.id]
|
||||
if (schedulePreview) {
|
||||
blocks.push({
|
||||
id: `${dm.id}:schedule-card`,
|
||||
type: 'schedule_card',
|
||||
seq: nextAssistantTimelineSeq(),
|
||||
schedulePreview,
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowDisplayReasoningBox(dm)) {
|
||||
const reasoningSeq = getDisplayReasoningSeq(dm)
|
||||
blocks.push({
|
||||
@@ -1699,6 +1718,30 @@ function isManualThinkingEnabled(mode: ThinkingModeType) {
|
||||
return mode === 'true'
|
||||
}
|
||||
|
||||
function openFineTuneModal(data: SchedulePreviewData) {
|
||||
activeFineTuneData.value = data
|
||||
isFineTuneModalVisible.value = true
|
||||
}
|
||||
|
||||
function closeFineTuneModal() {
|
||||
isFineTuneModalVisible.value = false
|
||||
}
|
||||
|
||||
function handleScheduleSaved() {
|
||||
// 保存成功后可选的操作:重新刷新历史或状态
|
||||
if (selectedConversationId.value) {
|
||||
void loadConversationContextStats(selectedConversationId.value, true)
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfirmOverlay() {
|
||||
// 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。
|
||||
// 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。
|
||||
confirmOverlayState.visible = false
|
||||
confirmOverlayState.manuallyClosed = true
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
|
||||
function resetConfirmOverlay() {
|
||||
// 1. 会话切换/新建会话时直接重置确认覆盖层,避免把上一个会话的确认状态误带到当前会话。
|
||||
// 2. interactionId 同时清空,确保下一次收到相同 ID 的事件也能被视为新事件并重新拉起卡片。
|
||||
@@ -1710,12 +1753,51 @@ function resetConfirmOverlay() {
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
|
||||
function closeConfirmOverlay() {
|
||||
// 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。
|
||||
// 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。
|
||||
async function sendConfirmAction(action: 'approve' | 'reject' | 'cancel') {
|
||||
const interactionId = confirmOverlayState.interactionId
|
||||
if (!interactionId) return
|
||||
|
||||
// 1. 立即关闭覆盖层,避免用户重复点击。
|
||||
// 2. 构造 resume 特殊载荷,复用 sendMessageInternal 发送到聊天接口。
|
||||
confirmOverlayState.visible = false
|
||||
confirmOverlayState.manuallyClosed = true
|
||||
confirmRejectDraft.value = ''
|
||||
await sendMessageInternal({
|
||||
preset: '',
|
||||
bypassConfirmOverlayCheck: true,
|
||||
requestExtra: {
|
||||
resume: {
|
||||
interaction_id: interactionId,
|
||||
type: 'confirm',
|
||||
action
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function submitConfirmRejectMessage() {
|
||||
const text = confirmRejectDraft.value.trim()
|
||||
if (!text) return
|
||||
|
||||
const interactionId = confirmOverlayState.interactionId
|
||||
if (!interactionId) return
|
||||
|
||||
confirmOverlayState.visible = false
|
||||
await sendMessageInternal({
|
||||
preset: text,
|
||||
bypassConfirmOverlayCheck: true,
|
||||
requestExtra: {
|
||||
resume: {
|
||||
interaction_id: interactionId,
|
||||
type: 'ask_user',
|
||||
action: 'reply'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleConfirmRejectInputEnter(event: KeyboardEvent) {
|
||||
if (event.shiftKey) return
|
||||
event.preventDefault()
|
||||
void submitConfirmRejectMessage()
|
||||
}
|
||||
|
||||
function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
|
||||
@@ -1887,6 +1969,19 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (extra.kind === 'schedule_completed') {
|
||||
// 异步拉取详细排程方案
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await getSchedulePreview(selectedConversationId.value)
|
||||
scheduleResultMap[assistantMessage.id] = preview
|
||||
scheduleScrollMessagesToBottom(true)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '获取排程方案失败')
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuppressReasoningDeltaByExtraKind(kind?: string) {
|
||||
@@ -2197,47 +2292,6 @@ async function sendMessage(preset?: string) {
|
||||
await sendMessageInternal({ preset })
|
||||
}
|
||||
|
||||
async function sendConfirmAction(action: 'accept' | 'reject', rejectMessage?: string) {
|
||||
// 1. confirm 是显式人工动作,先关闭覆盖层再发送,让对话框立即恢复可见。
|
||||
// 2. “拒绝”动作允许带上用户自定义要求,后端可据此进行重规划。
|
||||
// 3. 发送完成后清空输入草稿,避免旧要求残留到下一轮确认卡片。
|
||||
const normalizedRejectMessage = `${rejectMessage || ''}`.trim()
|
||||
const presetMessage =
|
||||
action === 'accept'
|
||||
? '我确认,继续执行。'
|
||||
: normalizedRejectMessage || '先不执行,请重新规划。'
|
||||
|
||||
closeConfirmOverlay()
|
||||
await sendMessageInternal({
|
||||
preset: presetMessage,
|
||||
requestExtra: { confirm_action: action },
|
||||
resetPlanningSelectionOnSuccess: false,
|
||||
bypassConfirmOverlayCheck: true,
|
||||
})
|
||||
confirmRejectDraft.value = ''
|
||||
}
|
||||
|
||||
async function submitConfirmRejectMessage() {
|
||||
const rejectMessage = confirmRejectDraft.value.trim()
|
||||
if (!rejectMessage) {
|
||||
ElMessage.warning('请输入你的调整要求后再发送。')
|
||||
return
|
||||
}
|
||||
|
||||
await sendConfirmAction('reject', rejectMessage)
|
||||
}
|
||||
|
||||
function handleConfirmRejectInputEnter(event: KeyboardEvent) {
|
||||
// 1. 中文输入法上屏期间按回车只用于选词,不应误触发发送。
|
||||
// 2. 仅在“非组合输入 + 单独 Enter”时提交,Shift+Enter 仍可换行。
|
||||
if (event.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void submitConfirmRejectMessage()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedMessages.value.length,
|
||||
() => {
|
||||
@@ -2583,13 +2637,20 @@ onBeforeUnmount(() => {
|
||||
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="block.type === 'content_indicator'" class="chat-message__streaming chat-message__streaming--plain">
|
||||
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
|
||||
<ScheduleResultCard
|
||||
:summary="block.schedulePreview.summary"
|
||||
@click="openFineTuneModal(block.schedulePreview)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
@@ -2652,7 +2713,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
class="assistant-confirm-card__button assistant-confirm-card__button--primary"
|
||||
:disabled="chatLoading"
|
||||
@click="sendConfirmAction('accept')"
|
||||
@click="sendConfirmAction('approve')"
|
||||
>
|
||||
确认执行
|
||||
</button>
|
||||
@@ -2792,6 +2853,14 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 日程排程方案精排弹窗 -->
|
||||
<ScheduleFineTuneModal
|
||||
v-if="isFineTuneModalVisible && activeFineTuneData"
|
||||
:preview-data="activeFineTuneData"
|
||||
@close="closeFineTuneModal"
|
||||
@saved="handleScheduleSaved"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -4566,5 +4635,143 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* --- AI 助手确认卡片 & 弹窗高级样式 --- */
|
||||
:global(.premium-msg-box) {
|
||||
--el-messagebox-width: 420px;
|
||||
border-radius: 24px !important;
|
||||
padding: 24px !important;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25) !important;
|
||||
backdrop-filter: blur(16px) !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
:global(.premium-msg-box .el-message-box__header) {
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
|
||||
:global(.premium-msg-box .el-message-box__title) {
|
||||
font-size: 18px !important;
|
||||
font-weight: 800 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
:global(.premium-msg-box .el-message-box__message) {
|
||||
color: #64748b !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
.assistant-confirm-composer {
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-confirm-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 15px -3px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.assistant-confirm-card__eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__summary {
|
||||
margin-bottom: 20px;
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__hint {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button {
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--primary {
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--ghost {
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__button--plain {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-box {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.assistant-confirm-card__reject-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -115,3 +115,43 @@ export interface ChatStreamRequest {
|
||||
thinking?: ThinkingModeType
|
||||
extra?: ChatRequestExtra
|
||||
}
|
||||
|
||||
export interface HybridScheduleEntry {
|
||||
week: number
|
||||
day_of_week: number
|
||||
section_from: number
|
||||
section_to: number
|
||||
name: string
|
||||
type: 'course' | 'task'
|
||||
status: 'existing' | 'suggested'
|
||||
task_item_id: number
|
||||
task_class_id: number
|
||||
event_id: number
|
||||
can_be_embedded: boolean
|
||||
block_for_suggested: boolean
|
||||
context_tag: string
|
||||
}
|
||||
|
||||
export interface ScheduleCandidatePlan {
|
||||
week: number
|
||||
events: TodayEvent[]
|
||||
}
|
||||
|
||||
export interface SchedulePreviewData {
|
||||
conversation_id: string
|
||||
trace_id: string
|
||||
summary: string
|
||||
candidate_plans: ScheduleCandidatePlan[]
|
||||
hybrid_entries: HybridScheduleEntry[]
|
||||
task_class_ids: number[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface PlacedItem {
|
||||
task_item_id: number
|
||||
week: number
|
||||
day_of_week: number
|
||||
start_section: number
|
||||
end_section: number
|
||||
embed_course_event_id?: number
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ function syncDashboardMainScale() {
|
||||
const gridGap = 10
|
||||
const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap
|
||||
if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return }
|
||||
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.98)
|
||||
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.96)
|
||||
dashboardMainScale.value = Number(nextScale.toFixed(4))
|
||||
})
|
||||
}
|
||||
@@ -343,9 +343,9 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
.dashboard-quadrants { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
|
||||
.dashboard-import {
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
min-height: 220px;
|
||||
border-radius: 20px;
|
||||
padding: 24px 32px;
|
||||
min-height: 180px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.05);
|
||||
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
|
||||
@@ -358,8 +358,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
|
||||
.dashboard-import__content { position: relative; z-index: 1; max-width: 460px; }
|
||||
.dashboard-import__eyebrow { margin: 0 0 10px; color: #3b82f6; text-transform: uppercase; font-size: 12px; font-weight: 700; }
|
||||
.dashboard-import h2 { margin: 0; font-size: 32px; color: #0f172a; font-weight: 800; }
|
||||
.dashboard-import p { margin: 14px 0 24px; color: #64748b; font-size: 14px; }
|
||||
.dashboard-import h2 { margin: 0; font-size: 24px; color: #0f172a; font-weight: 800; }
|
||||
.dashboard-import p { margin: 8px 0 16px; color: #64748b; font-size: 13px; line-height: 1.5; }
|
||||
.dashboard-import__button { height: 44px; padding: 0 24px; border: none; border-radius: 12px; background: #3b82f6; color: #ffffff; font-weight: 700; cursor: pointer; }
|
||||
|
||||
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
interface BaselineLine {
|
||||
id: string
|
||||
@@ -12,7 +13,7 @@ type ToolLineState = 'called' | 'create' | 'blocked'
|
||||
interface EnhancedLine {
|
||||
id: string
|
||||
atMs: number
|
||||
type: 'text' | 'tool'
|
||||
type: 'text' | 'tool' | 'schedule_card'
|
||||
text?: string
|
||||
summary?: string
|
||||
detail?: string
|
||||
@@ -76,6 +77,13 @@ const enhancedScript: EnhancedLine[] = [
|
||||
detail: '你还没授权“允许打乱顺序”,所以这一步先不执行。',
|
||||
},
|
||||
{ id: 'enh-8', atMs: 2460, type: 'text', text: '如果你还要我打乱同类任务顺序,需要你先明确授权。' },
|
||||
{
|
||||
id: 'enh-9',
|
||||
atMs: 2800,
|
||||
type: 'schedule_card',
|
||||
summary: '日程表编排已就绪',
|
||||
detail: '已为你避开晚上课程,并插入了“周日报告”提醒。点击查看并微调。',
|
||||
},
|
||||
]
|
||||
|
||||
const baselineLines = ref<BaselineLine[]>([])
|
||||
@@ -83,13 +91,114 @@ const enhancedLines = ref<EnhancedLine[]>([])
|
||||
const isReplaying = ref(false)
|
||||
const replayRound = ref(0)
|
||||
const expandedToolLineMap = reactive<Record<string, boolean>>({})
|
||||
const isScheduleModalVisible = ref(false)
|
||||
|
||||
// 模拟日程数据
|
||||
interface MockScheduleItem {
|
||||
id: number
|
||||
name: string
|
||||
day: number // 1-7
|
||||
order: number // 1-5
|
||||
type: 'course' | 'task'
|
||||
}
|
||||
|
||||
const sectionSlots = [
|
||||
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
||||
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
|
||||
{ order: 3, title: '5-6', timeRange: '14:00\n15:40' },
|
||||
{ order: 4, title: '7-8', timeRange: '16:15\n17:55' },
|
||||
{ order: 5, title: '9-10', timeRange: '19:00\n20:40' },
|
||||
{ order: 6, title: '11-12', timeRange: '20:50\n22:30' },
|
||||
]
|
||||
|
||||
const mockSchedule = ref<MockScheduleItem[]>([
|
||||
{ id: 1, name: '软件架构设计 (课程)', day: 1, order: 1, type: 'course' },
|
||||
{ id: 2, name: '英语听力练习', day: 1, order: 3, type: 'task' },
|
||||
{ id: 3, name: '算法分析 (课程)', day: 2, order: 2, type: 'course' },
|
||||
{ id: 4, name: '周日报告提交', day: 7, order: 5, type: 'task' },
|
||||
])
|
||||
|
||||
const isSaving = ref(false)
|
||||
const currentWeek = ref(1)
|
||||
|
||||
function openScheduleModal() {
|
||||
isScheduleModalVisible.value = true
|
||||
}
|
||||
|
||||
function closeScheduleModal() {
|
||||
isScheduleModalVisible.value = false
|
||||
}
|
||||
|
||||
function prevWeek() {
|
||||
if (currentWeek.value > 1) {
|
||||
currentWeek.value--
|
||||
}
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
currentWeek.value++
|
||||
}
|
||||
|
||||
function handleSaveToState() {
|
||||
isSaving.value = true
|
||||
setTimeout(() => {
|
||||
isSaving.value = false
|
||||
ElMessage({
|
||||
message: '日程已成功暂存至运行时 State',
|
||||
type: 'success',
|
||||
plain: true
|
||||
})
|
||||
}, 600)
|
||||
}
|
||||
|
||||
function handleOfficialSave() {
|
||||
ElMessageBox.confirm(
|
||||
'正式保存日程将把当前编排结果写入数据库。注意:保存后本轮编排微调将会终止,无法撤回。确认继续吗?',
|
||||
'正式保存确认',
|
||||
{
|
||||
confirmButtonText: '确认保存',
|
||||
cancelButtonText: '我再想想',
|
||||
type: 'warning',
|
||||
roundButton: true,
|
||||
customClass: 'premium-msg-box',
|
||||
}
|
||||
).then(() => {
|
||||
isSaving.value = true
|
||||
setTimeout(() => {
|
||||
isSaving.value = false
|
||||
closeScheduleModal()
|
||||
ElMessage.success('日程已正式持久化到数据库')
|
||||
}, 1200)
|
||||
}).catch(() => {
|
||||
// 用户取消,无需操作
|
||||
})
|
||||
}
|
||||
|
||||
// 拖拽逻辑
|
||||
function onDragStart(event: DragEvent, item: MockScheduleItem) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('text/plain', item.id.toString())
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent, day: number, order: number) {
|
||||
if (event.dataTransfer) {
|
||||
const id = parseInt(event.dataTransfer.getData('text/plain'))
|
||||
const item = mockSchedule.value.find(i => i.id === id)
|
||||
if (item) {
|
||||
item.day = day
|
||||
item.order = order
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timerHandles = new Set<number>()
|
||||
|
||||
function clearReplayTimers() {
|
||||
for (const handle of timerHandles) {
|
||||
timerHandles.forEach((handle) => {
|
||||
window.clearTimeout(handle)
|
||||
}
|
||||
})
|
||||
timerHandles.clear()
|
||||
}
|
||||
|
||||
@@ -224,7 +333,7 @@ onBeforeUnmount(() => {
|
||||
<p v-if="line.type === 'text'" class="proto-line">{{ line.text }}</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-else-if="line.type === 'tool'"
|
||||
class="proto-tool"
|
||||
:class="{
|
||||
'proto-tool--called': line.state === 'called',
|
||||
@@ -252,6 +361,31 @@ onBeforeUnmount(() => {
|
||||
{{ line.detail }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 日程小卡片 -->
|
||||
<div
|
||||
v-else-if="line.type === 'schedule_card'"
|
||||
class="proto-schedule-card"
|
||||
@click="openScheduleModal"
|
||||
>
|
||||
<div class="proto-schedule-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="proto-schedule-card__content">
|
||||
<h4 class="proto-schedule-card__summary">{{ line.summary }}</h4>
|
||||
<p class="proto-schedule-card__detail">{{ line.detail }}</p>
|
||||
</div>
|
||||
<div class="proto-schedule-card__arrow">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="enhancedLines.length <= 0" class="proto-line proto-line--placeholder">正在生成回复...</p>
|
||||
@@ -260,6 +394,105 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- 日程编排弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="isScheduleModalVisible" class="schedule-modal-overlay" @click.self="closeScheduleModal">
|
||||
<div class="schedule-modal">
|
||||
<header class="schedule-modal__header">
|
||||
<h3>日程预览与精排 (第 {{ currentWeek }} 周)</h3>
|
||||
<div class="schedule-modal__header-actions">
|
||||
<div class="week-switcher">
|
||||
<button class="week-switcher__btn" @click="prevWeek" :disabled="currentWeek <= 1">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="week-switcher__label">第 {{ currentWeek }} 周</span>
|
||||
<button class="week-switcher__btn" @click="nextWeek" :disabled="currentWeek >= 20">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="schedule-modal__close" @click="closeScheduleModal">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="schedule-modal__body">
|
||||
<div class="planning-board__grid">
|
||||
<!-- 避让左上角 -->
|
||||
<div class="planning-board__corner" />
|
||||
|
||||
<!-- 表头:周一到周日 -->
|
||||
<div v-for="d in 7" :key="`h-${d}`" class="planning-board__day-head">
|
||||
<span>{{ ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][d-1] }}</span>
|
||||
<small>{{ d + 18 }}日</small>
|
||||
</div>
|
||||
|
||||
<!-- 表身:6 个节次(每节次两行) -->
|
||||
<template v-for="slot in sectionSlots" :key="`r-${slot.order}`">
|
||||
<div class="planning-board__time-cell">
|
||||
<strong>{{ slot.title }}</strong>
|
||||
<small>{{ slot.timeRange }}</small>
|
||||
</div>
|
||||
<div
|
||||
v-for="d in 7"
|
||||
:key="`c-${d}-${slot.order}`"
|
||||
class="planning-board__cell"
|
||||
:class="{
|
||||
'planning-board__cell--empty': !mockSchedule.some(i => i.day === d && i.order === slot.order),
|
||||
'board-item-pop': mockSchedule.some(i => i.day === d && i.order === slot.order)
|
||||
}"
|
||||
@dragover.prevent
|
||||
@drop="onDrop($event, d, slot.order)"
|
||||
>
|
||||
<div
|
||||
v-for="item in mockSchedule.filter(i => i.day === d && i.order === slot.order)"
|
||||
:key="item.id"
|
||||
class="planning-board__cell-main"
|
||||
:class="`planning-board__cell-main--${item.type}`"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, item)"
|
||||
>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.type === 'course' ? '教学楼 A' : '个人任务' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="schedule-modal__footer">
|
||||
<p class="schedule-modal__hint">提示:拖动卡片可调整日程顺序</p>
|
||||
<div class="schedule-modal__actions">
|
||||
<button class="tool-compare-proto__btn tool-compare-proto__btn--ghost" @click="closeScheduleModal">取消</button>
|
||||
<button
|
||||
class="tool-compare-proto__btn tool-compare-proto__btn--state"
|
||||
:disabled="isSaving"
|
||||
@click="handleSaveToState"
|
||||
>
|
||||
暂存进state
|
||||
</button>
|
||||
<button
|
||||
class="tool-compare-proto__btn tool-compare-proto__btn--primary"
|
||||
:disabled="isSaving"
|
||||
@click="handleOfficialSave"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '正式保存日程' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -345,6 +578,18 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.tool-compare-proto__btn--state {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: #dbeafe;
|
||||
}
|
||||
|
||||
.tool-compare-proto__btn--state:hover {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.tool-compare-proto__btn--ghost {
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
@@ -621,4 +866,449 @@ onBeforeUnmount(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 新增:日程小卡片样式 */
|
||||
.proto-schedule-card {
|
||||
margin-top: 10px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
animation: line-fade-in 0.5s ease-out forwards;
|
||||
animation-delay: 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.proto-schedule-card:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 12px 24px -8px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.proto-schedule-card__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proto-schedule-card__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.proto-schedule-card__summary {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.proto-schedule-card__detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.proto-schedule-card__arrow {
|
||||
color: #94a3b8;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.proto-schedule-card:hover .proto-schedule-card__arrow {
|
||||
color: #3b82f6;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* 弹窗核心样式 */
|
||||
.schedule-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.schedule-modal {
|
||||
background: #ffffff;
|
||||
width: min(1400px, 92%);
|
||||
height: auto;
|
||||
max-height: 95vh;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-modal__header {
|
||||
padding: 20px 32px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.schedule-modal__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.week-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.week-switcher__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.week-switcher__btn:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.week-switcher__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.week-switcher__label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-modal__header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.schedule-modal__close {
|
||||
background: #f1f5f9;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.schedule-modal__close:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.schedule-modal__body {
|
||||
padding: 0; /* 这里的 padding 让 grid 自己处理,保持 full-bleed 效果 */
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schedule-modal__footer {
|
||||
padding: 20px 32px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.schedule-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.schedule-modal__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 基准样式:同步自 WeekPlanningBoard.vue */
|
||||
.planning-board__grid {
|
||||
--planning-grid-padding-x: 20px;
|
||||
--planning-grid-padding-y: 16px;
|
||||
--planning-grid-gap-x: 10px;
|
||||
--planning-grid-gap-y: 10px;
|
||||
--planning-time-column-width: 76px;
|
||||
--planning-day-column-min: 96px;
|
||||
--planning-cell-height: 82px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
|
||||
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
|
||||
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 32px;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.planning-board__corner {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.planning-board__day-head {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.planning-board__day-head span {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.planning-board__day-head small {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.planning-board__time-cell {
|
||||
min-height: var(--planning-cell-height);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: end;
|
||||
color: #94a3b8;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.planning-board__time-cell strong {
|
||||
font-size: 15px;
|
||||
color: #475569;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.planning-board__time-cell small {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
white-space: pre-line;
|
||||
text-align: right;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.planning-board__cell {
|
||||
position: relative;
|
||||
min-height: var(--planning-cell-height);
|
||||
border-radius: 16px;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.planning-board__cell--empty {
|
||||
background: #ffffff;
|
||||
border: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.planning-board__cell:hover:not(.planning-board__cell--empty) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.planning-board__cell-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
border-radius: 16px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.planning-board__cell-main:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.planning-board__cell-main strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.planning-board__cell-main span {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 颜色系统 */
|
||||
.planning-board__cell-main--course {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.planning-board__cell-main--task {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
/* 进场动画 */
|
||||
@keyframes board-item-spring {
|
||||
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
|
||||
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.board-item-pop {
|
||||
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* 弹窗动画 */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .schedule-modal {
|
||||
animation: modal-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal-leave-active .schedule-modal {
|
||||
animation: modal-in 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
transform: scale(0.95) translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 全局样式:用于拦截挂载在 body 上的弹窗组件 -->
|
||||
<style>
|
||||
.premium-msg-box {
|
||||
--el-messagebox-width: 420px;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
||||
padding: 12px !important;
|
||||
background: rgba(255, 255, 255, 0.85) !important;
|
||||
backdrop-filter: blur(25px) saturate(180%) !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2) !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.premium-msg-box .el-message-box__header {
|
||||
padding: 24px 28px 10px !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-message-box__title {
|
||||
font-size: 20px !important;
|
||||
font-weight: 900 !important;
|
||||
color: #0f172a !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-message-box__content {
|
||||
padding: 10px 28px 24px !important;
|
||||
color: #64748b !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-message-box__btns {
|
||||
padding: 16px 24px 24px !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-button {
|
||||
height: 44px !important;
|
||||
padding: 0 24px !important;
|
||||
border-radius: 14px !important;
|
||||
font-weight: 700 !important;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-button--primary {
|
||||
background: #0f172a !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.2) !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-button--primary:hover {
|
||||
background: #1e293b !important;
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.25) !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-button--default:not(.el-button--primary) {
|
||||
background: #f1f5f9 !important;
|
||||
border: none !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
.premium-msg-box .el-button--default:not(.el-button--primary):hover {
|
||||
background: #e2e8f0 !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user