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

1358 lines
55 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 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 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 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;
protected override void OnInit()
{
base.OnInit();
EnsureLayout();
}
public void SetContent(ShowUIOutsideQuestionnaire evt)
{
EnsureLayout();
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;
}
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,
new Color(0.62f, 0.66f, 0.64f, 1f), TextAlignmentOptions.Left);
hint.enableWordWrapping = true;
}
if (items.Count == 0)
{
var empty = CreateText(_sidebarContent, "EmptyList", "暂无可显示问卷", 18f,
new Color(0.74f, 0.76f, 0.72f, 1f), 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,
new Color(0.62f, 0.66f, 0.64f, 1f), TextAlignmentOptions.Left);
empty.enableWordWrapping = true;
return;
}
foreach (var info in infos)
{
CreateQuestionnaireListItem(info);
}
}
private void CreateQuestionnaireListItem(QuestionnaireInfo info)
{
var row = new GameObject("QuestionnaireItem", typeof(RectTransform), typeof(Image), typeof(Button),
typeof(VerticalLayoutGroup), typeof(LayoutElement));
row.transform.SetParent(_sidebarContent, false);
var image = row.GetComponent<Image>();
image.color = new Color(1f, 1f, 1f, 0.055f);
var layout = row.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 = row.GetComponent<LayoutElement>();
layoutElement.minHeight = 76f;
layoutElement.preferredHeight = 82f;
var button = row.GetComponent<Button>();
button.targetGraphic = image;
button.onClick.AddListener(() => SelectQuestionnaire(info));
var header = new GameObject("Header", typeof(RectTransform), typeof(HorizontalLayoutGroup));
header.transform.SetParent(row.transform, false);
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 = CreateText(header.transform, "Title", ResolveText(info.Title), 18f,
new Color(0.96f, 0.95f, 0.87f, 1f), TextAlignmentOptions.Left);
title.enableWordWrapping = true;
title.fontStyle = FontStyles.Bold;
title.gameObject.AddComponent<LayoutElement>().flexibleWidth = 1f;
var badge = CreateText(header.transform, "Badge", GetStatusText(info), 15f,
GetStatusColor(info), TextAlignmentOptions.Right);
badge.gameObject.AddComponent<LayoutElement>().preferredWidth = 56f;
var meta = CreateText(row.transform, "Meta", BuildListMetaText(info), 15f,
new Color(0.66f, 0.7f, 0.68f, 1f), 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,
new Color(0.98f, 0.96f, 0.86f, 1f), TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
TitleText.enableWordWrapping = true;
DescriptionText = CreateText(_rightBody, "Description", ResolveText(_questionnaireInfo.Description), 20f,
new Color(0.8f, 0.83f, 0.79f, 1f), TextAlignmentOptions.Left);
DescriptionText.enableWordWrapping = true;
CreateInfoStrip(_rightBody, BuildDetailMetaText(_questionnaireInfo, sheets));
var latestMessage = ResolveSubmittedMessage(_currentSheet);
if (!string.IsNullOrEmpty(latestMessage))
{
StatusText = CreateText(_rightBody, "LatestStatus", latestMessage, 18f,
new Color(0.36f, 0.86f, 0.76f, 1f), 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, new Color(0.9f, 0.68f, 0.36f, 1f));
}
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.SubmitButtonText);
if (string.IsNullOrEmpty(startText))
{
startText = sheets.Count > 0 ? "再次填写" : "开始填写";
}
_startButton = CreateButton(_rightFooter, "StartButton", startText, true, out _startButtonText, 160f);
_startButton.interactable = canStart;
_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,
new Color(0.98f, 0.96f, 0.86f, 1f), TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
TitleText.enableWordWrapping = true;
var hint = CreateText(_rightBody, "FormHint", "提交后会保留为一条新的填写记录,可在历史中只读查看。", 18f,
new Color(0.73f, 0.77f, 0.74f, 1f), TextAlignmentOptions.Left);
hint.enableWordWrapping = true;
CreateQuestionListRoot();
BuildQuestions(_questionnaireInfo.Questions, false, null);
StatusText = CreateText(_rightFooter, "Status", "", 17f,
new Color(0.36f, 0.86f, 0.76f, 1f), 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,
new Color(0.98f, 0.96f, 0.86f, 1f), TextAlignmentOptions.Left);
TitleText.fontStyle = FontStyles.Bold;
if (sheets.Count == 0)
{
var empty = CreateText(_rightBody, "EmptyRecords", "还没有填写记录。", 20f,
new Color(0.74f, 0.78f, 0.74f, 1f), 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,
new Color(0.98f, 0.96f, 0.86f, 1f), 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 = CreatePanel(_rightBody, "LatestRecordPreview", new Color(0.08f, 0.12f, 0.115f, 0.82f));
var layout = preview.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(16, 16, 14, 14);
layout.spacing = 6f;
var title = CreateText(preview.transform, "Title", "最近一次填写", 20f,
new Color(0.96f, 0.95f, 0.87f, 1f), TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var meta = CreateText(preview.transform, "Meta", $"{FormatSubmittedAt(sheet)} · {GetUploadStateText(sheet)}", 17f,
new Color(0.7f, 0.75f, 0.71f, 1f), TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
}
private void CreateRecordRow(QuestionnaireAnswerSheet sheet, int index)
{
var row = new GameObject("RecordRow", typeof(RectTransform), typeof(Image), typeof(HorizontalLayoutGroup),
typeof(LayoutElement));
row.transform.SetParent(_rightBody, false);
row.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.055f);
var layout = row.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 = row.GetComponent<LayoutElement>();
layoutElement.minHeight = 76f;
var textRoot = new GameObject("TextRoot", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(LayoutElement));
textRoot.transform.SetParent(row.transform, false);
textRoot.GetComponent<LayoutElement>().flexibleWidth = 1f;
var textLayout = textRoot.GetComponent<VerticalLayoutGroup>();
textLayout.spacing = 3f;
textLayout.childControlWidth = true;
textLayout.childControlHeight = true;
textLayout.childForceExpandWidth = true;
textLayout.childForceExpandHeight = false;
var title = CreateText(textRoot.transform, "Title", $"记录 {index}", 20f,
new Color(0.96f, 0.95f, 0.87f, 1f), TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var meta = CreateText(textRoot.transform, "Meta", $"{FormatSubmittedAt(sheet)} · {GetUploadStateText(sheet)}", 17f,
new Color(0.72f, 0.76f, 0.72f, 1f), TextAlignmentOptions.Left);
meta.enableWordWrapping = true;
var viewButton = 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;
}
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(0.035f, 0.045f, 0.047f, 0.96f);
if (_layoutBuilt) return;
_layoutBuilt = true;
CreateFloatingCloseButton();
CreateMainLayout();
}
private void CreateFloatingCloseButton()
{
CloseButton = CreateButton(transform, "CloseButton", "X", false, out CloseButtonText, 44f);
var rect = CloseButton.GetComponent<RectTransform>();
rect.anchorMin = new Vector2(1f, 1f);
rect.anchorMax = new Vector2(1f, 1f);
rect.pivot = new Vector2(1f, 1f);
rect.anchoredPosition = new Vector2(-32f, -28f);
rect.sizeDelta = new Vector2(44f, 44f);
var layout = CloseButton.GetComponent<LayoutElement>();
if (layout != null)
{
Destroy(layout);
}
}
private void CreateMainLayout()
{
var root = new GameObject("QuestionnaireCenter", typeof(RectTransform), typeof(HorizontalLayoutGroup));
root.transform.SetParent(transform, false);
var rect = root.GetComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = new Vector2(46f, 38f);
rect.offsetMax = new Vector2(-54f, -38f);
var layout = root.GetComponent<HorizontalLayoutGroup>();
layout.spacing = 16f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = true;
CreateSidebar(root.transform);
CreateRightPanel(root.transform);
}
private void CreateSidebar(Transform parent)
{
var sidebar = CreatePanel(parent, "Sidebar", new Color(0.07f, 0.095f, 0.09f, 0.95f), 326f);
var layout = sidebar.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(18, 18, 18, 18);
layout.spacing = 12f;
var title = CreateText(sidebar.transform, "Title", "问卷中心", 30f,
new Color(0.98f, 0.96f, 0.86f, 1f), TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
var subtitle = CreateText(sidebar.transform, "Subtitle", "当期反馈 / 长期收集 / 往期记录", 17f,
new Color(0.68f, 0.73f, 0.69f, 1f), TextAlignmentOptions.Left);
subtitle.enableWordWrapping = true;
CreateScroll(sidebar.transform, "SidebarScroll", out _sidebarContent);
}
private void CreateRightPanel(Transform parent)
{
var right = CreatePanel(parent, "RightPanel", new Color(0.055f, 0.066f, 0.064f, 0.95f));
var rightLayoutElement = right.GetComponent<LayoutElement>();
rightLayoutElement.flexibleWidth = 1f;
var layout = right.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(24, 24, 22, 20);
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,
new Color(0.36f, 0.86f, 0.76f, 1f), TextAlignmentOptions.Left);
_rightHeaderTitle.fontStyle = FontStyles.Bold;
_rightHeaderSubtitle = CreateText(header.transform, "Subtitle", "", 16f,
new Color(0.64f, 0.69f, 0.66f, 1f), 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,
new Color(0.8f, 0.82f, 0.78f, 1f), 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)
{
var go = new GameObject(objectName, typeof(RectTransform), typeof(Image), typeof(VerticalLayoutGroup),
typeof(LayoutElement));
go.transform.SetParent(parent, false);
var image = go.GetComponent<Image>();
image.color = 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;
}
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 header = CreateText(parent, "SectionHeader", text, 18f,
new Color(0.36f, 0.86f, 0.76f, 1f), 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 = new GameObject("Divider", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
divider.transform.SetParent(parent, false);
divider.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.08f);
var layout = divider.GetComponent<LayoutElement>();
layout.minHeight = 1f;
layout.preferredHeight = 1f;
}
private void CreateInfoStrip(Transform parent, string text)
{
CreateInfoStrip(parent, text, new Color(0.7f, 0.75f, 0.72f, 1f));
}
private void CreateInfoStrip(Transform parent, string text, Color textColor)
{
var strip = new GameObject("InfoStrip", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
strip.transform.SetParent(parent, false);
strip.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.055f);
var layoutElement = strip.GetComponent<LayoutElement>();
layoutElement.minHeight = 46f;
var label = CreateText(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 static Button CreateButton(Transform parent, string objectName, string text, bool primary,
out TextMeshProUGUI label, float preferredWidth)
{
var go = new GameObject(objectName, typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
go.transform.SetParent(parent, false);
var layout = go.GetComponent<LayoutElement>();
layout.minWidth = preferredWidth > 0f ? preferredWidth : 120f;
layout.preferredWidth = preferredWidth > 0f ? preferredWidth : 150f;
layout.minHeight = 42f;
layout.preferredHeight = 46f;
var image = go.GetComponent<Image>();
image.color = primary ? new Color(0.15f, 0.56f, 0.49f, 1f) : new Color(1f, 1f, 1f, 0.10f);
var button = go.GetComponent<Button>();
button.transition = Selectable.Transition.ColorTint;
button.targetGraphic = image;
label = CreateText(go.transform, "Label", text, 19f,
new Color(0.98f, 0.98f, 0.92f, 1f), 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 void UpdateListSelection()
{
foreach (var item in _listItems)
{
var selected = item.Info == _questionnaireInfo;
item.Background.color = selected
? new Color(0.16f, 0.38f, 0.34f, 0.94f)
: new Color(1f, 1f, 1f, 0.055f);
item.TitleText.color = selected
? new Color(1f, 0.98f, 0.88f, 1f)
: new Color(0.92f, 0.92f, 0.86f, 1f);
}
}
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 new Color(0.62f, 0.66f, 0.64f, 1f);
return info.GetEffectiveStatus(DateTime.UtcNow) switch
{
QuestionnaireStatus.Featured => new Color(0.42f, 0.9f, 0.78f, 1f),
QuestionnaireStatus.LongTerm => new Color(0.8f, 0.82f, 0.7f, 1f),
QuestionnaireStatus.Expired => new Color(0.68f, 0.68f, 0.66f, 1f),
_ => new Color(0.62f, 0.66f, 0.64f, 1f)
};
}
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--)
{
Destroy(parent.GetChild(i).gameObject);
}
}
private class QuestionnaireListItem
{
public QuestionnaireInfo Info;
public Image Background;
public TextMeshProUGUI TitleText;
public TextMeshProUGUI BadgeText;
public TextMeshProUGUI MetaText;
}
}
}