feat: 新增mock数据及vitest
This commit is contained in:
Vendored
+2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+938
-2193
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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -47,7 +47,8 @@ watch(
|
||||
option,
|
||||
(newOption) => {
|
||||
if (chartRef.value) {
|
||||
chart.setOption(newOption)
|
||||
// option 是完整配置:不合并旧值,否则换图表类型或删字段后会残留(如饼图上的坐标轴)
|
||||
chart.setOption(newOption, { notMerge: true })
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
|
||||
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
||||
import type { MaterialSchema } from '@/schema/materials'
|
||||
|
||||
// 画布数据结构
|
||||
interface CanvasSchema {
|
||||
export interface CanvasSchema {
|
||||
width: number
|
||||
height: number
|
||||
backgroundColor: string
|
||||
|
||||
+19
-51
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user