feat: 第一次上传
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
data.db
|
||||
image.jpg
|
||||
__pycache__/
|
||||
demo.__pycache__
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 zhanghoulin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
13
README.md
Normal file
13
README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## 小北学生线上签到平台
|
||||
|
||||
**让你免除必须到指定地点签到的烦恼(未完成),本代码采用 [MIT License](LICENSE) , 仅供学习交流**
|
||||
|
||||
#### 已有功能
|
||||
|
||||
- 登录
|
||||
- 获取个人信息
|
||||
- 获取签到信息
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
18
app.py
Normal file
18
app.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
from routers import auth, home
|
||||
from db.init_db import init_db
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(home.router)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app:app", host="0.0.0.0", port=8080, reload=True)
|
||||
18
config.py
Normal file
18
config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEBUG = True
|
||||
|
||||
BASE_URL = "https://xiaobei.yinghuaonline.com"
|
||||
HTTPX_TIMEOUT = 10
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Html5Plus/1.0 (Immersed/20) uni-app"
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "data.db"
|
||||
|
||||
|
||||
def get_env(key: str):
|
||||
return os.getenv(key, "")
|
||||
28
db/init_db.py
Normal file
28
db/init_db.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import sqlite3
|
||||
from config import DB_PATH
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS student (
|
||||
token TEXT PRIMARY KEY,
|
||||
student_id TEXT,
|
||||
student_number TEXT,
|
||||
student_name TEXT,
|
||||
student_sex TEXT,
|
||||
dept_name TEXT,
|
||||
college_name TEXT,
|
||||
professional_name TEXT,
|
||||
school_name TEXT,
|
||||
raw_json TEXT,
|
||||
updated_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
61
db/student.py
Normal file
61
db/student.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
from config import DB_PATH
|
||||
|
||||
|
||||
def get_student_by_token(token: str):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT raw_json FROM student WHERE token = ?",
|
||||
(token,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return json.loads(row[0])
|
||||
|
||||
|
||||
def save_student(token: str, student: dict):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO student (
|
||||
token,
|
||||
student_id,
|
||||
student_number,
|
||||
student_name,
|
||||
student_sex,
|
||||
dept_name,
|
||||
college_name,
|
||||
professional_name,
|
||||
school_name,
|
||||
raw_json,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
token,
|
||||
student.get("id"),
|
||||
student.get("studentNumber"),
|
||||
student.get("studentName"),
|
||||
student.get("studentSex"),
|
||||
student.get("deptName"),
|
||||
student.get("collegeName"),
|
||||
student.get("professionalName"),
|
||||
student.get("schoolName"),
|
||||
json.dumps(student, ensure_ascii=False),
|
||||
int(time.time()),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
156
demo.py
Normal file
156
demo.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
import base64
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_env(key: str):
|
||||
return os.getenv(key, "")
|
||||
|
||||
|
||||
class XBXS:
|
||||
def __init__(self, u: str, p: str) -> None:
|
||||
self.username = u
|
||||
self.password = base64.b64encode(p.encode()).decode()
|
||||
self.token = None
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url="https://xiaobei.yinghuaonline.com",
|
||||
timeout=10,
|
||||
headers={
|
||||
"user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Html5Plus/1.0 (Immersed/20) uni-app",
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh-Hans;q=0.9",
|
||||
"accept-encoding": "gzip, deflate, br",
|
||||
},
|
||||
)
|
||||
|
||||
async def get_captcha(self):
|
||||
resp = await self.client.get("/xiaobei-api/captchaImage")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if "uuid" not in result:
|
||||
raise RuntimeError(f"captcha error: {result}")
|
||||
return {
|
||||
"showCode": result.get("showCode", ""),
|
||||
"uuid": result.get("uuid", ""),
|
||||
}
|
||||
|
||||
async def login(self):
|
||||
data = await self.get_captcha()
|
||||
resp = await self.client.post(
|
||||
"/xiaobei-api/userLogin",
|
||||
json={
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"code": data.get("showCode", ""),
|
||||
"uuid": data.get("uuid", ""),
|
||||
"appUuid": "",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
result = resp.json()
|
||||
|
||||
self.token = result.get("token")
|
||||
|
||||
self.client.headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
return resp.json()
|
||||
|
||||
async def get_info(self):
|
||||
resp = await self.client.get("/xiaobei-api/getInfo")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return {
|
||||
"userName": result.get("user", {}).get("userName", ""),
|
||||
"nickName": result.get("user", {}).get("nickName", ""),
|
||||
"roles": result.get("roles", []),
|
||||
"phonenumber": result.get("user", {}).get("phonenumber", ""),
|
||||
"bindphonenumber": result.get("user", {}).get("bindphonenumber", ""),
|
||||
"idCard": result.get("user", {}).get("idCard", ""),
|
||||
"trtcId": result.get("user", {}).get("trtcId", ""),
|
||||
}
|
||||
|
||||
async def get_student_info(self):
|
||||
resp = await self.client.get("/xiaobei-api/student/studentInfo")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return {
|
||||
"id": result.get("data", {}).get("id", ""),
|
||||
"studentNumber": result.get("data", {}).get("studentNumber", ""),
|
||||
"deptId": result.get("data", {}).get("deptId", ""),
|
||||
"deptName": result.get("data", {}).get("deptName", ""),
|
||||
"studentName": result.get("data", {}).get("studentName", ""),
|
||||
"studentIpone": result.get("data", {}).get("studentIpone", ""),
|
||||
"studentIdCard": result.get("data", {}).get("studentIdCard", ""),
|
||||
"studentSex": result.get("data", {}).get("studentSex", ""),
|
||||
"studentSchoolTime": result.get("data", {}).get("studentSchoolTime", ""),
|
||||
"schoolId": result.get("data", {}).get("schoolId", ""),
|
||||
"schoolName": result.get("data", {}).get("schoolName", ""),
|
||||
"collegeId": result.get("data", {}).get("collegeId", ""),
|
||||
"collegeName": result.get("data", {}).get("collegeName", ""),
|
||||
"professionalId": result.get("data", {}).get("professionalId", ""),
|
||||
"professionalName": result.get("data", {}).get("professionalName", ""),
|
||||
}
|
||||
|
||||
async def dep_id(self):
|
||||
resp = await self.client.get("/xiaobei-api/student/deptId")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result
|
||||
|
||||
async def get_user_sig2(self):
|
||||
resp = await self.client.get("/xiaobei-api/trtc/genUserSig2")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result
|
||||
|
||||
async def get_know_list(self):
|
||||
resp = await self.client.get("/xiaobei-api/student/know/list")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("total", 0) > 0:
|
||||
pass
|
||||
return result
|
||||
|
||||
async def update_student_know(self, knowingID: str):
|
||||
files = {
|
||||
"file0": (
|
||||
str(uuid4()),
|
||||
open("./image.jpg", "rb"),
|
||||
"image/jpeg",
|
||||
)
|
||||
}
|
||||
resp = await self.client.post(
|
||||
"/xiaobei-api/student/know/updateStudentKnow",
|
||||
data={
|
||||
"address": "1388号",
|
||||
"remark": "无",
|
||||
"knowingId": knowingID,
|
||||
"location": "106.5346788194445:29.343291015625",
|
||||
"size": "1",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def main(self):
|
||||
try:
|
||||
data = await self.login()
|
||||
print(data)
|
||||
finally:
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(
|
||||
XBXS(
|
||||
get_env("XBXS_USERNAME"),
|
||||
get_env("XBXS_PASSWORD"),
|
||||
).main()
|
||||
)
|
||||
6
models.py
Normal file
6
models.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
38
routers/auth.py
Normal file
38
routers/auth.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from models import User
|
||||
from services.services import XBXS
|
||||
from starlette.templating import Jinja2Templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def index(request: Request):
|
||||
token = request.cookies.get("xbxs_token")
|
||||
if token:
|
||||
return RedirectResponse("/home")
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(user: User, response: Response):
|
||||
xbxs = XBXS()
|
||||
result = await xbxs.login(username=user.username, password=user.password)
|
||||
|
||||
token = result.get("token")
|
||||
if not token:
|
||||
return JSONResponse({"msg": "登录失败"}, status_code=401)
|
||||
|
||||
resp = JSONResponse({"msg": "ok"})
|
||||
|
||||
resp.set_cookie(
|
||||
key="xbxs_token",
|
||||
value=token,
|
||||
max_age=60 * 60 * 24 * 7, # 7 天
|
||||
httponly=False,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
return resp
|
||||
117
routers/home.py
Normal file
117
routers/home.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.templating import Jinja2Templates
|
||||
from services.services import XBXS
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def get_xbxs(request: Request) -> XBXS:
|
||||
token = request.cookies.get("xbxs_token")
|
||||
if not token:
|
||||
# 没有 token 跳转到登录页面
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
detail="Redirect to login",
|
||||
headers={"Location": "/login"},
|
||||
)
|
||||
return XBXS(token=token)
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
async def home(
|
||||
request: Request,
|
||||
xbxs: XBXS = Depends(get_xbxs),
|
||||
):
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"home.html",
|
||||
{
|
||||
"request": request,
|
||||
"active": "home",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def profile(
|
||||
request: Request,
|
||||
xbxs: XBXS = Depends(get_xbxs),
|
||||
):
|
||||
student = await xbxs.get_student_info_cached()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"profile.html",
|
||||
{
|
||||
"request": request,
|
||||
"student": student,
|
||||
"active": "profile",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sign")
|
||||
async def sign_list(
|
||||
request: Request,
|
||||
xbxs: XBXS = Depends(get_xbxs),
|
||||
):
|
||||
result = await xbxs.get_know_list()
|
||||
|
||||
rows = result.get("rows", [])
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
for item in rows:
|
||||
# 1️⃣ 计算是否结束(核心)
|
||||
end_str = f"{item['endDate']} {item['endTime']}"
|
||||
end_time = datetime.strptime(end_str, "%Y-%m-%d %H:%M")
|
||||
item["isEnded"] = now > end_time
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"sign_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"list": rows,
|
||||
"total": result.get("total", 0),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sign/{knowing_id}", response_class=HTMLResponse)
|
||||
async def sign_detail(
|
||||
knowing_id: int,
|
||||
request: Request,
|
||||
xbxs: XBXS = Depends(get_xbxs),
|
||||
):
|
||||
know_detail = await xbxs.get_know(str(knowing_id))
|
||||
|
||||
data = know_detail.get("data", {})
|
||||
|
||||
# 获取具体的数据
|
||||
knowing_name = data.get("knowingName", "未知签到")
|
||||
start_date = data.get("startDate", "未知")
|
||||
start_time = data.get("startTime", "未知")
|
||||
end_time = data.get("endTime", "未知")
|
||||
send_name = data.get("sendName", "未知")
|
||||
send_role = data.get("sendRole", "未知")
|
||||
is_check = data.get("isCheck", 0)
|
||||
is_picture = data.get("isPicture", 0)
|
||||
is_finish_know = data.get("isFinishKnowing", 0)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"sign_detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"knowing_name": knowing_name,
|
||||
"start_date": start_date,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"send_name": send_name,
|
||||
"send_role": send_role,
|
||||
"is_check": is_check,
|
||||
"is_picture": is_picture,
|
||||
"is_finish_know": is_finish_know,
|
||||
},
|
||||
)
|
||||
166
services/services.py
Normal file
166
services/services.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import base64
|
||||
from uuid import uuid4
|
||||
import httpx
|
||||
import config
|
||||
from services.student_cache import get_student_by_token, save_student
|
||||
|
||||
|
||||
SEX_MAP = {
|
||||
0: "男",
|
||||
1: "女",
|
||||
}
|
||||
|
||||
|
||||
class XBXS:
|
||||
def __init__(
|
||||
self,
|
||||
token: str | None = None,
|
||||
UA: str = config.DEFAULT_USER_AGENT,
|
||||
) -> None:
|
||||
self.token = token
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=config.BASE_URL,
|
||||
timeout=config.HTTPX_TIMEOUT,
|
||||
headers={
|
||||
"user-agent": UA,
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh-Hans;q=0.9",
|
||||
"accept-encoding": "gzip, deflate, br",
|
||||
},
|
||||
proxy="http://127.0.0.1:9000" if config.DEBUG else None,
|
||||
verify=False if config.DEBUG else True,
|
||||
)
|
||||
|
||||
if token:
|
||||
self.client.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
async def get_captcha(self):
|
||||
resp = await self.client.get("/xiaobei-api/captchaImage")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if "uuid" not in result:
|
||||
raise RuntimeError(f"captcha error: {result}")
|
||||
return {
|
||||
"showCode": result.get("showCode", ""),
|
||||
"uuid": result.get("uuid", ""),
|
||||
}
|
||||
|
||||
async def login(self, username: str, password: str):
|
||||
data = await self.get_captcha()
|
||||
resp = await self.client.post(
|
||||
"/xiaobei-api/userLogin",
|
||||
json={
|
||||
"username": username,
|
||||
"password": base64.b64encode(password.encode()).decode(),
|
||||
"code": data.get("showCode", ""),
|
||||
"uuid": data.get("uuid", ""),
|
||||
"appUuid": "",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
result = resp.json()
|
||||
|
||||
self.token = result.get("token")
|
||||
|
||||
self.client.headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
return result
|
||||
|
||||
async def get_info(self):
|
||||
resp = await self.client.get("/xiaobei-api/getInfo")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return {
|
||||
"userName": result.get("user", {}).get("userName", ""),
|
||||
"nickName": result.get("user", {}).get("nickName", ""),
|
||||
"roles": result.get("roles", []),
|
||||
"phonenumber": result.get("user", {}).get("phonenumber", ""),
|
||||
"bindphonenumber": result.get("user", {}).get("bindphonenumber", ""),
|
||||
"idCard": result.get("user", {}).get("idCard", ""),
|
||||
"trtcId": result.get("user", {}).get("trtcId", ""),
|
||||
}
|
||||
|
||||
async def get_student_info(self):
|
||||
resp = await self.client.get("/xiaobei-api/student/studentInfo")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
sex = result.get("data", {}).get("studentSex", "")
|
||||
sex_text = SEX_MAP.get(sex, "未知")
|
||||
|
||||
return {
|
||||
"id": result.get("data", {}).get("id", ""),
|
||||
"studentNumber": result.get("data", {}).get("studentNumber", ""),
|
||||
"deptId": result.get("data", {}).get("deptId", ""),
|
||||
"deptName": result.get("data", {}).get("deptName", ""),
|
||||
"studentName": result.get("data", {}).get("studentName", ""),
|
||||
"studentIpone": result.get("data", {}).get("studentIpone", ""),
|
||||
"studentIdCard": result.get("data", {}).get("studentIdCard", ""),
|
||||
"studentSex": sex_text,
|
||||
"studentSchoolTime": result.get("data", {}).get("studentSchoolTime", ""),
|
||||
"schoolId": result.get("data", {}).get("schoolId", ""),
|
||||
"schoolName": result.get("data", {}).get("schoolName", ""),
|
||||
"collegeId": result.get("data", {}).get("collegeId", ""),
|
||||
"collegeName": result.get("data", {}).get("collegeName", ""),
|
||||
"professionalId": result.get("data", {}).get("professionalId", ""),
|
||||
"professionalName": result.get("data", {}).get("professionalName", ""),
|
||||
}
|
||||
|
||||
async def dep_id(self):
|
||||
resp = await self.client.get("/xiaobei-api/student/deptId")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result
|
||||
|
||||
async def get_user_sig2(self):
|
||||
resp = await self.client.get("/xiaobei-api/trtc/genUserSig2")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result
|
||||
|
||||
async def get_know_list(self):
|
||||
resp = await self.client.get("/xiaobei-api/student/know/list")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("total", 0) > 0:
|
||||
pass
|
||||
return result
|
||||
|
||||
async def get_know(self, know: str):
|
||||
resp = await self.client.get(f"/xiaobei-api/student/know/{know}")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("total", 0) > 0:
|
||||
pass
|
||||
return result
|
||||
|
||||
async def update_student_know(self, knowingID: str):
|
||||
files = {
|
||||
"file0": (
|
||||
str(uuid4()),
|
||||
open("./image.jpg", "rb"),
|
||||
"image/jpeg",
|
||||
)
|
||||
}
|
||||
resp = await self.client.post(
|
||||
"/xiaobei-api/student/know/updateStudentKnow",
|
||||
data={
|
||||
"address": "1388号",
|
||||
"remark": "无",
|
||||
"knowingId": knowingID,
|
||||
"location": "106.5346788194445:29.343291015625",
|
||||
"size": "1",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_student_info_cached(self):
|
||||
cached = get_student_by_token(str(self.token))
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
student = await self.get_student_info()
|
||||
save_student(str(self.token), student)
|
||||
return student
|
||||
39
services/student_cache.py
Normal file
39
services/student_cache.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import time
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = "data.db"
|
||||
|
||||
|
||||
def get_student_by_token(token: str):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT raw_json FROM student WHERE token = ?",
|
||||
(token,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return json.loads(row[0])
|
||||
|
||||
|
||||
def save_student(token: str, student: dict):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO student
|
||||
(token, raw_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(token, json.dumps(student, ensure_ascii=False), int(time.time())),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
44
templates/base.html
Normal file
44
templates/base.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{% block title %}XBXS{% 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>
|
||||
|
||||
<!-- 底部 Tab -->
|
||||
<nav class="fixed bottom-0 left-1/2 -translate-x-1/2
|
||||
w-full max-w-md
|
||||
bg-white border-t
|
||||
flex justify-around items-center
|
||||
h-16">
|
||||
|
||||
<a href="/" class="flex flex-col items-center text-sm
|
||||
{% if active == 'home' %}text-blue-600{% else %}text-zinc-500{% endif %}">
|
||||
<span>🏠</span>
|
||||
首页
|
||||
</a>
|
||||
|
||||
<a href="/profile" class="flex flex-col items-center text-sm
|
||||
{% if active == 'profile' %}text-blue-600{% else %}text-zinc-500{% endif %}">
|
||||
<span>👤</span>
|
||||
我的
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
21
templates/home.html
Normal file
21
templates/home.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}首页{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-4 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-bold">应用</h1>
|
||||
|
||||
<!-- 签到 App 卡片 -->
|
||||
<a href="/sign" class="block bg-blue-500 text-white rounded-2xl p-6 shadow
|
||||
active:scale-95 transition">
|
||||
|
||||
<div class="text-lg font-semibold">📍 签到</div>
|
||||
<div class="text-sm opacity-90 mt-1">
|
||||
课程签到 / 定位 / 拍照
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
templates/login.html
Normal file
55
templates/login.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<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">
|
||||
|
||||
<div class="w-full max-w-sm bg-white rounded-xl shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold text-center mb-6">登录</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input id="username" type="text" placeholder="用户名"
|
||||
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring" />
|
||||
|
||||
<input id="password" type="password" placeholder="密码"
|
||||
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring" />
|
||||
|
||||
<button onclick="login()"
|
||||
class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="error" class="text-red-500 text-sm mt-4 hidden"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function login() {
|
||||
const username = document.getElementById("username").value;
|
||||
const password = document.getElementById("password").value;
|
||||
|
||||
const res = await fetch("/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = "/home";
|
||||
} else {
|
||||
document.getElementById("error").innerText = "登录失败";
|
||||
document.getElementById("error").classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
55
templates/profile.html
Normal file
55
templates/profile.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}我的{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-4 space-y-6">
|
||||
|
||||
<!-- 头像 + 名字 -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-indigo-500
|
||||
rounded-2xl p-6 text-white">
|
||||
|
||||
<div class="text-lg font-semibold">
|
||||
{{ student.studentName }}
|
||||
</div>
|
||||
<div class="text-sm opacity-90">
|
||||
学号 {{ student.studentNumber }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息卡 -->
|
||||
<div class="bg-white rounded-2xl shadow divide-y text-sm">
|
||||
|
||||
<div class="flex justify-between p-4">
|
||||
<span class="text-zinc-500">学院</span>
|
||||
<span>{{ student.collegeName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between p-4">
|
||||
<span class="text-zinc-500">专业</span>
|
||||
<span>{{ student.professionalName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between p-4">
|
||||
<span class="text-zinc-500">班级</span>
|
||||
<span>{{ student.deptName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between p-4">
|
||||
<span class="text-zinc-500">性别</span>
|
||||
<span>{{ student.studentSex }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between p-4">
|
||||
<span class="text-zinc-500">手机号</span>
|
||||
<span>{{ student.studentIpone }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 操作 -->
|
||||
<a href="/logout" class="block text-center text-red-500 py-3">
|
||||
退出登录
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
74
templates/sign_detail.html
Normal file
74
templates/sign_detail.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}签到详情{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-4 space-y-4">
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold">签到详情</h1>
|
||||
<a href="/sign" class="text-sm text-blue-500 hover:underline">返回签到列表</a>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- 详情信息 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<span class="text-zinc-500">签到名称</span>
|
||||
<div class="font-medium">{{ knowing_name }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-zinc-500">日期</span>
|
||||
<div class="font-medium">{{ start_date }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-zinc-500">时间</span>
|
||||
<div class="font-medium">{{ start_time }} - {{ end_time }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-zinc-500">发起人</span>
|
||||
<div class="font-medium">{{ send_name }} ({{ send_role }})</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-zinc-500">签到状态</span>
|
||||
<div class="font-medium">
|
||||
{% if is_finish_know == 1 %}
|
||||
<span class="text-green-600">已签到</span>
|
||||
{% elif is_check == 1 %}
|
||||
<span class="text-green-600">已签到</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">未签到</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_picture == 1 %}
|
||||
<div>
|
||||
<span class="text-zinc-500">签到需要拍照</span>
|
||||
<div class="font-medium">是</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<span class="text-zinc-500">签到需要拍照</span>
|
||||
<div class="font-medium">否</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 完成签到按钮 -->
|
||||
{% if is_finish_know == 0 and is_check == 0 %}
|
||||
<div class="mt-4">
|
||||
<form method="post" action="/sign/{{ know_id }}/complete">
|
||||
<button type="submit" class="bg-blue-600 text-white py-2 px-4 rounded-lg">完成签到</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
74
templates/sign_list.html
Normal file
74
templates/sign_list.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}签到{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-4 space-y-4">
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold">签到任务</h1>
|
||||
<span class="text-sm text-zinc-500">
|
||||
共 {{ total }} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if total == 0 %}
|
||||
<div class="text-center text-zinc-400 py-10">
|
||||
暂无签到任务
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="space-y-3">
|
||||
|
||||
{% for item in list %}
|
||||
<a href="/sign/{{ item.id }}" class="block bg-white rounded-2xl p-4 shadow
|
||||
active:scale-95 transition">
|
||||
|
||||
<!-- 第一行 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="font-semibold text-base">
|
||||
{{ item.knowingName }}
|
||||
</div>
|
||||
|
||||
{% if item.isFinishKnowing == 1 %}
|
||||
<span class="text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded-full">
|
||||
已签到
|
||||
</span>
|
||||
{% elif item.isEnded %}
|
||||
<span class="text-xs bg-zinc-100 text-zinc-400 px-2 py-0.5 rounded-full">
|
||||
已结束
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full">
|
||||
进行中
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="text-sm text-zinc-500 mt-1">
|
||||
{{ item.startDate }} {{ item.startTime }} - {{ item.endTime }}
|
||||
</div>
|
||||
|
||||
<!-- 发起人 -->
|
||||
<div class="flex justify-between items-center mt-2 text-sm">
|
||||
|
||||
<div class="text-zinc-600">
|
||||
{{ item.sendRole }} · {{ item.sendName }}
|
||||
</div>
|
||||
|
||||
{% if item.isPicture == 0 %}
|
||||
<span class="text-xs bg-orange-100 text-orange-600 px-2 py-0.5 rounded">
|
||||
需拍照
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user