前端使用 Vue.js 和 Vite 框架,使用 TypeScript 书写,Python 书写,MIT 协议开源,相关信息可以在文末找到,供一起学习的小伙伴们参考~
   
   
  
  
  本文为进阶要求 的实现过程,基本要求 请转到【基础篇】 
   
   
题目 题目描述 老王家里的小登玩口算被外挂虐爆,于是老王想自研一款口算软件,满足他家小登的好学心。请你帮帮老王,为他写一款口算程序。
基本要求 
题目范围包括100以内的加减乘除,除法必须能整除,题目随机生成。 
有两种模式,固定题目数量计算用时,以及固定时间计算做题数量。 
固定题目数量计算用时为:固定30道题目,计算用时,超过15分钟强行中止作答,最后以正确率的百分数加剩余时间为得分。 
固定时间计算做题数量为:固定3分钟,以做对的题目数量乘以正确率为得分。 
需要保存每次测试的日期时间、模式、题目和分数。 
 
进阶要求 
新增多用户功能,程序可以通过用户名和密码注册及登录,每个用户的数据分开存储。 
新增错题本功能,程序支持查看本人错题本中的题目。做错的题目自动加入错题本,在进行任何练习时,优先从错题本中抽取题目,如果答对则从错题本中移除。 
新增热座PK功能,两种模式都支持两名用户的热座PK。热座PK指的是:出相同的题目,两个小登依次上机测试,然后对得分进行排名。需要保存用户的PK场数和胜率。 
小登精通电脑,请你想办法让他没法乱改你保存的数据,特别是场数和胜率。 
 
实现过程【进阶篇】 啊呀,要写后端了呢(→_→)
数据存储结构的设计 受 DynamoDB 无法通过其他属性对主键进行快速查询的特性影响,若通过一张表来存储全部的内容,在使用中反复的遍历会造成响应速度下降的问题,同时带来多余的费用开销。因此,设计多个表将会是更高效的一种方式,如下图
在图中已经包含了表名,主键(分区键)名称,其他属性名称,以及一些基本的处理逻辑
云平台上的准备工作 小登乱改?我选择直接上云( 
DynamoDB 在 DynamoDB 上分别创建名称为 Oral-Arithmetic-Auth, Oral-Arithmetic-Session, Oral-Arithmetic-User, Oral-Arithmetic-Quiz 的四张表,并按照上一步骤中的设计填入分区键,排序键这里用不到
其中,为便于用户使用和记忆,将 uid 设计为纯数字,因此Oral-Arithmetic-User 的分区键 uid 值类型应设为“数字”,其余为“字符串”
在创建完成后,全部表如下图所示
IAM 在 AWS 的 IAM 控制台创建一个角色,服务选择 Lambda
搜索并附加 AmazonDynamoDBFullAccess 和 AWSLambdaBasicExecutionRole 两个托管策略
名称填写 Oral-Arithmetic ,随后创建角色
创建完成后应如图所示
Lambda 在 AWS Lambda 上,分别创建 Oral-Arithmetic-Auth, Oral-Arithmetic-User, Oral-Arithmetic-Quiz 3个函数,分别用于处理用户的注册与登录,用户信息的查询与管理,题目的记录和管理
由于后端使用 Python 编写,在这里选择 Python 3.13 运行时,为方便调试,架构选择 x86_64 ,执行角色选择 使用现有角色 并找到刚刚创建的 IAM 角色
在附加选项中勾选 启用函数 URL ,别忘记将授权类型改为 NONE 并配置 CORS ,都设置好就可以创建啦
在创建成功后可以点击函数 URL 进行测试,若返回 "Hello from Lambda!" 则创建成功
API Gateway 在 API Gateway 中创建一个 HTTP API ,名称依然是 Oral-Arithmetic ,集成选择创建的三个 Lambda 函数
路由暂时全部设置为 ANY 
其余不需要操作,直接创建
Cloudfront 项目总得有个域名吧~我们通过 Cloudfront CDN 将域名和 API 关联起来
在 Cloudfront 中,源站选择创建的 API Gateway ,会自动匹配分配域名。由于 API Gateway 的分配域名是支持 HTTPS 的,因此协议选择 仅 HTTPS 即可
其他选项可以不用改,默认就好,另外注意启用 Web 应用程序防火墙(WAF)会单独收费,因此这里不启用
在最后的设置中填写备用域名为自己的域名①,然后点下方的请求证书②跳转到 Certificate Manager
在 Certificate Manager 中,我们可以请求一个通配符证书
请求后按照页面提示进行 CNAME 解析验证,添加解析后稍等约2分钟即可完成证书签发,签发完成后回到 Cloudfront ,点击刷新按钮③,稍后便可在左侧找到刚刚创建的证书④
一切准备就绪,按下创建按钮吧~
创建成功后可以找到一个分配域名,将域名设置 CNAME 解析至分配域名就可以用啦
用户注册与登录 后端 在 Python 文件中, 先对 DynamoDB 进行初始化
1 dynamodb = boto3.resource('dynamodb' ) 
随后对表名进行统一定义,便于修改和使用
1 2 3 4 AUTH_TABLE = 'Oral-Arithmetic-Auth'  SESSION_TABLE = 'Oral-Arithmetic-Session'  USER_TABLE = 'Oral-Arithmetic-USER'  QUIZ_TABLE = 'Oral-Arithmetic-Quiz'  
在 Lambda 的入口函数 lambda_handler 中,event 参数已经携带了每一次请求的各项内容,因此可以很方便的引用
这里将注册和登录的状态放在 URL 参数中,例如 https://arith-api.223322.best/auth?type=register ,可以通过以下方式读到
1 event_type = event["queryStringParameters" ]["type" ] 
可以加一个错误处理
1 2 3 4 5 6 7 try :    event_type = event["queryStringParameters" ]["type" ] except  KeyError:    return  {         "statusCode" : 400 ,         "body" : json.dumps({"message" : "Bad Request: Missing parameter" }),     } 
然后我们将用户的相关数据放在消息体中进行 POST 请求,比如注册时的消息体是这样的:
1 2 3 4 5 {   "email" :  "email" ,    "nickname" :  "nickname" ,    "password" :  "password"  } 
登录时又是这样的:
1 2 3 4 {   "email" :  "email" ,    "password" :  "password"  } 
在 AWS Lambda 中,可以通过以下方式获得消息体的内容
不过这里要注意一下,body 部分有时会被 Base64 加密,但好在 AWS Lambda 在 event 中给了一个 key 为 isBase64Encoded 的布尔值,所以可以这样写啦
1 2 3 4 5 6 7 8 9 body = (     (         json.loads(base64.b64decode(event["body" ]).decode("utf-8" ))         if  event.get("isBase64Encoded" )         else  event["body" ]     )     if  "body"  in  event     else  None  ) 
在得到了请求体之后,自然而然就可以解析需要的内容了
1 2 3 email = body.get("email" , None ) if  body else  None  nickname = body.get("nickname" , None ) if  body else  None  password = body.get("password" , None ) if  body else  None  
嗯…真的很怕有人点炒饭( ̄▽ ̄)”
接下来,就是注册与登录相关逻辑的编写啦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 def  register (email: str , nickname: str , password: str  ) -> None :    """      注册     :param email: 邮箱     :param nickname: 昵称     :param password: 密码     :raise ValueError: 参数为空或邮箱已存在     """          if  not  email or  not  nickname or  not  password:         raise  ValueError("Missing parameter" )          auth_table = dynamodb.Table(AUTH_TABLE)     user_table = dynamodb.Table(USER_TABLE)          if  auth_table.get_item(Key={"email" : email}).get("Item" ):         raise  ValueError("Email already exists" )          hashed_password = bcrypt.hashpw(password.encode("utf-8" ), bcrypt.gensalt())          while  True :         uid = int (str (uuid.uuid4().int )[:8 ])         print (uid)         data = user_table.get_item(Key={"uid" : uid})         if  "Item"  in  data:             continue          else :             break           auth_item = {         "email" : email,         "password" : hashed_password.decode("utf-8" ),         "uid" : uid,     }     auth_table.put_item(Item=auth_item)     user_item = {"uid" : uid, "email" : email, "nickname" : nickname}     user_table.put_item(Item=user_item) 
在这段代码中,对重复邮箱和 UID 进行了排除 ,同时使用 bcrypt 对密码进行了随机加盐加密 ,确保了密码储存的安全性(bcrypt 库需要安装)
因此,相应的,在登录时也要使用 bcrypt 对密码进行加密,然后将加密后的内容与数据库进行对比,来判断密码是否正确
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 def  login (email: str , password: str  ) -> [str , int ]:    """      登录     :param email: 邮箱     :param password: 密码     :return: 用于 Cookie 的 session 值和有效期     :raise ValueError: 参数为空或邮箱密码错误。     """          if  not  email or  not  password:         raise  ValueError("Missing parameter" )          auth_table = dynamodb.Table(AUTH_TABLE)     session_table = dynamodb.Table(SESSION_TABLE)          data = auth_table.get_item(Key={"email" : email}).get("Item" )          if  not  data or  not  bcrypt.checkpw(         password.encode("utf-8" ), data["password" ].encode("utf-8" )     ):         raise  ValueError("Invalid Email or Password" )     uid = data.get("uid" )          timestamp = int (time.time())     random_number = str (random.randint(1000 , 9999 ))     session_raw = f"{uid} {str (timestamp)} {random_number} " .encode("utf-8" )          session = hashlib.sha256(session_raw).hexdigest()     expiration = 604800             session_table.put_item(         Item={"session" : session, "uid" : uid, "expiration" : timestamp + expiration}     )     return  session, expiration 
在登录部分创建了一个名为 session 的 Cookie 作为登录凭据 ,并存入相应数据表,后续调用时可以通过 session 来确定用户的身份
接下来对 Lambda 入口函数进行完善,Auth 模块就基本完成啦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 def  lambda_handler (event, context ):         try :         event_type = event["queryStringParameters" ]["type" ]     except  KeyError:         return  {             "statusCode" : 400 ,             "body" : json.dumps({"message" : "Missing parameter" }),         }          body = (         (             json.loads(base64.b64decode(event["body" ]).decode("utf-8" ))             if  event.get("isBase64Encoded" )             else  event["body" ]         )         if  "body"  in  event         else  None      )     email = body.get("email" , None ) if  body else  None      nickname = body.get("nickname" , None ) if  body else  None      password = body.get("password" , None ) if  body else  None           if  event_type == "register" :         try :             register(email, nickname, password)             return  {"statusCode" : 201 , "body" : "Success" }         except  ValueError as  e:             return  {"statusCode" : 400 , "body" : json.dumps({"message" : str (e)})}          if  event_type == "login" :         try :             session, expiration = login(email, password)             return  {                 "statusCode" : 201 ,                 "headers" : {                     "Set-Cookie" : f"session={session} ; Path=/; Max-Age={expiration} ; Secure"                  },                 "body" : "Cookie Set" ,             }         except  ValueError as  e:             return  {"statusCode" : 400 , "body" : json.dumps({"message" : str (e)})}     return  {"statusCode" : 400 , "body" : ({"message" : "Invalid parameter" })} 
前端(注册) 当然是先创建一个表单来承载用户的输入啦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <div>   <h2>注册</h2>   <input v-model="email" type="text" placeholder="邮箱" @blur="emailSelected = true" :class="{ 'invalid': emailSelected && ( !isEmailValid || !email ) }" required/><br />   <input v-model="nickname" type="text" placeholder="昵称" @blur="nicknameSelected = true" :class="{ 'invalid': nicknameSelected && !nickname }" required/><br />   <div class="password-container">     <input v-model="password" type="password" placeholder="密码" @blur="passwordSelected = true" :class="{ 'invalid': passwordSelected && ( !isPasswordValid || !password ) }" required/>     <div class="tooltip">       <p :class="{ valid: isPasswordLongEnough, invalid: !isPasswordLongEnough }">至少8个字符</p>       <p :class="{ valid: hasUpperCase, invalid: !hasUpperCase, unnecessary: !hasUpperCase && isPasswordValid }">包含大写字母</p>       <p :class="{ valid: hasLowerCase, invalid: !hasLowerCase, unnecessary: !hasLowerCase && isPasswordValid }">包含小写字母</p>       <p :class="{ valid: hasNumber, invalid: !hasNumber, unnecessary: !hasNumber && isPasswordValid }">包含数字</p>     </div>   </div><br />   <input v-model="confirmPassword" type="password" placeholder="再次输入密码" @blur="confirmPasswordSelected = true" :class="{ 'invalid': confirmPasswordSelected && ( !confirmPassword || confirmPassword !== password ) }" required/><br />   <button @click="register" :disabled="!email || !nickname || !password || !confirmPassword || registerStatus == 'clicked'" :class="{ 'disabled': !email || !nickname || !password || !confirmPassword }">注册</button>   <div v-if="registerStatus == 'clicked'" class="status-clicked">请稍后...</div>   <div v-if="registerStatus == 'success'" class="status-success">注册成功,3秒后跳转至登录页</div>   <div v-if="registerStatus == 'failed'" class="status-failed">注册失败</div> </div> 
在这里,通过 vue 的 computed 组件可以实现判断用户的输入是否合法 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const  isEmailValid = computed (() =>  {  const  emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ ;   return  emailPattern.test (email.value ); }); const  hasUpperCase = computed (() =>  /[A-Z]/ .test (password.value ));const  hasLowerCase = computed (() =>  /[a-z]/ .test (password.value ));const  hasNumber = computed (() =>  /[0-9]/ .test (password.value ));const  isPasswordLongEnough = computed (() =>  password.value .length  >= 8 );const  isPasswordValid = computed (() =>  {  const  validCount = [hasUpperCase.value , hasLowerCase.value , hasNumber.value ].filter (Boolean ).length ;   return  validCount >= 2  && isPasswordLongEnough.value ; }); 
对于用户输入的密码,可以通过 crypto-js 进行一次 SHA256 加密 来确保密码在数据传输过程的安全
毕竟判断用户密码是否正确并不依赖于原始密码,相同的原始密码经过 SHA256 加密后的值仍然相同
1 2 3 import  CryptoJS  from  'crypto-js' ;const  encryptedPassword = CryptoJS .SHA256 (password.value ).toString ();
随后就是发起请求了,这里我引入了 axios
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const  register  = async  (  registerStatus.value  = "clicked" ;   if  (!process.env .API_URL ) {     console .error ('API_URL is not defined' );     alert ('未找到环境变量 API_URL' );     return ;   }   try  {     const  encryptedPassword = CryptoJS .SHA256 (password.value ).toString ();      const  response = await  axios.post (`${process.env.API_URL} /auth?type=register` , {       email : email.value ,       nickname : nickname.value ,       password : encryptedPassword     }, {       withCredentials : true      });     console .log (response.data );     registerStatus.value  = "success" ;     setTimeout (async  () => {       await  router.push ('/login' );     }, 3000 );    } catch  (error) {     console .error (error);     registerStatus.value  = "failed" ;   } }; 
看上去似乎大功告成了,就在前后端共同测试的时候,一个折磨了我一下午的坑出现了
坑 
CORS 预检响应未能成功,跨源请求被拦截
起初我以为是 Cloudfront 的锅,把 Cloudfront 的响应标头策略改为了 SimpleCORS 
但是…无济于事…
我又修改了 API Gateway 的 CORS
依然是那一行红色的 CORS Preflight Did Not Succeed …
查遍了互联网的相关内容,与 API Gateway、Lambda、Cloudfront 相关的内容少之又少,况且我已尝试过修改 API Gateway 和 Cloudfront 的配置,直到我找到了一个说法:
“把请求类型OPTIONS做个简单的过滤就好啦!!!” 
也是,毕竟预检请求属于 OPTIONS 方法,于是,我在 Lambda 的入口函数处写下了这样的处理逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def  lambda_handler (event, context ):         http_method = event["requestContext" ]["http" ]["method" ]          if  http_method == "OPTIONS" :         return  {             "statusCode" : 200 ,             "headers" : {                 "Access-Control-Allow-Origin" : "*" ,                 "Access-Control-Allow-Methods" : "GET, POST, OPTIONS" ,                 "Access-Control-Allow-Headers" : "*" ,                 "Access-Control-Allow-Credentials" : True              },             "body" : "" ,         } 
似乎有了效果,预检请求正常了,但是 POST 还有 CORS Missing Allow Origin 的错误
那么照猫画虎,把所有的响应都加上 CORS 标头吧~
响应一切正常,注册成功!看到那一行绿色的字的时候我差点跳起来
你 AWS 不愧是 AWS ,强大到到处都是坑 (我菜)
前端(登录) 注册都写好了登录不是照猫画虎嘛.webp
同样的加密,发起请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const  login  = async  (  loginStatus.value  = "clicked" ;   if  (!process.env .API_URL ) {     console .error ('API_URL is not defined' );     alert ('未找到环境变量 API_URL' );     return ;   }   try  {     const  encryptedPassword = CryptoJS .SHA256 (password.value ).toString ();      const  response = await  axios.post (`${process.env.API_URL} /auth?type=login` , {       email : email.value ,       password : encryptedPassword     }, {       withCredentials : true      });     console .log (response.data );     loginStatus.value  = "success" ;     setTimeout (async  () => {       await  router.push ('/' );     }, 3000 );    } catch  (error) {     console .error (error);     loginStatus.value  = "failed" ;   } }; 
同样的模板
1 2 3 4 5 6 7 8 9 <div>   <h2>登录</h2>   <input v-model="email" type="text" placeholder="邮箱" required/><br />   <input v-model="password" type="password" placeholder="密码" required/><br />   <button @click="login" :disabled="!email || !password" :class="{ 'disabled': !email || !password || loginStatus == 'clicked' }">登录</button>   <div v-if="loginStatus == 'clicked'" class="status-clicked">请稍后...</div>   <div v-if="loginStatus == 'success'" class="status-success">登录成功,3秒后跳转至主页</div>   <div v-if="loginStatus == 'failed'" class="status-failed">登录失败</div> </div> 
看上去没什么问题了呢,那么…
用户信息的获取 对先前代码的改动 其实在上一个部分留下了一个小坑,在用户注册的时候并没有将 Oral-Arithmetic-User 表的键一次写入,只写入了相关部分,此时强行读取不存在的键值可能会出现问题。因此为了简化读取过程,需要对注册过程写入数据库的部分稍作修改
1 2 3 4 5 6 7 8 9 10 11 12 user_item = {     "uid" : uid,     "email" : email,     "nickname" : nickname,     "avatar" : "" ,     "total" : 0 ,     "competition_total" : 0 ,     "competition_win" : 0 ,     "qid" : [],     "mistake" : [], } user_table.put_item(Item=user_item) 
同时对登录时的逻辑也稍作修改,使后端 API 返回 nickname session expiration
1 2 3 4 5 6 7 8 "body" : json.dumps(    {         "message" : "Cookie Set" ,         "session" : session,         "expiration" : expiration,         "nickname" : nickname,     } ), 
在 LoginView.vue 组件中通过 localStorage 将 nickname 的值保存在浏览器,同时设置 Cookie
1 2 localStorage .setItem ('nickname' , response.data .nickname );document .cookie  = `session=${response.data.session} ; Path=/; Max-Age=${response.data.expiration} ; Secure; SameSite=None` ;
再写一个获取 Cookie 的函数
1 2 3 4 5 6 export  const  getCookie = (name : string ): string  | null  =>  const  value = `; ${document .cookie} ` ;   const  parts = value.split (`; ${name} =` );   if  (parts.length  === 2 ) return  parts.pop ()?.split (';' ).shift () || null ;   return  null ; }; 
这样,就可以在其他组件中很方便地获取到 nickname 了,并且在 session 有效时,该值不会随页面的刷新而丢失
1 2 const  session = getCookie ('session' ) || '' ;const  nickname = session ? localStorage .getItem ('nickname' ) : '' ;
同时也可以实现登录后隐藏注册和登录按钮,显示用户名并将其作为用户信息页面的入口
1 2 3 4 5 <div class="auth-links">   <RouterLink to="/login" class="nav-link" active-class="active" v-if="!session">登录</RouterLink>   <RouterLink to="/register" class="nav-link" active-class="active" v-if="!session">注册</RouterLink>   <RouterLink to="/user" class="nav-link" active-class="active" v-if="session">{{ nickname }}</RouterLink> </div> 
后端 在登录后有了 Cookie ,那么随后的请求便可以通过 Cookie 来快速判断用户以及登录状态
AWS Lambda 已经对 Cookie 进行了处理,并以列表形式储存,格式大概是这样的
1 [ 'session=a2a85ccf1229199800259113fc4fc2c5774c0a6cc5ed7b657eb66a1a8b3be1c3'] 
因此要想读取 session 的值,需要再次进行处理,然后就可以查表来得到对应的 UID
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 def  get_uid_from_cookie (cookie: dict  ) -> int :    """      通过 Cookie 获取 UID     :param cookie: Cookie     :return: UID     """          if  not  cookie:         raise  ValueError("Missing parameter" )          cookie_dict = {i.split("=" )[0 ].strip(): i.split("=" )[1 ].strip() for  i in  cookie}     session = cookie_dict.get("session" )          session_table = dynamodb.Table(SESSION_TABLE)          data = session_table.get_item(Key={"session" : session})     if  "Item"  in  data:         expiration = data["Item" ].get("expiration" )         if  expiration < int (time.time()):             raise  ValueError("Session expired" )         return  data["Item" ].get("uid" )     else :         raise  ValueError("Missing parameter" ) 
这样一来,就可以获取用户数据啦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def  get (uid: int  ) -> dict :    """      获取用户数据     :param uid: UID     """          if  not  uid:         raise  ValueError("Missing parameter" )          user_table = dynamodb.Table(USER_TABLE)          data = user_table.get_item(Key={"uid" : uid})     if  "Item"  in  data:         userdata = data["Item" ]         userdata = json.loads(json.dumps(userdata, default=str ))         return  userdata     else :         raise  ValueError("Missing parameter" ) 
前端 先初始化 ref 便于模板使用
1 2 3 4 5 6 7 8 9 const  uid = ref ('' );                                      const  email = ref ('' );                                    const  nickname = ref<string  | null >(null );                const  avatar = ref ('' );                                   const  total = ref (0 );                                     const  competition_total = ref (0 );                         const  competition_win = ref (0 );                           const  qid = ref<number []>([]);                            const  mistake = ref<[]>([]);                              
再创建一个 fetchUserData 函数,在函数内请求 API 并对相应 ref 值进行修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 const  fetchUserData  = async  (  if  (!process.env .API_URL ) {     console .error ('API_URL is not defined' );     alert ('未找到环境变量 API_URL' );     return ;   }   try  {     const  response = await  axios.get (`${process.env.API_URL} /user?type=get` , {       withCredentials : true      });     const  userdata = response.data ;     uid.value  = userdata["uid" ];     email.value  = userdata["email" ];     nickname.value  = userdata["nickname" ];     avatar.value  = userdata["avatar" ];     total.value  = userdata["total" ];     competition_total.value  = userdata["competition_total" ];     competition_win.value  = userdata["competition_win" ];     qid.value  = userdata["qid" ];     mistake.value  = userdata["mistake" ];   } catch  (error) {     console .error (error);   } }; 
最后通过一个事件钩子完成在登录状态下进入页面时的自动加载
1 2 3 4 5 onMounted (() =>  {  if  (session) {     fetchUserData ();   } }); 
大致写个模板,用表格形式来呈现内容,搞定~
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <template>   <div class="profile-container">     <img :src="`${avatar}`" class="profile-avatar" alt=""/>     <div class="profile-info">       <h2>欢迎,{{ nickname }}</h2>       <table class="profile-table">         <tr>           <td>UID:</td>           <td>{{ uid }}</td>         </tr>         <tr>           <td>邮箱:</td>           <td>{{ email }}</td>         </tr>         <tr>           <td>总场次:</td>           <td>{{ total }}</td>         </tr>         <tr>           <td>PK 总场次:</td>           <td>{{ competition_total }}</td>         </tr>         <tr>           <td>PK 胜场次:</td>           <td>{{ competition_win }}</td>         </tr>       </table>     </div>   </div> </template> 
在这里,图片使用了 Base64 格式编码以便于储存和使用
效果大概是这个样子啦~
作答情况的上传 前端 在先前基础篇的步骤中,已经实现了下载 JSON 来保存作答情况,那么上传对应的 JSON 即可
在上传前,先通过查找 Cookie 中 session 是否存在来判断用户是否登录,Cookie 过期会被浏览器自动清除,因此该方法对判断 session 是否有效同样适用
1 2 3 const  session = getCookie ('session' ) || '' ;if  (!session) return  console .info ('Not logged in' );
为方便复用,将 axios 相关的请求部分剥离成一个单独的文件 request.ts 放入 /src/utils,内容如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import  axios from  'axios' ;import  { getCookie } from  '@/utils/cookie' ;const  sendRequest  = async  (method : 'get'  | 'post' , url : string , data : unknown , errorMessage : { value: string  }  const  session = getCookie ('session' ) || '' ;   if  (!session) return  console .info ('Not logged in' );   if  (!process.env .API_URL ) return  alert ('未找到环境变量 API_URL' );   try  {     const  response = await  axios ({ method, url : `${process.env.API_URL} ${url} ` , data, withCredentials : true  });     return  response.data ;   } catch  (error) {     console .error (error);     errorMessage.value  = axios.isAxiosError (error) && error.response ?.data ?.message  || "请求失败" ;   } }; export  const  postData  = async  (url : string , data : unknown , errorMessage : { value: string  }  await  sendRequest ('post' , url, data, errorMessage); }; export  const  fetchData  = async  (url : string , errorMessage : { value: string  }  return  await  sendRequest ('get' , url, null , errorMessage); }; 
然后在其他组件引用这个模块即可
1 import  { postData, fetchData } from  '@/utils/request' ;
那么可以创建一个 uploadSummary 函数并在 stopQuiz 函数调用,如果用户登录,则在答题终止时自动进行上传
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const  getQuizSummary  = (  const  mode = selectedTimeLimit.value  === timeLimit[0 ] ? 'quantity'  : 'time' ;   return  {     questions : questionsDetails.value ,     correctCount : correctCount.value ,     questionCount : questionCount.value ,     startTime : startTime.value ,     elapsedTime : elapsedTime.value ,     score : score.value ,     mode : mode   }; }; const  uploadSummary  = async  (  const  quizSummary = getQuizSummary ();   await  postData ('/quiz?type=save_quiz' , quizSummary, errorMessage); }; 
后端 在后端首先通过解析 Cookie 得到 session ,然后查询 UID,与先前逻辑一致
在得到 UID 后,生成 qid 并将作答情况写入数据库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 while  True :    qid = str (uuid.uuid4())     data = quiz_table.get_item(Key={"qid" : qid})     if  "Item"  in  data:         continue      else :         break  quiz_item = {     "qid" : qid,     "mode" : mode,     "quiz_time" : quiz_time,     "questions" : questions,     "question_count" : question_count,     "correct_count" : correct_count,     "used_time" : used_time,     "is_competition" : is_competition,     "allow_competition" : allow_competition,     "p1_uid" : uid,     "p2_uid" : [], } quiz_table.put_item(Item=quiz_item) 
再将 qid 存入 user 表中,实现作答情况与用户的对应,同时更新场次数据
1 2 3 4 5 6 7 user_table.update_item(     Key={"uid" : uid},     UpdateExpression="SET qid = list_append(if_not_exists(qid, :empty_list), :qid), #total = #total + :increment" ,     ExpressionAttributeNames={"#total" : "total" },     ExpressionAttributeValues={":qid" : [qid], ":empty_list" : [], ":increment" : 1 }, ) 
错题本 错题的收集、获取与移除 在基础篇中已经通过 QuestionDetail 这个接口来储存单个问题的题目信息,那么在判断正误的函数 checkAnswer 中,可以对错误的题目进行上传,来实现了错题的收集,同时对于再次做对的错题,也可以做移出处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 if  (parseFloat (userAnswer.value ) === correctAnswer.value ) {  feedback.value  = '做对啦!' ;   correctCount.value ++;   if  (isMistake.value ) {     removeMistake ({       question : question.value ,       userAnswer : userAnswer.value ,       correctAnswer : correctAnswer.value ,       isCorrect : true      });   } } else  {   feedback.value  = `再想想呢...正确答案是 ${correctAnswer.value}  哦` ;   if  (!isMistake.value ) {     uploadMistake ({       question : question.value ,       userAnswer : userAnswer.value ,       correctAnswer : correctAnswer.value ,       isCorrect : false      });   } } 
上传部分也很简单
1 2 3 4 5 6 7 const  uploadMistake  = async  (questionDetail : QuestionDetail   await  postData ('/quiz?type=save_mistake' , questionDetail, errorMessage); }; const  removeMistake  = async  (questionDetail : QuestionDetail   await  postData ('/quiz?type=remove_mistake' , questionDetail, errorMessage); }; 
那怎样才能知道某一道题是错题呢?在做题前获取一下错题信息就可以啦
先创建一个 ref 用来存储是否为错题的状态
1 const  isMistake = ref (false );                            
然后在 startQuiz 函数初始化状态后对后端发起请求,得到错题数据,之后再调用 generateQuestion 完成题目的生成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 interface  MistakeDetail  {  question : string ;   userAnswer : string ;   correctAnswer : number  | null ; } const  mistake = ref<MistakeDetail []>([]);                const  startQuiz  = async  (  started.value  = true ;   stopped.value  = false ;   startTime.value  = Date .now ();   elapsedTime.value  = 0 ;   questionCount.value  = 0 ;   correctCount.value  = 0 ;   questionsDetails.value  = [];   mistake.value  = [];   try  {     const  data = await  fetchData ('/quiz?type=get_mistakes' , errorMessage);     if  (data) {       mistake.value  = data;     }   } catch  (error) {     console .error ('Failed to fetch:' , error);   }   generateQuestion ();      ...    } 
在 generateQuestion 函数中加入对错题数据的处理,来实现错题的优先展示,若是错题,将 isMistake 的值改为 true 以便移除错题部分使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const  generateQuestion  = (  if  (mistake.value .length  > 0 ) {     const  mistakeQuestion = mistake.value .pop ();     if  (mistakeQuestion) {       isMistake.value  = true ;       question.value  = mistakeQuestion.question ;       correctAnswer.value  = Number (mistakeQuestion.correctAnswer );       userAnswer.value  = '' ;       feedback.value  = '' ;       answered.value  = false ;       return ;     }   }        ...    } 
后端部分实现相应的数据库读取和编辑功能就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 def  save_mistake (    uid: int , question: str , user_answer: int , correct_answer: int  None :    """      保存错题     :param uid: 用户 ID     :param question: 题目     :param user_answer: 用户答案     :param correct_answer: 正确答案     """          if  uid is  None  or  not  question or  user_answer is  None  or  correct_answer is  None :         raise  ValueError("Missing parameter" )          user_table = dynamodb.Table(USER_TABLE)          mistake = {         "question" : question,         "userAnswer" : user_answer,         "correctAnswer" : correct_answer,     }          user_table.update_item(         Key={"uid" : uid},         UpdateExpression="SET mistake = list_append(if_not_exists(mistake, :empty_list), :mistake)" ,         ExpressionAttributeValues={":mistake" : [mistake], ":empty_list" : []},     ) def  remove_mistake (uid: int , question: str  ) -> None :    """      移除错题     :param uid: 用户 ID     :param question: 题目     """          if  uid is  None  or  not  question:         raise  ValueError("Missing parameter" )          user_table = dynamodb.Table(USER_TABLE)          response = user_table.get_item(Key={"uid" : uid})     if  "Item"  in  response:         mistakes = response["Item" ].get("mistake" , [])                  mistakes = [mistake for  mistake in  mistakes if  mistake["question" ] != question]                  user_table.update_item(             Key={"uid" : uid},             UpdateExpression="SET mistake = :mistakes" ,             ExpressionAttributeValues={":mistakes" : mistakes},         )     else :         raise  ValueError("错题不存在" ) def  get_mistakes (uid: int  ) -> list :    """      获取错题     :param uid: 用户 ID     :return: 错题列表     """          if  uid is  None :         raise  ValueError("Missing parameter" )          user_table = dynamodb.Table(USER_TABLE)          response = user_table.get_item(Key={"uid" : uid})     if  "Item"  in  response:         return  response["Item" ].get("mistake" , [])     else :         raise  ValueError("Missing parameter" ) 
错题展示页面 上面已经获取到错题列表了,用一个自动扩展的表格把内容存起来就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <div class="mistake-table-wrapper">   <table class="mistake-table">     <thead>       <tr>         <th>题目</th>         <th>你的作答</th>         <th>正确答案</th>         <th>操作</th>       </tr>     </thead>     <tbody>       <tr v-for="(item, index) in mistake" :key="index">         <td>{{ item.question }}</td>         <td>{{ item.userAnswer }}</td>         <td>{{ item.correctAnswer }}</td>         <td>           <button class="remove-button" @click="removeMistake(index)">删除</button>         </td>       </tr>     </tbody>   </table> </div> 
未完待续 未完待续
程序相关 Github 本程序源代码在 Github 平台完全开源
【链接 Link】: 【前端】Oral-Arithmetic   【后端】Oral-Arithmetic-Serverless MIT License
可以在 Github 点点右上角那颗小星星嘛?quq
DEMO https://arith.223322.best 
其他说明 后续的完善与更新将在 Github 平台进行,这里不再重复描述,可以前往前端 和后端 对应的 Github Commits 页面查看具体内容