Compare commits

..

7 Commits

Author SHA1 Message Date
v6ole 1ccb116f80 chore: add .gitignore, clean tracked junk files 2026-05-29 08:33:35 +08:00
v6ole 2d5c5d4c0d Fix Casdoor login URL issue and enhance user experience. Updated organization name in environment variables, improved error handling for login failures, and added automatic redirection to Casdoor login page. Enhanced user interface with new styles for user display in the navigation bar. Updated logging to capture detailed user actions and application events. 2025-12-11 22:24:20 +08:00
v6ole 539a981567 Update Casdoor configuration and enhance user experience in the application. Changed CASDOOR_ENDPOINT and CASDOOR_REDIRECT_URI to use secure URLs. Improved login message to prompt users in Chinese. Added session cookie settings for cross-domain SSO. Cleaned up flash messages on successful login. Updated logging to reflect user actions and application startup events. 2025-12-10 16:51:40 +08:00
v6ole 0bb323bbff Enhance user role management and access control in the application. Added user management link in the navigation for admins. Updated login flow to redirect to Casdoor for authentication. Improved logging for user actions and unauthorized access attempts. Adjusted role checks across various views to include '管理员' and 'admin' for access permissions. 2025-12-10 15:53:17 +08:00
v6ole bb39314940 Implement Casdoor authentication integration, including login and callback routes. Update environment variables for Casdoor configuration. Modify login page to support Casdoor login option and enhance UI styles. Adjust uWSGI port settings and update application logging for user actions. 2025-12-10 15:08:04 +08:00
v6ole 0f41063688 Remove supervisor.log and update uWSGI configuration paths to reflect new directory structure; enhance filtering options in work order views and improve CSS styles for better UI responsiveness. 2025-12-10 12:00:50 +08:00
v6ole c7dc4d6c02 开始 2025-08-08 10:38:10 +08:00
76 changed files with 8281 additions and 164 deletions
+39 -162
View File
@@ -1,176 +1,53 @@
# ---> Python # Python
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *.pyo
*.pyc
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg dist/
*.egg build/
MANIFEST
# PyInstaller # Virtualenv
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/ venv/
ENV/ env/
env.bak/ .venv/
venv.bak/
# Spyder project settings # Environment
.spyderproject .env
.spyproject .env.*
!.env.example
"app/.env copy"
# Rope project settings # Logs
.ropeproject *.log
logs/
# mkdocs documentation # uWSGI
/site uwsgi.pid
uwsgi.sock
uwsgi.log
# mypy # IDE
.mypy_cache/ .idea/
.dmypy.json .vscode/
dmypy.json *.swp
*.swo
*~
# Pyre type checker # OS
.pyre/ .DS_Store
Thumbs.db
# pytype static type analyzer # Uploads (keep directory, ignore contents)
.pytype/ static/uploads/*
!static/uploads/.gitkeep
# Cython debug symbols # Supervisor
cython_debug/ *.conf
!app/supervisor.conf
# PyCharm # Backup
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can backup/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Captcha
app/static/captcha.png
-2
View File
@@ -1,2 +0,0 @@
# 4an-workorder
Executable
+45
View File
@@ -0,0 +1,45 @@
# 应用密钥
SECRET_KEY=kU/W5y3uFbWuK1x+RTTOPgLp38yM6Z7d
# 数据库连接
SQLALCHEMY_DATABASE_URI=mysql+pymysql://4an:GtbJp6Ai5azntnTB@10.10.10.14/4an
# 量子密信消息Webhook
LZMX_WEBHOOK_URL=http://imtwo.zdxlz.com/im-external/v1/webhook/send?key=ef1dc605827f425e96491ce5725b5274
# 文件上传目录
UPLOAD_FOLDER=static/uploads
# 1key110数据库配置
KEY110_DB_HOST=10.10.10.14
KEY110_DB_USER=1key110
KEY110_DB_PASSWORD=KzdKtf7KfkXe7iCN
KEY110_DB_DATABASE=1key110
# uWSGI基本配置
UWSGI_HTTP=0.0.0.0:18019
UWSGI_PROCESSES=4
UWSGI_THREADS=2
UWSGI_LOG_FILE=/home/v6ole/pyproject/4an/uwsgi.log
UWSGI_VIRTUALENV=/home/v6ole/pyproject/venv/
UWSGI_CHDIR=/home/v6ole/pyproject/4an/app
UWSGI_PID_FILE=/home/v6ole/pyproject/4an/uwsgi.pid
UWSGI_SOCKET=/home/v6ole/pyproject/4an/uwsgi.sock
# uWSGI性能优化配置
UWSGI_MASTER=true
UWSGI_HARAKIRI=30
UWSGI_MAX_REQUESTS=1000
UWSGI_BUFFER_SIZE=32768
UWSGI_DISABLE_LOGGING=true
UWSGI_MEMORY_REPORT=true
UWSGI_ENABLE_THREADS=true
# casdoor配置
CASDOOR_ENDPOINT=https://casdoor.dhdx.fun
CASDOOR_CLIENT_ID=8f323670e073612794ef
CASDOOR_CLIENT_SECRET=bc37d89f9220fe5a46c17125c731d2daf22f1299
CASDOOR_REDIRECT_URI=https://zd.dhdx.fun/callback
CASDOOR_ORGANIZATION_NAME=dahua
CASDOOR_APPLICATION_NAME=4an
PROJECT_NAME=4an
Executable
+106
View File
@@ -0,0 +1,106 @@
from flask import Flask, redirect, url_for
from flask_login import LoginManager
from flask_migrate import Migrate
from scripts.models import db, User, Device, WorkOrder, WorkOrderHandling, ManualWorkOrder
from config import Config
from views.auth import init_auth_routes
from views.work_order import init_work_order_routes
from views.statistics import init_statistics_routes
from views.history import init_history_routes
from views.manual import init_manual_routes
from views.device import init_device_routes
from views.about import init_about_routes
from views.dashboard import init_dashboard_routes
from views.user import init_user_routes
import logging
from logging.handlers import RotatingFileHandler
import os
from utils.error_handlers import ErrorHandler
from utils.db_utils import retry_database_connection
def create_app():
# 创建应用实例
app = Flask(__name__)
app.config.from_object(Config)
# 配置日志记录
if not os.path.exists('logs'):
os.makedirs('logs')
# 创建日志处理器,设置最大文件大小为10MB,保留5个备份文件
file_handler = RotatingFileHandler(
'logs/app.log',
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
# 设置日志格式
formatter = logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s',
'%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(formatter)
# 设置日志级别
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('应用启动')
# 初始化数据库
db.init_app(app)
# 确保数据库连接成功后再继续启动
app.logger.info("正在连接数据库...")
if not retry_database_connection(db, app):
raise RuntimeError("无法连接到数据库,应用启动失败")
migrate = Migrate(app, db)
# 初始化异常处理器
error_handler = ErrorHandler(app)
# 初始化登录管理器
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'
# 默认未登录提示改为中文
login_manager.login_message = '请先登录以访问此页面'
@login_manager.user_loader
def load_user(user_id):
try:
return db.session.get(User, user_id)
except Exception as e:
app.logger.error(f"加载用户时出错: {str(e)}")
return None
# 注册路由
@app.route('/')
def index():
return redirect(url_for('dashboard'))
# 初始化各模块路由
init_auth_routes(app)
init_work_order_routes(app)
init_statistics_routes(app)
init_history_routes(app)
init_manual_routes(app)
init_device_routes(app)
init_about_routes(app)
init_dashboard_routes(app)
init_user_routes(app)
return app
app = create_app()
if __name__ == '__main__':
with app.app_context():
try:
db.create_all()
app.logger.info("数据库表创建成功")
except Exception as e:
app.logger.error(f"创建数据库表时出错: {str(e)}")
app.run(host='0.0.0.0', port=18019, debug=True)
Executable
+57
View File
@@ -0,0 +1,57 @@
import os
from pathlib import Path
from dotenv import load_dotenv
# 尽量兼容多种 .env 放置方式
BASE_DIR = Path(__file__).resolve().parent.parent # /home/.../4an
# 1. 优先加载项目根目录 .env
load_dotenv(BASE_DIR / '.env')
# 2. 兼容放在 app 目录下的 .env
load_dotenv(BASE_DIR / 'app' / '.env')
# 3. 再加载一次默认搜索路径(当前工作目录及其父级)
load_dotenv()
class Config:
"""配置类,支持从环境变量获取配置
环境变量优先级高于默认值,可在系统环境变量中设置这些参数
"""
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(24))
# 优先读取 SQLALCHEMY_DATABASE_URI,兼容旧配置;否则回退到 DATABASE_URI
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') or os.environ.get('DATABASE_URI')
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True, # 每次取连接前验证是否可用
'pool_recycle': 1800, # 连接空闲 30 分钟后自动回收重建
'pool_timeout': 10, # 等待连接池超时(秒)
'connect_args': {
'connect_timeout': 5, # 连接 MySQL 超时
'read_timeout': 15, # 读取超时
'write_timeout': 15, # 写入超时
},
}
LZMX_WEBHOOK_URL = os.environ.get('LZMX_WEBHOOK_URL')
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'static/uploads')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
# 添加1key110数据库配置
KEY110_DB = {
'host': os.environ.get('KEY110_DB_HOST'),
'user': os.environ.get('KEY110_DB_USER'),
'password': os.environ.get('KEY110_DB_PASSWORD'),
'database': os.environ.get('KEY110_DB_DATABASE')
}
# Casdoor 配置
CASDOOR_ENDPOINT = os.environ.get('CASDOOR_ENDPOINT', 'http://localhost:8000')
CASDOOR_CLIENT_ID = os.environ.get('CASDOOR_CLIENT_ID')
CASDOOR_CLIENT_SECRET = os.environ.get('CASDOOR_CLIENT_SECRET')
CASDOOR_ORGANIZATION_NAME = os.environ.get('CASDOOR_ORGANIZATION_NAME', 'built-in')
CASDOOR_APPLICATION_NAME = os.environ.get('CASDOOR_APPLICATION_NAME', 'app-built-in')
CASDOOR_REDIRECT_URI = os.environ.get('CASDOOR_REDIRECT_URI', 'http://localhost:18061/callback')
# 项目名称(用于与 Casdoor 用户标签匹配权限)
PROJECT_NAME = os.environ.get('PROJECT_NAME')
# 会话 Cookie 配置(跨域 SSO 时建议设置)
SESSION_COOKIE_SAMESITE = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true'
+90
View File
@@ -0,0 +1,90 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime, timezone
db = SQLAlchemy()
# 用户表
class User(UserMixin, db.Model):
__tablename__ = 'user'
phone = db.Column(db.String(20), primary_key=True, comment="手机号码")
name = db.Column(db.String(50), comment="姓名")
branch = db.Column(db.String(50), comment="所属支局")
role = db.Column(db.String(20), comment="角色")
def get_id(self):
return self.phone # 使用手机号码作为唯一标识符
# 设备表
class Device(db.Model):
__tablename__ = 'device'
device_id = db.Column(db.String(100), primary_key=True, comment="设备编号")
town = db.Column(db.String(50), comment="所属乡镇")
branch = db.Column(db.String(50), comment="所属支局")
unit_name = db.Column(db.String(100), comment="单位名称")
project_type = db.Column(db.String(50), comment="项目类型")
account = db.Column(db.String(20), comment="关联宽带账号")
device_type = db.Column(db.String(50), comment="设备类型")
puid = db.Column(db.String(100), comment="通道标识") # 添加 puid 字段
remark = db.Column(db.String(200), comment="备注")
in_use = db.Column(db.Boolean, default=True, comment="是否在用")
is_normal = db.Column(db.Boolean, default=True, comment="是否正常")
# 工单表
class WorkOrder(db.Model):
__tablename__ = 'work_order'
order_id = db.Column(db.String(20), primary_key=True, comment="工单号(日期+编号)")
device_id = db.Column(db.String(100), db.ForeignKey('device.device_id'), comment="设备编号") # 从20改为100
dispatch_time = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), comment="派单时间")
status = db.Column(db.String(20), default='待处理', comment="工单状态")
branch = db.Column(db.String(50), comment="所属支局")
# 与设备表的关系
device = db.relationship('Device', backref='work_orders')
# 处理工单信息表
class WorkOrderHandling(db.Model):
__tablename__ = 'work_order_handling'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
order_id = db.Column(db.String(20), db.ForeignKey('work_order.order_id'), comment="工单号")
device_id = db.Column(db.String(100), db.ForeignKey('device.device_id'), comment="设备编号") # 从20改为100
branch = db.Column(db.String(50), comment="所属支局")
restore_time = db.Column(db.DateTime, comment="恢复时间")
fault_type = db.Column(db.String(100), comment="故障类型")
contact_person = db.Column(db.String(50), comment="故障对接人")
contact_phone = db.Column(db.String(20), comment="联系电话")
handler = db.Column(db.String(50), comment="故障处理人")
handle_description = db.Column(db.String(500), comment="处理描述")
photo_paths = db.Column(db.String(500), comment="现场图片路径")
distance_from_branch = db.Column(db.Float, comment="距离支局公里数")
remark = db.Column(db.String(500), comment="备注")
# 与工单表的关系
work_order = db.relationship('WorkOrder', backref='handling_info')
# 手工故障单表
class ManualWorkOrder(db.Model):
__tablename__ = 'manual_work_order'
order_id = db.Column(db.String(20), primary_key=True, comment="工单号")
project_type = db.Column(db.String(50), comment="项目类型")
device_id = db.Column(db.String(100), comment="故障设备")
device_location = db.Column(db.String(200), comment="设备位置")
fault_time = db.Column(db.DateTime, comment="故障时间")
handler = db.Column(db.String(50), comment="故障处理人")
fault_type = db.Column(db.String(100), comment="故障类型")
handle_description = db.Column(db.String(500), comment="处理描述")
photo_paths = db.Column(db.String(500), comment="现场图片路径")
contact_person = db.Column(db.String(50), comment="故障对接人")
contact_phone = db.Column(db.String(20), comment="联系电话")
distance_from_branch = db.Column(db.Float, comment="距离支局公里数")
remark = db.Column(db.String(500), comment="备注")
create_time = db.Column(db.DateTime, default=datetime.now, comment="创建时间")
branch = db.Column(db.String(50), comment="所属支局")
class Channel(db.Model):
__tablename__ = 'channels'
puid = db.Column(db.String(50), primary_key=True)
pu_name = db.Column(db.String(200))
status = db.Column(db.String(20))
update_time = db.Column(db.String(50))
def __repr__(self):
return f'<Channel {self.puid}>'
+269
View File
@@ -0,0 +1,269 @@
import requests
import os
import time
from datetime import datetime
import hashlib
import uuid
from sqlalchemy import select, create_engine, Table, Column, String, MetaData, text
from sqlalchemy.dialects.mysql import insert
class LoginClient:
def __init__(self):
self.base_url = "https://116.10.244.81:28001"
self.session = requests.Session()
# 禁用SSL验证警告
requests.packages.urllib3.disable_warnings()
def get_captcha(self):
"""获取验证码图片"""
# 生成随机cid
cid = uuid.uuid4().hex.encode()
cid = hashlib.sha256(cid).hexdigest()
url = f"{self.base_url}/api/vss-auth-center/v1/captcha"
headers = {
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
"Referer": f"{self.base_url}/vss-em-iui/"
}
params = {"cid": cid}
response = self.session.get(url, headers=headers, params=params, verify=False)
# 缓存验证码到本地
captcha_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'static', 'captcha.png')
with open(captcha_path, "wb") as f:
f.write(response.content)
return cid
def login(self, account, password, validcode, cid):
"""执行登录"""
url = f"{self.base_url}/api/vss-auth-center/v1/user/login"
headers = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
"Referer": f"{self.base_url}/vss-em-iui/",
"language": "zh-CN"
}
params = {
"cutype": "12"
}
data = {
"account": account,
"password": password,
"validcode": validcode,
"cid": cid
}
response = self.session.post(
url,
headers=headers,
params=params,
json=data,
verify=False
)
return response.json()
def get_online_info(self, token, page=1, page_size=1500):
"""获取在线信息"""
url = f"{self.base_url}/api/vss-user-business/v1/pus/queryPage/4109"
headers = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"Authoration": token,
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/json",
"Referer": f"{self.base_url}/vss-em-iui/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
"language": "zh-CN"
}
# 生成防伪标识
forgery_defense = uuid.uuid4().hex
params = {
"page_size": page_size,
"page": page,
"pu_name": "",
"puid": "",
"protocol": "",
"nodeid": "",
"popunitid": "",
"status": "",
"forgerydefense": forgery_defense,
"csrftoken": forgery_defense
}
response = self.session.get(
url,
headers=headers,
params=params,
verify=False
)
return response.json()
def get_existing_channels(engine, channels_table):
"""获取数据库中现有的通道状态"""
with engine.connect() as conn:
query = select(channels_table.c.puid, channels_table.c.status)
result = conn.execute(query)
return {row.puid: row.status for row in result}
def update_device_status(engine):
"""更新设备状态"""
with engine.connect() as conn:
# 获取channels表和device表的数据
channels_query = """
SELECT puid, status, pu_name
FROM channels
"""
channels_result = conn.execute(text(channels_query))
channels_data = {row.puid: {'status': row.status, 'pu_name': row.pu_name} for row in channels_result}
# 获取device表中的puid
device_query = """
SELECT puid
FROM device
"""
device_result = conn.execute(text(device_query))
# 更新设备状态
updates = []
for row in device_result:
puid = row.puid
if puid in channels_data:
# 更新在线状态
is_normal = 1 if channels_data[puid]['status'] == 'online' else 0
# 检查使用状态
pu_name = channels_data[puid]['pu_name']
in_use = 0 if any(keyword in pu_name for keyword in ['无学生', '丢失']) else 1
# 构建更新语句
update_query = """
UPDATE device
SET is_normal = :is_normal, in_use = :in_use
WHERE puid = :puid
"""
updates.append({
'puid': puid,
'is_normal': is_normal,
'in_use': in_use
})
if updates:
update_stmt = text("""
UPDATE device
SET is_normal = :is_normal, in_use = :in_use
WHERE puid = :puid
""")
conn.execute(update_stmt, updates)
conn.commit()
print(f"成功更新 {len(updates)} 个设备状态")
def main():
client = LoginClient()
while True:
cid = client.get_captcha()
print("验证码已保存为captcha.png")
validcode = input("请查看captcha.png并输入验证码: ")
password = "Root1234#"
hashed_password = hashlib.sha256(password.encode()).hexdigest()
result = client.login(
account="DHDX001",
password=hashed_password,
validcode=validcode,
cid=cid
)
if result.get("result") == 0:
token = result.get("token")
online_info = client.get_online_info(token)
if online_info.get("result") == 0:
rows = online_info.get("rows", [])
filtered_rows = [
row for row in rows
if "大化4+N摄像头" not in row.get("pu_name", "")
and "大化幼儿园监控" not in row.get("pu_name", "")
]
engine = create_engine('mysql+pymysql://4an:GtbJp6Ai5azntnTB@10.10.10.253/4an')
metadata = MetaData()
channels = Table(
'channels', metadata,
Column('puid', String(50), primary_key=True),
Column('pu_name', String(200)),
Column('status', String(20)),
Column('update_time', String(50))
)
metadata.create_all(engine)
existing_channels = get_existing_channels(engine, channels)
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
update_data = []
for row in filtered_rows:
puid = row.get('puid')
current_status = row.get('status')
if puid not in existing_channels or existing_channels[puid] != current_status:
update_data.append({
'puid': puid,
'pu_name': row.get('pu_name'),
'status': current_status,
'update_time': current_time
})
# 执行更新
if update_data:
with engine.connect() as conn:
stmt = insert(channels).on_duplicate_key_update(
pu_name=channels.c.pu_name,
status=channels.c.status,
update_time=channels.c.update_time
)
conn.execute(stmt, update_data)
conn.commit()
print(f"\n成功更新 {len(update_data)} 条记录")
else:
print("\n没有需要更新的记录")
# 更新设备状态
print("\n开始更新设备状态...")
update_device_status(engine)
return
else:
print("获取通道信息失败!")
print(f"错误信息: {online_info.get('err_msg', '未知错误')}")
return # 获取信息失败也退出程序
else:
print("登录失败!")
print(result)
if result.get("result") == 12: # 验证码错误
retry = input("验证码错误,是否重新获取验证码?(y/n): ")
if retry.lower() != 'y':
return
else:
return # 其他错误直接退出
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
from datetime import datetime, timedelta
from collections import defaultdict
import sys
import os
# 添加项目根目录到 Python 路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.models import db, Device, WorkOrder, WorkOrderHandling, ManualWorkOrder
from utils.lzmx import send_weekly_stats_message
from flask import Flask
# 创建一个简单的 Flask 应用
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://4an:GtbJp6Ai5azntnTB@10.10.10.253/4an'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
LZMX_WEBHOOK_URL = "http://imtwo.zdxlz.com/im-external/v1/webhook/send?key=ef1dc605827f425e96491ce5725b5274"
# 初始化数据库
db.init_app(app)
def get_reward_by_distance(project_type, distance):
if project_type == '校园安防"4+N"项目':
if distance <= 5: return 20
elif distance <= 30: return 40
elif distance <= 50: return 50
else: return 60
elif project_type == '综治视联网':
if distance <= 10: return 30
elif distance <= 30: return 40
elif distance <= 50: return 50
else: return 60
elif project_type == '教育城域网':
if distance <= 5: return 20
elif distance <= 30: return 30
elif distance <= 50: return 40
else: return 50
elif project_type == '其他':
return 30
return 0
def weekly_stats():
with app.app_context():
# 计算本周的开始和结束时间
now = datetime.now()
week_start = now - timedelta(days=now.weekday())
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = now
# 统计工单数据
total_orders = WorkOrder.query.filter(
WorkOrder.dispatch_time >= week_start,
WorkOrder.dispatch_time < week_end
).count()
completed_orders = WorkOrder.query.filter(
WorkOrder.dispatch_time >= week_start,
WorkOrder.dispatch_time < week_end,
WorkOrder.status == '已处理'
).count()
manual_orders = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= week_start,
ManualWorkOrder.create_time < week_end
).count()
# 获取处理人统计
handler_stats = defaultdict(float)
# 统计普通工单奖励
handlings = WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= week_start,
WorkOrderHandling.restore_time < week_end
).all()
for handling in handlings:
device = db.session.get(Device, handling.device_id) # 更新为新的方法
if device:
distance = float(handling.distance_from_branch)
reward = get_reward_by_distance(device.project_type, distance)
handler_stats[handling.handler] += reward
# 统计手工故障单奖励
manual_orders_list = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= week_start,
ManualWorkOrder.create_time < week_end
).all()
for order in manual_orders_list:
distance = float(order.distance_from_branch)
reward = get_reward_by_distance(order.project_type, distance)
handler_stats[order.handler] += reward
# 获取前三名
top_handlers = sorted(handler_stats.items(), key=lambda x: x[1], reverse=True)[:3]
# 准备统计数据
stats = {
'total_orders': total_orders,
'completed_orders': completed_orders,
'pending_orders': total_orders - completed_orders,
'manual_orders': manual_orders,
'top_handlers': top_handlers
}
# 发送统计消息
send_weekly_stats_message(LZMX_WEBHOOK_URL, stats)
if __name__ == '__main__':
weekly_stats()
+121
View File
@@ -0,0 +1,121 @@
.quick-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin: 20px 0;
list-style: none;
padding: 0;
}
.quick-link-item {
background: white;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
overflow: hidden;
}
.quick-link-item:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.quick-link {
display: block;
padding: 16px 20px;
color: #2c3e50;
text-decoration: none;
position: relative;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
}
.quick-link:hover {
color: #1a73e8;
}
.quick-link::after {
content: '→';
position: absolute;
right: 20px;
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s ease;
}
.quick-link:hover::after {
opacity: 1;
transform: translateX(0);
}
.about-container {
max-width: 800px;
margin: 30px auto;
padding: 40px;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
.about-header {
text-align: center;
margin-bottom: 40px;
}
.about-header h1 {
color: #2c3e50;
font-size: 2.2em;
margin-bottom: 15px;
font-weight: 600;
}
.about-content {
line-height: 1.8;
}
.about-section {
margin-bottom: 35px;
}
.about-section h2 {
color: #34495e;
font-size: 1.5em;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.feature-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin: 20px 0;
}
.feature-item {
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
transition: transform 0.2s;
}
.feature-item:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
}
.dev-info {
background: #f8f9fa;
padding: 25px;
border-radius: 12px;
margin-top: 30px;
}
.dev-info p {
margin: 10px 0;
}
.footer {
margin-top: 40px;
text-align: center;
color: #7f8c8d;
font-size: 0.9em;
}
+7
View File
File diff suppressed because one or more lines are too long
+315
View File
@@ -0,0 +1,315 @@
/* 仪表盘容器 */
.dashboard_container {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
/* 仪表盘标题 */
.dashboard_title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #e9ecef;
}
/* 仪表盘区块 */
.dashboard_section {
margin-bottom: 30px;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
/* 区块标题 */
.section_title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
}
/* 图表容器 */
.chart_container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
/* 图表包装器 */
.chart_wrapper {
flex: 1;
min-width: 300px;
height: 300px;
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
/* 卡片网格布局 */
.card_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
/* 仪表盘卡片 */
.dashboard_card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.dashboard_card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
/* 卡片头部 */
.card_header {
padding: 15px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
/* 卡片标题 */
.card_title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
/* 卡片内容 */
.card_content {
padding: 15px;
}
/* 进度条容器 */
.progress_container {
height: 24px;
background: #e9ecef;
border-radius: 12px;
margin-bottom: 15px;
overflow: hidden;
}
/* 进度条 */
.progress_bar {
height: 100%;
background: linear-gradient(90deg, #0061f2 0%, #00a3ff 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.5s ease;
min-width: 30px;
}
/* 进度条文本 */
.progress_text {
color: white;
font-weight: 600;
font-size: 14px;
}
/* 统计详情 */
.stats_details {
display: flex;
justify-content: space-between;
}
/* 统计项 */
.stats_item {
display: flex;
flex-direction: column;
align-items: center;
}
/* 统计标签 */
.stats_label {
font-size: 12px;
color: #6c757d;
margin-bottom: 5px;
}
/* 统计值 */
.stats_value {
font-size: 16px;
font-weight: 600;
color: #333;
}
/* 表格容器 */
.table_container {
overflow-x: auto;
margin-top: 10px;
}
/* 仪表盘表格 */
.dashboard_table {
width: 100%;
border-collapse: collapse;
}
.dashboard_table th,
.dashboard_table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.dashboard_table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.dashboard_table tr:hover {
background: #f8f9fa;
}
/* 支局单元格 */
.branch_cell {
font-weight: 600;
background: #f8f9fa;
}
/* 状态标签 */
.status_badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.status_badge.good {
background: #d4edda;
color: #155724;
}
.status_badge.normal {
background: #fff3cd;
color: #856404;
}
.status_badge.bad {
background: #f8d7da;
color: #721c24;
}
/* 手风琴容器 */
.accordion_container {
margin-top: 10px;
}
/* 手风琴项 */
.accordion_item {
margin-bottom: 10px;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
}
/* 手风琴头部 */
.accordion_header {
padding: 15px;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background 0.2s;
}
.accordion_header:hover {
background: #e9ecef;
}
/* 手风琴标题 */
.accordion_title {
font-size: 16px;
font-weight: 600;
color: #333;
}
/* 手风琴摘要 */
.accordion_summary {
display: flex;
align-items: center;
gap: 15px;
}
/* 手风琴图标 */
.accordion_icon {
font-size: 12px;
color: #6c757d;
transition: transform 0.2s;
}
/* 手风琴内容 */
.accordion_content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
/* 详情表格 */
.detail_table {
width: 100%;
border-collapse: collapse;
}
.detail_table th,
.detail_table td {
padding: 10px 15px;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.detail_table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.detail_table tr:hover {
background: #f8f9fa;
}
/* 响应式调整 */
@media (max-width: 768px) {
.card_grid {
grid-template-columns: 1fr;
}
.dashboard_table {
min-width: 600px;
}
.accordion_summary {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.chart_wrapper {
min-width: 100%;
height: 250px;
}
.chart_container {
flex-direction: column;
}
}
+475
View File
@@ -0,0 +1,475 @@
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
width: 300px;
text-align: center;
}
.modal-buttons {
margin-top: 15px;
}
.modal-buttons button {
margin: 0 10px;
padding: 5px 15px;
}
/* 备注编辑样式 */
.remark-display {
cursor: pointer;
color: #666;
transition: color 0.2s;
min-height: 20px;
display: inline-block;
min-width: 80px;
}
.remark-display:hover {
color: #007bff;
}
.remark-input {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 120px;
transition: border-color 0.2s;
}
.remark-input:focus {
outline: none;
border-color: #007bff;
}
/* 模态框基础样式 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal-content {
position: relative;
background-color: #fff;
margin: 15vh auto;
padding: 25px;
width: 90%;
max-width: 500px;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.5em;
font-weight: 600;
}
.modal-content p {
margin: 15px 0;
color: #666;
line-height: 1.5;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 25px;
}
.modal-buttons button {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.modal-buttons button:first-child {
background-color: #4CAF50;
color: white;
}
.modal-buttons button:last-child {
background-color: #f5f5f5;
color: #333;
}
.modal-buttons button:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 验证码模态框特殊样式 */
#captchaModal .modal-content {
text-align: center;
}
#captchaImage {
margin: 15px 0;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
#captchaInput {
width: 200px;
padding: 8px 12px;
margin: 15px 0;
border: 2px solid #eee;
border-radius: 6px;
font-size: 16px;
text-align: center;
transition: border-color 0.2s ease;
}
#captchaInput:focus {
outline: none;
border-color: #4CAF50;
}
/* 结果显示样式 */
#updatedCount {
font-weight: bold;
color: #4CAF50;
}
#resultMessage {
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
text-align: center;
}
.dispatch_filter_group {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: nowrap;
gap: 10px;
}
.dispatch_search_form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
max-width: 1000px;
width: 100%;
}
.dispatch_action_group {
display: flex;
gap: 10px;
}
.dispatch_action_bar {
display: flex;
gap: 12px;
justify-content: center;
align-items: center;
width: 100%;
}
.dispatch_btn {
min-width: 100px;
padding: 8px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.dispatch_btn_primary {
background-color: #007bff;
color: white;
}
.dispatch_btn_primary:hover {
background-color: #5677fc;
}
.dispatch_btn_secondary_qx {
background-color: #607d8b;
color: white;
}
.dispatch_btn_secondary_qx:hover {
background-color: #b0bec5;
}
.dispatch_btn_secondary_fx {
background-color: #9e9e9e;
color: white;
}
.dispatch_btn_secondary_fx:hover {
background-color: #e0e0e0;
}
.dispatch_btn_secondary_bj {
background-color: #ff5722;
color: white;
}
.dispatch_btn_secondary_bj:hover {
background-color: #ff8a65;
}
.dispatch_btn_secondary_sxt {
background-color: #259b24;
color: white;
}
.dispatch_btn_secondary_sxt:hover {
background-color: #72d572;
}
.dispatch_filter {
position: sticky;
top: 0;
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 10px;
z-index: 2;
display: flex;
justify-content: center;
}
.dispatch_search {
flex: 1;
min-width: 150px;
max-width: 250px;
padding: 8px 15px;
border: 1px solid #ddd;
border-radius: 4px;
}
.dispatch_btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.dispatch_btn_secondary {
background: #6c757d;
color: white;
margin-left: 10px;
}
.dispatch_btn_secondary:hover {
background: #545b62;
}
.dispatch_table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
z-index: 1;
}
.dispatch_table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
}
.dispatch_table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
color: #495057;
}
.dispatch_table tr:hover {
background-color: #f5f5f5;
}
.dispatch_checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.dispatch_pagination {
margin-top: 10px;
text-align: center;
clear: both;
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
/* 移动端适配 */
@media (max-width: 991.98px) {
.dispatch_search_form {
flex-direction: column;
gap: 12px;
width: 100%;
}
.dispatch_action_group {
width: 100%;
}
.dispatch_search_form .dispatch_btn,
.dispatch_action_group .dispatch_btn {
flex: 1;
}
.dispatch_search {
width: 100%;
max-width: 100%;
margin: 0;
}
.dispatch_filter {
position: static; /* 移除固定定位 */
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.dispatch_filter form {
display: flex;
flex-direction: column;
gap: 10px;
}
.dispatch_filter_group {
flex-direction: column;
gap: 15px;
}
.dispatch_search {
width: 100%;
margin: 0;
}
.dispatch_action_bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
}
.dispatch_btn {
min-width: unset;
width: 100%;
padding: 12px 15px;
font-size: 14px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.dispatch_btn_primary {
grid-column: 1 / -1;
}
.dispatch_table {
display: block;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.dispatch_table th,
.dispatch_table td {
min-width: 120px;
padding: 8px;
}
.dispatch_table th:first-child,
.dispatch_table td:first-child {
position: sticky;
left: 0;
background: white;
z-index: 1;
min-width: 60px;
}
.dispatch_checkbox {
width: 20px;
height: 20px;
}
.dispatch_pagination {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
margin-top: 15px;
}
.dispatch_page_link {
white-space: nowrap;
padding: 8px 15px;
margin: 0;
}
.dispatch_page_info {
width: 100%;
text-align: center;
margin: 10px 0;
}
}
+627
View File
@@ -0,0 +1,627 @@
/* 筛选区域样式 */
.receive_filter {
margin: 20px 0;
padding: 20px;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
border: 1px solid #e2e8f0;
}
.filter_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid #e2e8f0;
}
.filter_title {
margin: 0;
font-size: 1.2em;
color: #1e293b;
font-weight: 600;
display: flex;
align-items: center;
}
.filter_title::before {
content: "🔍";
margin-right: 8px;
font-size: 1.1em;
}
.filter_actions {
display: flex;
gap: 10px;
}
.filter_form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
align-items: end;
}
.filter_group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter_label {
font-size: 0.9em;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.filter_select {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
background: white;
color: #374151;
outline: none;
transition: all 0.3s ease;
cursor: pointer;
}
.filter_select:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.filter_select:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.receive_btn_clear {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.9em;
text-decoration: none;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.receive_btn_clear::before {
content: "✕";
font-size: 0.8em;
}
.receive_btn_clear:hover {
background: linear-gradient(135deg, #d97706, #b45309);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
text-decoration: none;
color: white;
}
.receive_btn_primary {
background: linear-gradient(135deg, #4f46e5, #3b82f6);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: center;
}
.receive_btn_primary::before {
content: "✓";
font-size: 0.9em;
}
.receive_btn_primary:hover {
background: linear-gradient(135deg, #3b82f6, #2563eb);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(79, 70, 229, 0.3);
}
.receive_btn_warning {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.9em;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.receive_btn_warning::before {
content: "⚠";
font-size: 0.9em;
}
.receive_btn_warning:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
}
/* 统计卡片网格布局 */
.stats_section {
margin: 20px 0;
}
.stats_section_title {
font-size: 1.1em;
color: #374151;
margin-bottom: 15px;
font-weight: 600;
}
.stats_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stats_card {
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
padding: 12px 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: all 0.3s ease;
border: 2px solid transparent;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.stats_card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: inherit;
}
.stats_card.active {
border-color: currentColor;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stats_card_label {
font-size: 0.9em;
color: #6b7280;
margin-bottom: 4px;
}
.stats_card_value {
font-size: 1.4em;
font-weight: bold;
color: #1f2937;
}
/* 设备类型颜色 */
.stats_card.device-type-摄像头 {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
border-color: #3b82f6;
}
.stats_card.device-type-摄像头 .stats_card_value {
color: #1d4ed8;
}
.stats_card.device-type-一键报警 {
background: linear-gradient(135deg, #fed7aa, #fdba74);
border-color: #f59e0b;
}
.stats_card.device-type-一键报警 .stats_card_value {
color: #d97706;
}
.stats_card.device-type-紫色 {
background: linear-gradient(135deg, #e9d5ff, #c4b5fd);
border-color: #8b5cf6;
}
.stats_card.device-type-紫色 .stats_card_value {
color: #7c3aed;
}
.stats_card.device-type-绿色 {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
border-color: #22c55e;
}
.stats_card.device-type-绿色 .stats_card_value {
color: #16a34a;
}
.stats_card.device-type-灰色 {
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
border-color: #6b7280;
}
.stats_card.device-type-灰色 .stats_card_value {
color: #4b5563;
}
/* 项目类型颜色 */
.stats_card[class*="project-type-"] {
background: linear-gradient(135deg, #fef3c7, #fde68a);
border-color: #f59e0b;
}
.stats_card[class*="project-type-"] .stats_card_value {
color: #d97706;
}
/* 统计卡片样式 */
.receive_stats {
margin: 20px 0;
padding: 20px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.stats_item {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
border-radius: 8px;
color: white;
}
.stats_label {
font-size: 1.1em;
opacity: 0.9;
}
.stats_value {
font-size: 1.8em;
font-weight: bold;
margin-left: auto;
}
.receive_btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
}
.receive_btn_warning {
background-color: #ff9800;
color: white;
}
.receive_btn_warning:hover {
background-color: #ffc772;
color: white;
}
.receive_btn_secondary {
background-color: #008cff;
color: white;
text-decoration: none;
text-align: center;
}
.receive_btn_secondary:hover {
background-color: #43abff;
text-decoration: none;
color: white;
}
.receive_btn_primary {
background-color: #4CAF50;
color: white;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal-content {
background-color: #fff;
margin: 15vh auto;
padding: 25px;
width: 90%;
max-width: 400px;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
animation: modalSlideIn 0.3s ease-out;
}
.modal-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
margin: 20px 0;
}
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background-position: center;
background-repeat: no-repeat;
background-size: 24px;
}
.modal-icon.warning {
background-color: #fff3e0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ff5722' d='M12 2L1 21h22M12 6l7.53 13H4.47M11 10v4h2v-4m-2 6v2h2v-2'/%3E%3C/svg%3E");
}
.modal-icon.info {
background-color: #e8f5e9;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%234CAF50' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z'/%3E%3C/svg%3E");
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
@keyframes modalSlideIn {
from {
transform: translateY(-30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 分页样式 */
.receive_pagination {
margin: 25px 0;
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
}
.page_link {
padding: 8px 16px;
background: #f1f5f9;
border-radius: 6px;
text-decoration: none;
color: #475569;
transition: all 0.3s;
}
.page_link:hover {
background: #e2e8f0;
color: #1e293b;
}
.per_page_select {
padding: 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
outline: none;
}
.receive_card {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: inherit;
border: 1px solid #e9ecef;
}
.receive_card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.receive_card_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e9ecef;
}
.receive_card_title {
font-weight: 600;
color: #2c3e50;
}
.receive_card_duration {
color: #6c757d;
font-size: 0.9em;
}
.receive_card_content {
display: grid;
gap: 8px;
}
.receive_card_item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.receive_card_label {
color: #6c757d;
font-size: 0.9em;
}
.receive_card_value {
color: #2c3e50;
font-weight: 500;
}
.receive_card_value[data-device-type="摄像头"] {
background-color: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 4px;
}
.receive_card_value[data-device-type="紫色"] {
background-color: #f3e5f5;
color: #7b1fa2;
padding: 4px 8px;
border-radius: 4px;
}
.receive_card_value[data-device-type="绿色"] {
background-color: #e8f5e9;
color: #388e3c;
padding: 4px 8px;
border-radius: 4px;
}
.receive_card_value[data-device-type="一键报警"] {
background-color: #fff3e0;
color: #f57c00;
padding: 4px 8px;
border-radius: 4px;
}
.receive_card_value[data-device-type="灰色"] {
background-color: #f5f5f5;
color: #616161;
padding: 4px 8px;
border-radius: 4px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.receive_btn {
width: 100%;
padding: 12px 16px;
}
.modal-content {
margin: 10vh auto;
padding: 20px;
}
.receive_card {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 20px;
}
.receive_card_header {
margin-bottom: 16px;
}
.receive_card_title {
font-size: 1.1em;
}
.receive_card_content {
gap: 12px;
}
.receive_card_item {
padding: 6px 0;
}
/* 筛选区域在移动端的适配 */
.filter_header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.filter_title {
font-size: 1.1em;
}
.filter_form {
grid-template-columns: 1fr;
gap: 16px;
}
.filter_group {
gap: 6px;
}
.filter_select {
padding: 10px 14px;
font-size: 16px; /* 防止移动端自动缩放 */
}
.receive_btn_primary {
padding: 14px 20px;
font-size: 16px;
width: 100%;
margin-top: 8px;
}
.receive_btn_clear {
padding: 10px 16px;
font-size: 0.9em;
align-self: stretch;
}
.stats_grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
}
.stats_card {
padding: 10px 12px;
}
.stats_card_label {
font-size: 0.8em;
}
.stats_card_value {
font-size: 1.2em;
}
.stats_section_title {
font-size: 1em;
margin-bottom: 12px;
}
}
+46
View File
@@ -0,0 +1,46 @@
.form_tips {
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.form_tips ul {
margin: 10px 0 0 20px;
padding: 0;
}
.form_tips li {
margin-bottom: 5px;
color: #666;
}
.required {
color: #dc3545;
margin-left: 3px;
}
.detail_form input::placeholder,
.detail_form textarea::placeholder {
color: #999;
font-size: 13px;
}
.detail_form select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
color: #333;
}
.detail_form select option:first-child {
color: #999;
}
.detail_form input[type="tel"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
+268
View File
@@ -0,0 +1,268 @@
/* 容器样式 */
.statistics_container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.statistics_section {
margin-bottom: 30px;
}
/* 标题和按钮样式 */
.statistics_title {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
font-size: 1.5em;
color: #1e293b;
}
/* 统一按钮基础样式 */
.button_base {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.9em;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
height: 36px;
}
.export_button {
float: right;
padding: 6px 12px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 14px;
}
.export_button:hover {
background-color: #45a049;
color: #fff;
}
.incentive_button {
float: right;
padding: 6px 12px;
background-color: #dc2626;
color: white;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 14px;
}
.incentive_button:hover {
background-color: #b91c1c;
color: #fff;
}
/* 卡片样式 */
.statistics_card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.statistics_card h3 {
margin: 0 0 15px 0;
color: #334155;
font-size: 1.2em;
}
/* 网格布局 */
.statistics_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.statistics_item {
display: flex;
flex-direction: column;
gap: 8px;
}
.statistics_label {
color: #64748b;
font-size: 0.9em;
}
.statistics_value {
color: #1e293b;
font-size: 1.5em;
font-weight: bold;
}
/* 总计奖励样式 */
.total_reward {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
/* 表格样式 */
.handler_stats_table {
overflow-x: auto;
}
.handler_stats_table table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.handler_stats_table th,
.handler_stats_table td {
padding: 12px;
text-align: center;
border-bottom: 1px solid #e2e8f0;
}
.handler_stats_table th {
background-color: #f8fafc;
color: #475569;
font-weight: 600;
}
.handler_stats_table tr:hover {
background-color: #f8fafc;
}
/* 统计页面样式 */
.statistics_container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.statistics_section {
margin-bottom: 30px;
}
.statistics_title {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.statistics_card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.statistics_card h3 {
color: #666;
margin-bottom: 15px;
font-size: 1.1em;
}
.statistics_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.statistics_item {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.statistics_label {
color: #666;
font-size: 0.9em;
margin-bottom: 5px;
}
.statistics_value {
color: #2c3e50;
font-size: 1.5em;
font-weight: bold;
}
/* 响应式设计 */
@media (max-width: 768px) {
.statistics_grid {
grid-template-columns: 1fr;
}
.statistics_card {
padding: 15px;
}
.statistics_value {
font-size: 1.2em;
}
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 300px;
}
.month-picker-form {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 15px;
}
.month-input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.cancel_button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #6c757d;
color: white;
cursor: pointer;
}
.cancel_button:hover {
background-color: #5a6268;
}
+1028
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+7
View File
File diff suppressed because one or more lines are too long
+271
View File
@@ -0,0 +1,271 @@
function handleSelectAll() {
const checkboxes = document.querySelectorAll('input[name="device_ids"]');
const selectAllButton = document.getElementById('selectAllBtn');
const hasSelected = Array.from(checkboxes).some(checkbox => checkbox.checked);
const newState = !hasSelected;
checkboxes.forEach(checkbox => {
checkbox.checked = newState;
});
selectAllButton.textContent = newState ? '清空' : '全选';
}
function handleInvertSelect() {
const checkboxes = document.querySelectorAll('input[name="device_ids"]');
checkboxes.forEach(checkbox => {
checkbox.checked = !checkbox.checked;
});
updateSelectAllButtonText();
}
function updateSelectAllButtonText() {
const checkboxes = document.querySelectorAll('input[name="device_ids"]');
const selectAllButton = document.getElementById('selectAllBtn');
const hasSelected = Array.from(checkboxes).some(checkbox => checkbox.checked);
selectAllButton.textContent = hasSelected ? '清空' : '全选';
}
function changePerPage(value) {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('per_page', value);
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
async function handleSync(type) {
if (type === 'camera') {
try {
const response = await fetch('/get_captcha', {
method: 'GET'
});
if (response.ok) {
const blob = await response.blob();
document.getElementById('captchaImage').src = URL.createObjectURL(blob);
document.getElementById('captchaModal').style.display = 'block';
} else {
alert('获取验证码失败,请重试');
}
} catch (error) {
console.error('获取验证码失败:', error);
alert('获取验证码失败,请重试');
}
} else if (type === 'alarm') {
// 显示确认模态框
document.getElementById('alarmConfirmModal').style.display = 'block';
}
}
// 添加关闭确认模态框的函数
function closeAlarmConfirmModal() {
document.getElementById('alarmConfirmModal').style.display = 'none';
}
// 添加确认同步的函数
async function confirmAlarmSync() {
try {
// 关闭确认模态框
closeAlarmConfirmModal();
// 显示加载提示
document.getElementById('resultMessage').textContent = '正在同步数据,请稍候...';
document.getElementById('resultModal').style.display = 'block';
const response = await fetch('/sync_offline_devices', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
document.getElementById('resultMessage').textContent =
`同步成功!已为 ${data.count} 个离线设备创建工单。`;
} else {
document.getElementById('resultMessage').textContent = '同步失败:' + data.message;
}
document.getElementById('resultModal').style.display = 'block';
} catch (error) {
console.error('同步失败:', error);
document.getElementById('resultMessage').textContent = '同步失败,请重试';
document.getElementById('resultModal').style.display = 'block';
}
}
async function submitCaptcha() {
const captcha = document.getElementById('captchaInput').value;
if (!captcha) {
alert('请输入验证码');
return;
}
try {
const response = await fetch('/sync_camera_step1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ captcha: captcha })
});
const data = await response.json();
if (data.success) {
document.getElementById('captchaModal').style.display = 'none';
// 更新模态框中的数量
document.getElementById('updatedCount').textContent = data.updated_count || 0;
document.getElementById('confirmModal').style.display = 'block';
} else {
if (data.message === 'INVALID_CAPTCHA') {
alert('验证码错误,请重新输入');
handleSync('camera');
} else {
alert('同步失败:' + data.message);
}
}
} catch (error) {
console.error('同步失败:', error);
alert('同步失败,请重试');
}
}
async function handleDispatch(isCamera = false) {
try {
// 不立即关闭确认模态框,而是先更新其内容
const confirmModal = document.getElementById('confirmModal');
const modalContent = confirmModal.querySelector('.modal-content');
modalContent.innerHTML = `
<h3>正在处理</h3>
<p>正在为选中设备创建工单,请稍候...</p>
<div class="loading-spinner"></div>
`;
let response;
if (isCamera) {
// 如果是摄像头同步,调用第二步接口
response = await fetch('/sync_camera_step2', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
} else {
// 普通派单
response = await fetch('/dispatch', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
}
const result = await response.json();
// 处理完成后,平滑过渡到结果模态框
document.getElementById('confirmModal').style.display = 'none';
document.getElementById('resultMessage').textContent = `派单成功!已为 ${result.count} 个设备创建工单。`;
document.getElementById('resultModal').style.display = 'block';
} catch (error) {
console.error('派单失败:', error);
document.getElementById('resultMessage').textContent = '派单失败,请重试';
document.getElementById('resultModal').style.display = 'block';
}
}
function cancelDispatch() {
document.getElementById('confirmModal').style.display = 'none';
window.location.reload();
}
function closeCaptchaModal() {
document.getElementById('captchaModal').style.display = 'none';
document.getElementById('captchaInput').value = '';
}
// 备注编辑功能
function editRemark(deviceId, spanElement) {
const inputElement = spanElement.nextElementSibling;
spanElement.style.display = 'none';
inputElement.style.display = 'inline-block';
inputElement.focus();
}
async function saveRemark(deviceId, inputElement) {
const newRemark = inputElement.value.trim();
const spanElement = inputElement.previousElementSibling;
try {
const response = await fetch('/update_device_remark', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
device_id: deviceId,
remark: newRemark
})
});
const data = await response.json();
if (data.success) {
spanElement.textContent = newRemark || '点击添加备注';
spanElement.style.display = 'inline-block';
inputElement.style.display = 'none';
} else {
alert('保存失败:' + data.message);
inputElement.focus();
}
} catch (error) {
console.error('保存备注失败:', error);
alert('保存失败,请重试');
inputElement.focus();
}
}
document.addEventListener('DOMContentLoaded', function() {
// 监听复选框的变化
document.addEventListener('change', function(e) {
if (e.target.matches('input[name="device_ids"]')) {
updateSelectAllButtonText();
}
});
// 添加表单提交前的检查
document.getElementById('dispatchForm').addEventListener('submit', async function(e) {
e.preventDefault();
const selectedDevices = Array.from(document.querySelectorAll('input[name="device_ids"]:checked'))
.map(checkbox => checkbox.value);
if (selectedDevices.length === 0) {
alert('请至少选择一个设备');
return;
}
try {
const response = await fetch('/check_existing_orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ device_ids: selectedDevices })
});
const data = await response.json();
if (data.existing_devices.length > 0) {
alert(`以下设备已有在途工单,请勿重复派单:\n${data.existing_devices.join('\n')}`);
return;
}
this.submit();
} catch (error) {
console.error('检查失败:', error);
alert('检查在途工单失败,请重试');
}
});
});
+53
View File
@@ -0,0 +1,53 @@
function changePerPage(value) {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('per_page', value);
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
function recallAllOrders() {
document.getElementById('confirmModal').style.display = 'block';
}
function closeModal() {
document.getElementById('confirmModal').style.display = 'none';
}
async function confirmRecall() {
try {
// 关闭确认模态框
closeModal();
const response = await fetch('/recall_all_orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
document.getElementById('resultMessage').textContent = `成功追回 ${data.count} 个工单`;
document.getElementById('resultModal').style.display = 'block';
} else {
document.getElementById('resultMessage').textContent = '追回失败:' + data.message;
document.getElementById('resultModal').style.display = 'block';
}
} catch (error) {
document.getElementById('resultMessage').textContent = '操作失败,请重试';
document.getElementById('resultModal').style.display = 'block';
}
}
function closeResultModal() {
document.getElementById('resultModal').style.display = 'none';
window.location.reload();
}
// 添加点击模态框外部关闭功能
window.onclick = function(event) {
const modal = document.getElementById('confirmModal');
if (event.target == modal) {
closeModal();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/about.css') }}">
<div class="about-container">
<div class="about-header">
<h1>关于本平台</h1>
</div>
<div class="about-content">
<div class="about-section">
<h2>平台简介</h2>
<p>本平台是一个专门用于管理和处理政企重点项目运维工单的平台。平台支持工单的派发、处理、统计等功能,旨在提高运维工作效率和管理透明度。</p>
</div>
<div class="about-section">
<ul style="list-style-type: disc; margin-left: 20px;">
<li>工单管理:支持普通工单和手工故障单的创建、派发和处理</li>
<li>数据统计:提供周期性的工单处理统计和运维奖励计算</li>
<li>历史记录:记录和查询历史工单信息</li>
<li>设备管理:支持设备信息的管理和维护</li>
</ul>
</div>
<div class="about-section">
<h2>常用功能</h2>
<ul class="quick-links">
<li class="quick-link-item">
<a href="https://web.wps.cn/wo/sl/v3Qs5r1?app_id=3SDuJ9CDIyuC7KvoKUM6uT" class="quick-link">"4+N"项目关联账号查询</a>
</li>
<li class="quick-link-item">
<a href="https://www.kdocs.cn/l/crjbtOrFLgoD" class="quick-link">"4+N"项目和数字校园设备安装明细</a>
</li>
<li class="quick-link-item">
<a href="https://www.kdocs.cn/l/cdnzqi5GvcHo" class="quick-link">"4+N"项目云监控平台安装指南</a>
</li>
<li class="quick-link-item">
<a href="https://www.kdocs.cn/l/cg1kMuthlS1j" class="quick-link">大化教育城域网故障排查指南</a>
</li>
<li class="quick-link-item">
<a href="https://onu.dhdx.fun/statistics" class="quick-link">大化教育城域网终端信息管理系统</a>
</li>
<li class="quick-link-item">
<a href="https://www.kdocs.cn/l/cgJAP7E065tK" class="quick-link">大化教育城域网OLT安装规划表</a>
</li>
</ul>
</div>
<div class="about-section">
<h2>信息</h2>
<div class="dev-info">
<p>当前平台处于试行阶段,如果遇到页面错误,功能不正常等问题请截图后与管理员联系;</p>
<p>如果您有任何建议或意见,请随时与我们联系。</p>
<p>感谢您的支持和使用!</p>
</div>
<div class="dev-info">
<p><strong>联系:</strong>韦矍森</p>
<p><strong>电话:</strong>19977899008</p>
<p><strong>部门:</strong>大化云中台</p>
</div>
</div>
<div class="footer">
<p>© 2025 政企重点项目运维管理平台</p>
</div>
</div>
</div>
{% endblock %}
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大化重点项目工单管理平台</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg nav_container">
<div class="container-fluid">
<a class="nav_brand">
{% if request.endpoint == 'dispatch' %}
故障派单
{% elif request.endpoint == 'receive' %}
在途工单
{% elif request.endpoint == 'statistics' %}
工单统计
{% elif request.endpoint == 'history' %}
历史工单
{% elif request.endpoint == 'manual' %}
工单申报
{% elif request.endpoint == 'dashboard' %}
设备仪表盘
{% else %}
重点项目工单管理平台
{% endif %}
</a>
<button class="navbar-toggler nav_toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon nav_toggler_icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 nav_menu">
{% if current_user.is_authenticated %}
{% if current_user.role in ['统计员', '管理员', 'admin'] or current_user.name == 'Admin' %}
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'dashboard' }}" href="{{ url_for('dashboard') }}">仪表盘</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'dispatch' }}" href="{{ url_for('dispatch') }}">派单</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'receive' }}" href="{{ url_for('receive') }}">在途</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'manual' }}" href="{{ url_for('manual') }}">申报</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'statistics' }}" href="{{ url_for('statistics') }}">统计</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'history' }}" href="{{ url_for('history') }}">历史</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'about' }}" href="{{ url_for('about') }}">关于</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'user_list' }}" href="{{ url_for('user_list') }}">用户管理</a>
</li>
{% elif current_user.role == '装维员' %}
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'dashboard' }}" href="{{ url_for('dashboard') }}">仪表盘</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'receive' }}" href="{{ url_for('receive') }}">在途</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'manual' }}" href="{{ url_for('manual') }}">申报</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'statistics' }}" href="{{ url_for('statistics') }}">统计</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'history' }}" href="{{ url_for('history') }}">历史</a>
</li>
<li class="nav-item">
<a class="nav_link {{ 'active' if request.endpoint == 'about' }}" href="{{ url_for('about') }}">关于</a>
</li>
{% endif %}
{% endif %}
</ul>
<ul class="navbar-nav nav_menu">
{% if current_user.is_authenticated %}
<li class="nav-item">
<span class="nav_link nav_user_name">{{ current_user.name }}</span>
</li>
<li class="nav-item">
<a class="nav_link" href="{{ url_for('logout') }}">退出</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% if not request.endpoint == 'login' %}
{% with raw_messages = get_flashed_messages() %}
{% set messages = [] %}
{% for m in raw_messages %}
{% if not (current_user.is_authenticated and m == '请先登录以访问此页面') %}
{% set _ = messages.append(m) %}
{% endif %}
{% endfor %}
{% if messages %}
<div class="modal" id="flashModal" tabindex="-1" style="display: block; background: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-dialog-centered" style="z-index: 1060;">
<div class="modal-content">
<div class="modal-body">
{% for message in messages %}
<p class="mb-0">{{ message }}</p>
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="closeFlashModal()">确定</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endwith %}
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script>
function closeFlashModal() {
document.getElementById('flashModal').style.display = 'none';
}
</script>
</body>
</html>
+418
View File
@@ -0,0 +1,418 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
<div class="dashboard_container">
<h1 class="dashboard_title">设备在线率仪表盘</h1>
<!-- 图表区域 -->
<div class="dashboard_section">
<h2 class="section_title">设备在线率趋势图</h2>
<div class="chart_container">
<div class="chart_wrapper">
<canvas id="overallChart"></canvas>
</div>
<div class="chart_wrapper">
<canvas id="branchChart"></canvas>
</div>
</div>
<div class="chart_container">
<div class="chart_wrapper">
<canvas id="deviceTypeChart"></canvas>
</div>
<div class="chart_wrapper">
<canvas id="townChart"></canvas>
</div>
</div>
</div>
<!-- 总体设备在线率 -->
<div class="dashboard_section">
<h2 class="section_title">各类型设备总体在线率</h2>
<div class="card_grid">
{% for device_type, stats in overall_stats.items() %}
<div class="dashboard_card">
<div class="card_header">
<h3 class="card_title">{{ device_type }}</h3>
</div>
<div class="card_content">
<div class="progress_container">
<div class="progress_bar" style="width: {{ stats.online_rate }}%">
<span class="progress_text">{{ stats.online_rate }}%</span>
</div>
</div>
<div class="stats_details">
<div class="stats_item">
<span class="stats_label">设备总数</span>
<span class="stats_value">{{ stats.total }}</span>
</div>
<div class="stats_item">
<span class="stats_label">派单数</span>
<span class="stats_value">{{ stats.work_orders }}</span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 各支局设备在线率 -->
<div class="dashboard_section">
<h2 class="section_title">各支局设备在线率</h2>
<div class="table_container">
<table class="dashboard_table">
<thead>
<tr>
<th>支局</th>
<th>设备类型</th>
<th>设备总数</th>
<th>派单数</th>
<th>在线率</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for branch, types in branch_stats.items() %}
{% for device_type, stats in types.items() %}
{% if stats.total > 0 %}
<tr>
{% if loop.first %}
<td rowspan="{{ types|length }}" class="branch_cell">{{ branch }}</td>
{% endif %}
<td>{{ device_type }}</td>
<td>{{ stats.total }}</td>
<td>{{ stats.work_orders }}</td>
<td>{{ stats.online_rate }}%</td>
<td>
{% if stats.online_rate >= 90 %}
<span class="status_badge good">良好</span>
{% elif stats.online_rate >= 75 %}
<span class="status_badge normal">一般</span>
{% else %}
<span class="status_badge bad">需关注</span>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 各乡镇设备在线率 -->
<div class="dashboard_section">
<h2 class="section_title">各乡镇设备在线率</h2>
<div class="accordion_container">
{% for town, types in town_stats.items() %}
<div class="accordion_item">
<div class="accordion_header" onclick="toggleAccordion(this)">
<div class="accordion_title">{{ town }}</div>
<div class="accordion_summary">
<span>总设备数: {{ types['总计'].total }}</span>
<span>在线率: {{ types['总计'].online_rate }}%</span>
{% if types['总计'].online_rate >= 90 %}
<span class="status_badge good">良好</span>
{% elif types['总计'].online_rate >= 75 %}
<span class="status_badge normal">一般</span>
{% else %}
<span class="status_badge bad">需关注</span>
{% endif %}
</div>
<div class="accordion_icon"></div>
</div>
<div class="accordion_content">
<table class="detail_table">
<thead>
<tr>
<th>设备类型</th>
<th>设备总数</th>
<th>派单数</th>
<th>在线率</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for device_type, stats in types.items() %}
{% if device_type != '总计' and stats.total > 0 %}
<tr>
<td>{{ device_type }}</td>
<td>{{ stats.total }}</td>
<td>{{ stats.work_orders }}</td>
<td>{{ stats.online_rate }}%</td>
<td>
{% if stats.online_rate >= 90 %}
<span class="status_badge good">良好</span>
{% elif stats.online_rate >= 75 %}
<span class="status_badge normal">一般</span>
{% else %}
<span class="status_badge bad">需关注</span>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- 引入Chart.js库 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
function toggleAccordion(element) {
const content = element.nextElementSibling;
const icon = element.querySelector('.accordion_icon');
if (content.style.maxHeight) {
content.style.maxHeight = null;
icon.textContent = '▼';
} else {
content.style.maxHeight = content.scrollHeight + "px";
icon.textContent = '▲';
}
}
// 图表数据准备
document.addEventListener('DOMContentLoaded', function() {
// 设备类型在线率图表
const deviceTypeCtx = document.getElementById('deviceTypeChart').getContext('2d');
const deviceTypeLabels = [];
const deviceTypeData = [];
const deviceTypeColors = [];
{% for device_type, stats in overall_stats.items() %}
deviceTypeLabels.push('{{ device_type }}');
deviceTypeData.push({{ stats.online_rate }});
// 根据在线率设置颜色
if ({{ stats.online_rate }} >= 90) {
deviceTypeColors.push('rgba(40, 167, 69, 0.7)');
} else if ({{ stats.online_rate }} >= 75) {
deviceTypeColors.push('rgba(255, 193, 7, 0.7)');
} else {
deviceTypeColors.push('rgba(220, 53, 69, 0.7)');
}
{% endfor %}
new Chart(deviceTypeCtx, {
type: 'bar',
data: {
labels: deviceTypeLabels,
datasets: [{
label: '设备类型在线率(%)',
data: deviceTypeData,
backgroundColor: deviceTypeColors,
borderColor: deviceTypeColors.map(color => color.replace('0.7', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: '各设备类型在线率对比'
},
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: '在线率(%)'
}
}
}
}
});
// 支局在线率对比图
const branchCtx = document.getElementById('branchChart').getContext('2d');
const branchLabels = [];
const branchData = [];
const branchColors = [];
{% for branch, types in branch_stats.items() %}
branchLabels.push('{{ branch }}');
branchData.push({{ types['总计'].online_rate }});
// 根据在线率设置颜色
if ({{ types['总计'].online_rate }} >= 90) {
branchColors.push('rgba(40, 167, 69, 0.7)');
} else if ({{ types['总计'].online_rate }} >= 75) {
branchColors.push('rgba(255, 193, 7, 0.7)');
} else {
branchColors.push('rgba(220, 53, 69, 0.7)');
}
{% endfor %}
new Chart(branchCtx, {
type: 'bar',
data: {
labels: branchLabels,
datasets: [{
label: '支局在线率(%)',
data: branchData,
backgroundColor: branchColors,
borderColor: branchColors.map(color => color.replace('0.7', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: '各支局在线率对比'
},
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: '在线率(%)'
}
}
}
}
});
// 乡镇在线率对比图
const townCtx = document.getElementById('townChart').getContext('2d');
const townLabels = [];
const townData = [];
const townColors = [];
{% for town, types in town_stats.items() %}
townLabels.push('{{ town }}');
townData.push({{ types['总计'].online_rate }});
// 根据在线率设置颜色
if ({{ types['总计'].online_rate }} >= 90) {
townColors.push('rgba(40, 167, 69, 0.7)');
} else if ({{ types['总计'].online_rate }} >= 75) {
townColors.push('rgba(255, 193, 7, 0.7)');
} else {
townColors.push('rgba(220, 53, 69, 0.7)');
}
{% endfor %}
new Chart(townCtx, {
type: 'bar',
data: {
labels: townLabels,
datasets: [{
label: '乡镇在线率(%)',
data: townData,
backgroundColor: townColors,
borderColor: townColors.map(color => color.replace('0.7', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: '各乡镇在线率对比'
},
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: '在线率(%)'
}
}
}
}
});
// 总体在线率趋势图(模拟数据)
const overallCtx = document.getElementById('overallChart').getContext('2d');
// 模拟过去7天的数据
const dates = [];
const today = new Date();
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(today.getDate() - i);
dates.push(date.getMonth() + 1 + '/' + date.getDate());
}
// 生成模拟数据,假设在线率在当前值附近波动
const generateTrendData = (baseValue) => {
return Array.from({length: 7}, () => {
// 在基准值附近随机波动,但确保不超过100和不低于50
const fluctuation = Math.random() * 10 - 5; // -5 到 +5 之间的随机数
return Math.min(100, Math.max(50, baseValue + fluctuation));
});
};
// 假设当前总体在线率为所有设备类型在线率的平均值
let avgOnlineRate = 0;
let count = 0;
{% for device_type, stats in overall_stats.items() %}
avgOnlineRate += {{ stats.online_rate }};
count++;
{% endfor %}
avgOnlineRate = avgOnlineRate / (count || 1);
new Chart(overallCtx, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: '总体在线率趋势(%)',
data: generateTrendData(avgOnlineRate),
fill: false,
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: '总体在线率7天趋势'
}
},
scales: {
y: {
beginAtZero: false,
min: Math.max(0, avgOnlineRate - 20),
max: Math.min(100, avgOnlineRate + 20),
title: {
display: true,
text: '在线率(%)'
}
}
}
}
});
});
</script>
{% endblock %}
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dispatch.css') }}">
<div class="dispatch_filter">
<form method="GET" action="{{ url_for('dispatch') }}" class="dispatch_search_form">
<input type="text" name="search" class="dispatch_search" placeholder="输入搜索词" value="{{ request.args.get('search', '') }}">
<select name="town" class="dispatch_search">
<option value="">所有乡镇</option>
{% for town in towns %}
<option value="{{ town }}" {% if town == request.args.get('town') %}selected{% endif %}>{{ town }}</option>
{% endfor %}
</select>
<select name="branch" class="dispatch_search">
<option value="">所有支局</option>
{% for branch in branches %}
<option value="{{ branch }}" {% if branch == request.args.get('branch') %}selected{% endif %}>{{ branch }}</option>
{% endfor %}
</select>
<button type="submit" class="dispatch_btn dispatch_btn_primary">搜索</button>
</form>
<div class="dispatch_action_bar">
<button type="button" class="dispatch_btn dispatch_btn_secondary_bj" onclick="handleSync('alarm')">同步报警盒</button>
<button type="button" class="dispatch_btn dispatch_btn_secondary_sxt" onclick="handleSync('camera')">同步摄像头</button>
<button type="button" class="dispatch_btn dispatch_btn_secondary_qx" id="selectAllBtn" onclick="handleSelectAll()">全选</button>
<button type="button" class="dispatch_btn dispatch_btn_secondary_fx" onclick="handleInvertSelect()">反选</button>
<button type="submit" form="dispatchForm" class="dispatch_btn dispatch_btn_primary">派单</button>
</div>
</div>
<!-- 派单表单 -->
<form id="dispatchForm" method="POST" action="{{ url_for('dispatch') }}">
<!-- 设备表格 -->
<table class="dispatch_table">
<thead>
<tr>
<th>选择</th>
<th>设备编号</th>
<th>所属乡镇</th>
<th>所属支局</th>
<th>单位名称</th>
<th>项目类型</th>
<th>设备类型</th>
<th>备注</th>
</tr>
</thead>
<tbody>
{% for device in devices.items %}
<tr>
<td><input type="checkbox" class="dispatch_checkbox" name="device_ids" value="{{ device.device_id }}"></td>
<td>{{ device.device_id }}</td>
<td>{{ device.town }}</td>
<td>{{ device.branch }}</td>
<td>{{ device.unit_name }}</td>
<td>{{ device.project_type }}</td>
<td>{{ device.device_type }}</td>
<td>
<span class="remark-display" onclick="editRemark('{{ device.device_id }}', this)">{{ device.remark or '点击添加备注' }}</span>
<input type="text" class="remark-input" style="display: none;" value="{{ device.remark or '' }}"
onblur="saveRemark('{{ device.device_id }}', this)"
onkeypress="if(event.key==='Enter') saveRemark('{{ device.device_id }}', this)">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<!-- 分页导航部分 -->
<div class="dispatch_pagination">
{% if devices.has_prev %}
<a href="{{ url_for('dispatch', page=devices.prev_num, search=request.args.get('search', ''), town=request.args.get('town', ''), branch=request.args.get('branch', ''), per_page=request.args.get('per_page', '50')) }}" class="dispatch_page_link">上一页</a>
{% endif %}
<span class="dispatch_page_info">第 {{ devices.page }} 页,共 {{ devices.pages }} 页</span>
<select class="per_page_select" onchange="changePerPage(this.value)">
<option value="10" {% if request.args.get('per_page', '50') == '10' %}selected{% endif %}>10条/页</option>
<option value="20" {% if request.args.get('per_page', '50') == '20' %}selected{% endif %}>20条/页</option>
<option value="50" {% if request.args.get('per_page', '50') == '50' %}selected{% endif %}>50条/页</option>
<option value="100" {% if request.args.get('per_page', '50') == '100' %}selected{% endif %}>100条/页</option>
</select>
{% if devices.has_next %}
<a href="{{ url_for('dispatch', page=devices.next_num, search=request.args.get('search', ''), town=request.args.get('town', ''), branch=request.args.get('branch', ''), per_page=request.args.get('per_page', '50')) }}" class="dispatch_page_link">下一页</a>
{% endif %}
</div>
</div>
<!-- 添加验证码模态框 -->
<div id="captchaModal" class="modal">
<div class="modal-content">
<h3>请输入验证码</h3>
<img id="captchaImage" src="{{ url_for('static', filename='captcha.png') }}" alt="验证码">
<input type="text" id="captchaInput" placeholder="请输入验证码">
<div class="modal-buttons">
<button onclick="submitCaptcha()">确定</button>
<button onclick="closeCaptchaModal()">取消</button>
</div>
</div>
</div>
<!-- 添加一键报警确认模态框 -->
<div id="alarmConfirmModal" class="modal">
<div class="modal-content">
<h3>确认同步</h3>
<p>确定要同步一键报警设备状态吗?</p>
<div class="modal-buttons">
<button onclick="confirmAlarmSync()" class="dispatch_btn dispatch_btn_primary">确定</button>
<button onclick="closeAlarmConfirmModal()" class="dispatch_btn">取消</button>
</div>
</div>
</div>
<!-- 添加确认模态框 -->
<div id="confirmModal" class="modal">
<div class="modal-content">
<h3>数据更新完成</h3>
<p>已更新 <span id="updatedCount">0</span> 个设备的状态</p>
<p>是否要进行派单操作?</p>
<div class="modal-buttons">
<button onclick="handleDispatch(true)"></button>
<button onclick="cancelDispatch()"></button>
</div>
</div>
</div>
<!-- 添加结果模态框 -->
<div id="resultModal" class="modal">
<div class="modal-content">
<h3>操作结果</h3>
<p id="resultMessage"></p>
<div class="modal-buttons">
<button onclick="window.location.reload()">确定</button>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/dispatch.js') }}"></script>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block content %}
<div class="history_container">
<!-- 添加搜索框 -->
<div class="history_search">
<form method="GET" action="{{ url_for('history') }}" class="history_search_form">
<input type="text" name="search" value="{{ search }}" placeholder="搜索工单号、设备、支局、处理人等..." class="history_search_input">
<button type="submit" class="history_search_button">搜索</button>
</form>
</div>
<!-- PC端表格视图 -->
<table class="history_table">
<thead>
<tr>
<th>工单号</th>
<th>所属支局</th>
<th>故障设备</th>
<th>处理人</th>
<th>处理时间</th>
<th>故障类型</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for handling in handlings.items %}
<tr>
<td>{{ handling['order_id'] }}</td>
<td>{{ handling['branch'] }}</td>
<td>{{ handling['device_id'] }}</td>
<td>{{ handling['handler'] }}</td>
<td>{{ handling['time'].strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ handling['fault_type'] }}</td>
<td>
<a href="{{ url_for('history_detail', order_id=handling['order_id']) }}" class="view_btn">查看</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- 移动端卡片视图 -->
<div class="card_container">
{% for handling in handlings.items %}
<a href="{{ url_for('history_detail', order_id=handling['order_id']) }}" class="history_card {% if handling['type'] == 'manual' %}manual-card{% else %}system-card{% endif %}">
<div class="history_card_header">
<span class="history_card_title">工单号: {{ handling['order_id'] }}</span>
<span class="history_card_time">{{ handling['time'].strftime('%Y-%m-%d') }}</span>
</div>
<div class="history_card_content">
<div class="history_card_item">
<span class="history_card_label">所属支局</span>
<span class="history_card_value">{{ handling['branch'] }}</span>
</div>
<div class="history_card_item">
<span class="history_card_label">故障设备</span>
<span class="history_card_value">{{ handling['device_id'] }}</span>
</div>
<div class="history_card_item">
<span class="history_card_label">处理人</span>
<span class="history_card_value">{{ handling['handler'] }}</span>
</div>
<div class="history_card_item">
<span class="history_card_label">故障类型</span>
<span class="history_card_value">{{ handling['fault_type'] }}</span>
</div>
<div class="history_card_item">
<span class="history_card_label">工单类型</span>
<span class="history_card_value">{{ '手工申报' if handling['type'] == 'manual' else '系统派单' }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
<!-- 分页导航 -->
<div class="history_pagination">
{% if handlings.has_prev %}
<a href="{{ url_for('history', page=handlings.prev_num) }}" class="history_page_link">上一页</a>
{% endif %}
<span class="history_page_info">第 {{ handlings.page }} 页,共 {{ handlings.pages }} 页</span>
{% if handlings.has_next %}
<a href="{{ url_for('history', page=handlings.next_num) }}" class="history_page_link">下一页</a>
{% endif %}
</div>
</div>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block content %}
<div class="history_detail_container">
<!-- 工单信息卡片 -->
<div class="detail_card">
<div class="detail_header">
<h3 class="detail_title">工单详情 #{{ handling.order_id }}</h3>
<span class="detail_status">{{ '申报' if order_type == 'manual' else '系统' }}</span>
</div>
<div class="detail_section">
<h4 class="section_title">基本信息</h4>
<div class="detail_grid">
<div class="detail_item">
<span class="detail_label">所属支局</span>
<span class="detail_value">{{ handling.branch }}</span>
</div>
<div class="detail_item">
<span class="detail_label">故障设备</span>
<span class="detail_value">{{ handling.device_id }}</span>
</div>
<div class="detail_item">
<span class="detail_label">处理人</span>
<span class="detail_value highlight">{{ handling.handler }}</span>
</div>
{% if order_type == 'manual' %}
<div class="detail_item">
<span class="detail_label">项目类型</span>
<span class="detail_value">{{ handling.project_type }}</span>
</div>
<div class="detail_item">
<span class="detail_label">设备位置</span>
<span class="detail_value">{{ handling.device_location }}</span>
</div>
<div class="detail_item">
<span class="detail_label">故障时间</span>
<span class="detail_value">{{ handling.fault_time.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</div>
{% else %}
<div class="detail_item">
<span class="detail_label">处理时间</span>
<span class="detail_value">{{ handling.restore_time.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</div>
{% endif %}
</div>
</div>
<div class="detail_section">
<h4 class="section_title">故障信息</h4>
<div class="detail_grid">
<div class="detail_item">
<span class="detail_label">故障类型</span>
<span class="detail_value badge">{{ handling.fault_type }}</span>
</div>
<div class="detail_item">
<span class="detail_label">故障对接人</span>
<span class="detail_value">{{ handling.contact_person }}</span>
</div>
<div class="detail_item">
<span class="detail_label">联系电话</span>
<span class="detail_value">{{ handling.contact_phone }}</span>
</div>
<div class="detail_item">
<span class="detail_label">距离支局</span>
<span class="detail_value">{{ handling.distance_from_branch }} 公里</span>
</div>
</div>
</div>
<div class="detail_section">
<h4 class="section_title">处理详情</h4>
<div class="detail_description">
<p>{{ handling.handle_description }}</p>
</div>
{% if handling.remark %}
<div class="detail_remark">
<h5>备注信息</h5>
<p>{{ handling.remark }}</p>
</div>
{% endif %}
</div>
{% if handling.photo_paths %}
<div class="detail_section">
<h4 class="section_title">现场图片</h4>
<div class="photo_grid">
{% for photo in handling.photo_paths.split(',') %}
<div class="photo_item">
<img src="{{ url_for('static', filename='uploads/' + photo) }}" alt="现场图片"
onclick="showFullImage(this.src)">
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="detail_actions">
<a href="{{ url_for('history') }}" class="detail_btn detail_btn_secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
</div>
</div>
<!-- 图片预览模态框 -->
<div id="imageModal" class="modal">
<span class="modal_close">&times;</span>
<img class="modal_content" id="modalImage">
</div>
<script>
function showFullImage(src) {
var modal = document.getElementById("imageModal");
var modalImg = document.getElementById("modalImage");
modal.style.display = "block";
modalImg.src = src;
}
var modal = document.getElementById("imageModal");
var span = document.getElementsByClassName("modal_close")[0];
span.onclick = function() {
modal.style.display = "none";
}
window.onclick = function(event) {
if (event.target == modal) {
modal.style.display = "none";
}
}
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="login_container">
<div class="login_box">
<h2 class="login_title">欢迎使用</h2>
<div class="login_subtitle">大化重点项目工单管理平台</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="login_error">
{% for message in messages %}
{{ message }}
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Casdoor 登录跳转 -->
<div class="login_casdoor_section">
<a href="{{ url_for('casdoor_login') }}" class="login_button login_button_casdoor">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 8px;">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor"/>
</svg>
使用 Casdoor 登录
</a>
<p style="margin-top: 12px; color: #666;">正在跳转至 Casdoor 登录页,如未跳转请点击按钮。</p>
</div>
<script>
// 有错误提示时停留在本页,方便用户查看信息
const hasError = {{ 'true' if get_flashed_messages() else 'false' }};
if (!hasError) {
// 自动跳转至 Casdoor 登录
window.onload = function() {
window.location.href = "{{ url_for('casdoor_login') }}";
};
}
</script>
</div>
</div>
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block content %}
<div class="manual_container">
<div class="form_tips">
<p>注意事项</p>
<ul>
<li>本页面用于填写用户来电申报或者巡检途中发现的故障、移机,申报前请确保在途工单中没有重复故障</li>
<li>新装设备、移机等请在申报类型选择“新装”或“移机”</li>
<li>现场照片最多可上传5张,支持jpg、jpeg、png</li>
<li>距离支局公里数请填写支局办公室到故障地点实际距离</li>
<li><span class="required">*</span> 的字段为必填项</li>
</ul>
</div>
<form method="POST" enctype="multipart/form-data" class="manual_form">
<div class="form-group">
<label for="project_type">项目类型<span class="required">*</span></label>
<select id="project_type" name="project_type" required>
<option value="">请选择项目类型</option>
<option value='校园安防"4+N"项目'>校园安防"4+N"项目</option>
<option value="综治视联网">综治视联网</option>
<option value="教育城域网">教育城域网</option>
<option value="其他">其他</option>
</select>
</div>
<div class="form-group">
<label for="device_id">施工设备类型<span class="required">*</span></label>
<input type="text" id="device_id" name="device_id" required placeholder="如:摄像头、一键报警盒、光猫等">
</div>
<div class="form-group">
<label for="device_location">施工单位名称<span class="required">*</span></label>
<input type="text" id="device_location" name="device_location" required placeholder="请填写前往施工的单位名称如:古河村委、都阳镇中心小学等">
</div>
<div class="form-group">
<label for="fault_time">施工完成时间<span class="required">*</span></label>
<input type="datetime-local" id="fault_time" name="fault_time" required>
</div>
<div class="form-group">
<label for="handler">施工处理人<span class="required">*</span></label>
<input type="text" id="handler" name="handler" value="{{ current_user.name }}" required>
</div>
<div class="form-group">
<label for="fault_type">申报类型<span class="required">*</span></label>
<select id="fault_type" name="fault_type" required>
<option value="">请选择申报类型</option>
<option value="光猫故障">光猫故障</option>
<option value="设备故障">设备故障</option>
<option value="电源故障">电源故障</option>
<option value="电路故障">电路故障</option>
<option value="网线故障">网线故障</option>
<option value="皮线故障">皮线故障</option>
<option value="数据问题">数据问题</option>
<option value="用户欠费">用户欠费</option>
<option value="移机">移机申报</option>
<option value="新增">新装申报</option>
<option value="其他">其他</option>
</select>
</div>
<div class="form-group">
<label for="handle_description">处理描述<span class="required">*</span></label>
<textarea id="handle_description" name="handle_description" placeholder="请简述处理过程,如:光猫电源适配器坏,更好光猫电源适配器后恢复。" required></textarea>
</div>
<div class="form-group">
<label for="photo">现场照片(最多5张)</label>
<input type="file" id="photo" name="photo" multiple accept="image/*">
</div>
<div class="form-group">
<label for="contact_person">施工对接人<span class="required">*</span></label>
<input type="text" id="contact_person" name="contact_person" placeholder="请输入本次施工单位对接人" required>
</div>
<div class="form-group">
<label for="contact_phone">对接人联系电话</label>
<input type="tel" id="contact_phone" name="contact_phone" placeholder="请输入本次施工单位对接人联系电话">
</div>
<div class="form-group">
<label for="distance_from_branch">距离支局公里数<span class="required">*</span></label>
<input type="number" step="0.1" id="distance_from_branch" name="distance_from_branch" required placeholder="请输入实际路程距离,如:5.5、50">
</div>
<div class="form-group">
<label for="remark">备注</label>
<textarea id="remark" name="remark" placeholder="如有其他需要说明的信息,请在此填写"></textarea>
</div>
<button type="submit" class="manual_submit">提交</button>
</form>
</div>
<style>
.form_tips {
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.form_tips ul {
margin: 10px 0 0 20px;
padding: 0;
}
.form_tips li {
margin-bottom: 5px;
color: #666;
}
.required {
color: #dc3545;
margin-left: 3px;
}
.manual_form input::placeholder,
.manual_form textarea::placeholder {
color: #999;
font-size: 13px;
}
.manual_form select option:first-child {
color: #999;
}
</style>
{% endblock %}
+206
View File
@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/receive.css') }}">
<!-- 统计信息 -->
<div class="receive_stats">
<div class="stats_item">
<span class="stats_label">在途工单总数</span>
<span class="stats_value">{{ total_orders }}</span>
</div>
<!-- 设备类型统计 -->
{% if device_type_stats %}
<div class="stats_section">
<h4 class="stats_section_title">设备类型分布</h4>
<div class="stats_grid">
{% for device_type, count in device_type_stats.items() %}
<a href="{{ url_for('receive', device_type=device_type, branch=selected_branch, per_page=request.args.get('per_page', '50')) }}" class="stats_card device-type-{{ device_type | replace(' ', '-') }} {% if device_type == request.args.get('device_type') %}active{% endif %}">
<span class="stats_card_label">{{ device_type }}</span>
<span class="stats_card_value">{{ count }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- 项目类型统计 -->
{% if project_type_stats %}
<div class="stats_section">
<h4 class="stats_section_title">项目类型分布</h4>
<div class="stats_grid">
{% for project_type, count in project_type_stats.items() %}
<a href="{{ url_for('receive', project_type=project_type, branch=selected_branch, per_page=request.args.get('per_page', '50')) }}" class="stats_card project-type-{{ project_type | replace(' ', '-') }} {% if project_type == request.args.get('project_type') %}active{% endif %}">
<span class="stats_card_label">{{ project_type }}</span>
<span class="stats_card_value">{{ count }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- 筛选区域 -->
<div class="receive_filter">
<div class="filter_header">
<h4 class="filter_title">工单筛选</h4>
<div class="filter_actions">
{% if current_user.branch == '交付中心' %}
<button onclick="recallAllOrders()" class="receive_btn receive_btn_warning">一键追回所有工单</button>
{% endif %}
{% if request.args.get('device_type') or request.args.get('project_type') or selected_branch %}
<a href="{{ url_for('receive', per_page=request.args.get('per_page', '50')) }}" class="receive_btn receive_btn_clear">清除所有筛选</a>
{% endif %}
</div>
</div>
<form method="GET" action="{{ url_for('receive') }}" class="filter_form">
{% if current_user.branch == '交付中心' %}
<div class="filter_group">
<label class="filter_label">所属支局</label>
<select name="branch" class="filter_select">
<option value="">所有支局</option>
{% for branch in branches %}
<option value="{{ branch }}" {% if branch == selected_branch %}selected{% endif %}>{{ branch }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="filter_group">
<label class="filter_label">设备类型</label>
<select name="device_type" class="filter_select">
<option value="">所有设备类型</option>
{% for device_type in device_types %}
<option value="{{ device_type }}" {% if device_type == request.args.get('device_type') %}selected{% endif %}>{{ device_type }}</option>
{% endfor %}
</select>
</div>
<div class="filter_group">
<label class="filter_label">项目类型</label>
<select name="project_type" class="filter_select">
<option value="">所有项目类型</option>
{% for project_type in project_types %}
<option value="{{ project_type }}" {% if project_type == request.args.get('project_type') %}selected{% endif %}>{{ project_type }}</option>
{% endfor %}
</select>
</div>
<input type="hidden" name="page" value="1">
<input type="hidden" name="per_page" value="{{ request.args.get('per_page', '50') }}">
<div class="filter_group">
<button type="submit" class="receive_btn receive_btn_primary">应用筛选</button>
</div>
</form>
</div>
<table class="receive_table">
<thead>
<tr>
<th>工单号</th>
<th>所属支局</th>
<th>故障设备</th>
<th>设备类型</th>
<th>项目类型</th>
<th>派单时间</th>
<th>派单时长</th>
</tr>
</thead>
<tbody>
{% for work_order in work_orders %}
<tr>
<td>{{ work_order.order_id }}</td>
<td>{{ work_order.device.branch }}</td>
<td><a href="{{ url_for('receive_detail', work_order_id=work_order.order_id) }}" class="receive_action">{{ work_order.device.device_id }}</a></td>
<td>{{ work_order.device.device_type }}</td>
<td>{{ work_order.device.project_type }}</td>
<td>{{ work_order.dispatch_time }}</td>
<td>{{ work_order.duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- 移动端卡片视图 -->
<div class="card_container">
{% for work_order in work_orders %}
<a href="{{ url_for('receive_detail', work_order_id=work_order.order_id) }}" class="receive_card">
<div class="receive_card_header">
<span class="receive_card_title">工单号: {{ work_order.order_id }}</span>
<span class="receive_card_duration">{{ work_order.duration }}</span>
</div>
<div class="receive_card_content">
<div class="receive_card_item">
<span class="receive_card_label">故障设备</span>
<span class="receive_card_value">{{ work_order.device.device_id }}</span>
</div>
<div class="receive_card_item">
<span class="receive_card_label">设备类型</span>
<span class="receive_card_value" data-device-type="{{ work_order.device.device_type }}">{{ work_order.device.device_type }}</span>
</div>
<div class="receive_card_item">
<span class="receive_card_label">所属支局</span>
<span class="receive_card_value">{{ work_order.device.branch }}</span>
</div>
<div class="receive_card_item">
<span class="receive_card_label">项目类型</span>
<span class="receive_card_value">{{ work_order.device.project_type }}</span>
</div>
<div class="receive_card_item">
<span class="receive_card_label">派单时间</span>
<span class="receive_card_value">{{ work_order.dispatch_time }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
<!-- 添加分页 -->
<div class="receive_pagination">
{% if work_orders.has_prev %}
<a href="{{ url_for('receive', page=work_orders.prev_num, branch=selected_branch, device_type=request.args.get('device_type', ''), project_type=request.args.get('project_type', ''), per_page=request.args.get('per_page', '50')) }}" class="page_link">上一页</a>
{% endif %}
<span class="page_info">第 {{ work_orders.page }} 页,共 {{ work_orders.pages }} 页</span>
<select class="per_page_select" onchange="changePerPage(this.value)">
<option value="10" {% if request.args.get('per_page', '50') == '10' %}selected{% endif %}>10条/页</option>
<option value="20" {% if request.args.get('per_page', '50') == '20' %}selected{% endif %}>20条/页</option>
<option value="50" {% if request.args.get('per_page', '50') == '50' %}selected{% endif %}>50条/页</option>
<option value="100" {% if request.args.get('per_page', '50') == '100' %}selected{% endif %}>100条/页</option>
</select>
{% if work_orders.has_next %}
<a href="{{ url_for('receive', page=work_orders.next_num, branch=selected_branch, device_type=request.args.get('device_type', ''), project_type=request.args.get('project_type', ''), per_page=request.args.get('per_page', '50')) }}" class="page_link">下一页</a>
{% endif %}
</div>
<!-- 确认追回弹窗 -->
<div id="confirmModal" class="modal">
<div class="modal-content">
<h3>确认追回</h3>
<div class="modal-body">
<i class="modal-icon warning"></i>
<p>确定要追回所有在途工单吗?</p>
</div>
<div class="modal-buttons">
<button onclick="confirmRecall()" class="receive_btn receive_btn_warning">确定</button>
<button onclick="closeModal()" class="receive_btn receive_btn_secondary">取消</button>
</div>
</div>
</div>
<div id="resultModal" class="modal">
<div class="modal-content">
<h3>操作结果</h3>
<div class="modal-body">
<i class="modal-icon info"></i>
<p id="resultMessage"></p>
</div>
<div class="modal-buttons">
<button onclick="closeResultModal()" class="receive_btn receive_btn_primary">确定</button>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/receive.js') }}"></script>
{% endblock %}
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/receive_detail.css') }}">
<div class="receive_detail_container">
<!-- 工单信息卡片 -->
<div class="detail_card">
<h3 class="detail_title">工单信息</h3>
<div class="detail_grid">
<div class="detail_item">
<span class="detail_label">工单号:</span>
<span class="detail_value">{{ work_order.order_id }}</span>
</div>
<div class="detail_item">
<span class="detail_label">所属支局:</span>
<span class="detail_value">{{ work_order.device.branch }}</span>
</div>
<div class="detail_item">
<span class="detail_label">故障设备:</span>
<span class="detail_value">{{ work_order.device.device_id }}</span>
</div>
<div class="detail_item">
<span class="detail_label">设备类型:</span>
<span class="detail_value">{{ work_order.device.device_type }}</span>
</div>
<div class="detail_item">
<span class="detail_label">项目类型:</span>
<span class="detail_value">{{ work_order.device.project_type }}</span>
</div>
<div class="detail_item">
<span class="detail_label">派单时间:</span>
<span class="detail_value">{{ work_order.dispatch_time }}</span>
</div>
<div class="detail_item">
<span class="detail_label">关联账号:</span>
<span class="detail_value">{{ work_order.device.account }}</span>
</div>
</div>
</div>
<!-- 处理信息表单 -->
<div class="detail_card">
<h3 class="detail_title">处理信息</h3>
<form method="POST" enctype="multipart/form-data" class="detail_form">
<div class="form-group">
<label for="handler">故障处理人<span class="required">*</span></label>
<input type="text" id="handler" name="handler" value="{{ current_user.name }}" required>
</div>
<div class="form-group">
<label for="fault_type">故障类型<span class="required">*</span></label>
<select id="fault_type" name="fault_type" required>
<option value="">请选择故障类型</option>
<option value="光猫故障">光猫故障</option>
<option value="设备故障">设备故障</option>
<option value="电源故障">电源故障</option>
<option value="电路故障">电路故障</option>
<option value="网线故障">网线故障</option>
<option value="皮线故障">皮线故障</option>
<option value="数据问题">数据问题</option>
<option value="用户欠费">用户欠费</option>
<option value="其他">其他</option>
</select>
</div>
<div class="form-group">
<label for="handle_description">处理描述<span class="required">*</span></label>
<textarea id="handle_description" name="handle_description"
placeholder="请简述处理过程,如:光猫电源适配器坏,更换光猫电源适配器后恢复。" required></textarea>
</div>
<div class="form-group">
<label for="photo">现场图片(最多5张)</label>
<input type="file" id="photo" name="photo" multiple accept="image/*" class="detail_file_input">
</div>
<div class="form-group">
<label for="contact_person">故障对接人<span class="required">*</span></label>
<input type="text" id="contact_person" name="contact_person"
value="{{ work_order.contact_person }}"
placeholder="请输入本次故障单位对接人" required>
</div>
<div class="form-group">
<label for="contact_phone">联系电话</label>
<input type="tel" id="contact_phone" name="contact_phone"
value="{{ work_order.contact_phone }}"
placeholder="请输入本次故障单位对接人联系电话">
</div>
<div class="form-group">
<label for="distance_from_branch">距离支局公里数<span class="required">*</span></label>
<input type="number" id="distance_from_branch" name="distance_from_branch"
step="0.1" placeholder="请输入支局办公室到故障地点实际距离" required>
</div>
<div class="form-group">
<label for="remark">备注</label>
<textarea id="remark" name="remark"
placeholder="如有其他需要说明的信息请在此备注"></textarea>
</div>
<div class="form_tips">
<p>注意事项</p>
<ul>
<li>现场照片最多可上传5张,支持jpg、jpeg、png</li>
<li>距离支局公里数请填写支局办公室到故障地点实际距离</li>
<li><span class="required">*</span> 的字段为必填项</li>
</ul>
</div>
<div class="detail_actions">
<button type="submit" class="detail_btn detail_btn_primary">提交</button>
{% if current_user.role == '统计员' %}
<a href="{{ url_for('recall_order', order_id=work_order.order_id) }}"
class="detail_btn detail_btn_danger"
onclick="return confirm('确定要追回这张工单吗?')">追回</a>
{% endif %}
<a href="{{ url_for('receive') }}" class="detail_btn detail_btn_secondary">返回</a>
</div>
</form>
</div>
</div>
<script>
document.getElementById('fault_type').addEventListener('change', function() {
const faultType = this.value;
const descriptionField = document.getElementById('handle_description');
// 根据故障类型设置默认描述文本
const descriptions = {
'光猫故障': '检查发现光猫故障,更换光猫后恢复正常。',
'设备故障': '检查发现设备故障,重启设备后恢复正常。',
'电源故障': '检查发现电源适配器故障,更换电源适配器后恢复正常。',
'电路故障': '检查发现电路故障,修复电路后恢复正常。',
'网线故障': '检查发现网线故障,更换网线后恢复正常。',
'皮线故障': '检查发现皮线故障,更换皮线后恢复正常。',
'数据问题': '检查发现数据配置异常,修改配置后恢复正常。',
'用户欠费': '检查发现用户已欠费,通知用户缴费后恢复正常。',
'其他': ''
};
// 如果选择了故障类型,则填入对应的描述文本
if (faultType && descriptions[faultType]) {
descriptionField.value = descriptions[faultType];
} else {
descriptionField.value = '';
}
});
</script>
{% endblock %}
+255
View File
@@ -0,0 +1,255 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
<div class="statistics_container">
<!-- 本周统计 -->
<div class="statistics_section">
<h2 class="statistics_title">本周统计
<a href="{{ url_for('static', filename='file/关于政企交付运营中心2025年项目运维及交付实施激励方案.pdf') }}"
class="incentive_button" target="_blank">📄 关于政企交付运营中心2025年项目运维及交付实施激励方案</a>
</h2>
<!-- 普通工单统计 -->
<div class="statistics_card">
<h3>普通工单</h3>
{% if current_user.role == '统计员' %}
<div class="statistics_grid">
<div class="statistics_item">
<span class="statistics_label">派单总数</span>
<span class="statistics_value">{{ week_work_stats.total }}</span>
</div>
<div class="statistics_item">
<span class="statistics_label">已处理</span>
<span class="statistics_value">{{ week_work_stats.completed }}</span>
</div>
<div class="statistics_item">
<span class="statistics_label">未处理</span>
<span class="statistics_value">{{ week_work_stats.pending }}</span>
</div>
</div>
{% else %}
<div class="statistics_grid">
<div class="statistics_item">
<span class="statistics_label">已处理工单</span>
<span class="statistics_value">{{ week_work_stats.completed }}</span>
</div>
</div>
{% endif %}
</div>
<!-- 手工故障单统计 -->
<div class="statistics_card">
<h3>手工故障单</h3>
<div class="statistics_grid">
<div class="statistics_item">
<span class="statistics_label">处理完成数量</span>
<span class="statistics_value">{{ week_manual_stats }}</span>
</div>
</div>
</div>
<!-- 运维奖励统计 -->
<div class="statistics_card">
<h3>运维奖励(元)</h3>
<div class="statistics_grid">
{% if week_rewards is mapping %}
{% for project_type, reward in week_rewards.items() %}
<div class="statistics_item">
<span class="statistics_label">{{ project_type }}</span>
<span class="statistics_value">{{ "%.2f"|format(reward) }}</span>
</div>
{% endfor %}
{% endif %}
<div class="statistics_item total_reward">
<span class="statistics_label">总计</span>
<span class="statistics_value">{{ "%.2f"|format(week_rewards.values()|sum) if week_rewards is mapping else "%.2f"|format(week_rewards|default(0)) }}</span>
</div>
</div>
</div>
</div>
<!-- 本月统计 -->
<div class="statistics_section">
<h2 class="statistics_title">本月统计
{% if current_user.role == '统计员' %}
<button onclick="showMonthPicker()" class="export_button">📥︎ 导出往月明细</button>
{% endif %}
</h2>
<!-- 普通工单统计 -->
<div class="statistics_card">
<h3>普通工单</h3>
{% if current_user.role == '统计员' %}
<div class="statistics_grid">
<div class="statistics_item">
<span class="statistics_label">派单总数</span>
<span class="statistics_value">{{ month_work_stats.total }}</span>
</div>
<div class="statistics_item">
<span class="statistics_label">已处理</span>
<span class="statistics_value">{{ month_work_stats.completed }}</span>
</div>
<div class="statistics_item">
<span class="statistics_label">未处理</span>
<span class="statistics_value">{{ month_work_stats.pending }}</span>
</div>
</div>
{% else %}
<div class="statistics_grid">
<div class="statistics_item">
<span class="statistics_label">已处理工单</span>
<span class="statistics_value">{{ month_work_stats.completed }}</span>
</div>
</div>
{% endif %}
</div>
<!-- 手工故障单统计 -->
<div class="statistics_card">
<h3>手工故障单</h3>
<div class="statistics_grid">
<div class="statistics_item">
<span class="statistics_label">处理完成数量</span>
<span class="statistics_value">{{ month_manual_stats }}</span>
</div>
</div>
</div>
<!-- 运维奖励统计 -->
<div class="statistics_card">
<h3>运维奖励(元)</h3>
<div class="statistics_grid">
{% if month_rewards is mapping %}
{% for project_type, reward in month_rewards.items() %}
<div class="statistics_item">
<span class="statistics_label">{{ project_type }}</span>
<span class="statistics_value">{{ "%.2f"|format(reward) }}</span>
</div>
{% endfor %}
{% endif %}
<div class="statistics_item total_reward">
<span class="statistics_label">总计</span>
<span class="statistics_value">{{ "%.2f"|format(month_rewards.values()|sum) if month_rewards is mapping else "%.2f"|format(month_rewards|default(0)) }}</span>
</div>
</div>
</div>
{% if current_user.role == '统计员' %}
<div class="statistics_section">
<h2 class="statistics_title">本周奖励统计</h2>
<div class="statistics_card">
<div class="handler_stats_table">
<table>
<thead>
<tr>
<th>处理人</th>
<th>普通工单</th>
<th>手工工单</th>
<th>校园安防"4+N"项目</th>
<th>综治视联网</th>
<th>教育城域网</th>
<th>其他</th>
<th>预计奖励</th>
</tr>
</thead>
<tbody>
{% for handler, stats in week_handler_stats.items() %}
<tr>
<td>{{ handler }}</td>
<td>{{ stats.work_orders }}</td>
<td>{{ stats.manual_orders }}</td>
<td>{{ "%.2f"|format(stats.rewards['校园安防"4+N"项目']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards['综治视联网']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards['教育城域网']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards['其他']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards.values()|sum) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="statistics_section">
<h2 class="statistics_title">本月奖励统计</h2>
<div class="statistics_card">
<div class="handler_stats_table">
<table>
<thead>
<tr>
<th>处理人</th>
<th>普通工单</th>
<th>手工工单</th>
<th>校园安防"4+N"项目</th>
<th>综治视联网</th>
<th>教育城域网</th>
<th>其他</th>
<th>预计奖励</th>
</tr>
</thead>
<tbody>
{% for handler, stats in month_handler_stats.items() %}
<tr>
<td>{{ handler }}</td>
<td>{{ stats.work_orders }}</td>
<td>{{ stats.manual_orders }}</td>
<td>{{ "%.2f"|format(stats.rewards['校园安防"4+N"项目']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards['综治视联网']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards['教育城域网']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards['其他']|default(0)) }}</td>
<td>{{ "%.2f"|format(stats.rewards.values()|sum) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- 月份选择器模态框 -->
<div id="monthPickerModal" class="modal" style="display: none;">
<div class="modal-content">
<h3>选择导出月份</h3>
<div class="month-picker-form">
<input type="month" id="monthPicker" class="month-input">
<div class="modal-buttons">
<button onclick="exportMonthlyStats()" class="export_button">导出</button>
<button onclick="closeModal()" class="cancel_button">取消</button>
</div>
</div>
</div>
</div>
<script>
function showMonthPicker() {
document.getElementById('monthPickerModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('monthPickerModal').style.display = 'none';
}
function exportMonthlyStats() {
const monthPicker = document.getElementById('monthPicker');
const selectedMonth = monthPicker.value; // 格式:YYYY-MM
if (!selectedMonth) {
alert('请选择月份');
return;
}
// 将 YYYY-MM 格式拆分为年和月
const [year, month] = selectedMonth.split('-');
window.location.href = `/export_monthly_stats/${year}/${month}`;
closeModal();
}
// 点击模态框外部关闭
document.getElementById('monthPickerModal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2 class="mb-3">用户管理</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-info">
{% for message in messages %}
{{ message }}
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead class="table-light">
<tr>
<th>手机号</th>
<th>姓名</th>
<th>所属支局</th>
<th>角色</th>
<th style="width: 160px;">操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<form method="POST" action="{{ url_for('update_user', phone=user.phone) }}">
<td>{{ user.phone }}</td>
<td>{{ user.name }}</td>
<td>
<select name="branch" class="form-select">
<option value="" {% if not user.branch %}selected{% endif %}>未设置</option>
{% for branch in branch_options %}
<option value="{{ branch }}" {% if user.branch == branch %}selected{% endif %}>{{ branch }}</option>
{% endfor %}
</select>
</td>
<td>
<select name="role" class="form-select" required>
{% for role in role_options %}
<option value="{{ role }}" {% if user.role == role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
</td>
<td class="text-center">
<button type="submit" class="btn btn-primary btn-sm">保存</button>
</td>
</form>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+288
View File
@@ -0,0 +1,288 @@
"""
Casdoor 认证工具模块
用于处理 Casdoor OAuth/OIDC 认证流程
"""
import requests
import urllib.parse
from flask import session, current_app
import secrets
class CasdoorAuth:
"""Casdoor 认证处理类"""
def __init__(self, app=None):
self.app = app
if app:
self.init_app(app)
def init_app(self, app):
"""初始化应用配置"""
self.endpoint = app.config.get('CASDOOR_ENDPOINT')
self.client_id = app.config.get('CASDOOR_CLIENT_ID')
self.client_secret = app.config.get('CASDOOR_CLIENT_SECRET')
self.organization_name = app.config.get('CASDOOR_ORGANIZATION_NAME', 'built-in')
self.application_name = app.config.get('CASDOOR_APPLICATION_NAME', 'app-built-in')
self.redirect_uri = app.config.get('CASDOOR_REDIRECT_URI')
def get_authorization_url(self, state=None):
"""
生成 Casdoor 授权 URL
Args:
state: 状态参数,用于防止 CSRF 攻击
Returns:
str: 授权 URL
"""
if not state:
state = secrets.token_urlsafe(32)
session['casdoor_state'] = state
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.redirect_uri,
'scope': 'openid profile email phone',
'state': state
}
auth_url = f"{self.endpoint}/login/oauth/authorize"
return f"{auth_url}?{urllib.parse.urlencode(params)}"
def get_token(self, code):
"""
使用授权码获取访问令牌
Args:
code: 授权码
Returns:
dict: 包含 access_token 和 id_token 的字典
"""
token_url = f"{self.endpoint}/api/login/oauth/access_token"
data = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.redirect_uri
}
try:
response = requests.post(token_url, data=data)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
try:
current_app.logger.error(f"获取 Casdoor token 失败: {str(e)}")
except:
pass # 如果无法记录日志,忽略错误
return None
def get_user_info(self, access_token):
"""
使用访问令牌获取用户信息
Args:
access_token: 访问令牌
Returns:
dict: 用户信息字典
"""
user_info_url = f"{self.endpoint}/api/userinfo"
headers = {
'Authorization': f'Bearer {access_token}'
}
try:
response = requests.get(user_info_url, headers=headers)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
try:
current_app.logger.error(f"获取 Casdoor 用户信息失败: {str(e)}")
except:
pass # 如果无法记录日志,忽略错误
return None
def get_account_info(self, access_token):
"""
使用访问令牌获取完整账户信息(包含标签等字段)
"""
account_url = f"{self.endpoint}/api/get-account"
headers = {
'Authorization': f'Bearer {access_token}'
}
try:
response = requests.get(account_url, headers=headers)
response.raise_for_status()
result = response.json()
# Casdoor 通常返回 {"status": "ok", "data": {...}}
if isinstance(result, dict):
if result.get('status') == 'ok' and isinstance(result.get('data'), dict):
return result['data']
if 'data' in result and isinstance(result.get('data'), dict):
return result['data']
return result if isinstance(result, dict) else None
except requests.exceptions.RequestException as e:
try:
current_app.logger.error(f"获取 Casdoor 账户信息失败: {str(e)}")
except:
pass
return None
def verify_state(self, state):
"""
验证 state 参数,防止 CSRF 攻击
Args:
state: 从回调中获取的 state 参数
Returns:
bool: 验证是否通过
"""
stored_state = session.get('casdoor_state')
if stored_state and stored_state == state:
session.pop('casdoor_state', None)
return True
return False
def login_with_password(self, username, password):
"""
使用用户名和密码通过 API 登录 Casdoor
Args:
username: 用户名
password: 密码
Returns:
dict: 包含 access_token 和用户信息的字典,失败返回 None
"""
# Casdoor 使用 OAuth 2.0 password grant 方式登录
# 端点应该是 /api/login/oauth/access_token
login_url = f"{self.endpoint}/api/login/oauth/access_token"
# OAuth 2.0 password grant 需要的参数
data = {
'grant_type': 'password',
'username': username,
'password': password,
'client_id': self.client_id,
'client_secret': self.client_secret,
'scope': 'openid profile email phone'
}
try:
current_app.logger.info(f"尝试 Casdoor OAuth password grant 登录: username={username}")
except:
pass
try:
# OAuth 2.0 password grant 通常使用 form-data 格式
response = requests.post(login_url, data=data, timeout=10)
# 如果返回 415,尝试 JSON 格式
if response.status_code == 415:
response = requests.post(login_url, json=data, timeout=10)
# 检查响应状态
if response.status_code != 200:
try:
error_msg = response.text[:200] # 限制错误消息长度
current_app.logger.warning(f"Casdoor OAuth 登录失败 (状态码 {response.status_code}): {error_msg}")
except:
pass
return None
# 尝试解析 JSON 响应
try:
result = response.json()
except ValueError as e:
try:
current_app.logger.error(f"Casdoor API 登录响应不是有效的 JSON: {str(e)}, 响应内容: {response.text[:200]}")
except:
pass
return None
# 检查 result 是否为 None
if result is None:
try:
current_app.logger.error("Casdoor API 登录返回 None")
except:
pass
return None
# OAuth 2.0 返回格式通常是 {"access_token": "...", "token_type": "Bearer", ...}
# 或者 Casdoor 格式 {"status": "ok", "data": {"access_token": "..."}}
access_token = None
if isinstance(result, dict):
# 检查是否有错误
if result.get('status') == 'error':
try:
error_msg = result.get('msg', '未知错误')
current_app.logger.warning(f"Casdoor OAuth 登录错误: {error_msg}")
except:
pass
return None
# 标准 OAuth 2.0 格式
access_token = result.get('access_token')
# Casdoor 格式 {"status": "ok", "data": {...}}
if not access_token and result.get('status') == 'ok':
token_data = result.get('data', {})
if isinstance(token_data, dict):
access_token = token_data.get('access_token') or token_data.get('token') or token_data.get('accessToken')
# 如果还是没有,尝试其他可能的字段
if not access_token:
access_token = result.get('token') or result.get('accessToken')
# 如果还是没有 token,尝试从其他字段获取
if not access_token and 'data' in result and isinstance(result.get('data'), dict):
access_token = result['data'].get('access_token') or result['data'].get('token')
if not access_token:
try:
current_app.logger.warning(f"Casdoor API 登录返回格式异常,未找到 token: {result}")
except:
pass
return None
# 使用 token 获取用户信息
user_info = self.get_user_info(access_token)
if not user_info:
return None
return {
'access_token': access_token,
'user_info': user_info
}
except requests.exceptions.Timeout:
try:
current_app.logger.error("Casdoor API 登录超时")
except:
pass
return None
except requests.exceptions.RequestException as e:
try:
current_app.logger.error(f"Casdoor API 登录失败: {str(e)}")
except:
pass
return None
except Exception as e:
try:
current_app.logger.error(f"Casdoor API 登录发生未知错误: {str(e)}")
except:
pass
return None
# 创建全局实例
casdoor_auth = CasdoorAuth()
+175
View File
@@ -0,0 +1,175 @@
from functools import wraps
import time
from flask import current_app
import logging
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import text
from scripts.models import db
from collections import defaultdict
# 简单的内存缓存实现
class QueryCache:
def __init__(self, max_size=100, ttl=300): # 默认缓存300秒
self.cache = {}
self.max_size = max_size
self.ttl = ttl
self.access_times = defaultdict(int)
def get(self, key):
if key in self.cache:
item = self.cache[key]
if time.time() - item['time'] < self.ttl:
self.access_times[key] += 1
return item['value']
else:
# 缓存过期,删除
del self.cache[key]
if key in self.access_times:
del self.access_times[key]
return None
def set(self, key, value):
# 如果缓存满了,删除最少访问的项
if len(self.cache) >= self.max_size:
# 找出访问次数最少的键
if self.access_times:
min_key = min(self.access_times, key=self.access_times.get)
if min_key in self.cache:
del self.cache[min_key]
del self.access_times[min_key]
self.cache[key] = {
'value': value,
'time': time.time()
}
self.access_times[key] = 1
return value
# 创建全局缓存实例
query_cache = QueryCache()
# 数据库操作装饰器 - 用于缓存结果
def cache_query(ttl=300):
"""
缓存查询结果的装饰器
:param ttl: 缓存有效期(秒)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 生成缓存键
cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
# 尝试从缓存获取
cached_result = query_cache.get(cache_key)
if cached_result is not None:
return cached_result
# 执行查询
result = func(*args, **kwargs)
# 设置缓存
query_cache.set(cache_key, result)
return result
return wrapper
return decorator
# 数据库操作装饰器 - 用于异常处理
def db_operation(retries=3, retry_delay=0.5):
"""
数据库操作的装饰器,提供异常处理和重试功能
:param retries: 重试次数
:param retry_delay: 重试延迟(秒)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(retries):
try:
return func(*args, **kwargs)
except SQLAlchemyError as e:
last_error = e
current_app.logger.warning(f"数据库操作失败: {str(e)},尝试重试 ({attempt+1}/{retries})")
# 回滚会话
db.session.rollback()
# 如果不是最后一次尝试,则等待后重试
if attempt < retries - 1:
time.sleep(retry_delay)
# 所有重试都失败
current_app.logger.error(f"数据库操作失败(已重试{retries}次): {str(last_error)}")
raise last_error
return wrapper
return decorator
# 辅助函数:清除特定表相关的所有缓存
def clear_table_cache(table_name):
"""
清除与特定表相关的所有缓存
:param table_name: 表名
"""
keys_to_delete = []
for key in list(query_cache.cache.keys()):
if table_name.lower() in key.lower():
keys_to_delete.append(key)
for key in keys_to_delete:
if key in query_cache.cache:
del query_cache.cache[key]
if key in query_cache.access_times:
del query_cache.access_times[key]
# 数据库连接重试机制
def retry_database_connection(db_instance, app, max_retries=10, retry_delay=5):
"""
尝试连接数据库,在连接失败时进行重试
:param db_instance: SQLAlchemy数据库实例
:param app: Flask应用实例
:param max_retries: 最大重试次数
:param retry_delay: 重试间隔(秒)
:return: 连接成功返回True,失败返回False
"""
import sqlalchemy.exc as sa_exc
for attempt in range(max_retries):
try:
app.logger.info(f"尝试连接数据库... (尝试 {attempt + 1}/{max_retries})")
# 使用with语句确保应用上下文正确
with app.app_context():
# 尝试建立连接 - SQLAlchemy 2.0 兼容方式
with db_instance.engine.connect() as connection:
connection.execute(text("SELECT 1"))
connection.commit() # 确保事务提交
app.logger.info("数据库连接成功!")
return True
except (sa_exc.OperationalError, sa_exc.DatabaseError, Exception) as e:
app.logger.warning(f"数据库连接失败: {str(e)}")
if attempt < max_retries - 1:
app.logger.info(f"等待 {retry_delay} 秒后重试...")
time.sleep(retry_delay)
else:
app.logger.error(f"数据库连接失败,已重试 {max_retries} 次,放弃连接")
return False
return False
# 通用查询函数,包含异常处理和缓存
@db_operation()
@cache_query()
def get_all_distinct_values(model, column_name):
"""
获取指定模型指定列的所有不同值
:param model: 模型类
:param column_name: 列名
:return: 所有不同值的列表
"""
column = getattr(model, column_name)
results = db.session.query(column).distinct().all()
return [result[0] for result in results if result[0]]
+150
View File
@@ -0,0 +1,150 @@
from flask import render_template, jsonify, request, flash, redirect, url_for
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from werkzeug.exceptions import HTTPException, NotFound, Forbidden, BadRequest
import traceback
class ErrorHandler:
"""
全局异常处理器
用于注册Flask应用程序的错误处理函数
"""
def __init__(self, app=None):
if app:
self.init_app(app)
def init_app(self, app):
"""
初始化应用程序的错误处理器
:param app: Flask应用实例
"""
# 处理SQLAlchemy错误
app.register_error_handler(SQLAlchemyError, self.handle_db_error)
app.register_error_handler(IntegrityError, self.handle_integrity_error)
app.register_error_handler(OperationalError, self.handle_operational_error)
# 处理HTTP错误
app.register_error_handler(NotFound, self.handle_not_found)
app.register_error_handler(Forbidden, self.handle_forbidden)
app.register_error_handler(BadRequest, self.handle_bad_request)
# 处理通用错误
app.register_error_handler(Exception, self.handle_generic_error)
# 记录初始化完成
app.logger.info("错误处理器初始化完成")
def handle_db_error(self, error):
"""
处理一般数据库错误
"""
error_details = str(error)
traceback_details = traceback.format_exc()
current_app = self._get_current_app()
current_app.logger.error(f"数据库错误: {error_details}\n{traceback_details}")
return self._handle_error("数据库操作错误",
"抱歉,数据库操作失败,请稍后重试。",
500)
def handle_integrity_error(self, error):
"""
处理数据完整性错误(如违反唯一约束)
"""
error_details = str(error)
current_app = self._get_current_app()
current_app.logger.error(f"数据完整性错误: {error_details}")
# 尝试从错误消息中提取更友好的错误消息
friendly_message = "数据冲突,请检查您的输入。"
if "Duplicate entry" in error_details:
friendly_message = "数据已存在,请检查您的输入。"
return self._handle_error("数据完整性错误", friendly_message, 400)
def handle_operational_error(self, error):
"""
处理数据库操作错误(如连接问题)
"""
error_details = str(error)
current_app = self._get_current_app()
current_app.logger.error(f"数据库操作错误: {error_details}")
return self._handle_error("数据库连接错误",
"抱歉,无法连接到数据库,请稍后重试。",
503)
def handle_not_found(self, error):
"""
处理404错误
"""
return self._handle_error("页面未找到",
"抱歉,您请求的页面不存在。",
404)
def handle_forbidden(self, error):
"""
处理403错误
"""
return self._handle_error("访问被拒绝",
"抱歉,您没有权限访问此页面。",
403)
def handle_bad_request(self, error):
"""
处理400错误
"""
return self._handle_error("请求错误",
"抱歉,服务器无法理解您的请求。",
400)
def handle_generic_error(self, error):
"""
处理未被其他处理程序捕获的通用错误
"""
error_details = str(error)
traceback_details = traceback.format_exc()
current_app = self._get_current_app()
current_app.logger.error(f"未处理异常: {error_details}\n{traceback_details}")
if isinstance(error, HTTPException):
return self._handle_error("请求错误", error.description, error.code)
return self._handle_error("服务器错误",
"抱歉,服务器遇到了未知错误,请稍后重试。",
500)
def _handle_error(self, title, message, status_code):
"""
根据请求类型返回适当的错误响应
:param title: 错误标题
:param message: 错误消息
:param status_code: HTTP状态码
"""
# 根据接受的内容类型决定返回JSON还是HTML
# 检查是否是AJAX请求,is_xhr在新版Flask中已被移除
is_ajax_request = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
if is_ajax_request or request.headers.get('Accept') == 'application/json':
# 返回JSON格式的错误
return jsonify({
'error': title,
'message': message
}), status_code
else:
# 使用Flash消息并重定向到统计页面
from flask import current_app
flash(message)
# 根据错误类型决定重定向页面
if status_code == 404:
return redirect(url_for('statistics'))
elif status_code == 403:
return redirect(url_for('statistics'))
else:
return redirect(url_for('statistics'))
def _get_current_app(self):
"""
获取当前Flask应用实例
"""
from flask import current_app
return current_app
+10
View File
@@ -0,0 +1,10 @@
from PIL import Image
def allowed_file(filename, allowed_extensions):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
def compress_image(image_path, max_size=(1500, 1500), quality=85):
"""压缩图片"""
img = Image.open(image_path)
img.thumbnail(max_size, Image.Resampling.LANCZOS)
img.save(image_path, quality=quality)
+102
View File
@@ -0,0 +1,102 @@
# utils\lzmx.py
import requests
import json
from datetime import datetime
# 推送系统工单处理信息到量子密信
def send_lzmx_message(webhook_url, handler, device_id, device_type, distance_from_branch, restore_time):
headers = {'Content-Type': 'application/json'}
message = {
"type": "text",
"textMsg": {
"content":
f"📢 故障处理通知\n"
f"故障设备:{device_id}\n"
f"处理时间:{restore_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"故障类型:{device_type}\n"
f"距离支局:{distance_from_branch}公里\n"
f"处理人:{handler}"
}
}
try:
response = requests.post(
webhook_url,
headers=headers,
data=json.dumps(message)
)
response.raise_for_status()
return True
except Exception as e:
print(f"发送量子密信消息失败: {str(e)}")
return False
# 推送手工申报工单信息到量子密信
def send_manual_lzmx_message(webhook_url, handler, device_id, fault_type, distance_from_branch, fault_time, project_type, device_location):
headers = {'Content-Type': 'application/json'}
message = {
"type": "text",
"textMsg": {
"content":
f"📝 故障处理通知\n"
f"项目类型:{project_type}\n"
f"故障设备:{device_id}\n"
f"设备位置:{device_location}\n"
f"故障时间:{fault_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"故障类型:{fault_type}\n"
f"距离支局:{distance_from_branch}公里\n"
f"处理人:{handler}"
}
}
try:
response = requests.post(
webhook_url,
headers=headers,
data=json.dumps(message)
)
response.raise_for_status()
return True
except Exception as e:
print(f"发送量子密信消息失败: {str(e)}")
return False
# 每周五统计本周情况推送量子密信
def send_weekly_stats_message(webhook_url, stats):
headers = {'Content-Type': 'application/json'}
# 格式化前三名处理人信息
top_handlers = []
for handler, amount in stats['top_handlers']:
top_handlers.append(f"👤 {handler}: {amount}")
message = {
"type": "text",
"textMsg": {
"content":
f"📊 本周工单统计报告\n"
f"—————————————————\n"
f" 本周派单:{stats['total_orders']}\n"
f" 已处理数:{stats['completed_orders']}\n"
f" 未处理数:{stats['pending_orders']}\n"
f" 手工故障:{stats['manual_orders']}\n\n"
f"🏆 前三名预计奖励:\n"
f"{chr(10).join(top_handlers)}\n"
f"—————————————————\n"
f"统计时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
}
}
try:
response = requests.post(
webhook_url,
headers=headers,
data=json.dumps(message)
)
response.raise_for_status()
return True
except Exception as e:
print(f"发送量子密信消息失败: {str(e)}")
return False
+11
View File
@@ -0,0 +1,11 @@
class Pagination:
def __init__(self, items, page, per_page, total):
self.items = items
self.page = page
self.per_page = per_page
self.total = total
self.pages = (total + per_page - 1) // per_page
self.has_prev = page > 1
self.has_next = page < self.pages
self.prev_num = page - 1
self.next_num = page + 1
+66
View File
@@ -0,0 +1,66 @@
def get_reward_by_distance(project_type, distance):
"""
根据项目类型和距离计算奖励金额
Args:
project_type (str): 项目类型
distance (float): 距离(公里)
Returns:
float: 奖励金额(元)
"""
# 2025.04.21 by v6ole
# 奖励配置说明:
# 格式为: '项目类型': [(距离上限1, 奖励金额1), (距离上限2, 奖励金额2), ...]
# 距离单位: 公里, 金额单位: 元
# 配置按照距离从小到大排序,系统会自动匹配第一个符合条件的奖励金额
rewards_config = {
# 校园安防"4+N"项目奖励标准:
# - 0-5公里: 20元/台
# - 5-30公里: 40元/台
# - 30-50公里: 50元/台
# - 50公里以上: 60元/台
'校园安防"4+N"项目': [
(5, 20),
(30, 40),
(50, 50),
(float('inf'), 60)
],
# 综治视联网奖励标准:
# - 0-10公里: 30元/台
# - 10-30公里: 40元/台
# - 30-50公里: 50元/台
# - 50公里以上: 60元/台
'综治视联网': [
(10, 30),
(30, 40),
(50, 50),
(float('inf'), 60)
],
# 教育城域网奖励标准:
# - 0-5公里: 20元/台
# - 5-30公里: 30元/台
# - 30-50公里: 40元/台
# - 50公里以上: 50元/台
'教育城域网': [
(5, 20),
(30, 30),
(50, 40),
(float('inf'), 50)
],
# 其他项目统一标准:
# - 任何距离: 30元/台
'其他': [(float('inf'), 30)]
}
if project_type not in rewards_config:
return 0
for max_distance, reward in rewards_config[project_type]:
if distance <= max_distance:
return reward
return 0
Executable
+99
View File
@@ -0,0 +1,99 @@
[uwsgi]
# 指定 WSGI 应用的入口模块和对象
# 格式为 module:callable,例如 app:app 表示模块名为 app.py,应用对象为 app
module = app:app
# 指定 uWSGI 监听的 HTTP 地址和端口
# 如果使用 Nginx 反向代理,可以注释掉此行
http = 0.0.0.0:18019
# 设置 uWSGI 启动的进程数
# 根据服务器性能和需求调整进程数
processes = 4
# 设置每个进程的线程数
# 线程数和进程数共同影响并发处理能力
threads = 2
# 启用主进程
master = true
# 优雅地重启工作进程
# 当收到SIGINT信号时,等待当前请求完成后再重启
reload-mercy = 10
# 进程超时时间(秒),如果一个请求处理时间超过这个值,工作进程会被杀死
harakiri = 30
# 接收到请求的队列最大长度,如果队列已满,新的请求将被拒绝
listen = 100
# 最大并发请求数
max-requests = 1000
# 每个工作进程处理完指定数量的请求后自动重启,防止内存泄漏
max-worker-lifetime = 3600
# 缓冲区大小设置
buffer-size = 32768
# 指定日志文件的路径
# uWSGI 的运行日志将写入此文件
logto = /home/v6ole/pyproject/4an/uwsgi.log
# 指定虚拟环境的路径
# uWSGI 将使用此虚拟环境中的 Python 解释器和依赖库
virtualenv = /home/v6ole/pyproject/venv
# 设置应用的根目录
# uWSGI 将切换到此目录运行,确保应用的相对路径正确
chdir = /home/v6ole/pyproject/4an/app
# 设置 PID 文件的路径
# PID 文件用于管理 uWSGI 进程(如停止、重启)
pidfile = /home/v6ole/pyproject/4an/uwsgi.pid
# 设置 Unix Socket 文件的路径
# 用于与 Nginx 或其他代理服务器通信
# 如果使用 HTTP 模式,可以注释掉此行
socket = /home/v6ole/pyproject/4an/uwsgi.sock
# 设置 Socket 文件的权限
# 660 表示只有所有者和所属组有读写权限
chmod-socket = 660
# 当 uWSGI 退出时,自动清理其创建的套接字和 PID 文件
# 保持环境整洁,避免遗留文件
vacuum = true
# 设置日志文件的最大大小(单位为字节)
# 当日志文件达到此大小时,uWSGI 将自动轮转日志
# 示例值为 10MB
log-maxsize = 10000000
# 禁用请求日志,减少IO负担
disable-logging = true
log-slow = 1000 # 只记录处理时间超过1000ms的请求
# 紧急情况下尝试恢复的时间(秒)
worker-reload-mercy = 60
# 自动重载配置
py-autoreload = 1
# 内存报告
memory-report = true
# 启用统计服务器
stats = 127.0.0.1:9191
# 平滑重启
touch-chain-reload = true
touch-reload = uwsgi.reload
reload-on-exception = true # 当出现异常时自动重启
# 启用线程支持
enable-threads = true
# 设置自动异常处理
catch-exceptions = true
+8
View File
@@ -0,0 +1,8 @@
from flask import render_template
from flask_login import login_required
def init_about_routes(app):
@app.route('/about')
@login_required
def about():
return render_template('about.html')
+137
View File
@@ -0,0 +1,137 @@
from flask import render_template, request, redirect, url_for, flash, session
import urllib.parse
from flask_login import login_user, login_required, logout_user, current_user
from scripts.models import db, User
from utils.casdoor_auth import casdoor_auth
def init_auth_routes(app):
# 初始化 Casdoor 认证
casdoor_auth.init_app(app)
@app.route('/login', methods=['GET', 'POST'])
def login():
# 如果已登录,直接跳转到仪表盘
if current_user.is_authenticated:
return redirect(url_for('dashboard'))
# 未登录展示登录页(登录页会自动跳转 Casdoor,如有错误提示则不跳转)
return render_template('login.html')
@app.route('/casdoor/login')
def casdoor_login():
"""重定向到 Casdoor 登录页面"""
if not app.config.get('CASDOOR_CLIENT_ID'):
flash('Casdoor 未配置,无法登录')
return redirect(url_for('login'))
auth_url = casdoor_auth.get_authorization_url()
app.logger.info('重定向到 Casdoor 授权页面')
return redirect(auth_url)
@app.route('/callback')
def casdoor_callback():
"""Casdoor OAuth 回调处理"""
code = request.args.get('code')
state = request.args.get('state')
error = request.args.get('error')
if error:
app.logger.error(f'Casdoor 认证失败: {error}')
flash(f'登录失败: {error}')
return redirect(url_for('login'))
if not code:
app.logger.warning('Casdoor 回调缺少授权码')
flash('登录失败:未收到授权码')
return redirect(url_for('login'))
# 验证 state 参数
if not casdoor_auth.verify_state(state):
app.logger.warning('Casdoor state 验证失败,可能存在 CSRF 攻击')
flash('登录失败:安全验证失败')
return redirect(url_for('login'))
# 获取访问令牌
token_data = casdoor_auth.get_token(code)
if not token_data or 'access_token' not in token_data:
app.logger.error('获取 Casdoor token 失败')
flash('登录失败:无法获取访问令牌')
return redirect(url_for('login'))
access_token = token_data['access_token']
# 获取用户信息
user_info = casdoor_auth.get_user_info(access_token)
if not user_info:
app.logger.error('获取 Casdoor 用户信息失败')
flash('登录失败:无法获取用户信息')
return redirect(url_for('login'))
# 获取完整账户信息(包含标签)
account_info = casdoor_auth.get_account_info(access_token)
if not account_info:
app.logger.error('获取 Casdoor 账户信息失败')
flash('登录失败:无法获取账户信息')
return redirect(url_for('login'))
# 从 Casdoor 用户信息中提取数据
# Casdoor 返回的用户信息可能包含:name, email, phone, id 等字段
casdoor_id = user_info.get('sub') or user_info.get('id') or user_info.get('name')
phone = user_info.get('phone') or user_info.get('phoneNumber') or casdoor_id
name = user_info.get('name') or user_info.get('displayName') or '未知用户'
# 基于标签的访问控制:用户标签必须等于项目名
project_name = app.config.get('PROJECT_NAME')
if not project_name:
app.logger.error('未配置 PROJECT_NAME 环境变量,拒绝登录')
flash('登录失败:未配置项目名')
return redirect(url_for('login'))
raw_tags = account_info.get('tag') or account_info.get('tags')
if isinstance(raw_tags, str):
user_tags = [t.strip() for t in raw_tags.split(',') if t.strip()]
elif isinstance(raw_tags, list):
user_tags = [str(t).strip() for t in raw_tags if str(t).strip()]
else:
user_tags = []
if project_name not in user_tags:
app.logger.warning(f'用户 {name}({phone}) 标签 {user_tags} 不匹配项目 {project_name}')
flash('您没有本系统的访问权限,请联系管理员处理')
return redirect(url_for('login'))
app.logger.info(f'Casdoor 用户信息: {user_info}')
# 查找或创建本地用户
user = User.query.filter_by(phone=phone).first()
if not user:
user = User(
phone=phone,
name=name,
branch=user_info.get('affiliation', ''),
role=user_info.get('type', '')
)
db.session.add(user)
db.session.commit()
app.logger.info(f'创建新用户: {name}({phone})')
else:
user.name = name
if user_info.get('affiliation'):
user.branch = user_info.get('affiliation')
if user_info.get('type'):
user.role = user_info.get('type')
db.session.commit()
app.logger.info(f'更新用户信息: {name}({phone})')
login_user(user)
app.logger.info(f'用户 {user.name}({user.phone}) 通过 Casdoor 登录成功')
# 清除可能残留的未登录提示
session.pop('_flashes', None)
return redirect(url_for('dashboard'))
@app.route('/logout')
@login_required
def logout():
app.logger.info(f'用户 {current_user.name}({current_user.phone}) 登出')
logout_user()
# 仅退出本地会话,保留 Casdoor 登录态
return redirect(url_for('login'))
+198
View File
@@ -0,0 +1,198 @@
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from scripts.models import db, Device, WorkOrder
from collections import defaultdict
from sqlalchemy import func
from utils.db_utils import cache_query, db_operation
def init_dashboard_routes(app):
@app.route('/dashboard')
@login_required
def dashboard():
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问仪表盘页面')
flash('您没有权限访问此页面')
return redirect(url_for('login'))
try:
app.logger.info(f'用户 {current_user.name} 访问仪表盘页面')
# 获取设备在线率数据
overall_stats = get_overall_device_stats()
branch_stats = get_branch_device_stats()
town_stats = get_town_device_stats()
return render_template('dashboard.html',
overall_stats=overall_stats,
branch_stats=branch_stats,
town_stats=town_stats)
except Exception as e:
app.logger.error(f"仪表盘页面加载失败: {str(e)}")
flash('数据加载失败,请稍后重试')
return redirect(url_for('login'))
@cache_query(ttl=300) # 缓存5分钟
def get_overall_device_stats():
"""获取总体设备在线率统计"""
stats = {}
# 获取设备类型列表
device_types = db.session.query(Device.device_type).distinct().all()
device_types = [d[0] for d in device_types if d[0]]
for device_type in device_types:
# 获取该类型的设备总数
total_devices = db.session.query(func.count(Device.device_id))\
.filter(Device.device_type == device_type)\
.scalar() or 0
# 获取该类型的派单数
work_orders = db.session.query(func.count(WorkOrder.order_id))\
.join(Device, WorkOrder.device_id == Device.device_id)\
.filter(Device.device_type == device_type)\
.scalar() or 0
# 计算在线率
if total_devices > 0:
online_rate = ((total_devices - work_orders) / total_devices) * 100
else:
online_rate = 0
stats[device_type] = {
'total': total_devices,
'work_orders': work_orders,
'online_rate': round(online_rate, 2)
}
return stats
@cache_query(ttl=300) # 缓存5分钟
def get_branch_device_stats():
"""获取各支局设备在线率统计"""
stats = {}
# 获取所有支局
branches = db.session.query(Device.branch).distinct().all()
branches = [b[0] for b in branches if b[0]]
# 获取设备类型列表
device_types = db.session.query(Device.device_type).distinct().all()
device_types = [d[0] for d in device_types if d[0]]
for branch in branches:
branch_stats = {}
for device_type in device_types:
# 获取该支局该类型的设备总数
total_devices = db.session.query(func.count(Device.device_id))\
.filter(Device.branch == branch, Device.device_type == device_type)\
.scalar() or 0
# 获取该支局该类型的派单数
work_orders = db.session.query(func.count(WorkOrder.order_id))\
.join(Device, WorkOrder.device_id == Device.device_id)\
.filter(Device.branch == branch, Device.device_type == device_type)\
.scalar() or 0
# 计算在线率
if total_devices > 0:
online_rate = ((total_devices - work_orders) / total_devices) * 100
else:
online_rate = 0
branch_stats[device_type] = {
'total': total_devices,
'work_orders': work_orders,
'online_rate': round(online_rate, 2)
}
# 计算该支局总体在线率
total_branch_devices = db.session.query(func.count(Device.device_id))\
.filter(Device.branch == branch)\
.scalar() or 0
total_branch_orders = db.session.query(func.count(WorkOrder.order_id))\
.join(Device, WorkOrder.device_id == Device.device_id)\
.filter(Device.branch == branch)\
.scalar() or 0
if total_branch_devices > 0:
branch_online_rate = ((total_branch_devices - total_branch_orders) / total_branch_devices) * 100
else:
branch_online_rate = 0
branch_stats['总计'] = {
'total': total_branch_devices,
'work_orders': total_branch_orders,
'online_rate': round(branch_online_rate, 2)
}
stats[branch] = branch_stats
return stats
@cache_query(ttl=300) # 缓存5分钟
def get_town_device_stats():
"""获取各乡镇设备在线率统计"""
stats = {}
# 获取所有乡镇
towns = db.session.query(Device.town).distinct().all()
towns = [t[0] for t in towns if t[0]]
# 获取设备类型列表
device_types = db.session.query(Device.device_type).distinct().all()
device_types = [d[0] for d in device_types if d[0]]
for town in towns:
town_stats = {}
for device_type in device_types:
# 获取该乡镇该类型的设备总数
total_devices = db.session.query(func.count(Device.device_id))\
.filter(Device.town == town, Device.device_type == device_type)\
.scalar() or 0
# 获取该乡镇该类型的派单数
work_orders = db.session.query(func.count(WorkOrder.order_id))\
.join(Device, WorkOrder.device_id == Device.device_id)\
.filter(Device.town == town, Device.device_type == device_type)\
.scalar() or 0
# 计算在线率
if total_devices > 0:
online_rate = ((total_devices - work_orders) / total_devices) * 100
else:
online_rate = 0
town_stats[device_type] = {
'total': total_devices,
'work_orders': work_orders,
'online_rate': round(online_rate, 2)
}
# 计算该乡镇总体在线率
total_town_devices = db.session.query(func.count(Device.device_id))\
.filter(Device.town == town)\
.scalar() or 0
total_town_orders = db.session.query(func.count(WorkOrder.order_id))\
.join(Device, WorkOrder.device_id == Device.device_id)\
.filter(Device.town == town)\
.scalar() or 0
if total_town_devices > 0:
town_online_rate = ((total_town_devices - total_town_orders) / total_town_devices) * 100
else:
town_online_rate = 0
town_stats['总计'] = {
'total': total_town_devices,
'work_orders': total_town_orders,
'online_rate': round(town_online_rate, 2)
}
stats[town] = town_stats
return stats
+298
View File
@@ -0,0 +1,298 @@
from flask import jsonify, request, send_file, session, current_app
from flask_login import login_required, current_user
from scripts.update.zte import LoginClient
from scripts.models import db, Device, WorkOrder, Channel
from datetime import datetime
from sqlalchemy import text, create_engine
import hashlib
import pymysql
import io
import os
def init_device_routes(app):
@app.route('/sync_offline_devices', methods=['POST'])
@login_required
def sync_offline_devices():
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试同步离线设备')
return jsonify({'success': False, 'message': '没有权限执行此操作'})
try:
app.logger.info(f'用户 {current_user.name} 开始同步离线设备')
# 从配置中获取数据库连接信息
db_config = current_app.config['KEY110_DB']
conn = pymysql.connect(**db_config)
cursor = conn.cursor()
# 获取所有教学点的状态
cursor.execute("""
SELECT 教学点名称, 连通状态
FROM IP数据
""")
schools = cursor.fetchall()
# 分别处理在线和离线的教学点
offline_school_names = [school[0].strip().replace(' ', ' ') for school in schools if school[1] == '离线']
online_school_names = [school[0].strip().replace(' ', ' ') for school in schools if school[1] == '在线']
# 关闭1key110数据库连接
cursor.close()
conn.close()
# 处理恢复在线的设备
online_devices = Device.query.filter(
Device.device_id.in_(online_school_names),
Device.is_normal == False # 当前状态为故障的设备
).all()
# 删除恢复在线设备的在途工单
recovered_count = 0
for device in online_devices:
# 查找并删除该设备的在途工单
pending_orders = WorkOrder.query.filter_by(
device_id=device.device_id,
status='待处理'
).all()
for order in pending_orders:
db.session.delete(order)
recovered_count += 1
# 更新设备状态为正常
device.is_normal = True
db.session.add(device)
# 处理离线设备
offline_devices = Device.query.filter(
Device.device_id.in_(offline_school_names),
Device.in_use == True,
Device.is_normal == True
).all()
# 生成工单号
today = datetime.now().strftime('%Y%m%d')
last_work_order = WorkOrder.query.filter(
WorkOrder.order_id.like(f'{today}%')
).order_by(WorkOrder.order_id.desc()).first()
start_number = int(last_work_order.order_id[-4:]) + 1 if last_work_order else 1
# 创建工单
created_count = 0
for index, device in enumerate(offline_devices):
# 检查是否已有在途工单
existing_order = WorkOrder.query.filter_by(
device_id=device.device_id,
status='待处理'
).first()
if existing_order:
continue
current_number = start_number + index
new_number = f"{today}{str(current_number).zfill(4)}"
work_order = WorkOrder(
order_id=new_number,
device_id=device.device_id,
dispatch_time=datetime.now(),
status='待处理',
branch=device.branch
)
db.session.add(work_order)
device.is_normal = False
db.session.add(device)
created_count += 1
db.session.commit()
app.logger.info(f'同步完成:创建了 {created_count} 个工单,恢复了 {recovered_count} 个设备')
return jsonify({
'success': True,
'count': created_count
})
except Exception as e:
app.logger.error(f'同步离线设备失败:{str(e)}')
return jsonify({
'success': False,
'message': str(e)
})
@app.route('/check_existing_orders', methods=['POST'])
@login_required
def check_existing_orders():
device_ids = request.json.get('device_ids', [])
app.logger.info(f'用户 {current_user.name} 检查设备工单状态,设备数量:{len(device_ids)}')
# 查询设备状态
existing_devices = Device.query.filter(
Device.device_id.in_(device_ids),
Device.is_normal == False # 检查设备是否处于故障状态
).with_entities(Device.device_id).all()
# 提取设备ID列表
existing_device_ids = [device.device_id for device in existing_devices]
app.logger.info(f'发现 {len(existing_device_ids)} 个设备存在故障工单')
return jsonify({'existing_devices': existing_device_ids})
@app.route('/get_captcha', methods=['GET'])
def get_captcha():
app.logger.info('用户请求验证码')
try:
client = LoginClient()
cid = client.get_captcha()
# 将 cid 存储在 session 中供后续使用
session['zte_cid'] = cid
# 读取验证码图片并返回给前端
captcha_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
'static', 'captcha.png')
with open(captcha_path, "rb") as f:
app.logger.info('验证码获取成功')
return send_file(
io.BytesIO(f.read()),
mimetype='image/png'
)
except Exception as e:
app.logger.error(f'获取验证码失败:{str(e)}')
return jsonify({'success': False, 'message': '获取验证码失败'})
@app.route('/sync_camera_step1', methods=['POST'])
def sync_camera_step1():
try:
app.logger.info(f'用户 {current_user.name if current_user else "未登录"} 开始同步摄像头第一步')
data = request.get_json()
captcha = data.get('captcha')
cid = session.get('zte_cid')
if not captcha or not cid:
app.logger.warning('同步摄像头失败:验证码或会话ID缺失')
return jsonify({'success': False, 'message': 'Invalid request'})
client = LoginClient()
# 登录
app.logger.info('尝试登录中兴平台')
login_result = client.login(
account="DHDX001",
password=hashlib.sha256("Root1234#".encode()).hexdigest(),
validcode=captcha,
cid=cid
)
if login_result.get("result") != 0:
app.logger.warning('登录中兴平台失败:验证码错误')
return jsonify({'success': False, 'message': 'INVALID_CAPTCHA'})
token = login_result.get("token")
app.logger.info('登录成功,开始获取在线信息')
# 获取在线信息
online_info = client.get_online_info(token)
if online_info.get("result") == 0:
rows = online_info.get("rows", [])
updated_count = 0
offline_count = 0
app.logger.info(f'获取到 {len(rows)} 个设备信息,开始更新数据库')
# 更新 channels 表
for row in rows:
db.session.execute(
text("""
INSERT INTO channels (puid, pu_name, status)
VALUES (:puid, :pu_name, :status)
ON DUPLICATE KEY UPDATE
pu_name = :pu_name, status = :status
"""),
{
'puid': row['puid'],
'pu_name': row['pu_name'],
'status': row['status']
}
)
updated_count += 1
if row['status'] != 'online':
offline_count += 1
db.session.commit()
app.logger.info(f'数据库更新完成,更新了 {updated_count} 条记录,其中离线设备 {offline_count}')
return jsonify({
'success': True,
'updated_count': updated_count,
'offline_count': offline_count
})
app.logger.error('获取在线信息失败')
return jsonify({'success': False, 'message': 'Failed to get online info'})
except Exception as e:
app.logger.error(f'同步摄像头步骤1失败:{str(e)}')
return jsonify({'success': False, 'message': str(e)})
@app.route('/sync_camera_step2', methods=['POST'])
def sync_camera_step2():
try:
app.logger.info(f'用户 {current_user.name if current_user else "未登录"} 开始同步摄像头第二步')
# 更新设备状态
devices = Device.query.filter(
Device.puid.isnot(None),
Device.in_use == True # 添加这个条件,只检查在用设备
).all()
app.logger.info(f'查询到 {len(devices)} 个需要同步的设备')
offline_devices = []
for device in devices:
channel = db.session.query(Channel).filter_by(puid=device.puid).first()
if channel and channel.status != 'online':
device.is_normal = 0
# 检查是否已有在途工单
existing_order = WorkOrder.query.filter_by(
device_id=device.device_id,
status='待处理'
).first()
if not existing_order: # 只添加没有在途工单的设备
offline_devices.append(device)
db.session.commit()
app.logger.info(f'发现 {len(offline_devices)} 个离线设备需要创建工单')
# 为离线设备创建工单
created_count = 0
if offline_devices:
today = datetime.now().strftime('%Y%m%d')
last_work_order = WorkOrder.query.filter(
WorkOrder.order_id.like(f'{today}%')
).order_by(WorkOrder.order_id.desc()).first()
start_number = int(last_work_order.order_id[-4:]) + 1 if last_work_order else 1
for index, device in enumerate(offline_devices):
order_id = f"{today}{str(start_number + index).zfill(4)}"
work_order = WorkOrder(
order_id=order_id,
device_id=device.device_id,
status='待处理',
dispatch_time=datetime.now(),
branch=device.branch # 添加支局信息
)
db.session.add(work_order)
created_count += 1
db.session.commit()
app.logger.info(f'成功创建 {created_count} 个工单')
return jsonify({
'success': True,
'count': created_count
})
except Exception as e:
app.logger.error(f'同步摄像头步骤2失败:{str(e)}')
return jsonify({'success': False, 'message': str(e)})
+135
View File
@@ -0,0 +1,135 @@
from flask import render_template, request, flash, redirect, url_for
from flask_login import login_required, current_user
from scripts.models import db, WorkOrderHandling, ManualWorkOrder
from datetime import datetime
def init_history_routes(app):
@app.route('/history')
@login_required
def history():
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问历史记录')
flash('您没有权限访问此页面')
return redirect(url_for('statistics'))
app.logger.info(f'用户 {current_user.name} 访问历史记录页面')
page = request.args.get('page', 1, type=int)
per_page = 20
search = request.args.get('search', '')
# 查询普通工单
if current_user.role == '统计员':
query1 = WorkOrderHandling.query
query2 = ManualWorkOrder.query
else:
# 装维员只能看到自己处理的工单
query1 = WorkOrderHandling.query.filter_by(handler=current_user.name)
query2 = ManualWorkOrder.query.filter_by(handler=current_user.name)
# 添加搜索条件
if search:
query1 = query1.filter(
db.or_(
WorkOrderHandling.order_id.contains(search),
WorkOrderHandling.device_id.contains(search),
WorkOrderHandling.branch.contains(search),
WorkOrderHandling.handler.contains(search),
WorkOrderHandling.fault_type.contains(search),
WorkOrderHandling.handle_description.contains(search),
WorkOrderHandling.contact_person.contains(search),
WorkOrderHandling.remark.contains(search)
)
)
query2 = query2.filter(
db.or_(
ManualWorkOrder.order_id.contains(search),
ManualWorkOrder.device_id.contains(search),
ManualWorkOrder.branch.contains(search),
ManualWorkOrder.handler.contains(search),
ManualWorkOrder.fault_type.contains(search),
ManualWorkOrder.handle_description.contains(search),
ManualWorkOrder.contact_person.contains(search),
ManualWorkOrder.remark.contains(search)
)
)
# 合并两种工单并按时间排序
handlings1 = query1.all()
handlings2 = query2.all()
# 将手工故障单转换为与普通工单相同的格式
combined_handlings = []
for h in handlings1:
combined_handlings.append({
'order_id': h.order_id,
'branch': h.branch,
'device_id': h.device_id,
'handler': h.handler,
'time': h.restore_time,
'fault_type': h.fault_type,
'type': 'normal'
})
for h in handlings2:
combined_handlings.append({
'order_id': h.order_id,
'branch': h.branch,
'device_id': h.device_id,
'handler': h.handler,
'time': h.create_time,
'fault_type': h.fault_type,
'type': 'manual'
})
# 按时间排序
combined_handlings.sort(key=lambda x: x['time'], reverse=True)
# 手动分页
total = len(combined_handlings)
start = (page - 1) * per_page
end = start + per_page
current_page = combined_handlings[start:end]
# 创建分页对象
class Pagination:
def __init__(self, items, page, per_page, total):
self.items = items
self.page = page
self.per_page = per_page
self.total = total
self.pages = (total + per_page - 1) // per_page
self.has_prev = page > 1
self.has_next = page < self.pages
self.prev_num = page - 1
self.next_num = page + 1
handlings = Pagination(current_page, page, per_page, total)
return render_template('history.html', handlings=handlings, search=search)
@app.route('/history/<string:order_id>')
@login_required
def history_detail(order_id):
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试查看工单详情 {order_id}')
flash('您没有权限访问此页面')
return redirect(url_for('statistics'))
app.logger.info(f'用户 {current_user.name} 查看工单 {order_id} 的详情')
# 根据工单号前缀判断是普通工单还是手工故障单
if order_id.startswith('M'):
handling = ManualWorkOrder.query.get_or_404(order_id)
else:
handling = WorkOrderHandling.query.filter_by(order_id=order_id).first_or_404()
# 装维员只能查看自己处理的工单
if current_user.role == '装维员' and handling.handler != current_user.name:
flash('您没有权限查看此工单')
return redirect(url_for('history'))
# 传递工单类型到模板
order_type = 'manual' if order_id.startswith('M') else 'normal'
return render_template('history_detail.html', handling=handling, order_type=order_type)
+86
View File
@@ -0,0 +1,86 @@
from flask import render_template, request, jsonify, flash, redirect, url_for
from flask_login import login_required, current_user
from scripts.models import db, Device, ManualWorkOrder
from datetime import datetime
import os
from utils.file_utils import allowed_file, compress_image
from utils.lzmx import send_manual_lzmx_message
from config import Config
def init_manual_routes(app):
@app.route('/manual', methods=['GET', 'POST'])
@login_required
def manual():
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问手工故障单页面')
flash('您没有权限访问此页面')
return redirect(url_for('statistics'))
if request.method == 'POST':
app.logger.info(f'用户 {current_user.name} 开始创建手工故障单')
# 处理文件上传
photo_paths = []
if 'photo' in request.files:
files = request.files.getlist('photo')
if len(files) > 5:
flash('最多只能上传5张图片')
return redirect(url_for('manual'))
for file in files:
if file and allowed_file(file.filename, Config.ALLOWED_EXTENSIONS): # 修改这里
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
compress_image(file_path)
photo_paths.append(filename)
# 生成工单号
today = datetime.now().strftime('%Y%m%d')
last_order = ManualWorkOrder.query.filter(
ManualWorkOrder.order_id.like(f'M{today}%')
).order_by(ManualWorkOrder.order_id.desc()).first()
if last_order:
last_number = int(last_order.order_id[-4:])
new_number = f"M{today}{str(last_number + 1).zfill(4)}"
else:
new_number = f"M{today}0001"
# 创建手工故障单
manual_order = ManualWorkOrder(
order_id=new_number,
project_type=request.form['project_type'],
device_id=request.form['device_id'],
device_location=request.form['device_location'],
fault_time=datetime.strptime(request.form['fault_time'], '%Y-%m-%dT%H:%M'),
handler=request.form['handler'],
fault_type=request.form['fault_type'],
handle_description=request.form['handle_description'],
photo_paths=','.join(photo_paths) if photo_paths else None,
contact_person=request.form['contact_person'],
contact_phone=request.form['contact_phone'],
distance_from_branch=request.form['distance_from_branch'],
remark=request.form.get('remark', ''),
branch=current_user.branch
)
db.session.add(manual_order)
db.session.commit()
# 发送量子密信通知
send_manual_lzmx_message(
Config.LZMX_WEBHOOK_URL, # 修改这里
request.form['handler'],
request.form['device_id'],
request.form['fault_type'],
request.form['distance_from_branch'],
manual_order.fault_time,
request.form['project_type'],
request.form['device_location']
)
app.logger.info(f'用户 {current_user.name} 创建手工故障单成功:{new_number}')
flash('手工故障单创建成功')
return redirect(url_for('history'))
return render_template('manual.html')
+326
View File
@@ -0,0 +1,326 @@
from flask import render_template, flash, redirect, url_for, send_file
from flask_login import login_required, current_user
from scripts.models import db, WorkOrder, WorkOrderHandling, ManualWorkOrder
from datetime import datetime, timedelta
from collections import defaultdict
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
from utils.reward import get_reward_by_distance
from scripts.models import Device
import io
def init_statistics_routes(app):
@app.route('/statistics')
@login_required
def statistics():
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问统计页面')
flash('您没有权限访问此页面')
return redirect(url_for('login'))
app.logger.info(f'用户 {current_user.name} 访问统计页面')
# 获取当前时间
now = datetime.now()
# 计算本周开始和结束时间
week_start = now - timedelta(days=now.weekday())
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = week_start + timedelta(days=7)
# 计算本月开始和结束时间
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
month_end = now.replace(year=now.year + 1, month=1, day=1)
else:
month_end = now.replace(month=now.month + 1, day=1)
# 统计普通工单
def get_work_order_stats(start_time, end_time):
total = WorkOrder.query.filter(
WorkOrder.dispatch_time >= start_time,
WorkOrder.dispatch_time < end_time
).count()
completed = WorkOrder.query.filter(
WorkOrder.dispatch_time >= start_time,
WorkOrder.dispatch_time < end_time,
WorkOrder.status == '已处理'
).count()
pending = total - completed
return {'total': total, 'completed': completed, 'pending': pending}
# 统计手工故障单
def get_manual_order_stats(start_time, end_time):
total = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= start_time,
ManualWorkOrder.create_time < end_time
).count()
return total
# 计算奖励金额
def calculate_reward(start_time, end_time):
rewards = defaultdict(float)
# 查询所有已处理的工单
handlings = WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= start_time,
WorkOrderHandling.restore_time < end_time
).all()
# 查询所有手工故障单
manual_orders = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= start_time,
ManualWorkOrder.create_time < end_time
).all()
# 计算普通工单奖励
for handling in handlings:
device = Device.query.get(handling.device_id)
if device:
distance = float(handling.distance_from_branch)
reward = get_reward_by_distance(device.project_type, distance)
rewards[device.project_type] += reward
# 计算手工故障单奖励
for order in manual_orders:
distance = float(order.distance_from_branch)
reward = get_reward_by_distance(order.project_type, distance)
rewards[order.project_type] += reward
return rewards
def get_handler_stats(start_time, end_time):
handler_stats = defaultdict(lambda: {
'work_orders': 0,
'manual_orders': 0,
'rewards': defaultdict(float)
})
# 统计普通工单
handlings = WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= start_time,
WorkOrderHandling.restore_time < end_time
).all()
for handling in handlings:
handler = handling.handler
handler_stats[handler]['work_orders'] += 1
device = Device.query.get(handling.device_id)
if device:
distance = float(handling.distance_from_branch)
reward = get_reward_by_distance(device.project_type, distance)
handler_stats[handler]['rewards'][device.project_type] += reward
# 统计手工故障单
manual_orders = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= start_time,
ManualWorkOrder.create_time < end_time
).all()
for order in manual_orders:
handler = order.handler
handler_stats[handler]['manual_orders'] += 1
distance = float(order.distance_from_branch)
reward = get_reward_by_distance(order.project_type, distance)
handler_stats[handler]['rewards'][order.project_type] += reward
return handler_stats
# 获取统计数据
week_work_stats = get_work_order_stats(week_start, week_end)
month_work_stats = get_work_order_stats(month_start, month_end)
week_manual_stats = get_manual_order_stats(week_start, week_end)
month_manual_stats = get_manual_order_stats(month_start, month_end)
week_rewards = calculate_reward(week_start, week_end)
month_rewards = calculate_reward(month_start, month_end)
week_handler_stats = get_handler_stats(week_start, week_end)
month_handler_stats = get_handler_stats(month_start, month_end)
# 如果是装维员,只显示自己的数据
if current_user.role == '装维员':
week_work_stats = {
'completed': WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= week_start,
WorkOrderHandling.restore_time < week_end,
WorkOrderHandling.handler == current_user.name
).count()
}
month_work_stats = {
'completed': WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= month_start,
WorkOrderHandling.restore_time < month_end,
WorkOrderHandling.handler == current_user.name
).count()
}
week_manual_stats = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= week_start,
ManualWorkOrder.create_time < week_end,
ManualWorkOrder.handler == current_user.name
).count()
month_manual_stats = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= month_start,
ManualWorkOrder.create_time < month_end,
ManualWorkOrder.handler == current_user.name
).count()
# 修改这里:只获取当前装维员的奖励统计
week_rewards = calculate_reward(week_start, week_end)
month_rewards = calculate_reward(month_start, month_end)
week_handler_stats = {current_user.name: week_handler_stats.get(current_user.name, defaultdict(int))}
month_handler_stats = {current_user.name: month_handler_stats.get(current_user.name, defaultdict(int))}
# 从 handler_stats 中提取当前用户的奖励数据
week_rewards = week_handler_stats[current_user.name]['rewards']
month_rewards = month_handler_stats[current_user.name]['rewards']
return render_template('statistics.html',
week_work_stats=week_work_stats,
month_work_stats=month_work_stats,
week_manual_stats=week_manual_stats,
month_manual_stats=month_manual_stats,
week_rewards=week_rewards,
month_rewards=month_rewards,
week_handler_stats=week_handler_stats,
month_handler_stats=month_handler_stats)
@app.route('/export_monthly_stats/<int:year>/<int:month>')
@login_required
def export_monthly_stats(year, month):
if current_user.role != '统计员':
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试导出月度统计')
flash('您没有权限执行此操作')
return redirect(url_for('statistics'))
app.logger.info(f'用户 {current_user.name} 导出 {year}{month}月 统计报表')
# 计算指定月份的开始和结束时间
month_start = datetime(year, month, 1, 0, 0, 0)
if month == 12:
month_end = datetime(year + 1, 1, 1, 0, 0, 0)
else:
month_end = datetime(year, month + 1, 1, 0, 0, 0)
# 检查是否有数据
handlings = WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= month_start,
WorkOrderHandling.restore_time < month_end
).first()
if not handlings:
flash(f'{year}{month}月系统无检修工单')
return redirect(url_for('statistics'))
# 创建工作簿
wb = Workbook()
ws = wb.active
ws.title = "运维计件奖励"
# 设置标题
ws.merge_cells('A1:J1')
title = ws.cell(1, 1, f"{year}{month}月政企重点项目运维各支局检修激励汇总")
title.font = Font(size=14, bold=True)
title.alignment = Alignment(horizontal='center')
# 设置表头
headers = ['序号', '检修单位名称', '检修项目', '检修设备总数(台)',
'单位距支局维护人员所在地址(公里)', '检修设备每个单价(元)',
'检修项目合计数(路)', '本月检修金额(元)', '支局', '检修人员','本月个人检修合计','检修时间']
for col, header in enumerate(headers, 1):
cell = ws.cell(2, col, header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal='center', wrap_text=True)
# 获取指定月份数据
handlings = WorkOrderHandling.query.filter(
WorkOrderHandling.restore_time >= month_start,
WorkOrderHandling.restore_time < month_end
).all()
# 查询本月所有手工故障单
manual_orders = ManualWorkOrder.query.filter(
ManualWorkOrder.create_time >= month_start,
ManualWorkOrder.create_time < month_end
).order_by(ManualWorkOrder.create_time).all()
# 填充数据
row = 3
idx = 0 # 初始化序号计数器
# 处理普通工单
for handling in handlings:
device = db.session.get(Device, handling.device_id)
if not device:
continue
idx += 1 # 增加序号
ws.cell(row, 1, idx) # 序号
ws.cell(row, 2, device.device_id) # 检修单位名称
ws.cell(row, 3, device.project_type) # 检修项目
ws.cell(row, 4, 1) # 检修设备总数
ws.cell(row, 5, handling.distance_from_branch) # 距离
# 计算单价
distance = float(handling.distance_from_branch)
reward = get_reward_by_distance(device.project_type, distance)
ws.cell(row, 6, reward) # 单价
ws.cell(row, 7, 1) # 检修项目合计数
ws.cell(row, 8, reward) # 本月检修金额
ws.cell(row, 9, handling.branch) # 支局
ws.cell(row, 10, handling.handler) # 检修人员
ws.cell(row, 12, handling.restore_time.strftime('%Y-%m-%d %H:%M:%S')) # 检修时间
row += 1
# 处理手工故障单
for manual_order in manual_orders:
idx += 1 # 增加序号
ws.cell(row, 1, idx) # 序号
ws.cell(row, 2, manual_order.device_location) # 检修单位名称
ws.cell(row, 3, manual_order.project_type) # 检修项目
ws.cell(row, 4, 1) # 检修设备总数
ws.cell(row, 5, manual_order.distance_from_branch) # 距离
# 计算单价
distance = float(manual_order.distance_from_branch)
reward = get_reward_by_distance(manual_order.project_type, distance)
ws.cell(row, 6, reward) # 单价
ws.cell(row, 7, 1) # 检修项目合计数
ws.cell(row, 8, reward) # 本月检修金额
ws.cell(row, 9, manual_order.branch) # 支局
ws.cell(row, 10, manual_order.handler) # 检修人员
ws.cell(row, 12, manual_order.create_time.strftime('%Y-%m-%d %H:%M:%S')) # 检修时间
row += 1
idx += 1
# 添加合计行
ws.cell(row, 7, f"合计")
ws.cell(row, 8, f"=SUM(H3:H{row-1})")
ws.cell(row, 8).font = Font(bold=True)
# 设置列宽
ws.column_dimensions['B'].width = 20
ws.column_dimensions['C'].width = 15
ws.column_dimensions['E'].width = 15
ws.column_dimensions['F'].width = 15
ws.column_dimensions['H'].width = 15
# 保存到内存中
excel_file = io.BytesIO()
wb.save(excel_file)
excel_file.seek(0)
# 设置文件名
filename = f'{year}{month}月政企重点项目运维各支局检修激励汇总.xlsx'
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
+56
View File
@@ -0,0 +1,56 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from scripts.models import db, User, Device
def init_user_routes(app):
def is_admin_user():
"""允许统计员/管理员或名称为 Admin 的用户管理账号"""
return current_user.role in ['统计员', '管理员', 'admin'] or current_user.name == 'Admin'
@app.route('/users', methods=['GET'])
@login_required
def user_list():
if not is_admin_user():
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问用户管理')
flash('您没有权限访问此页面')
return redirect(url_for('dashboard'))
users = User.query.order_by(User.name).all()
branch_options = [b[0] for b in db.session.query(Device.branch).distinct().all() if b[0]]
role_options = ['统计员', '装维员', '管理员']
return render_template(
'users.html',
users=users,
branch_options=branch_options,
role_options=role_options
)
@app.route('/users/<phone>', methods=['POST'])
@login_required
def update_user(phone):
if not is_admin_user():
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试修改用户 {phone}')
flash('您没有权限执行此操作')
return redirect(url_for('dashboard'))
user = User.query.filter_by(phone=phone).first()
if not user:
flash('用户不存在')
return redirect(url_for('user_list'))
new_branch = request.form.get('branch', '').strip()
new_role = request.form.get('role', '').strip()
if new_role and new_role not in ['统计员', '装维员', '管理员']:
flash('角色不合法')
return redirect(url_for('user_list'))
user.branch = new_branch
user.role = new_role
db.session.commit()
app.logger.info(f'用户信息已更新: {user.name}({user.phone}) -> branch={user.branch}, role={user.role}')
flash('用户信息已更新')
return redirect(url_for('user_list'))
+379
View File
@@ -0,0 +1,379 @@
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from scripts.models import db, Device, WorkOrder, WorkOrderHandling
from datetime import datetime
import os
from utils.file_utils import allowed_file, compress_image
from utils.lzmx import send_lzmx_message
from config import Config
def init_work_order_routes(app):
# 添加获取支局列表的辅助函数
def get_branches():
branches = db.session.query(Device.branch).distinct().all()
return [branch[0] for branch in branches if branch[0]]
# 派单平台路由
@app.route('/dispatch', methods=['GET', 'POST'])
@login_required
def dispatch():
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
flash('您没有权限访问此页面')
return redirect(url_for('statistics'))
# 处理 POST 请求
if request.method == 'POST':
device_ids = request.form.getlist('device_ids')
if not device_ids:
flash('请选择至少一个设备')
return redirect(url_for('dispatch'))
today = datetime.now().strftime('%Y%m%d')
last_work_order = WorkOrder.query.filter(WorkOrder.order_id.like(f'{today}%')).order_by(WorkOrder.order_id.desc()).first()
# 获取起始编号
if last_work_order:
last_number = int(last_work_order.order_id[-4:])
start_number = last_number + 1
else:
start_number = 1
# 为每个设备生成唯一的工单号
for index, device_id in enumerate(device_ids):
device = db.session.get(Device, device_id)
if not device:
continue
current_number = start_number + index
new_number = f"{today}{str(current_number).zfill(4)}"
work_order = WorkOrder(
order_id=new_number,
device_id=device_id,
dispatch_time=datetime.now(),
status='待处理',
branch=device.branch
)
db.session.add(work_order)
device.is_normal = False
db.session.add(device)
db.session.commit()
flash('派单成功')
return redirect(url_for('dispatch'))
# 处理 GET 请求
# 获取分页参数
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int) # 从请求参数中获取每页显示数量
# 获取搜索词和筛选条件
search = request.args.get('search', '')
selected_town = request.args.get('town', '')
selected_branch = request.args.get('branch', '')
towns = db.session.query(Device.town).distinct().all()
towns = [town[0] for town in towns if town[0]]
branches = db.session.query(Device.branch).distinct().all()
branches = [branch[0] for branch in branches if branch[0]]
query = Device.query
if search:
query = query.filter(
(Device.device_id.contains(search)) |
(Device.town.contains(search)) |
(Device.branch.contains(search)) |
(Device.unit_name.contains(search)) |
(Device.project_type.contains(search)) |
(Device.device_type.contains(search)) |
(Device.remark.contains(search))
)
if selected_town:
query = query.filter(Device.town == selected_town)
if selected_branch:
query = query.filter(Device.branch == selected_branch)
# 分页时使用从请求获取的 per_page 参数
devices = query.filter_by(in_use=True).paginate(page=page, per_page=per_page)
return render_template('dispatch.html', devices=devices, towns=towns, branches=branches)
@app.route('/update_device_remark', methods=['POST'])
@login_required
def update_device_remark():
try:
data = request.get_json()
device_id = data.get('device_id')
remark = data.get('remark', '')
device = Device.query.get(device_id)
if not device:
return jsonify({'success': False, 'message': '设备不存在'})
device.remark = remark
db.session.commit()
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
# 接单平台路由
@app.route('/receive')
@login_required
def receive():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
branch = request.args.get('branch', '')
device_type = request.args.get('device_type', '')
project_type = request.args.get('project_type', '')
# 基础查询,直接join Device表
query = WorkOrder.query.join(Device).filter(WorkOrder.status == '待处理')
# 根据用户权限筛选工单
if current_user.branch != '交付中心':
query = query.filter(Device.branch == current_user.branch)
# 应用分支筛选(仅交付中心可用)
if branch and current_user.branch == '交付中心':
query = query.filter(Device.branch == branch)
# 应用设备类型筛选
if device_type:
query = query.filter(Device.device_type == device_type)
# 应用项目类型筛选
if project_type:
query = query.filter(Device.project_type == project_type)
# 获取总数统计(使用相同的筛选条件)
total_orders = query.count()
# 获取各支局统计
branch_stats = {}
if current_user.branch == '交付中心':
branch_counts = db.session.query(
Device.branch,
db.func.count(WorkOrder.order_id)
).join(WorkOrder, WorkOrder.device_id == Device.device_id
).filter(WorkOrder.status == '待处理'
).group_by(Device.branch).all()
branch_stats = dict(branch_counts)
# 获取筛选选项 - 修正查询方向
device_types_query = db.session.query(Device.device_type).distinct().filter(
Device.device_id.in_(
db.session.query(WorkOrder.device_id).filter(WorkOrder.status == '待处理')
)
)
project_types_query = db.session.query(Device.project_type).distinct().filter(
Device.device_id.in_(
db.session.query(WorkOrder.device_id).filter(WorkOrder.status == '待处理')
)
)
# 根据用户权限进一步筛选选项
if current_user.branch != '交付中心':
device_types_query = device_types_query.filter(Device.branch == current_user.branch)
project_types_query = project_types_query.filter(Device.branch == current_user.branch)
elif branch:
device_types_query = device_types_query.filter(Device.branch == branch)
project_types_query = project_types_query.filter(Device.branch == branch)
device_types = [dt[0] for dt in device_types_query.all() if dt[0]]
project_types = [pt[0] for pt in project_types_query.all() if pt[0]]
# 获取设备类型统计
device_type_stats = {}
if device_types:
device_type_counts = db.session.query(
Device.device_type,
db.func.count(WorkOrder.order_id)
).join(WorkOrder, WorkOrder.device_id == Device.device_id
).filter(WorkOrder.status == '待处理')
# 应用同样的权限筛选
if current_user.branch != '交付中心':
device_type_counts = device_type_counts.filter(Device.branch == current_user.branch)
elif branch:
device_type_counts = device_type_counts.filter(Device.branch == branch)
device_type_counts = device_type_counts.group_by(Device.device_type).all()
device_type_stats = dict(device_type_counts)
# 获取项目类型统计
project_type_stats = {}
if project_types:
project_type_counts = db.session.query(
Device.project_type,
db.func.count(WorkOrder.order_id)
).join(WorkOrder, WorkOrder.device_id == Device.device_id
).filter(WorkOrder.status == '待处理')
# 应用同样的权限筛选
if current_user.branch != '交付中心':
project_type_counts = project_type_counts.filter(Device.branch == current_user.branch)
elif branch:
project_type_counts = project_type_counts.filter(Device.branch == branch)
project_type_counts = project_type_counts.group_by(Device.project_type).all()
project_type_stats = dict(project_type_counts)
# 获取分页数据
work_orders = query.paginate(page=page, per_page=per_page)
# 计算派单时长
for work_order in work_orders.items:
dispatch_time = work_order.dispatch_time
now = datetime.now()
delta = now - dispatch_time
work_order.duration = f"{delta.days}{delta.seconds // 3600}小时{(delta.seconds % 3600) // 60}分钟"
return render_template('receive.html',
work_orders=work_orders,
total_orders=total_orders,
branch_stats=branch_stats,
device_type_stats=device_type_stats,
project_type_stats=project_type_stats,
show_filter=current_user.branch == '交付中心', # 只有交付中心可以看到筛选
branches=get_branches() if current_user.branch == '交付中心' else [], # 只有交付中心可以看到支局列表
selected_branch=branch,
device_types=device_types,
project_types=project_types
)
@app.route('/recall_all_orders', methods=['POST'])
@login_required
def recall_all_orders():
try:
# 获取所有在途工单
orders = WorkOrder.query.filter_by(status='待处理').all()
count = len(orders)
# 恢复设备状态并删除工单
for order in orders:
# 恢复设备状态
device = Device.query.get(order.device_id)
if device:
device.is_normal = True
db.session.add(device)
# 直接删除工单
db.session.delete(order)
db.session.commit()
return jsonify({
'success': True,
'count': count
})
except Exception as e:
return jsonify({
'success': False,
'message': str(e)
})
# 处理工单
@app.route('/receive/<string:work_order_id>', methods=['GET', 'POST'])
@login_required
def receive_detail(work_order_id):
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
if current_user.role not in allowed_roles and current_user.name != 'Admin':
flash('您没有权限访问此页面')
return redirect(url_for('statistics'))
work_order = WorkOrder.query.get_or_404(work_order_id)
device = Device.query.filter_by(device_id=work_order.device_id).first()
if request.method == 'POST':
photo_paths = []
if 'photo' in request.files:
files = request.files.getlist('photo')
if len(files) > 5:
flash('最多只能上传5张图片')
return redirect(url_for('receive_detail', work_order_id=work_order_id))
for file in files:
if file and allowed_file(file.filename, Config.ALLOWED_EXTENSIONS): # 修改这里
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
compress_image(file_path)
photo_paths.append(filename)
# 创建处理工单信息
handling_info = WorkOrderHandling(
order_id=work_order.order_id,
device_id=work_order.device_id,
branch=device.branch if device else None,
restore_time=datetime.now(),
fault_type=request.form['fault_type'],
contact_person=request.form['contact_person'],
contact_phone=request.form['contact_phone'],
handler=request.form['handler'],
handle_description=request.form['handle_description'],
photo_paths=','.join(photo_paths) if photo_paths else None,
distance_from_branch=request.form['distance_from_branch'],
remark=request.form.get('remark', '')
)
# 更新工单状态为已处理
work_order.status = '已处理'
# 更新设备状态为正常
if device:
device.is_normal = True
# 将所有更改添加到会话
db.session.add(handling_info)
db.session.add(work_order)
if device:
db.session.add(device)
# 一次性提交所有更改
db.session.commit()
# 发送量子密信通知
send_lzmx_message(
Config.LZMX_WEBHOOK_URL, # 修改这里
request.form['handler'],
work_order.device_id,
request.form['fault_type'],
request.form['distance_from_branch'],
datetime.now()
)
flash('工单处理成功')
return redirect(url_for('receive'))
return render_template('receive_detail.html', work_order=work_order, device=device)
@app.route('/recall_order/<order_id>')
@login_required
def recall_order(order_id):
# 检查用户权限
if current_user.role != '统计员':
flash('没有权限执行此操作')
return redirect(url_for('receive'))
# 获取工单
work_order = WorkOrder.query.get_or_404(order_id)
# 更新设备状态
device = Device.query.get(work_order.device_id)
if device:
device.is_normal = True
db.session.add(device)
# 删除工单
db.session.delete(work_order)
db.session.commit()
flash('工单已成功追回')
return redirect(url_for('receive'))
+9
View File
@@ -0,0 +1,9 @@
Flask>=2.0.0
Flask-Login>=0.6.0
Flask-Migrate>=3.0.0
Flask-SQLAlchemy>=2.5.0
SQLAlchemy>=1.4.0
python-dotenv>=0.19.0
requests>=2.25.0
PyMySQL>=1.0.0