├── app
├── auth
│ ├── __init__.py
│ ├── utils.py
│ └── dependencies.py
├── routes
│ ├── __init__.py
│ ├── admin
│ │ ├── __init__.py
│ │ ├── statistics.py
│ │ ├── dashboard.py
│ │ ├── system.py
│ │ ├── utils.py
│ │ ├── settings.py
│ │ ├── schedules.py
│ │ └── setup.py
│ ├── system.py.bak
│ ├── sign.py
│ ├── setup.py
│ └── index.py
├── utils
│ ├── __init__.py
│ ├── host.py
│ ├── settings.py
│ ├── api.py
│ ├── log.py
│ └── db_init.py
├── static
│ ├── favicon.ico
│ ├── templates
│ │ ├── admin
│ │ │ ├── embed.html
│ │ │ ├── schedules.html
│ │ │ ├── users.html
│ │ │ ├── settings.html
│ │ │ ├── privilege.html
│ │ │ ├── statistics.html
│ │ │ ├── dashboard.html
│ │ │ └── index.html
│ │ ├── login.html
│ │ └── setup.html
│ ├── js
│ │ ├── admin
│ │ │ ├── dashboard.js
│ │ │ ├── common.js
│ │ │ ├── settings.js
│ │ │ ├── user.js
│ │ │ └── schedules.js
│ │ ├── login.js
│ │ └── setup.js
│ └── css
│ │ ├── admin
│ │ ├── settings.css
│ │ ├── dashboard.css
│ │ ├── statistics.css
│ │ ├── user.css
│ │ ├── schedules.css
│ │ ├── logs.css
│ │ ├── common.css
│ │ └── privilege.css
│ │ ├── setup.css
│ │ └── main.css
├── __init__.py
└── main.py
├── .env.example
├── docker-compose.yml
├── requirements.txt
├── Dockerfile
├── ecosystem.config.js
├── run.py
├── Dockerfile.base
├── docker-entrypoint.sh
├── .gitignore
├── config.py
├── db.sh
└── README.md
/app/auth/__init__.py:
--------------------------------------------------------------------------------
1 | # 认证模块初始化文件
--------------------------------------------------------------------------------
/app/routes/__init__.py:
--------------------------------------------------------------------------------
1 | # 路由包初始化文件
2 |
--------------------------------------------------------------------------------
/app/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 工具函数包
3 | """
4 |
5 | # utils包初始化文件
--------------------------------------------------------------------------------
/app/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chiupam/WorkClockFastAPI/main/app/static/favicon.ico
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # 应用环境设置
2 | APP_ENV=development
3 | # 管理员密码
4 | ADMIN_PASSWORD=admin123
5 | # 生产环境API地址
6 | # API_URL=https://api.example.com
7 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | work:
5 | build: .
6 | ports:
7 | - "8000:8000"
8 | environment:
9 | - APP_ENV=production
10 | restart: always
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | fastapi
2 | uvicorn
3 | pydantic
4 | pydantic-settings
5 | python-jose
6 | passlib
7 | python-multipart
8 | jinja2
9 | sqlalchemy
10 | python-dotenv
11 | bcrypt
12 | httpx
13 | apscheduler
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM work:base
2 |
3 | # 设置工作目录
4 | WORKDIR /app
5 |
6 | # 复制应用代码
7 | COPY . .
8 |
9 | # 创建数据目录并设置入口点脚本权限(单层)
10 | RUN mkdir -p data && \
11 | chmod +x *.sh
12 |
13 | # 暴露端口
14 | EXPOSE 8000
15 |
16 | # 设置入口点
17 | ENTRYPOINT ["/app/docker-entrypoint.sh"]
--------------------------------------------------------------------------------
/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [{
3 | name: "work",
4 | script: "python",
5 | args: "-m uvicorn app.main:app --host 0.0.0.0 --port 8000",
6 | instances: 1,
7 | autorestart: true,
8 | watch: false,
9 | max_memory_restart: "500M",
10 | env: {
11 | NODE_ENV: "production",
12 | PYTHONPATH: "/app"
13 | },
14 | log_date_format: "YYYY-MM-DD HH:mm:ss Z"
15 | }]
16 | };
--------------------------------------------------------------------------------
/app/routes/admin/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 | from app.routes.admin import dashboard, settings, logs, schedules, statistics, privilege, system, setup
3 |
4 |
5 |
6 | # 创建主路由器
7 | router = APIRouter(tags=["管理员"])
8 |
9 | # 包含各个模块的路由
10 | router.include_router(dashboard.router)
11 | router.include_router(settings.router)
12 | router.include_router(logs.router)
13 | router.include_router(schedules.router)
14 | router.include_router(statistics.router)
15 | router.include_router(privilege.router)
16 | router.include_router(system.router)
17 | router.include_router(setup.router)
--------------------------------------------------------------------------------
/run.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import sys
3 |
4 | import uvicorn
5 | from dotenv import load_dotenv
6 |
7 | from config import settings
8 |
9 | # 配置日志级别 - 禁用httpcore和httpx的DEBUG日志
10 | logging.getLogger("httpcore").setLevel(logging.WARNING)
11 | logging.getLogger("httpx").setLevel(logging.WARNING)
12 |
13 | if __name__ == "__main__":
14 | # 加载.env文件中的环境变量
15 | load_dotenv()
16 |
17 | host = "0.0.0.0"
18 | port = 8000
19 | reload = True
20 | log_level = settings.LOG_LEVEL.lower()
21 |
22 | try:
23 | uvicorn.run("app.main:app", host=host, port=port, reload=reload, log_level=log_level)
24 | except ValueError as e:
25 | print(f"错误: {str(e)}")
26 | sys.exit(1)
--------------------------------------------------------------------------------
/app/utils/host.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from config import settings
4 |
5 |
6 | def build_api_url(path: str) -> str:
7 | """
8 | 构建API URL,确保正确处理带有或不带有斜杠的基础URL
9 |
10 | :param path: API路径,可以以斜杠开头也可以不以斜杠开头
11 | :return: 完整的API URL
12 | """
13 |
14 | host = settings.API_HOST
15 | if not host:
16 | return f"http://127.0.0.1:8001/{path.lstrip('/')}"
17 |
18 | host = re.sub(r'^(https?:\/{0,2})', '', host)
19 |
20 | if "zhcj" in host:
21 | return f"https://{host}/{path.lstrip('/')}"
22 | elif host.replace('.', '').replace(':', '').isdigit():
23 | return f"http://{host}/{path.lstrip('/')}"
24 | else:
25 | return f"http://{host}/{path.lstrip('/')}"
26 |
--------------------------------------------------------------------------------
/Dockerfile.base:
--------------------------------------------------------------------------------
1 | FROM python:3.10-alpine
2 |
3 | # 设置工作目录
4 | WORKDIR /app
5 |
6 | # 设置环境变量
7 | ENV PYTHONDONTWRITEBYTECODE=1 \
8 | PYTHONUNBUFFERED=1 \
9 | PIP_NO_CACHE_DIR=off \
10 | PIP_DISABLE_PIP_VERSION_CHECK=on
11 |
12 | # 设置时区
13 | RUN apk add --no-cache tzdata && \
14 | cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
15 | echo "Asia/Shanghai" > /etc/timezone && \
16 | apk update && \
17 | apk add --no-cache build-base libffi-dev openssl-dev nodejs npm bash && \
18 | npm install pm2 -g && \
19 | npm cache clean --force
20 |
21 | # 复制依赖文件
22 | COPY requirements.txt .
23 |
24 | # 安装依赖并清理缓存(单层)
25 | RUN pip install --no-cache-dir -r requirements.txt uvicorn && \
26 | apk del build-base && \
27 | rm -rf /var/cache/apk/*
28 |
--------------------------------------------------------------------------------
/app/static/templates/admin/embed.html:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/app/static/templates/admin/schedules.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block title %}{{ page_title }}{% endblock %}
4 |
5 | {% block styles %}
6 |
7 |
8 |
11 | {% endblock %}
12 |
13 | {% block content %}
14 |
29 | {% endblock %}
30 |
31 | {% block scripts %}
32 |
33 | {% endblock %}
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | # 空的初始化文件,使app目录成为一个Python包
2 |
3 | import logging
4 | import os
5 |
6 | # 配置基本日志(详细配置会在加载配置后进行)
7 | logging.basicConfig(
8 | level=logging.INFO,
9 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
10 | )
11 |
12 | # 设置第三方库的日志级别
13 | logging.getLogger("httpcore").setLevel(logging.WARNING)
14 | logging.getLogger("httpx").setLevel(logging.WARNING)
15 | logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
16 |
17 | # 设置模块日志
18 | logger = logging.getLogger(__name__)
19 |
20 | # 定义数据库文件常量
21 | USER_DB_FILE = "data/user.db" # 用户信息数据库
22 | SIGN_DB_FILE = "data/sign.db" # 签到日志数据库
23 | LOG_DB_FILE = "data/log.db"
24 | SET_DB_FILE = "data/set.db"
25 | CRON_DB_FILE = "data/cron.db"
26 |
27 | # 确保data目录存在
28 | data_dir = "data"
29 | if not os.path.exists(data_dir):
30 | os.makedirs(data_dir)
31 |
32 | # 导入数据库初始化模块并执行初始化
33 | from app.utils.db_init import initialize_database
34 |
35 | # 初始化数据库
36 | initialize_database()
37 |
38 | # 输出初始化消息
39 | logger.info("应用初始化完成")
40 |
--------------------------------------------------------------------------------
/app/routes/admin/statistics.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from fastapi import APIRouter, Request
4 |
5 | from app.auth.dependencies import admin_required
6 | from app.routes.admin.utils import get_admin_stats, get_admin_name, templates
7 |
8 | # 创建路由器
9 | router = APIRouter()
10 |
11 | @router.get("/statistics")
12 | @admin_required
13 | async def admin_statistics_page(request: Request):
14 | """
15 | 管理员考勤统计页面
16 | """
17 |
18 | # 获取基本统计信息
19 | stats = await get_admin_stats()
20 |
21 | # 获取open_id
22 | open_id = request.cookies.get("open_id")
23 |
24 | # 查询当前管理员信息
25 | admin_name = await get_admin_name(open_id)
26 |
27 | # 返回考勤统计页面
28 | return templates.TemplateResponse(
29 | "admin/statistics.html",
30 | {
31 | "request": request,
32 | "user_info": {"username": admin_name, "user_id": "admin"},
33 | "stats": stats,
34 | "page_title": "考勤统计",
35 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
36 | }
37 | )
--------------------------------------------------------------------------------
/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | echo "启动脚本开始执行..."
5 |
6 | # 初始化数据库
7 | echo "正在初始化数据库..."
8 | python -c "from app.utils.db_init import initialize_database; initialize_database()"
9 |
10 | # 检查标记并清除
11 | if [ -f "/app/data/needs_restart" ]; then
12 | echo "启动时发现重启标记文件,清除标记"
13 | rm -f "/app/data/needs_restart"
14 | fi
15 |
16 | if [ -f "/app/data/needs_update" ]; then
17 | echo "启动时发现更新标记文件,清除标记"
18 | rm -f "/app/data/needs_update"
19 | fi
20 |
21 | # 检查PM2是否安装
22 | which pm2 >/dev/null 2>&1
23 | if [ $? -ne 0 ]; then
24 | echo "错误: PM2未安装,请确保基础镜像中已安装PM2"
25 | exit 1
26 | fi
27 |
28 | # 检查ecosystem.config.js是否存在
29 | if [ ! -f "/app/ecosystem.config.js" ]; then
30 | echo "错误: 找不到PM2配置文件 ecosystem.config.js"
31 | exit 1
32 | fi
33 |
34 | # 使用PM2启动应用
35 | echo "使用PM2启动应用..."
36 | echo "当前工作目录: $(pwd)"
37 | echo "配置文件内容:"
38 | cat ecosystem.config.js
39 |
40 | # 启动前删除旧的PM2进程和日志
41 | pm2 delete all 2>/dev/null || true
42 | rm -rf /root/.pm2/logs/* 2>/dev/null || true
43 |
44 | # 启动应用
45 | exec pm2-runtime ecosystem.config.js
--------------------------------------------------------------------------------
/app/utils/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | 设置辅助函数
3 | """
4 | import os
5 | import sqlite3
6 |
7 | from app import SET_DB_FILE
8 |
9 |
10 | def get_setting_from_db(key, default_value=None):
11 | """从数据库获取设置"""
12 | try:
13 | # 确保data目录存在
14 | if not os.path.exists("data"):
15 | return default_value
16 |
17 | # 如果数据库文件不存在,返回默认值
18 | if not os.path.exists(SET_DB_FILE):
19 | return default_value
20 |
21 | # 连接数据库
22 | conn = sqlite3.connect(SET_DB_FILE)
23 | cursor = conn.cursor()
24 |
25 | # 查询设置
26 | cursor.execute("SELECT setting_value FROM system_settings WHERE setting_key = ?", (key,))
27 | result = cursor.fetchone()
28 |
29 | # 关闭连接
30 | conn.close()
31 |
32 | # 返回结果
33 | if result and result[0]:
34 | return result[0]
35 | return default_value
36 | except Exception as e:
37 | print(f"从数据库获取设置失败 {key}: {str(e)}")
38 | return default_value
39 |
40 | def validate_settings(settings):
41 | """
42 | 验证应用程序设置是否有效
43 |
44 | :param settings: 配置对象
45 | :return: 验证结果,布尔值
46 | """
47 | # 检查是否已初始化
48 | if not settings.IS_INITIALIZED:
49 | print("警告: 系统尚未初始化,请访问 /setup 完成初始化设置")
50 | return False
51 |
52 | # 检查API主机设置
53 | if not settings.API_HOST:
54 | print("错误: API主机地址未设置")
55 | return False
56 |
57 | return True
58 |
--------------------------------------------------------------------------------
/app/auth/utils.py:
--------------------------------------------------------------------------------
1 | import secrets
2 | import string
3 |
4 | def random_open_id() -> str:
5 | """
6 | 生成随机 open_id
7 | :return: 随机 open_id
8 | """
9 | random_string = ''.join(
10 | secrets.choice(
11 | string.ascii_letters + string.digits
12 | ) for _ in range(20)
13 | )
14 | return "oFDlxxE_" + random_string
15 |
16 |
17 | def get_mobile_user_agent(request_user_agent: str = "") -> str:
18 | """
19 | 获取移动设备 user_agent
20 | :param request_user_agent: 请求头中的 user_agent
21 | :return: 移动设备 user_agent
22 | """
23 | default_mobile_ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.4(0x18000428) NetType/4G'
24 |
25 | # 如果没有 User-Agent,使用默认值
26 | if not request_user_agent:
27 | return default_mobile_ua
28 |
29 | # 检查是否是移动设备
30 | mobile_strings = ['Android', 'iPhone']
31 | is_mobile = any(x in request_user_agent for x in mobile_strings)
32 |
33 | if is_mobile:
34 | # 如果已经包含 MicroMessenger,直接使用原始 UA
35 | if 'MicroMessenger' in request_user_agent:
36 | return request_user_agent
37 |
38 | # 否则添加微信标识
39 | if 'Android' in request_user_agent:
40 | return "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.4(0x18000428) EdgA/131.0.0.0 NetType/4G"
41 |
42 | # 非移动设备返回默认值
43 | return default_mobile_ua
--------------------------------------------------------------------------------
/app/static/js/admin/dashboard.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | // 显示当前时间并自动更新
3 | function updateClock() {
4 | const now = new Date();
5 | const timeDisplay = document.querySelector('.current-time');
6 | if (timeDisplay) {
7 | const formattedTime = now.getFullYear() + '-' +
8 | padZero(now.getMonth() + 1) + '-' +
9 | padZero(now.getDate()) + ' ' +
10 | padZero(now.getHours()) + ':' +
11 | padZero(now.getMinutes()) + ':' +
12 | padZero(now.getSeconds());
13 | timeDisplay.textContent = '当前时间: ' + formattedTime;
14 | }
15 | }
16 |
17 | function padZero(num) {
18 | return num < 10 ? '0' + num : num;
19 | }
20 |
21 | // 初始更新并设置定时器
22 | updateClock();
23 | setInterval(updateClock, 1000);
24 |
25 | // 统计卡片动画效果
26 | const statsCards = document.querySelectorAll('.stats-card');
27 | statsCards.forEach(card => {
28 | card.addEventListener('mouseenter', function() {
29 | this.style.transform = 'translateY(-5px)';
30 | this.style.boxShadow = '0 5px 15px rgba(0, 0, 0, 0.15)';
31 | });
32 |
33 | card.addEventListener('mouseleave', function() {
34 | this.style.transform = 'translateY(0)';
35 | this.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
36 | });
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python相关
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | env/
8 | build/
9 | develop-eggs/
10 | dist/
11 | downloads/
12 | eggs/
13 | .eggs/
14 | lib/
15 | lib64/
16 | parts/
17 | sdist/
18 | var/
19 | wheels/
20 | *.egg-info/
21 | .installed.cfg
22 | *.egg
23 | *.log
24 | .venv/
25 |
26 | # 系统文件
27 | .DS_Store
28 | .DS_Store?
29 | ._*
30 | .Spotlight-V100
31 | .Trashes
32 | ehthumbs.db
33 | Thumbs.db
34 | *~
35 | .*.swp
36 | .*.swo
37 |
38 | # IDE相关
39 | .idea/
40 | .vscode/
41 | *.sublime-project
42 | *.sublime-workspace
43 | .vs/
44 | *.suo
45 | *.ntvs*
46 | *.njsproj
47 | *.sln
48 | *.sw?
49 |
50 | # 项目特定敏感文件
51 | data/*.db
52 | data/needs_restart
53 | *.db
54 | *.sqlite3
55 | *.sqlite
56 | credentials.json
57 | .env
58 | config.json
59 | config.local.py
60 | config.*.py
61 | !config.py
62 | secret_key.txt
63 |
64 | # 日志和临时文件
65 | logs/
66 | *.log
67 | npm-debug.log*
68 | yarn-debug.log*
69 | yarn-error.log*
70 | .pnpm-debug.log*
71 | temp/
72 | tmp/
73 | .pytest_cache/
74 | .coverage
75 | htmlcov/
76 |
77 | # Node相关
78 | node_modules/
79 | package-lock.json
80 | yarn.lock
81 |
82 | # 其他
83 | .git/
84 | .dockerignore
85 | .editorconfig
86 |
87 | # 虚拟环境
88 | venv/
89 | ENV/
90 | .env
91 |
92 | # 数据库
93 | *.db
94 | *.sqlite
95 | *.sqlite3
96 | data/
97 |
98 | # 日志
99 | *.log
100 | logs/
101 |
102 | # IDE相关
103 | .idea/
104 | .vscode/
105 | *.swp
106 | *.swo
107 |
108 | # 操作系统相关
109 | .DS_Store
110 | Thumbs.db
111 |
112 | # Docker相关
113 | .dockerignore
114 |
115 | # 暂时不提交
116 | .github/
--------------------------------------------------------------------------------
/app/static/css/admin/settings.css:
--------------------------------------------------------------------------------
1 | .settings-container {
2 | padding: 20px;
3 | }
4 |
5 | .settings-header {
6 | margin-bottom: 25px;
7 | }
8 |
9 | .settings-header h1 {
10 | margin-bottom: 10px;
11 | color: #333;
12 | }
13 |
14 | .settings-description {
15 | color: #666;
16 | margin-bottom: 20px;
17 | }
18 |
19 | .settings-form {
20 | background-color: #fff;
21 | border-radius: 8px;
22 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
23 | padding: 25px;
24 | }
25 |
26 | .form-group {
27 | margin-bottom: 20px;
28 | }
29 |
30 | .form-group:last-child {
31 | margin-bottom: 0;
32 | }
33 |
34 | .form-actions {
35 | margin-top: 30px;
36 | display: flex;
37 | justify-content: flex-end;
38 | }
39 |
40 | .form-actions .btn {
41 | margin-left: 10px;
42 | }
43 |
44 | .alert {
45 | margin-bottom: 20px;
46 | padding: 15px;
47 | border-radius: 4px;
48 | }
49 |
50 | .alert-success {
51 | background-color: #d4edda;
52 | border-color: #c3e6cb;
53 | color: #155724;
54 | }
55 |
56 | .alert-danger {
57 | background-color: #f8d7da;
58 | border-color: #f5c6cb;
59 | color: #721c24;
60 | }
61 |
62 | .alert-hidden {
63 | display: none;
64 | }
65 |
66 | .checkbox-group {
67 | display: flex;
68 | align-items: center;
69 | margin-bottom: 1rem;
70 | }
71 |
72 | .checkbox-group input[type="checkbox"] {
73 | margin-right: 10px;
74 | }
75 |
76 | .checkbox-group label {
77 | margin-bottom: 0;
78 | font-weight: 500;
79 | }
80 |
81 | .checkbox-group .form-text {
82 | display: block;
83 | width: 100%;
84 | margin-top: 5px;
85 | margin-left: 25px;
86 | }
87 |
88 | /* 添加信息提示样式 */
89 | .alert-info {
90 | background-color: #cce5ff;
91 | border-color: #b8daff;
92 | color: #004085;
93 | }
--------------------------------------------------------------------------------
/app/static/templates/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 考勤系统 - 登录
7 |
8 |
9 |
27 |
28 |
29 |
30 |
考勤系统
31 |
42 |
登录中,请稍候...
43 |
44 | {% if error_message %}
45 | {{ error_message }}
46 | {% endif %}
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/app/static/templates/admin/users.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block title %}用户管理{% endblock %}
4 |
5 | {% block styles %}
6 |
7 |
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 |
16 |
17 |
18 |
22 |
26 |
27 |
--
28 |
长期未活跃用户
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | | 用户名 |
43 | 用户ID |
44 | 部门 |
45 | 职位 |
46 | 首次登录 |
47 | 最近活跃 |
48 | 操作 |
49 |
50 |
51 |
52 |
53 | | 加载用户数据... |
54 |
55 |
56 |
57 |
58 |
59 |
62 |
63 |
64 | {% endblock %}
65 |
66 | {% block scripts %}
67 |
68 | {% endblock %}
--------------------------------------------------------------------------------
/app/static/templates/setup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 系统初始化
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/app/static/css/admin/dashboard.css:
--------------------------------------------------------------------------------
1 | /* 仪表盘容器 */
2 | .dashboard-container {
3 | padding: 20px;
4 | }
5 |
6 | /* 仪表盘头部 */
7 | .dashboard-header {
8 | margin-bottom: 30px;
9 | }
10 |
11 | .dashboard-header h1 {
12 | margin-bottom: 10px;
13 | color: #333;
14 | font-size: 24px;
15 | }
16 |
17 | .welcome-text {
18 | color: #666;
19 | font-size: 16px;
20 | margin-bottom: 5px;
21 | }
22 |
23 | .current-time {
24 | color: #888;
25 | font-size: 14px;
26 | }
27 |
28 | /* 统计卡片区域 */
29 | .stats-overview {
30 | display: grid;
31 | grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
32 | gap: 20px;
33 | margin-bottom: 30px;
34 | }
35 |
36 | .stats-card {
37 | background-color: #fff;
38 | border-radius: 8px;
39 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
40 | padding: 20px;
41 | display: flex;
42 | align-items: center;
43 | transition: transform 0.2s ease, box-shadow 0.2s ease;
44 | }
45 |
46 | .stats-card:hover {
47 | transform: translateY(-5px);
48 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
49 | }
50 |
51 | .stats-icon {
52 | font-size: 32px;
53 | margin-right: 15px;
54 | }
55 |
56 | .stats-content {
57 | flex: 1;
58 | }
59 |
60 | .stats-value {
61 | font-size: 28px;
62 | font-weight: bold;
63 | color: #333;
64 | margin-bottom: 5px;
65 | }
66 |
67 | .stats-label {
68 | font-size: 14px;
69 | color: #666;
70 | }
71 |
72 | /* 系统信息区域 */
73 | .system-info {
74 | background-color: #fff;
75 | border-radius: 8px;
76 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
77 | padding: 20px;
78 | }
79 |
80 | .system-info h2 {
81 | margin-bottom: 20px;
82 | color: #333;
83 | font-size: 18px;
84 | border-bottom: 1px solid #eee;
85 | padding-bottom: 10px;
86 | }
87 |
88 | .info-grid {
89 | display: grid;
90 | grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
91 | gap: 20px;
92 | }
93 |
94 | .info-item {
95 | margin-bottom: 15px;
96 | }
97 |
98 | .info-label {
99 | font-size: 14px;
100 | color: #666;
101 | margin-bottom: 5px;
102 | }
103 |
104 | .info-value {
105 | font-size: 16px;
106 | color: #333;
107 | font-weight: 500;
108 | }
109 |
110 | /* 响应式调整 */
111 | @media (max-width: 768px) {
112 | .stats-overview,
113 | .info-grid {
114 | grid-template-columns: 1fr;
115 | }
116 |
117 | .stats-card {
118 | padding: 15px;
119 | }
120 |
121 | .stats-value {
122 | font-size: 24px;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/app/static/css/admin/statistics.css:
--------------------------------------------------------------------------------
1 | /* 考勤统计页面样式 */
2 | .statistics-container {
3 | padding: 20px;
4 | background-color: #f8f9fa;
5 | border-radius: 10px;
6 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
7 | }
8 |
9 | .statistics-header {
10 | display: flex;
11 | justify-content: space-between;
12 | align-items: center;
13 | margin-bottom: 20px;
14 | padding-bottom: 10px;
15 | border-bottom: 1px solid #eee;
16 | }
17 |
18 | .statistics-header h1 {
19 | font-size: 24px;
20 | color: #333;
21 | margin: 0;
22 | }
23 |
24 | .statistics-content {
25 | display: flex;
26 | flex-direction: column;
27 | gap: 20px;
28 | }
29 |
30 | /* 统计卡片样式 */
31 | .stats-cards {
32 | display: grid;
33 | grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
34 | gap: 20px;
35 | margin-bottom: 10px;
36 | }
37 |
38 | .stats-card {
39 | background-color: #fff;
40 | border-radius: 8px;
41 | padding: 20px;
42 | display: flex;
43 | align-items: center;
44 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
45 | transition: transform 0.3s, box-shadow 0.3s;
46 | }
47 |
48 | .stats-card:hover {
49 | transform: translateY(-5px);
50 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
51 | }
52 |
53 | .stats-icon {
54 | font-size: 32px;
55 | margin-right: 15px;
56 | color: #2196F3;
57 | }
58 |
59 | .stats-card .stats-content {
60 | flex: 1;
61 | }
62 |
63 | .stats-value {
64 | font-size: 24px;
65 | font-weight: 600;
66 | color: #333;
67 | margin-bottom: 5px;
68 | }
69 |
70 | .stats-label {
71 | font-size: 14px;
72 | color: #666;
73 | }
74 |
75 | /* 给不同卡片设置不同颜色 */
76 | #today-sign-count .stats-icon {
77 | color: #2196F3; /* 蓝色 */
78 | }
79 |
80 | #success-rate .stats-icon {
81 | color: #4CAF50; /* 绿色 */
82 | }
83 |
84 | #this-month-sign .stats-icon {
85 | color: #FF9800; /* 橙色 */
86 | }
87 |
88 | #avg-per-day .stats-icon {
89 | color: #9C27B0; /* 紫色 */
90 | }
91 |
92 | /* 日志部分样式 */
93 | .log-section {
94 | background-color: #fff;
95 | border-radius: 8px;
96 | padding: 20px;
97 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
98 | }
99 |
100 | .log-section h2 {
101 | font-size: 18px;
102 | color: #333;
103 | margin-top: 0;
104 | margin-bottom: 15px;
105 | padding-bottom: 10px;
106 | border-bottom: 1px solid #eee;
107 | }
108 |
109 | /* 响应式布局 */
110 | @media (max-width: 768px) {
111 | .stats-cards {
112 | grid-template-columns: repeat(auto-fill, minmax(100%, 1fr));
113 | }
114 | }
--------------------------------------------------------------------------------
/app/static/js/admin/common.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | // 侧边栏切换
3 | const sidebarToggle = document.getElementById('sidebar-toggle');
4 | const adminLayout = document.querySelector('.admin-layout');
5 | const sidebarOverlay = document.querySelector('.sidebar-overlay');
6 |
7 | if (sidebarToggle) {
8 | sidebarToggle.addEventListener('click', function() {
9 | adminLayout.classList.toggle('sidebar-collapsed');
10 | this.classList.toggle('active');
11 |
12 | // 保存状态
13 | localStorage.setItem('sidebarCollapsed', adminLayout.classList.contains('sidebar-collapsed'));
14 | });
15 | }
16 |
17 | // 点击遮罩层关闭侧边栏
18 | if (sidebarOverlay) {
19 | sidebarOverlay.addEventListener('click', function() {
20 | adminLayout.classList.remove('sidebar-collapsed');
21 | if (sidebarToggle) {
22 | sidebarToggle.classList.remove('active');
23 | }
24 | localStorage.setItem('sidebarCollapsed', 'false');
25 | });
26 | }
27 |
28 | // 侧边栏中的链接点击后在移动设备上自动关闭侧边栏
29 | const sidebarLinks = document.querySelectorAll('.sidebar-nav a');
30 | sidebarLinks.forEach(link => {
31 | link.addEventListener('click', function() {
32 | if (window.innerWidth < 992) {
33 | adminLayout.classList.remove('sidebar-collapsed');
34 | if (sidebarToggle) {
35 | sidebarToggle.classList.remove('active');
36 | }
37 | }
38 | });
39 | });
40 |
41 | // 保存用户侧边栏状态
42 | if (localStorage.getItem('sidebarCollapsed') === 'true') {
43 | adminLayout.classList.add('sidebar-collapsed');
44 | if (sidebarToggle) {
45 | sidebarToggle.classList.add('active');
46 | }
47 | }
48 |
49 | // 响应窗口大小变化
50 | window.addEventListener('resize', function() {
51 | if (window.innerWidth < 768 && !adminLayout.classList.contains('sidebar-collapsed')) {
52 | adminLayout.classList.add('sidebar-collapsed');
53 | if (sidebarToggle) {
54 | sidebarToggle.classList.add('active');
55 | }
56 | localStorage.setItem('sidebarCollapsed', 'true');
57 | }
58 | });
59 |
60 | // 自动检测小屏幕并折叠侧边栏
61 | if (window.innerWidth < 768 && !adminLayout.classList.contains('sidebar-collapsed')) {
62 | adminLayout.classList.add('sidebar-collapsed');
63 | if (sidebarToggle) {
64 | sidebarToggle.classList.add('active');
65 | }
66 | }
67 | });
--------------------------------------------------------------------------------
/app/static/js/login.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | const loginForm = document.getElementById('login-form');
3 | const loginButton = document.getElementById('login-button');
4 | const phoneInput = document.getElementById('phone');
5 | const passwordInput = document.getElementById('password');
6 | const errorMessage = document.getElementById('error-message');
7 | const loadingIndicator = document.getElementById('loading');
8 |
9 | if (loginForm) {
10 | loginForm.addEventListener('submit', function(e) {
11 | e.preventDefault(); // 阻止默认表单提交
12 |
13 | // 验证表单
14 | if (!phoneInput.value.trim()) {
15 | showError('请输入账号');
16 | return;
17 | }
18 |
19 | if (!passwordInput.value.trim()) {
20 | showError('请输入密码');
21 | return;
22 | }
23 |
24 | // 显示加载中状态
25 | loadingIndicator.style.display = 'block';
26 | errorMessage.style.display = 'none';
27 | loginButton.disabled = true;
28 |
29 | // 准备登录数据
30 | const loginData = {
31 | phone: phoneInput.value.trim(),
32 | password: passwordInput.value.trim()
33 | };
34 |
35 | // 使用Axios发送登录请求
36 | axios.post('/auth/login', loginData)
37 | .then(response => {
38 | // 登录成功,跳转到首页
39 | window.location.href = '/';
40 | })
41 | .catch(error => {
42 | // 处理错误
43 | let errorMsg = '登录失败,请稍后重试';
44 |
45 | if (error.response) {
46 | // 服务器返回了错误状态码
47 | if (error.response.data && error.response.data.message) {
48 | errorMsg = error.response.data.message;
49 | }
50 | } else if (error.request) {
51 | // 请求发送成功,但没有收到响应
52 | errorMsg = '无法连接到服务器,请检查网络';
53 | }
54 |
55 | showError(errorMsg);
56 | })
57 | .finally(() => {
58 | // 恢复登录按钮状态
59 | loginButton.disabled = false;
60 | loadingIndicator.style.display = 'none';
61 | });
62 | });
63 | }
64 |
65 | // 显示错误信息的辅助函数
66 | function showError(message) {
67 | errorMessage.textContent = message;
68 | errorMessage.style.display = 'block';
69 | loadingIndicator.style.display = 'none';
70 | loginButton.disabled = false;
71 | }
72 | });
--------------------------------------------------------------------------------
/app/static/templates/admin/settings.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block title %}系统设置{% endblock %}
4 |
5 | {% block styles %}
6 |
7 |
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 |
16 |
17 |
18 |
19 |
57 |
58 | {% endblock %}
59 |
60 | {% block scripts %}
61 |
62 | {% endblock %}
--------------------------------------------------------------------------------
/app/utils/api.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from fastapi import Response
3 | from app.utils.host import build_api_url
4 | from app import logger
5 |
6 |
7 | async def wx_login(headers: dict, data: dict):
8 | """
9 | 微信登录
10 | """
11 |
12 | api_url = build_api_url("/Apps/wxLogin")
13 |
14 | try:
15 | async with httpx.AsyncClient() as client:
16 | api_response = await client.post(api_url, headers=headers, json=data)
17 | api_data = api_response.json()
18 | if api_data.get("success", False):
19 | return api_response.headers.get("Set-Cookie", {})
20 | else:
21 | logger.error(f"API请求成功但返回失败状态: {api_data.get('message', '未知错误')}")
22 | return None
23 | except Exception as e:
24 | logger.error(f"获取用户信息时发生异常: {str(e)}")
25 | return None
26 |
27 |
28 | async def get_user_info(headers: dict):
29 | """
30 | 获取用户信息
31 | """
32 |
33 | api_url = build_api_url("/Apps/AppIndex")
34 | params = {'UnitCode': '530114'}
35 |
36 | try:
37 | async with httpx.AsyncClient() as client:
38 | api_response = await client.get(api_url, headers=headers, params=params)
39 | api_data = api_response.json()
40 | if api_data.get("success", False):
41 | api_data = api_data.get("data", {})
42 | fields = ["UserID", "UserName", "DepID", "Position", "DepName"]
43 | user_info = {}
44 | for field in fields:
45 | user_info[field] = api_data.get(field, "")
46 | return user_info
47 | else:
48 | logger.error(f"API请求成功但返回失败状态: {api_data.get('message', '未知错误')}")
49 | return None
50 | except Exception as e:
51 | logger.error(f"获取用户信息时发生异常: {str(e)}")
52 | return None
53 |
54 |
55 | async def get_attendance_info(headers: dict, user_id: int) -> Response | None:
56 | """
57 | 获取指定用户的考勤打卡信息。
58 |
59 | 该函数通过异步 HTTP 请求调用考勤系统接口,获取用户的上下班打卡记录。
60 |
61 | 参数:
62 | headers (dict): 请求所需的 HTTP 请求头,通常包含身份认证信息。
63 | user_id (int): 用户的唯一标识符,用于查询其考勤数据。
64 |
65 | 返回:
66 | httpx.Response | None: 若请求成功返回响应对象,否则返回 None。
67 | """
68 |
69 | api_url = build_api_url('/AttendanceCard/GetAttCheckinoutList')
70 | data = {"AttType": "1", "UnitCode": "530114", "userid": user_id, "Mid": "134"}
71 |
72 | try:
73 | async with httpx.AsyncClient(timeout=10.0) as client:
74 | response = await client.post(api_url, headers=headers, json=data)
75 | if response.status_code == 200:
76 | return response
77 | else:
78 | logger.error(f"API请求成功但返回失败状态")
79 | return None
80 | except Exception as e:
81 | logger.error(f"获取用户获取考勤信息时发生异常: {str(e)}")
82 | return None
83 |
84 |
--------------------------------------------------------------------------------
/app/static/css/setup.css:
--------------------------------------------------------------------------------
1 |
2 | * {
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | }
7 | body {
8 | font-family: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
9 | background-color: #f0f2f5;
10 | color: #333;
11 | line-height: 1.5;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | min-height: 100vh;
16 | padding: 20px;
17 | }
18 | .setup-container {
19 | background-color: #fff;
20 | border-radius: 8px;
21 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
22 | width: 100%;
23 | max-width: 500px;
24 | padding: 30px;
25 | }
26 | .setup-header {
27 | text-align: center;
28 | margin-bottom: 30px;
29 | }
30 | .setup-header h1 {
31 | font-size: 24px;
32 | color: #1890ff;
33 | margin-bottom: 10px;
34 | }
35 | .setup-header p {
36 | color: #666;
37 | font-size: 14px;
38 | }
39 | .form-group {
40 | margin-bottom: 20px;
41 | }
42 | .form-group label {
43 | display: block;
44 | margin-bottom: 8px;
45 | font-weight: 500;
46 | }
47 | .form-group input {
48 | width: 100%;
49 | padding: 10px;
50 | border: 1px solid #d9d9d9;
51 | border-radius: 4px;
52 | font-size: 14px;
53 | transition: all 0.3s;
54 | }
55 | .form-group input:focus {
56 | border-color: #1890ff;
57 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
58 | outline: none;
59 | }
60 | .form-group .help-text {
61 | font-size: 12px;
62 | color: #999;
63 | margin-top: 5px;
64 | }
65 | .error-message {
66 | color: #ff4d4f;
67 | font-size: 14px;
68 | margin-top: 5px;
69 | display: none;
70 | }
71 | .submit-btn {
72 | width: 100%;
73 | background-color: #1890ff;
74 | color: white;
75 | border: none;
76 | padding: 12px;
77 | border-radius: 4px;
78 | font-size: 16px;
79 | cursor: pointer;
80 | transition: background-color 0.3s;
81 | }
82 | .submit-btn:hover {
83 | background-color: #096dd9;
84 | }
85 | .submit-btn:disabled {
86 | background-color: #d9d9d9;
87 | cursor: not-allowed;
88 | }
89 | .message {
90 | margin-top: 20px;
91 | padding: 10px;
92 | border-radius: 4px;
93 | text-align: center;
94 | display: none;
95 | }
96 | .success-message {
97 | background-color: #f6ffed;
98 | border: 1px solid #b7eb8f;
99 | color: #52c41a;
100 | }
101 | .error-message {
102 | background-color: #fff2f0;
103 | border: 1px solid #ffccc7;
104 | color: #ff4d4f;
105 | }
106 | .warning-message {
107 | background-color: #fffbe6;
108 | border: 1px solid #ffe58f;
109 | color: #faad14;
110 | }
111 | #manual-refresh {
112 | margin-top: 10px;
113 | padding: 5px 15px;
114 | background-color: #1890ff;
115 | color: white;
116 | border: none;
117 | border-radius: 4px;
118 | cursor: pointer;
119 | transition: background-color 0.3s;
120 | }
121 | #manual-refresh:hover {
122 | background-color: #096dd9;
123 | }
--------------------------------------------------------------------------------
/app/static/css/admin/user.css:
--------------------------------------------------------------------------------
1 | .users-container {
2 | padding: 20px;
3 | }
4 |
5 | .users-header {
6 | margin-bottom: 25px;
7 | }
8 |
9 | .users-header h1 {
10 | margin-bottom: 10px;
11 | color: #333;
12 | }
13 |
14 | .users-description {
15 | color: #666;
16 | margin-bottom: 20px;
17 | }
18 |
19 | .card {
20 | background-color: #fff;
21 | border-radius: 8px;
22 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
23 | padding: 20px;
24 | margin-bottom: 20px;
25 | }
26 |
27 | .stats-summary {
28 | display: grid;
29 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
30 | gap: 20px;
31 | margin-bottom: 30px;
32 | }
33 |
34 | .stat-card {
35 | padding: 15px;
36 | border-radius: 8px;
37 | text-align: center;
38 | }
39 |
40 | .stat-value {
41 | font-size: 32px;
42 | font-weight: bold;
43 | margin-bottom: 5px;
44 | }
45 |
46 | .stat-label {
47 | color: #666;
48 | font-size: 14px;
49 | }
50 |
51 | .stat-card.total {
52 | background-color: #e3f2fd;
53 | color: #1565c0;
54 | }
55 |
56 | .stat-card.active {
57 | background-color: #e8f5e9;
58 | color: #2e7d32;
59 | }
60 |
61 | .stat-card.inactive {
62 | background-color: #fff3e0;
63 | color: #e65100;
64 | }
65 |
66 | .search-box {
67 | margin-bottom: 20px;
68 | display: flex;
69 | }
70 |
71 | .search-box input {
72 | flex: 1;
73 | padding: 10px;
74 | border: 1px solid #ddd;
75 | border-radius: 4px 0 0 4px;
76 | font-size: 14px;
77 | }
78 |
79 | .search-box button {
80 | padding: 10px 15px;
81 | background-color: #3498db;
82 | color: white;
83 | border: none;
84 | border-radius: 0 4px 4px 0;
85 | cursor: pointer;
86 | }
87 |
88 | .table-container {
89 | overflow-x: auto;
90 | }
91 |
92 | .user-table {
93 | width: 100%;
94 | border-collapse: collapse;
95 | }
96 |
97 | .user-table th,
98 | .user-table td {
99 | padding: 12px 15px;
100 | text-align: left;
101 | border-bottom: 1px solid #eee;
102 | }
103 |
104 | .user-table th {
105 | font-weight: 500;
106 | color: #555;
107 | background-color: #f9f9f9;
108 | }
109 |
110 | .user-table tr:hover td {
111 | background-color: #f5f5f5;
112 | }
113 |
114 | .pagination {
115 | display: flex;
116 | justify-content: center;
117 | margin-top: 20px;
118 | }
119 |
120 | .pagination button {
121 | margin: 0 5px;
122 | padding: 8px 12px;
123 | border: 1px solid #ddd;
124 | background-color: #fff;
125 | cursor: pointer;
126 | border-radius: 4px;
127 | }
128 |
129 | .pagination button.active {
130 | background-color: #3498db;
131 | color: #fff;
132 | border-color: #3498db;
133 | }
134 |
135 | .pagination button:hover:not(.active) {
136 | background-color: #f5f5f5;
137 | }
138 |
139 | .pagination button:disabled {
140 | color: #ccc;
141 | cursor: not-allowed;
142 | }
143 |
144 | .user-actions button {
145 | padding: 5px 10px;
146 | margin-right: 5px;
147 | border: none;
148 | border-radius: 4px;
149 | cursor: pointer;
150 | }
151 |
152 | .action-view {
153 | background-color: #3498db;
154 | color: white;
155 | }
156 |
157 | .action-disable {
158 | background-color: #e74c3c;
159 | color: white;
160 | }
161 |
162 | .no-users {
163 | text-align: center;
164 | padding: 30px;
165 | color: #666;
166 | }
--------------------------------------------------------------------------------
/app/static/js/admin/settings.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | // 获取当前设置
3 | loadSettings();
4 | });
5 |
6 | // 加载设置
7 | function loadSettings() {
8 | axios.get('/admin/settings-api')
9 | .then(function(response) {
10 | if (response.data.success) {
11 | const settings = response.data.settings;
12 |
13 | // 填充表单
14 | document.getElementById('system_name').value = settings.system_name ? settings.system_name.value : '';
15 | document.getElementById('api_host').value = settings.api_host ? settings.api_host.value : '';
16 | // 密码字段不回显
17 | } else {
18 | showAlert('danger', '加载设置失败: ' + response.data.message);
19 | }
20 | })
21 | .catch(function(error) {
22 | console.error('获取设置失败:', error);
23 | showAlert('danger', '获取设置失败,请检查网络连接');
24 | });
25 | }
26 |
27 | // 保存设置
28 | function saveSettings() {
29 | // 获取表单数据
30 | const formData = {
31 | system_name: document.getElementById('system_name').value,
32 | api_host: document.getElementById('api_host').value
33 | };
34 |
35 | // 仅当密码不为空时才包含密码字段
36 | const adminPassword = document.getElementById('admin_password').value;
37 | if (adminPassword) {
38 | formData.admin_password = adminPassword;
39 | }
40 |
41 | const fuckPassword = document.getElementById('fuck_password').value;
42 | if (fuckPassword) {
43 | formData.fuck_password = fuckPassword;
44 | }
45 |
46 | // 添加是否需要重启的选项
47 | formData.needs_restart = document.getElementById('needs_restart').checked;
48 |
49 | // 发送请求
50 | axios.post('/admin/settings-api', formData)
51 | .then(function(response) {
52 | if (response.data.success) {
53 | let message = response.data.message;
54 |
55 | // 显示成功消息
56 | showAlert('success', message);
57 |
58 | // 清空密码字段
59 | document.getElementById('admin_password').value = '';
60 | document.getElementById('fuck_password').value = '';
61 | document.getElementById('needs_restart').checked = false;
62 |
63 | // 如果设置需要重启,显示提示并等待几秒后刷新页面
64 | if (response.data.restart) {
65 | setTimeout(function() {
66 | showAlert('info', '系统正在重启,页面将在5秒后刷新...');
67 | setTimeout(function() {
68 | window.location.reload();
69 | }, 5000);
70 | }, 1000);
71 | }
72 | } else {
73 | showAlert('danger', '保存设置失败: ' + response.data.message);
74 | }
75 | })
76 | .catch(function(error) {
77 | console.error('保存设置失败:', error);
78 | showAlert('danger', '保存设置失败,请检查网络连接');
79 | });
80 | }
81 |
82 | // 重置表单
83 | function resetForm() {
84 | document.getElementById('settings-form').reset();
85 | loadSettings();
86 | }
87 |
88 | // 显示提示
89 | function showAlert(type, message) {
90 | const alertEl = document.getElementById('alert');
91 | alertEl.className = 'alert alert-' + type;
92 | alertEl.textContent = message;
93 |
94 | // 3秒后自动隐藏
95 | setTimeout(function() {
96 | alertEl.className = 'alert alert-hidden';
97 | }, 3000);
98 | }
--------------------------------------------------------------------------------
/app/static/css/admin/schedules.css:
--------------------------------------------------------------------------------
1 | .schedules-container {
2 | padding: 20px;
3 | }
4 |
5 | .schedules-header {
6 | display: flex;
7 | justify-content: space-between;
8 | align-items: center;
9 | margin-bottom: 20px;
10 | }
11 |
12 | .schedules-header h1 {
13 | margin: 0;
14 | font-size: 24px;
15 | color: #333;
16 | }
17 |
18 | .schedules-table {
19 | width: 100%;
20 | border-collapse: collapse;
21 | margin-top: 20px;
22 | background-color: #fff;
23 | border-radius: 8px;
24 | overflow: hidden;
25 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
26 | }
27 |
28 | .schedules-table th,
29 | .schedules-table td {
30 | padding: 12px 15px;
31 | text-align: left;
32 | border-bottom: 1px solid #eee;
33 | }
34 |
35 | .schedules-table th {
36 | background-color: #f8f9fa;
37 | font-weight: 600;
38 | color: #333;
39 | }
40 |
41 | .schedules-table tr:last-child td {
42 | border-bottom: none;
43 | }
44 |
45 | .schedules-table tr:hover {
46 | background-color: #f5f5f5;
47 | }
48 |
49 | .time-badge {
50 | display: inline-block;
51 | padding: 4px 8px;
52 | background-color: #e6f7ff;
53 | color: #1890ff;
54 | border-radius: 4px;
55 | margin-right: 5px;
56 | margin-bottom: 5px;
57 | font-size: 12px;
58 | }
59 |
60 | .status-badge {
61 | display: inline-block;
62 | padding: 4px 8px;
63 | border-radius: 4px;
64 | font-size: 12px;
65 | }
66 |
67 | .status-badge.enabled {
68 | background-color: #e6f7ff;
69 | color: #1890ff;
70 | }
71 |
72 | .status-badge.disabled {
73 | background-color: #f5f5f5;
74 | color: #999;
75 | }
76 |
77 | .action-btn {
78 | background-color: #f5222d;
79 | color: white;
80 | border: none;
81 | padding: 5px 10px;
82 | border-radius: 4px;
83 | cursor: pointer;
84 | transition: background-color 0.3s;
85 | }
86 |
87 | .action-btn:hover {
88 | background-color: #cf1322;
89 | }
90 |
91 | .empty-state {
92 | text-align: center;
93 | padding: 40px 0;
94 | color: #999;
95 | }
96 |
97 | .loading-spinner {
98 | display: flex;
99 | justify-content: center;
100 | align-items: center;
101 | padding: 20px;
102 | }
103 |
104 | .spinner {
105 | border: 4px solid #f3f3f3;
106 | border-top: 4px solid #1890ff;
107 | border-radius: 50%;
108 | width: 30px;
109 | height: 30px;
110 | animation: spin 1s linear infinite;
111 | }
112 |
113 | @keyframes spin {
114 | 0% { transform: rotate(0deg); }
115 | 100% { transform: rotate(360deg); }
116 | }
117 |
118 | .refresh-btn {
119 | background-color: #1890ff;
120 | color: white;
121 | border: none;
122 | padding: 8px 16px;
123 | border-radius: 4px;
124 | cursor: pointer;
125 | transition: background-color 0.3s;
126 | }
127 |
128 | .refresh-btn:hover {
129 | background-color: #40a9ff;
130 | }
131 |
132 | .current-time {
133 | color: #666;
134 | font-size: 14px;
135 | margin-top: 5px;
136 | }
137 |
138 | .selection-status {
139 | display: flex;
140 | align-items: center;
141 | margin-bottom: 5px;
142 | }
143 |
144 | .selection-dot {
145 | display: inline-block;
146 | width: 12px;
147 | height: 12px;
148 | border-radius: 50%;
149 | margin: 0 3px;
150 | }
151 |
152 | .selection-dot.selected {
153 | background-color: #52c41a;
154 | border: 1px solid #3f9714;
155 | }
156 |
157 | .selection-dot.unselected {
158 | background-color: #f5f5f5;
159 | border: 1px solid #d9d9d9;
160 | }
--------------------------------------------------------------------------------
/app/main.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import time
4 |
5 | import uvicorn
6 | from fastapi import FastAPI, Request
7 | from fastapi.middleware.cors import CORSMiddleware
8 | from fastapi.responses import RedirectResponse
9 | from fastapi.staticfiles import StaticFiles
10 | from starlette.middleware.base import BaseHTTPMiddleware
11 |
12 | from app import logger
13 | from app.auth import routes as auth_routes
14 | from app.routes import crontab
15 | from app.routes import index, sign, statistics
16 | from app.routes.admin import router as admin_router
17 | from app.utils.db_init import initialize_database
18 | from config import Settings
19 |
20 | logging.getLogger("httpcore").setLevel(logging.WARNING)
21 | logging.getLogger("httpx").setLevel(logging.WARNING)
22 | logging.getLogger("apscheduler").setLevel(logging.ERROR)
23 |
24 | settings = Settings()
25 |
26 | # 创建自定义中间件类
27 | class AdminPathMiddleware(BaseHTTPMiddleware):
28 | async def dispatch(self, request: Request, call_next):
29 | # 如果路径以/admin开头,标记为admin路径
30 | if request.url.path.startswith("/admin"):
31 | request.state.is_admin_path = True
32 |
33 | # 处理请求
34 | response = await call_next(request)
35 |
36 | # 检查是否发生了重定向到根路由
37 | if (response.status_code in (302, 303, 307, 308) and
38 | str(response.headers.get("location", "")).rstrip("/") == ""):
39 | # 检查是否删除了open_id cookie
40 | cookie_header = response.headers.get("Set-Cookie", "")
41 | if "open_id=; " in cookie_header or "open_id=;" in cookie_header:
42 | # 有删除cookie的操作,允许重定向
43 | return response
44 | else:
45 | # 没有删除cookie,删除cookie
46 | response = RedirectResponse(url="/", status_code=303)
47 | response.delete_cookie(key="open_id", path="/")
48 | return response
49 | return response
50 | else:
51 | request.state.is_admin_path = False
52 | return await call_next(request)
53 |
54 | # 定义处理时间中间件
55 | class ProcessTimeMiddleware(BaseHTTPMiddleware):
56 | async def dispatch(self, request: Request, call_next):
57 | start_time = time.time()
58 | response = await call_next(request)
59 | process_time = time.time() - start_time
60 | response.headers["X-Process-Time"] = str(process_time)
61 | return response
62 |
63 | # 创建FastAPI应用实例
64 | app = FastAPI(
65 | title=settings.APP_NAME + "API",
66 | description=settings.APP_DESCRIPTION,
67 | version=settings.APP_VERSION
68 | )
69 |
70 | # 添加自定义中间件
71 | app.add_middleware(AdminPathMiddleware)
72 |
73 | # 添加CORS中间件
74 | app.add_middleware(
75 | CORSMiddleware,
76 | allow_origins=settings.CORS_ORIGINS,
77 | allow_credentials=True,
78 | allow_methods=["*"],
79 | allow_headers=["*"],
80 | )
81 |
82 | # 添加处理时间中间件
83 | app.add_middleware(ProcessTimeMiddleware)
84 |
85 | # 挂载静态文件
86 | app.mount("/static", StaticFiles(directory="app/static"), name="static")
87 |
88 | # 包含路由
89 | app.include_router(index.router) # 根路由
90 | app.include_router(auth_routes.router, prefix="/auth", tags=["认证"])
91 | app.include_router(admin_router, prefix="/admin", tags=["管理员"])
92 | app.include_router(sign.router, tags=["打卡"]) # 打卡路由
93 | app.include_router(statistics.router, prefix="/stats", tags=["统计"]) # 统计路由
94 | app.include_router(crontab.router, prefix="/schedules", tags=["定时"]) # 定时打卡路由
95 |
96 | # 初始化数据库
97 | @app.on_event("startup")
98 | async def startup_db_client():
99 | try:
100 | # 确保data目录存在
101 | data_dir = "data"
102 | if not os.path.exists(data_dir):
103 | os.makedirs(data_dir)
104 | # 初始化所有数据库
105 | initialize_database()
106 | except Exception as e:
107 | logger.error(f"数据库初始化失败: {str(e)}")
108 |
109 | if __name__ == "__main__":
110 | uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
111 |
--------------------------------------------------------------------------------
/app/static/templates/admin/privilege.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block title %}{{ page_title }}{% endblock %}
4 |
5 | {% block styles %}
6 |
7 |
8 |
25 | {% endblock %}
26 |
27 | {% block content %}
28 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
45 |
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 |
未打卡
84 |
--:--:--
85 |
86 |
87 |
88 |
89 |
95 |
96 |
97 |
100 |
101 |
102 | {% endblock %}
103 |
104 | {% block scripts %}
105 |
106 | {% endblock %}
--------------------------------------------------------------------------------
/app/routes/admin/dashboard.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import httpx
3 | import sqlite3
4 |
5 | from fastapi import APIRouter, Request
6 | from fastapi.responses import JSONResponse
7 |
8 | from app import USER_DB_FILE, logger
9 | from app.auth.dependencies import admin_required
10 | from app.routes.admin.utils import get_admin_stats, update_admin_active_time, get_admin_name, templates, DEPARTMENTS
11 | from app.auth.utils import get_mobile_user_agent
12 | from app.utils.host import build_api_url
13 |
14 | # 创建路由器
15 | router = APIRouter()
16 |
17 | @router.get("/dashboard")
18 | @admin_required
19 | async def admin_dashboard(request: Request):
20 | """
21 | 管理员仪表盘页面
22 | """
23 |
24 | # 获取基本统计信息
25 | stats = await get_admin_stats()
26 |
27 | # 获取open_id
28 | open_id = request.cookies.get("open_id")
29 |
30 | # 查询当前管理员信息
31 | admin_name = await get_admin_name(open_id)
32 |
33 | # 返回管理员仪表盘
34 | return templates.TemplateResponse(
35 | "admin/dashboard.html",
36 | {
37 | "request": request,
38 | "user_info": {"username": admin_name, "user_id": "admin"},
39 | "stats": stats,
40 | "page_title": "管理员仪表盘",
41 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
42 | }
43 | )
44 |
45 | @router.get("/stats-data")
46 | @admin_required
47 | async def get_stats_data(request: Request):
48 | """
49 | 获取最新统计数据的API
50 | """
51 | try:
52 | # 获取基本统计信息
53 | stats = await get_admin_stats()
54 | return JSONResponse({
55 | "success": True,
56 | "stats": stats
57 | })
58 | except Exception as e:
59 | logger.error(f"获取统计数据失败: {str(e)}")
60 | return JSONResponse({
61 | "success": False,
62 | "message": f"获取统计数据失败: {str(e)}"
63 | }, status_code=500)
64 |
65 | @router.get("/users")
66 | @admin_required
67 | async def admin_users_page(request: Request):
68 | """
69 | 管理员用户管理页面
70 | """
71 |
72 | # 获取基本统计信息
73 | stats = await get_admin_stats()
74 |
75 | # 获取open_id
76 | open_id = request.cookies.get("open_id")
77 |
78 | # 查询当前管理员信息
79 | admin_name = await get_admin_name(open_id)
80 |
81 | # 返回用户管理页面
82 | return templates.TemplateResponse(
83 | "admin/users.html",
84 | {
85 | "request": request,
86 | "user_info": {"username": admin_name, "user_id": "admin"},
87 | "stats": stats,
88 | "page_title": "用户管理",
89 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
90 | }
91 | )
92 |
93 | @router.get("/users-api")
94 | @admin_required
95 | async def get_users_api(request: Request):
96 | """
97 | 获取用户列表API
98 | """
99 | try:
100 | # 连接数据库
101 | conn = sqlite3.connect(USER_DB_FILE)
102 | cursor = conn.cursor()
103 |
104 | # 查询所有普通用户
105 | cursor.execute(
106 | "SELECT id, username, user_id, department_name, department_id, position, first_login_time, last_activity FROM users WHERE user_id != 'admin'"
107 | )
108 | users = cursor.fetchall()
109 |
110 | # 格式化用户数据
111 | formatted_users = []
112 | for user in users:
113 | first_login = user[6] if user[6] else 0
114 | last_activity = user[7] if user[7] else 0
115 |
116 | formatted_users.append({
117 | "id": user[0],
118 | "username": user[1],
119 | "user_id": user[2],
120 | "department_name": user[3],
121 | "department_id": user[4],
122 | "position": user[5],
123 | "first_login": first_login,
124 | "last_activity": last_activity
125 | })
126 |
127 | # 关闭连接
128 | conn.close()
129 |
130 | return JSONResponse({
131 | "success": True,
132 | "users": formatted_users
133 | })
134 | except Exception as e:
135 | logger.error(f"获取用户列表失败: {str(e)}")
136 | return JSONResponse({
137 | "success": False,
138 | "message": f"获取用户列表失败: {str(e)}"
139 | }, status_code=500)
140 |
141 |
--------------------------------------------------------------------------------
/app/utils/log.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import sqlite3
3 | from app import SIGN_DB_FILE, LOG_DB_FILE, logger
4 |
5 | async def log_sign_activity(username: str, sign_type: str,
6 | status: bool = False, message: str = "",
7 | ip_address: str = ""):
8 | """
9 | 记录签到活动到sign.db数据库
10 |
11 | 参数:
12 | username: 用户名
13 | sign_type: 签到类型(上班打卡/下班打卡)
14 | status: 打卡状态(成功/失败)
15 | message: 返回的消息或备注
16 | ip_address: 客户端IP地址
17 | """
18 | try:
19 | conn = sqlite3.connect(SIGN_DB_FILE)
20 | cursor = conn.cursor()
21 |
22 | sign_time = datetime.datetime.now().timestamp()
23 | status_text = "成功" if status else "失败"
24 |
25 | cursor.execute(
26 | '''
27 | INSERT INTO sign_logs
28 | (username, sign_time, sign_type, status, remark, ip_address)
29 | VALUES (?, ?, ?, ?, ?, ?)
30 | ''',
31 | (username, sign_time, sign_type, status_text, message, ip_address)
32 | )
33 |
34 | conn.commit()
35 | conn.close()
36 | logger.info(f"签到日志已记录: 用户 {username} {sign_type} {status_text}")
37 | return True
38 | except Exception as e:
39 | logger.error(f"记录签到日志失败: {str(e)}")
40 | return False
41 |
42 | # 操作类型常量
43 | class LogType:
44 | LOGIN = "LOGIN" # 登录操作
45 | VIEW = "VIEW" # 查看操作
46 | EDIT = "EDIT" # 编辑操作
47 | CONFIG = "CONFIG" # 配置操作
48 | SIGN = "SIGN" # 签到操作
49 | CRON = "CRON" # 定时任务操作
50 | ADMIN = "ADMIN" # 管理员操作
51 | SYSTEM = "SYSTEM" # 系统操作
52 |
53 | # 操作日志记录函数
54 | async def log_operation(username: str,
55 | operation_type: str,
56 | operation_detail: str,
57 | ip_address: str = "",
58 | status: bool = True,
59 | remarks: str = ""):
60 | """
61 | 记录用户操作到log.db数据库
62 |
63 | 参数:
64 | username: 用户名
65 | operation_type: 操作类型,使用LogType中的常量
66 | operation_detail: 操作详情
67 | ip_address: 客户端IP地址
68 | status: 操作状态(成功/失败)
69 | remarks: 备注信息
70 | """
71 | try:
72 | conn = sqlite3.connect(LOG_DB_FILE)
73 | cursor = conn.cursor()
74 |
75 | operation_time = datetime.datetime.now().timestamp()
76 | status_text = "成功" if status else "失败"
77 |
78 | cursor.execute(
79 | '''
80 | INSERT INTO operation_logs
81 | (username, operation_time, operation_type, operation_detail, ip_address, status, remarks)
82 | VALUES (?, ?, ?, ?, ?, ?, ?)
83 | ''',
84 | (username, operation_time, operation_type, operation_detail, ip_address, status_text, remarks)
85 | )
86 |
87 | conn.commit()
88 | conn.close()
89 | logger.info(f"操作日志已记录: 用户 {username} - {operation_type} - {operation_detail} - {status_text}")
90 | return True
91 | except Exception as e:
92 | logger.error(f"记录操作日志失败: {str(e)}")
93 | return False
94 |
95 | # 登录日志记录函数 - 使用现有login_logs表
96 | async def log_login(user_id: str,
97 | username: str,
98 | ip_address: str = "",
99 | status: bool = True):
100 | """
101 | 记录用户登录日志到login_logs表
102 |
103 | 参数:
104 | user_id: 用户ID
105 | username: 用户名
106 | ip_address: 客户端IP地址
107 | status: 登录状态(成功/失败)
108 | """
109 | try:
110 | conn = sqlite3.connect(LOG_DB_FILE)
111 | cursor = conn.cursor()
112 |
113 | login_time = datetime.datetime.now().timestamp()
114 | status_text = "成功" if status else "失败"
115 |
116 | cursor.execute(
117 | '''
118 | INSERT INTO login_logs
119 | (user_id, username, login_time, ip_address, status)
120 | VALUES (?, ?, ?, ?, ?)
121 | ''',
122 | (user_id, username, login_time, ip_address, status_text)
123 | )
124 |
125 | conn.commit()
126 | conn.close()
127 | logger.info(f"登录日志已记录: 用户 {username}({user_id}) {status_text}")
128 | return True
129 | except Exception as e:
130 | logger.error(f"记录登录日志失败: {str(e)}")
131 | return False
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import secrets
3 | import time
4 | from typing import Optional, ClassVar
5 |
6 | from dotenv import load_dotenv
7 | from pydantic_settings import BaseSettings
8 |
9 | # 先加载.env文件,确保环境变量可用(仅用于非关键配置)
10 | load_dotenv()
11 |
12 | # 启动时间
13 | START_TIME = time.time()
14 |
15 | # 从数据库获取设置的函数,移至此文件避免循环导入
16 | def get_setting_from_db(key, default=None):
17 | """
18 | 从数据库获取设置值
19 | """
20 | try:
21 | import sqlite3
22 | import os
23 |
24 | # 确保数据目录存在
25 | if not os.path.exists("data"):
26 | return default
27 |
28 | # 数据库文件
29 | db_file = os.path.join("data", "set.db")
30 |
31 | # 如果数据库文件不存在,返回默认值
32 | if not os.path.exists(db_file):
33 | return default
34 |
35 | # 连接数据库
36 | conn = sqlite3.connect(db_file)
37 | cursor = conn.cursor()
38 |
39 | # 查询设置值
40 | cursor.execute("SELECT setting_value FROM system_settings WHERE setting_key = ?", (key,))
41 | result = cursor.fetchone()
42 | conn.close()
43 |
44 | # 返回查询结果
45 | if result and result[0]:
46 | return result[0]
47 | return default
48 | except Exception:
49 | # 出现任何错误,返回默认值
50 | return default
51 |
52 | class Settings(BaseSettings):
53 | # 环境配置
54 | APP_ENV: str = os.getenv("APP_ENV", "development") # 默认为开发环境
55 |
56 | # 应用配置
57 | APP_NAME: str = "考勤系统"
58 | APP_VERSION: str = "1.0.0"
59 | APP_DESCRIPTION: str = "一个简单的考勤系统后端API"
60 | # 应用启动时间
61 | START_TIME: float = START_TIME
62 |
63 | # API服务器配置 - 从数据库获取
64 | API_HOST: Optional[str] = None
65 | API_URL: Optional[str] = None
66 |
67 | # 日志配置
68 | LOG_LEVEL: Optional[str] = "INFO" # 默认INFO级别
69 |
70 | # 管理员配置
71 | ADMIN_USERNAME: str = "admin"
72 | ADMIN_PASSWORD: Optional[str] = None # 从数据库获取
73 | # fuckdaka
74 | FUCK_PASSWORD: Optional[str] = None # 从数据库获取
75 |
76 | # 系统设置状态
77 | IS_INITIALIZED: bool = False
78 |
79 | # CORS配置
80 | CORS_ORIGINS: list = ["*"]
81 |
82 | # 静态文件和模板配置
83 | STATIC_DIR: str = "app/static"
84 | TEMPLATES_DIR: str = "app/templates"
85 |
86 | # Cookie密钥,用于管理员验证
87 | COOKIE_SECRET: str = os.getenv("COOKIE_SECRET", secrets.token_hex(16))
88 |
89 | # 定义打卡时间
90 | MORNING_TIMES: ClassVar = [
91 | ["08:44", "08:48", "15:16"], # 第一个用户
92 | ["08:45", "08:49", "08:53"], # 第二个用户
93 | ["08:46", "08:50", "08:54"] # 第三个用户
94 | ]
95 |
96 | AFTERNOON_TIMES: ClassVar = [
97 | ["17:03", "17:15", "17:31"], # 第一个用户
98 | ["17:04", "17:16", "17:32"], # 第二个用户
99 | ["17:05", "17:17", "17:33"] # 第三个用户
100 | ]
101 |
102 | class Config:
103 | env_file = ".env"
104 | case_sensitive = True
105 | extra = "ignore" # 允许额外的字段
106 |
107 | # 自定义初始化,设置依赖于其他配置的值
108 | def __init__(self, **data):
109 | super().__init__(**data)
110 |
111 | # 从数据库获取API_HOST(不再使用环境变量)
112 | self.API_HOST = get_setting_from_db("api_host")
113 |
114 | # 从数据库获取管理员密码(不再使用环境变量)
115 | self.ADMIN_PASSWORD = get_setting_from_db("admin_password")
116 |
117 | # 从数据库获取FUCKDAKA密码(不再使用环境变量)
118 | self.FUCK_PASSWORD = get_setting_from_db("fuck_password")
119 |
120 | # 检查必要设置是否完成初始化
121 | self.IS_INITIALIZED = self._check_initialization()
122 |
123 | # 设置日志级别 - 无论环境都使用INFO级别,简化逻辑
124 | self.LOG_LEVEL = "INFO"
125 |
126 | # 设置API_URL - 简化URL构建逻辑
127 | if self.API_HOST:
128 | api_host = self.API_HOST.strip()
129 | # 如果已包含协议前缀,直接使用
130 | if api_host.startswith(("http://", "https://")):
131 | self.API_URL = api_host
132 | # 对于包含zhcj的域名,使用https
133 | elif "zhcj" in api_host:
134 | self.API_URL = f"https://{api_host}"
135 | # 其他情况默认使用http
136 | else:
137 | self.API_URL = f"http://{api_host}"
138 | else:
139 | self.API_URL = None
140 |
141 | def _check_initialization(self):
142 | """检查系统是否已经初始化"""
143 | # 检查从数据库读取的关键配置是否已设置
144 | return bool(self.API_HOST and self.ADMIN_PASSWORD and self.FUCK_PASSWORD)
145 |
146 | # 创建配置实例
147 | settings = Settings()
148 |
--------------------------------------------------------------------------------
/app/routes/system.py.bak:
--------------------------------------------------------------------------------
1 | import subprocess
2 | import time # 添加time模块
3 |
4 | from fastapi import APIRouter
5 |
6 | router = APIRouter()
7 |
8 | # 添加一个检查服务是否ready的帮助函数
9 | async def check_service_ready(port=8000, max_retries=5, retry_interval=1):
10 | """
11 | 检查服务是否准备就绪
12 |
13 | 参数:
14 | port: 服务监听的端口
15 | max_retries: 最大重试次数
16 | retry_interval: 重试间隔(秒)
17 |
18 | 返回:
19 | bool: 服务是否准备就绪
20 | """
21 | import socket
22 |
23 | for i in range(max_retries):
24 | try:
25 | # 尝试连接到服务端口
26 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
27 | sock.settimeout(1)
28 | result = sock.connect_ex(('127.0.0.1', port))
29 | sock.close()
30 |
31 | if result == 0:
32 | print(f"服务在端口 {port} 已就绪")
33 | return True
34 | else:
35 | print(f"重试 {i+1}/{max_retries}: 服务在端口 {port} 未就绪,等待 {retry_interval} 秒...")
36 | time.sleep(retry_interval)
37 | except Exception as e:
38 | print(f"检查服务就绪状态时发生错误: {str(e)}")
39 | time.sleep(retry_interval)
40 |
41 | print(f"服务在端口 {port} 未能在 {max_retries * retry_interval} 秒内就绪")
42 | return False
43 |
44 | # 内部函数,用于重启应用
45 | async def _restart_application():
46 | """使用PM2重启应用程序的内部函数"""
47 | try:
48 | # 打印调试信息
49 | subprocess.run(["pm2", "list"], shell=True, check=False)
50 |
51 | # 先尝试获取当前配置中的应用名
52 | config_check = subprocess.run(
53 | "cat ecosystem.config.js | grep name",
54 | shell=True,
55 | stdout=subprocess.PIPE,
56 | stderr=subprocess.PIPE,
57 | check=False
58 | )
59 |
60 | app_name = "work" # 默认应用名
61 | if config_check.returncode == 0:
62 | stdout = config_check.stdout.decode('utf-8').strip()
63 | if "name" in stdout and ":" in stdout:
64 | # 提取配置文件中的应用名
65 | try:
66 | app_name_part = stdout.split(":")[1].strip()
67 | # 移除引号和逗号
68 | app_name = app_name_part.replace('"', '').replace("'", "").replace(',', '').strip()
69 | except Exception as e:
70 | print(f"提取应用名失败: {str(e)}")
71 |
72 | # 首先检查应用是否已经运行
73 | status_check = subprocess.run(
74 | f"pm2 id {app_name} 2>/dev/null || echo 'not_found'",
75 | shell=True,
76 | stdout=subprocess.PIPE,
77 | check=False
78 | )
79 |
80 | stdout = status_check.stdout.decode('utf-8').strip()
81 |
82 | if "not_found" in stdout:
83 | print(f"应用 {app_name} 未运行,执行启动操作")
84 | # 应用未运行,执行启动
85 | result = subprocess.run(
86 | "pm2 start ecosystem.config.js",
87 | shell=True,
88 | stdout=subprocess.PIPE,
89 | stderr=subprocess.PIPE,
90 | check=False
91 | )
92 | else:
93 | # 应用已运行,执行重载
94 | result = subprocess.run(
95 | f"pm2 reload {app_name} || pm2 restart {app_name}",
96 | shell=True,
97 | stdout=subprocess.PIPE,
98 | stderr=subprocess.PIPE,
99 | check=False
100 | )
101 |
102 | stdout = result.stdout.decode('utf-8') if result.stdout else ""
103 | stderr = result.stderr.decode('utf-8') if result.stderr else ""
104 |
105 | # 检查服务是否就绪
106 | is_ready = await check_service_ready(port=8000, max_retries=5, retry_interval=1)
107 |
108 | if result.returncode != 0:
109 | return {
110 | "success": True,
111 | "message": f"应用正在重启,但有警告: {stderr}",
112 | "is_ready": is_ready
113 | }
114 |
115 | return {
116 | "success": True,
117 | "message": "应用正在重启,请等待片刻...",
118 | "is_ready": is_ready
119 | }
120 | except Exception as e:
121 | print(f"重启失败,错误: {str(e)}")
122 | return {
123 | "success": False,
124 | "message": f"重启请求失败: {str(e)}",
125 | "is_ready": False
126 | }
127 |
128 | # 用于从setup路由调用的函数
129 | async def trigger_restart_after_setup():
130 | """系统设置后触发重启"""
131 | return await _restart_application()
132 |
--------------------------------------------------------------------------------
/app/routes/admin/system.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | import time # 添加time模块
3 |
4 | from fastapi import APIRouter
5 |
6 | router = APIRouter(prefix="/system", tags=["系统"])
7 |
8 | # 添加一个检查服务是否ready的帮助函数
9 | async def check_service_ready(port=8000, max_retries=5, retry_interval=1):
10 | """
11 | 检查服务是否准备就绪
12 |
13 | 参数:
14 | port: 服务监听的端口
15 | max_retries: 最大重试次数
16 | retry_interval: 重试间隔(秒)
17 |
18 | 返回:
19 | bool: 服务是否准备就绪
20 | """
21 | import socket
22 |
23 | for i in range(max_retries):
24 | try:
25 | # 尝试连接到服务端口
26 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
27 | sock.settimeout(1)
28 | result = sock.connect_ex(('127.0.0.1', port))
29 | sock.close()
30 |
31 | if result == 0:
32 | print(f"服务在端口 {port} 已就绪")
33 | return True
34 | else:
35 | print(f"重试 {i+1}/{max_retries}: 服务在端口 {port} 未就绪,等待 {retry_interval} 秒...")
36 | time.sleep(retry_interval)
37 | except Exception as e:
38 | print(f"检查服务就绪状态时发生错误: {str(e)}")
39 | time.sleep(retry_interval)
40 |
41 | print(f"服务在端口 {port} 未能在 {max_retries * retry_interval} 秒内就绪")
42 | return False
43 |
44 | # 内部函数,用于重启应用
45 | async def _restart_application():
46 | """使用PM2重启应用程序的内部函数"""
47 | try:
48 | # 打印调试信息
49 | subprocess.run(["pm2", "list"], shell=True, check=False)
50 |
51 | # 先尝试获取当前配置中的应用名
52 | config_check = subprocess.run(
53 | "cat ecosystem.config.js | grep name",
54 | shell=True,
55 | stdout=subprocess.PIPE,
56 | stderr=subprocess.PIPE,
57 | check=False
58 | )
59 |
60 | app_name = "work" # 默认应用名
61 | if config_check.returncode == 0:
62 | stdout = config_check.stdout.decode('utf-8').strip()
63 | if "name" in stdout and ":" in stdout:
64 | # 提取配置文件中的应用名
65 | try:
66 | app_name_part = stdout.split(":")[1].strip()
67 | # 移除引号和逗号
68 | app_name = app_name_part.replace('"', '').replace("'", "").replace(',', '').strip()
69 | except Exception as e:
70 | print(f"提取应用名失败: {str(e)}")
71 |
72 | # 首先检查应用是否已经运行
73 | status_check = subprocess.run(
74 | f"pm2 id {app_name} 2>/dev/null || echo 'not_found'",
75 | shell=True,
76 | stdout=subprocess.PIPE,
77 | check=False
78 | )
79 |
80 | stdout = status_check.stdout.decode('utf-8').strip()
81 |
82 | if "not_found" in stdout:
83 | print(f"应用 {app_name} 未运行,执行启动操作")
84 | # 应用未运行,执行启动
85 | result = subprocess.run(
86 | "pm2 start ecosystem.config.js",
87 | shell=True,
88 | stdout=subprocess.PIPE,
89 | stderr=subprocess.PIPE,
90 | check=False
91 | )
92 | else:
93 | # 应用已运行,执行重载
94 | result = subprocess.run(
95 | f"pm2 reload {app_name} || pm2 restart {app_name}",
96 | shell=True,
97 | stdout=subprocess.PIPE,
98 | stderr=subprocess.PIPE,
99 | check=False
100 | )
101 |
102 | stdout = result.stdout.decode('utf-8') if result.stdout else ""
103 | stderr = result.stderr.decode('utf-8') if result.stderr else ""
104 |
105 | # 检查服务是否就绪
106 | is_ready = await check_service_ready(port=8000, max_retries=5, retry_interval=1)
107 |
108 | if result.returncode != 0:
109 | return {
110 | "success": True,
111 | "message": f"应用正在重启,但有警告: {stderr}",
112 | "is_ready": is_ready
113 | }
114 |
115 | return {
116 | "success": True,
117 | "message": "应用正在重启,请等待片刻...",
118 | "is_ready": is_ready
119 | }
120 | except Exception as e:
121 | print(f"重启失败,错误: {str(e)}")
122 | return {
123 | "success": False,
124 | "message": f"重启请求失败: {str(e)}",
125 | "is_ready": False
126 | }
127 |
128 | # 用于从setup路由调用的函数
129 | async def trigger_restart_after_setup():
130 | """系统设置后触发重启"""
131 | return await _restart_application()
132 |
--------------------------------------------------------------------------------
/app/routes/admin/utils.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import sqlite3
3 |
4 | from fastapi.templating import Jinja2Templates
5 |
6 | from app import USER_DB_FILE, SET_DB_FILE
7 |
8 | # 设置模板
9 | templates = Jinja2Templates(directory="app/static/templates")
10 |
11 | # 部门信息常量(仅后端可见)
12 | DEPARTMENTS = {
13 | "3": "院领导",
14 | "7": "政治部",
15 | "11": "办公室",
16 | "10": "综合业务部",
17 | "8": "第一检察部",
18 | "9": "第二检察部",
19 | "4": "第三检察部",
20 | "5": "第四检察部",
21 | "12": "第五检察部",
22 | "15": "未成年人检察组",
23 | "6": "待入职人员",
24 | "13": "检委办",
25 | "2": "系统管理员",
26 | "1": "测试部门",
27 | "14": "退休离职人员"
28 | }
29 |
30 | async def update_admin_active_time(open_id: str):
31 | """
32 | 更新管理员活跃时间
33 | """
34 | conn = sqlite3.connect(USER_DB_FILE)
35 | cursor = conn.cursor()
36 |
37 | # 更新管理员活跃时间
38 | cursor.execute("UPDATE users SET last_activity = ? WHERE open_id = ?", (datetime.datetime.now().timestamp(), open_id))
39 | conn.commit()
40 | conn.close()
41 |
42 | async def get_admin_stats():
43 | """
44 | 获取系统基本统计信息
45 | """
46 | try:
47 | # 连接数据库
48 | conn = sqlite3.connect(USER_DB_FILE)
49 | cursor = conn.cursor()
50 |
51 | # 统计总用户数
52 | cursor.execute("SELECT COUNT(*) FROM users WHERE user_id != 'admin'")
53 | total_users = cursor.fetchone()[0]
54 |
55 | # 统计今日活跃用户
56 | today_start = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
57 | cursor.execute("SELECT COUNT(*) FROM users WHERE last_activity >= ? AND user_id != 'admin'", (today_start,))
58 | active_users_today = cursor.fetchone()[0]
59 |
60 | # 统计本周活跃用户
61 | week_start = (datetime.datetime.now() - datetime.timedelta(days=datetime.datetime.now().weekday())).replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
62 | cursor.execute("SELECT COUNT(*) FROM users WHERE last_activity >= ? AND user_id != 'admin'", (week_start,))
63 | active_users_week = cursor.fetchone()[0]
64 |
65 | # 统计本月活跃用户
66 | month_start = datetime.datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0).timestamp()
67 | cursor.execute("SELECT COUNT(*) FROM users WHERE last_activity >= ? AND user_id != 'admin'", (month_start,))
68 | active_users_month = cursor.fetchone()[0]
69 |
70 | # 关闭连接
71 | conn.close()
72 |
73 | # 获取登录日志信息
74 | from app import LOG_DB_FILE
75 | conn_log = sqlite3.connect(LOG_DB_FILE)
76 | cursor_log = conn_log.cursor()
77 |
78 | # 获取今日登录次数
79 | cursor_log.execute("SELECT COUNT(*) FROM login_logs WHERE login_time >= ?", (today_start,))
80 | today_logins = cursor_log.fetchone()[0]
81 |
82 | conn_log.close()
83 |
84 | # 获取系统名称设置
85 | conn_set = sqlite3.connect(SET_DB_FILE)
86 | cursor_set = conn_set.cursor()
87 |
88 | cursor_set.execute("SELECT setting_value FROM system_settings WHERE setting_key = 'system_name'")
89 | result = cursor_set.fetchone()
90 | system_name = result[0] if result else "考勤管理系统"
91 |
92 | conn_set.close()
93 |
94 | from config import settings
95 |
96 | # 返回统计信息
97 | return {
98 | "total_users": total_users,
99 | "active_users_today": active_users_today,
100 | "active_users_week": active_users_week,
101 | "active_users_month": active_users_month,
102 | "today_logins": today_logins,
103 | "system_name": system_name,
104 | "system_version": settings.APP_VERSION,
105 | "system_start_time": datetime.datetime.fromtimestamp(settings.START_TIME).strftime("%Y-%m-%d %H:%M:%S") if hasattr(settings, "START_TIME") else "未知"
106 | }
107 | except Exception as e:
108 | # 发生错误,返回默认值
109 | from config import settings
110 | return {
111 | "total_users": 0,
112 | "active_users_today": 0,
113 | "active_users_week": 0,
114 | "active_users_month": 0,
115 | "today_logins": 0,
116 | "system_name": "考勤管理系统",
117 | "system_version": settings.APP_VERSION,
118 | "system_start_time": "未知",
119 | "error": str(e)
120 | }
121 |
122 | async def get_admin_name(open_id: str) -> str:
123 | """
124 | 查询管理员名称
125 | """
126 | conn = sqlite3.connect(USER_DB_FILE)
127 | cursor = conn.cursor()
128 | cursor.execute(
129 | "SELECT username FROM users WHERE open_id = ? AND user_id = 'admin'",
130 | (open_id,)
131 | )
132 | admin_info = cursor.fetchone()
133 | conn.close()
134 |
135 | return admin_info[0] if admin_info else "管理员"
--------------------------------------------------------------------------------
/app/auth/dependencies.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | import time
3 | from functools import wraps
4 | from typing import Tuple, Dict
5 |
6 | from fastapi import Request
7 | from fastapi.responses import RedirectResponse
8 |
9 | from app import USER_DB_FILE, logger
10 |
11 | # 管理员超时时间(秒)
12 | ADMIN_TIMEOUT = 3600 # 60分钟
13 |
14 | def is_valid_open_id(open_id: str) -> Tuple[bool, Dict]:
15 | """
16 | 检查open_id是否在数据库中有效,并返回用户信息
17 | 返回: (是否有效, 用户信息)
18 | """
19 | try:
20 | conn = sqlite3.connect(USER_DB_FILE)
21 | cursor = conn.cursor()
22 | cursor.execute("SELECT * FROM users WHERE open_id = ?", (open_id,))
23 | result = cursor.fetchone()
24 | conn.close()
25 |
26 | if result:
27 | # 返回用户信息字典
28 | user_info = dict(zip(
29 | ["id", "username", "user_id", "department_name",
30 | "department_id", "position", "open_id", "first_login_time", "last_activity"],
31 | result
32 | ))
33 | return True, user_info
34 | return False, {}
35 | except Exception as e:
36 | logger.error(f"验证open_id时出错: {str(e)}")
37 | return False, {}
38 |
39 | def admin_required(func):
40 | """
41 | 管理员权限验证装饰器
42 | 验证用户是否为管理员以及最后活跃时间是否在5分钟内
43 | """
44 | @wraps(func)
45 | async def wrapper(request: Request, *args, **kwargs):
46 | # 获取open_id cookie
47 | open_id = request.cookies.get("open_id")
48 |
49 | if not open_id:
50 | # 未登录,重定向到首页
51 | return RedirectResponse(url="/", status_code=303)
52 |
53 | try:
54 | # 连接数据库
55 | conn = sqlite3.connect(USER_DB_FILE)
56 | cursor = conn.cursor()
57 |
58 | # 查询管理员信息
59 | cursor.execute(
60 | "SELECT last_activity FROM users WHERE open_id = ? AND user_id = 'admin'",
61 | (open_id,)
62 | )
63 | admin = cursor.fetchone()
64 |
65 | # 检查是否管理员
66 | if not admin:
67 | conn.close()
68 | # 不是管理员,重定向到首页并清除cookie
69 | response = RedirectResponse(url="/", status_code=303)
70 | response.delete_cookie(key="open_id", path="/")
71 | return response
72 |
73 | # 检查活跃时间是否在5分钟内
74 | current_time = int(time.time())
75 | if current_time - admin[0] > ADMIN_TIMEOUT:
76 | conn.close()
77 | # 超时,重定向到首页并清除cookie
78 | response = RedirectResponse(url="/", status_code=303)
79 | response.delete_cookie(key="open_id", path="/")
80 | return response
81 |
82 | # 更新管理员最后活跃时间
83 | cursor.execute(
84 | "UPDATE users SET last_activity = ? WHERE open_id = ?",
85 | (current_time, open_id)
86 | )
87 | conn.commit()
88 | conn.close()
89 |
90 | # 执行原函数
91 | return await func(request, *args, **kwargs)
92 |
93 | except Exception as e:
94 | logger.error(f"管理员验证出错: {str(e)}")
95 | response = RedirectResponse(url="/", status_code=303)
96 | response.delete_cookie(key="open_id", path="/")
97 | return response
98 |
99 | return wrapper
100 |
101 | # def user_required(func):
102 | # """
103 | # 用户权限验证装饰器
104 | # 验证用户是否登录
105 | # """
106 | # @wraps(func)
107 | # async def wrapper(request: Request, *args, **kwargs):
108 | # # 获取open_id cookie
109 | # open_id = request.cookies.get("open_id")
110 |
111 | # if not open_id:
112 | # # 未登录,重定向到首页
113 | # return RedirectResponse(url="/", status_code=303)
114 |
115 | # # 检查open_id是否有效
116 | # is_valid, user_info = is_valid_open_id(open_id)
117 |
118 | # if not is_valid:
119 | # # 无效用户,清除cookie并重定向
120 | # response = RedirectResponse(url="/", status_code=303)
121 | # response.delete_cookie(key="open_id")
122 | # return response
123 |
124 | # # 更新用户最后活跃时间
125 | # try:
126 | # current_time = int(time.time())
127 | # conn = sqlite3.connect(USER_DB_FILE)
128 | # cursor = conn.cursor()
129 | # cursor.execute(
130 | # "UPDATE users SET last_activity = ? WHERE open_id = ?",
131 | # (current_time, open_id)
132 | # )
133 | # conn.commit()
134 | # conn.close()
135 | # except Exception as e:
136 | # logger.error(f"更新用户活跃时间出错: {str(e)}")
137 |
138 | # # 执行原函数,并传递用户信息
139 | # return await func(request, user_info=user_info, *args, **kwargs)
140 |
141 | # return wrapper
142 |
--------------------------------------------------------------------------------
/app/static/css/main.css:
--------------------------------------------------------------------------------
1 | /* 主样式文件 */
2 | :root {
3 | --primary-color: #1e88e5;
4 | --secondary-color: #e3f2fd;
5 | --text-color: #333;
6 | --bg-color: #f5f5f5;
7 | --error-color: #f44336;
8 | --success-color: #4caf50;
9 | --admin-color: #ff9800;
10 | }
11 |
12 | * {
13 | box-sizing: border-box;
14 | margin: 0;
15 | padding: 0;
16 | }
17 |
18 | body {
19 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
20 | line-height: 1.6;
21 | color: var(--text-color);
22 | background-color: var(--bg-color);
23 | padding: 20px;
24 | }
25 |
26 | .container {
27 | max-width: 1200px;
28 | margin: 0 auto;
29 | padding: 20px;
30 | }
31 |
32 | /* 登录表单样式 */
33 | .login-container {
34 | max-width: 400px;
35 | margin: 100px auto;
36 | padding: 30px;
37 | background-color: white;
38 | border-radius: 8px;
39 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
40 | }
41 |
42 | .login-title {
43 | text-align: center;
44 | color: var(--primary-color);
45 | margin-bottom: 20px;
46 | }
47 |
48 | .form-group {
49 | margin-bottom: 20px;
50 | }
51 |
52 | .form-group label {
53 | display: block;
54 | margin-bottom: 5px;
55 | font-weight: 500;
56 | }
57 |
58 | .form-control {
59 | width: 100%;
60 | padding: 10px;
61 | border: 1px solid #ddd;
62 | border-radius: 4px;
63 | font-size: 16px;
64 | }
65 |
66 | .form-control:focus {
67 | border-color: var(--primary-color);
68 | outline: none;
69 | }
70 |
71 | .btn {
72 | display: inline-block;
73 | padding: 10px 15px;
74 | background-color: var(--primary-color);
75 | color: white;
76 | border: none;
77 | border-radius: 4px;
78 | cursor: pointer;
79 | font-size: 16px;
80 | text-align: center;
81 | width: 100%;
82 | }
83 |
84 | .btn:hover {
85 | background-color: #1565c0;
86 | }
87 |
88 | /* 登录提示样式 */
89 | .login-tips {
90 | margin-top: 20px;
91 | padding: 10px;
92 | background-color: var(--secondary-color);
93 | border-radius: 4px;
94 | font-size: 12px;
95 | }
96 |
97 | .login-tips p {
98 | margin: 5px 0;
99 | color: #555;
100 | }
101 |
102 | /* 错误信息样式 */
103 | #error-message {
104 | margin-top: 15px;
105 | padding: 10px;
106 | border-radius: 4px;
107 | background-color: rgba(244, 67, 54, 0.1);
108 | color: var(--error-color);
109 | text-align: center;
110 | font-size: 14px;
111 | display: none;
112 | }
113 |
114 | #error-message.admin-error {
115 | background-color: rgba(255, 152, 0, 0.1);
116 | color: var(--admin-color);
117 | font-weight: bold;
118 | border-left: 3px solid var(--admin-color);
119 | }
120 |
121 | /* 仪表盘样式 */
122 | header {
123 | display: flex;
124 | justify-content: space-between;
125 | align-items: center;
126 | margin-bottom: 30px;
127 | flex-wrap: wrap;
128 | }
129 |
130 | header h1 {
131 | color: var(--primary-color);
132 | margin-bottom: 10px;
133 | flex: 1 0 100%;
134 | }
135 |
136 | header p {
137 | color: var(--text-color);
138 | flex: 1;
139 | }
140 |
141 | .user-info {
142 | display: flex;
143 | align-items: center;
144 | gap: 15px;
145 | }
146 |
147 | .btn-small {
148 | width: auto;
149 | padding: 8px 12px;
150 | font-size: 14px;
151 | }
152 |
153 | .dashboard-content {
154 | display: grid;
155 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
156 | gap: 20px;
157 | margin-bottom: 40px;
158 | }
159 |
160 | .dashboard-card {
161 | background-color: white;
162 | border-radius: 8px;
163 | padding: 20px;
164 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
165 | }
166 |
167 | .dashboard-card h2 {
168 | color: var(--primary-color);
169 | margin-bottom: 15px;
170 | font-size: 20px;
171 | }
172 |
173 | .dashboard-card .btn {
174 | margin-top: 10px;
175 | margin-right: 10px;
176 | width: auto;
177 | }
178 |
179 | .stats, .attendance-status {
180 | display: flex;
181 | justify-content: space-around;
182 | margin-bottom: 20px;
183 | }
184 |
185 | .stat-item, .status-item {
186 | text-align: center;
187 | padding: 10px;
188 | }
189 |
190 | .stat-value, .status-value {
191 | display: block;
192 | font-size: 24px;
193 | font-weight: bold;
194 | color: var(--primary-color);
195 | }
196 |
197 | .stat-label, .status-label {
198 | font-size: 14px;
199 | color: #666;
200 | }
201 |
202 | .attendance-actions {
203 | display: flex;
204 | justify-content: space-between;
205 | gap: 10px;
206 | }
207 |
208 | footer {
209 | margin-top: 20px;
210 | text-align: center;
211 | color: #666;
212 | font-size: 14px;
213 | }
214 |
215 | .session-info {
216 | margin-top: 5px;
217 | color: var(--error-color);
218 | }
219 |
220 | /* 响应式设计 */
221 | @media (max-width: 768px) {
222 | .login-container {
223 | margin: 50px auto;
224 | padding: 20px;
225 | }
226 |
227 | .dashboard-content {
228 | grid-template-columns: 1fr;
229 | }
230 |
231 | header {
232 | flex-direction: column;
233 | align-items: flex-start;
234 | }
235 |
236 | .user-info {
237 | margin-top: 10px;
238 | width: 100%;
239 | justify-content: space-between;
240 | }
241 | }
--------------------------------------------------------------------------------
/app/routes/sign.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import httpx
3 | from typing import Optional
4 |
5 | from fastapi import APIRouter, Request, Cookie, HTTPException
6 | from fastapi.responses import JSONResponse
7 | from pydantic import BaseModel
8 |
9 | from app.auth.dependencies import is_valid_open_id
10 | from app.auth.utils import get_mobile_user_agent
11 | from app.utils.host import build_api_url
12 | from app.utils.log import log_sign_activity, log_operation, LogType
13 |
14 | router = APIRouter(tags=["打卡"])
15 |
16 | class SignData(BaseModel):
17 | attendance: int = 0
18 |
19 | @router.post("/sign")
20 | async def sign_in(request: Request, data: SignData, open_id: Optional[str] = Cookie(None)):
21 | """
22 | 用户打卡接口
23 |
24 | 处理用户的打卡请求,根据当前时间确定是上班打卡还是下班打卡
25 | """
26 |
27 | # 验证用户身份
28 | if not open_id:
29 | raise HTTPException(status_code=401, detail="未登录,请先登录")
30 |
31 | is_valid, user_info = is_valid_open_id(open_id)
32 | if not is_valid:
33 | raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
34 |
35 | # 获取用户ID
36 | user_id = user_info.get("user_id", None)
37 | username = user_info.get("username", "未知用户")
38 | if not user_id:
39 | raise HTTPException(status_code=400, detail="无法获取用户ID")
40 |
41 | # 根据attendance状态决定打卡类型
42 | attendance = data.attendance
43 | is_valid, clock_type, message = check_sign_time(attendance)
44 | sign_type = "上班打卡" if clock_type == 1 else "下班打卡"
45 |
46 | if not is_valid:
47 | # 记录失败的打卡请求
48 | await log_sign_activity(
49 | username,
50 | sign_type,
51 | False,
52 | message,
53 | request.client.host
54 | )
55 |
56 | # 记录操作日志 - 打卡失败
57 | await log_operation(
58 | username,
59 | LogType.SIGN,
60 | f"{sign_type}失败: {message}",
61 | request.client.host,
62 | False
63 | )
64 |
65 | return JSONResponse(
66 | status_code=200,
67 | content={
68 | "success": False,
69 | "message": message,
70 | "clockType": sign_type
71 | }
72 | )
73 |
74 | result = await SaveAttCheckinout(request, open_id)
75 |
76 | # 记录打卡结果到日志数据库
77 | success = result.get("success", False)
78 | result_message = result.get("message", "未知结果")
79 |
80 | await log_sign_activity(
81 | username,
82 | sign_type,
83 | success,
84 | result_message,
85 | request.client.host
86 | )
87 |
88 | # 记录操作日志 - 打卡结果
89 | await log_operation(
90 | username,
91 | LogType.SIGN,
92 | f"{sign_type}结果: {result_message}",
93 | request.client.host,
94 | success
95 | )
96 |
97 | return JSONResponse(
98 | status_code=200,
99 | content={
100 | "success": success,
101 | "message": result_message,
102 | "clockType": sign_type
103 | }
104 | )
105 |
106 | def check_sign_time(attendance: int):
107 | """
108 | 判断当前时间段是否可以打卡
109 | 早上7:00-9:00可以打卡,下午17:00-23:59可以打卡
110 | """
111 |
112 | current_time = datetime.datetime.now().time()
113 | morning_start = datetime.time(7, 0)
114 | morning_end = datetime.time(9, 0)
115 | afternoon_start = datetime.time(17, 0)
116 | afternoon_end = datetime.time(23, 59)
117 |
118 | # 判断是否在早上打卡时间段
119 | if morning_start <= current_time <= morning_end:
120 | if attendance == 0:
121 | return True, 1, "上班打卡"
122 | else:
123 | return False, 0, "上班已经打卡"
124 | # 判断是否在下午打卡时间段
125 | elif afternoon_start <= current_time <= afternoon_end:
126 | if attendance == 0 or attendance == 1:
127 | return True, 2, "下班打卡,请补签上班卡"
128 | else:
129 | return False, 0, "下班已经打卡"
130 | else:
131 | return False, 0, "不在打卡时间段"
132 |
133 |
134 | async def SaveAttCheckinout(request: Request, open_id: str):
135 | """
136 | 保存打卡记录
137 | """
138 |
139 | _, user_info = is_valid_open_id(open_id)
140 |
141 | user_id = user_info.get("user_id", None)
142 | dep_id = user_info.get("department_id", None)
143 |
144 | if not user_id or not dep_id:
145 | raise HTTPException(status_code=400, detail="无法获取用户ID")
146 |
147 | api_url = build_api_url("/AttendanceCard/SaveAttCheckinout")
148 | headers = {"User-Agent": get_mobile_user_agent(request.headers.get("User-Agent", ""))}
149 | data = {"model": {"Aid": 0, "UnitCode": "530114", "userID": user_id, "userDepID": dep_id, "Mid": 134, "Num_RunID": 14, "lng": "", "lat": "", "realaddress": "呈贡区人民检察院", "iSDelete": 0, "administratorChangesRemark": "呈贡区人民检察院"}, "AttType": 1}
150 |
151 | # 实际环境中解除注释,返回真实API响应
152 | async with httpx.AsyncClient() as client:
153 | response = await client.post(api_url, json=data, headers=headers)
154 | return response.json()
155 |
--------------------------------------------------------------------------------
/app/static/templates/admin/statistics.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block title %}{{ page_title }}{% endblock %}
4 |
5 | {% block styles %}
6 |
7 |
8 |
9 | {% endblock %}
10 |
11 | {% block content %}
12 |
13 |
17 |
18 |
19 |
20 |
50 |
51 |
52 |
53 |
签到日志记录
54 |
55 |
56 |
70 |
71 |
72 |
73 |
78 |
79 |
80 |
81 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | | ID |
100 | 用户名 |
101 | 签到时间 |
102 | 签到类型 |
103 | 状态 |
104 | 备注 |
105 | IP地址 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
117 |
118 |
119 |
120 | {% endblock %}
121 |
122 | {% block scripts %}
123 |
124 | {% endblock %}
--------------------------------------------------------------------------------
/app/static/css/admin/logs.css:
--------------------------------------------------------------------------------
1 | /* 日志页面样式 */
2 | .logs-container {
3 | padding: 20px;
4 | background-color: #f8f9fa;
5 | border-radius: 10px;
6 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
7 | }
8 |
9 | .logs-header {
10 | display: flex;
11 | justify-content: space-between;
12 | align-items: center;
13 | margin-bottom: 20px;
14 | padding-bottom: 10px;
15 | border-bottom: 1px solid #eee;
16 | }
17 |
18 | .logs-header h1 {
19 | font-size: 24px;
20 | color: #333;
21 | margin: 0;
22 | }
23 |
24 | .current-time {
25 | color: #666;
26 | font-size: 14px;
27 | }
28 |
29 | /* 选项卡导航样式 */
30 | .logs-tabs {
31 | background-color: #fff;
32 | border-radius: 8px;
33 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
34 | overflow: hidden;
35 | }
36 |
37 | .tab-buttons {
38 | display: flex;
39 | background-color: #f0f2f5;
40 | border-bottom: 1px solid #e0e0e0;
41 | }
42 |
43 | .tab-button {
44 | flex: 1;
45 | padding: 12px 20px;
46 | background: none;
47 | border: none;
48 | cursor: pointer;
49 | font-size: 15px;
50 | font-weight: 500;
51 | color: #666;
52 | transition: background-color 0.3s, color 0.3s;
53 | text-align: center;
54 | }
55 |
56 | .tab-button:hover {
57 | background-color: #e8e8e8;
58 | color: #333;
59 | }
60 |
61 | .tab-button.active {
62 | background-color: #fff;
63 | color: #2196F3;
64 | border-bottom: 2px solid #2196F3;
65 | }
66 |
67 | /* 选项卡内容样式 */
68 | .tab-content {
69 | display: none;
70 | padding: 20px;
71 | }
72 |
73 | .tab-content.active {
74 | display: block;
75 | }
76 |
77 | /* 筛选区域样式 */
78 | .filter-section {
79 | background-color: #f9f9f9;
80 | padding: 15px;
81 | border-radius: 6px;
82 | margin-bottom: 20px;
83 | }
84 |
85 | .filter-row {
86 | display: flex;
87 | flex-wrap: wrap;
88 | gap: 15px;
89 | margin-bottom: 10px;
90 | }
91 |
92 | .filter-row:last-child {
93 | margin-bottom: 0;
94 | }
95 |
96 | .filter-item {
97 | flex: 1;
98 | min-width: 200px;
99 | }
100 |
101 | .filter-item label {
102 | display: block;
103 | margin-bottom: 5px;
104 | font-size: 14px;
105 | color: #555;
106 | }
107 |
108 | .filter-item input,
109 | .filter-item select {
110 | width: 100%;
111 | padding: 8px 12px;
112 | border: 1px solid #ddd;
113 | border-radius: 4px;
114 | font-size: 14px;
115 | }
116 |
117 | .filter-item input:focus,
118 | .filter-item select:focus {
119 | border-color: #2196F3;
120 | outline: none;
121 | box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
122 | }
123 |
124 | .filter-actions {
125 | display: flex;
126 | gap: 10px;
127 | align-items: flex-end;
128 | }
129 |
130 | .filter-button {
131 | padding: 8px 16px;
132 | background-color: #2196F3;
133 | color: white;
134 | border: none;
135 | border-radius: 4px;
136 | cursor: pointer;
137 | font-weight: 500;
138 | transition: background-color 0.3s;
139 | }
140 |
141 | .filter-button:hover {
142 | background-color: #1976D2;
143 | }
144 |
145 | .filter-button.reset {
146 | background-color: #607D8B;
147 | }
148 |
149 | .filter-button.reset:hover {
150 | background-color: #455A64;
151 | }
152 |
153 | .filter-button.export {
154 | background-color: #4CAF50;
155 | }
156 |
157 | .filter-button.export:hover {
158 | background-color: #388E3C;
159 | }
160 |
161 | /* 表格样式 */
162 | .logs-table-wrapper {
163 | overflow-x: auto;
164 | margin-bottom: 20px;
165 | }
166 |
167 | .logs-table {
168 | width: 100%;
169 | border-collapse: collapse;
170 | border-spacing: 0;
171 | font-size: 14px;
172 | }
173 |
174 | .logs-table th,
175 | .logs-table td {
176 | padding: 12px 15px;
177 | border-bottom: 1px solid #e0e0e0;
178 | text-align: left;
179 | }
180 |
181 | .logs-table th {
182 | background-color: #f5f5f5;
183 | font-weight: 600;
184 | color: #333;
185 | position: sticky;
186 | top: 0;
187 | }
188 |
189 | .logs-table tbody tr:hover {
190 | background-color: #f5f9ff;
191 | }
192 |
193 | .logs-table .success {
194 | color: #4CAF50;
195 | }
196 |
197 | .logs-table .failure {
198 | color: #F44336;
199 | }
200 |
201 | /* 分页控件样式 */
202 | .pagination {
203 | display: flex;
204 | justify-content: center;
205 | align-items: center;
206 | gap: 5px;
207 | margin-top: 20px;
208 | }
209 |
210 | .pagination-button {
211 | padding: 6px 12px;
212 | background-color: #f0f2f5;
213 | border: 1px solid #ddd;
214 | border-radius: 4px;
215 | color: #333;
216 | cursor: pointer;
217 | font-size: 14px;
218 | transition: background-color 0.3s, color 0.3s;
219 | }
220 |
221 | .pagination-button:hover {
222 | background-color: #e0e0e0;
223 | }
224 |
225 | .pagination-button.active {
226 | background-color: #2196F3;
227 | color: white;
228 | border-color: #2196F3;
229 | }
230 |
231 | .pagination-button.disabled {
232 | opacity: 0.5;
233 | cursor: not-allowed;
234 | }
235 |
236 | .pagination-info {
237 | font-size: 14px;
238 | color: #666;
239 | margin: 0 10px;
240 | }
241 |
242 | /* 响应式样式 */
243 | @media (max-width: 768px) {
244 | .filter-item {
245 | min-width: 100%;
246 | }
247 |
248 | .tab-button {
249 | padding: 10px 5px;
250 | font-size: 14px;
251 | }
252 | }
--------------------------------------------------------------------------------
/db.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # 数据库信息查询脚本
3 | # 用于显示data目录下所有SQLite数据库的表结构信息
4 |
5 | # 颜色定义
6 | RED='\033[0;31m'
7 | GREEN='\033[0;32m'
8 | YELLOW='\033[0;33m'
9 | BLUE='\033[0;34m'
10 | PURPLE='\033[0;35m'
11 | CYAN='\033[0;36m'
12 | NC='\033[0m' # 无颜色
13 |
14 | # 检查data目录是否存在
15 | if [ ! -d "data" ]; then
16 | echo -e "${RED}Error: data目录不存在${NC}"
17 | echo "请在项目根目录下运行此脚本"
18 | exit 1
19 | fi
20 |
21 | # 检查是否安装了sqlite3
22 | if ! command -v sqlite3 &> /dev/null; then
23 | echo -e "${YELLOW}未找到sqlite3命令,尝试自动安装...${NC}"
24 |
25 | # 检测Alpine环境并安装
26 | if [ -f /etc/alpine-release ]; then
27 | echo -e "${CYAN}检测到Alpine环境,使用apk安装sqlite...${NC}"
28 | apk add --no-cache sqlite
29 | else
30 | echo -e "${RED}Error: 未找到sqlite3命令${NC}"
31 | echo "请安装sqlite3: "
32 | echo " - Ubuntu/Debian: sudo apt-get install sqlite3"
33 | echo " - MacOS: brew install sqlite3"
34 | echo " - Windows: 请下载SQLite并添加到PATH"
35 | exit 1
36 | fi
37 |
38 | # 再次检查安装是否成功
39 | if ! command -v sqlite3 &> /dev/null; then
40 | echo -e "${RED}自动安装sqlite3失败,请手动安装${NC}"
41 | exit 1
42 | else
43 | echo -e "${GREEN}sqlite3安装成功,继续执行...${NC}"
44 | fi
45 | fi
46 |
47 | # 声明全局数组变量用于存储数据库文件
48 | declare -a DB_FILES
49 |
50 | # 获取data目录下所有数据库文件
51 | get_all_db_files() {
52 | # 清空数组
53 | DB_FILES=()
54 |
55 | # 查找data目录下所有.db文件
56 | for file in data/*.db; do
57 | # 检查文件是否存在且是常规文件
58 | if [ -f "$file" ]; then
59 | DB_FILES+=("$file")
60 | fi
61 | done
62 |
63 | # 如果未找到任何数据库文件,使用默认列表
64 | if [ ${#DB_FILES[@]} -eq 0 ]; then
65 | echo -e "${YELLOW}未在data目录下找到任何.db文件,使用默认列表${NC}"
66 | DB_FILES=("data/sign.db" "data/log.db" "data/set.db")
67 | else
68 | echo -e "${GREEN}发现 ${#DB_FILES[@]} 个数据库文件${NC}"
69 | fi
70 |
71 | # 打印找到的所有数据库文件
72 | echo -e "${CYAN}数据库文件列表:${NC}"
73 | for db in "${DB_FILES[@]}"; do
74 | echo " - $db"
75 | done
76 |
77 | echo ""
78 | }
79 |
80 | # 获取数据库文件列表
81 | get_all_db_files
82 |
83 | # 统计变量
84 | TOTAL_TABLES=0
85 | TOTAL_RECORDS=0
86 |
87 | echo -e "${GREEN}======================================${NC}"
88 | echo -e "${CYAN}数据库信息查询工具 v1.0${NC}"
89 | echo -e "${GREEN}======================================${NC}"
90 |
91 | # 遍历每个数据库文件
92 | for DB_FILE in "${DB_FILES[@]}"; do
93 | # 检查数据库文件是否存在
94 | if [ ! -f "$DB_FILE" ]; then
95 | echo -e "${YELLOW}警告: 数据库文件 $DB_FILE 不存在, 跳过${NC}"
96 | continue
97 | fi
98 |
99 | echo -e "\n${BLUE}数据库: $DB_FILE${NC}"
100 | echo -e "${GREEN}--------------------------------------${NC}"
101 |
102 | # 获取所有表名
103 | TABLES=$(sqlite3 "$DB_FILE" ".tables")
104 |
105 | # 如果没有表,显示警告并继续
106 | if [ -z "$TABLES" ]; then
107 | echo -e "${YELLOW}此数据库没有表${NC}"
108 | continue
109 | fi
110 |
111 | # 将表名字符串分割成数组
112 | IFS=' ' read -ra TABLE_ARRAY <<< "$TABLES"
113 | DB_TABLE_COUNT=0
114 | DB_RECORD_COUNT=0
115 |
116 | # 处理每个表
117 | for TABLE in "${TABLE_ARRAY[@]}"; do
118 | # 获取表的行数
119 | ROW_COUNT=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM $TABLE;")
120 |
121 | echo -e "${PURPLE}表名: $TABLE${NC} (${YELLOW}${ROW_COUNT}条记录${NC})"
122 |
123 | # 获取表结构
124 | echo -e "${CYAN}表结构:${NC}"
125 | sqlite3 "$DB_FILE" ".schema $TABLE" | sed 's/^/ /'
126 |
127 | # 如果表有记录,显示一行示例数据
128 | if [ "$ROW_COUNT" -gt 0 ]; then
129 | echo -e "${CYAN}示例数据:${NC}"
130 |
131 | # 构建获取所有列名的查询
132 | COLUMNS=$(sqlite3 "$DB_FILE" "PRAGMA table_info($TABLE);" | awk -F'|' '{print $2}' | tr '\n' ',' | sed 's/,$//')
133 |
134 | # 构建查询语句,限制结果为1行
135 | SELECT_QUERY="SELECT * FROM $TABLE LIMIT 1;"
136 |
137 | # 执行查询并美化输出
138 | echo -e " $(sqlite3 -header -column "$DB_FILE" "$SELECT_QUERY" | head -n 1)"
139 | echo -e " $(sqlite3 -header -column "$DB_FILE" "$SELECT_QUERY" | tail -n 1)"
140 | else
141 | echo -e "${YELLOW} 表为空,无示例数据${NC}"
142 | fi
143 |
144 | echo ""
145 |
146 | # 更新统计
147 | DB_TABLE_COUNT=$((DB_TABLE_COUNT + 1))
148 | DB_RECORD_COUNT=$((DB_RECORD_COUNT + ROW_COUNT))
149 | done
150 |
151 | # 显示该数据库的统计信息
152 | echo -e "${GREEN}$DB_FILE 统计信息:${NC}"
153 | echo -e " ${YELLOW}表数量: $DB_TABLE_COUNT${NC}"
154 | echo -e " ${YELLOW}总记录数: $DB_RECORD_COUNT${NC}"
155 |
156 | # 更新总统计
157 | TOTAL_TABLES=$((TOTAL_TABLES + DB_TABLE_COUNT))
158 | TOTAL_RECORDS=$((TOTAL_RECORDS + DB_RECORD_COUNT))
159 | done
160 |
161 | # 显示总统计
162 | echo -e "\n${GREEN}======================================${NC}"
163 | echo -e "${BLUE}总体统计:${NC}"
164 | echo -e " ${YELLOW}总表数: $TOTAL_TABLES${NC}"
165 | echo -e " ${YELLOW}总记录数: $TOTAL_RECORDS${NC}"
166 | echo -e "${GREEN}======================================${NC}"
167 |
168 | # 如果没有找到有效的数据库文件,显示提醒
169 | if [ $TOTAL_TABLES -eq 0 ]; then
170 | echo -e "\n${YELLOW}提示: 未找到任何表,可能的原因:${NC}"
171 | echo " 1. 应用程序尚未初始化"
172 | echo " 2. 数据目录路径不正确"
173 | echo " 3. 数据库结构已更改"
174 | echo -e "\n建议运行以下命令初始化数据库:"
175 | echo -e " ${CYAN}python -c \"from app.utils.db_init import initialize_database; initialize_database()\"${NC}"
176 | fi
177 |
178 | exit 0
--------------------------------------------------------------------------------
/app/static/templates/admin/dashboard.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block title %}{{ page_title }}{% endblock %}
4 |
5 | {% block styles %}
6 |
7 |
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 |
17 |
18 |
19 |
20 |
👥
21 |
22 |
{{ stats.total_users }}
23 |
总用户数
24 |
25 |
26 |
27 |
🔆
28 |
29 |
{{ stats.active_users_today }}
30 |
今日活跃用户
31 |
32 |
33 |
34 |
📆
35 |
36 |
{{ stats.active_users_week }}
37 |
本周活跃用户
38 |
39 |
40 |
41 |
📊
42 |
43 |
{{ stats.active_users_month }}
44 |
本月活跃用户
45 |
46 |
47 |
48 |
49 |
50 |
系统信息
51 |
52 |
53 |
系统名称
54 |
{{ stats.system_name }}
55 |
56 |
57 |
系统版本
58 |
{{ stats.system_version }}
59 |
60 |
61 |
系统启动时间
62 |
{{ stats.system_start_time }}
63 |
64 |
65 |
今日登录次数
66 |
{{ stats.today_logins }}
67 |
68 |
69 |
70 |
71 | {% endblock %}
72 |
73 | {% block scripts %}
74 |
135 | {% endblock %}
136 |
--------------------------------------------------------------------------------
/app/static/templates/admin/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {% block title %}管理员后台{% endblock %} - {{ stats.system_name | default('考勤管理系统') }}
7 |
8 |
9 | {% block styles %}{% endblock %}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
77 |
78 |
79 |
80 |
81 |
96 |
97 |
98 |
99 | {% block content %}{% endblock %}
100 |
101 |
102 |
103 |
106 |
107 |
108 |
109 |
110 |
123 | {% block scripts %}{% endblock %}
124 |
125 |
126 |
--------------------------------------------------------------------------------
/app/routes/admin/settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import sqlite3
3 |
4 | from fastapi import APIRouter, Request
5 | from fastapi.responses import JSONResponse
6 |
7 | from app import SET_DB_FILE, logger
8 | from app.auth.dependencies import admin_required
9 | from app.routes.admin.utils import get_admin_stats, get_admin_name, templates
10 |
11 | # 创建路由器
12 | router = APIRouter()
13 |
14 | @router.get("/settings")
15 | @admin_required
16 | async def admin_settings_page(request: Request):
17 | """
18 | 管理员系统设置页面
19 | """
20 |
21 | # 获取基本统计信息
22 | stats = await get_admin_stats()
23 |
24 | # 获取open_id
25 | open_id = request.cookies.get("open_id")
26 |
27 | # 查询当前管理员信息
28 | admin_name = await get_admin_name(open_id)
29 |
30 | # 返回设置页面
31 | return templates.TemplateResponse(
32 | "admin/settings.html",
33 | {
34 | "request": request,
35 | "user_info": {"username": admin_name, "user_id": "admin"},
36 | "stats": stats,
37 | "page_title": "系统设置",
38 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
39 | }
40 | )
41 |
42 | @router.get("/settings-api")
43 | @admin_required
44 | async def get_settings_api(request: Request):
45 | """
46 | 获取系统设置API
47 | """
48 | try:
49 | # 连接数据库
50 | conn = sqlite3.connect(SET_DB_FILE)
51 | cursor = conn.cursor()
52 |
53 | # 获取所有设置
54 | cursor.execute("SELECT setting_key, setting_value, description FROM system_settings")
55 | settings_data = cursor.fetchall()
56 |
57 | # 格式化设置数据
58 | formatted_settings = {}
59 | for setting in settings_data:
60 | formatted_settings[setting[0]] = {
61 | "value": setting[1],
62 | "description": setting[2]
63 | }
64 |
65 | conn.close()
66 |
67 | return JSONResponse({
68 | "success": True,
69 | "settings": formatted_settings
70 | })
71 | except Exception as e:
72 | logger.error(f"获取设置失败: {str(e)}")
73 | return JSONResponse({
74 | "success": False,
75 | "message": f"获取设置失败: {str(e)}"
76 | }, status_code=500)
77 |
78 | @router.post("/settings-api")
79 | @admin_required
80 | async def update_settings_api(request: Request):
81 | """
82 | 更新系统设置API
83 | """
84 | try:
85 | # 获取请求数据
86 | data = await request.json()
87 |
88 | # 连接数据库
89 | conn = sqlite3.connect(SET_DB_FILE)
90 | cursor = conn.cursor()
91 |
92 | # 更新设置
93 | updated_at = datetime.datetime.now().timestamp()
94 |
95 | for key, value in data.items():
96 | cursor.execute(
97 | "UPDATE system_settings SET setting_value = ?, updated_at = ? WHERE setting_key = ?",
98 | (value, updated_at, key)
99 | )
100 |
101 | conn.commit()
102 | conn.close()
103 |
104 | return JSONResponse({
105 | "success": True,
106 | "message": "设置已更新"
107 | })
108 | except Exception as e:
109 | logger.error(f"更新设置失败: {str(e)}")
110 | return JSONResponse({
111 | "success": False,
112 | "message": f"更新设置失败: {str(e)}"
113 | }, status_code=500)
114 |
115 | @router.post("/sign/settings")
116 | @admin_required
117 | async def post_sign_settings(request: Request):
118 | """
119 | 获取签到设置API (POST方法)
120 | """
121 | try:
122 | # 连接设置数据库
123 | conn = sqlite3.connect(SET_DB_FILE)
124 | cursor = conn.cursor()
125 |
126 | # 查询签到设置
127 | cursor.execute("SELECT setting_key, setting_value FROM system_settings WHERE setting_key LIKE 'sign_%'")
128 |
129 | settings = {}
130 | for row in cursor.fetchall():
131 | settings[row[0]] = row[1]
132 |
133 | conn.close()
134 |
135 | # 如果没有配置,使用默认值
136 | if not settings:
137 | settings = {
138 | "sign_morning_start_hour": "8",
139 | "sign_morning_start_minute": "0",
140 | "sign_morning_end_hour": "9",
141 | "sign_morning_end_minute": "0",
142 | "sign_afternoon_start_hour": "17",
143 | "sign_afternoon_start_minute": "0",
144 | "sign_afternoon_end_hour": "18",
145 | "sign_afternoon_end_minute": "0"
146 | }
147 |
148 | # 提取设置值
149 | sign_settings = {
150 | "morning_start_hour": settings.get("sign_morning_start_hour", "8"),
151 | "morning_start_minute": settings.get("sign_morning_start_minute", "0"),
152 | "morning_end_hour": settings.get("sign_morning_end_hour", "9"),
153 | "morning_end_minute": settings.get("sign_morning_end_minute", "0"),
154 | "afternoon_start_hour": settings.get("sign_afternoon_start_hour", "17"),
155 | "afternoon_start_minute": settings.get("sign_afternoon_start_minute", "0"),
156 | "afternoon_end_hour": settings.get("sign_afternoon_end_hour", "18"),
157 | "afternoon_end_minute": settings.get("sign_afternoon_end_minute", "0")
158 | }
159 |
160 | return JSONResponse({
161 | "success": True,
162 | "settings": sign_settings
163 | })
164 | except Exception as e:
165 | logger.error(f"获取签到设置失败: {str(e)}")
166 | return JSONResponse({
167 | "success": False,
168 | "message": f"获取签到设置失败: {str(e)}"
169 | }, status_code=500)
--------------------------------------------------------------------------------
/app/static/js/admin/user.js:
--------------------------------------------------------------------------------
1 | let users = [];
2 | let filteredUsers = [];
3 | let currentPage = 1;
4 | const usersPerPage = 10;
5 |
6 | document.addEventListener('DOMContentLoaded', function() {
7 | // 加载用户数据
8 | loadUsers();
9 |
10 | // 添加搜索框事件监听
11 | document.getElementById('search-input').addEventListener('keyup', function(event) {
12 | if (event.key === 'Enter') {
13 | searchUsers();
14 | }
15 | });
16 | });
17 |
18 | // 加载用户数据
19 | function loadUsers() {
20 | axios.get('/admin/users-api')
21 | .then(function(response) {
22 | if (response.data.success) {
23 | users = response.data.users;
24 | filteredUsers = [...users]; // 复制用户数据
25 |
26 | // 更新统计信息
27 | updateStats();
28 |
29 | // 渲染用户表格
30 | renderUserTable();
31 |
32 | // 渲染分页
33 | renderPagination();
34 | } else {
35 | document.getElementById('users-table-body').innerHTML =
36 | `| 加载用户失败: ${response.data.message} |
`;
37 | }
38 | })
39 | .catch(function(error) {
40 | console.error('获取用户数据失败:', error);
41 | document.getElementById('users-table-body').innerHTML =
42 | `| 获取用户数据失败,请检查网络连接 |
`;
43 | });
44 | }
45 |
46 | // 更新统计信息
47 | function updateStats() {
48 | const totalUsers = users.length;
49 |
50 | // 计算最近一周活跃用户
51 | const oneWeekAgo = new Date();
52 | oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
53 | const oneWeekAgoTimestamp = Math.floor(oneWeekAgo.getTime() / 1000);
54 |
55 | const activeUsers = users.filter(user => user.last_activity >= oneWeekAgoTimestamp).length;
56 | const inactiveUsers = totalUsers - activeUsers;
57 |
58 | document.getElementById('total-users').textContent = totalUsers;
59 | document.getElementById('active-users').textContent = activeUsers;
60 | document.getElementById('inactive-users').textContent = inactiveUsers;
61 | }
62 |
63 | // 搜索用户
64 | function searchUsers() {
65 | const searchTerm = document.getElementById('search-input').value.toLowerCase();
66 |
67 | if (searchTerm.trim() === '') {
68 | filteredUsers = [...users]; // 清空搜索词,显示所有用户
69 | } else {
70 | filteredUsers = users.filter(user =>
71 | user.username.toLowerCase().includes(searchTerm) ||
72 | user.department_name.toLowerCase().includes(searchTerm) ||
73 | user.position.toLowerCase().includes(searchTerm) ||
74 | user.user_id.toLowerCase().includes(searchTerm)
75 | );
76 | }
77 |
78 | currentPage = 1; // 重置到第一页
79 | renderUserTable();
80 | renderPagination();
81 | }
82 |
83 | // 渲染用户表格
84 | function renderUserTable() {
85 | const tableBody = document.getElementById('users-table-body');
86 | const start = (currentPage - 1) * usersPerPage;
87 | const end = start + usersPerPage;
88 | const pageUsers = filteredUsers.slice(start, end);
89 |
90 | if (pageUsers.length === 0) {
91 | tableBody.innerHTML = `| 没有找到匹配的用户 |
`;
92 | return;
93 | }
94 |
95 | let html = '';
96 | pageUsers.forEach(user => {
97 | const firstLogin = new Date(user.first_login * 1000).toLocaleString();
98 | const lastActivity = new Date(user.last_activity * 1000).toLocaleString();
99 |
100 | html += `
101 |
102 | | ${user.username} |
103 | ${user.user_id} |
104 | ${user.department_name} |
105 | ${user.position} |
106 | ${firstLogin} |
107 | ${lastActivity} |
108 |
109 |
110 | |
111 |
112 | `;
113 | });
114 |
115 | tableBody.innerHTML = html;
116 | }
117 |
118 | // 渲染分页
119 | function renderPagination() {
120 | const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
121 | const pagination = document.getElementById('pagination');
122 |
123 | if (totalPages <= 1) {
124 | pagination.innerHTML = '';
125 | return;
126 | }
127 |
128 | let html = `
129 |
130 |
131 | `;
132 |
133 | // 显示当前页码周围的页码
134 | const maxPagesToShow = 5;
135 | let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
136 | let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
137 |
138 | if (endPage - startPage + 1 < maxPagesToShow) {
139 | startPage = Math.max(1, endPage - maxPagesToShow + 1);
140 | }
141 |
142 | for (let i = startPage; i <= endPage; i++) {
143 | html += ``;
144 | }
145 |
146 | html += `
147 |
148 |
149 | `;
150 |
151 | pagination.innerHTML = html;
152 | }
153 |
154 | // 切换页面
155 | function changePage(page) {
156 | currentPage = page;
157 | renderUserTable();
158 | renderPagination();
159 |
160 | // 滚动到顶部
161 | window.scrollTo(0, 0);
162 | }
163 |
164 | // 查看用户详情
165 | function viewUserDetails(userId) {
166 | const user = users.find(u => u.id === userId);
167 | if (user) {
168 | alert(`用户详情将在此显示: ${user.username}`);
169 | // 这里可以实现显示用户详情的逻辑,例如弹出模态框或跳转到用户详情页
170 | }
171 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🕒 WorkClockFastAPI - 智慧打卡助手
2 |
3 | [](https://github.com/chiupam/WorkClockFastAPI/blob/main/LICENSE)
4 | [](https://github.com/chiupam/WorkClockFastAPI/issues)
5 | [](https://github.com/chiupam/WorkClockFastAPI/stargazers)
6 | [](https://www.python.org/downloads/)
7 | [](https://hub.docker.com/r/chiupam/workclock-fastapi)
8 |
9 | 基于 FastAPI 重构的智能打卡辅助系统,提供便捷的考勤管理功能。通过自动化打卡流程,帮助您更高效地管理考勤记录。
10 |
11 | > **⚠️ 重要提示**:使用本项目前,请确保正确配置主机地址(HOST)。由于主机地址涉及敏感信息,本文档中已使用"某单位"替代实际单位名称。未正确配置主机地址将导致系统无法正常使用。
12 |
13 | ## ✨ 功能特点
14 |
15 | - 🔄 **自动化打卡** - 支持定时自动打卡,无需手动操作
16 | - 📊 **数据统计** - 直观的打卡记录和统计信息
17 | - 👥 **多用户管理** - 支持多用户和部门管理
18 | - 🔔 **实时通知** - 打卡成功或失败的实时通知
19 | - 🛡️ **安全防护** - 完善的安全机制和数据加密
20 | - 📱 **响应式设计** - 支持各种设备屏幕尺寸
21 | - ⏱️ **考勤统计** - 详细的考勤数据分析与统计功能
22 | - ⏳ **定时倒计时** - 自定义打卡时间与倒计时提醒
23 |
24 | ## 👨💻 作者
25 |
26 | **chiupam** - [GitHub](https://github.com/chiupam)
27 |
28 | ## 🛠️ 技术栈
29 |
30 | ### 后端
31 | - 🐍 Python 3.9+
32 | - ⚡ FastAPI Web 框架
33 | - 🦄 Uvicorn ASGI 服务器
34 | - 🗄️ SQLAlchemy ORM
35 | - 🔐 JWT 用户认证
36 | - 📡 WebSockets 实时通信
37 | - ⏰ APScheduler 任务调度
38 |
39 | ### 前端
40 | - 📜 原生 JavaScript
41 | - 🎨 CSS3 动画
42 | - 📱 响应式设计
43 | - 🖼️ SVG 图标系统
44 | - ⚡ 实时状态更新
45 |
46 | ### 数据库
47 | - 💾 SQLite(支持多数据库)
48 | - `app.db`:用户主数据库
49 | - `logs.db`:操作日志数据库
50 | - `sign.db`:签到日志数据库
51 |
52 | ### 安全
53 | - 🔒 JWT Token 认证
54 | - 🛡️ OAuth2 授权
55 | - 🔰 CSRF 防护
56 | - 🛑 XSS 防护
57 | - 🧪 请求参数验证
58 | - 💫 用户状态持久化
59 |
60 | ## 🚀 快速开始
61 |
62 | ### Docker 快速部署
63 |
64 | ```bash
65 | docker run -d \
66 | --name workclock-fastapi \
67 | -p 9051:9051 \
68 | -e SECRET_KEY=yoursecretkey \
69 | -e ADMIN_USERNAME=admin \
70 | -e ADMIN_PASSWORD=yourpassword \
71 | -e DEVELOPMENT=false \
72 | -e HOST=https://your-domain.com \
73 | -e FUCK_PASSWORD=fastloginpassword \
74 | -e VERSION=v1.0.0 \
75 | -v $(pwd)/data:/app/data \
76 | chiupam/workclock-fastapi:latest
77 | ```
78 |
79 | ## ⚙️ 环境变量配置
80 |
81 | | 环境变量 | 说明 | 默认值 | 是否必填 |
82 | |---------|------|-------|----------|
83 | | SECRET_KEY | 应用密钥 | abcdef123456!@#$%^ | 生产环境必填 |
84 | | ADMIN_USERNAME | 管理员用户名 | admin | 否 |
85 | | ADMIN_PASSWORD | 管理员密码 | 1qaz2wsx3edc | 生产环境必填 |
86 | | DEVELOPMENT | 开发环境标志 | true | 否 |
87 | | HOST | API 主机地址 | - | 生产环境必填 |
88 | | FUCK_PASSWORD | 快速打卡密码 | fuckdaka | 否 |
89 | | VERSION | 应用版本号 | - | 否 |
90 |
91 | > **⚠️ 注意**:在生产环境(DEVELOPMENT=false)中,必须设置 HOST 环境变量,否则系统将无法正常工作。HOST 应该是完整的 URL 地址,包含协议(http/https)。
92 |
93 | ## 🏗️ 项目架构
94 |
95 | ```
96 | WorkClockFastAPI/ # 项目根目录
97 | ├── app/ # 应用主目录
98 | │ ├── __init__.py # 应用初始化文件
99 | │ ├── main.py # FastAPI 主应用入口
100 | │ ├── auth/ # 认证相关模块
101 | │ │ ├── __init__.py
102 | │ │ ├── dependencies.py # 认证依赖项
103 | │ │ ├── routes.py # 认证路由
104 | │ │ └── utils.py # 认证工具函数
105 | │ ├── routes/ # 路由模块
106 | │ │ ├── __init__.py
107 | │ │ ├── index.py # 首页路由
108 | │ │ ├── sign.py # 签到相关路由
109 | │ │ ├── statistics.py # 统计相关路由
110 | │ │ ├── crontab.py # 定时任务路由
111 | │ │ ├── setup.py # 设置路由
112 | │ │ └── admin/ # 管理员路由
113 | │ │ ├── __init__.py
114 | │ │ ├── dashboard.py # 仪表盘路由
115 | │ │ ├── logs.py # 日志管理
116 | │ │ ├── privilege.py # 权限管理
117 | │ │ ├── schedules.py # 计划任务管理
118 | │ │ ├── settings.py # 系统设置
119 | │ │ ├── setup.py # 系统初始化
120 | │ │ ├── statistics.py # 统计数据
121 | │ │ ├── system.py # 系统管理
122 | │ │ └── utils.py # 工具函数
123 | │ ├── static/ # 静态资源目录
124 | │ │ ├── css/ # CSS 样式文件
125 | │ │ ├── js/ # JavaScript 文件
126 | │ │ ├── templates/ # 模板文件
127 | │ │ └── favicon.ico # 网站图标
128 | │ └── utils/ # 通用工具模块
129 | ├── data/ # 数据存储目录
130 | ├── Dockerfile # Docker 构建文件
131 | ├── Dockerfile.base # 基础 Docker 构建文件
132 | ├── docker-compose.yml # Docker Compose 配置
133 | ├── docker-entrypoint.sh # Docker 入口脚本
134 | ├── .dockerignore # Docker 忽略文件
135 | ├── config.py # 应用配置文件
136 | ├── db.sh # 数据库管理脚本
137 | ├── ecosystem.config.js # PM2 配置文件
138 | ├── requirements.txt # Python 依赖清单
139 | └── run.py # 应用入口文件
140 | ```
141 |
142 | ## 💻 本地开发
143 |
144 | 1. **克隆项目**
145 | ```bash
146 | git clone https://github.com/chiupam/WorkClockFastAPI.git
147 | cd WorkClockFastAPI
148 | ```
149 |
150 | 2. **创建并激活虚拟环境**
151 | ```bash
152 | # 创建虚拟环境
153 | python -m venv .venv
154 |
155 | # 激活虚拟环境
156 | # Linux/macOS
157 | source .venv/bin/activate
158 | # Windows(CMD)
159 | .venv\Scripts\activate
160 | # Windows(PowerShell)
161 | .venv\Scripts\Activate.ps1
162 | ```
163 |
164 | 3. **安装项目依赖**
165 | ```bash
166 | # 升级 pip
167 | python -m pip install --upgrade pip
168 |
169 | # 安装依赖
170 | pip install -r requirements.txt
171 | ```
172 |
173 | 4. **设置环境变量**
174 | ```bash
175 | # Linux/macOS
176 | export DEVELOPMENT=true
177 | export VERSION=v1.0.0-dev
178 |
179 | # Windows(CMD)
180 | set DEVELOPMENT=true
181 | set VERSION=v1.0.0-dev
182 |
183 | # Windows(PowerShell)
184 | $env:DEVELOPMENT="true"
185 | $env:VERSION="v1.0.0-dev"
186 | ```
187 |
188 | 5. **运行开发服务器**
189 | ```bash
190 | python run.py
191 | ```
192 |
193 | 6. **访问应用**
194 | - 打开浏览器访问:`http://localhost:9051`
195 | - 使用默认管理员账号密码登录:admin/1qaz2wsx3edc
196 |
197 | ## 🔧 系统优化
198 |
199 | ### 最近优化更新
200 | - ✅ 移动端界面改进:添加侧边栏遮罩、模糊效果、改进表单样式和响应式布局
201 | - ✅ 修复签到按钮:解决POST请求问题,分离CSS和JS文件
202 | - ✅ 用户界面优化:修复用户名显示和颜色问题,增加"用户信息"标题
203 | - ✅ 日志显示顺序调整:操作日志第一、登录日志第二、签到日志第三
204 | - ✅ 系统设置优化:增加"立即重启系统"选项,避免频繁重启
205 |
206 | ## 📞 支持与帮助
207 |
208 | 如果您在使用过程中遇到任何问题,可以通过以下方式获取帮助:
209 |
210 | - 🔍 [提交 Issue](https://github.com/chiupam/WorkClockFastAPI/issues)
211 |
212 | ## 📜 许可证
213 |
214 | 本项目采用 [MIT 许可证](LICENSE)。
215 |
216 | ## 📋 更新日志
217 |
218 | ### v1.0.0 (2023-12-01)
219 | - 从Flask重构为FastAPI框架
220 | - 优化数据库结构与性能
221 | - 改进用户界面和响应式设计
222 | - 增强安全性与权限管理
223 | - 添加WebSocket实时通信功能
--------------------------------------------------------------------------------
/app/static/js/setup.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | const setupForm = document.getElementById('setup-form');
3 | const submitBtn = document.getElementById('submit-btn');
4 | const successMessage = document.getElementById('success-message');
5 | const errorMessage = document.getElementById('error-message');
6 |
7 | setupForm.addEventListener('submit', async function(event) {
8 | event.preventDefault();
9 |
10 | // 清除所有错误信息
11 | document.querySelectorAll('.error-message').forEach(el => {
12 | el.style.display = 'none';
13 | el.textContent = '';
14 | });
15 |
16 | // 获取表单数据
17 | const apiHost = document.getElementById('api_host').value.trim();
18 | const adminPassword = document.getElementById('admin_password').value;
19 | const adminPasswordConfirm = document.getElementById('admin_password_confirm').value;
20 | const fuckPassword = document.getElementById('fuck_password').value;
21 |
22 | // 验证数据
23 | let hasError = false;
24 |
25 | if (!apiHost) {
26 | showError('api_host_error', 'API主机地址不能为空');
27 | hasError = true;
28 | }
29 |
30 | if (adminPassword.length < 6) {
31 | showError('admin_password_error', '管理员密码长度不能少于6个字符');
32 | hasError = true;
33 | }
34 |
35 | if (adminPassword !== adminPasswordConfirm) {
36 | showError('admin_password_confirm_error', '两次输入的密码不一致');
37 | hasError = true;
38 | }
39 |
40 | if (!fuckPassword) {
41 | showError('fuck_password_error', 'FuckDaka密码不能为空');
42 | hasError = true;
43 | }
44 |
45 | if (hasError) {
46 | return;
47 | }
48 |
49 | // 禁用提交按钮
50 | submitBtn.disabled = true;
51 | submitBtn.textContent = '正在提交...';
52 |
53 | try {
54 | // 发送数据到服务器
55 | const response = await fetch('/setup', {
56 | method: 'POST',
57 | headers: {
58 | 'Content-Type': 'application/json'
59 | },
60 | body: JSON.stringify({
61 | api_host: apiHost,
62 | admin_password: adminPassword,
63 | admin_password_confirm: adminPasswordConfirm,
64 | fuck_password: fuckPassword
65 | })
66 | });
67 |
68 | const result = await response.json();
69 |
70 | if (result.success) {
71 | // 显示成功消息
72 | successMessage.textContent = result.message;
73 | successMessage.style.display = 'block';
74 |
75 | // 禁用按钮
76 | submitBtn.disabled = true;
77 | submitBtn.textContent = '配置已保存';
78 |
79 | // 设置更长的等待时间,并显示倒计时
80 | const waitSeconds = 30; // 增加到30秒
81 | let remainingSeconds = waitSeconds;
82 |
83 | // 更新倒计时消息
84 | const updateCountdown = () => {
85 | successMessage.textContent = `${result.message} 页面将在 ${remainingSeconds} 秒后刷新...`;
86 | if (remainingSeconds <= 0) {
87 | clearInterval(countdownInterval);
88 | window.location.reload();
89 | }
90 | remainingSeconds--;
91 | };
92 |
93 | // 初始更新
94 | updateCountdown();
95 |
96 | // 设置倒计时
97 | const countdownInterval = setInterval(updateCountdown, 1000);
98 | } else {
99 | // 显示错误消息
100 | errorMessage.textContent = result.message;
101 | errorMessage.style.display = 'block';
102 |
103 | // 恢复提交按钮
104 | submitBtn.disabled = false;
105 | submitBtn.textContent = '完成设置';
106 | }
107 | } catch (error) {
108 | console.error('提交过程中发生错误:', error);
109 |
110 | // 处理可能是由于服务器重启导致的连接中断
111 | // 创建一个警告消息,而不是错误消息
112 | const warningMessage = document.createElement('div');
113 | warningMessage.className = 'message warning-message';
114 | warningMessage.id = 'warning-message';
115 | warningMessage.innerHTML = `
116 | 提交过程中连接断开,这可能是因为系统正在重启。
117 | 系统配置可能已保存成功,页面将在 30 秒后自动刷新。
118 |
119 | `;
120 |
121 | // 隐藏错误消息,显示警告消息
122 | errorMessage.style.display = 'none';
123 | // 找到表单的父元素并添加警告消息
124 | const container = document.querySelector('.setup-container');
125 |
126 | // 检查是否已存在警告消息
127 | if (!document.getElementById('warning-message')) {
128 | container.appendChild(warningMessage);
129 | } else {
130 | document.getElementById('warning-message').style.display = 'block';
131 | }
132 |
133 | // 禁用提交按钮,避免重复提交
134 | submitBtn.disabled = true;
135 | submitBtn.textContent = '系统可能正在重启';
136 |
137 | // 设置自动刷新倒计时
138 | let refreshSeconds = 30;
139 | const countdownElement = document.getElementById('auto-refresh-countdown');
140 |
141 | const refreshCountdown = setInterval(() => {
142 | refreshSeconds--;
143 | if (countdownElement) {
144 | countdownElement.textContent = refreshSeconds;
145 | }
146 |
147 | if (refreshSeconds <= 0) {
148 | clearInterval(refreshCountdown);
149 | window.location.reload();
150 | }
151 | }, 1000);
152 |
153 | // 添加手动刷新按钮事件
154 | setTimeout(() => {
155 | document.getElementById('manual-refresh').addEventListener('click', () => {
156 | window.location.reload();
157 | });
158 | }, 100);
159 | }
160 | });
161 |
162 | function showError(elementId, message) {
163 | const errorElement = document.getElementById(elementId);
164 | errorElement.textContent = message;
165 | errorElement.style.display = 'block';
166 | }
167 | });
--------------------------------------------------------------------------------
/app/routes/admin/schedules.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import datetime
3 | import sqlite3
4 |
5 | from fastapi import APIRouter, Request
6 | from fastapi.responses import JSONResponse
7 |
8 | from app import CRON_DB_FILE, USER_DB_FILE, logger
9 | from app.auth.dependencies import admin_required
10 | from app.routes.admin.utils import get_admin_stats, get_admin_name, templates
11 |
12 | # 创建路由器
13 | router = APIRouter()
14 |
15 | @router.get("/schedules")
16 | @admin_required
17 | async def admin_schedules_page(request: Request):
18 | """
19 | 管理员定时任务管理页面
20 | """
21 | # 获取基本统计信息
22 | stats = await get_admin_stats()
23 |
24 | # 获取open_id
25 | open_id = request.cookies.get("open_id")
26 |
27 | # 查询当前管理员信息
28 | admin_name = await get_admin_name(open_id)
29 |
30 | # 返回定时任务管理页面
31 | return templates.TemplateResponse(
32 | "admin/schedules.html",
33 | {
34 | "request": request,
35 | "user_info": {"username": admin_name, "user_id": "admin"},
36 | "stats": stats,
37 | "page_title": "定时任务管理",
38 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
39 | }
40 | )
41 |
42 | @router.get("/schedules-api")
43 | @admin_required
44 | async def get_schedules_api(request: Request):
45 | """
46 | 获取定时任务列表API
47 | """
48 | try:
49 | # 连接cron.db数据库
50 | conn = sqlite3.connect(CRON_DB_FILE)
51 | conn.row_factory = sqlite3.Row
52 | cursor = conn.cursor()
53 |
54 | # 查询所有定时任务
55 | cursor.execute(
56 | """
57 | SELECT s.id, s.user_id, s.username, s.morning, s.afternoon,
58 | s.schedule_index, s.created_at, s.morning_time, s.afternoon_time,
59 | s.morning_selecte, s.afternoon_selecte
60 | FROM schedules s
61 | ORDER BY s.created_at DESC
62 | """
63 | )
64 |
65 | schedules = cursor.fetchall()
66 |
67 | # 格式化定时任务数据
68 | formatted_schedules = []
69 | for schedule in schedules:
70 | index = schedule['schedule_index']
71 |
72 | # 查询用户详细信息
73 | user_conn = sqlite3.connect(USER_DB_FILE)
74 | user_conn.row_factory = sqlite3.Row
75 | user_cursor = user_conn.cursor()
76 | user_cursor.execute(
77 | "SELECT department_name, position FROM users WHERE user_id = ?",
78 | (schedule['user_id'],)
79 | )
80 | user_info = user_cursor.fetchone()
81 | user_conn.close()
82 |
83 | department = user_info['department_name'] if user_info else "未知"
84 | position = user_info['position'] if user_info else "未知"
85 |
86 | # 解析选择状态数组
87 | morning_selections = []
88 | afternoon_selections = []
89 |
90 | if schedule['morning_selecte']:
91 | try:
92 | morning_selections = ast.literal_eval(schedule['morning_selecte'])
93 | except:
94 | morning_selections = [0, 0, 0]
95 | else:
96 | morning_selections = [0, 0, 0]
97 |
98 | if schedule['afternoon_selecte']:
99 | try:
100 | afternoon_selections = ast.literal_eval(schedule['afternoon_selecte'])
101 | except:
102 | afternoon_selections = [0, 0, 0]
103 | else:
104 | afternoon_selections = [0, 0, 0]
105 |
106 | # 获取选择的时间
107 | active_morning_times = []
108 | active_afternoon_times = []
109 |
110 | if schedule['morning_time']:
111 | try:
112 | active_morning_times = ast.literal_eval(schedule['morning_time'])
113 | except:
114 | active_morning_times = []
115 |
116 | if schedule['afternoon_time']:
117 | try:
118 | active_afternoon_times = ast.literal_eval(schedule['afternoon_time'])
119 | except:
120 | active_afternoon_times = []
121 |
122 | formatted_schedules.append({
123 | "id": schedule['id'],
124 | "user_id": schedule['user_id'],
125 | "username": schedule['username'],
126 | "department": department,
127 | "position": position,
128 | "morning": schedule['morning'] == 1,
129 | "afternoon": schedule['afternoon'] == 1,
130 | "morning_times": active_morning_times,
131 | "afternoon_times": active_afternoon_times,
132 | "schedule_index": schedule['schedule_index'],
133 | "morning_selections": morning_selections,
134 | "afternoon_selections": afternoon_selections,
135 | "created_at": schedule['created_at']
136 | })
137 |
138 | # 关闭连接
139 | conn.close()
140 |
141 | return JSONResponse({
142 | "success": True,
143 | "schedules": formatted_schedules,
144 | "total": len(formatted_schedules)
145 | })
146 | except Exception as e:
147 | logger.error(f"获取定时任务列表失败: {str(e)}")
148 | return JSONResponse({
149 | "success": False,
150 | "message": f"获取定时任务列表失败: {str(e)}"
151 | }, status_code=500)
152 |
153 | @router.delete("/schedules/{schedule_id}")
154 | @admin_required
155 | async def delete_schedule_api(request: Request, schedule_id: int):
156 | """
157 | 删除定时任务API
158 | """
159 | try:
160 | # 连接cron.db数据库
161 | conn = sqlite3.connect(CRON_DB_FILE)
162 | conn.row_factory = sqlite3.Row
163 | cursor = conn.cursor()
164 |
165 | # 获取定时任务信息
166 | cursor.execute("SELECT user_id FROM schedules WHERE id = ?", (schedule_id,))
167 | schedule = cursor.fetchone()
168 |
169 | if not schedule:
170 | return JSONResponse({
171 | "success": False,
172 | "message": "定时任务不存在"
173 | }, status_code=404)
174 |
175 | user_id = schedule['user_id']
176 |
177 | # 删除定时任务
178 | cursor.execute("DELETE FROM schedules WHERE id = ?", (schedule_id,))
179 | conn.commit()
180 |
181 | # 删除APScheduler中的任务
182 | from app.routes.crontab import remove_schedule_jobs
183 | remove_schedule_jobs(user_id)
184 |
185 | # 关闭连接
186 | conn.close()
187 |
188 | return JSONResponse({
189 | "success": True,
190 | "message": "定时任务已删除"
191 | })
192 | except Exception as e:
193 | logger.error(f"删除定时任务失败: {str(e)}")
194 | return JSONResponse({
195 | "success": False,
196 | "message": f"删除定时任务失败: {str(e)}"
197 | }, status_code=500)
--------------------------------------------------------------------------------
/app/routes/setup.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | import sqlite3
4 |
5 | from fastapi import APIRouter, Request
6 | from fastapi.responses import RedirectResponse, HTMLResponse
7 | from fastapi.templating import Jinja2Templates
8 |
9 | from app import SET_DB_FILE
10 | from app.routes.system import trigger_restart_after_setup
11 | from config import Settings
12 | from config import settings
13 |
14 | # 创建路由器
15 | router = APIRouter(tags=["系统设置"])
16 |
17 | # 设置模板
18 | templates = Jinja2Templates(directory="app/static/templates")
19 |
20 | @router.get("/setup", response_class=HTMLResponse)
21 | async def setup_page(request: Request):
22 | """
23 | 系统初始化设置页面
24 | """
25 |
26 | # 如果系统已初始化,重定向到首页
27 | if settings.IS_INITIALIZED:
28 | return RedirectResponse(url="/")
29 |
30 | return templates.TemplateResponse(
31 | "setup.html",
32 | {"request": request}
33 | )
34 |
35 | @router.post("/setup")
36 | async def process_setup(request: Request):
37 | """
38 | 处理系统初始化设置
39 | """
40 |
41 | # 如果系统已初始化,返回错误
42 | if settings.IS_INITIALIZED:
43 | return {"success": False, "message": "系统已经初始化"}
44 |
45 | try:
46 | # 获取提交的数据
47 | data = await request.json()
48 |
49 | # 验证必填字段
50 | required_fields = ["api_host", "admin_password", "admin_password_confirm", "fuck_password"]
51 | for field in required_fields:
52 | if field not in data or not data[field]:
53 | return {"success": False, "message": f"缺少必填字段: {field}"}
54 |
55 | # 验证密码
56 | if data["admin_password"] != data["admin_password_confirm"]:
57 | return {"success": False, "message": "两次输入的管理员密码不一致"}
58 |
59 | if len(data["admin_password"]) < 6:
60 | return {"success": False, "message": "管理员密码长度不能少于6个字符"}
61 |
62 | # 确保数据目录存在
63 | os.makedirs("data", exist_ok=True)
64 |
65 | # 连接数据库
66 | conn = sqlite3.connect(SET_DB_FILE)
67 | cursor = conn.cursor()
68 |
69 | # 更新设置
70 | current_time = datetime.datetime.now().timestamp()
71 |
72 | # 只处理允许的字段
73 | allowed_fields = ["api_host", "admin_password", "fuck_password"]
74 | for key in allowed_fields:
75 | if key in data:
76 | cursor.execute(
77 | "INSERT OR REPLACE INTO system_settings (setting_key, setting_value, updated_at, is_initialized) VALUES (?, ?, ?, 1)",
78 | (key, data[key], current_time)
79 | )
80 |
81 | conn.commit()
82 | conn.close()
83 |
84 | # 创建标记文件,表示需要重启
85 | try:
86 | with open("data/needs_restart", "w") as f:
87 | f.write(f"Initial setup completed at: {datetime.datetime.now().isoformat()}")
88 | except Exception:
89 | pass # 忽略标记文件创建失败的情况
90 |
91 | # 触发系统重启
92 | restart_result = await trigger_restart_after_setup()
93 |
94 | # 返回成功消息
95 | if restart_result["success"]:
96 | is_ready = restart_result.get("is_ready", False)
97 | if is_ready:
98 | return {"success": True, "message": "系统初始化成功!服务已就绪,即将刷新页面..."}
99 | else:
100 | return {"success": True, "message": "系统初始化成功!服务正在启动中,请稍候..."}
101 | else:
102 | return {"success": True, "message": f"系统初始化成功!但重启可能需要手动操作: {restart_result['message']}"}
103 | except Exception as e:
104 | return {"success": False, "message": f"系统初始化失败: {str(e)}"}
105 |
106 | @router.post("/settings/update")
107 | async def update_settings(request: Request):
108 | """
109 | 更新系统设置
110 | """
111 | # 确保系统已初始化
112 | if not settings.IS_INITIALIZED:
113 | return {"success": False, "message": "系统尚未初始化"}
114 |
115 | try:
116 | # 获取提交的数据
117 | data = await request.json()
118 |
119 | # 连接数据库
120 | conn = sqlite3.connect(SET_DB_FILE)
121 | cursor = conn.cursor()
122 |
123 | # 更新设置
124 | current_time = datetime.datetime.now().timestamp()
125 |
126 | # 允许更新的字段
127 | allowed_fields = ["api_host", "admin_password", "fuck_password"]
128 | updated = False
129 |
130 | for key in allowed_fields:
131 | if key in data and data[key]:
132 | # 密码字段验证长度
133 | if key.endswith("_password") and len(data[key]) < 6:
134 | return {"success": False, "message": f"{key}长度不能少于6个字符"}
135 |
136 | cursor.execute(
137 | "INSERT OR REPLACE INTO system_settings (setting_key, setting_value, updated_at, is_initialized) VALUES (?, ?, ?, 1)",
138 | (key, data[key], current_time)
139 | )
140 | updated = True
141 |
142 | conn.commit()
143 | conn.close()
144 |
145 | if not updated:
146 | return {"success": False, "message": "没有提供有效的设置字段"}
147 |
148 | # 重新加载设置
149 | Settings()
150 |
151 | # 创建标记文件,表示需要重启
152 | try:
153 | with open("data/needs_restart", "w") as f:
154 | f.write(f"Settings updated at: {datetime.datetime.now().isoformat()}")
155 | except Exception:
156 | pass # 忽略标记文件创建失败的情况
157 |
158 | # 返回成功消息
159 | return {"success": True, "message": "设置已更新,需要重启系统使更改生效"}
160 | except Exception as e:
161 | return {"success": False, "message": f"更新设置失败: {str(e)}"}
162 |
163 | # @router.get("/api/settings")
164 | # @admin_required
165 | # async def get_settings(request: Request):
166 | # """获取系统设置 (需要管理员权限)"""
167 | # try:
168 | # conn = sqlite3.connect(SET_DB_FILE)
169 | # cursor = conn.cursor()
170 |
171 | # # 查询所有非敏感设置
172 | # cursor.execute("SELECT setting_key, setting_value, updated_at FROM system_settings WHERE setting_key NOT LIKE '%password%'")
173 | # settings_rows = cursor.fetchall()
174 |
175 | # # 查询敏感设置,但不返回实际值
176 | # cursor.execute("SELECT setting_key, updated_at FROM system_settings WHERE setting_key LIKE '%password%'")
177 | # sensitive_rows = cursor.fetchall()
178 |
179 | # conn.close()
180 |
181 | # # 格式化设置信息
182 | # settings_info = {}
183 |
184 | # # 非敏感设置
185 | # for key, value, updated_at in settings_rows:
186 | # settings_info[key] = {
187 | # "value": value,
188 | # "updated_at": datetime.datetime.fromtimestamp(updated_at).isoformat() if updated_at else None
189 | # }
190 |
191 | # # 敏感设置 - 只返回存在标记和更新时间
192 | # for key, updated_at in sensitive_rows:
193 | # settings_info[key] = {
194 | # "value": "******", # 不返回实际密码值
195 | # "updated_at": datetime.datetime.fromtimestamp(updated_at).isoformat() if updated_at else None
196 | # }
197 |
198 | # # 添加系统状态信息
199 | # settings_info["system_info"] = {
200 | # "version": settings.APP_VERSION,
201 | # "is_initialized": settings.IS_INITIALIZED,
202 | # "app_env": settings.APP_ENV,
203 | # "start_time": datetime.datetime.fromtimestamp(settings.START_TIME).isoformat()
204 | # }
205 |
206 | # return JSONResponse(settings_info)
207 | # except Exception as e:
208 | # raise HTTPException(status_code=500, detail=f"获取设置信息失败: {str(e)}")
--------------------------------------------------------------------------------
/app/static/css/admin/common.css:
--------------------------------------------------------------------------------
1 | /* 重置和通用样式 */
2 | * {
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | }
7 |
8 | body {
9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
10 | font-size: 16px;
11 | line-height: 1.5;
12 | color: #333;
13 | background-color: #f5f7fa;
14 | }
15 |
16 | a {
17 | text-decoration: none;
18 | color: inherit;
19 | }
20 |
21 | ul, ol {
22 | list-style: none;
23 | }
24 |
25 | /* 管理布局 */
26 | .admin-layout {
27 | display: flex;
28 | min-height: 100vh;
29 | }
30 |
31 | /* 侧边栏 */
32 | .admin-sidebar {
33 | width: 240px;
34 | background-color: #2c3e50;
35 | color: #fff;
36 | transition: all 0.3s ease;
37 | display: flex;
38 | flex-direction: column;
39 | z-index: 1000;
40 | }
41 |
42 | .sidebar-header {
43 | padding: 20px;
44 | border-bottom: 1px solid rgba(255, 255, 255, 0.1);
45 | }
46 |
47 | .sidebar-header h2 {
48 | font-size: 18px;
49 | margin-bottom: 5px;
50 | }
51 |
52 | .sidebar-header p {
53 | font-size: 14px;
54 | opacity: 0.7;
55 | }
56 |
57 | .sidebar-nav {
58 | flex: 1;
59 | padding: 20px 0;
60 | }
61 |
62 | .sidebar-nav li {
63 | margin-bottom: 5px;
64 | }
65 |
66 | .sidebar-nav a {
67 | display: flex;
68 | align-items: center;
69 | padding: 12px 20px;
70 | color: rgba(255, 255, 255, 0.7);
71 | transition: all 0.2s ease;
72 | }
73 |
74 | .sidebar-nav a:hover {
75 | background-color: rgba(255, 255, 255, 0.1);
76 | color: #fff;
77 | }
78 |
79 | .sidebar-nav li.active a {
80 | background-color: rgba(255, 255, 255, 0.15);
81 | color: #fff;
82 | border-left: 4px solid #3498db;
83 | }
84 |
85 | .sidebar-nav .icon {
86 | margin-right: 10px;
87 | font-size: 18px;
88 | }
89 |
90 | .sidebar-footer {
91 | padding: 15px 20px;
92 | border-top: 1px solid rgba(255, 255, 255, 0.1);
93 | }
94 |
95 | .logout-btn {
96 | display: flex;
97 | align-items: center;
98 | color: rgba(255, 255, 255, 0.7);
99 | padding: 8px 0;
100 | transition: color 0.2s ease;
101 | }
102 |
103 | .logout-btn:hover {
104 | color: #fff;
105 | }
106 |
107 | .logout-btn .icon {
108 | margin-right: 10px;
109 | }
110 |
111 | /* 主内容区域 */
112 | .admin-main {
113 | flex: 1;
114 | display: flex;
115 | flex-direction: column;
116 | overflow-x: hidden;
117 | transition: all 0.3s ease;
118 | }
119 |
120 | /* 顶部导航 */
121 | .admin-header {
122 | display: flex;
123 | justify-content: space-between;
124 | align-items: center;
125 | height: 60px;
126 | padding: 0 20px;
127 | background-color: #fff;
128 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
129 | z-index: 10;
130 | }
131 |
132 | .sidebar-toggle {
133 | background: none;
134 | border: none;
135 | cursor: pointer;
136 | display: flex;
137 | flex-direction: column;
138 | height: 20px;
139 | justify-content: space-between;
140 | width: 25px;
141 | }
142 |
143 | .sidebar-toggle span {
144 | background-color: #333;
145 | border-radius: 3px;
146 | height: 3px;
147 | width: 100%;
148 | transition: all 0.3s ease;
149 | }
150 |
151 | .user-info {
152 | display: flex;
153 | align-items: center;
154 | }
155 |
156 | .username {
157 | font-weight: 500;
158 | margin-right: 8px;
159 | }
160 |
161 | .role {
162 | font-size: 14px;
163 | color: #666;
164 | }
165 |
166 | /* 内容区 */
167 | .admin-content {
168 | flex: 1;
169 | padding: 20px;
170 | overflow-y: auto;
171 | }
172 |
173 | /* 页脚 */
174 | .admin-footer {
175 | padding: 15px 20px;
176 | text-align: center;
177 | font-size: 14px;
178 | color: #666;
179 | border-top: 1px solid #eee;
180 | }
181 |
182 | /* 侧边栏遮罩层 */
183 | .sidebar-overlay {
184 | display: none;
185 | position: fixed;
186 | top: 0;
187 | left: 0;
188 | right: 0;
189 | bottom: 0;
190 | background-color: rgba(0, 0, 0, 0.5);
191 | z-index: 900;
192 | }
193 |
194 | /* 响应式侧边栏 */
195 | @media (max-width: 991px) {
196 | .admin-sidebar {
197 | position: fixed;
198 | height: 100%;
199 | left: -240px;
200 | top: 0;
201 | box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
202 | }
203 |
204 | .admin-layout.sidebar-collapsed .admin-sidebar {
205 | left: 0;
206 | }
207 |
208 | .admin-layout.sidebar-collapsed .sidebar-overlay {
209 | display: block;
210 | }
211 |
212 | .admin-layout.sidebar-collapsed .admin-main {
213 | filter: blur(3px);
214 | opacity: 0.7;
215 | pointer-events: none;
216 | }
217 |
218 | .admin-header .sidebar-toggle.active span:first-child {
219 | transform: rotate(45deg) translate(5px, 5px);
220 | }
221 |
222 | .admin-header .sidebar-toggle.active span:nth-child(2) {
223 | opacity: 0;
224 | }
225 |
226 | .admin-header .sidebar-toggle.active span:last-child {
227 | transform: rotate(-45deg) translate(5px, -5px);
228 | }
229 | }
230 |
231 | @media (max-width: 480px) {
232 | .admin-content {
233 | padding: 15px;
234 | }
235 |
236 | .admin-header {
237 | padding: 0 15px;
238 | }
239 | }
240 |
241 | /* 卡片样式 */
242 | .card {
243 | background-color: #fff;
244 | border-radius: 8px;
245 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
246 | padding: 20px;
247 | margin-bottom: 20px;
248 | }
249 |
250 | .card-header {
251 | margin-bottom: 15px;
252 | border-bottom: 1px solid #eee;
253 | padding-bottom: 15px;
254 | }
255 |
256 | .card-title {
257 | font-size: 18px;
258 | color: #333;
259 | }
260 |
261 | /* 表格样式 */
262 | .table-container {
263 | overflow-x: auto;
264 | }
265 |
266 | table {
267 | width: 100%;
268 | border-collapse: collapse;
269 | }
270 |
271 | table th,
272 | table td {
273 | padding: 12px 15px;
274 | text-align: left;
275 | border-bottom: 1px solid #eee;
276 | }
277 |
278 | table th {
279 | font-weight: 500;
280 | color: #555;
281 | background-color: #f9f9f9;
282 | }
283 |
284 | table tbody tr:hover {
285 | background-color: #f5f5f5;
286 | }
287 |
288 | /* 按钮样式 */
289 | .btn {
290 | display: inline-block;
291 | padding: 8px 16px;
292 | font-size: 14px;
293 | border-radius: 4px;
294 | cursor: pointer;
295 | transition: all 0.2s ease;
296 | border: none;
297 | }
298 |
299 | .btn-primary {
300 | background-color: #3498db;
301 | color: #fff;
302 | }
303 |
304 | .btn-primary:hover {
305 | background-color: #2980b9;
306 | }
307 |
308 | .btn-danger {
309 | background-color: #e74c3c;
310 | color: #fff;
311 | }
312 |
313 | .btn-danger:hover {
314 | background-color: #c0392b;
315 | }
316 |
317 | .btn-success {
318 | background-color: #2ecc71;
319 | color: #fff;
320 | }
321 |
322 | .btn-success:hover {
323 | background-color: #27ae60;
324 | }
325 |
326 | /* 表单样式 */
327 | .form-group {
328 | margin-bottom: 20px;
329 | }
330 |
331 | .form-label {
332 | display: block;
333 | margin-bottom: 8px;
334 | font-weight: 500;
335 | }
336 |
337 | .form-control {
338 | width: 100%;
339 | padding: 10px 12px;
340 | font-size: 14px;
341 | border: 1px solid #ddd;
342 | border-radius: 4px;
343 | transition: border-color 0.2s ease;
344 | }
345 |
346 | .form-control:focus {
347 | border-color: #3498db;
348 | outline: none;
349 | }
350 |
351 | /* 警告和提示 */
352 | .alert {
353 | padding: 15px;
354 | border-radius: 4px;
355 | margin-bottom: 20px;
356 | }
357 |
358 | .alert-success {
359 | background-color: #d4edda;
360 | color: #155724;
361 | border: 1px solid #c3e6cb;
362 | }
363 |
364 | .alert-danger {
365 | background-color: #f8d7da;
366 | color: #721c24;
367 | border: 1px solid #f5c6cb;
368 | }
369 |
370 | .alert-warning {
371 | background-color: #fff3cd;
372 | color: #856404;
373 | border: 1px solid #ffeeba;
374 | }
375 |
376 | .alert-info {
377 | background-color: #d1ecf1;
378 | color: #0c5460;
379 | border: 1px solid #bee5eb;
380 | }
--------------------------------------------------------------------------------
/app/routes/admin/setup.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | import sqlite3
4 |
5 | from fastapi import APIRouter, Request
6 | from fastapi.responses import RedirectResponse, HTMLResponse
7 | from fastapi.templating import Jinja2Templates
8 |
9 | from app import SET_DB_FILE
10 | from app.routes.admin.system import trigger_restart_after_setup
11 | from config import Settings
12 | from config import settings
13 |
14 | # 创建路由器
15 | router = APIRouter(tags=["系统设置"])
16 |
17 | # 设置模板
18 | templates = Jinja2Templates(directory="app/static/templates")
19 |
20 | @router.get("/setup", response_class=HTMLResponse)
21 | async def setup_page(request: Request):
22 | """
23 | 系统初始化设置页面
24 | """
25 |
26 | # 如果系统已初始化,重定向到首页
27 | if settings.IS_INITIALIZED:
28 | return RedirectResponse(url="/")
29 |
30 | return templates.TemplateResponse(
31 | "setup.html",
32 | {"request": request}
33 | )
34 |
35 | @router.post("/setup")
36 | async def process_setup(request: Request):
37 | """
38 | 处理系统初始化设置
39 | """
40 |
41 | # 如果系统已初始化,返回错误
42 | if settings.IS_INITIALIZED:
43 | return {"success": False, "message": "系统已经初始化"}
44 |
45 | try:
46 | # 获取提交的数据
47 | data = await request.json()
48 |
49 | # 验证必填字段
50 | required_fields = ["api_host", "admin_password", "admin_password_confirm", "fuck_password"]
51 | for field in required_fields:
52 | if field not in data or not data[field]:
53 | return {"success": False, "message": f"缺少必填字段: {field}"}
54 |
55 | # 验证密码
56 | if data["admin_password"] != data["admin_password_confirm"]:
57 | return {"success": False, "message": "两次输入的管理员密码不一致"}
58 |
59 | if len(data["admin_password"]) < 6:
60 | return {"success": False, "message": "管理员密码长度不能少于6个字符"}
61 |
62 | # 确保数据目录存在
63 | os.makedirs("data", exist_ok=True)
64 |
65 | # 连接数据库
66 | conn = sqlite3.connect(SET_DB_FILE)
67 | cursor = conn.cursor()
68 |
69 | # 更新设置
70 | current_time = datetime.datetime.now().timestamp()
71 |
72 | # 只处理允许的字段
73 | allowed_fields = ["api_host", "admin_password", "fuck_password"]
74 | for key in allowed_fields:
75 | if key in data:
76 | cursor.execute(
77 | "INSERT OR REPLACE INTO system_settings (setting_key, setting_value, updated_at, is_initialized) VALUES (?, ?, ?, 1)",
78 | (key, data[key], current_time)
79 | )
80 |
81 | conn.commit()
82 | conn.close()
83 |
84 | # 创建标记文件,表示需要重启
85 | try:
86 | with open("data/needs_restart", "w") as f:
87 | f.write(f"Initial setup completed at: {datetime.datetime.now().isoformat()}")
88 | except Exception:
89 | pass # 忽略标记文件创建失败的情况
90 |
91 | # 触发系统重启
92 | restart_result = await trigger_restart_after_setup()
93 |
94 | # 返回成功消息
95 | if restart_result["success"]:
96 | is_ready = restart_result.get("is_ready", False)
97 | if is_ready:
98 | return {"success": True, "message": "系统初始化成功!服务已就绪,即将刷新页面..."}
99 | else:
100 | return {"success": True, "message": "系统初始化成功!服务正在启动中,请稍候..."}
101 | else:
102 | return {"success": True, "message": f"系统初始化成功!但重启可能需要手动操作: {restart_result['message']}"}
103 | except Exception as e:
104 | return {"success": False, "message": f"系统初始化失败: {str(e)}"}
105 |
106 | @router.post("/settings/update")
107 | async def update_settings(request: Request):
108 | """
109 | 更新系统设置
110 | """
111 | # 确保系统已初始化
112 | if not settings.IS_INITIALIZED:
113 | return {"success": False, "message": "系统尚未初始化"}
114 |
115 | try:
116 | # 获取提交的数据
117 | data = await request.json()
118 |
119 | # 连接数据库
120 | conn = sqlite3.connect(SET_DB_FILE)
121 | cursor = conn.cursor()
122 |
123 | # 更新设置
124 | current_time = datetime.datetime.now().timestamp()
125 |
126 | # 允许更新的字段
127 | allowed_fields = ["api_host", "admin_password", "fuck_password"]
128 | updated = False
129 |
130 | for key in allowed_fields:
131 | if key in data and data[key]:
132 | # 密码字段验证长度
133 | if key.endswith("_password") and len(data[key]) < 6:
134 | return {"success": False, "message": f"{key}长度不能少于6个字符"}
135 |
136 | cursor.execute(
137 | "INSERT OR REPLACE INTO system_settings (setting_key, setting_value, updated_at, is_initialized) VALUES (?, ?, ?, 1)",
138 | (key, data[key], current_time)
139 | )
140 | updated = True
141 |
142 | conn.commit()
143 | conn.close()
144 |
145 | if not updated:
146 | return {"success": False, "message": "没有提供有效的设置字段"}
147 |
148 | # 重新加载设置
149 | Settings()
150 |
151 | # 检查是否需要重启
152 | needs_restart = data.get("needs_restart", False)
153 |
154 | if needs_restart:
155 | # 创建标记文件,表示需要重启
156 | try:
157 | with open("data/needs_restart", "w") as f:
158 | f.write(f"Settings updated at: {datetime.datetime.now().isoformat()}")
159 | except Exception:
160 | pass # 忽略标记文件创建失败的情况
161 |
162 | # 触发系统重启
163 | restart_result = await trigger_restart_after_setup()
164 |
165 | # 返回重启消息
166 | if restart_result["success"]:
167 | return {"success": True, "message": "设置已更新,系统正在重启...", "restart": True}
168 | else:
169 | return {"success": True, "message": f"设置已更新,但重启失败: {restart_result['message']}", "restart": False}
170 | else:
171 | # 不需要重启,直接返回成功
172 | return {"success": True, "message": "设置已更新", "restart": False}
173 | except Exception as e:
174 | return {"success": False, "message": f"更新设置失败: {str(e)}"}
175 |
176 | # @router.get("/api/settings")
177 | # @admin_required
178 | # async def get_settings(request: Request):
179 | # """获取系统设置 (需要管理员权限)"""
180 | # try:
181 | # conn = sqlite3.connect(SET_DB_FILE)
182 | # cursor = conn.cursor()
183 |
184 | # # 查询所有非敏感设置
185 | # cursor.execute("SELECT setting_key, setting_value, updated_at FROM system_settings WHERE setting_key NOT LIKE '%password%'")
186 | # settings_rows = cursor.fetchall()
187 |
188 | # # 查询敏感设置,但不返回实际值
189 | # cursor.execute("SELECT setting_key, updated_at FROM system_settings WHERE setting_key LIKE '%password%'")
190 | # sensitive_rows = cursor.fetchall()
191 |
192 | # conn.close()
193 |
194 | # # 格式化设置信息
195 | # settings_info = {}
196 |
197 | # # 非敏感设置
198 | # for key, value, updated_at in settings_rows:
199 | # settings_info[key] = {
200 | # "value": value,
201 | # "updated_at": datetime.datetime.fromtimestamp(updated_at).isoformat() if updated_at else None
202 | # }
203 |
204 | # # 敏感设置 - 只返回存在标记和更新时间
205 | # for key, updated_at in sensitive_rows:
206 | # settings_info[key] = {
207 | # "value": "******", # 不返回实际密码值
208 | # "updated_at": datetime.datetime.fromtimestamp(updated_at).isoformat() if updated_at else None
209 | # }
210 |
211 | # # 添加系统状态信息
212 | # settings_info["system_info"] = {
213 | # "version": settings.APP_VERSION,
214 | # "is_initialized": settings.IS_INITIALIZED,
215 | # "app_env": settings.APP_ENV,
216 | # "start_time": datetime.datetime.fromtimestamp(settings.START_TIME).isoformat()
217 | # }
218 |
219 | # return JSONResponse(settings_info)
220 | # except Exception as e:
221 | # raise HTTPException(status_code=500, detail=f"获取设置信息失败: {str(e)}")
--------------------------------------------------------------------------------
/app/static/js/admin/schedules.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | // 显示当前时间并自动更新
3 | function updateClock() {
4 | const now = new Date();
5 | const timeDisplay = document.querySelector('.current-time');
6 | if (timeDisplay) {
7 | const formattedTime = now.getFullYear() + '-' +
8 | padZero(now.getMonth() + 1) + '-' +
9 | padZero(now.getDate()) + ' ' +
10 | padZero(now.getHours()) + ':' +
11 | padZero(now.getMinutes()) + ':' +
12 | padZero(now.getSeconds());
13 | timeDisplay.textContent = '当前时间: ' + formattedTime;
14 | }
15 | }
16 |
17 | function padZero(num) {
18 | return num < 10 ? '0' + num : num;
19 | }
20 |
21 | // 初始更新并设置定时器
22 | updateClock();
23 | setInterval(updateClock, 1000);
24 |
25 | // 加载定时任务数据
26 | loadSchedulesData();
27 |
28 | // 刷新按钮事件
29 | document.getElementById('refreshBtn').addEventListener('click', function() {
30 | loadSchedulesData();
31 | });
32 |
33 | // 加载定时任务数据
34 | function loadSchedulesData() {
35 | const container = document.getElementById('schedulesTableContainer');
36 |
37 | // 显示加载中状态
38 | container.innerHTML = `
39 |
42 | `;
43 |
44 | // 使用axios获取数据
45 | axios.get('/admin/schedules-api')
46 | .then(function(response) {
47 | if (response.data.success) {
48 | renderSchedulesTable(response.data.schedules);
49 | } else {
50 | container.innerHTML = `
51 |
52 |
加载数据失败: ${response.data.message}
53 |
54 | `;
55 | }
56 | })
57 | .catch(function(error) {
58 | console.error('获取定时任务数据失败:', error);
59 | container.innerHTML = `
60 |
63 | `;
64 | });
65 | }
66 |
67 | // 渲染定时任务表格
68 | function renderSchedulesTable(schedules) {
69 | const container = document.getElementById('schedulesTableContainer');
70 |
71 | if (!schedules || schedules.length === 0) {
72 | container.innerHTML = `
73 |
76 | `;
77 | return;
78 | }
79 |
80 | let tableHtml = `
81 |
82 |
83 |
84 | | ID |
85 | 用户名 |
86 | 用户ID |
87 | 部门 |
88 | 职位 |
89 | 上午打卡 |
90 | 下午打卡 |
91 | 选择状态 |
92 | 创建时间 |
93 | 操作 |
94 |
95 |
96 |
97 | `;
98 |
99 | schedules.forEach(schedule => {
100 | const createdDate = new Date(schedule.created_at);
101 | const formattedDate = createdDate.getFullYear() + '-' +
102 | padZero(createdDate.getMonth() + 1) + '-' +
103 | padZero(createdDate.getDate()) + ' ' +
104 | padZero(createdDate.getHours()) + ':' +
105 | padZero(createdDate.getMinutes());
106 |
107 | // 格式化上午打卡时间
108 | let morningTimesHtml = '';
109 | if (schedule.morning && schedule.morning_times.length > 0) {
110 | schedule.morning_times.forEach(time => {
111 | morningTimesHtml += `${time}`;
112 | });
113 | } else {
114 | morningTimesHtml = `未设置`;
115 | }
116 |
117 | // 格式化下午打卡时间
118 | let afternoonTimesHtml = '';
119 | if (schedule.afternoon && schedule.afternoon_times.length > 0) {
120 | schedule.afternoon_times.forEach(time => {
121 | afternoonTimesHtml += `${time}`;
122 | });
123 | } else {
124 | afternoonTimesHtml = `未设置`;
125 | }
126 |
127 | // 格式化选择状态
128 | let selectionStatusHtml = '';
129 |
130 | // 上午选择状态
131 | selectionStatusHtml += '上午: ';
132 | if (Array.isArray(schedule.morning_selections)) {
133 | schedule.morning_selections.forEach((status, index) => {
134 | selectionStatusHtml += ``;
135 | });
136 | }
137 | selectionStatusHtml += '
';
138 |
139 | // 下午选择状态
140 | selectionStatusHtml += '下午: ';
141 | if (Array.isArray(schedule.afternoon_selections)) {
142 | schedule.afternoon_selections.forEach((status, index) => {
143 | selectionStatusHtml += ``;
144 | });
145 | }
146 | selectionStatusHtml += '
';
147 |
148 | tableHtml += `
149 |
150 | | ${schedule.id} |
151 | ${schedule.username} |
152 | ${schedule.user_id} |
153 | ${schedule.department} |
154 | ${schedule.position} |
155 | ${morningTimesHtml} |
156 | ${afternoonTimesHtml} |
157 | ${selectionStatusHtml} |
158 | ${formattedDate} |
159 |
160 |
161 | |
162 |
163 | `;
164 | });
165 |
166 | tableHtml += `
167 |
168 |
169 | `;
170 |
171 | container.innerHTML = tableHtml;
172 |
173 | // 绑定删除按钮事件
174 | document.querySelectorAll('.delete-btn').forEach(btn => {
175 | btn.addEventListener('click', function() {
176 | const scheduleId = this.getAttribute('data-id');
177 | if (confirm('确定要删除此定时任务吗?此操作不可恢复。')) {
178 | deleteSchedule(scheduleId);
179 | }
180 | });
181 | });
182 | }
183 |
184 | // 删除定时任务
185 | function deleteSchedule(scheduleId) {
186 | axios.delete(`/admin/schedules/${scheduleId}`)
187 | .then(function(response) {
188 | if (response.data.success) {
189 | // 刷新数据
190 | loadSchedulesData();
191 | alert('删除成功');
192 | } else {
193 | alert(`删除失败: ${response.data.message}`);
194 | }
195 | })
196 | .catch(function(error) {
197 | console.error('删除定时任务失败:', error);
198 | alert('删除失败,请重试');
199 | });
200 | }
201 |
202 | function padZero(num) {
203 | return num < 10 ? '0' + num : num;
204 | }
205 | });
--------------------------------------------------------------------------------
/app/utils/db_init.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | import os
4 | import sqlite3
5 |
6 | from .. import SIGN_DB_FILE, LOG_DB_FILE, SET_DB_FILE, USER_DB_FILE, CRON_DB_FILE
7 |
8 | logger = logging.getLogger(__name__)
9 |
10 | def initialize_database():
11 | """初始化所有数据库:创建data目录和相关表"""
12 | # 确保data目录存在
13 | data_dir = "data"
14 | if not os.path.exists(data_dir):
15 | os.makedirs(data_dir)
16 |
17 | # 初始化用户数据库
18 | initialize_user_db()
19 |
20 | # 初始化签到日志数据库
21 | initialize_sign_db()
22 |
23 | # 初始化日志数据库
24 | initialize_log_db()
25 |
26 | # 初始化设置数据库
27 | initialize_settings_db()
28 |
29 | # 初始化定时任务数据库
30 | initialize_cron_db()
31 |
32 | return True
33 |
34 | def initialize_user_db():
35 | """初始化用户数据库"""
36 | # 数据库文件路径
37 |
38 | try:
39 | # 连接数据库
40 | conn = sqlite3.connect(USER_DB_FILE)
41 | cursor = conn.cursor()
42 |
43 | # 创建users表(如果不存在)
44 | cursor.execute('''
45 | CREATE TABLE IF NOT EXISTS users (
46 | id INTEGER PRIMARY KEY AUTOINCREMENT,
47 | username TEXT,
48 | user_id TEXT,
49 | department_name TEXT,
50 | department_id TEXT,
51 | position TEXT,
52 | open_id TEXT,
53 | first_login_time INTEGER DEFAULT 0,
54 | last_activity INTEGER DEFAULT 0,
55 | deleted INTEGER DEFAULT 0
56 | )
57 | ''')
58 |
59 | # 创建索引以提高查询性能
60 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_id ON users (user_id)')
61 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_open_id ON users (open_id)')
62 |
63 | conn.commit()
64 | conn.close()
65 | return True
66 | except Exception as e:
67 | logger.error(f"用户数据库初始化失败: {str(e)}")
68 | return False
69 |
70 | def initialize_sign_db():
71 | """初始化签到日志数据库"""
72 |
73 | try:
74 | # 连接数据库
75 | conn = sqlite3.connect(SIGN_DB_FILE)
76 | cursor = conn.cursor()
77 |
78 | # 创建sign_logs表(如果不存在)
79 | cursor.execute('''
80 | CREATE TABLE IF NOT EXISTS sign_logs (
81 | id INTEGER PRIMARY KEY AUTOINCREMENT,
82 | username TEXT,
83 | sign_time REAL NOT NULL,
84 | sign_type TEXT,
85 | status TEXT,
86 | remark TEXT,
87 | ip_address TEXT DEFAULT '',
88 | create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
89 | )
90 | ''')
91 |
92 | # 创建索引以提高查询性能
93 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_sign_time ON sign_logs (sign_time)')
94 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_username ON sign_logs (username)')
95 |
96 | conn.commit()
97 | conn.close()
98 | return True
99 | except Exception as e:
100 | logger.error(f"签到日志数据库初始化失败: {str(e)}")
101 | return False
102 |
103 | def initialize_log_db():
104 | """初始化日志数据库"""
105 | try:
106 | # 连接数据库
107 | conn = sqlite3.connect(LOG_DB_FILE)
108 | cursor = conn.cursor()
109 |
110 | # 创建login_logs表(如果不存在)- 保留原有表结构
111 | cursor.execute('''
112 | CREATE TABLE IF NOT EXISTS login_logs (
113 | id INTEGER PRIMARY KEY AUTOINCREMENT,
114 | user_id TEXT NOT NULL,
115 | username TEXT,
116 | login_time REAL NOT NULL,
117 | ip_address TEXT,
118 | status TEXT
119 | )
120 | ''')
121 |
122 | # 创建operation_logs表 - 记录各种操作日志
123 | cursor.execute('''
124 | CREATE TABLE IF NOT EXISTS operation_logs (
125 | id INTEGER PRIMARY KEY AUTOINCREMENT,
126 | username TEXT NOT NULL, -- 用户名
127 | operation_time REAL NOT NULL, -- 操作时间戳
128 | operation_type TEXT NOT NULL, -- 操作类型:LOGIN, VIEW, EDIT, CONFIG, SIGN等
129 | operation_detail TEXT, -- 操作详情
130 | ip_address TEXT, -- 操作IP
131 | status TEXT, -- 操作状态:成功/失败
132 | remarks TEXT, -- 备注信息
133 | create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
134 | )
135 | ''')
136 |
137 | # 创建索引
138 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_operation_time ON operation_logs (operation_time)')
139 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_operation_username ON operation_logs (username)')
140 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_operation_type ON operation_logs (operation_type)')
141 |
142 | conn.commit()
143 | conn.close()
144 | return True
145 | except Exception as e:
146 | logger.error(f"日志数据库初始化失败: {str(e)}")
147 | return False
148 |
149 | def initialize_settings_db():
150 | """初始化设置数据库"""
151 | # 数据库文件路径
152 |
153 | try:
154 | # 连接数据库
155 | conn = sqlite3.connect(SET_DB_FILE)
156 | cursor = conn.cursor()
157 |
158 | # 创建system_settings表(如果不存在)
159 | cursor.execute('''
160 | CREATE TABLE IF NOT EXISTS system_settings (
161 | id INTEGER PRIMARY KEY AUTOINCREMENT,
162 | setting_key TEXT UNIQUE NOT NULL,
163 | setting_value TEXT,
164 | description TEXT,
165 | updated_at REAL,
166 | is_initialized BOOLEAN DEFAULT 0
167 | )
168 | ''')
169 |
170 | # 只插入必要的设置项
171 | required_settings = [
172 | ('api_host', '', 'API主机地址', datetime.datetime.now().timestamp(), 0),
173 | ('admin_password', '', '管理员密码', datetime.datetime.now().timestamp(), 0),
174 | ('fuck_password', '', 'FuckDaka密码', datetime.datetime.now().timestamp(), 0)
175 | ]
176 |
177 | cursor.executemany('''
178 | INSERT OR IGNORE INTO system_settings (setting_key, setting_value, description, updated_at, is_initialized)
179 | VALUES (?, ?, ?, ?, ?)
180 | ''', required_settings)
181 |
182 | conn.commit()
183 | conn.close()
184 | return True
185 | except Exception as e:
186 | logger.error(f"设置数据库初始化失败: {str(e)}")
187 | return False
188 |
189 | def initialize_cron_db():
190 | """初始化定时任务数据库"""
191 | # 数据库文件路径
192 |
193 | try:
194 | # 连接数据库
195 | conn = sqlite3.connect(CRON_DB_FILE)
196 | cursor = conn.cursor()
197 |
198 | # 创建schedules表
199 | cursor.execute('''
200 | CREATE TABLE IF NOT EXISTS schedules (
201 | id INTEGER PRIMARY KEY AUTOINCREMENT,
202 | user_id TEXT NOT NULL,
203 | username TEXT NOT NULL,
204 | morning BOOLEAN NOT NULL DEFAULT 0,
205 | afternoon BOOLEAN NOT NULL DEFAULT 0,
206 | schedule_index INTEGER NOT NULL DEFAULT 0,
207 | morning_time TEXT,
208 | afternoon_time TEXT,
209 | morning_selecte TEXT,
210 | afternoon_selecte TEXT,
211 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
212 | )
213 | ''')
214 |
215 | # 创建索引
216 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_id ON schedules (user_id)')
217 |
218 | conn.commit()
219 | conn.close()
220 |
221 | return True
222 | except Exception as e:
223 | logger.error(f"定时任务数据库初始化失败: {str(e)}")
224 | return False
225 |
226 | def reset_cron_db():
227 | """完全重置cron.db数据库(删除并重新创建)"""
228 | db_file = "cron.db"
229 |
230 | try:
231 | # 如果数据库文件存在,删除它
232 | if os.path.exists(db_file):
233 | # 尝试创建备份
234 | backup_file = f"{db_file}.bak"
235 | try:
236 | import shutil
237 | shutil.copy2(db_file, backup_file)
238 | logger.info(f"已备份旧数据库到 {backup_file}")
239 | except Exception as e:
240 | logger.warning(f"备份数据库失败: {str(e)}")
241 |
242 | # 删除原文件
243 | os.remove(db_file)
244 | logger.info(f"已删除旧的数据库文件: {db_file}")
245 |
246 | # 重新初始化
247 | return initialize_cron_db()
248 | except Exception as e:
249 | logger.error(f"重置cron数据库失败: {str(e)}")
250 | return False
251 |
252 | if __name__ == "__main__":
253 | logging.basicConfig(level=logging.INFO)
254 | initialize_database()
--------------------------------------------------------------------------------
/app/static/css/admin/privilege.css:
--------------------------------------------------------------------------------
1 | /* 特权登录页面样式 */
2 | .privilege-container {
3 | padding: 20px;
4 | background-color: #f8f9fa;
5 | border-radius: 10px;
6 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
7 | }
8 |
9 | .privilege-header {
10 | display: flex;
11 | justify-content: space-between;
12 | align-items: center;
13 | margin-bottom: 20px;
14 | padding-bottom: 10px;
15 | border-bottom: 1px solid #eee;
16 | }
17 |
18 | .privilege-header h1 {
19 | font-size: 24px;
20 | color: #333;
21 | margin: 0;
22 | }
23 |
24 | .privilege-content {
25 | display: flex;
26 | flex-direction: column;
27 | gap: 20px;
28 | }
29 |
30 | /* 选择面板样式 */
31 | .selection-panel {
32 | background-color: #fff;
33 | border-radius: 12px;
34 | padding: 20px;
35 | display: flex;
36 | flex-wrap: wrap;
37 | gap: 20px;
38 | align-items: flex-end;
39 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
40 | }
41 |
42 | .selection-group {
43 | flex: 1;
44 | min-width: 200px;
45 | }
46 |
47 | .selection-group label {
48 | display: block;
49 | margin-bottom: 10px;
50 | font-weight: 500;
51 | color: #333;
52 | font-size: 15px;
53 | }
54 |
55 | .selection-group select {
56 | width: 100%;
57 | padding: 12px 15px;
58 | border: 1px solid #dce0e6;
59 | border-radius: 8px;
60 | background-color: #fff;
61 | font-size: 15px;
62 | color: #333;
63 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
64 | transition: all 0.3s ease;
65 | }
66 |
67 | .selection-group select:hover:not(:disabled) {
68 | border-color: #90caf9;
69 | }
70 |
71 | .selection-group select:focus {
72 | border-color: #2196F3;
73 | outline: none;
74 | box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
75 | }
76 |
77 | .selection-group select:disabled {
78 | background-color: #f5f5f5;
79 | cursor: not-allowed;
80 | opacity: 0.7;
81 | }
82 |
83 | .selection-actions {
84 | flex: 0 0 auto;
85 | align-self: flex-end;
86 | }
87 |
88 | .action-button {
89 | padding: 12px 24px;
90 | background-color: #2196F3;
91 | color: white;
92 | border: none;
93 | border-radius: 8px;
94 | cursor: pointer;
95 | font-weight: 500;
96 | font-size: 15px;
97 | transition: all 0.3s ease;
98 | box-shadow: 0 2px 5px rgba(33, 150, 243, 0.3);
99 | }
100 |
101 | .action-button:hover {
102 | background-color: #0d8aee;
103 | transform: translateY(-2px);
104 | box-shadow: 0 4px 8px rgba(33, 150, 243, 0.4);
105 | }
106 |
107 | .action-button:disabled {
108 | background-color: #b0bec5;
109 | cursor: not-allowed;
110 | box-shadow: none;
111 | transform: none;
112 | }
113 |
114 | /* 用户信息面板样式 */
115 | .user-info-panel {
116 | background-color: #fff;
117 | border-radius: 8px;
118 | padding: 20px;
119 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
120 | }
121 |
122 | .user-basic-info {
123 | display: flex;
124 | align-items: center;
125 | margin-bottom: 25px;
126 | padding-bottom: 20px;
127 | border-bottom: 1px solid #eee;
128 | }
129 |
130 | .user-avatar {
131 | margin-right: 20px;
132 | }
133 |
134 | .avatar-placeholder {
135 | display: flex;
136 | justify-content: center;
137 | align-items: center;
138 | width: 80px;
139 | height: 80px;
140 | background-color: #e1f5fe;
141 | border-radius: 50%;
142 | color: #2196F3;
143 | font-size: 32px;
144 | font-weight: bold;
145 | }
146 |
147 | .avatar-placeholder::before {
148 | content: '👤';
149 | }
150 |
151 | .user-details {
152 | flex: 1;
153 | }
154 |
155 | .user-details h2 {
156 | margin: 0 0 10px 0;
157 | font-size: 22px;
158 | color: #333;
159 | }
160 |
161 | .user-details p {
162 | margin: 5px 0;
163 | color: #666;
164 | font-size: 14px;
165 | }
166 |
167 | .status-badge {
168 | display: inline-block;
169 | padding: 3px 8px;
170 | border-radius: 12px;
171 | font-size: 12px;
172 | font-weight: 500;
173 | }
174 |
175 | .status-badge.online {
176 | background-color: #e8f5e9;
177 | color: #4caf50;
178 | }
179 |
180 | .status-badge.offline {
181 | background-color: #ffebee;
182 | color: #f44336;
183 | }
184 |
185 | /* 签到操作区域样式 */
186 | .sign-actions {
187 | display: flex;
188 | gap: 20px;
189 | margin-bottom: 25px;
190 | }
191 |
192 | .sign-card {
193 | flex: 1;
194 | background-color: #f9f9f9;
195 | border-radius: 8px;
196 | padding: 15px;
197 | text-align: center;
198 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
199 | transition: transform 0.3s, box-shadow 0.3s;
200 | }
201 |
202 | .sign-card:hover {
203 | transform: translateY(-3px);
204 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
205 | }
206 |
207 | .sign-card h3 {
208 | margin: 0 0 10px 0;
209 | font-size: 16px;
210 | color: #333;
211 | }
212 |
213 | .sign-status {
214 | font-size: 14px;
215 | margin-bottom: 8px;
216 | font-weight: 500;
217 | }
218 |
219 | .sign-status.success {
220 | color: #4caf50;
221 | }
222 |
223 | .sign-status.pending {
224 | color: #ff9800;
225 | }
226 |
227 | .sign-time {
228 | font-size: 20px;
229 | font-weight: 700;
230 | margin-bottom: 12px;
231 | color: #333;
232 | }
233 |
234 | .sign-button {
235 | padding: 8px 16px;
236 | background-color: #4caf50;
237 | color: white;
238 | border: none;
239 | border-radius: 4px;
240 | cursor: pointer;
241 | font-weight: 500;
242 | transition: background-color 0.3s;
243 | width: 100%;
244 | }
245 |
246 | .sign-button:hover {
247 | background-color: #388e3c;
248 | }
249 |
250 | .sign-button:disabled {
251 | background-color: #c8e6c9;
252 | cursor: not-allowed;
253 | }
254 |
255 | #morning-sign .sign-button {
256 | background-color: #ff9800;
257 | }
258 |
259 | #morning-sign .sign-button:hover {
260 | background-color: #f57c00;
261 | }
262 |
263 | #morning-sign .sign-button:disabled {
264 | background-color: #ffe0b2;
265 | }
266 |
267 | /* 签到记录样式 */
268 | .sign-records {
269 | background-color: #f9f9f9;
270 | border-radius: 8px;
271 | padding: 15px;
272 | }
273 |
274 | .sign-records h3 {
275 | margin: 0 0 15px 0;
276 | font-size: 16px;
277 | color: #333;
278 | }
279 |
280 | .sign-records-list {
281 | max-height: 200px;
282 | overflow-y: auto;
283 | }
284 |
285 | .record-item {
286 | display: flex;
287 | justify-content: space-between;
288 | padding: 10px;
289 | border-bottom: 1px solid #eee;
290 | }
291 |
292 | .record-item:last-child {
293 | border-bottom: none;
294 | }
295 |
296 | .record-type {
297 | font-weight: 500;
298 | }
299 |
300 | .record-time {
301 | color: #666;
302 | }
303 |
304 | .record-status {
305 | font-weight: 500;
306 | }
307 |
308 | .record-status.success {
309 | color: #4caf50;
310 | }
311 |
312 | .record-status.failure {
313 | color: #f44336;
314 | }
315 |
316 | .empty-records {
317 | text-align: center;
318 | padding: 20px;
319 | color: #9e9e9e;
320 | font-style: italic;
321 | }
322 |
323 | /* 消息面板样式 */
324 | .message-panel {
325 | background-color: #fff;
326 | border-radius: 8px;
327 | padding: 15px;
328 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
329 | }
330 |
331 | .message-content {
332 | padding: 10px;
333 | background-color: #e8f5e9;
334 | border-left: 4px solid #4caf50;
335 | color: #2e7d32;
336 | border-radius: 0 4px 4px 0;
337 | }
338 |
339 | .message-content.error {
340 | background-color: #ffebee;
341 | border-left-color: #f44336;
342 | color: #c62828;
343 | }
344 |
345 | /* 响应式布局 */
346 | @media (max-width: 768px) {
347 | .selection-panel {
348 | flex-direction: column;
349 | gap: 15px;
350 | padding: 15px;
351 | }
352 |
353 | .selection-group {
354 | min-width: 100%;
355 | margin-bottom: 5px;
356 | }
357 |
358 | .selection-actions {
359 | align-self: stretch;
360 | margin-top: 10px;
361 | }
362 |
363 | .action-button {
364 | width: 100%;
365 | }
366 |
367 | .sign-actions {
368 | flex-direction: column;
369 | gap: 15px;
370 | }
371 |
372 | .privilege-header {
373 | flex-direction: column;
374 | align-items: flex-start;
375 | gap: 10px;
376 | }
377 |
378 | .privilege-header h1 {
379 | font-size: 20px;
380 | }
381 |
382 | .user-basic-info {
383 | flex-direction: column;
384 | align-items: flex-start;
385 | text-align: center;
386 | }
387 |
388 | .user-avatar {
389 | margin: 0 auto 15px;
390 | }
391 |
392 | .user-details {
393 | width: 100%;
394 | text-align: center;
395 | }
396 | }
397 |
398 | @media (max-width: 480px) {
399 | .privilege-container {
400 | padding: 15px;
401 | }
402 |
403 | .selection-panel {
404 | padding: 12px;
405 | }
406 |
407 | .selection-group label {
408 | font-size: 14px;
409 | margin-bottom: 8px;
410 | }
411 |
412 | .selection-group select {
413 | padding: 10px 12px;
414 | font-size: 14px;
415 | }
416 |
417 | .action-button {
418 | padding: 10px 20px;
419 | font-size: 14px;
420 | }
421 | }
--------------------------------------------------------------------------------
/app/routes/index.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import Optional
3 |
4 | import httpx
5 | from fastapi import APIRouter, Request, Cookie
6 | from fastapi.responses import RedirectResponse
7 | from fastapi.templating import Jinja2Templates
8 |
9 | from app import logger
10 | from app.auth.dependencies import is_valid_open_id
11 | from app.routes.statistics import GetYueTjList
12 | from app.utils.host import build_api_url
13 | from config import settings
14 |
15 | # 创建路由器
16 | router = APIRouter()
17 |
18 | # 设置模板
19 | templates = Jinja2Templates(directory="app/static/templates")
20 |
21 | @router.get("/")
22 | async def root(request: Request, open_id: Optional[str] = Cookie(None)):
23 | """
24 | 首页路由 - 检查系统是否需要初始化,并根据用户状态显示适当内容
25 | """
26 |
27 | # 检查系统是否已完成设置配置
28 | if not settings.IS_INITIALIZED:
29 | return RedirectResponse(url="/setup")
30 |
31 | # 如果没有cookie,直接返回登录页面
32 | if not open_id:
33 | return templates.TemplateResponse("login.html", {"request": request})
34 |
35 | # 验证用户身份
36 | is_valid, user_info = is_valid_open_id(open_id)
37 |
38 | if not is_valid:
39 | return templates.TemplateResponse("login.html", {"request": request})
40 |
41 | # 检查是否是管理员用户
42 | if user_info.get("user_id") == "admin":
43 | # 管理员特殊处理
44 | return RedirectResponse(url="/admin/dashboard")
45 |
46 | # 普通用户,获取考勤信息
47 | try:
48 | now = datetime.datetime.now()
49 | headers = {'User-Agent': request.headers.get('User-Agent')}
50 | yue_tj_list = await GetYueTjList(headers, user_info.get("user_id"), str(now.year), str(now.month).zfill(2))
51 | is_workday = yue_tj_list[now.day - 1].get("isholiday") == 0
52 | if not is_workday:
53 | # 非工作日,不需要打卡,就不需要进行请求打卡数据了
54 | attendance_data = []
55 | show_sign_btn = {"show": False, "message": "今天是休息日,无需打卡"}
56 | else:
57 | # 工作日,需要打卡,需要进行请求打卡数据,显示签到按钮
58 | attendance_data = await get_attendance_info(request.headers.get('User-Agent'), user_info.get("user_id"))
59 | print(attendance_data)
60 | show_sign_btn = show_sign_button(attendance_data)
61 |
62 | # 登录成功,返回index.html
63 | return templates.TemplateResponse(
64 | "index.html",
65 | {
66 | "request": request,
67 | "user_info": user_info,
68 | "attendance_data": attendance_data,
69 | "is_workday": is_workday,
70 | "show_sign_button": show_sign_btn,
71 | "current_month": datetime.datetime.now().month,
72 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
73 | }
74 | )
75 | except Exception as e:
76 | # 处理异常,确保页面正常显示
77 | return templates.TemplateResponse(
78 | "index.html",
79 | {
80 | "request": request,
81 | "user_info": user_info,
82 | "error_message": f"获取考勤数据失败: {str(e)}",
83 | "current_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
84 | "show_sign_button": {"show": False, "message": "获取数据失败"}
85 | }
86 | )
87 |
88 |
89 | async def get_attendance_info(ua: str, user_id: int):
90 | """获取考勤信息"""
91 |
92 | def _format_record(_record):
93 | if not _record:
94 | return None
95 |
96 | _timestamp = int(_record['clockTime'].replace('/Date(', '').replace(')/', ''))
97 | clock_time = datetime.datetime.fromtimestamp(_timestamp / 1000)
98 |
99 | return {
100 | "type": "上班" if _record['clockType'] == 1 else "下班",
101 | "standardTime": "09:00" if _record['clockType'] == 1 else "17:00",
102 | "clockTime": clock_time.strftime("%H:%M:%S"),
103 | "location": _record['administratorChangesRemark'] or "未知地点"
104 | }
105 |
106 | try:
107 | # 构建API URL
108 | api_url = build_api_url('/AttendanceCard/GetAttCheckinoutList')
109 | headers = {'User-Agent': ua}
110 | params = {"AttType": "1", "UnitCode": "530114", "userid": user_id, "Mid": "134"}
111 |
112 | # 使用httpx发送GET请求
113 | async with httpx.AsyncClient(timeout=10.0) as client:
114 | response = await client.post(api_url, headers=headers, json=params)
115 |
116 | # 检查响应状态
117 | if response.status_code == 200:
118 | now = datetime.datetime.now()
119 | today = now.replace(hour=0, minute=0, second=0, microsecond=0)
120 |
121 | # 过滤出天的记录
122 | today_records = []
123 | for record in response.json():
124 | timestamp = int(record['clockTime'].replace('/Date(', '').replace(')/', ''))
125 | record_date = datetime.datetime.fromtimestamp(timestamp / 1000) # 转换
126 | if record_date.date() == today.date():
127 | today_records.append(record)
128 | # 分离上班下班记录
129 | clock_in = next((record for record in today_records if record['clockType'] == 1), None)
130 | clock_out = next((record for record in today_records if record['clockType'] == 2), None)
131 |
132 | # 检查是否要显示补卡提醒
133 | current_time = now.hour * 60 + now.minute
134 | work_end_time = 17 * 60 # 17:00
135 | need_reminder = current_time >= work_end_time and not clock_in
136 |
137 | return {
138 | "needReminder": need_reminder,
139 | "clockInRecord": _format_record(clock_in),
140 | "clockOutRecord": _format_record(clock_out)
141 | }
142 |
143 | except Exception as e:
144 | # 处理异常,返回空字典
145 | logger.error(f"获取考勤信息异常: {str(e)}")
146 | return {}
147 |
148 |
149 |
150 | async def check_is_workday(ua: str, user_id: int):
151 | """检查今天是否是工作日"""
152 | try:
153 | # 构建API URL
154 | now = datetime.datetime.now()
155 |
156 | api_url = build_api_url('/AttendanceCard/GetYueTjList')
157 | headers = {'User-Agent': ua}
158 | params = {"AttType": "1", "UnitCode": "530114", "userid": user_id, "Mid": "134"}
159 | params = {**params, "year": f"{now.year}年", "month": f"{str(now.month).zfill(2)}月"}
160 |
161 | # 使用httpx发送GET请求
162 | async with httpx.AsyncClient(timeout=10.0) as client:
163 | response = await client.get(api_url, headers={"User-Agent": ua})
164 |
165 | # 检查响应状态
166 | if response.status_code == 200:
167 | data = response.json()
168 |
169 | if data.get("code") == 200:
170 | work_day = data["data"]["workday"]
171 | return work_day == 1
172 | else:
173 | # 如果接口返回非200,默认为工作日
174 | return True
175 | else:
176 | # 如果请求失败,默认为工作日
177 | return True
178 | except Exception as e:
179 | # 处理异常,默认为工作日
180 | logger.error(f"检查工作日异常: {str(e)}")
181 | return True
182 |
183 |
184 | def show_sign_button(attendance_data: dict):
185 | """
186 | 根据考勤数据决定是否显示打卡按钮
187 | 返回格式: {"show": True/False, "message": "提示信息"}
188 | """
189 | result = {"show": False, "message": ""} # 默认不显示按钮
190 |
191 | # 获取当前时间
192 | now = datetime.datetime.now()
193 | current_hour = now.hour
194 | current_minute = now.minute
195 | current_time = current_hour * 60 + current_minute # 转换为分钟
196 |
197 | # 上午打卡时间范围:06:00:00-09:00:59
198 | morning_start_time = 6 * 60 # 06:00
199 | morning_end_time = 9 * 60 + 59 # 09:59
200 |
201 | # 下午打卡时间范围:17:00:00-23:59:59
202 | afternoon_start_time = 17 * 60 # 17:00
203 | afternoon_end_time = 23 * 60 + 59 # 23:59
204 |
205 | # 检查打卡记录
206 | clock_in_record = attendance_data.get("clockInRecord")
207 | clock_out_record = attendance_data.get("clockOutRecord")
208 |
209 | # 判断是否在上午打卡时间段
210 | if morning_start_time <= current_time <= morning_end_time:
211 | if not clock_in_record:
212 | result["show"] = True
213 | result["message"] = "上班打卡"
214 | else:
215 | result["show"] = False
216 | result["message"] = "您已完成上班打卡"
217 |
218 | # 判断是否在下午打卡时间段
219 | elif afternoon_start_time <= current_time <= afternoon_end_time:
220 | if not clock_out_record:
221 | # 检查是否有上班打卡记录,如果没有上班打卡记录,提示需要先补签上班
222 | if not clock_in_record:
223 | result["show"] = True
224 | result["message"] = "请您先补签上班打卡"
225 | else:
226 | result["show"] = True
227 | result["message"] = "下班打卡"
228 | else:
229 | result["show"] = False
230 | result["message"] = "您已完成下班打卡"
231 |
232 | # 不在打卡时间段
233 | else:
234 | result["show"] = False
235 | result["message"] = f"当前时间 {now.strftime('%H:%M')} 不在打卡时间范围内"
236 |
237 | # 特殊情况处理:如果系统提示需要补签
238 | if attendance_data.get("needReminder", False):
239 | if not clock_in_record:
240 | result["show"] = True
241 | result["message"] = "请您补签上班打卡"
242 |
243 | return result
--------------------------------------------------------------------------------