Compare commits

..

1 Commits

Author SHA1 Message Date
v6ole 15219c0f87 Initial commit 2026-05-29 08:26:43 +08:00
76 changed files with 164 additions and 8281 deletions
+162 -39
View File
@@ -1,53 +1,176 @@
# Python # ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.pyo *$py.class
*.pyc
*.egg-info/ # C extensions
dist/ *.so
# Distribution / packaging
.Python
build/ build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtualenv # PyInstaller
venv/ # Usually these files are written by a python script from a template
env/ # before PyInstaller builds the exe, so as to inject date/other infos into it.
.venv/ *.manifest
*.spec
# Environment # Installer logs
.env pip-log.txt
.env.* pip-delete-this-directory.txt
!.env.example
"app/.env copy"
# Logs # 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 *.log
logs/ local_settings.py
db.sqlite3
db.sqlite3-journal
# uWSGI # Flask stuff:
uwsgi.pid instance/
uwsgi.sock .webassets-cache
uwsgi.log
# IDE # Scrapy stuff:
.idea/ .scrapy
.vscode/
*.swp
*.swo
*~
# OS # Sphinx documentation
.DS_Store docs/_build/
Thumbs.db
# Uploads (keep directory, ignore contents) # PyBuilder
static/uploads/* .pybuilder/
!static/uploads/.gitkeep target/
# Supervisor # Jupyter Notebook
*.conf .ipynb_checkpoints
!app/supervisor.conf
# Backup # IPython
backup/ 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/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# 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
@@ -0,0 +1,2 @@
# 4an-workorder
-45
View File
@@ -1,45 +0,0 @@
# 应用密钥
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
-106
View File
@@ -1,106 +0,0 @@
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)
-57
View File
@@ -1,57 +0,0 @@
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
@@ -1,90 +0,0 @@
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
@@ -1,269 +0,0 @@
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
@@ -1,110 +0,0 @@
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
@@ -1,121 +0,0 @@
.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;
}
File diff suppressed because one or more lines are too long
-315
View File
@@ -1,315 +0,0 @@
/* 仪表盘容器 */
.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
@@ -1,475 +0,0 @@
.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
@@ -1,627 +0,0 @@
/* 筛选区域样式 */
.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
@@ -1,46 +0,0 @@
.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
@@ -1,268 +0,0 @@
/* 容器样式 */
.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;
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long
-271
View File
@@ -1,271 +0,0 @@
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
@@ -1,53 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

-66
View File
@@ -1,66 +0,0 @@
{% 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
@@ -1,137 +0,0 @@
<!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
@@ -1,418 +0,0 @@
{% 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
@@ -1,141 +0,0 @@
{% 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
@@ -1,88 +0,0 @@
{% 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
@@ -1,132 +0,0 @@
{% 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
@@ -1,40 +0,0 @@
{% 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
@@ -1,131 +0,0 @@
{% 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
@@ -1,206 +0,0 @@
{% 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
@@ -1,141 +0,0 @@
{% 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
@@ -1,255 +0,0 @@
{% 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
@@ -1,59 +0,0 @@
{% 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
@@ -1,288 +0,0 @@
"""
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
@@ -1,175 +0,0 @@
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
@@ -1,150 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,102 +0,0 @@
# 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
@@ -1,11 +0,0 @@
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
@@ -1,66 +0,0 @@
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
-99
View File
@@ -1,99 +0,0 @@
[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
@@ -1,8 +0,0 @@
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
@@ -1,137 +0,0 @@
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
@@ -1,198 +0,0 @@
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
@@ -1,298 +0,0 @@
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
@@ -1,135 +0,0 @@
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
@@ -1,86 +0,0 @@
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
@@ -1,326 +0,0 @@
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
@@ -1,56 +0,0 @@
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
@@ -1,379 +0,0 @@
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
@@ -1,9 +0,0 @@
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