feat: frame-based positioning and mark system foundation

Core Changes:
- Fix SearchView to use start_frame/end_frame directly (no time*fps conversion)
- Add hard_delete support to delete_trace API
- VideoPlayer: Main timeline + Mark system foundation
- Proxy: Add local routes for auth, media, identity-matches, cluster-results
- Add .gitignore to exclude build artifacts and dependencies

Design Documents:
- Multi-track Mark system design (.opencode/plans/)
- Video editing positioning standards research

Files Modified:
- src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames)
- src/views/PeopleView.vue: batchDeleteGroups with hard_delete
- src/api/index.ts: delete_trace with hard_delete body
- src/components/VideoPlayer.vue: Timeline + Mark UI
- src-tauri/src/proxy.rs: New local routes
- AGENTS.md: Update documentation
This commit is contained in:
2026-07-24 20:19:47 +08:00
parent 299f58e883
commit 5951aca086
38 changed files with 12107 additions and 1666 deletions

View File

@@ -0,0 +1,978 @@
# 多轨 Mark 系统设计文档
## 一、概述
### 1.1 目标
构建一个多轨道视频/音频编辑展示系统,支持:
- 多轨 Video/Audio 同步播放
- 每个轨道独立的 Mark 系统
- 基于 Frame 的精确定位和对齐
### 1.2 核心原则
| 原则 | 说明 |
|------|------|
| **Frame 是唯一标准** | 所有定位、长度、对齐都用 Frame(整数) |
| **Time 仅用于显示** | Frame / FPS 转换,不存储 |
| **整数运算** | 所有 Frame 操作都是整数,避免浮点精度问题 |
| **原点对齐** | Frame 0 = Time 0,所有轨道共享同一原点 |
| **轨道独立** | 每个轨道有独立的 Mark 系统 |
| **同步播放** | 全局播放头对齐所有轨道 |
### 1.3 概念统一
**核心洞察**:Trace 和 Segment 只是不同 tag 的 Marks
```
传统概念 → 统一概念
────────────────────────────────
Trace → Marks with tag='face'
Segment → Marks with tag='segment'
ASR 分析 → Marks with tag='asr'
Noise 检测 → Marks with tag='noise'
用户标记 → Marks with tag='user'
```
**好处**:
- 统一数据模型
- 简化代码实现
- 一致的交互行为
- 灵活扩展新类型
---
## 二、架构设计
### 2.1 层级架构
```
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (Application) │
│ - 剪接工具 │
│ - 审核工具 │
│ - 播放器 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 项目层 (Project) │
│ - 多轨道管理 │
│ - 全局播放状态 │
│ - FPS / TotalFrames 元数据 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 轨道层 (Track) │
│ - Video Track (视频轨道 + Marks) │
│ - Audio Track (音频轨道 + Marks) │
│ - Mark Track (纯 Mark 轨道) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Mark 层 (Mark) │
│ - startFrame / endFrame (整数) │
│ - tag / note / metadata │
│ - 属于特定 Track │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 数据流
```
┌──────────────┐
│ 数据源 │
│ - Face API │
│ - ASR API │
│ - Noise API │
└──────┬───────┘
│ 获取数据
▼
┌──────────────┐
│ 转换层 │
│ Time→Frame │ (用 FPS)
│ 统一格式 │
└──────┬───────┘
│ Mark 数据
▼
┌──────────────┐
│ Project │
│ 多轨道管理 │
└──────┬───────┘
│ Track + Marks
▼
┌──────────────┐
│ 展示层 │
│ - Timeline │
│ - 播放器 │
└──────────────┘
```
---
## 三、数据结构
### 3.1 核心类型定义
```typescript
// ========== 基础类型 ==========
type Frame = number // 整数,绝对定位标准
// ========== Mark ==========
interface Mark {
id: string
trackId: string // 所属轨道
// 定位(Frame,整数)
startFrame: Frame
endFrame?: Frame
// 分类与内容
tag: string // 'face' | 'asr' | 'noise' | 'user' | ...
note?: string
metadata?: any // 原始数据(如 ASR text, noise dB)
// 审核状态
status?: 'pending' | 'confirmed' | 'resolved' | 'ignored'
severity?: 'low' | 'medium' | 'high'
}
// ========== Clip ==========
interface Clip {
id: string
// 源文件范围
sourceStartFrame: Frame
sourceEndFrame: Frame
// 时间轴位置(全局 Frame)
timelineStartFrame: Frame
timelineEndFrame: Frame
}
// ========== Track ==========
interface Track {
id: string
type: 'video' | 'audio' | 'mark-only'
name: string
enabled: boolean // 是否启用
// 轨道内容
file_uuid?: string
clips?: Clip[]
// 独立的 Mark 系统
marks: Mark[]
}
// ========== Project ==========
interface Project {
id: string
name: string
// 全局元数据(所有轨道共享)
fps: number // 帧率
totalFrames: Frame // 总帧数(最长轨道)
duration: number // 总时长(秒),仅用于显示
// 多轨道
tracks: Track[]
// 播放状态
playhead: Frame // 当前播放位置(全局)
playing: boolean
}
```
### 3.2 Frame 操作规范
```typescript
// 所有 Frame 操作都是整数
// Frame → Time(仅显示用)
function frameToTime(frame: Frame, fps: number): number {
return frame / fps
}
// Time → Frame(数据转换用)
function timeToFrame(time: number, fps: number): Frame {
return Math.round(time * fps)
}
// 位置百分比(Timeline 显示)
function frameToPercent(frame: Frame, totalFrames: Frame): number {
return (frame / totalFrames) * 100
}
// 格式化显示
function formatFrame(frame: Frame, fps: number): string {
const sec = frame / fps
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
```
---
## 四、轨道系统
### 4.1 轨道类型
| 类型 | 说明 | 内容 | Marks |
|------|------|------|-------|
| **Video Track** | 视频轨道 | video clips + file_uuid | face, noise, blurry, user... |
| **Audio Track** | 音频轨道 | audio clips + file_uuid | asr, silence, beat, noise... |
| **Mark Track** | 纯标记轨道 | 无媒体内容 | user, comment, todo, review... |
### 4.2 轨道 UI 布局
```
┌─────────────────────────────────────────────────────────────┐
│ Global Playhead: Frame 12345 (0:24.5) │
│ ▼ │
│ ──────────────────────────────────────────────────────── │
│ │
│ ☑ V1 (主视频) │
│ ├─ ████████████████████████████████ │
│ └─ marks: │
│ ├─ [face] ████ F100-120 │
│ ├─ [face] ███████████ F500-650 │
│ └─ [noise] ████ F800-900 │
│ │
│ ☑ A1 (原声) │
│ ├─ ████████████████████████████████ │
│ └─ marks: │
│ ├─ [asr] ████ F100-120 "你好" │
│ └─ [noise] ████ F800-900 │
│ │
│ ☑ M1 (审核标记) │
│ └─ marks: │
│ ├─ [user] ▼ F500 "需要检查" │
│ └─ [user] ▼ F1000 "待确认" │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 4.3 轨道操作
```typescript
interface TrackOperations {
// 播放控制
play(): void
pause(): void
seek(frame: Frame): void
// Mark 操作
addMark(mark: Omit<Mark, 'id' | 'trackId'>): Mark
updateMark(id: string, updates: Partial<Mark>): void
deleteMark(id: string): void
// 批量操作
batchUpdateMarks(filter: (m: Mark) => boolean, updates: Partial<Mark>): void
batchDeleteMarks(filter: (m: Mark) => boolean): void
// 导航
nextMark(tag?: string): Mark | null
prevMark(tag?: string): Mark | null
}
```
---
## 五、Mark 系统
### 5.1 Mark 类型与来源
| Tag | 来源 | 数据格式 | 转换 |
|-----|------|----------|------|
| **face** | Face Trace API | first_frame, last_frame | 无需转换 |
| **asr** | ASR API | start_time, end_time | Time → Frame |
| **noise** | 噪声检测 | start_time, end_time | Time → Frame |
| **silent** | 静音检测 | start_time, end_time | Time → Frame |
| **user** | 用户创建 | frame | 无需转换 |
| **comment** | 用户评论 | frame | 无需转换 |
### 5.2 Mark 数据源转换
```typescript
// Face Trace → Mark
function traceToMark(trace: FaceTrace, trackId: string): Mark {
return {
id: generateId(),
trackId,
startFrame: trace.first_frame,
endFrame: trace.last_frame,
tag: 'face',
note: trace.identity_name,
metadata: trace
}
}
// ASR → Mark
function asrToMark(asr: AsrChunk, trackId: string, fps: number): Mark {
return {
id: generateId(),
trackId,
startFrame: timeToFrame(asr.start_time, fps),
endFrame: timeToFrame(asr.end_time, fps),
tag: 'asr',
note: asr.text,
metadata: asr
}
}
// Noise → Mark
function noiseToMark(noise: NoiseSegment, trackId: string, fps: number): Mark {
return {
id: generateId(),
trackId,
startFrame: timeToFrame(noise.start_time, fps),
endFrame: timeToFrame(noise.end_time, fps),
tag: 'noise',
note: `${noise.db}dB`,
metadata: noise,
severity: noise.db > -30 ? 'high' : noise.db > -40 ? 'medium' : 'low'
}
}
```
### 5.3 Mark 操作表
| Tag | 操作 | 参数 |
|-----|------|------|
| **noise** | 删除片段 | padding: 5 frames |
| **silent** | 静音处理 | fade: 10ms |
| **blurry** | 标记忽略 | - |
| **face** | 绑定身份 | identity_uuid |
| **asr** | 编辑文本 | new_text |
| **user** | 自定义 | - |
---
## 六、Mark 点击交互行为
### 6.1 核心概念
点击 Mark 时,根据**当前播放状态**和**全局 PlayMode** 决定行为。
### 6.2 Sync Lock(同步锁)
```typescript
// 全局同步锁
const syncLock = ref<boolean>(true) // 默认锁定
// 锁定 🔒:点击 Mark → 所有轨道跳转到同一 frame
// 解锁 🔓:点击 Mark → 仅当前轨道跳转
```
**UI 控件**:
```
[🔒 Sync ON] / [🔓 Sync OFF]
```
### 6.3 PlayMode(全局播放模式)
```typescript
type PlayMode = 'normal' | 'continue' | 'loop'
const globalPlayMode = ref<PlayMode>('normal')
```
| 模式 | 行为 |
|------|------|
| **Normal** | 播放到 Mark.endFrame 后停止 |
| **Continue** | 播放到 Mark.endFrame 后继续播放 |
| **Loop** | 循环播放 Mark.startFrame ~ endFrame |
**UI 控件**:
```
[Normal] [Continue] [Loop]
```
### 6.4 点击逻辑流程
```
┌─────────────────────────────────────────────────────┐
│ 点击 Mark 触发 │
├─────────────────────────────────────────────────────┤
│ │
│ 1. Seek 到 Mark.startFrame │
│ ├─ SyncLock ON → 所有轨道同步跳转 │
│ └─ SyncLock OFF → 仅当前轨道跳转 │
│ │
│ 2. 检查当前播放状态 │
│ ├─ Pause → 只跳转,不播放 │
│ └─ Play → 跳转后根据 PlayMode 播放 │
│ │
│ 3. 根据 PlayMode 设置播放行为 │
│ ├─ Normal → 设置 stopAtFrame │
│ ├─ Continue → 清除 stopAtFrame │
│ └─ Loop → 设置 loopRange │
│ │
└─────────────────────────────────────────────────────┘
```
### 6.5 代码实现
```typescript
// 播放状态
const playhead = ref<Frame>(0)
const isPlaying = ref<boolean>(false)
const stopAtFrame = ref<Frame | null>(null)
const loopRange = ref<{ start: Frame; end: Frame } | null>(null)
// 点击 Mark
function onMarkClick(mark: Mark) {
// 1. Seek
if (syncLock.value) {
seekAllTracks(mark.startFrame) // 所有轨道同步
} else {
seekTrack(mark.trackId, mark.startFrame) // 仅当前轨道
}
// 2. Pause 状态:只跳转,不播放
if (!isPlaying.value) return
// 3. Play 状态:设置播放行为
switch (globalPlayMode.value) {
case 'normal':
stopAtFrame.value = mark.endFrame || mark.startFrame
loopRange.value = null
break
case 'continue':
stopAtFrame.value = null
loopRange.value = null
break
case 'loop':
// 不退出 loop(点击其他 mark 不退出)
loopRange.value = {
start: mark.startFrame,
end: mark.endFrame || mark.startFrame
}
break
}
play()
}
// PlayMode 切换
function setPlayMode(mode: PlayMode) {
globalPlayMode.value = mode
// 切换模式时退出 loop
if (mode !== 'loop') {
loopRange.value = null
}
}
// 播放控制器
function tick() {
if (!isPlaying.value) return
const currentFrame = playhead.value
// 检查 Loop 模式
if (loopRange.value && currentFrame >= loopRange.value.end) {
seekToFrame(loopRange.value.start)
requestAnimationFrame(() => tick())
return
}
// 检查 Normal 模式的停止点
if (stopAtFrame.value !== null && currentFrame >= stopAtFrame.value) {
pause()
return
}
// Continue 模式:正常播放
playhead.value++
requestAnimationFrame(() => tick())
}
```
### 6.6 Loop 退出条件
| 操作 | 是否退出 Loop |
|------|--------------|
| 点击其他 Mark | ❌ 不退出 |
| 切换 PlayMode | ✅ 退出 |
| 手动暂停 | ✅ 退出 |
| 关闭播放器 | ✅ 退出 |
### 6.7 UI 控件设计
```
┌─────────────────────────────────────────────────────┐
│ 控制栏 │
├─────────────────────────────────────────────────────┤
│ [🔒 Sync] | [Normal] [Continue] [Loop] │
│ │
│ 状态指示: │
│ - Sync: ON/OFF │
│ - Mode: Normal/Continue/Loop │
│ - Loop Range: F100-200 (如果 looping) │
│ - Stop At: F300 (如果 normal mode) │
└─────────────────────────────────────────────────────┘
```
---
## 七、同步播放
### 6.1 播放机制
```typescript
class PlaybackController {
private project: Project
private playhead: Frame = 0
private playing: boolean = false
private startTime: number = 0
private startFrame: Frame = 0
play() {
this.playing = true
this.startTime = performance.now()
this.startFrame = this.playhead
this.tick()
}
private tick() {
if (!this.playing) return
const elapsed = (performance.now() - this.startTime) / 1000
this.playhead = this.startFrame + Math.round(elapsed * this.project.fps)
// 同步所有轨道
this.syncAllTracks(this.playhead)
requestAnimationFrame(() => this.tick())
}
private syncAllTracks(frame: Frame) {
this.project.tracks.forEach(track => {
if (!track.enabled) return
// 更新媒体内容
if (track.type === 'video') {
this.syncVideo(track, frame)
} else if (track.type === 'audio') {
this.syncAudio(track, frame)
}
// 高亮 Marks
this.highlightMarks(track.marks, frame)
})
}
seek(frame: Frame) {
this.playhead = frame
this.startTime = performance.now()
this.startFrame = frame
this.syncAllTracks(frame)
}
}
```
### 6.2 对齐保证
```typescript
// 验证原点对齐
function validateAlignment(project: Project): boolean {
// 所有轨道共享原点
const globalPlayhead = project.playhead
project.tracks.forEach(track => {
// 所有轨道的当前帧 = 全局播放头
const trackFrame = globalPlayhead
// Mark 高亮基于同一 frame
track.marks.forEach(mark => {
const isActive = mark.startFrame <= trackFrame &&
(mark.endFrame || mark.startFrame) >= trackFrame
})
})
return true
}
```
---
## 八、Timeline 设计
### 8.1 Timeline 对齐原则
```
所有 Timeline 共享同一基准:
┌─────────────────────────────────────────────────────────────┐
│ 基准:Frame 0 ~ TotalFrames (65000) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Main Timeline │
│ ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ▲ Playhead: F12345 │
│ │
│ V1 Timeline │
│ ████████████████████████████████ │
│ [face] ████ [noise] ████ │
│ │
│ A1 Timeline │
│ ████████████████████████████████ │
│ [asr] ████ [asr] ████ │
│ │
│ M1 Timeline │
│ [user] ▼ [user] ▼ │
│ │
│ 所有 Timeline 长度一致,位置百分比计算相同 │
└─────────────────────────────────────────────────────────────┘
```
### 8.2 Timeline 计算
```typescript
// 所有 Timeline 用相同公式
const totalFrames = project.totalFrames
// 位置(百分比)
function getPosition(frame: Frame): number {
return (frame / totalFrames) * 100
}
// 宽度(百分比)
function getWidth(startFrame: Frame, endFrame: Frame): number {
return ((endFrame - startFrame) / totalFrames) * 100
}
// 像素位置(用于精确渲染)
function getPixelPosition(frame: Frame, width: number): number {
return Math.round((frame / totalFrames) * width)
}
```
---
## 九、API 设计
### 9.1 Project API
```typescript
// 创建项目
POST /api/v1/project
{
name: string,
fps: number,
totalFrames: number,
tracks: TrackConfig[]
}
// 获取项目
GET /api/v1/project/:id
// 更新项目
PUT /api/v1/project/:id
// 添加轨道
POST /api/v1/project/:id/track
{
type: 'video' | 'audio' | 'mark-only',
name: string,
file_uuid?: string
}
```
### 9.2 Track API
```typescript
// 获取轨道
GET /api/v1/track/:id
// 更新轨道
PUT /api/v1/track/:id
{
enabled?: boolean,
name?: string
}
// 删除轨道
DELETE /api/v1/track/:id
```
### 9.3 Mark API
```typescript
// 获取轨道的所有 Marks
GET /api/v1/track/:trackId/marks
// 添加 Mark
POST /api/v1/track/:trackId/mark
{
startFrame: Frame,
endFrame?: Frame,
tag: string,
note?: string
}
// 更新 Mark
PUT /api/v1/mark/:id
{
startFrame?: Frame,
endFrame?: Frame,
tag?: string,
note?: string,
status?: string
}
// 删除 Mark
DELETE /api/v1/mark/:id
// 批量操作
POST /api/v1/track/:trackId/marks/batch
{
action: 'update' | 'delete',
filter: { tag?: string, status?: string },
updates?: Partial<Mark>
}
```
---
## 十、数据持久化
### 10.1 SQLite 表结构
```sql
-- 项目表
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fps INTEGER NOT NULL,
total_frames INTEGER NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- 轨道表
CREATE TABLE tracks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
type TEXT NOT NULL, -- 'video' | 'audio' | 'mark-only'
name TEXT NOT NULL,
file_uuid TEXT,
enabled INTEGER DEFAULT 1,
position INTEGER, -- 轨道顺序
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
);
-- Mark 表
CREATE TABLE marks (
id TEXT PRIMARY KEY,
track_id TEXT NOT NULL,
start_frame INTEGER NOT NULL,
end_frame INTEGER,
tag TEXT NOT NULL,
note TEXT,
status TEXT DEFAULT 'pending',
severity TEXT,
metadata TEXT, -- JSON
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (track_id) REFERENCES tracks(id)
);
-- 索引
CREATE INDEX idx_marks_track ON marks(track_id);
CREATE INDEX idx_marks_frame ON marks(start_frame, end_frame);
CREATE INDEX idx_marks_tag ON marks(tag);
```
---
## 十一、UI 组件
### 11.1 组件层级
```
App
└─ ProjectEditor
├─ Toolbar
│ ├─ PlayButton
│ ├─ FrameInput
│ └─ ZoomControl
├─ TimelinePanel
│ ├─ PlayheadIndicator
│ ├─ TrackList
│ │ ├─ TrackItem (Video)
│ │ ├─ TrackItem (Audio)
│ │ └─ TrackItem (Mark-only)
│ └─ TimeScale
└─ VideoPlayer (可选)
└─ MarkOverlay
```
### 11.2 核心组件
#### TimelinePanel
```vue
<template>
<div class="timeline-panel">
<div class="playhead" :style="{ left: playheadPct + '%' }"></div>
<div v-for="track in tracks" :key="track.id" class="track-row">
<div class="track-header">
<input type="checkbox" v-model="track.enabled" />
<span>{{ track.name }}</span>
</div>
<div class="track-timeline" @click="onTimelineClick">
<div
v-for="mark in track.marks"
:key="mark.id"
class="mark"
:class="{ active: isMarkActive(mark) }"
:style="getMarkStyle(mark)"
@click.stop="onMarkClick(mark)"
>
{{ mark.tag }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const playheadPct = computed(() =>
(project.playhead / project.totalFrames) * 100
)
function getMarkStyle(mark: Mark) {
return {
left: `${(mark.startFrame / project.totalFrames) * 100}%`,
width: `${((mark.endFrame || mark.startFrame) - mark.startFrame) / project.totalFrames * 100}%`
}
}
</script>
```
---
## 十二、实现步骤
### 12.1 Phase 1: 基础架构(P0)
1. **数据结构定义**
- 定义 TypeScript 接口
- 实现 Frame 操作函数
2. **SQLite 存储**
- 创建表结构
- 实现 CRUD API
3. **单轨道 Mark 系统**
- Track + Mark 基础功能
- Timeline 展示
### 12.2 Phase 2: 多轨播放(P1)
1. **多轨道管理**
- Project 层实现
- Track 增删改
2. **同步播放**
- PlaybackController
- 全局 Playhead
3. **Timeline 对齐**
- 统一计算公式
- 视觉对齐
### 12.3 Phase 3: 数据集成(P1)
1. **数据源转换**
- Face Trace → Mark
- ASR → Mark
- Noise → Mark
2. **FPS 管理**
- 视频元数据获取
- Time ↔ Frame 转换
### 12.4 Phase 4: 操作功能(P2)
1. **Mark 操作**
- 批量更新
- 批量删除
2. **剪辑功能**
- 基于 Mark 的剪辑
- 片段操作
---
## 十三、测试计划
### 13.1 单元测试
- Frame 操作函数
- Time ↔ Frame 转换
- Mark 过滤/排序
- Timeline 位置计算
### 13.2 集成测试
- 多轨同步播放
- Playhead 对齐
- Mark 高亮状态
- 数据持久化
### 13.3 E2E 测试
- 创建项目
- 添加轨道
- 创建/编辑/删除 Mark
- 播放和导航
---
## 十四、附录
### 14.1 命名约定
| 概念 | 变量名 | 类型 |
|------|--------|------|
| 帧 | `frame` | `Frame` (number) |
| 起始帧 | `startFrame` | `Frame` |
| 结束帧 | `endFrame` | `Frame` |
| 总帧数 | `totalFrames` | `Frame` |
| 帧率 | `fps` | `number` |
| 播放头 | `playhead` | `Frame` |
| 时间(秒) | `time` / `sec` | `number` |
### 14.2 配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 默认 FPS | 30 | 无视频时使用 |
| 最小 Mark 宽度 | 1px | 渲染时保证可见 |
| Timeline 高度 | 24px | 每个轨道行高 |
| Playhead 更新频率 | 60fps | requestAnimationFrame |
---
## 十五、版本历史
| 版本 | 日期 | 变更 |
|------|------|------|
| 1.0 | 2026-07-24 | 初始设计文档 |
| 1.1 | 2026-07-24 | 添加概念统一说明、Mark 点击交互行为章节 |