feat: 新增mock数据及vitest

This commit is contained in:
2026-09-30 21:34:51 -07:00
parent b6d617a380
commit be7e8908fc
13 changed files with 1633 additions and 2255 deletions
+2
View File
@@ -31,12 +31,14 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSplitter: typeof import('element-plus/es')['ElSplitter']
ElSplitterPanel: typeof import('element-plus/es')['ElSplitterPanel']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
MaterialsGate: typeof import('./src/components/MaterialsGate/index.vue')['default']
MonacoEditor: typeof import('./src/components/MonacoEditor/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+4 -2
View File
@@ -12,7 +12,8 @@
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
"format": "prettier --write --experimental-cli src/",
"test": "vitest run"
},
"dependencies": {
"@tailwindcss/postcss": "^4.3.3",
@@ -53,10 +54,11 @@
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^5.0.3",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
}
+938 -2193
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import type { AgentAction, AgentNode } from '@/schema/agent'
import type { MaterialSchema } from '@/schema/materials'
import type { PageSchema } from '@/schema/page'
import { applyActions } from '../applyActions'
const node = (id: string, type = 'line-chart'): MaterialSchema => ({
id,
type,
name: id,
layout: { x: 0, y: 0, width: 100, height: 100 },
props: { option: { xAxis: {}, series: [{ type: 'line' }] } },
})
const page = (...ids: string[]): PageSchema => ({
canvas: { width: 1920, height: 1080, backgroundColor: '#000' },
nodes: ids.map((id) => node(id)),
dataSource: [],
})
const addNode = (id: string, type = 'pie-chart'): AgentAction => ({
type: 'add',
node: { ...node(id, type), props: { option: { series: [{ type: 'pie' }] } } } as AgentNode,
plan_id: null,
component_key: null,
sample_data: false,
})
const ids = (nodes: MaterialSchema[]) => nodes.map((n) => n.id)
describe('applyActions', () => {
it('先删后增同一 ID 视为更换:保持原层级,只进 replacedIds', () => {
const p = page('a', 'b', 'c')
const r = applyActions(p, [{ type: 'delete', target_id: 'b' }, addNode('b')])
expect(ids(r.nodes)).toEqual(['a', 'b', 'c'])
expect(r.nodes[1]!.type).toBe('pie-chart')
expect(r.replacedIds).toEqual(['b'])
expect(r.addedIds).toEqual([])
expect(r.deletedIds).toEqual([])
// 原节点不被改动,撤销栈引用有效
expect(p.nodes[1]!.type).toBe('line-chart')
})
it('更换最上层节点仍在末尾', () => {
const r = applyActions(page('a', 'b'), [{ type: 'delete', target_id: 'b' }, addNode('b')])
expect(ids(r.nodes)).toEqual(['a', 'b'])
expect(r.replacedIds).toEqual(['b'])
})
it('只删除:进 deletedIds', () => {
const r = applyActions(page('a', 'b'), [{ type: 'delete', target_id: 'a' }])
expect(ids(r.nodes)).toEqual(['b'])
expect(r.deletedIds).toEqual(['a'])
expect(r.addedIds).toEqual([])
expect(r.replacedIds).toEqual([])
})
it('只新增:追加到末尾并进 addedIds', () => {
const r = applyActions(page('a', 'b'), [addNode('n')])
expect(ids(r.nodes)).toEqual(['a', 'b', 'n'])
expect(r.addedIds).toEqual(['n'])
expect(r.deletedIds).toEqual([])
expect(r.replacedIds).toEqual([])
})
it('删除一个、另新增一个不同 ID:新节点追加到末尾', () => {
const r = applyActions(page('a', 'b'), [{ type: 'delete', target_id: 'a' }, addNode('n')])
expect(ids(r.nodes)).toEqual(['b', 'n'])
expect(r.addedIds).toEqual(['n'])
expect(r.deletedIds).toEqual(['a'])
})
it('未先删除就新增已有 ID:报 ID 已存在', () => {
expect(() => applyActions(page('a'), [addNode('a')])).toThrow('ID 已存在: a')
})
it('移动、改尺寸、改属性行为不变', () => {
const r = applyActions(page('a'), [
{ type: 'move', target_id: 'a', delta_x: 10, delta_y: 5 },
{ type: 'move', target_id: 'a', delta_x: 10, delta_y: 0 },
{ type: 'resize', target_id: 'a', width: 300, height: null },
{
type: 'update',
target_id: 'a',
section: 'props',
path: ['option', 'series', 0, 'color'],
value: '#f00',
},
])
expect(r.nodes[0]!.layout).toEqual({ x: 20, y: 5, width: 300, height: 100 })
expect(r.nodes[0]!.props.option.series[0].color).toBe('#f00')
})
})
@@ -0,0 +1,142 @@
import type { AgentAction, AgentNode, UpdateAction } from '@/schema/agent'
import type { MaterialSchema } from '@/schema/materials'
import type { CanvasSchema, PageSchema } from '@/schema/page'
import { deepClone } from '@/utils'
export interface ActionResult {
/** 画布顶层字段补丁,整体替换 */
canvas: Partial<CanvasSchema>
nodes: MaterialSchema[]
/** 批次前不存在、批次后存在 */
addedIds: string[]
/** 批次前存在、批次后不存在 */
deletedIds: string[]
/** 同批先删除又以同一 ID 新增(更换组件类型),节点仍存在 */
replacedIds: string[]
}
const isObject = (v: unknown): v is Record<string, any> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
/** add 下发的节点转成画布节点:null 字段去掉,扩展字段原样保留 */
const toMaterial = (node: AgentNode): MaterialSchema => {
const { name, style, events, ...rest } = deepClone(node)
const material = { ...rest, name: name ?? '' } as MaterialSchema
if (style) material.style = style
if (events) material.events = events
return material
}
/** 按数组路径替换末段的值;中间段必须存在,数组下标不越界 */
const setByPath = (root: unknown, action: UpdateAction) => {
const { path, value } = action
if (!path.length) throw new Error('update 路径为空')
const fail = () => {
throw new Error(`路径无效: ${action.section}.${path.join('.')}`)
}
let cur: unknown = root
path.forEach((seg, i) => {
const last = i === path.length - 1
if (typeof seg === 'number') {
if (!Array.isArray(cur) || !Number.isInteger(seg) || seg < 0 || seg >= cur.length) fail()
const arr = cur as unknown[]
if (last) arr[seg] = value
else cur = arr[seg]
} else {
if (!isObject(cur)) fail()
const obj = cur as Record<string, unknown>
if (last) obj[seg] = value
else if (!Object.hasOwn(obj, seg)) fail()
else cur = obj[seg]
}
})
}
/**
* 在副本上按顺序执行整批 Agent 动作,任一失败直接抛错(整批放弃)。
* 节点写时复制:未涉及的节点保留原引用,改动的节点先 deepClone(节点里可能嵌着响应式代理和函数,不能用 structuredClone),保证撤销栈里的旧对象不被污染。
*/
export const applyActions = (page: PageSchema, actions: AgentAction[]): ActionResult => {
const canvas: Partial<CanvasSchema> = {}
let nodes = [...page.nodes]
const cloned = new Set<string>()
// 本批删除过的 id → 删除前的下标,同 id 再新增时插回原层级
const deletedIndex = new Map<string, number>()
// 本批删除过的 id,用于区分"更换"和"原本就在"
const touched = new Set<string>()
/** 取目标节点,首次修改前克隆一份替换进数组 */
const target = (id: string) => {
const index = nodes.findIndex((n) => n.id === id)
if (index < 0) throw new Error(`目标组件不存在: ${id}`)
if (!cloned.has(id)) {
nodes[index] = deepClone(nodes[index]!)
cloned.add(id)
}
return nodes[index]!
}
for (const action of actions) {
switch (action.type) {
case 'update_canvas':
Object.assign(canvas, action.properties)
break
case 'add': {
const { id } = action.node
if (nodes.some((n) => n.id === id)) throw new Error(`ID 已存在: ${id}`)
const material = toMaterial(action.node)
const index = deletedIndex.get(id)
if (index === undefined) nodes.push(material)
else {
// 更换组件:沿用原层级(后续删除可能让数组变短,越界时追加到末尾)
nodes.splice(Math.min(index, nodes.length), 0, material)
deletedIndex.delete(id)
}
// 新节点本身就是副本,后续修改不必再克隆
cloned.add(id)
break
}
case 'delete': {
const { target_id } = action
const index = nodes.findIndex((n) => n.id === target_id)
if (index < 0) throw new Error(`目标组件不存在: ${target_id}`)
nodes = nodes.filter((n) => n.id !== target_id)
deletedIndex.set(target_id, index)
touched.add(target_id)
break
}
case 'move': {
const node = target(action.target_id)
node.layout.x += action.delta_x
node.layout.y += action.delta_y
break
}
case 'resize': {
const node = target(action.target_id)
if (action.width != null) node.layout.width = action.width
if (action.height != null) node.layout.height = action.height
break
}
case 'update': {
const node = target(action.target_id)
if (action.section === 'style') node.style ??= {}
else if (action.section !== 'props') throw new Error(`不支持的区域: ${action.section}`)
setByPath(node[action.section], action)
break
}
default:
throw new Error(`不支持的动作: ${(action as { type: string }).type}`)
}
}
// 按批次前后的存在性归类,不受中间顺序影响
const before = new Set(page.nodes.map((n) => n.id))
const after = new Set(nodes.map((n) => n.id))
return {
canvas,
nodes,
addedIds: [...after].filter((id) => !before.has(id)),
deletedIds: [...before].filter((id) => !after.has(id)),
replacedIds: [...after].filter((id) => before.has(id) && touched.has(id)),
}
}
@@ -10,6 +10,7 @@ import type {
ConversationTurn,
} from '@/schema/agent'
import { useEditorStore } from '@/stores/editor'
import { applyActions } from './applyActions'
type SseEvent = { event: string; data: unknown }
@@ -117,6 +118,8 @@ export const useAgentChat = () => {
// 会话 ID 由后端签发,run 必传;等待补充时记录暂停的任务
let conversationId: string | undefined
let pendingThreadId: string | undefined
// 发送时的画布快照,用于判断等待期间是否被手动改过
let requestSnapshot = ''
let abortController: AbortController | null = null
let typingTimer: ReturnType<typeof setInterval> | null = null
@@ -130,6 +133,36 @@ export const useAgentChat = () => {
conversation_id: conversationId,
})
/** 只比较画布和节点;api 数据源轮询会改 dataSource,不算用户修改 */
const snapshotScreen = () =>
JSON.stringify({ canvas: editor.page.canvas, nodes: editor.page.nodes })
/** 整批执行并提交为一条撤销记录;出错整批放弃 */
const applyToCanvas = (assistant: ChatMessage) => {
try {
editor.applyAgentChanges(applyActions(editor.page, assistant.actions))
assistant.applyState = 'applied'
assistant.applyError = undefined
} catch (err) {
assistant.applyState = 'failed'
assistant.applyError = err instanceof Error ? err.message : '动作执行失败'
ElMessage.error(`画布修改未应用:${assistant.applyError},请重试`)
}
}
/** 画布在等待期间未被改动才自动应用,否则交给用户决定 */
const autoApply = (assistant: ChatMessage) => {
assistant.sampleData = assistant.actions.some((a) => a.type === 'add' && a.sample_data)
if (snapshotScreen() !== requestSnapshot) assistant.applyState = 'stale'
else applyToCanvas(assistant)
}
/** 画布已变更时用户确认仍然应用;只允许应用一次 */
const forceApply = (assistant: ChatMessage) => {
if (assistant.applyState !== 'stale') return
applyToCanvas(assistant)
}
const fail = (assistant: ChatMessage, text: string) => {
assistant.error = true
assistant.state = 'done'
@@ -174,6 +207,7 @@ export const useAgentChat = () => {
assistant.threadId = result.thread_id
assistant.actions = result.actions ?? []
pendingThreadId = result.status === 'waiting_for_input' ? result.thread_id : undefined
if (result.status === 'completed' && assistant.actions.length) autoApply(assistant)
// 已结束的进度收尾,避免转圈残留
assistant.progress.forEach((p) => {
if (p.status === 'started') p.status = 'completed'
@@ -313,6 +347,7 @@ export const useAgentChat = () => {
// 上一条澄清作废,避免重复点选
messages.value.forEach((m) => (m.clarification = undefined))
const assistant = pushTurn(text)
requestSnapshot = snapshotScreen()
try {
if (!conversationId) await createConversation()
@@ -380,5 +415,5 @@ export const useAgentChat = () => {
stopTyping()
})
return { messages, running, send, clear }
return { messages, running, send, clear, forceApply }
}
+24 -4
View File
@@ -13,7 +13,7 @@ defineOptions({
const editorStore = useEditorStore()
const { selectedNodeIds } = storeToRefs(editorStore)
const { messages, running, send, clear } = useAgentChat()
const { messages, running, send, clear, forceApply } = useAgentChat()
// 输入
const draft = ref('')
@@ -193,9 +193,29 @@ const onSend = () => {
</div>
</div>
<!-- 本版不落画布,只提示 -->
<div v-if="msg.actions.length" class="mt-2 text-xs text-white/40">
{{ msg.actions.length }} 个修改动作(尚未应用)
<!-- 画布动作执行结果 -->
<div
v-if="msg.applyState === 'applied'"
class="mt-2 flex items-center gap-1 text-xs text-white/40"
>
<Icon icon="lucide:circle-check" width="12" />
已应用 {{ msg.actions.length }} 个修改
</div>
<div
v-else-if="msg.applyState === 'stale'"
class="mt-2 flex items-center gap-2 text-xs text-white/40"
>
<span>画布已变更,{{ msg.actions.length }} 个修改未自动应用</span>
<button
type="button"
class="cursor-pointer text-(--el-color-primary) hover:underline"
@click="forceApply(msg)"
>
仍然应用
</button>
</div>
<div v-else-if="msg.applyState === 'failed'" class="mt-2 text-xs text-red-400">
修改未应用:{{ msg.applyError }},请重试
</div>
</div>
</div>
+2 -1
View File
@@ -47,7 +47,8 @@ watch(
option,
(newOption) => {
if (chartRef.value) {
chart.setOption(newOption)
// option 是完整配置:不合并旧值,否则换图表类型或删字段后会残留(如饼图上的坐标轴)
chart.setOption(newOption, { notMerge: true })
}
},
{ deep: true },
+293
View File
@@ -0,0 +1,293 @@
import type { DataSourceSchema } from '@/schema/page'
/**
* 内置演示数据源。字段名对齐各图表物料默认的 encode,绑定后无需改字段即可直接出图
*/
export const mockDataSource: DataSourceSchema[] = [
// 柱状图 / 堆叠柱状图 / 柱线混合图:month + sales / online / offline / agent / growth
{
type: 'static',
id: 'ds-monthly-sales',
name: '月度销售经营',
data: [
{ month: '一月', sales: 446, online: 132, offline: 218, agent: 96, growth: 0 },
{ month: '二月', sales: 402, online: 118, offline: 196, agent: 88, growth: -9.9 },
{ month: '三月', sales: 492, online: 156, offline: 232, agent: 104, growth: 22.4 },
{ month: '四月', sales: 523, online: 171, offline: 240, agent: 112, growth: 6.3 },
{ month: '五月', sales: 560, online: 188, offline: 251, agent: 121, growth: 7.1 },
{ month: '六月', sales: 640, online: 236, offline: 266, agent: 138, growth: 14.3 },
{ month: '七月', sales: 603, online: 214, offline: 258, agent: 131, growth: -5.8 },
{ month: '八月', sales: 631, online: 229, offline: 262, agent: 140, growth: 4.6 },
{ month: '九月', sales: 674, online: 248, offline: 274, agent: 152, growth: 6.8 },
{ month: '十月', sales: 737, online: 281, offline: 290, agent: 166, growth: 9.3 },
{ month: '十一月', sales: 863, online: 356, offline: 318, agent: 189, growth: 17.1 },
{ month: '十二月', sales: 785, online: 312, offline: 301, agent: 172, growth: -9 },
],
},
// 折线图 / 面积图:date + visits / revenue / orders
{
type: 'static',
id: 'ds-daily-traffic',
name: '近14日流量趋势',
data: [
{ date: '09-17', visits: 1235, orders: 65, revenue: 11479 },
{ date: '09-18', visits: 1220, orders: 64, revenue: 13300 },
{ date: '09-19', visits: 1748, orders: 102, revenue: 18205 },
{ date: '09-20', visits: 1745, orders: 111, revenue: 20608 },
{ date: '09-21', visits: 1457, orders: 96, revenue: 17366 },
{ date: '09-22', visits: 1506, orders: 84, revenue: 15799 },
{ date: '09-23', visits: 1289, orders: 69, revenue: 11733 },
{ date: '09-24', visits: 1389, orders: 83, revenue: 14818 },
{ date: '09-25', visits: 1576, orders: 100, revenue: 19097 },
{ date: '09-26', visits: 1910, orders: 105, revenue: 20734 },
{ date: '09-27', visits: 1975, orders: 103, revenue: 17645 },
{ date: '09-28', visits: 1559, orders: 102, revenue: 20428 },
{ date: '09-29', visits: 1402, orders: 97, revenue: 16897 },
{ date: '09-30', visits: 1510, orders: 81, revenue: 14298 },
],
},
// 条形图(region + sales)/ 玫瑰图(region + value 门店数)
{
type: 'static',
id: 'ds-region-sales',
name: '区域销售与门店',
data: [
{ region: '华东', sales: 4820, value: 48 },
{ region: '华南', sales: 3960, value: 41 },
{ region: '华北', sales: 3410, value: 36 },
{ region: '华中', sales: 2580, value: 29 },
{ region: '西南', sales: 2240, value: 25 },
{ region: '西北', sales: 1320, value: 16 },
{ region: '东北', sales: 1180, value: 14 },
],
},
// 饼图:channel + value
{
type: 'static',
id: 'ds-traffic-channel',
name: '流量渠道占比',
data: [
{ channel: '搜索引擎', value: 3280 },
{ channel: '直接访问', value: 2410 },
{ channel: '社交媒体', value: 1960 },
{ channel: '短视频', value: 1720 },
{ channel: '广告投放', value: 1150 },
{ channel: '邮件营销', value: 460 },
],
},
// 环形图:device + value
{
type: 'static',
id: 'ds-device',
name: '终端设备分布',
data: [
{ device: '移动端', value: 6280 },
{ device: '桌面端', value: 2940 },
{ device: '平板', value: 860 },
{ device: '小程序', value: 1530 },
{ device: '其他', value: 210 },
],
},
// 漏斗图:stage + value
{
type: 'static',
id: 'ds-funnel',
name: '用户转化漏斗',
data: [
{ stage: '曝光', value: 10000 },
{ stage: '访问', value: 6200 },
{ stage: '加购', value: 3150 },
{ stage: '下单', value: 1860 },
{ stage: '支付', value: 1420 },
{ stage: '复购', value: 520 },
],
},
// 散点图:users + sales(name 为门店名)
{
type: 'static',
id: 'ds-store-scatter',
name: '门店访客与销售额',
data: [
{ name: '门店01', users: 9, sales: 5 },
{ name: '门店02', users: 81, sales: 69 },
{ name: '门店03', users: 35, sales: 29 },
{ name: '门店04', users: 32, sales: 44 },
{ name: '门店05', users: 92, sales: 71 },
{ name: '门店06', users: 72, sales: 67 },
{ name: '门店07', users: 34, sales: 20 },
{ name: '门店08', users: 99, sales: 96 },
{ name: '门店09', users: 62, sales: 61 },
{ name: '门店10', users: 48, sales: 53 },
{ name: '门店11', users: 99, sales: 81 },
{ name: '门店12', users: 68, sales: 56 },
{ name: '门店13', users: 32, sales: 19 },
{ name: '门店14', users: 78, sales: 67 },
{ name: '门店15', users: 91, sales: 77 },
{ name: '门店16', users: 28, sales: 22 },
{ name: '门店17', users: 24, sales: 16 },
{ name: '门店18', users: 24, sales: 14 },
{ name: '门店19', users: 30, sales: 18 },
{ name: '门店20', users: 47, sales: 38 },
{ name: '门店21', users: 76, sales: 56 },
{ name: '门店22', users: 89, sales: 73 },
{ name: '门店23', users: 9, sales: 16 },
{ name: '门店24', users: 66, sales: 63 },
{ name: '门店25', users: 66, sales: 76 },
{ name: '门店26', users: 15, sales: 17 },
{ name: '门店27', users: 40, sales: 50 },
{ name: '门店28', users: 61, sales: 55 },
{ name: '门店29', users: 76, sales: 70 },
{ name: '门店30', users: 44, sales: 35 },
{ name: '门店31', users: 87, sales: 74 },
{ name: '门店32', users: 26, sales: 42 },
{ name: '门店33', users: 57, sales: 48 },
{ name: '门店34', users: 43, sales: 39 },
{ name: '门店35', users: 48, sales: 42 },
{ name: '门店36', users: 19, sales: 25 },
],
},
// 雷达图:name + sales / market / develop / support / tech,分值 0-100
{
type: 'static',
id: 'ds-store-radar',
name: '门店服务评分',
data: [
{ name: '旗舰店', sales: 92, market: 85, develop: 88, support: 90, tech: 86 },
{ name: '直营店', sales: 78, market: 90, develop: 82, support: 76, tech: 88 },
{ name: '加盟店', sales: 70, market: 72, develop: 91, support: 84, tech: 68 },
],
},
// 仪表盘:name + value,0-100
{
type: 'static',
id: 'ds-kpi-sales',
name: '销售目标完成率',
data: [{ name: '销售目标完成率', value: 78.6 }],
},
// 进度环:name + value,0-100
{
type: 'static',
id: 'ds-kpi-year',
name: '年度目标完成率',
data: [{ name: '年度目标完成率', value: 63.2 }],
},
// 象形柱图:warehouse + stock
{
type: 'static',
id: 'ds-warehouse-stock',
name: '仓库库存水位',
data: [
{ warehouse: '上海仓', stock: 92 },
{ warehouse: '广州仓', stock: 84 },
{ warehouse: '北京仓', stock: 76 },
{ warehouse: '成都仓', stock: 63 },
{ warehouse: '武汉仓', stock: 58 },
{ warehouse: '西安仓', stock: 47 },
{ warehouse: '沈阳仓', stock: 39 },
{ warehouse: '昆明仓', stock: 28 },
],
},
// 热力图:slot + day + value,值 0-120 对齐 visualMap
{
type: 'static',
id: 'ds-visit-heatmap',
name: '一周访问时段分布',
data: [
{ slot: '00时', day: '周一', value: 8 },
{ slot: '02时', day: '周一', value: 6 },
{ slot: '04时', day: '周一', value: 7 },
{ slot: '06时', day: '周一', value: 23 },
{ slot: '08时', day: '周一', value: 57 },
{ slot: '10时', day: '周一', value: 74 },
{ slot: '12时', day: '周一', value: 75 },
{ slot: '14时', day: '周一', value: 73 },
{ slot: '16时', day: '周一', value: 92 },
{ slot: '18时', day: '周一', value: 110 },
{ slot: '20时', day: '周一', value: 97 },
{ slot: '22时', day: '周一', value: 42 },
{ slot: '00时', day: '周二', value: 16 },
{ slot: '02时', day: '周二', value: 12 },
{ slot: '04时', day: '周二', value: 12 },
{ slot: '06时', day: '周二', value: 22 },
{ slot: '08时', day: '周二', value: 67 },
{ slot: '10时', day: '周二', value: 82 },
{ slot: '12时', day: '周二', value: 71 },
{ slot: '14时', day: '周二', value: 72 },
{ slot: '16时', day: '周二', value: 84 },
{ slot: '18时', day: '周二', value: 101 },
{ slot: '20时', day: '周二', value: 90 },
{ slot: '22时', day: '周二', value: 46 },
{ slot: '00时', day: '周三', value: 10 },
{ slot: '02时', day: '周三', value: 5 },
{ slot: '04时', day: '周三', value: 15 },
{ slot: '06时', day: '周三', value: 21 },
{ slot: '08时', day: '周三', value: 65 },
{ slot: '10时', day: '周三', value: 77 },
{ slot: '12时', day: '周三', value: 70 },
{ slot: '14时', day: '周三', value: 71 },
{ slot: '16时', day: '周三', value: 91 },
{ slot: '18时', day: '周三', value: 105 },
{ slot: '20时', day: '周三', value: 91 },
{ slot: '22时', day: '周三', value: 43 },
{ slot: '00时', day: '周四', value: 10 },
{ slot: '02时', day: '周四', value: 10 },
{ slot: '04时', day: '周四', value: 14 },
{ slot: '06时', day: '周四', value: 20 },
{ slot: '08时', day: '周四', value: 62 },
{ slot: '10时', day: '周四', value: 79 },
{ slot: '12时', day: '周四', value: 66 },
{ slot: '14时', day: '周四', value: 73 },
{ slot: '16时', day: '周四', value: 88 },
{ slot: '18时', day: '周四', value: 100 },
{ slot: '20时', day: '周四', value: 94 },
{ slot: '22时', day: '周四', value: 46 },
{ slot: '00时', day: '周五', value: 7 },
{ slot: '02时', day: '周五', value: 10 },
{ slot: '04时', day: '周五', value: 7 },
{ slot: '06时', day: '周五', value: 23 },
{ slot: '08时', day: '周五', value: 68 },
{ slot: '10时', day: '周五', value: 73 },
{ slot: '12时', day: '周五', value: 65 },
{ slot: '14时', day: '周五', value: 79 },
{ slot: '16时', day: '周五', value: 88 },
{ slot: '18时', day: '周五', value: 109 },
{ slot: '20时', day: '周五', value: 90 },
{ slot: '22时', day: '周五', value: 50 },
{ slot: '00时', day: '周六', value: 10 },
{ slot: '02时', day: '周六', value: 14 },
{ slot: '04时', day: '周六', value: 10 },
{ slot: '06时', day: '周六', value: 36 },
{ slot: '08时', day: '周六', value: 76 },
{ slot: '10时', day: '周六', value: 90 },
{ slot: '12时', day: '周六', value: 74 },
{ slot: '14时', day: '周六', value: 84 },
{ slot: '16时', day: '周六', value: 105 },
{ slot: '18时', day: '周六', value: 120 },
{ slot: '20时', day: '周六', value: 106 },
{ slot: '22时', day: '周六', value: 46 },
{ slot: '00时', day: '周日', value: 11 },
{ slot: '02时', day: '周日', value: 9 },
{ slot: '04时', day: '周日', value: 17 },
{ slot: '06时', day: '周日', value: 32 },
{ slot: '08时', day: '周日', value: 68 },
{ slot: '10时', day: '周日', value: 83 },
{ slot: '12时', day: '周日', value: 74 },
{ slot: '14时', day: '周日', value: 81 },
{ slot: '16时', day: '周日', value: 99 },
{ slot: '18时', day: '周日', value: 114 },
{ slot: '20时', day: '周日', value: 108 },
{ slot: '22时', day: '周日', value: 55 },
],
},
{
type: 'api',
id: '123125',
name: 'test3',
url: '/api/data',
interval: 2000,
params: {
date: '2026-01-01',
},
data: [],
},
]
+64 -2
View File
@@ -1,3 +1,5 @@
import type { MaterialEvent } from '@/schema/materials'
// Agent 后端(/agent/run、/agent/resume)的请求与 SSE 事件类型
export type AgentStatus = 'completed' | 'waiting_for_input' | 'failed' | 'cancelled'
@@ -28,12 +30,67 @@ export interface Clarification {
options: ClarificationOption[]
}
// 画布修改动作,本版不解释,只收下并展示数量
export interface AgentAction {
// 画布修改动作:final 里按顺序整批执行,见 Action 协议
/** add 下发的完整节点;name/style/events 可能为 null,扩展字段原样保留 */
export interface AgentNode {
id: string
type: string
name?: string | null
layout: { x: number; y: number; width: number; height: number }
props: Record<string, any>
style?: Record<string, any> | null
events?: MaterialEvent[] | null
[key: string]: unknown
}
export interface AddAction {
type: 'add'
node: AgentNode
plan_id: string | null
component_key: string | null
/** true 表示节点里是物料自带的示例数据 */
sample_data: boolean
}
export interface DeleteAction {
type: 'delete'
target_id: string
}
/** 相对位移 */
export interface MoveAction {
type: 'move'
target_id: string
delta_x: number
delta_y: number
}
/** 绝对尺寸,null 表示该方向不变 */
export interface ResizeAction {
type: 'resize'
target_id: string
width: number | null
height: number | null
}
/** path 中字符串是对象键、整数是数组下标;只替换末段,不深度合并 */
export interface UpdateAction {
type: 'update'
target_id: string
section: 'props' | 'style'
path: (string | number)[]
value: unknown
}
/** 画布顶层字段整体替换 */
export interface UpdateCanvasAction {
type: 'update_canvas'
properties: Record<string, unknown>
}
export type AgentAction =
AddAction | DeleteAction | MoveAction | ResizeAction | UpdateAction | UpdateCanvasAction
// answer_delta 事件:问答节点的文本增量,完整回答仍在 final 里
export interface AnswerDelta {
type: 'answer_delta'
@@ -107,6 +164,11 @@ export interface ChatMessage {
error?: boolean
progress: ChatProgressItem[]
actions: AgentAction[]
/** applied: 已落画布;stale: 等待期间画布被改过,未自动应用;failed: 执行出错已整批放弃 */
applyState?: 'applied' | 'stale' | 'failed'
applyError?: string
/** 新增节点含示例数据 */
sampleData?: boolean
clarification?: Clarification
threadId?: string
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { MaterialSchema } from '@/schema/materials'
// 画布数据结构
interface CanvasSchema {
export interface CanvasSchema {
width: number
height: number
backgroundColor: string
+19 -51
View File
@@ -1,7 +1,9 @@
import type { MaterialSchema } from '@/schema/materials'
import type { PageSchema } from '@/schema/page'
import type { ActionResult } from '@/editor/panels/chat/composables/applyActions'
import { defineStore } from 'pinia'
import { useUndoRedo } from '@/composables/useUndoRedo'
import { mockDataSource } from '@/mock/dataSource'
const { applyChange, startBatch, commitBatch } = useUndoRedo()
@@ -34,57 +36,7 @@ export const useEditorStore = defineStore('editor', () => {
backgroundColor: '#ffffff10',
},
nodes: [],
dataSource: [
{
type: 'static',
id: '123123',
name: 'test1',
data: [
{
label: '一月',
value: 100,
},
{
label: '二月',
value: 200,
},
{
label: '三月',
value: 300,
},
],
},
{
type: 'static',
id: '123124',
name: 'test2',
data: [
{
label: '一月',
value: 1200,
},
{
label: '二月',
value: 2200,
},
{
label: '三月',
value: 3200,
},
],
},
{
type: 'api',
id: '123125',
name: 'test3',
url: '/api/data',
interval: 2000,
params: {
date: '2026-01-01',
},
data: [],
},
],
dataSource: mockDataSource,
})
// 设置页面数据
@@ -199,6 +151,21 @@ export const useEditorStore = defineStore('editor', () => {
setNodes(newNodes)
}
/** 落一批 Agent 动作的结果:画布字段 + 整表节点合成一条撤销记录 */
const applyAgentChanges = (result: ActionResult) => {
commitBatch()
startBatch()
Object.entries(result.canvas).forEach(([key, value]) =>
applyChange(page.value.canvas, key, value),
)
applyChange(nodes, 'value', result.nodes)
commitBatch()
// 更换(replacedIds)的节点仍存在,选中状态保持不变
const kept = selectedNodeIds.value.filter((id) => !result.deletedIds.includes(id))
selectedNodeIds.value = result.addedIds.length ? result.addedIds : kept
}
return {
panelVisible,
panelSize,
@@ -224,5 +191,6 @@ export const useEditorStore = defineStore('editor', () => {
toggleLock,
updateNode,
setPage,
applyAgentChanges,
}
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
// 只跑纯函数单测,不加载 vue / devtools 等插件
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
include: ['src/**/__tests__/*.spec.ts'],
},
})