""" 需求澄清视图组件 用于通过交互式问答澄清用户的模糊需求 """ import tkinter as tk from tkinter import ttk from typing import Callable, Optional, Dict, List, Any class ClarifyOption: """澄清选项数据类""" def __init__( self, id: str, type: str, # radio, checkbox, input label: str, choices: List[str] = None, default: str = None, placeholder: str = None ): self.id = id self.type = type self.label = label self.choices = choices or [] self.default = default self.placeholder = placeholder or "" class ClarifyView: """ 需求澄清视图 支持: - 单选按钮 (radio) - 复选框 (checkbox) - 输入框 (input) - 多轮对话展示 """ def __init__( self, parent: tk.Widget, on_submit: Callable[[Dict[str, Any]], None], on_cancel: Callable[[], None] ): self.parent = parent self.on_submit = on_submit self.on_cancel = on_cancel # 存储控件变量 self._vars: Dict[str, Any] = {} self._option_widgets: List[tk.Widget] = [] # 对话历史 self._history: List[Dict[str, Any]] = [] self._create_widgets() def _create_widgets(self): """创建 UI 组件""" self.frame = tk.Frame(self.parent, bg='#1e1e1e') # 标题栏 title_frame = tk.Frame(self.frame, bg='#2d2d2d') title_frame.pack(fill=tk.X) title_label = tk.Label( title_frame, text="💬 需求澄清", font=('Microsoft YaHei UI', 14, 'bold'), fg='#4fc3f7', bg='#2d2d2d', pady=10 ) title_label.pack(side=tk.LEFT, padx=15) # 提示信息 tip_label = tk.Label( title_frame, text="请回答以下问题,帮助我更好地理解您的需求", font=('Microsoft YaHei UI', 9), fg='#888888', bg='#2d2d2d' ) tip_label.pack(side=tk.RIGHT, padx=15) # 主内容区域(可滚动) content_container = tk.Frame(self.frame, bg='#1e1e1e') content_container.pack(fill=tk.BOTH, expand=True, padx=15, pady=10) # 创建 Canvas 和滚动条 self.canvas = tk.Canvas(content_container, bg='#1e1e1e', highlightthickness=0) scrollbar = ttk.Scrollbar(content_container, orient=tk.VERTICAL, command=self.canvas.yview) self.content_frame = tk.Frame(self.canvas, bg='#1e1e1e') self.canvas.configure(yscrollcommand=scrollbar.set) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.canvas_window = self.canvas.create_window((0, 0), window=self.content_frame, anchor=tk.NW) # 绑定事件 self.content_frame.bind("", self._on_frame_configure) self.canvas.bind("", self._on_canvas_configure) # 鼠标滚轮支持 self.canvas.bind_all("", self._on_mousewheel) # 对话历史区域 self.history_frame = tk.Frame(self.content_frame, bg='#1e1e1e') self.history_frame.pack(fill=tk.X, pady=(0, 10)) # 当前问题区域 self.question_frame = tk.Frame(self.content_frame, bg='#252526', relief=tk.FLAT) self.question_frame.pack(fill=tk.X, pady=10) # 问题标签 self.question_label = tk.Label( self.question_frame, text="", font=('Microsoft YaHei UI', 11), fg='#ffffff', bg='#252526', wraplength=600, justify=tk.LEFT, padx=15, pady=10 ) self.question_label.pack(fill=tk.X) # 选项区域 self.options_frame = tk.Frame(self.question_frame, bg='#252526') self.options_frame.pack(fill=tk.X, padx=15, pady=(0, 15)) # 底部按钮区域 btn_frame = tk.Frame(self.frame, bg='#1e1e1e') btn_frame.pack(fill=tk.X, padx=15, pady=15) # 取消按钮 self.cancel_btn = tk.Button( btn_frame, text="取消", font=('Microsoft YaHei UI', 10), bg='#424242', fg='white', activebackground='#616161', activeforeground='white', relief=tk.FLAT, padx=20, pady=5, cursor='hand2', command=self._on_cancel ) self.cancel_btn.pack(side=tk.LEFT) # 已收集信息提示 self.info_label = tk.Label( btn_frame, text="", font=('Microsoft YaHei UI', 9), fg='#81c784', bg='#1e1e1e' ) self.info_label.pack(side=tk.LEFT, padx=20) # 确定按钮 self.submit_btn = tk.Button( btn_frame, text="确定 →", font=('Microsoft YaHei UI', 10, 'bold'), bg='#0e639c', fg='white', activebackground='#1177bb', activeforeground='white', relief=tk.FLAT, padx=20, pady=5, cursor='hand2', command=self._on_submit ) self.submit_btn.pack(side=tk.RIGHT) def _on_frame_configure(self, event): """内容框架大小变化""" self.canvas.configure(scrollregion=self.canvas.bbox("all")) def _on_canvas_configure(self, event): """Canvas 大小变化""" self.canvas.itemconfig(self.canvas_window, width=event.width) def _on_mousewheel(self, event): """鼠标滚轮""" self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def set_question(self, question: str, options: List[Dict[str, Any]]): """ 设置当前问题和选项 Args: question: 问题文本 options: 选项列表,每个选项是一个字典 """ # 更新问题 self.question_label.config(text=f"❓ {question}") # 清除旧选项 for widget in self._option_widgets: widget.destroy() self._option_widgets.clear() self._vars.clear() # 创建新选项 for opt_data in options: opt = ClarifyOption( id=opt_data.get('id', ''), type=opt_data.get('type', 'input'), label=opt_data.get('label', ''), choices=opt_data.get('choices', []), default=opt_data.get('default'), placeholder=opt_data.get('placeholder', '') ) self._create_option_widget(opt) def _create_option_widget(self, option: ClarifyOption): """创建选项控件""" # 选项容器 container = tk.Frame(self.options_frame, bg='#252526') container.pack(fill=tk.X, pady=5) self._option_widgets.append(container) # 标签 if option.label: label = tk.Label( container, text=option.label, font=('Microsoft YaHei UI', 10), fg='#cccccc', bg='#252526' ) label.pack(anchor=tk.W, pady=(0, 5)) if option.type == 'radio': self._create_radio_option(container, option) elif option.type == 'checkbox': self._create_checkbox_option(container, option) elif option.type == 'input': self._create_input_option(container, option) def _create_radio_option(self, parent: tk.Widget, option: ClarifyOption): """创建单选按钮""" var = tk.StringVar(value=option.default or (option.choices[0] if option.choices else '')) self._vars[option.id] = var radio_frame = tk.Frame(parent, bg='#252526') radio_frame.pack(fill=tk.X) # 检查是否是位置选项(需要预览) is_position = self._is_position_option(option) if is_position: # 使用网格布局显示位置预览 self._create_position_radio_with_preview(radio_frame, option, var) else: # 普通单选按钮 for choice in option.choices: rb = tk.Radiobutton( radio_frame, text=choice, variable=var, value=choice, font=('Microsoft YaHei UI', 10), fg='#e0e0e0', bg='#252526', activebackground='#252526', activeforeground='#ffffff', selectcolor='#3c3c3c', cursor='hand2' ) rb.pack(anchor=tk.W, pady=2) self._option_widgets.append(rb) def _is_position_option(self, option: ClarifyOption) -> bool: """判断是否是位置选项""" position_keywords = ['position', 'pos', '位置', '方位'] opt_id_lower = option.id.lower() label_lower = option.label.lower() for keyword in position_keywords: if keyword in opt_id_lower or keyword in label_lower: return True # 检查选项是否包含位置相关词汇 position_values = ['左上', '右上', '左下', '右下', '居中', '中心', '顶部', '底部', 'top', 'bottom', 'left', 'right', 'center', 'middle'] for choice in option.choices: choice_lower = choice.lower() for pos in position_values: if pos in choice_lower: return True return False def _create_position_radio_with_preview(self, parent: tk.Widget, option: ClarifyOption, var: tk.StringVar): """创建带位置预览的单选按钮""" container = tk.Frame(parent, bg='#252526') container.pack(fill=tk.X, pady=5) # 左侧:单选按钮列表 radio_list = tk.Frame(container, bg='#252526') radio_list.pack(side=tk.LEFT, fill=tk.Y) for choice in option.choices: rb = tk.Radiobutton( radio_list, text=choice, variable=var, value=choice, font=('Microsoft YaHei UI', 10), fg='#e0e0e0', bg='#252526', activebackground='#252526', activeforeground='#ffffff', selectcolor='#3c3c3c', cursor='hand2', command=lambda: self._update_position_preview(var, preview_canvas) ) rb.pack(anchor=tk.W, pady=2) self._option_widgets.append(rb) # 右侧:位置预览 preview_frame = tk.Frame(container, bg='#3c3c3c', relief=tk.SOLID, borderwidth=1) preview_frame.pack(side=tk.LEFT, padx=(20, 0)) preview_canvas = tk.Canvas( preview_frame, width=120, height=80, bg='#3c3c3c', highlightthickness=0 ) preview_canvas.pack(padx=2, pady=2) self._option_widgets.append(preview_canvas) # 绘制初始预览 self._update_position_preview(var, preview_canvas) # 绑定变量变化 var.trace_add('write', lambda *args: self._update_position_preview(var, preview_canvas)) def _update_position_preview(self, var: tk.StringVar, canvas: tk.Canvas): """更新位置预览""" canvas.delete("all") # 绘制背景矩形(代表图片) canvas.create_rectangle(5, 5, 115, 75, outline='#666666', width=1) # 获取当前选择的位置 position = var.get().lower() # 计算标记位置 positions_map = { # 中文 '左上': (20, 20), '右上': (100, 20), '左下': (20, 60), '右下': (100, 60), '居中': (60, 40), '中心': (60, 40), '顶部居中': (60, 20), '底部居中': (60, 60), '左侧居中': (20, 40), '右侧居中': (100, 40), # 英文 'top-left': (20, 20), 'top-right': (100, 20), 'bottom-left': (20, 60), 'bottom-right': (100, 60), 'center': (60, 40), 'top': (60, 20), 'bottom': (60, 60), 'left': (20, 40), 'right': (100, 40), } # 查找匹配的位置 marker_pos = None for key, pos in positions_map.items(): if key in position: marker_pos = pos break if not marker_pos: # 默认居中 marker_pos = (60, 40) # 绘制位置标记 x, y = marker_pos canvas.create_oval(x-8, y-8, x+8, y+8, fill='#4fc3f7', outline='#29b6f6', width=2) canvas.create_text(x, y, text="W", fill='white', font=('Arial', 8, 'bold')) def _create_checkbox_option(self, parent: tk.Widget, option: ClarifyOption): """创建复选框""" vars_dict = {} self._vars[option.id] = vars_dict checkbox_frame = tk.Frame(parent, bg='#252526') checkbox_frame.pack(fill=tk.X) # 解析默认值 default_values = [] if option.default: if isinstance(option.default, list): default_values = option.default elif isinstance(option.default, str): default_values = [option.default] for choice in option.choices: var = tk.BooleanVar(value=choice in default_values) vars_dict[choice] = var cb = tk.Checkbutton( checkbox_frame, text=choice, variable=var, font=('Microsoft YaHei UI', 10), fg='#e0e0e0', bg='#252526', activebackground='#252526', activeforeground='#ffffff', selectcolor='#3c3c3c', cursor='hand2' ) cb.pack(anchor=tk.W, pady=2) self._option_widgets.append(cb) def _create_input_option(self, parent: tk.Widget, option: ClarifyOption): """创建输入框""" var = tk.StringVar(value=option.default or '') self._vars[option.id] = var input_container = tk.Frame(parent, bg='#252526') input_container.pack(fill=tk.X, pady=2) entry = tk.Entry( input_container, textvariable=var, font=('Microsoft YaHei UI', 10), bg='#3c3c3c', fg='#ffffff', insertbackground='#ffffff', relief=tk.FLAT, width=40 ) entry.pack(side=tk.LEFT, ipady=5) self._option_widgets.append(entry) # 检查是否是颜色输入(通过 id 或 label 判断) is_color = self._is_color_option(option) if is_color: # 添加颜色预览框 preview_frame = tk.Frame(input_container, bg='#252526') preview_frame.pack(side=tk.LEFT, padx=(10, 0)) color_preview = tk.Label( preview_frame, text=" ", bg=option.default or '#000000', width=4, height=1, relief=tk.SOLID, borderwidth=1 ) color_preview.pack(side=tk.LEFT) self._option_widgets.append(color_preview) # 添加颜色选择按钮 color_btn = tk.Button( preview_frame, text="选择", font=('Microsoft YaHei UI', 9), bg='#424242', fg='white', activebackground='#616161', activeforeground='white', relief=tk.FLAT, padx=8, cursor='hand2', command=lambda v=var, p=color_preview: self._pick_color(v, p) ) color_btn.pack(side=tk.LEFT, padx=(5, 0)) self._option_widgets.append(color_btn) # 绑定输入变化事件更新预览 var.trace_add('write', lambda *args, v=var, p=color_preview: self._update_color_preview(v, p)) # 占位符提示 if option.placeholder: placeholder_label = tk.Label( parent, text=f"💡 {option.placeholder}", font=('Microsoft YaHei UI', 9), fg='#666666', bg='#252526' ) placeholder_label.pack(anchor=tk.W) self._option_widgets.append(placeholder_label) def _is_color_option(self, option: ClarifyOption) -> bool: """判断是否是颜色选项""" color_keywords = ['color', 'colour', '颜色', '色彩', 'rgb', 'hex'] # 检查 id opt_id_lower = option.id.lower() for keyword in color_keywords: if keyword in opt_id_lower: return True # 检查 label label_lower = option.label.lower() for keyword in color_keywords: if keyword in label_lower: return True # 检查默认值是否像颜色值 if option.default: default = option.default.strip() if default.startswith('#') and len(default) in [4, 7, 9]: return True # 检查 placeholder if option.placeholder: placeholder_lower = option.placeholder.lower() for keyword in color_keywords: if keyword in placeholder_lower: return True # 检查是否包含颜色格式提示 if '#' in option.placeholder and ('rgb' in placeholder_lower or 'rrggbb' in placeholder_lower): return True return False def _update_color_preview(self, var: tk.StringVar, preview: tk.Label): """更新颜色预览""" color = var.get().strip() # 验证颜色格式 if self._is_valid_color(color): try: preview.config(bg=color) except tk.TclError: pass # 无效颜色,忽略 def _is_valid_color(self, color: str) -> bool: """验证颜色格式是否有效""" if not color: return False # 检查十六进制颜色格式 if color.startswith('#'): hex_part = color[1:] if len(hex_part) in [3, 6, 8]: try: int(hex_part, 16) return True except ValueError: return False # 检查常见颜色名称 common_colors = [ 'red', 'green', 'blue', 'yellow', 'orange', 'purple', 'pink', 'black', 'white', 'gray', 'grey', 'cyan', 'magenta', 'brown' ] if color.lower() in common_colors: return True return False def _pick_color(self, var: tk.StringVar, preview: tk.Label): """打开颜色选择器""" from tkinter import colorchooser # 获取当前颜色作为初始值 current = var.get().strip() initial_color = current if self._is_valid_color(current) else '#000000' # 打开颜色选择对话框 color = colorchooser.askcolor( color=initial_color, title="选择颜色" ) if color[1]: # color[1] 是十六进制颜色值 var.set(color[1].upper()) preview.config(bg=color[1]) def add_history_item(self, question: str, answer: str): """ 添加历史对话项 Args: question: 问题 answer: 用户的回答 """ self._history.append({'question': question, 'answer': answer}) # 创建历史项 UI item_frame = tk.Frame(self.history_frame, bg='#2d2d2d', relief=tk.FLAT) item_frame.pack(fill=tk.X, pady=3) # 问题 q_label = tk.Label( item_frame, text=f"Q: {question}", font=('Microsoft YaHei UI', 9), fg='#888888', bg='#2d2d2d', anchor=tk.W, padx=10, pady=3 ) q_label.pack(fill=tk.X) # 回答 a_label = tk.Label( item_frame, text=f"A: {answer}", font=('Microsoft YaHei UI', 9, 'bold'), fg='#81c784', bg='#2d2d2d', anchor=tk.W, padx=10, pady=3 ) a_label.pack(fill=tk.X) def get_current_answers(self) -> Dict[str, Any]: """获取当前选项的答案""" answers = {} for opt_id, var in self._vars.items(): if isinstance(var, tk.StringVar): answers[opt_id] = var.get() elif isinstance(var, dict): # checkbox 的情况 selected = [k for k, v in var.items() if v.get()] answers[opt_id] = selected return answers def update_info_label(self, collected_count: int, total_count: int): """更新已收集信息提示""" if total_count > 0: self.info_label.config(text=f"已收集 {collected_count}/{total_count} 项信息") else: self.info_label.config(text="") def set_submit_button_text(self, text: str): """设置确定按钮文本""" self.submit_btn.config(text=text) def _on_submit(self): """确定按钮点击""" answers = self.get_current_answers() self.on_submit(answers) def _on_cancel(self): """取消按钮点击""" self.on_cancel() def show_loading(self, text: str = "加载中..."): """显示加载状态""" # 禁用按钮 self.submit_btn.config(state=tk.DISABLED) self.cancel_btn.config(state=tk.DISABLED) # 更新信息标签显示加载状态 self._original_info_text = self.info_label.cget('text') self.info_label.config(text=f"⏳ {text}", fg='#ffa726') def hide_loading(self): """隐藏加载状态""" # 恢复按钮 self.submit_btn.config(state=tk.NORMAL) self.cancel_btn.config(state=tk.NORMAL) # 恢复信息标签 if hasattr(self, '_original_info_text'): self.info_label.config(text=self._original_info_text, fg='#81c784') def show(self): """显示视图""" self.frame.pack(fill=tk.BOTH, expand=True) def hide(self): """隐藏视图""" self.frame.pack_forget() def reset(self): """重置视图""" # 清除历史 self._history.clear() for widget in self.history_frame.winfo_children(): widget.destroy() # 清除选项 for widget in self._option_widgets: widget.destroy() self._option_widgets.clear() self._vars.clear() # 重置标签 self.question_label.config(text="") self.info_label.config(text="") self.submit_btn.config(text="确定 →") def get_frame(self) -> tk.Frame: """获取主框架""" return self.frame