class_name CourseBoardPanel extends Control # ============================================================================ # CourseBoardPanel.gd - Datawhale 课程看板 # ============================================================================ # 展示 Datawhale 学习中心课程列表,由打工区 VirtualWhaleRecruitmentBoard 打开。 # ============================================================================ const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"] const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const API_PATH: String = "/course-resources/datawhale" const PANEL_SIZE: Vector2 = Vector2(1500, 1020) const MIN_MARGIN: Vector2 = Vector2(24, 24) const CARD_SIZE: Vector2 = Vector2(320, 348) const COVER_HEIGHT: float = 171.0 const TEXT_COLOR: Color = Color(0.098, 0.140, 0.190) const MUTED_COLOR: Color = Color(0.380, 0.450, 0.540) const ACCENT_COLOR: Color = Color(0.000, 0.322, 0.851) const SOFT_BLUE: Color = Color(0.925, 0.965, 1.0, 1.0) const IMAGE_PLACEHOLDER: Color = Color(1.0, 0.965, 0.870, 1.0) var _overlay: ColorRect var _panel: Control var _grid: GridContainer var _statusLabel: Label var _request: HTTPRequest var _isOpen: bool = false var _transitionTween: Tween func _ready() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) _buildUi() func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED and is_instance_valid(_panel): _positionPanel() func _input(event: InputEvent) -> void: if not _isOpen: return if event is InputEventKey: var keyEvent := event as InputEventKey if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE: hide_panel() get_viewport().set_input_as_handled() func _process(_delta: float) -> void: if _isOpen: _releaseMovementActions() func show_panel() -> void: if _isOpen: return _isOpen = true visible = true mouse_filter = Control.MOUSE_FILTER_STOP set_process(true) _releaseMovementInputState() _fetchCourses() _animatePanel(true) func hide_panel() -> void: if not _isOpen: return _isOpen = false _releaseMovementInputState() _animatePanel(false) func is_panel_open() -> bool: return _isOpen func _buildUi() -> void: _overlay = ColorRect.new() _overlay.name = "courseBoardDimOverlay" _overlay.set_anchors_preset(Control.PRESET_FULL_RECT) _overlay.color = Color(0.020, 0.036, 0.060, 0.64) _overlay.mouse_filter = Control.MOUSE_FILTER_STOP _overlay.gui_input.connect(_onOverlayGuiInput) add_child(_overlay) _panel = Control.new() _panel.name = "courseBoardPanel" _panel.custom_minimum_size = PANEL_SIZE _panel.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_panel) _positionPanel() var frame := PanelContainer.new() frame.set_anchors_preset(Control.PRESET_FULL_RECT) frame.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.972, 0.988, 1.0, 1.0), 26, true)) frame.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.add_child(frame) var margin := MarginContainer.new() margin.set_anchors_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_left", 34) margin.add_theme_constant_override("margin_top", 28) margin.add_theme_constant_override("margin_right", 34) margin.add_theme_constant_override("margin_bottom", 28) _panel.add_child(margin) var root := VBoxContainer.new() root.add_theme_constant_override("separation", 18) margin.add_child(root) root.add_child(_buildHeader()) root.add_child(_buildGridPanel()) root.add_child(_buildFooter()) _request = HTTPRequest.new() _request.timeout = 18.0 _request.request_completed.connect(_onRequestCompleted) add_child(_request) _updatePanelAlpha(0.0, 0.0) func _buildHeader() -> Control: var header := PanelContainer.new() header.custom_minimum_size = Vector2(0, 106) header.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.000, 0.322, 0.851, 0.96), 24, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 28) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_right", 20) margin.add_theme_constant_override("margin_bottom", 12) header.add_child(margin) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 16) margin.add_child(row) var titleStack := VBoxContainer.new() titleStack.size_flags_horizontal = Control.SIZE_EXPAND_FILL titleStack.add_theme_constant_override("separation", 4) row.add_child(titleStack) var title := Label.new() title.text = "Datawhale 课程看板" title.add_theme_font_size_override("font_size", 32) title.add_theme_color_override("font_color", Color.WHITE) titleStack.add_child(title) var subtitle := Label.new() subtitle.text = "AI学习中心最新课程" subtitle.add_theme_font_size_override("font_size", 18) subtitle.add_theme_color_override("font_color", Color(0.835, 0.925, 1.0, 1.0)) titleStack.add_child(subtitle) var closeButton := Button.new() closeButton.text = "×" closeButton.custom_minimum_size = Vector2(54, 54) closeButton.focus_mode = Control.FOCUS_NONE closeButton.add_theme_font_size_override("font_size", 30) closeButton.add_theme_color_override("font_color", Color.WHITE) closeButton.add_theme_stylebox_override("normal", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.12))) closeButton.add_theme_stylebox_override("hover", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.22))) closeButton.add_theme_stylebox_override("pressed", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.30))) closeButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) closeButton.pressed.connect(hide_panel) row.add_child(closeButton) return header func _buildGridPanel() -> Control: var holder := PanelContainer.new() holder.size_flags_vertical = Control.SIZE_EXPAND_FILL holder.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.948, 0.978, 1.0, 0.92), 20, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 18) margin.add_theme_constant_override("margin_top", 18) margin.add_theme_constant_override("margin_right", 18) margin.add_theme_constant_override("margin_bottom", 18) holder.add_child(margin) var scroll := ScrollContainer.new() scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL margin.add_child(scroll) _grid = GridContainer.new() _grid.columns = 4 _grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL _grid.add_theme_constant_override("h_separation", 24) _grid.add_theme_constant_override("v_separation", 24) scroll.add_child(_grid) return holder func _buildFooter() -> Control: var footer := HBoxContainer.new() footer.custom_minimum_size = Vector2(0, 34) footer.alignment = BoxContainer.ALIGNMENT_CENTER _statusLabel = Label.new() _statusLabel.text = "靠近招募看板按 E 打开,点击课程卡片进入详情" _statusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _statusLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL _statusLabel.add_theme_font_size_override("font_size", 16) _statusLabel.add_theme_color_override("font_color", MUTED_COLOR) footer.add_child(_statusLabel) return footer func _fetchCourses() -> void: _statusLabel.text = "正在同步 Datawhale 课程..." _clearChildren(_grid) var err := _request.request("%s%s" % [NetworkConfig.get_api_base_url(), API_PATH]) if err != OK: _statusLabel.text = "课程加载失败:%s" % error_string(err) func _onRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: _statusLabel.text = "课程加载失败,请稍后再试" return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if not parsed is Dictionary: _statusLabel.text = "课程数据解析失败" return var payload: Dictionary = parsed as Dictionary if not bool(payload.get("success", true)): _statusLabel.text = str(payload.get("message", "课程加载失败")) return var data: Variant = payload.get("data", {}) if not data is Dictionary: _statusLabel.text = "课程数据为空" return var coursesVariant: Variant = (data as Dictionary).get("courses", []) if not coursesVariant is Array: _statusLabel.text = "课程数据为空" return var courses: Array = coursesVariant as Array _renderCourses(courses) _statusLabel.text = "已同步 %d 门课程" % courses.size() func _renderCourses(rows: Array) -> void: _clearChildren(_grid) for row in rows: if row is Dictionary: _grid.add_child(_buildCourseCard(row as Dictionary)) func _buildCourseCard(course: Dictionary) -> Button: var card := Button.new() card.custom_minimum_size = CARD_SIZE card.size_flags_horizontal = Control.SIZE_EXPAND_FILL card.focus_mode = Control.FOCUS_NONE card.clip_contents = true card.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND card.add_theme_stylebox_override("normal", _createCardStyle(false)) card.add_theme_stylebox_override("hover", _createCardStyle(true)) card.add_theme_stylebox_override("pressed", _createCardStyle(true)) card.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) card.pressed.connect(_openCourse.bind(course)) var root := VBoxContainer.new() root.set_anchors_preset(Control.PRESET_FULL_RECT) root.add_theme_constant_override("separation", 0) root.mouse_filter = Control.MOUSE_FILTER_IGNORE card.add_child(root) var coverPanel := Control.new() coverPanel.custom_minimum_size = Vector2(0, COVER_HEIGHT) coverPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL coverPanel.clip_contents = true coverPanel.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(coverPanel) var coverBg := ColorRect.new() coverBg.set_anchors_preset(Control.PRESET_FULL_RECT) coverBg.color = IMAGE_PLACEHOLDER coverBg.mouse_filter = Control.MOUSE_FILTER_IGNORE coverPanel.add_child(coverBg) var coverImage := TextureRect.new() coverImage.set_anchors_preset(Control.PRESET_FULL_RECT) coverImage.expand_mode = TextureRect.EXPAND_IGNORE_SIZE coverImage.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED coverImage.mouse_filter = Control.MOUSE_FILTER_IGNORE coverPanel.add_child(coverImage) var coverText := Label.new() coverText.text = _shortTitle(str(course.get("title", "课程"))) coverText.set_anchors_preset(Control.PRESET_FULL_RECT) coverText.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER coverText.vertical_alignment = VERTICAL_ALIGNMENT_CENTER coverText.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART coverText.add_theme_font_size_override("font_size", 22) coverText.add_theme_color_override("font_color", ACCENT_COLOR) coverText.mouse_filter = Control.MOUSE_FILTER_IGNORE coverPanel.add_child(coverText) _loadCoverImage(str(course.get("coverUrl", "")), coverImage, coverText) var bodyMargin := MarginContainer.new() bodyMargin.size_flags_horizontal = Control.SIZE_EXPAND_FILL bodyMargin.size_flags_vertical = Control.SIZE_EXPAND_FILL bodyMargin.add_theme_constant_override("margin_left", 16) bodyMargin.add_theme_constant_override("margin_top", 16) bodyMargin.add_theme_constant_override("margin_right", 16) bodyMargin.add_theme_constant_override("margin_bottom", 30) bodyMargin.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(bodyMargin) var info := VBoxContainer.new() info.size_flags_horizontal = Control.SIZE_EXPAND_FILL info.size_flags_vertical = Control.SIZE_EXPAND_FILL info.clip_contents = true info.add_theme_constant_override("separation", 6) info.mouse_filter = Control.MOUSE_FILTER_IGNORE bodyMargin.add_child(info) var title := Label.new() title.text = str(course.get("title", "未命名课程")) title.custom_minimum_size = Vector2(0, 46) title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS title.add_theme_font_size_override("font_size", 18) title.add_theme_color_override("font_color", TEXT_COLOR) title.mouse_filter = Control.MOUSE_FILTER_IGNORE info.add_child(title) var intro := Label.new() intro.text = str(course.get("intro", "")) intro.custom_minimum_size = Vector2(0, 58) intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART intro.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS intro.add_theme_font_size_override("font_size", 13) intro.add_theme_color_override("font_color", MUTED_COLOR) intro.mouse_filter = Control.MOUSE_FILTER_IGNORE info.add_child(intro) var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL info.add_child(spacer) var meta := HFlowContainer.new() meta.custom_minimum_size = Vector2(0, 34) meta.size_flags_horizontal = Control.SIZE_EXPAND_FILL meta.add_theme_constant_override("h_separation", 6) meta.add_theme_constant_override("v_separation", 6) meta.mouse_filter = Control.MOUSE_FILTER_IGNORE info.add_child(meta) meta.add_child(_buildTag(str(course.get("difficultyLabel", "其他")), Color(0.918, 0.960, 1.0, 1.0), ACCENT_COLOR, 56.0)) meta.add_child(_buildTag(str(course.get("categoryLabel", "其他")), Color(0.945, 0.950, 0.965, 1.0), MUTED_COLOR, 112.0)) var views := int(course.get("viewCount", 0)) if views > 0: meta.add_child(_buildTag(_formatViews(views), Color(0.950, 0.980, 0.965, 1.0), Color(0.160, 0.510, 0.340), 82.0)) return card func _buildTag(text: String, bgColor: Color, fontColor: Color, maxWidth: float) -> Label: var tag := Label.new() tag.text = text tag.custom_minimum_size = Vector2(maxWidth, 26) tag.clip_text = true tag.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER tag.add_theme_font_size_override("font_size", 12) tag.add_theme_color_override("font_color", fontColor) tag.add_theme_stylebox_override("normal", _createPanelStyle(bgColor, 12, false)) tag.mouse_filter = Control.MOUSE_FILTER_IGNORE return tag func _loadCoverImage(url: String, textureRect: TextureRect, fallbackLabel: Label) -> void: var coverUrl := url.strip_edges() if coverUrl.is_empty(): return var request := HTTPRequest.new() request.timeout = 12.0 request.request_completed.connect(_onCoverRequestCompleted.bind(textureRect, fallbackLabel, request)) add_child(request) var err := request.request(coverUrl) if err != OK: request.queue_free() func _onCoverRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray, textureRect: TextureRect, fallbackLabel: Label, request: HTTPRequest) -> void: if is_instance_valid(request): request.queue_free() if not is_instance_valid(textureRect): return if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: return var image := Image.new() var loadError := image.load_png_from_buffer(body) if loadError != OK: loadError = image.load_jpg_from_buffer(body) if loadError != OK: loadError = image.load_webp_from_buffer(body) if loadError != OK: return textureRect.texture = ImageTexture.create_from_image(image) if is_instance_valid(fallbackLabel): fallbackLabel.visible = false func _openCourse(course: Dictionary) -> void: var detailUrl := str(course.get("detailUrl", "")).strip_edges() if detailUrl.is_empty(): return OS.shell_open(detailUrl) func _onOverlayGuiInput(event: InputEvent) -> void: if event is InputEventMouseButton: var mouseEvent := event as InputEventMouseButton if mouseEvent.pressed and mouseEvent.button_index == MOUSE_BUTTON_LEFT: hide_panel() func _positionPanel() -> void: var viewportSize := get_viewport_rect().size var width := minf(PANEL_SIZE.x, maxf(980.0, viewportSize.x - MIN_MARGIN.x * 2.0)) var height := minf(PANEL_SIZE.y, maxf(760.0, viewportSize.y - MIN_MARGIN.y * 2.0)) _panel.position = (viewportSize - Vector2(width, height)) * 0.5 _panel.size = Vector2(width, height) _panel.pivot_offset = _panel.size * 0.5 func _animatePanel(opening: bool) -> void: if is_instance_valid(_transitionTween): _transitionTween.kill() _transitionTween = create_tween() _transitionTween.set_parallel(true) if opening: _updatePanelAlpha(0.0, 0.0) _panel.scale = Vector2(0.985, 0.985) _transitionTween.tween_property(_overlay, "modulate:a", 1.0, 0.16) _transitionTween.tween_property(_panel, "modulate:a", 1.0, 0.18) _transitionTween.tween_property(_panel, "scale", Vector2.ONE, 0.18) else: _transitionTween.tween_property(_overlay, "modulate:a", 0.0, 0.12) _transitionTween.tween_property(_panel, "modulate:a", 0.0, 0.12) _transitionTween.tween_property(_panel, "scale", Vector2(0.985, 0.985), 0.12) _transitionTween.finished.connect(_onCloseAnimationFinished) func _onCloseAnimationFinished() -> void: if _isOpen: return visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) func _updatePanelAlpha(overlayAlpha: float, panelAlpha: float) -> void: if is_instance_valid(_overlay): _overlay.modulate.a = overlayAlpha if is_instance_valid(_panel): _panel.modulate.a = panelAlpha func _releaseMovementInputState() -> void: Input.flush_buffered_events() _releaseMovementActions() func _releaseMovementActions() -> void: for action in MOVEMENT_ACTIONS: Input.action_release(action) func _clearChildren(node: Node) -> void: for child in node.get_children(): child.queue_free() func _shortTitle(title: String) -> String: var trimmed := title.strip_edges() if trimmed.length() <= 8: return trimmed return trimmed.substr(0, 8) func _formatViews(views: int) -> String: if views >= 10000: var wan := float(views) / 10000.0 return "%.1f万看过" % wan return "%d人看过" % views func _createPanelStyle(color: Color, radius: int, shadow: bool) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color style.corner_radius_top_left = radius style.corner_radius_top_right = radius style.corner_radius_bottom_left = radius style.corner_radius_bottom_right = radius style.content_margin_left = 10 style.content_margin_top = 7 style.content_margin_right = 10 style.content_margin_bottom = 7 if shadow: style.shadow_color = Color(0.030, 0.120, 0.250, 0.18) style.shadow_size = 16 style.shadow_offset = Vector2(0, 5) return style func _createButtonStyle(color: Color) -> StyleBoxFlat: var style := _createPanelStyle(color, 18, false) style.content_margin_left = 0 style.content_margin_top = 0 style.content_margin_right = 0 style.content_margin_bottom = 0 return style func _createCardStyle(hover: bool) -> StyleBoxFlat: var style := _createPanelStyle(Color.WHITE if not hover else Color(0.986, 0.996, 1.0, 1.0), 18, false) style.border_width_left = 2 style.border_width_top = 2 style.border_width_right = 2 style.border_width_bottom = 2 style.border_color = Color(0.815, 0.890, 0.960, 0.92) if not hover else Color(0.000, 0.322, 0.851, 0.72) style.shadow_color = Color(0.040, 0.150, 0.300, 0.08 if not hover else 0.14) style.shadow_size = 4 if not hover else 8 style.shadow_offset = Vector2(0, 3) return style