feat: 完善代码,应该可以进行签到了,就差测试了

This commit is contained in:
2025-12-16 22:39:42 +08:00
parent 366ab0b2e8
commit 66199c8130
16 changed files with 353 additions and 90 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ data.db
image.jpg image.jpg
__pycache__/ __pycache__/
demo.__pycache__ demo.__pycache__
uploads

View File

@@ -1,13 +1,36 @@
## 小北学生线上签到平台 ## 小北学生线上签到平台
**让你免除必须到指定地点签到的烦恼(未完成),本代码采用 [MIT License](LICENSE) , 仅供学习交流** **让你免除必须到指定地点签到的烦恼,本代码采用 [MIT License](LICENSE) , 仅供学习交流**
#### 已有功能 #### 已有功能
- 登录 - 登录
- 获取个人信息 - 获取个人信息
- 获取签到信息 - 获取签到信息
- 上传图片签到
## 📜 License ### 界面
- 登录界面
![登录界面](./images/image.png)
- 个人界面
![个人界面](./images/image2.png)
- 首页
![首页](./images/image3.png)
- 签到列表
![签到列表](./images/image4.png)
- 签到页
![签到页](./images/image5.png)
### 📜 License
This project is licensed under the [MIT License](LICENSE). This project is licensed under the [MIT License](LICENSE).

BIN
images/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
images/image2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/image3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
images/image4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/image5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from models import User from models import User
@@ -13,7 +14,13 @@ async def index(request: Request):
token = request.cookies.get("xbxs_token") token = request.cookies.get("xbxs_token")
if token: if token:
return RedirectResponse("/home") return RedirectResponse("/home")
return templates.TemplateResponse("login.html", {"request": request}) return templates.TemplateResponse(
"login.html",
{
"request": request,
"year": datetime.now().year,
},
)
@router.post("/login") @router.post("/login")

View File

@@ -1,6 +1,19 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, status import json
from fastapi.responses import HTMLResponse import os
from typing import List
from uuid import uuid4
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
status,
)
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.templating import Jinja2Templates from starlette.templating import Jinja2Templates
from services.services import XBXS from services.services import XBXS
@@ -88,7 +101,6 @@ async def sign_detail(
know_detail = await xbxs.get_know(str(knowing_id)) know_detail = await xbxs.get_know(str(knowing_id))
data = know_detail.get("data", {}) data = know_detail.get("data", {})
# 获取具体的数据 # 获取具体的数据
knowing_name = data.get("knowingName", "未知签到") knowing_name = data.get("knowingName", "未知签到")
start_date = data.get("startDate", "未知") start_date = data.get("startDate", "未知")
@@ -98,12 +110,14 @@ async def sign_detail(
send_role = data.get("sendRole", "未知") send_role = data.get("sendRole", "未知")
is_check = data.get("isCheck", 0) is_check = data.get("isCheck", 0)
is_picture = data.get("isPicture", 0) is_picture = data.get("isPicture", 0)
is_finish_know = data.get("isFinishKnowing", 0) # is_finish_know = data.get("isFinishKnowing", 0)
locations = json.loads(data.get("location", []))
return templates.TemplateResponse( return templates.TemplateResponse(
"sign_detail.html", "sign_detail.html",
{ {
"request": request, "request": request,
"know_id": knowing_id,
"knowing_name": knowing_name, "knowing_name": knowing_name,
"start_date": start_date, "start_date": start_date,
"start_time": start_time, "start_time": start_time,
@@ -112,6 +126,33 @@ async def sign_detail(
"send_role": send_role, "send_role": send_role,
"is_check": is_check, "is_check": is_check,
"is_picture": is_picture, "is_picture": is_picture,
"is_finish_know": is_finish_know, "locations": locations,
# "is_finish_know": is_finish_know,
}, },
) )
# 完善的 sign 完成接口
@router.post("/sign/{know_id}/complete")
async def complete_sign(
know_id: int,
request: Request,
location: str = Form(...), # 获取经纬度,必填
address: str = Form(...), # 获取经纬度,必填
remark: str = Form(...), # 获取备注,必填
images: List[UploadFile] = File(...), # 获取上传的图片,支持多张
xbxs: XBXS = Depends(get_xbxs),
):
# 备注必填,未填写时返回错误
if not remark:
raise HTTPException(status_code=400, detail="备注是必填项")
print(location, remark, know_id, images, address)
try:
await xbxs.update_student_know(str(know_id), remark, address, location, images)
except Exception as e:
raise HTTPException(status_code=500, detail="保存签到数据失败")
# 成功后跳转到签到列表
return RedirectResponse(url="/sign", status_code=status.HTTP_303_SEE_OTHER)

View File

@@ -1,5 +1,7 @@
import base64 import base64
from typing import List
from uuid import uuid4 from uuid import uuid4
from fastapi import UploadFile
import httpx import httpx
import config import config
from services.student_cache import get_student_by_token, save_student from services.student_cache import get_student_by_token, save_student
@@ -136,25 +138,35 @@ class XBXS:
pass pass
return result return result
async def update_student_know(self, knowingID: str): async def update_student_know(
files = { self,
"file0": ( knowingID: str,
str(uuid4()), remark: str,
open("./image.jpg", "rb"), address: str,
"image/jpeg", location: str,
images: List[UploadFile],
):
files = {}
for i, image in enumerate(images):
# 为每个图片生成一个唯一的 UUID 文件名
files[f"file{i}"] = (
str(uuid4()), # 这里使用 UUID 作为文件名
await image.read(),
"image/jpeg", # 假设是 jpeg 格式,按需修改
) )
} # resp = await self.client.post(
resp = await self.client.post( # "/xiaobei-api/student/know/updateStudentKnow",
"/xiaobei-api/student/know/updateStudentKnow", # data={
data={ # "address": address,
"address": "1388号", # "remark": remark,
"remark": "", # "knowingId": knowingID,
"knowingId": knowingID, # "location": location,
"location": "106.5346788194445:29.343291015625", # # "location": "106.5346788194445:29.343291015625",
"size": "1", # "size": len(images),
}, # },
) # files=files,
resp.raise_for_status() # )
# resp.raise_for_status()
async def get_student_info_cached(self): async def get_student_info_cached(self):
cached = get_student_by_token(str(self.token)) cached = get_student_by_token(str(self.token))

23
templates/auth.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{% block title %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-zinc-100">
<!-- App 容器 -->
<div class="mx-auto max-w-md min-h-screen bg-white relative">
<!-- 主内容 -->
<main class="pb-20">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>

View File

@@ -14,10 +14,11 @@
<div class="mx-auto max-w-md min-h-screen bg-white relative"> <div class="mx-auto max-w-md min-h-screen bg-white relative">
<!-- 主内容 --> <!-- 主内容 -->
<main class="pb-20"> <main class="pt-6 pb-20">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- 底部 Tab --> <!-- 底部 Tab -->
<nav class="fixed bottom-0 left-1/2 -translate-x-1/2 <nav class="fixed bottom-0 left-1/2 -translate-x-1/2
w-full max-w-md w-full max-w-md

View File

@@ -6,6 +6,8 @@
<h1 class="text-xl font-bold">应用</h1> <h1 class="text-xl font-bold">应用</h1>
<hr>
<!-- 签到 App 卡片 --> <!-- 签到 App 卡片 -->
<a href="/sign" class="block bg-blue-500 text-white rounded-2xl p-6 shadow <a href="/sign" class="block bg-blue-500 text-white rounded-2xl p-6 shadow
active:scale-95 transition"> active:scale-95 transition">

View File

@@ -1,55 +1,116 @@
<!DOCTYPE html> {% extends "auth.html" %}
<html lang="zh-CN">
<head> {% block title %}登录{% endblock %}
<meta charset="UTF-8" />
<title>登录</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen flex items-center justify-center bg-gray-100"> {% block content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div class="w-full max-w-sm bg-white rounded-xl shadow-lg p-8"> <div class="w-full max-w-sm bg-white rounded-2xl shadow-xl p-8 relative">
<h2 class="text-2xl font-bold text-center mb-6">登录</h2>
<!-- Logo / 标题 -->
<div class="text-center mb-6">
<div
class="mx-auto w-14 h-14 flex items-center justify-center rounded-full bg-blue-600 text-white text-xl font-bold shadow">
</div>
<h2 class="text-2xl font-bold mt-4">小北学生系统</h2>
<p class="text-sm text-gray-500 mt-1">
校园签到 · 数据可视化 · 自动化
</p>
</div>
<!-- 表单 -->
<div class="space-y-4"> <div class="space-y-4">
<!-- 用户名 -->
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
👤
</span>
<input id="username" type="text" placeholder="用户名" <input id="username" type="text" placeholder="用户名"
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring" /> class="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring focus:ring-blue-200" />
</div>
<!-- 密码 -->
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
🔒
</span>
<input id="password" type="password" placeholder="密码" <input id="password" type="password" placeholder="密码"
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring" /> class="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring focus:ring-blue-200" />
</div>
<button onclick="login()" <!-- 登录按钮 -->
class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition"> <button id="login-btn"
登录 class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition disabled:opacity-60 flex items-center justify-center gap-2">
<span id="btn-text">登录</span>
<svg id="loading" class="hidden w-5 h-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</button> </button>
</div> </div>
<p id="error" class="text-red-500 text-sm mt-4 hidden"></p> <!-- 错误提示 -->
<p id="error" class="text-red-500 text-sm mt-4 text-center hidden animate-pulse"></p>
<!-- 底部 -->
<div class="mt-6 text-center text-xs text-gray-400">
&copy; {{ year }} 小北 · 学生服务系统
</div>
</div> </div>
<script> </div>
async function login() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
<script>
const btn = document.getElementById("login-btn")
const btnText = document.getElementById("btn-text")
const loading = document.getElementById("loading")
const errorBox = document.getElementById("error")
async function login() {
const username = document.getElementById("username").value.trim()
const password = document.getElementById("password").value.trim()
if (!username || !password) {
showError("请输入用户名和密码")
return
}
btn.disabled = true
btnText.innerText = "登录中"
loading.classList.remove("hidden")
try {
const res = await fetch("/login", { const res = await fetch("/login", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); })
if (res.ok) { if (res.ok) {
window.location.href = "/home"; window.location.href = "/home"
} else { } else {
document.getElementById("error").innerText = "登录失败"; showError("用户名或密码错误")
document.getElementById("error").classList.remove("hidden"); }
} catch {
showError("网络异常,请稍后再试")
} finally {
btn.disabled = false
btnText.innerText = "登录"
loading.classList.add("hidden")
} }
} }
</script>
</body> function showError(msg) {
errorBox.innerText = msg
errorBox.classList.remove("hidden")
}
</html> // 事件绑定
btn.addEventListener("click", login)
document.addEventListener("keydown", e => {
if (e.key === "Enter") login()
})
</script>
{% endblock %}

View File

@@ -4,6 +4,10 @@
{% block content %} {% block content %}
<div class="p-4 space-y-6"> <div class="p-4 space-y-6">
<h1 class="text-xl font-bold">个人信息</h1>
<hr>
<!-- 头像 + 名字 --> <!-- 头像 + 名字 -->
<div class="bg-gradient-to-r from-blue-500 to-indigo-500 <div class="bg-gradient-to-r from-blue-500 to-indigo-500
rounded-2xl p-6 text-white"> rounded-2xl p-6 text-white">

View File

@@ -3,72 +3,160 @@
{% block title %}签到详情{% endblock %} {% block title %}签到详情{% endblock %}
{% block content %} {% block content %}
<div class="p-4 space-y-4"> <div class="p-4 max-w-xl mx-auto space-y-6">
<!-- 标题 --> <!-- 顶部 -->
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h1 class="text-xl font-bold">签到详情</h1> <h1 class="text-xl font-bold">签到详情</h1>
<a href="/sign" class="text-sm text-blue-500 hover:underline">返回签到列表</a> <a href="/sign" class="text-sm text-blue-500 hover:underline">返回签到列表</a>
</div> </div>
<hr /> <hr>
<!-- 详情信息 --> <!-- 基本信息 -->
<div class="space-y-4"> <div class="space-y-3 text-sm">
<div> <div>
<span class="text-zinc-500">签到名称</span> <span class="text-zinc-500">签到名称</span>
<div class="font-medium">{{ knowing_name }}</div> <div class="font-medium">{{ knowing_name }}</div>
</div> </div>
<div>
<span class="text-zinc-500">日期</span>
<div class="font-medium">{{ start_date }}</div>
</div>
<div> <div>
<span class="text-zinc-500">时间</span> <span class="text-zinc-500">时间</span>
<div class="font-medium">{{ start_time }} - {{ end_time }}</div> <div class="font-medium">
{{ start_date }} {{ start_time }} - {{ end_time }}
</div>
</div> </div>
<div> <div>
<span class="text-zinc-500">发起人</span> <span class="text-zinc-500">发起人</span>
<div class="font-medium">{{ send_name }} ({{ send_role }})</div> <div class="font-medium">
{{ send_name }}{{ send_role }}
</div>
</div> </div>
<div> <!-- <div>
<span class="text-zinc-500">签到状态</span> <span class="text-zinc-500">签到状态</span>
<div class="font-medium"> <div class="font-medium">
{% if is_finish_know == 1 %} {% if is_finish_know == 1 %}
<span class="text-green-600">已签到</span> <span class="text-green-600">已签到</span>
{% elif is_check == 1 %}
<span class="text-green-600">已签到</span>
{% else %} {% else %}
<span class="text-red-600">未签到</span> <span class="text-red-600">未签到</span>
{% endif %} {% endif %}
</div> </div>
</div> -->
<div>
<span class="text-zinc-500">是否需要拍照</span>
<div class="font-medium">
{{ "是" if is_picture == 0 else "否" }}
</div>
</div>
</div> </div>
<!-- 签到表单 -->
{% if is_finish_know == 0 and is_check == 0 %}
<form method="POST" action="/sign/{{ know_id }}/complete" enctype="multipart/form-data"
class="space-y-4 border rounded-lg p-4 bg-zinc-50">
<!-- 地点选择 -->
<div>
<label class="block text-sm font-medium text-zinc-700 mb-1">
签到地点
</label>
<select id="location-select" class="w-full p-2 border rounded-md bg-white">
<option value="">请选择预设地点</option>
{% for loc in locations %}
<option value="{{ loc.longitude }}:{{ loc.latitude }}" data-address="{{ loc.location }}">
{{ loc.alias }}{{ loc.radius }}m
</option>
{% endfor %}
<option value="custom">📍 自定义地点</option>
</select>
</div>
<!-- 自定义地点 -->
<div id="custom-location" class="hidden space-y-2">
<div>
<label class="block text-sm text-zinc-600">
经度:纬度
</label>
<input type="text" id="custom-location-value" class="w-full p-2 border rounded-md"
placeholder="106.534597:29.341189">
</div>
<div>
<label class="block text-sm text-zinc-600">
地址描述
</label>
<input type="text" id="custom-address" class="w-full p-2 border rounded-md" placeholder="请输入签到地址">
</div>
</div>
<!-- 实际提交字段 -->
<input type="hidden" name="location" id="location" required>
<input type="hidden" name="address" id="address" required>
<!-- 备注 -->
<div>
<label class="block text-sm font-medium text-zinc-700 mb-1">
备注(必填)
</label>
<textarea name="remark" class="w-full p-2 border rounded-md" rows="3" placeholder="请输入备注"
required></textarea>
</div>
<!-- 图片上传 -->
{% if is_picture == 1 %} {% if is_picture == 1 %}
<div> <div>
<span class="text-zinc-500">签到需要拍照</span> <label class="block text-sm font-medium text-zinc-700 mb-1">
<div class="font-medium"></div> 上传图片(可多张)
</div> </label>
{% else %} <input type="file" name="images" multiple accept="image/*" class="w-full p-2 border rounded-md bg-white">
<div>
<span class="text-zinc-500">签到需要拍照</span>
<div class="font-medium"></div>
</div> </div>
{% endif %} {% endif %}
</div>
<!-- 完成签到按钮 --> <!-- 提交 -->
{% if is_finish_know == 0 and is_check == 0 %} <button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
<div class="mt-4"> 完成签到
<form method="post" action="/sign/{{ know_id }}/complete"> </button>
<button type="submit" class="bg-blue-600 text-white py-2 px-4 rounded-lg">完成签到</button>
</form> </form>
</div>
{% endif %} {% endif %}
</div> </div>
<script>
const select = document.getElementById("location-select")
const customBox = document.getElementById("custom-location")
select?.addEventListener("change", function () {
const value = this.value
if (value === "custom") {
customBox.classList.remove("hidden")
document.getElementById("location").value = ""
document.getElementById("address").value = ""
return
}
customBox.classList.add("hidden")
if (!value) return
const opt = this.options[this.selectedIndex]
document.getElementById("location").value = value
document.getElementById("address").value = opt.dataset.address
})
document.getElementById("custom-location-value")?.addEventListener("input", e => {
document.getElementById("location").value = e.target.value
})
document.getElementById("custom-address")?.addEventListener("input", e => {
document.getElementById("address").value = e.target.value
})
</script>
{% endblock %} {% endblock %}