feat: add version history and AI diff highlighting in editor
This commit is contained in:
336
docs/E18_VERSION_AND_DIFF_PROPOSAL.md
Normal file
336
docs/E18_VERSION_AND_DIFF_PROPOSAL.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# E18: 版本回滚 & AI 修改标记
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
为编辑器的 AI 助手增加两个生产级功能:
|
||||||
|
1. **版本回滚** — AI 修改前自动存档,错误时可恢复到任意历史版本
|
||||||
|
2. **修改标记** — AI 修改后在 SceneGraph 节点上显示角标(新增/修改/删除),视觉可感知变更
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、版本回滚
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户发起 AI 请求
|
||||||
|
→ buildMessage → POST /api/ai
|
||||||
|
→ AIPanel.send() 在调用 sendAIRequest 前:
|
||||||
|
editorStore.saveVersion("AI 修改前")
|
||||||
|
→ AI 修改完成 → reloadFromDisk() → gameData 刷新
|
||||||
|
|
||||||
|
用户发现改错了
|
||||||
|
→ AIPanel 版本下拉 → 选择历史版本
|
||||||
|
→ editorStore.restoreVersion(index)
|
||||||
|
→ gameData = version.gameData → autoSave()
|
||||||
|
→ SceneGraph + NodeEditor 重绘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `editor/stores/editorStore.ts` | 新增 `versions` ref、`saveVersion(label)`、`restoreVersion(idx)` |
|
||||||
|
| `editor/db/editorDB.ts` | **新建** — IndexedDB 封装:`put`/`get`/`clear`,按 `sourcePath` 分组 |
|
||||||
|
| `editor/components/AIPanel.vue` | 新增版本下拉 UI(在聊天区顶部),send() 前调用 `saveVersion()` |
|
||||||
|
|
||||||
|
### 版本数据结构
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface EditorVersion {
|
||||||
|
id: string // crypto.randomUUID()
|
||||||
|
sourcePath: string // "/scenes/demo.json"
|
||||||
|
timestamp: number // Date.now()
|
||||||
|
label: string // "AI 修改前" / "初始版本"
|
||||||
|
gameData: GameData // 完整游戏数据快照
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IndexedDB 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
数据库: editor_versions
|
||||||
|
表: versions
|
||||||
|
索引: sourcePath (查询同一文件的所有版本)
|
||||||
|
索引: timestamp (排序)
|
||||||
|
```
|
||||||
|
|
||||||
|
保留策略:同一 `sourcePath` 最多 20 个版本,超出删除最旧的。
|
||||||
|
|
||||||
|
### editorDB.ts API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 保存版本
|
||||||
|
putVersion(v: EditorVersion): Promise<void>
|
||||||
|
|
||||||
|
// 获取某文件的所有版本(按时间倒序)
|
||||||
|
getVersions(sourcePath: string): Promise<EditorVersion[]>
|
||||||
|
|
||||||
|
// 清除某文件的所有版本(切换文件时)
|
||||||
|
clearVersions(sourcePath: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 版本 UI 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
AIPanel 顶部
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 历史版本: [AI 修改前 ▼] │ ← 下拉列表,默认隐藏
|
||||||
|
│ 2026-06-15 14:30 AI 修改前 │
|
||||||
|
│ 2026-06-15 14:28 初始版本 │ ← restoreVersion 后刷新全部
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复后逻辑:
|
||||||
|
1. `gameData = version.gameData`
|
||||||
|
2. `autoSave()` 写回磁盘
|
||||||
|
3. `selectedNodeId = null`(重置选中)
|
||||||
|
4. AIPanel 聊天区显示 "已恢复到 'AI 修改前'"
|
||||||
|
5. 不清除该版本快照(用户可能想在不同版本间多次切换)
|
||||||
|
|
||||||
|
### saveVersion 触发时机
|
||||||
|
|
||||||
|
| 时机 | 标签 |
|
||||||
|
|------|------|
|
||||||
|
| AI 模式 `send()` 调用前(`reloadFromDisk` 之前) | `"AI 修改前"` |
|
||||||
|
| `App.vue` 首次加载 demo.json 后 | `"初始版本"` |
|
||||||
|
|
||||||
|
不自动在手动编辑时保存版本(用户手动点"保存版本"按钮即可,可选后续添加)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、AI 修改标记
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
reloadFromDisk() 前:
|
||||||
|
记下 preGameData = gameData(深拷贝)
|
||||||
|
|
||||||
|
reloadFromDisk() 后:
|
||||||
|
diff = computeDiff(preGameData, newGameData)
|
||||||
|
aiChanges = { added, modified, deleted }
|
||||||
|
|
||||||
|
SceneGraph:
|
||||||
|
渲染时根据 aiChanges 给对应节点加角标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据结构
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AIDiff {
|
||||||
|
added: string[] // 新 gameData 中有,旧 gameData 中没有的 scene ID
|
||||||
|
modified: string[] // 相同 ID,JSON.stringify 不同的 scene ID
|
||||||
|
deleted: string[] // 旧 gameData 中有,新 gameData 中没有的 scene ID
|
||||||
|
globalFields: string[] // 变更的全局配置字段名,例如 ["title", "assetBase"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### diff 计算逻辑(editorStore.reloadFromDisk 内)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { scenes: oldScenes, ...oldGlobal } = oldGameData
|
||||||
|
const { scenes: newScenes, ...newGlobal } = newGameData
|
||||||
|
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
|
||||||
|
|
||||||
|
for (const id of Object.keys(newScenes)) {
|
||||||
|
if (!oldScenes[id]) diff.added.push(id)
|
||||||
|
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
|
||||||
|
}
|
||||||
|
for (const id of Object.keys(oldScenes)) {
|
||||||
|
if (!newScenes[id]) diff.deleted.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys({ ...oldGlobal, ...newGlobal })) {
|
||||||
|
if (JSON.stringify(oldGlobal[key]) !== JSON.stringify(newGlobal[key])) {
|
||||||
|
diff.globalFields.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时检测全局配置变更(startScene、title 等),记录到 `globalFields`。
|
||||||
|
|
||||||
|
### 角标视觉效果
|
||||||
|
|
||||||
|
Vue Flow 自定义节点模板(SceneGraph.vue 已有 `CustomNode` 组件):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ ● NEW │ ← 绿色角标 (纯 CSS 色块)
|
||||||
|
│ scene_A │
|
||||||
|
└──────────────┘
|
||||||
|
|
||||||
|
┌──────────────┐
|
||||||
|
│ ● MOD │ ← 橙色角标
|
||||||
|
│ scene_B │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS 实现:在节点元素右上角添加绝对定位的 `<span class="diff-badge">`。
|
||||||
|
|
||||||
|
### 全局配置变更标记
|
||||||
|
|
||||||
|
当 `aiChanges.globalFields` 非空时,在 `NodeEditor` 的"全局配置"标题区展示:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 全局配置 ● 已修改 3 字段 │ ← 橙色圆点 + 变更字段数
|
||||||
|
│ ╭──────────────────╮ │
|
||||||
|
│ │ title: "xxx" │ │ ← 鼠标 hover 时弹出 tooltip
|
||||||
|
│ │ startScene: "yyy" │ │ 列出具体变更的字段名
|
||||||
|
│ │ assetBase: "zzz" │ │
|
||||||
|
│ ╰──────────────────╯ │
|
||||||
|
│ { │
|
||||||
|
│ "title": "xxx", ← 正常 JSON textarea 不变
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**不变更项占位**:用逗号分隔字段名,如 `已修改 3 字段`;hover 列出所有字段名。
|
||||||
|
|
||||||
|
**清除**:用户 blur JSON textarea 后标记消失(手工编辑即为最终确认)。
|
||||||
|
|
||||||
|
### 标记清除时机
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 用户手动编辑任意节点后 blur 保存 | 清除 `aiChanges`,所有标记消失 |
|
||||||
|
| 用户手动编辑全局配置后 blur 保存 | 清除全局配置标记 |
|
||||||
|
| AIPanel 点击"清除高亮"按钮 | 手动清除所有标记 |
|
||||||
|
| 新一轮 AI 修改 | 覆盖旧 diff(新 diff 替换旧 diff) |
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `editor/stores/editorStore.ts` | 新增 `aiChanges` ref、diff 计算逻辑、标记清除 |
|
||||||
|
| `editor/components/SceneGraph.vue` | watch `aiChanges`、自定义节点模板加角标 |
|
||||||
|
| `editor/components/NodeEditor.vue` | 全局配置标题加变更提示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、完整交互时序
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入需求 "添加一个新场景 scene_battle"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AIPanel.send()
|
||||||
|
① saveVersion("AI 修改前") ← 快照
|
||||||
|
② preGameData = clone gameData ← 记下旧值
|
||||||
|
③ sendAIRequest(buildMessage(msg), ...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
opencode 读文件 → 新增 scene_battle → 写盘 → 回复 "已创建 scene_battle"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AIPanel 收到 result
|
||||||
|
④ 聊天显示 result
|
||||||
|
⑤ store.reloadFromDisk()
|
||||||
|
├─ fetch → 新 gameData
|
||||||
|
├─ diff(oldGameData, newGameData) → aiChanges = { added: ["scene_battle"], modified: [], deleted: [], globalFields: [] }
|
||||||
|
├─ gameData = newGameData
|
||||||
|
└─ selectedNodeId = null
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SceneGraph 检测到 aiChanges 非空
|
||||||
|
→ 新增节点 scene_battle 渲染时显示绿色 "NEW" 角标
|
||||||
|
→ 已有节点无角标
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
用户手动编辑任一节点后 blur
|
||||||
|
→ store.clearAIMarkers()
|
||||||
|
→ 所有角标消失
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改动清单
|
||||||
|
|
||||||
|
### editor/db/editorDB.ts(新建)
|
||||||
|
|
||||||
|
| API | 说明 |
|
||||||
|
|-----|------|
|
||||||
|
| `initDB()` | 打开/创建 `editor_versions` 数据库 |
|
||||||
|
| `putVersion(v)` | 存入版本快照 |
|
||||||
|
| `getVersions(sourcePath)` | 获取某文件所有版本(按时间倒序,最多 20) |
|
||||||
|
| `clearVersions(sourcePath)` | 清除某文件的版本(切换文件/删除旧版本时) |
|
||||||
|
|
||||||
|
### editor/stores/editorStore.ts
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `versions: Ref<EditorVersion[]>` | 当前文件的所有历史版本 |
|
||||||
|
| `aiChanges: Ref<AIDiff>` | 上次 AI 修改的 diff 结果 |
|
||||||
|
| `saveVersion(label)` | 深拷贝 gameData → 存 IndexedDB → 更新 versions |
|
||||||
|
| `restoreVersion(idx)` | gameData = versions[idx].gameData → autoSave |
|
||||||
|
| `reloadFromDisk()` 增强 | diff 计算 + 设置 aiChanges |
|
||||||
|
| `clearAIMarkers()` | aiChanges = null |
|
||||||
|
| `loadVersions()` | 加载当前 sourcePath 的版本列表 |
|
||||||
|
|
||||||
|
### editor/components/SceneGraph.vue
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `watch(aiChanges)` | 重新渲染时按 aiChanges 给节点加 badge |
|
||||||
|
| CSS `.diff-badge` / `.diff-badge-added` / `.diff-badge-modified` / `.diff-badge-deleted` | 角标样式 |
|
||||||
|
|
||||||
|
### editor/components/AIPanel.vue
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 版本下拉 UI | `<select v-model="selectedVersion">` + 恢复按钮 |
|
||||||
|
| `send()` 前调 `saveVersion("AI 修改前")` | 在 `loading = true` 前 |
|
||||||
|
| "清除高亮" 按钮 | 调用 `store.clearAIMarkers()` |
|
||||||
|
|
||||||
|
### editor/components/NodeEditor.vue
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 全局配置标题加标记 | `全局配置 ● 已修改 N 字段` + hover tooltip 显示字段名,当 `aiChanges.globalFields` 非空 |
|
||||||
|
| `onBlur()` 时调 `clearAIMarkers()` | 编辑后清除标记 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、非侵入性保证
|
||||||
|
|
||||||
|
| 项 | 保障 |
|
||||||
|
|----|------|
|
||||||
|
| IndexedDB 故障容忍 | try/catch 包裹所有操作,失败不影响主流程 |
|
||||||
|
| 版本存储增长 | 20 个上限 + 切换文件时清空旧文件的版本 |
|
||||||
|
| SceneGraph 性能 | diff 比较仅用 `Object.keys` + `JSON.stringify`,百级节点 < 2ms |
|
||||||
|
| 不影响手动编辑 | `clearAIMarkers()` 在 blur 时调用,角标不留存 |
|
||||||
|
| 不影响 Code 模式 | 仅在 JSON 模式 `reloadFromDisk()` 时做 diff |
|
||||||
|
| 不使用 emoji | CSS 纯色圆点 + 文字实现角标 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试用例
|
||||||
|
|
||||||
|
### V1:版本自动保存
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| 编辑器中加载 demo.json | 自动创建版本 "初始版本" |
|
||||||
|
| 打开 AIPanel,JSON 模式发送"添加场景" | 自动创建版本 "AI 修改前" |
|
||||||
|
| 打开版本下拉 | 显示两个版本 |
|
||||||
|
|
||||||
|
### V2:版本恢复
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| AI 修改后选择 "AI 修改前" 版本 | 点击恢复 |
|
||||||
|
| 编辑器内容回到修改前 | SceneGraph + NodeEditor 刷新为旧内容 |
|
||||||
|
|
||||||
|
### V3:修改标记
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| AI 新增 scene_battle | SceneGraph 中该节点显示绿色 "NEW" 角标 |
|
||||||
|
| AI 修改 scene_start | 该节点显示橙色 "MOD" 角标 |
|
||||||
|
| AI 修改 title + startScene | NodeEditor 全局配置标题显示 "● 已修改 2 字段",hover 列出 title/startScene |
|
||||||
|
| 用户手动编辑任一节点后 blur | 所有角标消失 |
|
||||||
|
| 用户手动编辑全局配置后 blur | 全局配置标记消失 |
|
||||||
|
|
||||||
|
### V4:非侵入
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| 不通过 AI 直接手动编辑 JSON | 不触发版本保存,不显示角标 |
|
||||||
|
| 浏览器不支持 IndexedDB | 版本功能静默失效,不影响编辑 |
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, onMounted } from 'vue'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { sendAIRequest } from '../composables/useAI'
|
import { sendAIRequest } from '../composables/useAI'
|
||||||
|
|
||||||
@@ -40,13 +40,19 @@ async function send() {
|
|||||||
messages.value.push({ role: 'user', content: userInput })
|
messages.value.push({ role: 'user', content: userInput })
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
|
if (mode.value === 'json') {
|
||||||
|
await store.saveVersion('AI 修改前')
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldGameData = mode.value === 'json' ? JSON.parse(JSON.stringify(store.gameData)) : undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { result, sessionId: newSid } = await sendAIRequest(fullMessage, mode.value, store.deepseekKey, store.aiSessionId || undefined)
|
const { result, sessionId: newSid } = await sendAIRequest(fullMessage, mode.value, store.deepseekKey, store.aiSessionId || undefined)
|
||||||
if (newSid) store.setAISessionId(newSid)
|
if (newSid) store.setAISessionId(newSid)
|
||||||
|
|
||||||
if (mode.value === 'json') {
|
if (mode.value === 'json') {
|
||||||
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
||||||
await store.reloadFromDisk()
|
await store.reloadFromDisk(oldGameData)
|
||||||
} else {
|
} else {
|
||||||
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
||||||
}
|
}
|
||||||
@@ -68,6 +74,10 @@ function newSession() {
|
|||||||
messages.value = []
|
messages.value = []
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadVersions()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -83,6 +93,16 @@ function newSession() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.versions.length > 0 || store.aiChanges" class="ai-toolbar">
|
||||||
|
<select v-if="store.versions.length > 0" class="ai-version-select" @change="store.restoreVersion(($event.target as HTMLSelectElement).selectedIndex)">
|
||||||
|
<option disabled selected>历史版本</option>
|
||||||
|
<option v-for="v in store.versions" :key="v.timestamp">
|
||||||
|
{{ new Date(v.timestamp).toLocaleString('zh-CN') }} {{ v.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button v-if="store.aiChanges" class="ai-clear-btn" @click="store.clearAIMarkers()">清除高亮</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ai-chat" ref="chatRef">
|
<div class="ai-chat" ref="chatRef">
|
||||||
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
||||||
<span class="key-label">DeepSeek API Key</span>
|
<span class="key-label">DeepSeek API Key</span>
|
||||||
@@ -289,4 +309,36 @@ function newSession() {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-version-select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #ccc;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-clear-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-clear-btn:hover { color: #fff; border-color: rgba(255,255,255,0.2); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const store = useEditorStore()
|
|||||||
const jsonText = ref('')
|
const jsonText = ref('')
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
const globalTooltip = ref('')
|
||||||
|
|
||||||
watch(() => [props.scene, store.gameData] as const, () => {
|
watch(() => [props.scene, store.gameData] as const, () => {
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
@@ -32,6 +33,7 @@ watch(() => [props.scene, store.gameData] as const, () => {
|
|||||||
}, { immediate: true, deep: true })
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
function onBlur() {
|
function onBlur() {
|
||||||
|
store.clearAIMarkers()
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(jsonText.value)
|
const parsed = JSON.parse(jsonText.value)
|
||||||
@@ -54,7 +56,7 @@ function onBlur() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="node-editor" v-if="scene || jsonText">
|
<div class="node-editor" v-if="scene || jsonText">
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<h3>{{ scene ? scene.id : '全局配置' }}</h3>
|
<h3>{{ scene ? scene.id : '全局配置' }}<span v-if="!scene && store.aiChanges?.globalFields?.length" class="global-diff-tag" :title="store.aiChanges.globalFields.join(', ')">● 已修改 {{ store.aiChanges.globalFields.length }} 字段</span></h3>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<span v-if="saved" class="saved-hint">已保存</span>
|
<span v-if="saved" class="saved-hint">已保存</span>
|
||||||
<span v-if="errorMsg" class="error-hint">JSON 错误: {{ errorMsg }}</span>
|
<span v-if="errorMsg" class="error-hint">JSON 错误: {{ errorMsg }}</span>
|
||||||
@@ -165,4 +167,17 @@ function onBlur() {
|
|||||||
.json-area.error {
|
.json-area.error {
|
||||||
border-left: 3px solid #e74c3c;
|
border-left: 3px solid #e74c3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-diff-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #ffe0b2;
|
||||||
|
background: rgba(230, 81, 0, 0.15);
|
||||||
|
border: 1px solid rgba(230, 81, 0, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { VueFlow, useVueFlow, SmoothStepEdge } from '@vue-flow/core'
|
import { VueFlow, useVueFlow, SmoothStepEdge, Handle, Position } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
import { Controls } from '@vue-flow/controls'
|
||||||
import '@vue-flow/core/dist/style.css'
|
import '@vue-flow/core/dist/style.css'
|
||||||
@@ -8,6 +8,7 @@ import '@vue-flow/controls/dist/style.css'
|
|||||||
import '@vue-flow/core/dist/theme-default.css'
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
import type { Connection } from '@vue-flow/core'
|
import type { Connection } from '@vue-flow/core'
|
||||||
import { computePositions, savePosition } from '../composables/useLayout'
|
import { computePositions, savePosition } from '../composables/useLayout'
|
||||||
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sceneNodes: { id: string; label: string }[]
|
sceneNodes: { id: string; label: string }[]
|
||||||
@@ -23,6 +24,8 @@ const emit = defineEmits<{
|
|||||||
clearSelection: []
|
clearSelection: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
|
|
||||||
const nodes = ref<any[]>([])
|
const nodes = ref<any[]>([])
|
||||||
const edges = ref<any[]>([])
|
const edges = ref<any[]>([])
|
||||||
const { onNodeClick, onConnect, onNodeContextMenu, onNodeDragStop, onPaneClick, fitView } = useVueFlow()
|
const { onNodeClick, onConnect, onNodeContextMenu, onNodeDragStop, onPaneClick, fitView } = useVueFlow()
|
||||||
@@ -33,13 +36,23 @@ const ctxMenuNodeId = ref('')
|
|||||||
|
|
||||||
function makeNodes() {
|
function makeNodes() {
|
||||||
const positions = computePositions(props.sceneNodes, props.sceneEdges, props.startScene)
|
const positions = computePositions(props.sceneNodes, props.sceneEdges, props.startScene)
|
||||||
|
const changes = store.aiChanges
|
||||||
return props.sceneNodes.map((n) => {
|
return props.sceneNodes.map((n) => {
|
||||||
const pos = positions.get(n.id) ?? { x: 0, y: 0 }
|
const pos = positions.get(n.id) ?? { x: 0, y: 0 }
|
||||||
|
let badge = ''
|
||||||
|
if (changes) {
|
||||||
|
if (changes.added.includes(n.id)) badge = 'NEW'
|
||||||
|
else if (changes.modified.includes(n.id)) badge = 'MOD'
|
||||||
|
else if (changes.deleted.includes(n.id)) badge = 'DEL'
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: 'default',
|
type: 'default',
|
||||||
position: pos,
|
position: pos,
|
||||||
data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
|
data: {
|
||||||
|
label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label,
|
||||||
|
badge,
|
||||||
|
},
|
||||||
style: n.id === props.startScene
|
style: n.id === props.startScene
|
||||||
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
|
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
|
||||||
: n.id === props.selectedNodeId
|
: n.id === props.selectedNodeId
|
||||||
@@ -99,7 +112,7 @@ function inlineRebuild() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
|
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId, store.aiChanges] as const,
|
||||||
() => {
|
() => {
|
||||||
const nc = props.sceneNodes.length
|
const nc = props.sceneNodes.length
|
||||||
const ec = props.sceneEdges.length
|
const ec = props.sceneEdges.length
|
||||||
@@ -152,6 +165,16 @@ onConnect((conn: Connection) => {
|
|||||||
:min-zoom="0.2"
|
:min-zoom="0.2"
|
||||||
:max-zoom="2"
|
:max-zoom="2"
|
||||||
>
|
>
|
||||||
|
<template #node-default="nodeProps">
|
||||||
|
<div class="custom-node">
|
||||||
|
<Handle type="target" :position="Position.Left" />
|
||||||
|
<div class="custom-node-label">{{ nodeProps.data.label }}</div>
|
||||||
|
<span v-if="nodeProps.data.badge" class="diff-badge" :class="`diff-badge-${nodeProps.data.badge}`">
|
||||||
|
{{ nodeProps.data.badge }}
|
||||||
|
</span>
|
||||||
|
<Handle type="source" :position="Position.Right" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<Background :gap="20" />
|
<Background :gap="20" />
|
||||||
<Controls />
|
<Controls />
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
@@ -207,4 +230,44 @@ onConnect((conn: Connection) => {
|
|||||||
background: rgba(100,200,255,0.12);
|
background: rgba(100,200,255,0.12);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-node {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 30px 8px 12px;
|
||||||
|
min-width: 120px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-node-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge-NEW {
|
||||||
|
background: #2e7d32;
|
||||||
|
color: #c8e6c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge-MOD {
|
||||||
|
background: #e65100;
|
||||||
|
color: #ffe0b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge-DEL {
|
||||||
|
background: #c62828;
|
||||||
|
color: #ffcdd2;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
57
editor/db/editorDB.ts
Normal file
57
editor/db/editorDB.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Dexie, { type Table } from 'dexie'
|
||||||
|
import type { GameData } from '@engine/types'
|
||||||
|
|
||||||
|
export interface VersionRecord {
|
||||||
|
id?: number
|
||||||
|
sourcePath: string
|
||||||
|
timestamp: number
|
||||||
|
label: string
|
||||||
|
gameData: GameData
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditorDB extends Dexie {
|
||||||
|
versions!: Table<VersionRecord, number>
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('EditorVersions')
|
||||||
|
this.version(1).stores({
|
||||||
|
versions: '++id, sourcePath, timestamp',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new EditorDB()
|
||||||
|
|
||||||
|
export async function putVersion(record: VersionRecord): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.versions.add(record)
|
||||||
|
const all = await db.versions
|
||||||
|
.where('sourcePath')
|
||||||
|
.equals(record.sourcePath)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('timestamp')
|
||||||
|
if (all.length > 20) {
|
||||||
|
const toDelete = all.slice(20)
|
||||||
|
await db.versions.bulkDelete(toDelete.map((v) => v.id!))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersions(sourcePath: string): Promise<VersionRecord[]> {
|
||||||
|
try {
|
||||||
|
return await db.versions
|
||||||
|
.where('sourcePath')
|
||||||
|
.equals(sourcePath)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('timestamp')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearVersions(sourcePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const records = await db.versions.where('sourcePath').equals(sourcePath).toArray()
|
||||||
|
await db.versions.bulkDelete(records.map((v) => v.id!))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { shallowRef, ref, computed, triggerRef } from 'vue'
|
import { shallowRef, ref, computed, triggerRef } from 'vue'
|
||||||
import type { GameData, SceneNode, Choice } from '@engine/types'
|
import type { GameData, SceneNode, Choice } from '@engine/types'
|
||||||
|
import { putVersion, getVersions } from '../db/editorDB'
|
||||||
|
|
||||||
|
export interface AIDiff {
|
||||||
|
added: string[]
|
||||||
|
modified: string[]
|
||||||
|
deleted: string[]
|
||||||
|
globalFields: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorVersion {
|
||||||
|
id?: number
|
||||||
|
sourcePath: string
|
||||||
|
timestamp: number
|
||||||
|
label: string
|
||||||
|
gameData: GameData
|
||||||
|
}
|
||||||
|
|
||||||
export const useEditorStore = defineStore('editor', () => {
|
export const useEditorStore = defineStore('editor', () => {
|
||||||
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
||||||
@@ -11,6 +27,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
||||||
const showAIPanel = ref(false)
|
const showAIPanel = ref(false)
|
||||||
const aiSessionId = ref('')
|
const aiSessionId = ref('')
|
||||||
|
const aiChanges = ref<AIDiff | null>(null)
|
||||||
|
const versions = ref<EditorVersion[]>([])
|
||||||
|
|
||||||
const selectedScene = computed(() => {
|
const selectedScene = computed(() => {
|
||||||
if (!selectedNodeId.value) return null
|
if (!selectedNodeId.value) return null
|
||||||
@@ -151,11 +169,64 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
} catch { /* dev server not running */ }
|
} catch { /* dev server not running */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadFromDisk() {
|
async function saveVersion(label: string) {
|
||||||
|
try {
|
||||||
|
await putVersion({
|
||||||
|
sourcePath: sourcePath.value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
label,
|
||||||
|
gameData: JSON.parse(JSON.stringify(gameData.value)),
|
||||||
|
})
|
||||||
|
await loadVersions()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreVersion(idx: number) {
|
||||||
|
const v = versions.value[idx]
|
||||||
|
if (!v) return
|
||||||
|
gameData.value = v.gameData
|
||||||
|
startSceneId.value = v.gameData.startScene || ''
|
||||||
|
selectedNodeId.value = null
|
||||||
|
aiChanges.value = null
|
||||||
|
dirty.value = false
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
try {
|
||||||
|
versions.value = await getVersions(sourcePath.value)
|
||||||
|
} catch {
|
||||||
|
versions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAIMarkers() {
|
||||||
|
aiChanges.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadFromDisk(oldGameData?: GameData) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(sourcePath.value)
|
const resp = await fetch(sourcePath.value)
|
||||||
const data = await resp.json()
|
const newData = await resp.json()
|
||||||
gameData.value = data
|
if (oldGameData) {
|
||||||
|
const { scenes: newScenes, ...newGlobal } = newData
|
||||||
|
const { scenes: oldScenes, ...oldGlobal } = oldGameData
|
||||||
|
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
|
||||||
|
for (const id of Object.keys(newScenes)) {
|
||||||
|
if (!oldScenes[id]) diff.added.push(id)
|
||||||
|
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
|
||||||
|
}
|
||||||
|
for (const id of Object.keys(oldScenes)) {
|
||||||
|
if (!newScenes[id]) diff.deleted.push(id)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys({ ...(oldGlobal as any), ...(newGlobal as any) })) {
|
||||||
|
if (JSON.stringify((oldGlobal as any)[key]) !== JSON.stringify((newGlobal as any)[key])) {
|
||||||
|
diff.globalFields.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aiChanges.value = diff
|
||||||
|
}
|
||||||
|
gameData.value = newData
|
||||||
selectedNodeId.value = null
|
selectedNodeId.value = null
|
||||||
clearAISession()
|
clearAISession()
|
||||||
} catch { /* failed to reload */ }
|
} catch { /* failed to reload */ }
|
||||||
@@ -163,9 +234,10 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
||||||
deepseekKey, showAIPanel, aiSessionId,
|
deepseekKey, showAIPanel, aiSessionId, aiChanges, versions,
|
||||||
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
||||||
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
||||||
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
||||||
|
saveVersion, restoreVersion, loadVersions, clearAIMarkers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
"scenes": {
|
"scenes": {
|
||||||
"intro": {
|
"intro": {
|
||||||
"id": "intro",
|
"id": "intro",
|
||||||
|
"nextScene": "investigation_site",
|
||||||
"videoUrl": "intro/intro.mp4",
|
"videoUrl": "intro/intro.mp4",
|
||||||
"streamingUrl": {
|
"streamingUrl": {
|
||||||
"超清 (1080P)": "intro/1080p/index.m3u8",
|
"超清 (1080P)": "intro/1080p/index.m3u8",
|
||||||
|
|||||||
Reference in New Issue
Block a user