File size: 13,193 Bytes
9fce90e
 
3a1d166
9a93f1f
 
afaa666
9fce90e
4a22058
 
 
 
 
fb1b4d2
4a22058
 
fb1b4d2
4a22058
 
 
 
 
 
 
 
 
9a93f1f
 
 
afaa666
9a93f1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a22058
 
90d91c3
4a22058
afaa666
 
 
4a22058
afaa666
 
 
 
 
8dd9716
4a22058
 
8dd9716
9fce90e
afaa666
 
4a22058
fb1b4d2
afaa666
 
4a22058
fb1b4d2
4a22058
 
 
 
 
 
9a93f1f
 
 
 
 
 
4a22058
 
 
 
 
fb1b4d2
afaa666
4a22058
 
 
 
 
 
afaa666
fb1b4d2
afaa666
 
acc9566
afaa666
 
acc9566
8dd9716
afaa666
4a22058
afaa666
acc9566
4a22058
acc9566
afaa666
 
9fce90e
4a22058
afaa666
4a22058
8dd9716
7bec91b
c5b4bef
8dd9716
 
afaa666
4a22058
 
 
79dc9c8
98f1b98
4a22058
c5b4bef
98f1b98
afaa666
 
4a22058
 
 
 
c5b4bef
fb1b4d2
8dd9716
c5b4bef
585bcee
afaa666
4a22058
 
 
acc9566
afaa666
4a22058
acc9566
 
afaa666
4a22058
fb1b4d2
4a22058
fb1b4d2
8e78049
4a22058
 
 
 
afaa666
8e78049
 
 
 
 
 
 
 
 
fb1b4d2
afaa666
 
 
 
 
 
 
 
fb1b4d2
4a22058
 
 
afaa666
 
 
 
0d354cf
90d91c3
 
4a22058
 
 
 
 
 
 
fb1b4d2
acc9566
4a22058
acc9566
fb1b4d2
 
 
4a22058
8e78049
 
fb1b4d2
8e78049
fb1b4d2
8e78049
acc9566
 
 
 
 
 
 
90d91c3
c5b4bef
acc9566
90d91c3
fb1b4d2
8e78049
fb1b4d2
 
8e78049
fb1b4d2
8e78049
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90d91c3
8e78049
90d91c3
8e78049
 
 
fb1b4d2
 
8e78049
 
 
 
c5b4bef
afaa666
 
9fce90e
 
8dd9716
4a22058
 
 
 
 
fb1b4d2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import gradio as gr
import json
import base64
import re                     # 【新增】用于正则匹配本地脚本路径
from pathlib import Path      # 【新增】用于处理相对路径
from data_manager import data_manager

# ============== 状态管理 (封装逻辑) ==============
class ReviewState:
    def __init__(self):
        self.all_paths = []
        self.current_idx = -1

    def sync_paths(self):
        self.all_paths = data_manager.get_all_chart_paths()

    def get_nav_target(self, direction):
        new_idx = self.current_idx + direction
        if 0 <= new_idx < len(self.all_paths):
            self.current_idx = new_idx
            return self.all_paths[new_idx]
        return None

nav_state = ReviewState()

# ============== 工具函数 (动态内联注入升级版) ==============
def to_html_frame(html_content, html_path):
    if not html_content or not html_path:
        return '<div style="padding:20px;text-align:center;">请选择数据进行加载</div>'
    
    # 获取当前 html 文件所在的本地绝对路径文件夹
    base_dir = Path(html_path).parent

    # 1. 动态内联本地 JS 文件
    def script_replacer(match):
        src_path = match.group(1)
        # 如果已经是互联网 CDN 链接,则跳过
        if src_path.startswith("http://") or src_path.startswith("https://") or src_path.startswith("//"):
            return match.group(0)
        
        # 拼接并解析出本地 JS 文件的真实路径
        local_file = (base_dir / src_path).resolve()
        try:
            with open(local_file, 'r', encoding='utf-8') as f:
                content = f.read()
            # 关键替换:防止 JS 源码中的 </script> 导致 HTML 提前闭合崩溃
            content = content.replace('</script>', '<\\/script>')
            return f'<script>\n{content}\n</script>'
        except Exception as e:
            print(f"Warning: 未找到依赖的本地脚本 {local_file}")
            return match.group(0) # 失败则保持原样
            
    # 正则匹配所有 <script src="..."></script> 标签
    html_content = re.sub(r'<script\b[^>]*?src=["\']([^"\']+)["\'][^>]*></script>', script_replacer, html_content)
    
    # 2. 动态内联本地 CSS 文件
    def css_replacer(match):
        href_path = match.group(1)
        if href_path.startswith("http://") or href_path.startswith("https://") or href_path.startswith("//"):
            return match.group(0)
            
        local_file = (base_dir / href_path).resolve()
        try:
            with open(local_file, 'r', encoding='utf-8') as f:
                content = f.read()
            return f'<style>\n{content}\n</style>'
        except Exception as e:
            return match.group(0)
            
    # 匹配 <link rel="stylesheet" href="..."> 
    html_content = re.sub(r'<link\b[^>]*?rel=["\']stylesheet["\'][^>]*?href=["\']([^"\']+)["\'][^>]*?>', css_replacer, html_content)
    html_content = re.sub(r'<link\b[^>]*?href=["\']([^"\']+)["\'][^>]*?rel=["\']stylesheet["\'][^>]*?>', css_replacer, html_content)

    # 3. 转换为最终的自我包裹式 Base64 Iframe
    b64_content = base64.b64encode(html_content.encode('utf-8')).decode('utf-8')
    return f'<iframe src="data:text/html;base64,{b64_content}" style="width:100%;height:600px;border:none;"></iframe>'

# ============== 交互逻辑 ==============
def handle_source_change(source):
    struct = data_manager.get_dataset_structure()
    types = list(struct.get('sources', {}).get(source, {}).get('chart_types', {}).keys())
    return gr.update(choices=types, value=types[0] if types else None)

def handle_type_change(source, c_type):
    charts = data_manager.get_chart_list(source, c_type)
    struct = data_manager.get_dataset_structure()
    models = struct.get('sources', {}).get(source, {}).get('chart_types', {}).get(c_type, {}).get('models', [])
    return (
        gr.update(choices=charts, value=charts[0] if charts else None),
        gr.update(choices=models, value=models[0] if models else None)
    )

def handle_load(source, c_type, c_id, model):
    if not all([source, c_type, c_id, model]):
        return [gr.update()] * 8

    chart_data = data_manager.get_chart_data(source, c_type, c_id)
    qa_list = data_manager.get_qa_list(source, c_type, model, c_id)
    stats = data_manager.get_review_stats()

    nav_state.sync_paths()
    for i, p in enumerate(nav_state.all_paths):
        if p['chart_id'] == c_id and p['model'] == model:
            nav_state.current_idx = i
            break

    # 【传入参数升级】传入 html_path 用于定位本地依赖
    html_code = to_html_frame(
        chart_data.get('html_content', ''), 
        chart_data.get('html_path', '')
    )
    
    meta_md = "\n".join([f"- **{k}**: {v}" for k, v in chart_data.get('label_info', {}).items()])
    qa_json = json.dumps([{"id": q.id, "q": q.question, "a": q.answer} for q in qa_list])
    stats_str = f"✅{stats['correct']} | ❌{stats['incorrect']} | 总{stats['total']}"
    prog_str = f"{nav_state.current_idx + 1} / {len(nav_state.all_paths)}"
    radio_choices = [f"Q{i+1}: {q.question[:20]}..." for i, q in enumerate(qa_list)]

    return [
        html_code, 
        meta_md, 
        qa_json, 
        stats_str, 
        prog_str, 
        f"{source}/{c_type}/{c_id}",
        gr.update(choices=radio_choices, value=radio_choices[0] if radio_choices else None),
        json.dumps({}) 
    ]

# 严格匹配 6 个 outputs 数量
def handle_qa_select(selection, qa_json):
    if not selection or not qa_json:
        return ["", "", "", "正确", "无", ""]
    try:
        qas = json.loads(qa_json)
        idx = int(selection.split(":")[0][1:]) - 1
        curr = qas[idx]
        return [curr['id'], curr['q'], curr['a'], "正确", "无", ""]
    except:
        return ["", "", "", "正确", "无", ""]

# ============== UI 布局 ==============
def create_ui():
    with gr.Blocks(title="审核系统 V2", theme=gr.themes.Soft()) as demo:
        qa_store = gr.State(value="[]")
        review_store = gr.State(value="{}")
        
        gr.Markdown("## 📑 图表问答数据集审核")
        
        with gr.Row():
            with gr.Column(scale=4):
                with gr.Row():
                    src_dd = gr.Dropdown(label="数据源", choices=["None"])
                    typ_dd = gr.Dropdown(label="图表类型")
                    id_dd = gr.Dropdown(label="图表 ID")
                    mdl_dd = gr.Dropdown(label="类型")
                
                chart_view = gr.HTML(value='<div style="height:500px; background:#f0f0f0;"></div>')
                path_info = gr.Text(label="当前路径", interactive=False)
                
            with gr.Column(scale=2):
                with gr.Group():
                    stats_txt = gr.Text(label="统计信息", interactive=False)
                    prog_txt = gr.Text(label="审核进度", interactive=False)
                
                with gr.Accordion("元数据解析", open=False):
                    meta_md = gr.Markdown()
                
                gr.Markdown("---")
                qa_radio = gr.Radio(label="题目列表", choices=[])
                
                with gr.Group():
                    curr_qid = gr.Text(visible=False)
                    q_disp = gr.Text(label="问题内容", lines=2)
                    a_disp = gr.Text(label="标准答案")
                    
                    status_opt = gr.Radio(
                        label="审核结论", 
                        choices=["正确", "错误", "优化"],
                        value="正确"
                    )
                    err_type = gr.Dropdown(label="错误分类", choices=["无", "事实错误", "逻辑错误", "图表无法读取"])
                
                comment = gr.Text(label="审核备注")
                save_status = gr.Text(label="操作反馈", interactive=False)
                save_btn = gr.Button("💾 提交本题并下一题", variant="primary")
                
                with gr.Row():
                    prev_btn = gr.Button("⬅️ 上一个图表")
                    next_btn = gr.Button("➡️ 下一个图表")

        gr.Markdown("---")
        gr.Markdown("### 📦 记录导出与预览")
        # 【新增替代方案】使用 gr.Code 展示所有记录,自带右上角复制按钮
        records_code = gr.Code(
            label="当前所有审核记录 JSON (光标悬浮至代码块右上角可一键复制)", 
            language="json", 
            interactive=False,
            lines=15
        )

        # --- 事件绑定 ---
        demo.load(
            fn=lambda: gr.update(choices=list(data_manager.get_dataset_structure().get('sources', {}).keys())),
            outputs=[src_dd]
        )

        src_dd.change(handle_source_change, inputs=[src_dd], outputs=[typ_dd])
        typ_dd.change(handle_type_change, inputs=[src_dd, typ_dd], outputs=[id_dd, mdl_dd])

        load_event_outputs = [chart_view, meta_md, qa_store, stats_txt, prog_txt, path_info, qa_radio, review_store]
        id_dd.change(handle_load, inputs=[src_dd, typ_dd, id_dd, mdl_dd], outputs=load_event_outputs)
        mdl_dd.change(handle_load, inputs=[src_dd, typ_dd, id_dd, mdl_dd], outputs=load_event_outputs)

        qa_radio.change(
            handle_qa_select, 
            inputs=[qa_radio, qa_store], 
            outputs=[curr_qid, q_disp, a_disp, status_opt, err_type, comment] 
        )

        def navigate(direction):
            target = nav_state.get_nav_target(direction)
            if target:
                return [
                    gr.update(value=target['source']),
                    gr.update(value=target['chart_type']),
                    gr.update(value=target['chart_id']),
                    gr.update(value=target['model']),
                    gr.update(value="")
                ]
            return [gr.update(), gr.update(), gr.update(), gr.update(), gr.update()]
            
        prev_btn.click(lambda: navigate(-1), outputs=[src_dd, typ_dd, id_dd, mdl_dd, save_status])
        next_btn.click(lambda: navigate(1), outputs=[src_dd, typ_dd, id_dd, mdl_dd, save_status])

        # 【核心逻辑升级】保存 + 自动跳转 + 刷新记录展示
        def quick_save_and_next(qid, cid, src, status_label, cmt, current_qa_selection, qa_json_str):
            if not qid: 
                return "⚠️ 无效操作:未选择题目", gr.update(), gr.update(), gr.update()
            
            # 1. 状态映射与保存
            status_map = {
                "正确": "correct",
                "错误": "incorrect",
                "优化": "needs_modification"
            }
            mapped_status = status_map.get(status_label, "correct")
            
            data_manager.save_review({
                "qa_id": qid, "chart_id": cid, "source": src,
                "status": mapped_status, "comment": cmt
            })
            
            # 2. 刷新统计
            stats = data_manager.get_review_stats()
            stats_str = f"✅{stats['correct']} | ❌{stats['incorrect']} | 总{stats['total']}"
            feedback = f"✅ 保存成功 (ID: {qid})"
            
            # 3. 提取所有记录并格式化为 JSON 字符串给代码块展示
            all_reviews = data_manager.get_all_reviews()
            reviews_json_text = json.dumps(all_reviews, ensure_ascii=False, indent=2)

            # 4. 自动跳转下一题逻辑
            next_qa_update = gr.update()
            try:
                qas = json.loads(qa_json_str)
                # 解析当前选中的题号,比如 "Q1: xxx..." 提取数字 1
                curr_q_num = int(current_qa_selection.split(":")[0][1:])
                next_idx = curr_q_num # 下一题的索引正好等于当前题号(基于0的索引)
                
                if next_idx < len(qas):
                    next_q = qas[next_idx]
                    next_choice = f"Q{next_idx+1}: {next_q['q'][:20]}..."
                    # 通知 Radio 组件切换到下一题,这会自动触发 qa_radio.change 事件刷新题目内容
                    next_qa_update = gr.update(value=next_choice)
                else:
                    feedback += " (当前图表题目已全部审核完毕)"
            except Exception as e:
                pass
            
            return feedback, stats_str, next_qa_update, reviews_json_text

        # 绑定升级后的保存事件
        save_btn.click(
            quick_save_and_next, 
            inputs=[curr_qid, id_dd, src_dd, status_opt, comment, qa_radio, qa_store],
            outputs=[save_status, stats_txt, qa_radio, records_code]
        )

        # 初始化时也加载一次记录
        demo.load(
            fn=lambda: json.dumps(data_manager.get_all_reviews(), ensure_ascii=False, indent=2),
            outputs=[records_code]
        )

    return demo

if __name__ == "__main__":
    app = create_ui()
    app.launch(
        server_name="0.0.0.0", 
        server_port=7860, 
        show_api=False,
        max_threads=10
    )