Files
whale-town-front-v2/scenes/ui/CourseBoardPanel.gd

517 lines
19 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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