背景
一位经管院的同学需要定制一份问卷,希望能够让用户填写一题,就能保存提交一题,后台按照题目来查看,并不管用户是否全部做完。而现有的问卷系统大多是把问卷作为基本单位——答完全部题目之后,才能提交问卷,一次性保存全部填写的结果。于是,这位同学找我希望能够定制这样一份问卷。
当时我在微软实习,大组内就有用ASP.NET + React的项目,想想我还没接触过,正有尝试的意思。于是,干脆答应了,而且不是只做一份问卷,打算直接做一个问卷系统(平台)。
经过对React的学习,初步了解了它组件式开发前端的思想。结合逐题提交的要求,我做了一个大胆的设想……
设计特点
市面上的问卷平台,像问卷星、腾讯问卷等,都是预设了诸多题型,可以加入到问卷中。然而,如果用户有独特的需求,需要定制组件,那可能只能走商业洽谈的途径了。
React为前端带来了组件式的解决方案,那么不妨就把问卷中的每一种题型,都抽象成一种React组件——听到这,好像理所应当;既然决定用React,自然要这样做呀——但我还想,用户是否也能自己上传React组件的代码,自定义组件呢?
此时并不确定这样可行性是否很高,也许会带来复杂度的急剧增加,有些得不偿失。而且,这种设计,对于一般用户群体而言,真是没有什么用处,可以说是优先级极低的考虑了。然而,就技术层面而言,如果能做到这个特点,应该可以让系统的设计更加丰富有趣,也能储备一些技术经验。
那么就干吧!
说的很笼统,该系列文章后面第(二)(三)部分的内容也很简略,可以先看第(四)部分的Demo演示,对功能设计有个大概的了解。
技术栈
React + ASP.NET SPA with RESTful API
- C# 7
- ASP.NET Core 2.0
- EntityFramework Core
- SQLite
- React
- TypeScript
- Office UI Fabric React Component
SPA = Single-Page Application
设计细节
数据模型
数据库设计
PK = Primary Key, 主键
FK = Foreign Key, 外键
Questionaire
Name | Type | Flag | Description |
---|---|---|---|
Id | Int | PK | ID |
Title | String | Unique Index | 标题 |
Description | String | 描述文本 | |
OwnerIP | String | 问卷创建者IP | |
Guid | Guid | Unique Index | 问卷的唯一标志(创建者保存,用于获取编辑、查看报告等权限) |
StartDate | DateTime | 问卷开始日期 | |
EndDate | DateTime | 问卷结束日期 | |
CreatedAt | DateTimeOffset | 创建于 | |
UpdatedAt | DateTimeOffset | 修改于 |
Question
Name | Type | Flag | Description |
---|---|---|---|
Id | Int | PK | ID |
QuestionaireId | Int | FK | Questionaire.Id |
QuestionTypeId | Int | FK | QuestionType.Id |
Order | Int | 问题序号 | |
Content | String | 问题内容(序列化存储问题参数) | |
CreatedAt | DateTimeOffset | 创建于 | |
UpdatedAt | DateTimeOffset | 修改于 |
QuestionType
Name | Type | Flag | Description |
---|---|---|---|
Id | Int | PK | ID |
Name | String | Unique Index | 题型名称 |
CreateFormTSX | String | FK | 创建表单TSX代码(用于题型用户编辑题目信息) |
ShowFormTSX | String | 题目展示表单TSX代码(用于用户填写问卷时展示该题型的题目) | |
CompiledCreateForm | String | 编译后创建表单代码(JavaScript) | |
CompiledShowForm | String | 编译后展示表单代码(JavaScript) | |
OwnerIP | String | 提醒创建者IP |
Answer
Name | Type | Flag | Description |
---|---|---|---|
Id | Int | PK | ID |
QuestionId | Int | FK | Question.Id |
Content | String | 回答内容(序列化存储答案参数) | |
IP | String | 回答者IP | |
CreatedAt | DateTimeOffset | 创建于 | |
UpdatedAt | DateTimeOffset | 修改于 |
C# 模型类
Questionaire
public class Questionaire
{
public int Id { get; set; }
[Required] public string Title { get; set; }
[Required] public string Description { get; set; }
public string OwnerIP { get; set; }
public Guid Guid { get; private set; } = Guid.NewGuid();
[Required] public DateTime StartDate { get; set; }
[Required] public DateTime EndDate { get; set; }
// IsEnabled 用于判断问卷是否在启用期内
[NotMapped]
public bool IsEnabled
{
get
{
return DateTime.Compare(DateTime.Now, EndDate) <= 0
&& DateTime.Compare(StartDate, DateTime.Now) <= 0;
}
}
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// EntityFramework Core 可以自动绑定一对多关系
// Questionaire对应一系列Question
[JsonIgnore]
public ICollection<Question> Questions { get; set; } = new List<Question>();
// SafeContent用于非创建者读取Questionaire信息,屏蔽IP、Guid等非公开内容
[NotMapped] [JsonIgnore]
public object SafeContent
{
get
{
var ip = "*.*.*.*";
var ipParts = OwnerIP.Split('.');
if (ipParts.Length == 4)
{
ip = ipParts[0] + ".*.*." + ipParts[3];
}
return new
{
Id = Id,
Title = Title,
Description = Description,
OwnerIP = ip,
StartDate = StartDate,
EndDate = EndDate,
IsEnabled = IsEnabled,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
};
}
}
}
Question
public class Question
{
public int Id { get; set; }
[Required] public int QuestionaireId { get; set; }
// EntityFramework Core自动映射Foreign Key ID到对应对象
[JsonIgnore] public Questionaire Questionaire { get; set; }
[Required] public int TypeId { get; set; }
// EntityFramework Core自动映射Foreign Key ID到对应对象
[JsonIgnore] public QuestionType Type { get; set; }
[Required] public int Order { get; set; }
public string Content { get; set; }
// EntityFramework Core 可以自动绑定一对多关系
// Question对应一系列Answer
[JsonIgnore] public ICollection<Answer> Answers { get; set; } = new List<Answer>();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
QuestionType
public class QuestionType
{
public int Id { get; set; }
[Required] public string Name { get; set; }
[Required] public string CreateFormTSX { get; set; }
[Required] public string ShowFormTSX { get; set; }
[Required] public string CompiledCreateForm { get; set; }
[Required] public string CompiledShowForm { get; set; }
public string OwnerIP { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// SafeContent用于非创建者读取QuestionType信息,屏蔽IP等非公开内容
[NotMapped] [JsonIgnore]
public object SafeContent
{
get
{
var ip = "*.*.*.*";
var ipParts = OwnerIP.Split('.');
if (ipParts.Length == 4)
{
ip = ipParts[0] + ".*.*." + ipParts[3];
}
return new
{
Id = Id,
Name = Name,
CreateFormTSX = CreateFormTSX,
ShowFormTSX = ShowFormTSX,
CompiledCreateForm = CompiledCreateForm,
CompiledShowForm = CompiledShowForm,
OwnerIP = ip,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
};
}
}
}
Answer
public class Answer
{
public int Id { get; set; }
[Required] public string Content { get; set; }
public string OwnerIP { get; set; }
public string SessionId { get; set; }
[Required] public int TimeSpent { get; set; } // By Second
[Required] public int QuestionId { get; set; }
// EntityFramework Core自动映射Foreign Key ID到对应对象
[JsonIgnore] public Question Question { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// SafeContent用于非创建者读取Answer信息,屏蔽IP等非公开内容
[NotMapped] [JsonIgnore]
public object SafeContent
{
get
{
var ip = "*.*.*.*";
var ipParts = OwnerIP.Split('.');
if (ipParts.Length == 4)
{
ip = ipParts[0] + ".*.*." + ipParts[3];
}
return new
{
Id = Id,
Content = Content,
OwnerIP = ip,
TimeSpent = TimeSpent,
QuestionId = QuestionId,
Question = Question,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
};
}
}
}
TypeScript 模型接口
Questionaire
export interface IQuestionaireModel {
[key: string]: any,
id: number,
title: string,
description: string,
startDate: Date,
endDate: Date,
isEnabled: boolean,
ownerIP: string,
guid?: string,
createdAt: Date,
updatedAt: Date,
}
Question
export interface IQuestionModel {
[key: string]: any,
id: number,
questionaireId: number,
typeId: number,
order: number,
content: string,
createdAt: Date,
updatedAt: Date,
}
QuestionType
export interface IQuestionTypeModel {
[key: string]: any,
id: number,
name: string,
createFormTSX: string,
showFormTSX: string,
compiledCreateForm: string,
compiledShowForm: string,
ownerIP: string,
createdAt: Date,
updatedAt: Date,
}
Answer
export interface IAnswerModel {
[key: string]: any,
id: number,
content: string,
sessionId: string,
timeSpent: number,
questionId: number,
ownerIP: string,
createdAt: Date,
updatedAt: Date,
}
TypeScript接口中的
[key: string]: any
可以让你以foo['PropertyName']
的形式访问成员属性。