TH1/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideQuestionnaireView.cs

2540 lines
101 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Logic.CrashSight;
using Logic.Multilingual;
using TH1_Core.Events;
using TH1_Logic.Oss;
using TH1_Logic.Questionnaire;
using TH1Resource;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
#if STEAM_CHANNEL || STEAMWORKS_NET
using Steamworks;
#endif
namespace TH1_UI.View.Outside
{
public class UIOutsideQuestionnaireView : Base.View
{
private const string DataAssetPath = "DataAssets/QuestionnaireDataAssets";
private const string QuestionPrefabPath = "Prefab/UI/Outside/UIOutsideQuestionnaireQuestion";
private const string OptionPrefabPath = "Prefab/UI/Outside/UIOutsideQuestionnaireOption";
public static readonly Color InkColor = new Color(0.25490198f, 0.23137257f, 0.41960788f, 1f);
public static readonly Color InkMutedColor = new Color(0.53f, 0.53f, 0.53f, 1f);
public static readonly Color InkLightColor = new Color(0.96f, 0.96f, 0.92f, 1f);
public static readonly Color GoldColor = new Color(0.91372555f, 0.7176471f, 0.3647059f, 1f);
public static readonly Color PanelTint = Color.white;
public static readonly Color TransparentPanelTint = new Color(1f, 1f, 1f, 0f);
public const float ItemButtonPixelsPerUnitMultiplier = 2f;
private const float SidebarWidth = 300f;
private const float OutsidePanelHorizontalInset = 150f;
private const float OutsidePanelTopInset = 52f;
private const float OutsidePanelBottomInset = 58f;
private const float TitleAreaHeight = 82f;
private const float ContentAreaSpacing = 18f;
private const float OutsidePanelPixelsPerUnitMultiplier = 1.3f;
private const float PrimaryButtonPixelsPerUnitMultiplier = 1.3f;
private const float SecondaryButtonPixelsPerUnitMultiplier = 1.2f;
private const string SpriteOutsidePanel = "TH1UI/TechTree/TechCheckPanel";
private const string SpriteCommonWindow = "TH1UI/Common/CommonBG/CommonWindowBG";
private const string SpriteCommonPanel = "ArtResources/TH1UI/Common/CommonPanelBG";
private const string SpriteCommonLabel = "TH1UI/Common/CommonBG/CommonLabelBG";
private const string SpriteCommonSelectLabel = "TH1UI/Common/CommonBG/CommonSelectLabelBG";
private const string SpriteButtonPrimary = "TH1UI/Common/CommonButton/CheckButton1";
private const string SpriteButtonSecondary = "TH1UI/Common/CommonButton/CheckButton2";
private const string SpriteButtonDisabled = "TH1UI/Common/CommonButton/CheckButton_Gray";
private const string SpriteReturnButton = "TH1UI/Common/CommonButton/CommonReturnButton";
private const string SpriteItemButton = "TH1UI/Common/CommonButton/ItemButtonBG";
private const string SpriteItemButtonSelected = "TH1UI/Common/CommonButton/ItemButtonSelectedBG";
private const string SpriteDivider = "TH1UI/Common/CommonDeco/DecoDashedHoriLine";
public Button CloseButton;
public Button SubmitButton;
public Button RefillButton;
public TextMeshProUGUI TitleText;
public TextMeshProUGUI DescriptionText;
public TextMeshProUGUI StatusText;
public TextMeshProUGUI SubmitButtonText;
public TextMeshProUGUI RefillButtonText;
public TextMeshProUGUI CloseButtonText;
public Transform QuestionListContent;
public GameObject QuestionPrefab;
public GameObject OptionPrefab;
public GameObject QuestionnaireItemTemplate;
public GameObject SectionHeaderTemplate;
public GameObject DividerTemplate;
public GameObject InfoStripTemplate;
public GameObject LatestRecordPreviewTemplate;
public GameObject RecordRowTemplate;
public GameObject PrimaryButtonTemplate;
public GameObject SecondaryButtonTemplate;
public ViDelegateAssisstant.Dele OnBtnCloseClick;
private readonly List<UIOutsideQuestionnaireQuestionMono> _questionItems = new List<UIOutsideQuestionnaireQuestionMono>();
private readonly List<QuestionnaireListItem> _listItems = new List<QuestionnaireListItem>();
private QuestionnaireDataAssets _dataAssets;
private QuestionnaireInfo _questionnaireInfo;
private QuestionnaireAnswerSheet _currentSheet;
private bool _isUploading;
private bool _layoutBuilt;
private bool _showExpired;
private bool _templatesExtracted;
private Transform _runtimeTemplateRoot;
private Transform _sidebarContent;
private Transform _rightBody;
private Transform _rightFooter;
private TextMeshProUGUI _expiredToggleText;
private TextMeshProUGUI _rightHeaderTitle;
private TextMeshProUGUI _rightHeaderSubtitle;
private Button _recordsButton;
private Button _startButton;
private TextMeshProUGUI _recordsButtonText;
private TextMeshProUGUI _startButtonText;
private static readonly Dictionary<string, Sprite> SpriteCache = new Dictionary<string, Sprite>();
protected override void OnInit()
{
base.OnInit();
EnsureLayout();
}
#if UNITY_EDITOR
[ContextMenu("Questionnaire/Rebuild Design Templates")]
public void RebuildDesignTemplatesContext()
{
EnsureDesignTemplates(true);
UnityEditor.EditorUtility.SetDirty(this);
}
private void OnValidate()
{
if (Application.isPlaying) return;
UnityEditor.EditorApplication.delayCall -= EnsureDesignTemplatesDelayed;
UnityEditor.EditorApplication.delayCall += EnsureDesignTemplatesDelayed;
}
private void EnsureDesignTemplatesDelayed()
{
if (this == null || Application.isPlaying) return;
EnsureDesignTemplates(false);
}
#endif
public void SetContent(ShowUIOutsideQuestionnaire evt)
{
EnsureLayout();
if (Application.isPlaying)
{
ExtractPrefabTemplates();
}
LoadAssetsIfNeeded();
CloseButton.onClick.RemoveAllListeners();
CloseButton.onClick.AddListener(OnCloseClicked);
SetUploading(false);
var selected = ResolveInitialQuestionnaire(evt);
if (selected != null && selected.GetEffectiveStatus(DateTime.UtcNow) == QuestionnaireStatus.Expired)
{
_showExpired = true;
}
BuildQuestionnaireList();
if (selected == null)
{
ShowEmptyState("暂无可填写问卷");
return;
}
SelectQuestionnaire(selected);
}
public void OnCloseView()
{
}
public static string ResolveText(string raw)
{
if (string.IsNullOrEmpty(raw)) return string.Empty;
if (uint.TryParse(raw, out var id))
{
return MultilingualManager.Instance.GetMultilingualText(id);
}
return raw;
}
public static TextMeshProUGUI CreateText(Transform parent, string objectName, string text, float fontSize,
Color color, TextAlignmentOptions alignment)
{
var go = new GameObject(objectName, typeof(RectTransform), typeof(TextMeshProUGUI));
go.transform.SetParent(parent, false);
var label = go.GetComponent<TextMeshProUGUI>();
label.text = text;
label.fontSize = fontSize;
label.color = color;
label.alignment = alignment;
label.enableWordWrapping = false;
label.raycastTarget = false;
return label;
}
public static Sprite LoadUiSprite(string path)
{
if (string.IsNullOrEmpty(path)) return null;
if (SpriteCache.TryGetValue(path, out var cached)) return cached;
#if UNITY_EDITOR
if (!Application.isPlaying)
{
var editorPath = $"Assets/BundleResources/{path}.png";
var editorSprite = UnityEditor.AssetDatabase.LoadAssetAtPath<Sprite>(editorPath);
if (editorSprite != null)
{
SpriteCache[path] = editorSprite;
return editorSprite;
}
}
#endif
var sprite = ResourceLoader.Load<Sprite>(path);
SpriteCache[path] = sprite;
return sprite;
}
public static void ApplySprite(Image image, string path, Color tint, bool preserveAspect = false,
float pixelsPerUnitMultiplier = 1f)
{
if (image == null) return;
image.sprite = LoadUiSprite(path);
image.color = tint;
image.preserveAspect = preserveAspect;
image.pixelsPerUnitMultiplier = pixelsPerUnitMultiplier;
image.type = image.sprite != null && image.sprite.border != Vector4.zero
? Image.Type.Sliced
: Image.Type.Simple;
}
private void LoadAssetsIfNeeded()
{
if (_dataAssets == null)
{
_dataAssets = ResourceLoader.Load<QuestionnaireDataAssets>(DataAssetPath);
}
if (QuestionPrefab == null)
{
QuestionPrefab = ResourceLoader.Load<GameObject>(QuestionPrefabPath);
}
if (OptionPrefab == null)
{
OptionPrefab = ResourceLoader.Load<GameObject>(OptionPrefabPath);
}
}
private QuestionnaireInfo ResolveInitialQuestionnaire(ShowUIOutsideQuestionnaire evt)
{
if (_dataAssets == null)
{
Debug.LogError("[UIOutsideQuestionnaireView] QuestionnaireDataAssets is missing.");
return null;
}
QuestionnaireInfo selected = null;
if (!string.IsNullOrEmpty(evt.QuestionnaireId))
{
_dataAssets.GetQuestionnaireInfo(evt.QuestionnaireId, out selected);
}
selected ??= _dataAssets.GetDefaultQuestionnaire();
if (selected != null && selected.IsVisibleAt(DateTime.UtcNow)) return selected;
return GetVisibleQuestionnaires().FirstOrDefault();
}
private List<QuestionnaireInfo> GetVisibleQuestionnaires()
{
return _dataAssets != null
? _dataAssets.GetVisibleQuestionnaires(DateTime.UtcNow)
: new List<QuestionnaireInfo>();
}
private void SelectQuestionnaire(QuestionnaireInfo info)
{
if (info == null)
{
ShowEmptyState("暂无可填写问卷");
return;
}
_questionnaireInfo = info;
_currentSheet = QuestionnaireAnswerStore.Instance.GetLatestAnswerSheet(info.QuestionnaireId);
if (info.GetEffectiveStatus(DateTime.UtcNow) == QuestionnaireStatus.Expired && !_showExpired)
{
_showExpired = true;
BuildQuestionnaireList();
}
UpdateListSelection();
ShowDetail();
}
private void BuildQuestionnaireList()
{
if (_sidebarContent == null) return;
DestroyChildren(_sidebarContent);
_listItems.Clear();
var items = GetVisibleQuestionnaires();
var featured = items.Where(item => item.GetEffectiveStatus(DateTime.UtcNow) == QuestionnaireStatus.Featured).ToList();
var longTerm = items.Where(item => item.GetEffectiveStatus(DateTime.UtcNow) == QuestionnaireStatus.LongTerm).ToList();
var expired = items.Where(item => item.GetEffectiveStatus(DateTime.UtcNow) == QuestionnaireStatus.Expired).ToList();
CreateSectionHeader(_sidebarContent, "当期问卷");
CreateQuestionnaireListItems(featured, "暂无当期问卷");
CreateDivider(_sidebarContent);
CreateSectionHeader(_sidebarContent, "长期问卷");
CreateQuestionnaireListItems(longTerm, "暂无长期问卷");
CreateDivider(_sidebarContent);
CreateExpiredHeader(expired.Count);
if (_showExpired)
{
CreateQuestionnaireListItems(expired, "暂无往期问卷");
}
else if (expired.Count > 0)
{
var hint = CreateText(_sidebarContent, "ExpiredHiddenHint", $"{expired.Count} 份往期问卷已收起", 17f,
InkMutedColor, TextAlignmentOptions.Left);
hint.enableWordWrapping = true;
}
if (items.Count == 0)
{
var empty = CreateText(_sidebarContent, "EmptyList", "暂无可显示问卷", 18f,
InkMutedColor, TextAlignmentOptions.Left);
empty.enableWordWrapping = true;
}
}
private void CreateQuestionnaireListItems(List<QuestionnaireInfo> infos, string emptyText)
{
if (infos == null || infos.Count == 0)
{
var empty = CreateText(_sidebarContent, "Empty", emptyText, 17f,
InkMutedColor, TextAlignmentOptions.Left);
empty.enableWordWrapping = true;
return;
}
foreach (var info in infos)
{
CreateQuestionnaireListItem(info);
}
}
private void CreateQuestionnaireListItem(QuestionnaireInfo info)
{
var row = InstantiateTemplate(QuestionnaireItemTemplate, _sidebarContent, "QuestionnaireItem");
if (row == null)
{
row = new GameObject("QuestionnaireItem", typeof(RectTransform), typeof(Image), typeof(Button),
typeof(VerticalLayoutGroup), typeof(LayoutElement));
row.transform.SetParent(_sidebarContent, false);
}
var image = EnsureImage(row.transform, PanelTint, true);
ApplySprite(image, SpriteItemButton, PanelTint, false, ItemButtonPixelsPerUnitMultiplier);
var layout = row.GetComponent<VerticalLayoutGroup>();
if (layout == null)
{
layout = row.AddComponent<VerticalLayoutGroup>();
}
layout.padding = new RectOffset(12, 12, 10, 10);
layout.spacing = 4f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var layoutElement = row.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = row.AddComponent<LayoutElement>();
}
layoutElement.minHeight = 76f;
layoutElement.preferredHeight = 82f;
var button = row.GetComponent<Button>();
if (button == null)
{
button = row.AddComponent<Button>();
}
button.targetGraphic = image;
button.transition = Selectable.Transition.ColorTint;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => SelectQuestionnaire(info));
var header = row.transform.Find("Header");
if (header == null)
{
var headerGo = new GameObject("Header", typeof(RectTransform), typeof(HorizontalLayoutGroup));
headerGo.transform.SetParent(row.transform, false);
header = headerGo.transform;
}
var headerLayout = header.GetComponent<HorizontalLayoutGroup>();
if (headerLayout == null)
{
headerLayout = header.gameObject.AddComponent<HorizontalLayoutGroup>();
}
headerLayout.spacing = 8f;
headerLayout.childControlWidth = true;
headerLayout.childControlHeight = true;
headerLayout.childForceExpandWidth = false;
headerLayout.childForceExpandHeight = false;
headerLayout.childAlignment = TextAnchor.MiddleLeft;
var title = EnsureText(header.transform, "Title", ResolveText(info.Title), 18f,
InkColor, TextAlignmentOptions.Left);
title.enableWordWrapping = true;
title.fontStyle = FontStyles.Bold;
var titleLayout = title.GetComponent<LayoutElement>();
if (titleLayout == null)
{
titleLayout = title.gameObject.AddComponent<LayoutElement>();
}
titleLayout.flexibleWidth = 1f;
var badge = EnsureText(header.transform, "Badge", GetStatusText(info), 15f,
GetStatusColor(info), TextAlignmentOptions.Right);
var badgeLayout = badge.GetComponent<LayoutElement>();
if (badgeLayout == null)
{
badgeLayout = badge.gameObject.AddComponent<LayoutElement>();
}
badgeLayout.preferredWidth = 56f;
var meta = EnsureText(row.transform, "Meta", BuildListMetaText(info), 15f,
InkMutedColor, TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
_listItems.Add(new QuestionnaireListItem
{
Info = info,
Background = image,
TitleText = title,
BadgeText = badge,
MetaText = meta
});
}
private void ShowDetail()
{
if (_questionnaireInfo == null)
{
ShowEmptyState("暂无可填写问卷");
return;
}
ClearRight();
var sheets = QuestionnaireAnswerStore.Instance.GetAnswerSheets(_questionnaireInfo.QuestionnaireId);
_currentSheet = sheets.FirstOrDefault();
SetRightHeader(ResolveText(_questionnaireInfo.Title), GetStatusText(_questionnaireInfo));
TitleText = CreateText(_rightBody, "DetailTitle", ResolveText(_questionnaireInfo.Title), 34f,
InkColor, TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
TitleText.enableWordWrapping = true;
DescriptionText = CreateText(_rightBody, "Description", ResolveText(_questionnaireInfo.Description), 20f,
InkColor, TextAlignmentOptions.Left);
DescriptionText.enableWordWrapping = true;
CreateInfoStrip(_rightBody, BuildDetailMetaText(_questionnaireInfo, sheets));
var latestMessage = ResolveSubmittedMessage(_currentSheet);
if (!string.IsNullOrEmpty(latestMessage))
{
StatusText = CreateText(_rightBody, "LatestStatus", latestMessage, 18f,
GoldColor, TextAlignmentOptions.Left);
StatusText.enableWordWrapping = true;
}
if (_currentSheet != null)
{
CreateLatestRecordPreview(_currentSheet);
}
var canStart = CanStartNewSubmission(_questionnaireInfo, sheets, out var blockReason);
if (!canStart && !string.IsNullOrEmpty(blockReason))
{
CreateInfoStrip(_rightBody, blockReason, GoldColor);
}
if (sheets.Count > 0 && _questionnaireInfo.CanViewHistory)
{
_recordsButton = CreateButton(_rightFooter, "RecordsButton", $"查看记录 ({sheets.Count})", false,
out _recordsButtonText, 170f);
_recordsButton.onClick.AddListener(ShowRecords);
}
var startText = sheets.Count > 0
? ResolveText(_questionnaireInfo.ResubmitButtonText)
: ResolveText(_questionnaireInfo.StartButtonText);
if (string.IsNullOrEmpty(startText))
{
startText = ResolveText(_questionnaireInfo.StartButtonText);
}
_startButton = CreateButton(_rightFooter, "StartButton", startText, true, out _startButtonText, 160f);
_startButton.interactable = canStart;
ApplyQuestionnaireButtonState(_startButton, true);
_startButton.onClick.AddListener(ShowForm);
}
private void ShowForm()
{
if (_questionnaireInfo == null) return;
var sheets = QuestionnaireAnswerStore.Instance.GetAnswerSheets(_questionnaireInfo.QuestionnaireId);
if (!CanStartNewSubmission(_questionnaireInfo, sheets, out var blockReason))
{
ShowDetail();
SetStatus(blockReason);
return;
}
ClearRight();
SetRightHeader("填写问卷", ResolveText(_questionnaireInfo.Title));
TitleText = CreateText(_rightBody, "FormTitle", ResolveText(_questionnaireInfo.Title), 30f,
InkColor, TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
TitleText.enableWordWrapping = true;
var hint = CreateText(_rightBody, "FormHint", "提交后会保留为一条新的填写记录,可在历史中只读查看。", 18f,
InkMutedColor, TextAlignmentOptions.Left);
hint.enableWordWrapping = true;
CreateQuestionListRoot();
BuildQuestions(_questionnaireInfo.Questions, false, null);
StatusText = CreateText(_rightFooter, "Status", "", 17f,
GoldColor, TextAlignmentOptions.Left);
StatusText.enableWordWrapping = true;
StatusText.gameObject.AddComponent<LayoutElement>().flexibleWidth = 1f;
var backButton = CreateButton(_rightFooter, "BackButton", "返回", false, out _, 120f);
backButton.onClick.AddListener(ShowDetail);
RefillButton = CreateButton(_rightFooter, "RefillButton", "清空", false, out RefillButtonText, 120f);
RefillButton.onClick.AddListener(OnRefillClicked);
var submitText = ResolveText(_questionnaireInfo.SubmitButtonText);
if (string.IsNullOrEmpty(submitText)) submitText = "提交";
SubmitButton = CreateButton(_rightFooter, "SubmitButton", submitText, true, out SubmitButtonText, 140f);
SubmitButton.onClick.AddListener(OnSubmitClicked);
SetUploading(false);
SetStatus(string.Empty);
}
private void ShowRecords()
{
if (_questionnaireInfo == null) return;
ClearRight();
SetRightHeader("填写记录", ResolveText(_questionnaireInfo.Title));
var sheets = QuestionnaireAnswerStore.Instance.GetAnswerSheets(_questionnaireInfo.QuestionnaireId);
TitleText = CreateText(_rightBody, "RecordsTitle", "填写记录", 31f,
InkColor, TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
if (sheets.Count == 0)
{
var empty = CreateText(_rightBody, "EmptyRecords", "还没有填写记录。", 20f,
InkMutedColor, TextAlignmentOptions.Left);
empty.enableWordWrapping = true;
}
else
{
for (var i = 0; i < sheets.Count; i++)
{
CreateRecordRow(sheets[i], i + 1);
}
}
var backButton = CreateButton(_rightFooter, "BackButton", "返回", false, out _, 120f);
backButton.onClick.AddListener(ShowDetail);
}
private void ShowReadonlyRecord(QuestionnaireAnswerSheet sheet)
{
if (sheet == null) return;
ClearRight();
SetRightHeader("查看记录", ResolveText(_questionnaireInfo?.Title));
var snapshotTitle = sheet.DefinitionSnapshot != null && !string.IsNullOrEmpty(sheet.DefinitionSnapshot.Title)
? sheet.DefinitionSnapshot.Title
: ResolveText(_questionnaireInfo?.Title);
TitleText = CreateText(_rightBody, "RecordTitle", snapshotTitle, 30f,
InkColor, TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
TitleText.enableWordWrapping = true;
CreateInfoStrip(_rightBody, $"{FormatSubmittedAt(sheet)} · {GetUploadStateText(sheet)} · 只读记录");
CreateQuestionListRoot();
var questions = BuildQuestionsFromSnapshot(sheet.DefinitionSnapshot);
if (questions == null || questions.Count == 0)
{
questions = _questionnaireInfo?.Questions;
}
BuildQuestions(questions, true, sheet);
var backButton = CreateButton(_rightFooter, "BackButton", "返回记录", false, out _, 140f);
backButton.onClick.AddListener(ShowRecords);
}
private void CreateLatestRecordPreview(QuestionnaireAnswerSheet sheet)
{
var preview = InstantiateTemplate(LatestRecordPreviewTemplate, _rightBody, "LatestRecordPreview") ??
CreatePanel(_rightBody, "LatestRecordPreview", PanelTint);
var image = EnsureImage(preview.transform, PanelTint, true);
ApplySprite(image, SpriteCommonPanel, PanelTint);
var layout = preview.GetComponent<VerticalLayoutGroup>();
if (layout == null)
{
layout = preview.AddComponent<VerticalLayoutGroup>();
}
layout.padding = new RectOffset(16, 16, 14, 14);
layout.spacing = 6f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var title = EnsureText(preview.transform, "Title", "最近一次填写", 20f,
InkColor, TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var meta = EnsureText(preview.transform, "Meta", $"{FormatSubmittedAt(sheet)} · {GetUploadStateText(sheet)}", 17f,
InkMutedColor, TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
}
private void CreateRecordRow(QuestionnaireAnswerSheet sheet, int index)
{
var row = InstantiateTemplate(RecordRowTemplate, _rightBody, "RecordRow");
if (row == null)
{
row = new GameObject("RecordRow", typeof(RectTransform), typeof(Image), typeof(HorizontalLayoutGroup),
typeof(LayoutElement));
row.transform.SetParent(_rightBody, false);
}
var image = EnsureImage(row.transform, PanelTint, true);
ApplySprite(image, SpriteCommonLabel, PanelTint);
var layout = row.GetComponent<HorizontalLayoutGroup>();
if (layout == null)
{
layout = row.AddComponent<HorizontalLayoutGroup>();
}
layout.padding = new RectOffset(16, 14, 12, 12);
layout.spacing = 14f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
layout.childAlignment = TextAnchor.MiddleLeft;
var layoutElement = row.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = row.AddComponent<LayoutElement>();
}
layoutElement.minHeight = 76f;
var textRoot = row.transform.Find("TextRoot");
if (textRoot == null)
{
var textRootGo = new GameObject("TextRoot", typeof(RectTransform), typeof(VerticalLayoutGroup),
typeof(LayoutElement));
textRootGo.transform.SetParent(row.transform, false);
textRoot = textRootGo.transform;
}
var textRootLayoutElement = textRoot.GetComponent<LayoutElement>();
if (textRootLayoutElement == null)
{
textRootLayoutElement = textRoot.gameObject.AddComponent<LayoutElement>();
}
textRootLayoutElement.flexibleWidth = 1f;
var textLayout = textRoot.GetComponent<VerticalLayoutGroup>();
if (textLayout == null)
{
textLayout = textRoot.gameObject.AddComponent<VerticalLayoutGroup>();
}
textLayout.spacing = 3f;
textLayout.childControlWidth = true;
textLayout.childControlHeight = true;
textLayout.childForceExpandWidth = true;
textLayout.childForceExpandHeight = false;
var title = EnsureText(textRoot.transform, "Title", $"记录 {index}", 20f,
InkColor, TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var meta = EnsureText(textRoot.transform, "Meta", $"{FormatSubmittedAt(sheet)} · {GetUploadStateText(sheet)}", 17f,
InkMutedColor, TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
var viewButtonTransform = row.transform.Find("ViewButton");
var viewButton = viewButtonTransform != null
? ConfigureButton(viewButtonTransform.gameObject, "查看", false, out _, 104f)
: CreateButton(row.transform, "ViewButton", "查看", false, out _, 104f);
viewButton.onClick.AddListener(() => ShowReadonlyRecord(sheet));
}
private void BuildQuestions(IList<QuestionnaireQuestionInfo> questions, bool readOnly, QuestionnaireAnswerSheet sheet)
{
if (QuestionListContent == null) return;
DestroyChildren(QuestionListContent);
_questionItems.Clear();
if (questions == null) return;
Dictionary<string, QuestionnaireAnswer> answerDict = null;
if (sheet?.Answers != null)
{
answerDict = sheet.Answers
.Where(answer => answer != null && !string.IsNullOrEmpty(answer.QuestionId))
.GroupBy(answer => answer.QuestionId)
.ToDictionary(group => group.Key, group => group.First());
}
for (var i = 0; i < questions.Count; i++)
{
var question = questions[i];
if (question == null) continue;
var item = CreateQuestionItem();
item.SetContent(i + 1, question, OptionPrefab, readOnly ? null : OnAnswerChanged);
if (answerDict != null && !string.IsNullOrEmpty(question.QuestionId) &&
answerDict.TryGetValue(question.QuestionId, out var answer))
{
item.ApplyAnswer(answer);
}
item.SetReadOnly(readOnly);
_questionItems.Add(item);
}
}
private UIOutsideQuestionnaireQuestionMono CreateQuestionItem()
{
GameObject go;
if (QuestionPrefab != null)
{
go = Instantiate(QuestionPrefab, QuestionListContent);
}
else
{
go = new GameObject("Question", typeof(RectTransform));
go.transform.SetParent(QuestionListContent, false);
}
var item = go.GetComponent<UIOutsideQuestionnaireQuestionMono>();
if (item == null)
{
item = go.AddComponent<UIOutsideQuestionnaireQuestionMono>();
}
return item;
}
private void OnSubmitClicked()
{
if (_isUploading) return;
_ = SubmitQuestionnaireAsync();
}
private async System.Threading.Tasks.Task SubmitQuestionnaireAsync()
{
if (_questionnaireInfo == null) return;
var sheets = QuestionnaireAnswerStore.Instance.GetAnswerSheets(_questionnaireInfo.QuestionnaireId);
if (!CanStartNewSubmission(_questionnaireInfo, sheets, out var blockReason))
{
SetStatus(blockReason);
return;
}
if (!ValidateRequiredQuestions())
{
SetStatus(ResolveText(_questionnaireInfo.RequiredMessage));
return;
}
var submitted = false;
SetUploading(true);
try
{
var sheet = new QuestionnaireAnswerSheet
{
QuestionnaireId = _questionnaireInfo.QuestionnaireId,
QuestionnaireRevision = _questionnaireInfo.Revision,
ClientVersion = QuestionnaireUploadService.GetCurrentVersion(),
DefinitionSnapshot = CreateDefinitionSnapshot(_questionnaireInfo),
Answers = new List<QuestionnaireAnswer>()
};
foreach (var item in _questionItems)
{
sheet.Answers.Add(item.CreateAnswer());
}
if (!QuestionnaireAnswerStore.Instance.SaveAnswerSheet(sheet))
{
SetStatus(ResolveText(_questionnaireInfo.SaveFailedMessage));
return;
}
submitted = true;
_currentSheet = sheet;
SetStatus(ResolveQuestionnaireMessage(_questionnaireInfo.UploadingMessage,
_questionnaireInfo.SubmittedMessage));
if (!TryGetSteamId(out var steamId))
{
QuestionnaireAnswerStore.Instance.SaveUploadResultByResponseId(sheet.ResponseId, false, null,
"Steam login unavailable");
SetStatus(ResolveQuestionnaireMessage(_questionnaireInfo.UploadAuthFailedMessage,
_questionnaireInfo.SubmittedMessage));
return;
}
await UploadSavedSheetAsync(steamId, sheet);
}
catch (Exception ex)
{
LogSystem.LogError($"Questionnaire submit exception: {ex}");
SetStatus(ResolveQuestionnaireMessage(_questionnaireInfo.UploadFailedMessage,
_questionnaireInfo.SubmittedMessage));
}
finally
{
SetUploading(false);
if (submitted)
{
BuildQuestionnaireList();
SelectQuestionnaire(_questionnaireInfo);
}
}
}
private async System.Threading.Tasks.Task UploadSavedSheetAsync(string steamId, QuestionnaireAnswerSheet sheet)
{
try
{
var version = QuestionnaireUploadService.GetCurrentVersion();
var bytes = QuestionnaireUploadService.BuildPayloadBytes(steamId, sheet, version);
if (bytes.Length > QuestionnaireUploadService.MaxQuestionnaireUploadBytes)
{
LogSystem.LogError(
$"Questionnaire upload failed: package size {bytes.Length} exceeds {QuestionnaireUploadService.MaxQuestionnaireUploadBytes}");
QuestionnaireAnswerStore.Instance.SaveUploadResultByResponseId(sheet.ResponseId, false, null,
"Questionnaire payload too large");
SetStatus(ResolveQuestionnaireMessage(_questionnaireInfo.UploadFailedMessage,
_questionnaireInfo.SubmittedMessage));
return;
}
var result = await OssManager.Instance.UploadQuestionnaireAnswerAsync(steamId, bytes, version);
QuestionnaireAnswerStore.Instance.SaveUploadResultByResponseId(sheet.ResponseId, result.success,
result.objectKey, result.success ? string.Empty : "OSS upload failed");
SetStatus(result.success
? ResolveQuestionnaireMessage(_questionnaireInfo.UploadSuccessMessage, _questionnaireInfo.SubmittedMessage)
: ResolveQuestionnaireMessage(_questionnaireInfo.UploadFailedMessage, _questionnaireInfo.SubmittedMessage));
}
catch (Exception ex)
{
LogSystem.LogError($"Questionnaire submit exception: {ex}");
QuestionnaireAnswerStore.Instance.SaveUploadResultByResponseId(sheet.ResponseId, false, null, ex.Message);
SetStatus(ResolveQuestionnaireMessage(_questionnaireInfo.UploadFailedMessage,
_questionnaireInfo.SubmittedMessage));
}
}
private bool ValidateRequiredQuestions()
{
foreach (var item in _questionItems)
{
if (item.QuestionInfo != null && item.QuestionInfo.Required && !item.HasAnswer())
{
return false;
}
}
return true;
}
private void OnRefillClicked()
{
foreach (var item in _questionItems)
{
item.ClearAnswer();
}
SetStatus(ResolveText(_questionnaireInfo?.RefillHintText));
}
private void OnAnswerChanged()
{
if (!string.IsNullOrEmpty(StatusText?.text))
{
SetStatus(string.Empty);
}
}
private void OnCloseClicked()
{
OnBtnCloseClick?.Invoke();
}
private void SetStatus(string text)
{
if (StatusText == null) return;
StatusText.text = text ?? string.Empty;
StatusText.gameObject.SetActive(!string.IsNullOrEmpty(StatusText.text));
}
private void SetUploading(bool uploading)
{
_isUploading = uploading;
if (SubmitButton != null) SubmitButton.interactable = !uploading;
if (RefillButton != null) RefillButton.interactable = !uploading;
if (CloseButton != null) CloseButton.interactable = !uploading;
if (_recordsButton != null) _recordsButton.interactable = !uploading;
ApplyQuestionnaireButtonState(SubmitButton, true);
ApplyQuestionnaireButtonState(RefillButton, false);
ApplyQuestionnaireButtonState(_recordsButton, false);
ApplyQuestionnaireButtonState(_startButton, true);
}
private void ApplyQuestionnaireButtonState(Button button, bool primary)
{
if (button == null) return;
var image = button.targetGraphic as Image;
if (image == null) image = button.GetComponent<Image>();
if (image == null) return;
ApplySprite(image, button.interactable ? (primary ? SpriteButtonPrimary : SpriteButtonSecondary) : SpriteButtonDisabled,
PanelTint, false, GetActionButtonPixelsPerUnitMultiplier(primary));
}
private static bool CanStartNewSubmission(QuestionnaireInfo info, List<QuestionnaireAnswerSheet> sheets,
out string reason)
{
reason = string.Empty;
if (info == null)
{
reason = "问卷不存在";
return false;
}
var now = DateTime.UtcNow;
if (!info.CanSubmitAt(now))
{
reason = info.GetEffectiveStatus(now) == QuestionnaireStatus.Expired
? "这份问卷已经结束,只能查看历史记录。"
: "这份问卷当前不可填写。";
return false;
}
var submittedCount = sheets?.Count ?? 0;
if (submittedCount > 0 && !info.AllowMultipleSubmissions)
{
reason = "这份问卷已经填写过。";
return false;
}
if (info.MaxSubmissionCount > 0 && submittedCount >= info.MaxSubmissionCount)
{
reason = "这份问卷已经达到填写次数上限。";
return false;
}
if (info.MinSubmitIntervalHours > 0 && sheets != null && sheets.Count > 0)
{
var latestUnix = sheets[0].SubmittedAtUnix;
if (latestUnix > 0)
{
var nextAt = DateTimeOffset.FromUnixTimeSeconds(latestUnix).UtcDateTime
.AddHours(info.MinSubmitIntervalHours);
if (now < nextAt)
{
reason = $"请在 {nextAt.ToLocalTime():yyyy-MM-dd HH:mm} 后再次填写。";
return false;
}
}
}
return true;
}
private static string ResolveQuestionnaireMessage(string message, string fallback)
{
var resolved = ResolveText(message);
return string.IsNullOrEmpty(resolved) ? ResolveText(fallback) : resolved;
}
private string ResolveSubmittedMessage(QuestionnaireAnswerSheet sheet)
{
if (sheet == null || _questionnaireInfo == null) return string.Empty;
if (!string.IsNullOrEmpty(sheet.LastUploadObjectKey))
return ResolveQuestionnaireMessage(_questionnaireInfo.UploadSuccessMessage,
_questionnaireInfo.SubmittedMessage);
if (!string.IsNullOrEmpty(sheet.LastUploadError))
return ResolveQuestionnaireMessage(_questionnaireInfo.UploadFailedMessage,
_questionnaireInfo.SubmittedMessage);
return ResolveText(_questionnaireInfo.SubmittedMessage);
}
private static bool TryGetSteamId(out string steamId)
{
steamId = string.Empty;
#if STEAM_CHANNEL || STEAMWORKS_NET
try
{
if (!SteamUser.BLoggedOn())
return false;
var id = SteamUser.GetSteamID().m_SteamID;
if (id == 0)
return false;
steamId = id.ToString();
return true;
}
catch (Exception ex)
{
LogSystem.LogWarning($"Questionnaire SteamID unavailable: {ex.Message}");
return false;
}
#else
return false;
#endif
}
private static QuestionnaireDefinitionSnapshot CreateDefinitionSnapshot(QuestionnaireInfo info)
{
if (info == null) return null;
var snapshot = new QuestionnaireDefinitionSnapshot
{
QuestionnaireId = info.QuestionnaireId,
Revision = info.Revision,
Title = ResolveText(info.Title),
Description = ResolveText(info.Description),
Questions = new List<QuestionnaireQuestionSnapshot>()
};
if (info.Questions == null) return snapshot;
foreach (var question in info.Questions)
{
if (question == null) continue;
var questionSnapshot = new QuestionnaireQuestionSnapshot
{
QuestionId = question.QuestionId,
QuestionType = question.QuestionType,
Title = ResolveText(question.Title),
Hint = ResolveText(question.Hint),
Required = question.Required,
Options = new List<QuestionnaireOptionSnapshot>()
};
if (question.Options != null)
{
foreach (var option in question.Options)
{
if (option == null) continue;
questionSnapshot.Options.Add(new QuestionnaireOptionSnapshot
{
OptionId = option.OptionId,
Text = ResolveText(option.Text)
});
}
}
snapshot.Questions.Add(questionSnapshot);
}
return snapshot;
}
private static List<QuestionnaireQuestionInfo> BuildQuestionsFromSnapshot(QuestionnaireDefinitionSnapshot snapshot)
{
if (snapshot?.Questions == null || snapshot.Questions.Count == 0) return null;
var result = new List<QuestionnaireQuestionInfo>();
foreach (var question in snapshot.Questions)
{
if (question == null) continue;
var info = new QuestionnaireQuestionInfo
{
QuestionId = question.QuestionId,
QuestionType = question.QuestionType,
Title = question.Title,
Hint = question.Hint,
Required = question.Required,
Options = new List<QuestionnaireOptionInfo>()
};
if (question.Options != null)
{
foreach (var option in question.Options)
{
if (option == null) continue;
info.Options.Add(new QuestionnaireOptionInfo
{
OptionId = option.OptionId,
Text = option.Text
});
}
}
result.Add(info);
}
return result;
}
private void EnsureLayout()
{
var rootRect = GetComponent<RectTransform>();
rootRect.anchorMin = Vector2.zero;
rootRect.anchorMax = Vector2.one;
rootRect.offsetMin = Vector2.zero;
rootRect.offsetMax = Vector2.zero;
var canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup == null)
{
gameObject.AddComponent<CanvasGroup>();
}
var background = GetComponent<Image>();
if (background == null)
{
background = gameObject.AddComponent<Image>();
}
background.color = new Color(0f, 0f, 0f, 0.5882353f);
if (_layoutBuilt) return;
_layoutBuilt = true;
if (TryBindPrefabLayout())
{
return;
}
CreateMainLayout();
}
private bool TryBindPrefabLayout()
{
var center = transform.Find("QuestionnaireCenter");
var sidebar = FindPanelChild(center, "Sidebar");
var right = FindPanelChild(center, "RightPanel");
var close = transform.Find("CloseButton") ??
(center != null ? center.Find("TitleArea/CloseButton") : null) ??
FindDeepChild(center, "CloseButton");
if (center == null || sidebar == null || right == null)
{
return false;
}
close = EnsureOutsidePanelStructure(center, sidebar, right, close);
var sidebarScroll = sidebar != null ? sidebar.Find("SidebarScroll") : null;
var sidebarContent = sidebarScroll != null ? sidebarScroll.Find("Viewport/Content") : null;
var rightHeader = right != null ? right.Find("Header") : null;
var bodyScroll = right != null ? right.Find("BodyScroll") : null;
var bodyContent = bodyScroll != null ? bodyScroll.Find("Viewport/Content") : null;
var footer = right != null ? right.Find("Footer") : null;
if (close == null || center == null || sidebarContent == null || rightHeader == null ||
bodyContent == null || footer == null)
{
return false;
}
ConfigureCloseButton(close);
_sidebarContent = sidebarContent;
SetLegacySidebarHeaderVisible(sidebar, false);
_rightBody = bodyContent;
_rightFooter = footer;
_rightHeaderTitle = EnsureText(rightHeader, "Title", "", 22f,
InkColor, TextAlignmentOptions.Left);
_rightHeaderTitle.fontStyle = FontStyles.Bold;
_rightHeaderSubtitle = EnsureText(rightHeader, "Subtitle", "", 16f,
InkMutedColor, TextAlignmentOptions.Left);
_rightHeaderSubtitle.enableWordWrapping = true;
ConfigureBoundPrefabLayout(center, sidebar, sidebarScroll, sidebarContent, right, rightHeader, bodyScroll,
bodyContent, footer);
if (Application.isPlaying)
{
ExtractPrefabTemplates();
}
return CloseButton != null && _rightHeaderTitle != null && _rightHeaderSubtitle != null;
}
private void ExtractPrefabTemplates()
{
if (_templatesExtracted) return;
_templatesExtracted = true;
_runtimeTemplateRoot = transform.Find("RuntimeTemplates");
if (_runtimeTemplateRoot == null)
{
var templateRoot = new GameObject("RuntimeTemplates", typeof(RectTransform));
templateRoot.transform.SetParent(transform, false);
_runtimeTemplateRoot = templateRoot.transform;
}
ExtractTemplate(ref QuestionnaireItemTemplate, _sidebarContent, "QuestionnaireItemTemplate");
ExtractTemplate(ref SectionHeaderTemplate, _sidebarContent, "SectionHeaderTemplate");
ExtractTemplate(ref DividerTemplate, _sidebarContent, "DividerTemplate");
ExtractTemplate(ref InfoStripTemplate, _rightBody, "InfoStripTemplate");
ExtractTemplate(ref LatestRecordPreviewTemplate, _rightBody, "LatestRecordPreviewTemplate");
ExtractTemplate(ref RecordRowTemplate, _rightBody, "RecordRowTemplate");
ExtractTemplate(ref PrimaryButtonTemplate, _rightFooter, "PrimaryButtonTemplate");
ExtractTemplate(ref SecondaryButtonTemplate, _rightFooter, "SecondaryButtonTemplate");
_runtimeTemplateRoot.gameObject.SetActive(false);
}
private void ExtractTemplate(ref GameObject template, Transform preferredParent, string objectName)
{
var templateTransform = template != null
? template.transform
: preferredParent != null
? preferredParent.Find(objectName)
: null;
templateTransform ??= FindDeepChild(transform, objectName);
if (templateTransform == null) return;
template = templateTransform.gameObject;
template.SetActive(false);
templateTransform.SetParent(_runtimeTemplateRoot, false);
}
private GameObject InstantiateTemplate(GameObject template, Transform parent, string objectName)
{
if (template == null || parent == null) return null;
var go = Instantiate(template, parent, false);
go.name = objectName;
go.SetActive(true);
return go;
}
private static Image EnsureImage(Transform target, Color color, bool raycastTarget)
{
var image = target.GetComponent<Image>();
if (image == null)
{
image = target.gameObject.AddComponent<Image>();
}
image.color = color;
image.raycastTarget = raycastTarget;
return image;
}
private static TextMeshProUGUI EnsureText(Transform parent, string objectName, string text, float fontSize,
Color color, TextAlignmentOptions alignment)
{
var child = parent.Find(objectName) ?? FindDeepChild(parent, objectName);
TextMeshProUGUI label;
if (child == null)
{
label = CreateText(parent, objectName, text, fontSize, color, alignment);
}
else
{
label = child.GetComponent<TextMeshProUGUI>();
if (label == null)
{
label = child.gameObject.AddComponent<TextMeshProUGUI>();
}
}
label.text = text;
label.fontSize = fontSize;
label.color = color;
label.alignment = alignment;
return label;
}
private static Transform FindDeepChild(Transform parent, string objectName)
{
if (parent == null || string.IsNullOrEmpty(objectName)) return null;
for (var i = 0; i < parent.childCount; i++)
{
var child = parent.GetChild(i);
if (child.name == objectName) return child;
var result = FindDeepChild(child, objectName);
if (result != null) return result;
}
return null;
}
private static Transform FindPanelChild(Transform parent, string objectName)
{
if (parent == null) return null;
return parent.Find($"ContentArea/{objectName}") ?? parent.Find(objectName) ?? FindDeepChild(parent, objectName);
}
private static Transform EnsureOutsidePanelStructure(Transform center, Transform sidebar, Transform right,
Transform close)
{
ConfigureOutsideRoot(center);
HideLegacyTopStripe(center);
var titleArea = EnsureTitleArea(center);
var contentArea = EnsureContentArea(center);
MoveIntoContentArea(sidebar, contentArea, 0);
MoveIntoContentArea(right, contentArea, 1);
close ??= EnsureCloseButton(titleArea);
close.SetParent(titleArea, false);
close.SetSiblingIndex(0);
EnsurePanelTitle(titleArea);
titleArea.SetSiblingIndex(0);
contentArea.SetSiblingIndex(1);
return close;
}
private static void ConfigureOutsideRoot(Transform center)
{
if (center is RectTransform rect)
{
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = new Vector2(OutsidePanelHorizontalInset, OutsidePanelBottomInset);
rect.offsetMax = new Vector2(-OutsidePanelHorizontalInset, -OutsidePanelTopInset);
rect.pivot = new Vector2(0.5f, 0.5f);
}
var image = EnsureImage(center, PanelTint, true);
ApplySprite(image, SpriteOutsidePanel, PanelTint, true, OutsidePanelPixelsPerUnitMultiplier);
image.raycastTarget = true;
var horizontal = center.GetComponent<HorizontalLayoutGroup>();
if (horizontal != null)
{
horizontal.enabled = false;
}
var layout = center.GetComponent<VerticalLayoutGroup>();
if (layout == null)
{
layout = center.gameObject.AddComponent<VerticalLayoutGroup>();
}
layout.padding = new RectOffset(36, 36, 18, 28);
layout.spacing = ContentAreaSpacing;
layout.childAlignment = TextAnchor.UpperLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
}
private static Transform EnsureTitleArea(Transform center)
{
var titleArea = center.Find("TitleArea");
if (titleArea == null)
{
var go = new GameObject("TitleArea", typeof(RectTransform), typeof(HorizontalLayoutGroup),
typeof(LayoutElement));
go.transform.SetParent(center, false);
titleArea = go.transform;
}
var image = titleArea.GetComponent<Image>();
if (image != null)
{
image.sprite = null;
image.color = TransparentPanelTint;
image.raycastTarget = false;
}
var layout = titleArea.GetComponent<HorizontalLayoutGroup>();
if (layout == null)
{
layout = titleArea.gameObject.AddComponent<HorizontalLayoutGroup>();
}
layout.padding = new RectOffset(0, 0, 0, 0);
layout.spacing = 14f;
layout.childAlignment = TextAnchor.MiddleLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
var layoutElement = titleArea.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = titleArea.gameObject.AddComponent<LayoutElement>();
}
layoutElement.minHeight = TitleAreaHeight;
layoutElement.preferredHeight = TitleAreaHeight;
return titleArea;
}
private static Transform EnsureContentArea(Transform center)
{
var contentArea = center.Find("ContentArea");
if (contentArea == null)
{
var go = new GameObject("ContentArea", typeof(RectTransform), typeof(HorizontalLayoutGroup),
typeof(LayoutElement));
go.transform.SetParent(center, false);
contentArea = go.transform;
}
var layout = contentArea.GetComponent<HorizontalLayoutGroup>();
if (layout == null)
{
layout = contentArea.gameObject.AddComponent<HorizontalLayoutGroup>();
}
layout.padding = new RectOffset(0, 0, 0, 0);
layout.spacing = ContentAreaSpacing;
layout.childAlignment = TextAnchor.UpperLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = true;
var layoutElement = contentArea.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = contentArea.gameObject.AddComponent<LayoutElement>();
}
layoutElement.minHeight = 480f;
layoutElement.flexibleHeight = 1f;
return contentArea;
}
private static void MoveIntoContentArea(Transform child, Transform contentArea, int siblingIndex)
{
if (child == null || contentArea == null) return;
if (child.parent != contentArea)
{
child.SetParent(contentArea, false);
}
child.SetSiblingIndex(siblingIndex);
}
private static Transform EnsureCloseButton(Transform titleArea)
{
var close = titleArea.Find("CloseButton");
if (close != null) return close;
var go = new GameObject("CloseButton", typeof(RectTransform), typeof(Image), typeof(Button),
typeof(LayoutElement));
go.transform.SetParent(titleArea, false);
return go.transform;
}
private void ConfigureCloseButton(Transform close)
{
var closeImage = EnsureImage(close, PanelTint, true);
ApplySprite(closeImage, SpriteReturnButton, PanelTint, true);
CloseButton = close.GetComponent<Button>();
if (CloseButton == null)
{
CloseButton = close.gameObject.AddComponent<Button>();
}
CloseButton.transition = Selectable.Transition.ColorTint;
CloseButton.targetGraphic = closeImage;
if (close is RectTransform rect)
{
rect.anchorMin = new Vector2(0f, 0.5f);
rect.anchorMax = new Vector2(0f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(52f, 52f);
}
var layoutElement = close.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = close.gameObject.AddComponent<LayoutElement>();
}
layoutElement.minWidth = 56f;
layoutElement.preferredWidth = 56f;
layoutElement.minHeight = 56f;
layoutElement.preferredHeight = 56f;
var label = close.Find("Label");
CloseButtonText = label != null ? label.GetComponent<TextMeshProUGUI>() : null;
if (CloseButtonText != null)
{
CloseButtonText.text = string.Empty;
CloseButtonText.raycastTarget = false;
CloseButtonText.gameObject.SetActive(false);
}
}
private static void EnsurePanelTitle(Transform titleArea)
{
var textRoot = titleArea.Find("TitleTextRoot");
if (textRoot == null)
{
var go = new GameObject("TitleTextRoot", typeof(RectTransform), typeof(VerticalLayoutGroup),
typeof(LayoutElement));
go.transform.SetParent(titleArea, false);
textRoot = go.transform;
}
textRoot.SetSiblingIndex(1);
var rootLayoutElement = textRoot.GetComponent<LayoutElement>();
if (rootLayoutElement == null)
{
rootLayoutElement = textRoot.gameObject.AddComponent<LayoutElement>();
}
rootLayoutElement.flexibleWidth = 1f;
var rootLayout = textRoot.GetComponent<VerticalLayoutGroup>();
if (rootLayout == null)
{
rootLayout = textRoot.gameObject.AddComponent<VerticalLayoutGroup>();
}
rootLayout.padding = new RectOffset(0, 0, 8, 0);
rootLayout.spacing = 0f;
rootLayout.childControlWidth = true;
rootLayout.childControlHeight = true;
rootLayout.childForceExpandWidth = true;
rootLayout.childForceExpandHeight = false;
var title = EnsureText(textRoot, "PanelTitle", "问卷中心", 34f, InkLightColor, TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
title.enableAutoSizing = true;
title.fontSizeMin = 22f;
title.fontSizeMax = 34f;
title.enableWordWrapping = false;
var subtitle = EnsureText(textRoot, "PanelSubtitle", "当前反馈 / 长期收集 / 往期记录", 18f,
InkLightColor, TextAlignmentOptions.Left);
subtitle.enableWordWrapping = false;
}
private static void HideLegacyTopStripe(Transform center)
{
var stripe = center.Find("TopStripe");
if (stripe != null)
{
stripe.gameObject.SetActive(false);
}
}
private static void SetLegacySidebarHeaderVisible(Transform sidebar, bool visible)
{
var title = sidebar != null ? sidebar.Find("Title") : null;
if (title != null)
{
title.gameObject.SetActive(visible);
}
var subtitle = sidebar != null ? sidebar.Find("Subtitle") : null;
if (subtitle != null)
{
subtitle.gameObject.SetActive(visible);
}
}
private static void ConfigureBoundPrefabLayout(Transform center, Transform sidebar, Transform sidebarScroll,
Transform sidebarContent, Transform right, Transform rightHeader, Transform bodyScroll, Transform bodyContent,
Transform footer)
{
ConfigureOutsideRoot(center);
HideLegacyTopStripe(center);
ConfigureBoundPanel(sidebar, TransparentPanelTint, SidebarWidth, 0, 0, 0, 0, null);
ConfigureBoundPanel(right, PanelTint, 0f, 22, 22, 20, 18, SpriteCommonWindow);
right.GetComponent<LayoutElement>().flexibleWidth = 1f;
ConfigureBoundHeader(rightHeader);
ConfigureBoundScroll(sidebarScroll, sidebarContent);
ConfigureBoundScroll(bodyScroll, bodyContent);
ConfigureFooter(footer);
if (center is RectTransform centerRect)
{
LayoutRebuilder.ForceRebuildLayoutImmediate(centerRect);
}
}
private static void ConfigureBoundHeader(Transform header)
{
var layout = header.GetComponent<VerticalLayoutGroup>();
if (layout == null)
{
layout = header.gameObject.AddComponent<VerticalLayoutGroup>();
}
layout.spacing = 2f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
}
private static void ConfigureBoundPanel(Transform panel, Color color, float preferredWidth,
int left, int right, int top, int bottom, string spritePath = SpriteCommonPanel)
{
var image = EnsureImage(panel, color, true);
if (string.IsNullOrEmpty(spritePath))
{
image.sprite = null;
image.color = color;
image.type = Image.Type.Simple;
image.raycastTarget = color.a > 0.001f;
}
else
{
ApplySprite(image, spritePath, color);
}
var layout = panel.GetComponent<VerticalLayoutGroup>();
if (layout == null)
{
layout = panel.gameObject.AddComponent<VerticalLayoutGroup>();
}
layout.padding = new RectOffset(left, right, top, bottom);
layout.spacing = 12f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var layoutElement = panel.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = panel.gameObject.AddComponent<LayoutElement>();
}
if (preferredWidth > 0f)
{
layoutElement.minWidth = preferredWidth;
layoutElement.preferredWidth = preferredWidth;
layoutElement.flexibleWidth = 0f;
}
else
{
layoutElement.minWidth = 0f;
layoutElement.preferredWidth = 0f;
layoutElement.flexibleWidth = 1f;
}
}
private static void ConfigureBoundScroll(Transform scrollRoot, Transform content)
{
var layoutElement = scrollRoot.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = scrollRoot.gameObject.AddComponent<LayoutElement>();
}
layoutElement.flexibleHeight = 1f;
layoutElement.minHeight = 120f;
var viewport = scrollRoot.Find("Viewport") as RectTransform;
if (viewport == null)
{
return;
}
viewport.anchorMin = Vector2.zero;
viewport.anchorMax = Vector2.one;
viewport.offsetMin = Vector2.zero;
viewport.offsetMax = Vector2.zero;
EnsureImage(viewport, new Color(1f, 1f, 1f, 0.01f), false);
var mask = viewport.GetComponent<Mask>();
if (mask == null)
{
mask = viewport.gameObject.AddComponent<Mask>();
}
mask.showMaskGraphic = false;
var contentRect = content as RectTransform;
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.offsetMin = Vector2.zero;
contentRect.offsetMax = Vector2.zero;
var contentLayout = content.GetComponent<VerticalLayoutGroup>();
if (contentLayout == null)
{
contentLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
}
contentLayout.spacing = 10f;
contentLayout.childControlWidth = true;
contentLayout.childControlHeight = true;
contentLayout.childForceExpandWidth = true;
contentLayout.childForceExpandHeight = false;
var fitter = content.GetComponent<ContentSizeFitter>();
if (fitter == null)
{
fitter = content.gameObject.AddComponent<ContentSizeFitter>();
}
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var scrollRect = scrollRoot.GetComponent<ScrollRect>();
if (scrollRect == null)
{
scrollRect = scrollRoot.gameObject.AddComponent<ScrollRect>();
}
scrollRect.viewport = viewport;
scrollRect.content = contentRect;
scrollRect.horizontal = false;
scrollRect.vertical = true;
scrollRect.scrollSensitivity = 30f;
scrollRect.movementType = ScrollRect.MovementType.Clamped;
}
private static void ConfigureFooter(Transform footer)
{
var layout = footer.GetComponent<HorizontalLayoutGroup>();
if (layout == null)
{
layout = footer.gameObject.AddComponent<HorizontalLayoutGroup>();
}
layout.spacing = 12f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
layout.childAlignment = TextAnchor.MiddleRight;
var layoutElement = footer.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = footer.gameObject.AddComponent<LayoutElement>();
}
layoutElement.minHeight = 48f;
layoutElement.preferredHeight = 50f;
}
private void CreateMainLayout()
{
var root = new GameObject("QuestionnaireCenter", typeof(RectTransform), typeof(Image),
typeof(VerticalLayoutGroup));
root.transform.SetParent(transform, false);
ConfigureOutsideRoot(root.transform);
var titleArea = EnsureTitleArea(root.transform);
ConfigureCloseButton(EnsureCloseButton(titleArea));
EnsurePanelTitle(titleArea);
var contentArea = EnsureContentArea(root.transform);
CreateSidebar(contentArea);
CreateRightPanel(contentArea);
}
private void CreateSidebar(Transform parent)
{
var sidebar = CreatePanel(parent, "Sidebar", TransparentPanelTint, SidebarWidth, null);
var layout = sidebar.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(0, 0, 0, 0);
layout.spacing = 12f;
CreateScroll(sidebar.transform, "SidebarScroll", out _sidebarContent);
}
private void CreateRightPanel(Transform parent)
{
var right = CreatePanel(parent, "RightPanel", PanelTint, 0f, SpriteCommonWindow);
var rightLayoutElement = right.GetComponent<LayoutElement>();
rightLayoutElement.flexibleWidth = 1f;
var layout = right.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(22, 22, 20, 18);
layout.spacing = 14f;
var header = new GameObject("Header", typeof(RectTransform), typeof(VerticalLayoutGroup));
header.transform.SetParent(right.transform, false);
var headerLayout = header.GetComponent<VerticalLayoutGroup>();
headerLayout.spacing = 2f;
headerLayout.childControlWidth = true;
headerLayout.childControlHeight = true;
headerLayout.childForceExpandWidth = true;
headerLayout.childForceExpandHeight = false;
_rightHeaderTitle = CreateText(header.transform, "Title", "", 22f,
InkColor, TextAlignmentOptions.Left);
_rightHeaderTitle.fontStyle = FontStyles.Bold;
_rightHeaderSubtitle = CreateText(header.transform, "Subtitle", "", 16f,
InkMutedColor, TextAlignmentOptions.Left);
_rightHeaderSubtitle.enableWordWrapping = true;
CreateScroll(right.transform, "BodyScroll", out _rightBody);
var footer = new GameObject("Footer", typeof(RectTransform), typeof(HorizontalLayoutGroup), typeof(LayoutElement));
footer.transform.SetParent(right.transform, false);
_rightFooter = footer.transform;
var footerLayout = footer.GetComponent<HorizontalLayoutGroup>();
footerLayout.spacing = 12f;
footerLayout.childControlWidth = true;
footerLayout.childControlHeight = true;
footerLayout.childForceExpandWidth = false;
footerLayout.childForceExpandHeight = false;
footerLayout.childAlignment = TextAnchor.MiddleRight;
var footerLayoutElement = footer.GetComponent<LayoutElement>();
footerLayoutElement.minHeight = 48f;
footerLayoutElement.preferredHeight = 50f;
}
private void CreateQuestionListRoot()
{
var root = new GameObject("QuestionListContent", typeof(RectTransform), typeof(VerticalLayoutGroup),
typeof(ContentSizeFitter));
root.transform.SetParent(_rightBody, false);
var layout = root.GetComponent<VerticalLayoutGroup>();
layout.spacing = 10f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
root.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
QuestionListContent = root.transform;
}
private void ClearRight()
{
DestroyChildren(_rightBody);
DestroyChildren(_rightFooter);
_questionItems.Clear();
QuestionListContent = null;
SubmitButton = null;
RefillButton = null;
StatusText = null;
_recordsButton = null;
_startButton = null;
}
private void ShowEmptyState(string text)
{
ClearRight();
SetRightHeader("问卷中心", string.Empty);
var empty = CreateText(_rightBody, "EmptyState", text, 24f,
InkMutedColor, TextAlignmentOptions.Center);
empty.enableWordWrapping = true;
}
private void SetRightHeader(string title, string subtitle)
{
if (_rightHeaderTitle != null) _rightHeaderTitle.text = title ?? string.Empty;
if (_rightHeaderSubtitle != null) _rightHeaderSubtitle.text = subtitle ?? string.Empty;
}
private GameObject CreatePanel(Transform parent, string objectName, Color color, float preferredWidth = 0f,
string spritePath = SpriteCommonPanel)
{
var go = new GameObject(objectName, typeof(RectTransform), typeof(Image), typeof(VerticalLayoutGroup),
typeof(LayoutElement));
go.transform.SetParent(parent, false);
var image = go.GetComponent<Image>();
if (string.IsNullOrEmpty(spritePath))
{
image.sprite = null;
image.color = color;
image.type = Image.Type.Simple;
image.raycastTarget = color.a > 0.001f;
}
else
{
ApplySprite(image, spritePath, color);
}
var layout = go.GetComponent<VerticalLayoutGroup>();
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var layoutElement = go.GetComponent<LayoutElement>();
if (preferredWidth > 0f)
{
layoutElement.minWidth = preferredWidth;
layoutElement.preferredWidth = preferredWidth;
layoutElement.flexibleWidth = 0f;
}
else
{
layoutElement.minWidth = 0f;
layoutElement.preferredWidth = 0f;
layoutElement.flexibleWidth = 1f;
}
return go;
}
private static void CreateScroll(Transform parent, string objectName, out Transform content)
{
var scrollRoot = new GameObject(objectName, typeof(RectTransform), typeof(ScrollRect), typeof(LayoutElement));
scrollRoot.transform.SetParent(parent, false);
var layoutElement = scrollRoot.GetComponent<LayoutElement>();
layoutElement.flexibleHeight = 1f;
layoutElement.minHeight = 120f;
var viewport = new GameObject("Viewport", typeof(RectTransform), typeof(Image), typeof(Mask));
viewport.transform.SetParent(scrollRoot.transform, false);
var viewportRect = viewport.GetComponent<RectTransform>();
viewportRect.anchorMin = Vector2.zero;
viewportRect.anchorMax = Vector2.one;
viewportRect.offsetMin = Vector2.zero;
viewportRect.offsetMax = Vector2.zero;
viewport.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.01f);
viewport.GetComponent<Mask>().showMaskGraphic = false;
var contentRoot = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup),
typeof(ContentSizeFitter));
contentRoot.transform.SetParent(viewport.transform, false);
var contentRect = contentRoot.GetComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.offsetMin = Vector2.zero;
contentRect.offsetMax = Vector2.zero;
var layout = contentRoot.GetComponent<VerticalLayoutGroup>();
layout.spacing = 10f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
contentRoot.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var scrollRect = scrollRoot.GetComponent<ScrollRect>();
scrollRect.viewport = viewportRect;
scrollRect.content = contentRect;
scrollRect.horizontal = false;
scrollRect.vertical = true;
scrollRect.scrollSensitivity = 30f;
scrollRect.movementType = ScrollRect.MovementType.Clamped;
content = contentRoot.transform;
}
private void CreateSectionHeader(Transform parent, string text)
{
var go = InstantiateTemplate(SectionHeaderTemplate, parent, "SectionHeader");
var header = go != null
? EnsureText(go.transform, "Label", text, 18f, GoldColor, TextAlignmentOptions.Left)
: CreateText(parent, "SectionHeader", text, 18f,
GoldColor, TextAlignmentOptions.Left);
header.fontStyle = FontStyles.Bold;
}
private void CreateExpiredHeader(int expiredCount)
{
var button = CreateButton(_sidebarContent, "ExpiredToggle", _showExpired ? "往期问卷 v" : "往期问卷 >",
false, out _expiredToggleText, 0f);
var layout = button.GetComponent<LayoutElement>();
layout.minHeight = 34f;
layout.preferredHeight = 36f;
if (expiredCount > 0)
{
_expiredToggleText.text = _showExpired ? $"往期问卷 ({expiredCount}) v" : $"往期问卷 ({expiredCount}) >";
}
button.onClick.AddListener(() =>
{
_showExpired = !_showExpired;
BuildQuestionnaireList();
UpdateListSelection();
});
}
private void CreateDivider(Transform parent)
{
var divider = InstantiateTemplate(DividerTemplate, parent, "Divider");
if (divider == null)
{
divider = new GameObject("Divider", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
divider.transform.SetParent(parent, false);
}
ApplySprite(EnsureImage(divider.transform, PanelTint, false), SpriteDivider, PanelTint);
var layout = divider.GetComponent<LayoutElement>();
if (layout == null)
{
layout = divider.AddComponent<LayoutElement>();
}
layout.minHeight = 1f;
layout.preferredHeight = 1f;
}
private void CreateInfoStrip(Transform parent, string text)
{
CreateInfoStrip(parent, text, InkLightColor);
}
private void CreateInfoStrip(Transform parent, string text, Color textColor)
{
var strip = InstantiateTemplate(InfoStripTemplate, parent, "InfoStrip");
if (strip == null)
{
strip = new GameObject("InfoStrip", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
strip.transform.SetParent(parent, false);
}
ApplySprite(EnsureImage(strip.transform, PanelTint, true), SpriteCommonSelectLabel, PanelTint, false,
ItemButtonPixelsPerUnitMultiplier);
var layoutElement = strip.GetComponent<LayoutElement>();
if (layoutElement == null)
{
layoutElement = strip.AddComponent<LayoutElement>();
}
layoutElement.minHeight = 46f;
var label = EnsureText(strip.transform, "Label", text, 18f, textColor, TextAlignmentOptions.Left);
label.enableWordWrapping = true;
label.rectTransform.anchorMin = Vector2.zero;
label.rectTransform.anchorMax = Vector2.one;
label.rectTransform.offsetMin = new Vector2(14f, 8f);
label.rectTransform.offsetMax = new Vector2(-14f, -8f);
}
private Button CreateButton(Transform parent, string objectName, string text, bool primary,
out TextMeshProUGUI label, float preferredWidth)
{
var template = primary ? PrimaryButtonTemplate : SecondaryButtonTemplate;
var go = InstantiateTemplate(template, parent, objectName);
if (go == null)
{
go = new GameObject(objectName, typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
go.transform.SetParent(parent, false);
}
return ConfigureButton(go, text, primary, out label, preferredWidth);
}
private static Button ConfigureButton(GameObject go, string text, bool primary, out TextMeshProUGUI label,
float preferredWidth)
{
var layout = go.GetComponent<LayoutElement>();
if (layout == null)
{
layout = go.AddComponent<LayoutElement>();
}
layout.minWidth = preferredWidth > 0f ? preferredWidth : 120f;
layout.preferredWidth = preferredWidth > 0f ? preferredWidth : 150f;
layout.minHeight = 48f;
layout.preferredHeight = 52f;
var image = EnsureImage(go.transform, PanelTint, true);
ApplySprite(image, primary ? SpriteButtonPrimary : SpriteButtonSecondary, PanelTint, false,
GetActionButtonPixelsPerUnitMultiplier(primary));
var button = go.GetComponent<Button>();
if (button == null)
{
button = go.AddComponent<Button>();
}
button.transition = Selectable.Transition.ColorTint;
button.targetGraphic = image;
button.onClick.RemoveAllListeners();
label = EnsureText(go.transform, "Label", text, 19f,
InkLightColor, TextAlignmentOptions.Center);
label.rectTransform.anchorMin = Vector2.zero;
label.rectTransform.anchorMax = Vector2.one;
label.rectTransform.offsetMin = Vector2.zero;
label.rectTransform.offsetMax = Vector2.zero;
label.enableWordWrapping = false;
return button;
}
private static float GetActionButtonPixelsPerUnitMultiplier(bool primary)
{
return primary ? PrimaryButtonPixelsPerUnitMultiplier : SecondaryButtonPixelsPerUnitMultiplier;
}
#if UNITY_EDITOR
private void EnsureDesignTemplates(bool rebuild)
{
if (Application.isPlaying) return;
if (_layoutBuilt && (_sidebarContent == null || _rightBody == null || _rightFooter == null))
{
_layoutBuilt = false;
}
EnsureLayout();
if (_sidebarContent == null || _rightBody == null || _rightFooter == null) return;
if (_runtimeTemplateRoot != null)
{
RestoreDesignTemplate(QuestionnaireItemTemplate, _sidebarContent);
RestoreDesignTemplate(SectionHeaderTemplate, _sidebarContent);
RestoreDesignTemplate(DividerTemplate, _sidebarContent);
RestoreDesignTemplate(InfoStripTemplate, _rightBody);
RestoreDesignTemplate(LatestRecordPreviewTemplate, _rightBody);
RestoreDesignTemplate(RecordRowTemplate, _rightBody);
RestoreDesignTemplate(PrimaryButtonTemplate, _rightFooter);
RestoreDesignTemplate(SecondaryButtonTemplate, _rightFooter);
_runtimeTemplateRoot.gameObject.SetActive(false);
_templatesExtracted = false;
}
if (rebuild)
{
RemoveExistingTemplate("QuestionnaireItemTemplate");
RemoveExistingTemplate("SectionHeaderTemplate");
RemoveExistingTemplate("DividerTemplate");
RemoveExistingTemplate("InfoStripTemplate");
RemoveExistingTemplate("LatestRecordPreviewTemplate");
RemoveExistingTemplate("RecordRowTemplate");
RemoveExistingTemplate("PrimaryButtonTemplate");
RemoveExistingTemplate("SecondaryButtonTemplate");
}
SectionHeaderTemplate = EnsureSectionHeaderTemplate();
QuestionnaireItemTemplate = EnsureQuestionnaireItemTemplate();
DividerTemplate = EnsureDividerTemplate();
InfoStripTemplate = EnsureInfoStripTemplate();
LatestRecordPreviewTemplate = EnsureLatestRecordPreviewTemplate();
RecordRowTemplate = EnsureRecordRowTemplate();
SecondaryButtonTemplate = EnsureActionButtonTemplate("SecondaryButtonTemplate", "查看记录", false, 140f);
PrimaryButtonTemplate = EnsureActionButtonTemplate("PrimaryButtonTemplate", "开始填写", true, 150f);
SortTemplate(_sidebarContent, "SectionHeaderTemplate", 0);
SortTemplate(_sidebarContent, "QuestionnaireItemTemplate", 1);
SortTemplate(_sidebarContent, "DividerTemplate", 2);
SortTemplate(_rightBody, "InfoStripTemplate", 0);
SortTemplate(_rightBody, "LatestRecordPreviewTemplate", 1);
SortTemplate(_rightBody, "RecordRowTemplate", 2);
SortTemplate(_rightFooter, "SecondaryButtonTemplate", 0);
SortTemplate(_rightFooter, "PrimaryButtonTemplate", 1);
}
private static void RestoreDesignTemplate(GameObject template, Transform targetParent)
{
if (template == null || targetParent == null) return;
if (!template.name.EndsWith("Template", StringComparison.Ordinal)) return;
template.transform.SetParent(targetParent, false);
template.SetActive(true);
}
private void RemoveExistingTemplate(string objectName)
{
var existing = FindDeepChild(transform, objectName);
if (existing == null) return;
UnityEditor.Undo.DestroyObjectImmediate(existing.gameObject);
}
private static void SortTemplate(Transform parent, string objectName, int index)
{
var child = parent != null ? parent.Find(objectName) : null;
if (child == null) return;
child.SetSiblingIndex(Mathf.Clamp(index, 0, parent.childCount - 1));
}
private GameObject EnsureSectionHeaderTemplate()
{
var go = FindOrCreateTemplate(_sidebarContent, "SectionHeaderTemplate", typeof(LayoutElement));
var label = EnsureText(go.transform, "Label", "当期问卷", 18f, GoldColor, TextAlignmentOptions.Left);
label.fontStyle = FontStyles.Bold;
label.enableWordWrapping = false;
var layout = go.GetComponent<LayoutElement>();
layout.minHeight = 26f;
layout.preferredHeight = 28f;
return go;
}
private GameObject EnsureQuestionnaireItemTemplate()
{
var go = FindOrCreateTemplate(_sidebarContent, "QuestionnaireItemTemplate", typeof(Image),
typeof(Button), typeof(VerticalLayoutGroup), typeof(LayoutElement));
ApplySprite(EnsureImage(go.transform, PanelTint, true), SpriteItemButton, PanelTint, false,
ItemButtonPixelsPerUnitMultiplier);
var layout = go.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(12, 12, 10, 10);
layout.spacing = 4f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var layoutElement = go.GetComponent<LayoutElement>();
layoutElement.minHeight = 76f;
layoutElement.preferredHeight = 82f;
var button = go.GetComponent<Button>();
button.transition = Selectable.Transition.ColorTint;
button.targetGraphic = go.GetComponent<Image>();
var header = FindOrCreateChild(go.transform, "Header", typeof(HorizontalLayoutGroup));
var headerLayout = header.GetComponent<HorizontalLayoutGroup>();
headerLayout.spacing = 8f;
headerLayout.childControlWidth = true;
headerLayout.childControlHeight = true;
headerLayout.childForceExpandWidth = false;
headerLayout.childForceExpandHeight = false;
headerLayout.childAlignment = TextAnchor.MiddleLeft;
var title = EnsureText(header, "Title", "TOHOTOPIA玩法与策略反馈问卷", 18f, InkColor,
TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
title.enableWordWrapping = true;
EnsureLayoutElement(title.gameObject).flexibleWidth = 1f;
var badge = EnsureText(header, "Badge", "当期", 15f, GoldColor, TextAlignmentOptions.Right);
EnsureLayoutElement(badge.gameObject).preferredWidth = 56f;
var meta = EnsureText(go.transform, "Meta", "22题 · 未填写", 15f, InkMutedColor,
TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
return go;
}
private GameObject EnsureDividerTemplate()
{
var go = FindOrCreateTemplate(_sidebarContent, "DividerTemplate", typeof(Image), typeof(LayoutElement));
ApplySprite(EnsureImage(go.transform, PanelTint, false), SpriteDivider, PanelTint);
var layout = go.GetComponent<LayoutElement>();
layout.minHeight = 1f;
layout.preferredHeight = 1f;
return go;
}
private GameObject EnsureInfoStripTemplate()
{
var go = FindOrCreateTemplate(_rightBody, "InfoStripTemplate", typeof(Image), typeof(LayoutElement));
ApplySprite(EnsureImage(go.transform, PanelTint, true), SpriteCommonSelectLabel, PanelTint, false,
ItemButtonPixelsPerUnitMultiplier);
var layout = go.GetComponent<LayoutElement>();
layout.minHeight = 46f;
layout.preferredHeight = 46f;
var label = EnsureText(go.transform, "Label", "当期 · 22题 · 未填写", 18f, InkLightColor,
TextAlignmentOptions.Left);
label.enableWordWrapping = true;
label.rectTransform.anchorMin = Vector2.zero;
label.rectTransform.anchorMax = Vector2.one;
label.rectTransform.offsetMin = new Vector2(14f, 8f);
label.rectTransform.offsetMax = new Vector2(-14f, -8f);
return go;
}
private GameObject EnsureLatestRecordPreviewTemplate()
{
var go = FindOrCreateTemplate(_rightBody, "LatestRecordPreviewTemplate", typeof(Image),
typeof(VerticalLayoutGroup), typeof(LayoutElement));
ApplySprite(EnsureImage(go.transform, PanelTint, true), SpriteCommonPanel, PanelTint);
var layout = go.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(16, 16, 14, 14);
layout.spacing = 6f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var layoutElement = go.GetComponent<LayoutElement>();
layoutElement.minHeight = 74f;
layoutElement.preferredHeight = 78f;
var title = EnsureText(go.transform, "Title", "最近一次填写", 20f, InkColor,
TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var meta = EnsureText(go.transform, "Meta", "2026-06-30 15:30 · 已上传", 17f,
InkMutedColor, TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
return go;
}
private GameObject EnsureRecordRowTemplate()
{
var go = FindOrCreateTemplate(_rightBody, "RecordRowTemplate", typeof(Image),
typeof(HorizontalLayoutGroup), typeof(LayoutElement));
ApplySprite(EnsureImage(go.transform, PanelTint, true), SpriteCommonLabel, PanelTint);
var layout = go.GetComponent<HorizontalLayoutGroup>();
layout.padding = new RectOffset(16, 14, 12, 12);
layout.spacing = 14f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
layout.childAlignment = TextAnchor.MiddleLeft;
var layoutElement = go.GetComponent<LayoutElement>();
layoutElement.minHeight = 76f;
layoutElement.preferredHeight = 78f;
var textRoot = FindOrCreateChild(go.transform, "TextRoot", typeof(VerticalLayoutGroup),
typeof(LayoutElement));
EnsureLayoutElement(textRoot.gameObject).flexibleWidth = 1f;
var textLayout = textRoot.GetComponent<VerticalLayoutGroup>();
textLayout.spacing = 3f;
textLayout.childControlWidth = true;
textLayout.childControlHeight = true;
textLayout.childForceExpandWidth = true;
textLayout.childForceExpandHeight = false;
var title = EnsureText(textRoot, "Title", "记录 1", 20f, InkColor, TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var meta = EnsureText(textRoot, "Meta", "2026-06-30 15:30 · 已上传", 17f,
InkMutedColor, TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
var viewButton = FindOrCreateChild(go.transform, "ViewButton", typeof(Image), typeof(Button),
typeof(LayoutElement));
ConfigureButton(viewButton.gameObject, "查看", false, out _, 104f);
return go;
}
private GameObject EnsureActionButtonTemplate(string objectName, string text, bool primary, float preferredWidth)
{
var go = FindOrCreateTemplate(_rightFooter, objectName, typeof(Image), typeof(Button),
typeof(LayoutElement));
ConfigureButton(go, text, primary, out _, preferredWidth);
return go;
}
private static GameObject FindOrCreateTemplate(Transform parent, string objectName, params Type[] componentTypes)
{
var child = parent.Find(objectName);
if (child != null)
{
foreach (var componentType in componentTypes)
{
if (child.GetComponent(componentType) == null)
{
UnityEditor.Undo.AddComponent(child.gameObject, componentType);
}
}
child.gameObject.SetActive(true);
return child.gameObject;
}
var go = new GameObject(objectName, typeof(RectTransform));
UnityEditor.Undo.RegisterCreatedObjectUndo(go, $"Create {objectName}");
go.transform.SetParent(parent, false);
foreach (var componentType in componentTypes)
{
UnityEditor.Undo.AddComponent(go, componentType);
}
return go;
}
private static Transform FindOrCreateChild(Transform parent, string objectName, params Type[] componentTypes)
{
var child = parent.Find(objectName);
if (child == null)
{
var go = new GameObject(objectName, typeof(RectTransform));
UnityEditor.Undo.RegisterCreatedObjectUndo(go, $"Create {objectName}");
go.transform.SetParent(parent, false);
child = go.transform;
}
foreach (var componentType in componentTypes)
{
if (child.GetComponent(componentType) == null)
{
UnityEditor.Undo.AddComponent(child.gameObject, componentType);
}
}
return child;
}
private static LayoutElement EnsureLayoutElement(GameObject go)
{
var layout = go.GetComponent<LayoutElement>();
return layout != null ? layout : UnityEditor.Undo.AddComponent<LayoutElement>(go);
}
#endif
private void UpdateListSelection()
{
foreach (var item in _listItems)
{
var selected = item.Info == _questionnaireInfo;
ApplySprite(item.Background, selected ? SpriteItemButtonSelected : SpriteItemButton, PanelTint, false,
ItemButtonPixelsPerUnitMultiplier);
item.TitleText.color = selected
? InkLightColor
: InkColor;
}
}
private static string BuildListMetaText(QuestionnaireInfo info)
{
var sheets = QuestionnaireAnswerStore.Instance.GetAnswerSheets(info.QuestionnaireId);
var questionCount = info.Questions?.Count ?? 0;
if (sheets.Count == 0) return $"{questionCount} 题 · 未填写";
return $"{questionCount} 题 · 已填写 {sheets.Count} 次";
}
private static string BuildDetailMetaText(QuestionnaireInfo info, List<QuestionnaireAnswerSheet> sheets)
{
var questionCount = info.Questions?.Count ?? 0;
var count = sheets?.Count ?? 0;
var status = GetStatusText(info);
if (count == 0) return $"{status} · {questionCount} 题 · 未填写";
return $"{status} · {questionCount} 题 · 已填写 {count} 次 · 最近 {FormatSubmittedAt(sheets[0])}";
}
private static string GetStatusText(QuestionnaireInfo info)
{
if (info == null) return string.Empty;
return info.GetEffectiveStatus(DateTime.UtcNow) switch
{
QuestionnaireStatus.Featured => "当期",
QuestionnaireStatus.LongTerm => "长期",
QuestionnaireStatus.Expired => "往期",
_ => "隐藏"
};
}
private static Color GetStatusColor(QuestionnaireInfo info)
{
if (info == null) return InkMutedColor;
return info.GetEffectiveStatus(DateTime.UtcNow) switch
{
QuestionnaireStatus.Featured => GoldColor,
QuestionnaireStatus.LongTerm => InkColor,
QuestionnaireStatus.Expired => InkMutedColor,
_ => InkMutedColor
};
}
private static string FormatSubmittedAt(QuestionnaireAnswerSheet sheet)
{
if (sheet == null) return string.Empty;
if (sheet.SubmittedAtUnix > 0)
{
return DateTimeOffset.FromUnixTimeSeconds(sheet.SubmittedAtUnix).ToLocalTime()
.ToString("yyyy-MM-dd HH:mm");
}
if (DateTime.TryParse(sheet.SubmittedAtUtc, out var parsed))
{
return parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
return "未知时间";
}
private static string GetUploadStateText(QuestionnaireAnswerSheet sheet)
{
if (sheet == null) return string.Empty;
if (!string.IsNullOrEmpty(sheet.LastUploadObjectKey)) return "已上传";
if (!string.IsNullOrEmpty(sheet.LastUploadError)) return "仅本地保存";
return "等待上传结果";
}
private static void DestroyChildren(Transform parent)
{
if (parent == null) return;
for (var i = parent.childCount - 1; i >= 0; i--)
{
var child = parent.GetChild(i);
if (child.name.EndsWith("Template", StringComparison.Ordinal) ||
child.name == "RuntimeTemplates")
{
continue;
}
child.gameObject.SetActive(false);
child.SetParent(null, false);
Destroy(child.gameObject);
}
}
private class QuestionnaireListItem
{
public QuestionnaireInfo Info;
public Image Background;
public TextMeshProUGUI TitleText;
public TextMeshProUGUI BadgeText;
public TextMeshProUGUI MetaText;
}
}
}