# 工作区图表功能完整实现分析

## 1. 概述

工作区图表功能允许用户在工作区页面中添加和管理仪表板图表。该功能基于EditorJS实现，采用Block架构，图表作为一种特殊的Block类型存在。

## 2. 数据模型层

### 2.1 核心PageType

#### Workspace (工作区)
**文件位置**: `jingrow/desk/pagetype/workspace/workspace.json`

关键字段：
- `charts`: Table字段，关联 `WorkspaceChart`
- `content`: LongText字段，存储EditorJS blocks的JSON配置
- `public`: Check字段，是否公开工作区
- `module`: Link字段，关联模块

#### WorkspaceChart (工作区图表关联)
**文件位置**: `jingrow/desk/pagetype/workspace_chart/workspace_chart.json`

字段定义：
```json
{
  "chart_name": {
    "fieldtype": "Link",
    "options": "Dashboard Chart",
    "reqd": 1
  },
  "label": {
    "fieldtype": "Data"
  }
}
```

#### DashboardChart (仪表板图表)
**文件位置**: `jingrow/desk/pagetype/dashboard_chart/dashboard_chart.py`

关键属性：
- `chart_name`: 图表名称
- `chart_type`: 图表类型 (Count/Sum/Average/Group By/Custom/Report)
- `type`: 显示类型 (Line/Bar/Percentage/Pie/Donut/Heatmap)
- `document_type`: 关联的PageType
- `timeseries`: 是否时间序列
- `filters_json`: 过滤条件JSON

### 2.2 数据关系图

```
Workspace (工作区)
    └── charts (Table[WorkspaceChart])
            └── chart_name (Link) → DashboardChart
                                    └── document_type → PageType
```

## 3. 后端API层

### 3.1 工作区数据获取

#### 获取工作区侧边栏列表
**API**: `jingrow.desk.desktop.get_workspace_sidebar_items`
**文件**: `jingrow/desk/desktop.py`

```python
@jingrow.whitelist()
def get_workspace_sidebar_items():
    """Get list of sidebar items for desk"""
    has_access = "Workspace Manager" in jingrow.get_roles()
    
    # Filter by domain and module permissions
    blocked_modules = jingrow.get_cached_pg("User", jingrow.session.user).get_blocked_modules()
    allowed_domains = [None, *jingrow.get_active_domains()]
    
    # Return pages with permission check
    return {
        "pages": pages,
        "has_access": has_access,
        "has_create_access": jingrow.has_permission(pagetype="Workspace", ptype="create"),
    }
```

#### 获取工作区页面数据
**API**: `jingrow.desk.desktop.get_desktop_page`
**文件**: `jingrow/desk/desktop.py`

```python
@jingrow.whitelist()
@jingrow.read_only()
def get_desktop_page(page):
    """Applies permissions and returns workspace configuration"""
    workspace = Workspace(loads(page))
    workspace.build_workspace()
    return {
        "charts": workspace.charts,
        "shortcuts": workspace.shortcuts,
        "cards": workspace.cards,
        "onboardings": workspace.onboardings,
        "quick_lists": workspace.quick_lists,
        "number_cards": workspace.number_cards,
        "custom_blocks": workspace.custom_blocks,
    }
```

### 3.2 图表数据获取

#### Workspace.get_charts()
**文件**: `jingrow/desk/desktop.py`

```python
@handle_not_exist
def get_charts(self):
    all_charts = []
    if jingrow.has_permission("Dashboard Chart", throw=False):
        charts = self.pg.charts
        
        for chart in charts:
            if jingrow.has_permission("Dashboard Chart", pg=chart.chart_name):
                # Translate label
                chart.label = _(chart.label) if chart.label else _(chart.chart_name)
                all_charts.append(chart)
    
    return all_charts
```

#### 获取图表数据
**API**: `jingrow.desk.pagetype.dashboard_chart.dashboard_chart.get`
**文件**: `jingrow/desk/pagetype/dashboard_chart/dashboard_chart.py`

```python
@jingrow.whitelist()
@cache_source
def get(chart_name=None, chart=None, no_cache=None, filters=None,
        from_date=None, to_date=None, timespan=None, time_interval=None,
        heatmap_year=None, refresh=None):
    
    chart: DashboardChart = jingrow.get_pg("Dashboard Chart", chart_name)
    
    # Build filters
    filters = jingrow.parse_json(filters) or jingrow.parse_json(chart.filters_json)
    filters.append([chart.document_type, "pagestatus", "<", 2, False])
    
    # Get chart config based on type
    if chart.chart_type == "Group By":
        chart_config = get_group_by_chart_config(chart, filters)
    elif chart.type == "Heatmap":
        chart_config = get_heatmap_chart_config(chart, filters, heatmap_year)
    else:
        chart_config = get_chart_config(chart, filters, timespan, timegrain, from_date, to_date)
    
    return chart_config
```

### 3.3 图表数据缓存机制

**文件**: `jingrow/utils/dashboard.py`

```python
def cache_source(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        chart_name = jingrow.parse_json(chart).name
        cache_key = f"chart-data:{chart_name}"
        
        if int(kwargs.get("refresh") or 0):
            results = generate_and_cache_results(kwargs, function, cache_key, chart)
        else:
            cached_results = jingrow.cache.get_value(cache_key)
            if cached_results:
                results = jingrow.parse_json(jingrow.safe_decode(cached_results))
            else:
                results = generate_and_cache_results(kwargs, function, cache_key, chart)
        
        return results
    return wrapper
```

## 4. 前端实现层

### 4.1 整体架构

```
workspace.js (工作区主入口)
    └── EditorJS (编辑器核心)
            └── blocks/chart.js (图表Block定义)
                    └── SingleWidgetGroup (Widget容器)
                            └── ChartWidget (图表Widget实现)
                                    └── jingrow.utils.make_chart() (图表渲染)
```

### 4.2 工作区主入口

**文件**: `jingrow/public/js/jingrow/views/workspace/workspace.js`

#### 初始化流程

```javascript
class Workspace {
    constructor(wrapper) {
        this.blocks = jingrow.workspace_block.blocks;  // Block类型定义
        this.pages = {};
        this.sidebar_items = { public: {}, private: {} };
        this.prepare_container();
        this.setup_pages();
    }
    
    async setup_pages(reload) {
        this.sidebar_pages = await this.get_pages();
        this.all_pages = this.sidebar_pages.pages;
        this.has_access = this.sidebar_pages.has_access;
        this.make_sidebar();
    }
    
    get_pages() {
        return jingrow.xcall("jingrow.desk.desktop.get_workspace_sidebar_items");
    }
}
```

#### 显示工作区页面

```javascript
async show_page(page) {
    let current_page = pages.filter((p) => p.title == page.name)[0];
    this.content = current_page && JSON.parse(current_page.content);
    
    // 获取页面数据（包含charts）
    if (this.pages && this.pages[current_page.name]) {
        this.page_data = this.pages[current_page.name];
    } else {
        await this.get_data(current_page);
    }
    
    this.prepare_editorjs();
}
```

#### 获取页面数据

```javascript
get_data(page) {
    return jingrow.call("jingrow.desk.desktop.get_desktop_page", { page: page })
        .then((data) => {
            this.page_data = data.message;
            this.pages[page.name] = data.message;
            
            // Get dashboard settings for chart config
            return jingrow.dashboard_utils.get_dashboard_settings().then((settings) => {
                if (settings) {
                    let chart_config = settings.chart_config ? JSON.parse(settings.chart_config) : {};
                    this.page_data.charts.items.map((chart) => {
                        chart.chart_settings = chart_config[chart.chart_name] || {};
                    });
                }
            });
        });
}
```

#### EditorJS初始化

```javascript
initialize_editorjs(blocks) {
    this.tools = {
        chart: {
            class: this.blocks["chart"],
            config: {
                page_data: this.page_data || [],
            },
        },
        // ... other blocks
    };
    
    this.editor = new EditorJS({
        data: { blocks: blocks || [] },
        tools: this.tools,
        readOnly: true,
    });
}
```

### 4.3 图表Block实现

**文件**: `jingrow/public/js/jingrow/views/workspace/blocks/chart.js`

```javascript
import Block from "./block.js";

export default class Chart extends Block {
    static get toolbox() {
        return {
            title: "Chart",
            icon: jingrow.utils.icon("chart", "sm"),
        };
    }
    
    constructor({ data, api, config, readOnly, block }) {
        super({ data, api, config, readOnly, block });
        this.col = this.data.col ? this.data.col : "12";
        this.options = {
            allow_sorting: !this.readOnly,
            allow_create: !this.readOnly,
            allow_delete: !this.readOnly,
            allow_hiding: false,
            allow_edit: true,
            allow_resize: true,
            min_width: 6,
            max_widget_count: 2,
        };
    }
    
    render() {
        this.wrapper = document.createElement("div");
        this.new("chart");  // Create new chart dialog
        
        if (this.data && this.data.chart_name) {
            let has_data = this.make("chart", this.data.chart_name);
            if (!has_data) return;
        }
        
        if (!this.readOnly) {
            $(this.wrapper).find(".widget").addClass("chart edit-mode");
            this.add_settings_button();
            this.add_new_block_button();
        }
        
        return this.wrapper;
    }
    
    save() {
        return {
            chart_name: this.wrapper.getAttribute("chart_name"),
            col: this.get_col(),
            new: this.new_block_widget,
        };
    }
}
```

### 4.4 基础Block类

**文件**: `jingrow/public/js/jingrow/views/workspace/blocks/block.js`

```javascript
export default class Block {
    make(block, block_name, widget_type = block) {
        // Find block data from page_data
        let block_data = this.config.page_data[block + "s"].items.find((obj) => {
            return jingrow.utils.unescape_html(obj.label) == jingrow.utils.unescape_html(__(block_name));
        });
        
        if (!block_data) return false;
        
        block_data.in_customize_mode = !this.readOnly;
        
        // Create widget using SingleWidgetGroup
        this.block_widget = new jingrow.widget.SingleWidgetGroup({
            container: this.wrapper,
            type: widget_type,
            class_name: block == "chart" ? "widget-charts" : "",
            options: this.options,
            widgets: block_data,
            api: this.api,
            block: this.block,
        });
        
        this.wrapper.setAttribute(block + "_name", block_name);
        
        if (!this.readOnly) {
            this.block_widget.customize();
        }
        
        return true;
    }
    
    new(block, widget_type = block) {
        const dialog_class = get_dialog_constructor(widget_type);
        
        this.dialog = new dialog_class({
            for_workspace: true,
            label: this.label,
            type: widget_type,
            primary_action: (widget) => {
                widget.in_customize_mode = 1;
                this.block_widget = jingrow.widget.make_widget({
                    ...widget,
                    widget_type: widget_type,
                    container: this.wrapper,
                    options: {
                        ...this.options,
                        on_delete: () => this.api.blocks.delete(),
                        on_edit: () => this.on_edit(this.block_widget),
                    },
                });
                this.block_widget.customize(this.options);
                this.wrapper.setAttribute(block_name, this.block_widget.label);
                this.new_block_widget = this.block_widget.get_config();
            },
        });
        
        if (!this.readOnly && this.data && !this.data[block_name]) {
            this.dialog.make();
        }
    }
}
```

### 4.5 Widget容器

**文件**: `jingrow/public/js/jingrow/widgets/widget_group.js`

```javascript
export class SingleWidgetGroup {
    constructor(opts) {
        Object.assign(this, opts);
        this.widgets_list = [];
        this.widgets_dict = {};
        this.make();
    }
    
    make() {
        this.add_widget(this.widgets);
    }
    
    add_widget(widget) {
        let widget_object = jingrow.widget.make_widget({
            ...widget,
            widget_type: this.type,
            container: this.container,
            options: {
                ...this.options,
                on_delete: () => this.on_delete(),
                on_edit: () => this.on_edit(widget_object),
            },
        });
        this.widgets_list.push(widget_object);
        this.widgets_dict[widget.name] = widget_object;
        return widget_object;
    }
    
    customize() {
        this.widgets_list.forEach((wid) => {
            wid.customize(this.options);
        });
    }
}

// Widget Factory
jingrow.widget.widget_factory = {
    chart: ChartWidget,
    base: BaseWidget,
    shortcut: ShortcutWidget,
    links: LinksWidget,
    // ...
};

jingrow.widget.make_widget = (opts) => {
    const widget_class = jingrow.widget.widget_factory[opts.widget_type];
    if (widget_class) {
        return new widget_class(opts);
    }
};
```

### 4.6 图表Widget实现

**文件**: `jingrow/public/js/jingrow/widgets/chart_widget.js`

```javascript
export default class ChartWidget extends Widget {
    constructor(opts) {
        opts.shadow = true;
        super(opts);
        this.height = this.height || 240;
    }
    
    make_chart() {
        this.get_settings().then(() => {
            if (!this.settings) {
                this.deleted = true;
                this.widget.remove();
                return;
            }
            
            this.setup_container();
            
            if (!this.in_customize_mode) {
                this.action_area.empty();
                this.prepare_chart_actions();
                
                if (this.chart_pg.timeseries) {
                    this.render_time_series_filters();
                }
            }
            
            jingrow.run_serially([
                () => this.prepare_chart_object(),
                () => this.setup_filter_button(),
                () => this.fetch_and_update_chart(),
            ]);
        });
    }
    
    get_settings() {
        return jingrow.model.with_pg("Dashboard Chart", this.chart_name).then((chart_pg) => {
            if (chart_pg) {
                this.chart_pg = chart_pg;
                
                if (this.chart_pg.chart_type == "Custom") {
                    // Custom source
                    if (jingrow.dashboards.chart_sources[this.chart_pg.source]) {
                        this.settings = jingrow.dashboards.chart_sources[this.chart_pg.source];
                    } else {
                        return jingrow.xcall(
                            "jingrow.desk.pagetype.dashboard_chart_source.dashboard_chart_source.get_config",
                            { name: this.chart_pg.source }
                        ).then((config) => {
                            jingrow.dom.eval(config);
                            this.settings = jingrow.dashboards.chart_sources[this.chart_pg.source];
                        });
                    }
                } else if (this.chart_pg.chart_type == "Report") {
                    this.settings = { method: "jingrow.desk.query_report.run" };
                } else {
                    this.settings = { method: "jingrow.desk.pagetype.dashboard_chart.dashboard_chart.get" };
                }
            }
        });
    }
    
    fetch_and_update_chart() {
        this.args = {
            timespan: this.selected_timespan || this.chart_settings.timespan,
            time_interval: this.selected_time_interval || this.chart_settings.time_interval,
            from_date: this.selected_from_date || this.chart_settings.from_date,
            to_date: this.selected_to_date || this.chart_settings.to_date,
            heatmap_year: this.selected_heatmap_year || this.chart_settings.heatmap_year,
        };
        
        this.fetch(this.filters, true, this.args).then((data) => {
            if (this.chart_pg.chart_type == "Report") {
                this.report_result = data;
                this.summary = data.report_summary;
                data = this.get_report_chart_data(data);
            }
            
            this.update_chart_object();
            this.data = data;
            this.render();
        });
    }
    
    fetch(filters, refresh = false, args) {
        let method = this.settings.method;
        
        if (this.chart_pg.chart_type == "Report") {
            args = {
                report_name: this.chart_pg.report_name,
                filters: filters,
                ignore_prepared_report: 1,
            };
        } else {
            args = {
                chart_name: this.chart_pg.name,
                filters: filters,
                refresh: refresh ? 1 : 0,
                time_interval: args?.time_interval,
                timespan: args?.timespan,
                from_date: args?.from_date,
                to_date: args?.to_date,
                heatmap_year: args?.heatmap_year,
            };
        }
        
        return jingrow.xcall(method, args);
    }
    
    async render() {
        let setup_dashboard_chart = () => {
            const chart_args = this.get_chart_args();
            
            if (!this.dashboard_chart) {
                this.dashboard_chart = jingrow.utils.make_chart(this.chart_wrapper[0], chart_args);
            } else {
                this.dashboard_chart.update(this.data);
            }
        };
        
        if (!this.data || !this.data.labels || !Object.keys(this.data).length) {
            this.chart_wrapper.hide();
            this.empty.show();
        } else {
            this.loading.hide();
            this.empty.hide();
            this.chart_wrapper.show();
            
            if (this.chart_pg.document_type) {
                jingrow.model.with_pagetype(this.chart_pg.document_type, setup_dashboard_chart);
            } else {
                setup_dashboard_chart();
            }
        }
    }
    
    get_chart_args() {
        let colors = this.get_chart_colors();
        
        const chart_type_map = {
            Line: "line",
            Bar: "bar",
            Percentage: "percentage",
            Pie: "pie",
            Donut: "donut",
            Heatmap: "heatmap",
        };
        
        let chart_args = {
            data: this.data,
            type: chart_type_map[this.chart_pg.type],
            colors: colors,
            height: this.height,
            maxSlices: this.chart_pg.number_of_groups || 6,
            axisOptions: {
                xIsSeries: this.chart_pg.timeseries,
                shortenYAxisNumbers: 1,
            },
        };
        
        // Apply custom options
        if (this.chart_pg.custom_options) {
            let custom_options = JSON.parse(this.chart_pg.custom_options);
            Object.assign(chart_args, custom_options);
        }
        
        return chart_args;
    }
}
```

### 4.7 图表对话框

**文件**: `jingrow/public/js/jingrow/widgets/widget_dialog.js`

```javascript
class ChartDialog extends WidgetDialog {
    get_fields() {
        return [
            {
                fieldtype: "Link",
                fieldname: "chart_name",
                label: __("Chart Name"),
                options: "Dashboard Chart",
                reqd: 1,
            },
            {
                fieldtype: "Data",
                fieldname: "label",
                label: "Label",
            },
        ];
    }
    
    process_data(data) {
        data.label = data.label ? data.label : data.chart_name;
        return data;
    }
}

export default function get_dialog_constructor(type) {
    const widget_map = {
        chart: ChartDialog,
        shortcut: ShortcutDialog,
        links: CardDialog,
        // ...
    };
    return widget_map[type] || WidgetDialog;
}
```

## 5. 完整执行流程

### 5.1 工作区加载流程

```
1. 用户访问工作区
   ↓
2. workspace.js: setup_pages()
   ↓
3. 调用 get_workspace_sidebar_items API
   ↓
4. 返回工作区列表（包含权限检查）
   ↓
5. 用户选择工作区 → show_page()
   ↓
6. 调用 get_desktop_page API
   ↓
7. Workspace.build_workspace() → get_charts()
   ↓
8. 返回图表列表（包含权限检查）
   ↓
9. initialize_editorjs() 创建编辑器
   ↓
10. EditorJS 渲染 chart block
```

### 5.2 图表Block渲染流程

```
1. Chart.render() 被调用
   ↓
2. 检查 data.chart_name 是否存在
   ↓
3. 存在 → Block.make("chart", chart_name)
   ↓
4. 从 page_data.charts.items 查找图表数据
   ↓
5. 创建 SingleWidgetGroup
   ↓
6. 调用 widget_factory["chart"] → ChartWidget
   ↓
7. ChartWidget.make_chart()
   ↓
8. get_settings() → 获取 Dashboard Chart 配置
   ↓
9. fetch_and_update_chart() → 调用后端API获取数据
   ↓
10. render() → jingrow.utils.make_chart() 渲染图表
```

### 5.3 新增图表流程

```
1. 用户点击添加图表
   ↓
2. Block.new("chart") 被调用
   ↓
3. 创建 ChartDialog
   ↓
4. 用户选择 Dashboard Chart
   ↓
5. primary_action 回调
   ↓
6. 创建 ChartWidget
   ↓
7. 保存 new_block_widget 配置
   ↓
8. 用户点击保存工作区
   ↓
9. workspace.save_page()
   ↓
10. EditorJS.save() 获取blocks数据
    ↓
11. 调用 workspace.save_page API
    ↓
12. save_new_widget() 创建 WorkspaceChart 记录
```

## 6. 权限控制

### 6.1 工作区权限

```python
def is_permitted(self):
    """Returns true if Has Role is not set or the user is allowed."""
    allowed = [d.role for d in self.pg.roles]
    custom_roles = get_custom_allowed_roles("page", self.pg.name)
    allowed.extend(custom_roles)
    
    if not allowed:
        return True
    
    roles = jingrow.get_roles()
    if has_common(roles, allowed):
        return True
```

### 6.2 图表权限

```python
@handle_not_exist
def get_charts(self):
    all_charts = []
    if jingrow.has_permission("Dashboard Chart", throw=False):
        charts = self.pg.charts
        
        for chart in charts:
            if jingrow.has_permission("Dashboard Chart", pg=chart.chart_name):
                chart.label = _(chart.label) if chart.label else _(chart.chart_name)
                all_charts.append(chart)
    
    return all_charts
```

## 7. 关键配置

### 7.1 图表类型映射

| chart_type | 数据来源 | API方法 |
|------------|----------|---------|
| Count/Sum/Average | PageType记录 | dashboard_chart.get |
| Group By | PageType分组 | dashboard_chart.get |
| Report | 报表数据 | query_report.run |
| Custom | 自定义源 | 自定义方法 |

### 7.2 图表显示类型

| type | 渲染类型 |
|------|----------|
| Line | line |
| Bar | bar |
| Percentage | percentage |
| Pie | pie |
| Donut | donut |
| Heatmap | heatmap |

## 8. 新版前端重构要点

### 8.1 数据获取

1. 使用 `get_workspace_sidebar_items` 获取工作区列表
2. 使用 `get_desktop_page` 获取工作区数据（包含charts）
3. 使用 `dashboard_chart.get` 获取图表数据

### 8.2 组件结构

```
WorkspacePage.vue
    └── WorkspaceContent.vue
            └── ChartBlock.vue
                    └── ChartWidget.vue
                            └── ChartRenderer.vue (使用图表库)
```

### 8.3 状态管理

```typescript
// workspace store
interface WorkspaceState {
    pages: WorkspacePage[]
    currentPage: WorkspacePage | null
    pageData: PageData | null
    hasAccess: boolean
}

interface PageData {
    charts: { items: ChartItem[] }
    shortcuts: { items: ShortcutItem[] }
    cards: { items: CardItem[] }
    // ...
}
```

### 8.4 图表组件实现

```vue
<template>
    <div class="chart-widget">
        <div class="widget-head">
            <span class="widget-title">{{ label }}</span>
            <div class="widget-actions">
                <button @click="refresh">刷新</button>
                <button @click="edit" v-if="canEdit">编辑</button>
            </div>
        </div>
        <div class="widget-body">
            <div v-if="loading">加载中...</div>
            <div v-else-if="!hasData">无数据</div>
            <ChartRenderer v-else :config="chartConfig" :data="chartData" />
        </div>
    </div>
</template>

<script setup>
const props = defineProps({
    chartName: String,
    label: String,
    chartSettings: Object,
})

const chartPg = ref(null)
const chartData = ref(null)
const loading = ref(true)

async function fetchChartData() {
    loading.value = true
    try {
        const data = await call('jingrow.desk.pagetype.dashboard_chart.dashboard_chart.get', {
            chart_name: props.chartName,
            ...chartSettings.value,
        })
        chartData.value = data
    } finally {
        loading.value = false
    }
}

onMounted(() => {
    fetchChartData()
})
</script>
```

## 9. 注意事项

1. **权限检查**: 图表显示前必须检查用户是否有 `Dashboard Chart` 的读取权限
2. **数据缓存**: 图表数据使用 Redis 缓存，key 格式为 `chart-data:{chart_name}`
3. **国际化**: 图表 label 需要使用 `_()` 函数进行翻译
4. **自定义选项**: 通过 `custom_options` JSON 字段可以覆盖默认图表配置
5. **时间序列**: 时间序列图表需要额外的 timespan/time_interval 参数
