Initial WhaleTown V2 frontend

This commit is contained in:
2026-07-19 22:42:20 +08:00
commit 435c578dde
1421 changed files with 54486 additions and 0 deletions

View File

@@ -0,0 +1,516 @@
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