Compare commits
7 Commits
15219c0f87
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ccb116f80 | |||
| 2d5c5d4c0d | |||
| 539a981567 | |||
| 0bb323bbff | |||
| bb39314940 | |||
| 0f41063688 | |||
| c7dc4d6c02 |
@@ -1,176 +1,53 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.pyo
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
dist/
|
||||
build/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
# Virtualenv
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
"app/.env copy"
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# uWSGI
|
||||
uwsgi.pid
|
||||
uwsgi.sock
|
||||
uwsgi.log
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
# Uploads (keep directory, ignore contents)
|
||||
static/uploads/*
|
||||
!static/uploads/.gitkeep
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
# Supervisor
|
||||
*.conf
|
||||
!app/supervisor.conf
|
||||
|
||||
# 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
|
||||
# Backup
|
||||
backup/
|
||||
|
||||
# Captcha
|
||||
app/static/captcha.png
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 应用密钥
|
||||
SECRET_KEY=kU/W5y3uFbWuK1x+RTTOPgLp38yM6Z7d
|
||||
|
||||
# 数据库连接
|
||||
SQLALCHEMY_DATABASE_URI=mysql+pymysql://4an:GtbJp6Ai5azntnTB@10.10.10.14/4an
|
||||
|
||||
# 量子密信消息Webhook
|
||||
LZMX_WEBHOOK_URL=http://imtwo.zdxlz.com/im-external/v1/webhook/send?key=ef1dc605827f425e96491ce5725b5274
|
||||
|
||||
# 文件上传目录
|
||||
UPLOAD_FOLDER=static/uploads
|
||||
|
||||
# 1key110数据库配置
|
||||
KEY110_DB_HOST=10.10.10.14
|
||||
KEY110_DB_USER=1key110
|
||||
KEY110_DB_PASSWORD=KzdKtf7KfkXe7iCN
|
||||
KEY110_DB_DATABASE=1key110
|
||||
|
||||
# uWSGI基本配置
|
||||
UWSGI_HTTP=0.0.0.0:18019
|
||||
UWSGI_PROCESSES=4
|
||||
UWSGI_THREADS=2
|
||||
UWSGI_LOG_FILE=/home/v6ole/pyproject/4an/uwsgi.log
|
||||
UWSGI_VIRTUALENV=/home/v6ole/pyproject/venv/
|
||||
UWSGI_CHDIR=/home/v6ole/pyproject/4an/app
|
||||
UWSGI_PID_FILE=/home/v6ole/pyproject/4an/uwsgi.pid
|
||||
UWSGI_SOCKET=/home/v6ole/pyproject/4an/uwsgi.sock
|
||||
|
||||
# uWSGI性能优化配置
|
||||
UWSGI_MASTER=true
|
||||
UWSGI_HARAKIRI=30
|
||||
UWSGI_MAX_REQUESTS=1000
|
||||
UWSGI_BUFFER_SIZE=32768
|
||||
UWSGI_DISABLE_LOGGING=true
|
||||
UWSGI_MEMORY_REPORT=true
|
||||
UWSGI_ENABLE_THREADS=true
|
||||
|
||||
# casdoor配置
|
||||
CASDOOR_ENDPOINT=https://casdoor.dhdx.fun
|
||||
CASDOOR_CLIENT_ID=8f323670e073612794ef
|
||||
CASDOOR_CLIENT_SECRET=bc37d89f9220fe5a46c17125c731d2daf22f1299
|
||||
CASDOOR_REDIRECT_URI=https://zd.dhdx.fun/callback
|
||||
CASDOOR_ORGANIZATION_NAME=dahua
|
||||
CASDOOR_APPLICATION_NAME=4an
|
||||
PROJECT_NAME=4an
|
||||
@@ -0,0 +1,106 @@
|
||||
from flask import Flask, redirect, url_for
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from scripts.models import db, User, Device, WorkOrder, WorkOrderHandling, ManualWorkOrder
|
||||
from config import Config
|
||||
from views.auth import init_auth_routes
|
||||
from views.work_order import init_work_order_routes
|
||||
from views.statistics import init_statistics_routes
|
||||
from views.history import init_history_routes
|
||||
from views.manual import init_manual_routes
|
||||
from views.device import init_device_routes
|
||||
from views.about import init_about_routes
|
||||
from views.dashboard import init_dashboard_routes
|
||||
from views.user import init_user_routes
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
from utils.error_handlers import ErrorHandler
|
||||
from utils.db_utils import retry_database_connection
|
||||
|
||||
def create_app():
|
||||
# 创建应用实例
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 配置日志记录
|
||||
if not os.path.exists('logs'):
|
||||
os.makedirs('logs')
|
||||
|
||||
# 创建日志处理器,设置最大文件大小为10MB,保留5个备份文件
|
||||
file_handler = RotatingFileHandler(
|
||||
'logs/app.log',
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
# 设置日志格式
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s %(levelname)s: %(message)s',
|
||||
'%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# 设置日志级别
|
||||
file_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.info('应用启动')
|
||||
|
||||
# 初始化数据库
|
||||
db.init_app(app)
|
||||
|
||||
# 确保数据库连接成功后再继续启动
|
||||
app.logger.info("正在连接数据库...")
|
||||
if not retry_database_connection(db, app):
|
||||
raise RuntimeError("无法连接到数据库,应用启动失败")
|
||||
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
# 初始化异常处理器
|
||||
error_handler = ErrorHandler(app)
|
||||
|
||||
# 初始化登录管理器
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'login'
|
||||
# 默认未登录提示改为中文
|
||||
login_manager.login_message = '请先登录以访问此页面'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
try:
|
||||
return db.session.get(User, user_id)
|
||||
except Exception as e:
|
||||
app.logger.error(f"加载用户时出错: {str(e)}")
|
||||
return None
|
||||
|
||||
# 注册路由
|
||||
@app.route('/')
|
||||
def index():
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
# 初始化各模块路由
|
||||
init_auth_routes(app)
|
||||
init_work_order_routes(app)
|
||||
init_statistics_routes(app)
|
||||
init_history_routes(app)
|
||||
init_manual_routes(app)
|
||||
init_device_routes(app)
|
||||
init_about_routes(app)
|
||||
init_dashboard_routes(app)
|
||||
init_user_routes(app)
|
||||
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
with app.app_context():
|
||||
try:
|
||||
db.create_all()
|
||||
app.logger.info("数据库表创建成功")
|
||||
except Exception as e:
|
||||
app.logger.error(f"创建数据库表时出错: {str(e)}")
|
||||
app.run(host='0.0.0.0', port=18019, debug=True)
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 尽量兼容多种 .env 放置方式
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent # /home/.../4an
|
||||
# 1. 优先加载项目根目录 .env
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
# 2. 兼容放在 app 目录下的 .env
|
||||
load_dotenv(BASE_DIR / 'app' / '.env')
|
||||
# 3. 再加载一次默认搜索路径(当前工作目录及其父级)
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""配置类,支持从环境变量获取配置
|
||||
环境变量优先级高于默认值,可在系统环境变量中设置这些参数
|
||||
"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||
# 优先读取 SQLALCHEMY_DATABASE_URI,兼容旧配置;否则回退到 DATABASE_URI
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') or os.environ.get('DATABASE_URI')
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True, # 每次取连接前验证是否可用
|
||||
'pool_recycle': 1800, # 连接空闲 30 分钟后自动回收重建
|
||||
'pool_timeout': 10, # 等待连接池超时(秒)
|
||||
'connect_args': {
|
||||
'connect_timeout': 5, # 连接 MySQL 超时
|
||||
'read_timeout': 15, # 读取超时
|
||||
'write_timeout': 15, # 写入超时
|
||||
},
|
||||
}
|
||||
LZMX_WEBHOOK_URL = os.environ.get('LZMX_WEBHOOK_URL')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'static/uploads')
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
|
||||
|
||||
# 添加1key110数据库配置
|
||||
KEY110_DB = {
|
||||
'host': os.environ.get('KEY110_DB_HOST'),
|
||||
'user': os.environ.get('KEY110_DB_USER'),
|
||||
'password': os.environ.get('KEY110_DB_PASSWORD'),
|
||||
'database': os.environ.get('KEY110_DB_DATABASE')
|
||||
}
|
||||
|
||||
# Casdoor 配置
|
||||
CASDOOR_ENDPOINT = os.environ.get('CASDOOR_ENDPOINT', 'http://localhost:8000')
|
||||
CASDOOR_CLIENT_ID = os.environ.get('CASDOOR_CLIENT_ID')
|
||||
CASDOOR_CLIENT_SECRET = os.environ.get('CASDOOR_CLIENT_SECRET')
|
||||
CASDOOR_ORGANIZATION_NAME = os.environ.get('CASDOOR_ORGANIZATION_NAME', 'built-in')
|
||||
CASDOOR_APPLICATION_NAME = os.environ.get('CASDOOR_APPLICATION_NAME', 'app-built-in')
|
||||
CASDOOR_REDIRECT_URI = os.environ.get('CASDOOR_REDIRECT_URI', 'http://localhost:18061/callback')
|
||||
# 项目名称(用于与 Casdoor 用户标签匹配权限)
|
||||
PROJECT_NAME = os.environ.get('PROJECT_NAME')
|
||||
|
||||
# 会话 Cookie 配置(跨域 SSO 时建议设置)
|
||||
SESSION_COOKIE_SAMESITE = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
|
||||
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true'
|
||||
@@ -0,0 +1,90 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime, timezone
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
# 用户表
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'user'
|
||||
phone = db.Column(db.String(20), primary_key=True, comment="手机号码")
|
||||
name = db.Column(db.String(50), comment="姓名")
|
||||
branch = db.Column(db.String(50), comment="所属支局")
|
||||
role = db.Column(db.String(20), comment="角色")
|
||||
def get_id(self):
|
||||
return self.phone # 使用手机号码作为唯一标识符
|
||||
|
||||
# 设备表
|
||||
class Device(db.Model):
|
||||
__tablename__ = 'device'
|
||||
device_id = db.Column(db.String(100), primary_key=True, comment="设备编号")
|
||||
town = db.Column(db.String(50), comment="所属乡镇")
|
||||
branch = db.Column(db.String(50), comment="所属支局")
|
||||
unit_name = db.Column(db.String(100), comment="单位名称")
|
||||
project_type = db.Column(db.String(50), comment="项目类型")
|
||||
account = db.Column(db.String(20), comment="关联宽带账号")
|
||||
device_type = db.Column(db.String(50), comment="设备类型")
|
||||
puid = db.Column(db.String(100), comment="通道标识") # 添加 puid 字段
|
||||
remark = db.Column(db.String(200), comment="备注")
|
||||
in_use = db.Column(db.Boolean, default=True, comment="是否在用")
|
||||
is_normal = db.Column(db.Boolean, default=True, comment="是否正常")
|
||||
|
||||
# 工单表
|
||||
class WorkOrder(db.Model):
|
||||
__tablename__ = 'work_order'
|
||||
order_id = db.Column(db.String(20), primary_key=True, comment="工单号(日期+编号)")
|
||||
device_id = db.Column(db.String(100), db.ForeignKey('device.device_id'), comment="设备编号") # 从20改为100
|
||||
dispatch_time = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), comment="派单时间")
|
||||
status = db.Column(db.String(20), default='待处理', comment="工单状态")
|
||||
branch = db.Column(db.String(50), comment="所属支局")
|
||||
# 与设备表的关系
|
||||
device = db.relationship('Device', backref='work_orders')
|
||||
|
||||
# 处理工单信息表
|
||||
class WorkOrderHandling(db.Model):
|
||||
__tablename__ = 'work_order_handling'
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
order_id = db.Column(db.String(20), db.ForeignKey('work_order.order_id'), comment="工单号")
|
||||
device_id = db.Column(db.String(100), db.ForeignKey('device.device_id'), comment="设备编号") # 从20改为100
|
||||
branch = db.Column(db.String(50), comment="所属支局")
|
||||
restore_time = db.Column(db.DateTime, comment="恢复时间")
|
||||
fault_type = db.Column(db.String(100), comment="故障类型")
|
||||
contact_person = db.Column(db.String(50), comment="故障对接人")
|
||||
contact_phone = db.Column(db.String(20), comment="联系电话")
|
||||
handler = db.Column(db.String(50), comment="故障处理人")
|
||||
handle_description = db.Column(db.String(500), comment="处理描述")
|
||||
photo_paths = db.Column(db.String(500), comment="现场图片路径")
|
||||
distance_from_branch = db.Column(db.Float, comment="距离支局公里数")
|
||||
remark = db.Column(db.String(500), comment="备注")
|
||||
# 与工单表的关系
|
||||
work_order = db.relationship('WorkOrder', backref='handling_info')
|
||||
|
||||
# 手工故障单表
|
||||
class ManualWorkOrder(db.Model):
|
||||
__tablename__ = 'manual_work_order'
|
||||
order_id = db.Column(db.String(20), primary_key=True, comment="工单号")
|
||||
project_type = db.Column(db.String(50), comment="项目类型")
|
||||
device_id = db.Column(db.String(100), comment="故障设备")
|
||||
device_location = db.Column(db.String(200), comment="设备位置")
|
||||
fault_time = db.Column(db.DateTime, comment="故障时间")
|
||||
handler = db.Column(db.String(50), comment="故障处理人")
|
||||
fault_type = db.Column(db.String(100), comment="故障类型")
|
||||
handle_description = db.Column(db.String(500), comment="处理描述")
|
||||
photo_paths = db.Column(db.String(500), comment="现场图片路径")
|
||||
contact_person = db.Column(db.String(50), comment="故障对接人")
|
||||
contact_phone = db.Column(db.String(20), comment="联系电话")
|
||||
distance_from_branch = db.Column(db.Float, comment="距离支局公里数")
|
||||
remark = db.Column(db.String(500), comment="备注")
|
||||
create_time = db.Column(db.DateTime, default=datetime.now, comment="创建时间")
|
||||
branch = db.Column(db.String(50), comment="所属支局")
|
||||
|
||||
class Channel(db.Model):
|
||||
__tablename__ = 'channels'
|
||||
|
||||
puid = db.Column(db.String(50), primary_key=True)
|
||||
pu_name = db.Column(db.String(200))
|
||||
status = db.Column(db.String(20))
|
||||
update_time = db.Column(db.String(50))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Channel {self.puid}>'
|
||||
@@ -0,0 +1,269 @@
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import uuid
|
||||
from sqlalchemy import select, create_engine, Table, Column, String, MetaData, text
|
||||
from sqlalchemy.dialects.mysql import insert
|
||||
|
||||
class LoginClient:
|
||||
def __init__(self):
|
||||
self.base_url = "https://116.10.244.81:28001"
|
||||
self.session = requests.Session()
|
||||
# 禁用SSL验证警告
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
def get_captcha(self):
|
||||
"""获取验证码图片"""
|
||||
# 生成随机cid
|
||||
cid = uuid.uuid4().hex.encode()
|
||||
cid = hashlib.sha256(cid).hexdigest()
|
||||
|
||||
url = f"{self.base_url}/api/vss-auth-center/v1/captcha"
|
||||
headers = {
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
|
||||
"Referer": f"{self.base_url}/vss-em-iui/"
|
||||
}
|
||||
params = {"cid": cid}
|
||||
|
||||
response = self.session.get(url, headers=headers, params=params, verify=False)
|
||||
|
||||
# 缓存验证码到本地
|
||||
captcha_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
'static', 'captcha.png')
|
||||
with open(captcha_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return cid
|
||||
|
||||
def login(self, account, password, validcode, cid):
|
||||
"""执行登录"""
|
||||
url = f"{self.base_url}/api/vss-auth-center/v1/user/login"
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
|
||||
"Referer": f"{self.base_url}/vss-em-iui/",
|
||||
"language": "zh-CN"
|
||||
}
|
||||
|
||||
params = {
|
||||
"cutype": "12"
|
||||
}
|
||||
|
||||
data = {
|
||||
"account": account,
|
||||
"password": password,
|
||||
"validcode": validcode,
|
||||
"cid": cid
|
||||
}
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=data,
|
||||
verify=False
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
def get_online_info(self, token, page=1, page_size=1500):
|
||||
"""获取在线信息"""
|
||||
url = f"{self.base_url}/api/vss-user-business/v1/pus/queryPage/4109"
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"Authoration": token,
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/json",
|
||||
"Referer": f"{self.base_url}/vss-em-iui/",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
|
||||
"language": "zh-CN"
|
||||
}
|
||||
|
||||
# 生成防伪标识
|
||||
forgery_defense = uuid.uuid4().hex
|
||||
|
||||
params = {
|
||||
"page_size": page_size,
|
||||
"page": page,
|
||||
"pu_name": "",
|
||||
"puid": "",
|
||||
"protocol": "",
|
||||
"nodeid": "",
|
||||
"popunitid": "",
|
||||
"status": "",
|
||||
"forgerydefense": forgery_defense,
|
||||
"csrftoken": forgery_defense
|
||||
}
|
||||
|
||||
response = self.session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
verify=False
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
def get_existing_channels(engine, channels_table):
|
||||
"""获取数据库中现有的通道状态"""
|
||||
with engine.connect() as conn:
|
||||
query = select(channels_table.c.puid, channels_table.c.status)
|
||||
result = conn.execute(query)
|
||||
return {row.puid: row.status for row in result}
|
||||
|
||||
def update_device_status(engine):
|
||||
"""更新设备状态"""
|
||||
with engine.connect() as conn:
|
||||
# 获取channels表和device表的数据
|
||||
channels_query = """
|
||||
SELECT puid, status, pu_name
|
||||
FROM channels
|
||||
"""
|
||||
channels_result = conn.execute(text(channels_query))
|
||||
channels_data = {row.puid: {'status': row.status, 'pu_name': row.pu_name} for row in channels_result}
|
||||
|
||||
# 获取device表中的puid
|
||||
device_query = """
|
||||
SELECT puid
|
||||
FROM device
|
||||
"""
|
||||
device_result = conn.execute(text(device_query))
|
||||
|
||||
# 更新设备状态
|
||||
updates = []
|
||||
for row in device_result:
|
||||
puid = row.puid
|
||||
if puid in channels_data:
|
||||
# 更新在线状态
|
||||
is_normal = 1 if channels_data[puid]['status'] == 'online' else 0
|
||||
|
||||
# 检查使用状态
|
||||
pu_name = channels_data[puid]['pu_name']
|
||||
in_use = 0 if any(keyword in pu_name for keyword in ['无学生', '丢失']) else 1
|
||||
|
||||
# 构建更新语句
|
||||
update_query = """
|
||||
UPDATE device
|
||||
SET is_normal = :is_normal, in_use = :in_use
|
||||
WHERE puid = :puid
|
||||
"""
|
||||
updates.append({
|
||||
'puid': puid,
|
||||
'is_normal': is_normal,
|
||||
'in_use': in_use
|
||||
})
|
||||
|
||||
if updates:
|
||||
update_stmt = text("""
|
||||
UPDATE device
|
||||
SET is_normal = :is_normal, in_use = :in_use
|
||||
WHERE puid = :puid
|
||||
""")
|
||||
conn.execute(update_stmt, updates)
|
||||
conn.commit()
|
||||
print(f"成功更新 {len(updates)} 个设备状态")
|
||||
|
||||
def main():
|
||||
client = LoginClient()
|
||||
|
||||
while True:
|
||||
cid = client.get_captcha()
|
||||
print("验证码已保存为captcha.png")
|
||||
validcode = input("请查看captcha.png并输入验证码: ")
|
||||
password = "Root1234#"
|
||||
hashed_password = hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
result = client.login(
|
||||
account="DHDX001",
|
||||
password=hashed_password,
|
||||
validcode=validcode,
|
||||
cid=cid
|
||||
)
|
||||
|
||||
if result.get("result") == 0:
|
||||
token = result.get("token")
|
||||
|
||||
online_info = client.get_online_info(token)
|
||||
if online_info.get("result") == 0:
|
||||
rows = online_info.get("rows", [])
|
||||
filtered_rows = [
|
||||
row for row in rows
|
||||
if "大化4+N摄像头" not in row.get("pu_name", "")
|
||||
and "大化幼儿园监控" not in row.get("pu_name", "")
|
||||
]
|
||||
|
||||
engine = create_engine('mysql+pymysql://4an:GtbJp6Ai5azntnTB@10.10.10.253/4an')
|
||||
metadata = MetaData()
|
||||
channels = Table(
|
||||
'channels', metadata,
|
||||
Column('puid', String(50), primary_key=True),
|
||||
Column('pu_name', String(200)),
|
||||
Column('status', String(20)),
|
||||
Column('update_time', String(50))
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
existing_channels = get_existing_channels(engine, channels)
|
||||
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
update_data = []
|
||||
|
||||
for row in filtered_rows:
|
||||
puid = row.get('puid')
|
||||
current_status = row.get('status')
|
||||
|
||||
if puid not in existing_channels or existing_channels[puid] != current_status:
|
||||
|
||||
update_data.append({
|
||||
'puid': puid,
|
||||
'pu_name': row.get('pu_name'),
|
||||
'status': current_status,
|
||||
'update_time': current_time
|
||||
})
|
||||
|
||||
# 执行更新
|
||||
if update_data:
|
||||
with engine.connect() as conn:
|
||||
stmt = insert(channels).on_duplicate_key_update(
|
||||
pu_name=channels.c.pu_name,
|
||||
status=channels.c.status,
|
||||
update_time=channels.c.update_time
|
||||
)
|
||||
conn.execute(stmt, update_data)
|
||||
conn.commit()
|
||||
print(f"\n成功更新 {len(update_data)} 条记录")
|
||||
else:
|
||||
print("\n没有需要更新的记录")
|
||||
|
||||
# 更新设备状态
|
||||
print("\n开始更新设备状态...")
|
||||
update_device_status(engine)
|
||||
|
||||
return
|
||||
else:
|
||||
print("获取通道信息失败!")
|
||||
print(f"错误信息: {online_info.get('err_msg', '未知错误')}")
|
||||
return # 获取信息失败也退出程序
|
||||
|
||||
else:
|
||||
print("登录失败!")
|
||||
print(result)
|
||||
if result.get("result") == 12: # 验证码错误
|
||||
retry = input("验证码错误,是否重新获取验证码?(y/n): ")
|
||||
if retry.lower() != 'y':
|
||||
return
|
||||
else:
|
||||
return # 其他错误直接退出
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from scripts.models import db, Device, WorkOrder, WorkOrderHandling, ManualWorkOrder
|
||||
from utils.lzmx import send_weekly_stats_message
|
||||
from flask import Flask
|
||||
|
||||
# 创建一个简单的 Flask 应用
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://4an:GtbJp6Ai5azntnTB@10.10.10.253/4an'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
LZMX_WEBHOOK_URL = "http://imtwo.zdxlz.com/im-external/v1/webhook/send?key=ef1dc605827f425e96491ce5725b5274"
|
||||
|
||||
# 初始化数据库
|
||||
db.init_app(app)
|
||||
|
||||
def get_reward_by_distance(project_type, distance):
|
||||
if project_type == '校园安防"4+N"项目':
|
||||
if distance <= 5: return 20
|
||||
elif distance <= 30: return 40
|
||||
elif distance <= 50: return 50
|
||||
else: return 60
|
||||
elif project_type == '综治视联网':
|
||||
if distance <= 10: return 30
|
||||
elif distance <= 30: return 40
|
||||
elif distance <= 50: return 50
|
||||
else: return 60
|
||||
elif project_type == '教育城域网':
|
||||
if distance <= 5: return 20
|
||||
elif distance <= 30: return 30
|
||||
elif distance <= 50: return 40
|
||||
else: return 50
|
||||
elif project_type == '其他':
|
||||
return 30
|
||||
return 0
|
||||
|
||||
def weekly_stats():
|
||||
with app.app_context():
|
||||
# 计算本周的开始和结束时间
|
||||
now = datetime.now()
|
||||
week_start = now - timedelta(days=now.weekday())
|
||||
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_end = now
|
||||
|
||||
# 统计工单数据
|
||||
total_orders = WorkOrder.query.filter(
|
||||
WorkOrder.dispatch_time >= week_start,
|
||||
WorkOrder.dispatch_time < week_end
|
||||
).count()
|
||||
|
||||
completed_orders = WorkOrder.query.filter(
|
||||
WorkOrder.dispatch_time >= week_start,
|
||||
WorkOrder.dispatch_time < week_end,
|
||||
WorkOrder.status == '已处理'
|
||||
).count()
|
||||
|
||||
manual_orders = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= week_start,
|
||||
ManualWorkOrder.create_time < week_end
|
||||
).count()
|
||||
|
||||
# 获取处理人统计
|
||||
handler_stats = defaultdict(float)
|
||||
|
||||
# 统计普通工单奖励
|
||||
handlings = WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= week_start,
|
||||
WorkOrderHandling.restore_time < week_end
|
||||
).all()
|
||||
|
||||
for handling in handlings:
|
||||
device = db.session.get(Device, handling.device_id) # 更新为新的方法
|
||||
if device:
|
||||
distance = float(handling.distance_from_branch)
|
||||
reward = get_reward_by_distance(device.project_type, distance)
|
||||
handler_stats[handling.handler] += reward
|
||||
|
||||
# 统计手工故障单奖励
|
||||
manual_orders_list = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= week_start,
|
||||
ManualWorkOrder.create_time < week_end
|
||||
).all()
|
||||
|
||||
for order in manual_orders_list:
|
||||
distance = float(order.distance_from_branch)
|
||||
reward = get_reward_by_distance(order.project_type, distance)
|
||||
handler_stats[order.handler] += reward
|
||||
|
||||
# 获取前三名
|
||||
top_handlers = sorted(handler_stats.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
|
||||
# 准备统计数据
|
||||
stats = {
|
||||
'total_orders': total_orders,
|
||||
'completed_orders': completed_orders,
|
||||
'pending_orders': total_orders - completed_orders,
|
||||
'manual_orders': manual_orders,
|
||||
'top_handlers': top_handlers
|
||||
}
|
||||
|
||||
# 发送统计消息
|
||||
send_weekly_stats_message(LZMX_WEBHOOK_URL, stats)
|
||||
|
||||
if __name__ == '__main__':
|
||||
weekly_stats()
|
||||
@@ -0,0 +1,121 @@
|
||||
.quick-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 20px 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.quick-link-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-link-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
display: block;
|
||||
padding: 16px 20px;
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
}
|
||||
|
||||
.quick-link:hover {
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.quick-link::after {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-link:hover::after {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.about-container {
|
||||
max-width: 800px;
|
||||
margin: 30px auto;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.about-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.about-header h1 {
|
||||
color: #2c3e50;
|
||||
font-size: 2.2em;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
color: #34495e;
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.feature-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.dev-info {
|
||||
background: #f8f9fa;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.dev-info p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/* 仪表盘容器 */
|
||||
.dashboard_container {
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 仪表盘标题 */
|
||||
.dashboard_title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* 仪表盘区块 */
|
||||
.dashboard_section {
|
||||
margin-bottom: 30px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.section_title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 图表容器 */
|
||||
.chart_container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 图表包装器 */
|
||||
.chart_wrapper {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
height: 300px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 卡片网格布局 */
|
||||
.card_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 仪表盘卡片 */
|
||||
.dashboard_card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.dashboard_card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card_header {
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* 卡片标题 */
|
||||
.card_title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.card_content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
/* 进度条容器 */
|
||||
.progress_container {
|
||||
height: 24px;
|
||||
background: #e9ecef;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress_bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0061f2 0%, #00a3ff 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: width 0.5s ease;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* 进度条文本 */
|
||||
.progress_text {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 统计详情 */
|
||||
.stats_details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 统计项 */
|
||||
.stats_item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 统计标签 */
|
||||
.stats_label {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 统计值 */
|
||||
.stats_value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.table_container {
|
||||
overflow-x: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 仪表盘表格 */
|
||||
.dashboard_table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.dashboard_table th,
|
||||
.dashboard_table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.dashboard_table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dashboard_table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 支局单元格 */
|
||||
.branch_cell {
|
||||
font-weight: 600;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.status_badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status_badge.good {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status_badge.normal {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status_badge.bad {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
/* 手风琴容器 */
|
||||
.accordion_container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 手风琴项 */
|
||||
.accordion_item {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 手风琴头部 */
|
||||
.accordion_header {
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.accordion_header:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
/* 手风琴标题 */
|
||||
.accordion_title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 手风琴摘要 */
|
||||
.accordion_summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 手风琴图标 */
|
||||
.accordion_icon {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 手风琴内容 */
|
||||
.accordion_content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
/* 详情表格 */
|
||||
.detail_table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail_table th,
|
||||
.detail_table td {
|
||||
padding: 10px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.detail_table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.detail_table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.card_grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard_table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.accordion_summary {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chart_wrapper {
|
||||
min-width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.chart_container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
margin: 15% auto;
|
||||
padding: 20px;
|
||||
width: 300px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
margin: 0 10px;
|
||||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
/* 备注编辑样式 */
|
||||
.remark-display {
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
min-height: 20px;
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.remark-display:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.remark-input {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
min-width: 120px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.remark-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
/* 模态框基础样式 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
margin: 15vh auto;
|
||||
padding: 25px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin: 15px 0;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-buttons button:first-child {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-buttons button:last-child {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-buttons button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 验证码模态框特殊样式 */
|
||||
#captchaModal .modal-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#captchaImage {
|
||||
margin: 15px 0;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#captchaInput {
|
||||
width: 200px;
|
||||
padding: 8px 12px;
|
||||
margin: 15px 0;
|
||||
border: 2px solid #eee;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
#captchaInput:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
/* 结果显示样式 */
|
||||
#updatedCount {
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
#resultMessage {
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.dispatch_filter_group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dispatch_search_form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dispatch_action_group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.dispatch_action_bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dispatch_btn {
|
||||
min-width: 100px;
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dispatch_btn_primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dispatch_btn_primary:hover {
|
||||
background-color: #5677fc;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_qx {
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_qx:hover {
|
||||
background-color: #b0bec5;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_fx {
|
||||
background-color: #9e9e9e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_fx:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_bj {
|
||||
background-color: #ff5722;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_bj:hover {
|
||||
background-color: #ff8a65;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_sxt {
|
||||
background-color: #259b24;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary_sxt:hover {
|
||||
background-color: #72d572;
|
||||
}
|
||||
|
||||
.dispatch_filter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 10px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dispatch_search {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
max-width: 250px;
|
||||
padding: 8px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
.dispatch_btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dispatch_btn_secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.dispatch_btn_secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.dispatch_table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dispatch_table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.dispatch_table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dispatch_table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dispatch_checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dispatch_pagination {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
clear: both;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 991.98px) {
|
||||
|
||||
.dispatch_search_form {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dispatch_action_group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dispatch_search_form .dispatch_btn,
|
||||
.dispatch_action_group .dispatch_btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dispatch_search {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dispatch_filter {
|
||||
position: static; /* 移除固定定位 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dispatch_filter form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dispatch_filter_group {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.dispatch_search {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dispatch_action_bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dispatch_btn {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dispatch_btn_primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dispatch_table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.dispatch_table th,
|
||||
.dispatch_table td {
|
||||
min-width: 120px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dispatch_table th:first-child,
|
||||
.dispatch_table td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: white;
|
||||
z-index: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.dispatch_checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dispatch_pagination {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.dispatch_page_link {
|
||||
white-space: nowrap;
|
||||
padding: 8px 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dispatch_page_info {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
/* 筛选区域样式 */
|
||||
.receive_filter {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.filter_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.filter_title {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter_title::before {
|
||||
content: "🔍";
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.filter_actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter_form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter_group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter_label {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.filter_select {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter_select:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter_select:focus {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.receive_btn_clear {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.receive_btn_clear::before {
|
||||
content: "✕";
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.receive_btn_clear:hover {
|
||||
background: linear-gradient(135deg, #d97706, #b45309);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.receive_btn_primary {
|
||||
background: linear-gradient(135deg, #4f46e5, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.receive_btn_primary::before {
|
||||
content: "✓";
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.receive_btn_primary:hover {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.receive_btn_warning {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.receive_btn_warning::before {
|
||||
content: "⚠";
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.receive_btn_warning:hover {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* 统计卡片网格布局 */
|
||||
.stats_section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stats_section_title {
|
||||
font-size: 1.1em;
|
||||
color: #374151;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats_card {
|
||||
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stats_card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.stats_card.active {
|
||||
border-color: currentColor;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stats_card_label {
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats_card_value {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 设备类型颜色 */
|
||||
.stats_card.device-type-摄像头 {
|
||||
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.stats_card.device-type-摄像头 .stats_card_value {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.stats_card.device-type-一键报警 {
|
||||
background: linear-gradient(135deg, #fed7aa, #fdba74);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.stats_card.device-type-一键报警 .stats_card_value {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.stats_card.device-type-紫色 {
|
||||
background: linear-gradient(135deg, #e9d5ff, #c4b5fd);
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.stats_card.device-type-紫色 .stats_card_value {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stats_card.device-type-绿色 {
|
||||
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.stats_card.device-type-绿色 .stats_card_value {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.stats_card.device-type-灰色 {
|
||||
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.stats_card.device-type-灰色 .stats_card_value {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* 项目类型颜色 */
|
||||
.stats_card[class*="project-type-"] {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.stats_card[class*="project-type-"] .stats_card_value {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.receive_stats {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats_label {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stats_value {
|
||||
font-size: 1.8em;
|
||||
font-weight: bold;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.receive_btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.receive_btn_warning {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.receive_btn_warning:hover {
|
||||
background-color: #ffc772;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.receive_btn_secondary {
|
||||
background-color: #008cff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.receive_btn_secondary:hover {
|
||||
background-color: #43abff;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.receive_btn_primary {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
margin: 15vh auto;
|
||||
padding: 25px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 24px;
|
||||
}
|
||||
|
||||
.modal-icon.warning {
|
||||
background-color: #fff3e0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ff5722' d='M12 2L1 21h22M12 6l7.53 13H4.47M11 10v4h2v-4m-2 6v2h2v-2'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.modal-icon.info {
|
||||
background-color: #e8f5e9;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%234CAF50' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分页样式 */
|
||||
.receive_pagination {
|
||||
margin: 25px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.page_link {
|
||||
padding: 8px 16px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: #475569;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page_link:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.per_page_select {
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.receive_card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.receive_card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.receive_card_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.receive_card_title {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.receive_card_duration {
|
||||
color: #6c757d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.receive_card_content {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.receive_card_item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.receive_card_label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.receive_card_value {
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.receive_card_value[data-device-type="摄像头"] {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.receive_card_value[data-device-type="紫色"] {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.receive_card_value[data-device-type="绿色"] {
|
||||
background-color: #e8f5e9;
|
||||
color: #388e3c;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.receive_card_value[data-device-type="一键报警"] {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.receive_card_value[data-device-type="灰色"] {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.receive_btn {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 10vh auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.receive_card {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.receive_card_header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.receive_card_title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.receive_card_content {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.receive_card_item {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* 筛选区域在移动端的适配 */
|
||||
.filter_header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filter_title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.filter_form {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter_group {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter_select {
|
||||
padding: 10px 14px;
|
||||
font-size: 16px; /* 防止移动端自动缩放 */
|
||||
}
|
||||
|
||||
.receive_btn_primary {
|
||||
padding: 14px 20px;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.receive_btn_clear {
|
||||
padding: 10px 16px;
|
||||
font-size: 0.9em;
|
||||
align-self: stretch;
|
||||
}
|
||||
.stats_grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats_card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.stats_card_label {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.stats_card_value {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.stats_section_title {
|
||||
font-size: 1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.form_tips {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form_tips ul {
|
||||
margin: 10px 0 0 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form_tips li {
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc3545;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.detail_form input::placeholder,
|
||||
.detail_form textarea::placeholder {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail_form select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.detail_form select option:first-child {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.detail_form input[type="tel"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/* 容器样式 */
|
||||
.statistics_container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.statistics_section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 标题和按钮样式 */
|
||||
.statistics_title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 统一按钮基础样式 */
|
||||
.button_base {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.export_button {
|
||||
float: right;
|
||||
padding: 6px 12px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.export_button:hover {
|
||||
background-color: #45a049;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.incentive_button {
|
||||
float: right;
|
||||
padding: 6px 12px;
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.incentive_button:hover {
|
||||
background-color: #b91c1c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.statistics_card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.statistics_card h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #334155;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.statistics_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.statistics_item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statistics_label {
|
||||
color: #64748b;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.statistics_value {
|
||||
color: #1e293b;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 总计奖励样式 */
|
||||
.total_reward {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.handler_stats_table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.handler_stats_table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.handler_stats_table th,
|
||||
.handler_stats_table td {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.handler_stats_table th {
|
||||
background-color: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.handler_stats_table tr:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* 统计页面样式 */
|
||||
.statistics_container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.statistics_section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.statistics_title {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.statistics_card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.statistics_card h3 {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.statistics_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.statistics_item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.statistics_label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.statistics_value {
|
||||
color: #2c3e50;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.statistics_grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.statistics_card {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.statistics_value {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.month-picker-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancel_button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel_button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,271 @@
|
||||
function handleSelectAll() {
|
||||
const checkboxes = document.querySelectorAll('input[name="device_ids"]');
|
||||
const selectAllButton = document.getElementById('selectAllBtn');
|
||||
const hasSelected = Array.from(checkboxes).some(checkbox => checkbox.checked);
|
||||
const newState = !hasSelected;
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = newState;
|
||||
});
|
||||
|
||||
selectAllButton.textContent = newState ? '清空' : '全选';
|
||||
}
|
||||
|
||||
function handleInvertSelect() {
|
||||
const checkboxes = document.querySelectorAll('input[name="device_ids"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
});
|
||||
updateSelectAllButtonText();
|
||||
}
|
||||
|
||||
function updateSelectAllButtonText() {
|
||||
const checkboxes = document.querySelectorAll('input[name="device_ids"]');
|
||||
const selectAllButton = document.getElementById('selectAllBtn');
|
||||
const hasSelected = Array.from(checkboxes).some(checkbox => checkbox.checked);
|
||||
selectAllButton.textContent = hasSelected ? '清空' : '全选';
|
||||
}
|
||||
|
||||
function changePerPage(value) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('per_page', value);
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
|
||||
async function handleSync(type) {
|
||||
if (type === 'camera') {
|
||||
try {
|
||||
const response = await fetch('/get_captcha', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
document.getElementById('captchaImage').src = URL.createObjectURL(blob);
|
||||
document.getElementById('captchaModal').style.display = 'block';
|
||||
} else {
|
||||
alert('获取验证码失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error);
|
||||
alert('获取验证码失败,请重试');
|
||||
}
|
||||
} else if (type === 'alarm') {
|
||||
// 显示确认模态框
|
||||
document.getElementById('alarmConfirmModal').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 添加关闭确认模态框的函数
|
||||
function closeAlarmConfirmModal() {
|
||||
document.getElementById('alarmConfirmModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 添加确认同步的函数
|
||||
async function confirmAlarmSync() {
|
||||
try {
|
||||
// 关闭确认模态框
|
||||
closeAlarmConfirmModal();
|
||||
|
||||
// 显示加载提示
|
||||
document.getElementById('resultMessage').textContent = '正在同步数据,请稍候...';
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
|
||||
const response = await fetch('/sync_offline_devices', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('resultMessage').textContent =
|
||||
`同步成功!已为 ${data.count} 个离线设备创建工单。`;
|
||||
} else {
|
||||
document.getElementById('resultMessage').textContent = '同步失败:' + data.message;
|
||||
}
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('同步失败:', error);
|
||||
document.getElementById('resultMessage').textContent = '同步失败,请重试';
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCaptcha() {
|
||||
const captcha = document.getElementById('captchaInput').value;
|
||||
if (!captcha) {
|
||||
alert('请输入验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/sync_camera_step1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ captcha: captcha })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('captchaModal').style.display = 'none';
|
||||
// 更新模态框中的数量
|
||||
document.getElementById('updatedCount').textContent = data.updated_count || 0;
|
||||
document.getElementById('confirmModal').style.display = 'block';
|
||||
} else {
|
||||
if (data.message === 'INVALID_CAPTCHA') {
|
||||
alert('验证码错误,请重新输入');
|
||||
handleSync('camera');
|
||||
} else {
|
||||
alert('同步失败:' + data.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('同步失败:', error);
|
||||
alert('同步失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDispatch(isCamera = false) {
|
||||
try {
|
||||
// 不立即关闭确认模态框,而是先更新其内容
|
||||
const confirmModal = document.getElementById('confirmModal');
|
||||
const modalContent = confirmModal.querySelector('.modal-content');
|
||||
modalContent.innerHTML = `
|
||||
<h3>正在处理</h3>
|
||||
<p>正在为选中设备创建工单,请稍候...</p>
|
||||
<div class="loading-spinner"></div>
|
||||
`;
|
||||
|
||||
let response;
|
||||
if (isCamera) {
|
||||
// 如果是摄像头同步,调用第二步接口
|
||||
response = await fetch('/sync_camera_step2', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 普通派单
|
||||
response = await fetch('/dispatch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 处理完成后,平滑过渡到结果模态框
|
||||
document.getElementById('confirmModal').style.display = 'none';
|
||||
document.getElementById('resultMessage').textContent = `派单成功!已为 ${result.count} 个设备创建工单。`;
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('派单失败:', error);
|
||||
document.getElementById('resultMessage').textContent = '派单失败,请重试';
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDispatch() {
|
||||
document.getElementById('confirmModal').style.display = 'none';
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function closeCaptchaModal() {
|
||||
document.getElementById('captchaModal').style.display = 'none';
|
||||
document.getElementById('captchaInput').value = '';
|
||||
}
|
||||
|
||||
// 备注编辑功能
|
||||
function editRemark(deviceId, spanElement) {
|
||||
const inputElement = spanElement.nextElementSibling;
|
||||
spanElement.style.display = 'none';
|
||||
inputElement.style.display = 'inline-block';
|
||||
inputElement.focus();
|
||||
}
|
||||
|
||||
async function saveRemark(deviceId, inputElement) {
|
||||
const newRemark = inputElement.value.trim();
|
||||
const spanElement = inputElement.previousElementSibling;
|
||||
|
||||
try {
|
||||
const response = await fetch('/update_device_remark', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_id: deviceId,
|
||||
remark: newRemark
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
spanElement.textContent = newRemark || '点击添加备注';
|
||||
spanElement.style.display = 'inline-block';
|
||||
inputElement.style.display = 'none';
|
||||
} else {
|
||||
alert('保存失败:' + data.message);
|
||||
inputElement.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存备注失败:', error);
|
||||
alert('保存失败,请重试');
|
||||
inputElement.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 监听复选框的变化
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.matches('input[name="device_ids"]')) {
|
||||
updateSelectAllButtonText();
|
||||
}
|
||||
});
|
||||
|
||||
// 添加表单提交前的检查
|
||||
document.getElementById('dispatchForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const selectedDevices = Array.from(document.querySelectorAll('input[name="device_ids"]:checked'))
|
||||
.map(checkbox => checkbox.value);
|
||||
|
||||
if (selectedDevices.length === 0) {
|
||||
alert('请至少选择一个设备');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/check_existing_orders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ device_ids: selectedDevices })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.existing_devices.length > 0) {
|
||||
alert(`以下设备已有在途工单,请勿重复派单:\n${data.existing_devices.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.submit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查失败:', error);
|
||||
alert('检查在途工单失败,请重试');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
function changePerPage(value) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('per_page', value);
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
|
||||
function recallAllOrders() {
|
||||
document.getElementById('confirmModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('confirmModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function confirmRecall() {
|
||||
try {
|
||||
// 关闭确认模态框
|
||||
closeModal();
|
||||
|
||||
const response = await fetch('/recall_all_orders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
document.getElementById('resultMessage').textContent = `成功追回 ${data.count} 个工单`;
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('resultMessage').textContent = '追回失败:' + data.message;
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('resultMessage').textContent = '操作失败,请重试';
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function closeResultModal() {
|
||||
document.getElementById('resultModal').style.display = 'none';
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// 添加点击模态框外部关闭功能
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
if (event.target == modal) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/about.css') }}">
|
||||
<div class="about-container">
|
||||
<div class="about-header">
|
||||
<h1>关于本平台</h1>
|
||||
</div>
|
||||
|
||||
<div class="about-content">
|
||||
<div class="about-section">
|
||||
<h2>平台简介</h2>
|
||||
<p>本平台是一个专门用于管理和处理政企重点项目运维工单的平台。平台支持工单的派发、处理、统计等功能,旨在提高运维工作效率和管理透明度。</p>
|
||||
</div>
|
||||
|
||||
<div class="about-section">
|
||||
<ul style="list-style-type: disc; margin-left: 20px;">
|
||||
<li>工单管理:支持普通工单和手工故障单的创建、派发和处理</li>
|
||||
<li>数据统计:提供周期性的工单处理统计和运维奖励计算</li>
|
||||
<li>历史记录:记录和查询历史工单信息</li>
|
||||
<li>设备管理:支持设备信息的管理和维护</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="about-section">
|
||||
<h2>常用功能</h2>
|
||||
<ul class="quick-links">
|
||||
<li class="quick-link-item">
|
||||
<a href="https://web.wps.cn/wo/sl/v3Qs5r1?app_id=3SDuJ9CDIyuC7KvoKUM6uT" class="quick-link">"4+N"项目关联账号查询</a>
|
||||
</li>
|
||||
<li class="quick-link-item">
|
||||
<a href="https://www.kdocs.cn/l/crjbtOrFLgoD" class="quick-link">"4+N"项目和数字校园设备安装明细</a>
|
||||
</li>
|
||||
<li class="quick-link-item">
|
||||
<a href="https://www.kdocs.cn/l/cdnzqi5GvcHo" class="quick-link">"4+N"项目云监控平台安装指南</a>
|
||||
</li>
|
||||
<li class="quick-link-item">
|
||||
<a href="https://www.kdocs.cn/l/cg1kMuthlS1j" class="quick-link">大化教育城域网故障排查指南</a>
|
||||
</li>
|
||||
<li class="quick-link-item">
|
||||
<a href="https://onu.dhdx.fun/statistics" class="quick-link">大化教育城域网终端信息管理系统</a>
|
||||
</li>
|
||||
<li class="quick-link-item">
|
||||
<a href="https://www.kdocs.cn/l/cgJAP7E065tK" class="quick-link">大化教育城域网OLT安装规划表</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="about-section">
|
||||
<h2>信息</h2>
|
||||
<div class="dev-info">
|
||||
<p>当前平台处于试行阶段,如果遇到页面错误,功能不正常等问题请截图后与管理员联系;</p>
|
||||
<p>如果您有任何建议或意见,请随时与我们联系。</p>
|
||||
<p>感谢您的支持和使用!</p>
|
||||
</div>
|
||||
<div class="dev-info">
|
||||
<p><strong>联系:</strong>韦矍森</p>
|
||||
<p><strong>电话:</strong>19977899008</p>
|
||||
<p><strong>部门:</strong>大化云中台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 政企重点项目运维管理平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>大化重点项目工单管理平台</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg nav_container">
|
||||
<div class="container-fluid">
|
||||
<a class="nav_brand">
|
||||
{% if request.endpoint == 'dispatch' %}
|
||||
故障派单
|
||||
{% elif request.endpoint == 'receive' %}
|
||||
在途工单
|
||||
{% elif request.endpoint == 'statistics' %}
|
||||
工单统计
|
||||
{% elif request.endpoint == 'history' %}
|
||||
历史工单
|
||||
{% elif request.endpoint == 'manual' %}
|
||||
工单申报
|
||||
{% elif request.endpoint == 'dashboard' %}
|
||||
设备仪表盘
|
||||
{% else %}
|
||||
重点项目工单管理平台
|
||||
{% endif %}
|
||||
</a>
|
||||
<button class="navbar-toggler nav_toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon nav_toggler_icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0 nav_menu">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.role in ['统计员', '管理员', 'admin'] or current_user.name == 'Admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'dashboard' }}" href="{{ url_for('dashboard') }}">仪表盘</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'dispatch' }}" href="{{ url_for('dispatch') }}">派单</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'receive' }}" href="{{ url_for('receive') }}">在途</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'manual' }}" href="{{ url_for('manual') }}">申报</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'statistics' }}" href="{{ url_for('statistics') }}">统计</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'history' }}" href="{{ url_for('history') }}">历史</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'about' }}" href="{{ url_for('about') }}">关于</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'user_list' }}" href="{{ url_for('user_list') }}">用户管理</a>
|
||||
</li>
|
||||
{% elif current_user.role == '装维员' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'dashboard' }}" href="{{ url_for('dashboard') }}">仪表盘</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'receive' }}" href="{{ url_for('receive') }}">在途</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'manual' }}" href="{{ url_for('manual') }}">申报</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'statistics' }}" href="{{ url_for('statistics') }}">统计</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'history' }}" href="{{ url_for('history') }}">历史</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link {{ 'active' if request.endpoint == 'about' }}" href="{{ url_for('about') }}">关于</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav nav_menu">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<span class="nav_link nav_user_name">{{ current_user.name }}</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav_link" href="{{ url_for('logout') }}">退出</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
{% if not request.endpoint == 'login' %}
|
||||
{% with raw_messages = get_flashed_messages() %}
|
||||
{% set messages = [] %}
|
||||
{% for m in raw_messages %}
|
||||
{% if not (current_user.is_authenticated and m == '请先登录以访问此页面') %}
|
||||
{% set _ = messages.append(m) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if messages %}
|
||||
<div class="modal" id="flashModal" tabindex="-1" style="display: block; background: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-dialog-centered" style="z-index: 1060;">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-body">
|
||||
{% for message in messages %}
|
||||
<p class="mb-0">{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="closeFlashModal()">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
<script>
|
||||
function closeFlashModal() {
|
||||
document.getElementById('flashModal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,418 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
||||
<div class="dashboard_container">
|
||||
<h1 class="dashboard_title">设备在线率仪表盘</h1>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="dashboard_section">
|
||||
<h2 class="section_title">设备在线率趋势图</h2>
|
||||
<div class="chart_container">
|
||||
<div class="chart_wrapper">
|
||||
<canvas id="overallChart"></canvas>
|
||||
</div>
|
||||
<div class="chart_wrapper">
|
||||
<canvas id="branchChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart_container">
|
||||
<div class="chart_wrapper">
|
||||
<canvas id="deviceTypeChart"></canvas>
|
||||
</div>
|
||||
<div class="chart_wrapper">
|
||||
<canvas id="townChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总体设备在线率 -->
|
||||
<div class="dashboard_section">
|
||||
<h2 class="section_title">各类型设备总体在线率</h2>
|
||||
<div class="card_grid">
|
||||
{% for device_type, stats in overall_stats.items() %}
|
||||
<div class="dashboard_card">
|
||||
<div class="card_header">
|
||||
<h3 class="card_title">{{ device_type }}</h3>
|
||||
</div>
|
||||
<div class="card_content">
|
||||
<div class="progress_container">
|
||||
<div class="progress_bar" style="width: {{ stats.online_rate }}%">
|
||||
<span class="progress_text">{{ stats.online_rate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats_details">
|
||||
<div class="stats_item">
|
||||
<span class="stats_label">设备总数</span>
|
||||
<span class="stats_value">{{ stats.total }}</span>
|
||||
</div>
|
||||
<div class="stats_item">
|
||||
<span class="stats_label">派单数</span>
|
||||
<span class="stats_value">{{ stats.work_orders }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各支局设备在线率 -->
|
||||
<div class="dashboard_section">
|
||||
<h2 class="section_title">各支局设备在线率</h2>
|
||||
<div class="table_container">
|
||||
<table class="dashboard_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>支局</th>
|
||||
<th>设备类型</th>
|
||||
<th>设备总数</th>
|
||||
<th>派单数</th>
|
||||
<th>在线率</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for branch, types in branch_stats.items() %}
|
||||
{% for device_type, stats in types.items() %}
|
||||
{% if stats.total > 0 %}
|
||||
<tr>
|
||||
{% if loop.first %}
|
||||
<td rowspan="{{ types|length }}" class="branch_cell">{{ branch }}</td>
|
||||
{% endif %}
|
||||
<td>{{ device_type }}</td>
|
||||
<td>{{ stats.total }}</td>
|
||||
<td>{{ stats.work_orders }}</td>
|
||||
<td>{{ stats.online_rate }}%</td>
|
||||
<td>
|
||||
{% if stats.online_rate >= 90 %}
|
||||
<span class="status_badge good">良好</span>
|
||||
{% elif stats.online_rate >= 75 %}
|
||||
<span class="status_badge normal">一般</span>
|
||||
{% else %}
|
||||
<span class="status_badge bad">需关注</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各乡镇设备在线率 -->
|
||||
<div class="dashboard_section">
|
||||
<h2 class="section_title">各乡镇设备在线率</h2>
|
||||
<div class="accordion_container">
|
||||
{% for town, types in town_stats.items() %}
|
||||
<div class="accordion_item">
|
||||
<div class="accordion_header" onclick="toggleAccordion(this)">
|
||||
<div class="accordion_title">{{ town }}</div>
|
||||
<div class="accordion_summary">
|
||||
<span>总设备数: {{ types['总计'].total }}</span>
|
||||
<span>在线率: {{ types['总计'].online_rate }}%</span>
|
||||
{% if types['总计'].online_rate >= 90 %}
|
||||
<span class="status_badge good">良好</span>
|
||||
{% elif types['总计'].online_rate >= 75 %}
|
||||
<span class="status_badge normal">一般</span>
|
||||
{% else %}
|
||||
<span class="status_badge bad">需关注</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="accordion_icon">▼</div>
|
||||
</div>
|
||||
<div class="accordion_content">
|
||||
<table class="detail_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备类型</th>
|
||||
<th>设备总数</th>
|
||||
<th>派单数</th>
|
||||
<th>在线率</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device_type, stats in types.items() %}
|
||||
{% if device_type != '总计' and stats.total > 0 %}
|
||||
<tr>
|
||||
<td>{{ device_type }}</td>
|
||||
<td>{{ stats.total }}</td>
|
||||
<td>{{ stats.work_orders }}</td>
|
||||
<td>{{ stats.online_rate }}%</td>
|
||||
<td>
|
||||
{% if stats.online_rate >= 90 %}
|
||||
<span class="status_badge good">良好</span>
|
||||
{% elif stats.online_rate >= 75 %}
|
||||
<span class="status_badge normal">一般</span>
|
||||
{% else %}
|
||||
<span class="status_badge bad">需关注</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入Chart.js库 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<script>
|
||||
function toggleAccordion(element) {
|
||||
const content = element.nextElementSibling;
|
||||
const icon = element.querySelector('.accordion_icon');
|
||||
|
||||
if (content.style.maxHeight) {
|
||||
content.style.maxHeight = null;
|
||||
icon.textContent = '▼';
|
||||
} else {
|
||||
content.style.maxHeight = content.scrollHeight + "px";
|
||||
icon.textContent = '▲';
|
||||
}
|
||||
}
|
||||
|
||||
// 图表数据准备
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 设备类型在线率图表
|
||||
const deviceTypeCtx = document.getElementById('deviceTypeChart').getContext('2d');
|
||||
const deviceTypeLabels = [];
|
||||
const deviceTypeData = [];
|
||||
const deviceTypeColors = [];
|
||||
|
||||
{% for device_type, stats in overall_stats.items() %}
|
||||
deviceTypeLabels.push('{{ device_type }}');
|
||||
deviceTypeData.push({{ stats.online_rate }});
|
||||
|
||||
// 根据在线率设置颜色
|
||||
if ({{ stats.online_rate }} >= 90) {
|
||||
deviceTypeColors.push('rgba(40, 167, 69, 0.7)');
|
||||
} else if ({{ stats.online_rate }} >= 75) {
|
||||
deviceTypeColors.push('rgba(255, 193, 7, 0.7)');
|
||||
} else {
|
||||
deviceTypeColors.push('rgba(220, 53, 69, 0.7)');
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
new Chart(deviceTypeCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: deviceTypeLabels,
|
||||
datasets: [{
|
||||
label: '设备类型在线率(%)',
|
||||
data: deviceTypeData,
|
||||
backgroundColor: deviceTypeColors,
|
||||
borderColor: deviceTypeColors.map(color => color.replace('0.7', '1')),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '各设备类型在线率对比'
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: '在线率(%)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 支局在线率对比图
|
||||
const branchCtx = document.getElementById('branchChart').getContext('2d');
|
||||
const branchLabels = [];
|
||||
const branchData = [];
|
||||
const branchColors = [];
|
||||
|
||||
{% for branch, types in branch_stats.items() %}
|
||||
branchLabels.push('{{ branch }}');
|
||||
branchData.push({{ types['总计'].online_rate }});
|
||||
|
||||
// 根据在线率设置颜色
|
||||
if ({{ types['总计'].online_rate }} >= 90) {
|
||||
branchColors.push('rgba(40, 167, 69, 0.7)');
|
||||
} else if ({{ types['总计'].online_rate }} >= 75) {
|
||||
branchColors.push('rgba(255, 193, 7, 0.7)');
|
||||
} else {
|
||||
branchColors.push('rgba(220, 53, 69, 0.7)');
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
new Chart(branchCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: branchLabels,
|
||||
datasets: [{
|
||||
label: '支局在线率(%)',
|
||||
data: branchData,
|
||||
backgroundColor: branchColors,
|
||||
borderColor: branchColors.map(color => color.replace('0.7', '1')),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '各支局在线率对比'
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: '在线率(%)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 乡镇在线率对比图
|
||||
const townCtx = document.getElementById('townChart').getContext('2d');
|
||||
const townLabels = [];
|
||||
const townData = [];
|
||||
const townColors = [];
|
||||
|
||||
{% for town, types in town_stats.items() %}
|
||||
townLabels.push('{{ town }}');
|
||||
townData.push({{ types['总计'].online_rate }});
|
||||
|
||||
// 根据在线率设置颜色
|
||||
if ({{ types['总计'].online_rate }} >= 90) {
|
||||
townColors.push('rgba(40, 167, 69, 0.7)');
|
||||
} else if ({{ types['总计'].online_rate }} >= 75) {
|
||||
townColors.push('rgba(255, 193, 7, 0.7)');
|
||||
} else {
|
||||
townColors.push('rgba(220, 53, 69, 0.7)');
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
new Chart(townCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: townLabels,
|
||||
datasets: [{
|
||||
label: '乡镇在线率(%)',
|
||||
data: townData,
|
||||
backgroundColor: townColors,
|
||||
borderColor: townColors.map(color => color.replace('0.7', '1')),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '各乡镇在线率对比'
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: '在线率(%)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 总体在线率趋势图(模拟数据)
|
||||
const overallCtx = document.getElementById('overallChart').getContext('2d');
|
||||
|
||||
// 模拟过去7天的数据
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(today.getDate() - i);
|
||||
dates.push(date.getMonth() + 1 + '/' + date.getDate());
|
||||
}
|
||||
|
||||
// 生成模拟数据,假设在线率在当前值附近波动
|
||||
const generateTrendData = (baseValue) => {
|
||||
return Array.from({length: 7}, () => {
|
||||
// 在基准值附近随机波动,但确保不超过100和不低于50
|
||||
const fluctuation = Math.random() * 10 - 5; // -5 到 +5 之间的随机数
|
||||
return Math.min(100, Math.max(50, baseValue + fluctuation));
|
||||
});
|
||||
};
|
||||
|
||||
// 假设当前总体在线率为所有设备类型在线率的平均值
|
||||
let avgOnlineRate = 0;
|
||||
let count = 0;
|
||||
{% for device_type, stats in overall_stats.items() %}
|
||||
avgOnlineRate += {{ stats.online_rate }};
|
||||
count++;
|
||||
{% endfor %}
|
||||
avgOnlineRate = avgOnlineRate / (count || 1);
|
||||
|
||||
new Chart(overallCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [{
|
||||
label: '总体在线率趋势(%)',
|
||||
data: generateTrendData(avgOnlineRate),
|
||||
fill: false,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '总体在线率7天趋势'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
min: Math.max(0, avgOnlineRate - 20),
|
||||
max: Math.min(100, avgOnlineRate + 20),
|
||||
title: {
|
||||
display: true,
|
||||
text: '在线率(%)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dispatch.css') }}">
|
||||
|
||||
<div class="dispatch_filter">
|
||||
<form method="GET" action="{{ url_for('dispatch') }}" class="dispatch_search_form">
|
||||
<input type="text" name="search" class="dispatch_search" placeholder="输入搜索词" value="{{ request.args.get('search', '') }}">
|
||||
<select name="town" class="dispatch_search">
|
||||
<option value="">所有乡镇</option>
|
||||
{% for town in towns %}
|
||||
<option value="{{ town }}" {% if town == request.args.get('town') %}selected{% endif %}>{{ town }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="branch" class="dispatch_search">
|
||||
<option value="">所有支局</option>
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch }}" {% if branch == request.args.get('branch') %}selected{% endif %}>{{ branch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="dispatch_btn dispatch_btn_primary">搜索</button>
|
||||
</form>
|
||||
|
||||
<div class="dispatch_action_bar">
|
||||
<button type="button" class="dispatch_btn dispatch_btn_secondary_bj" onclick="handleSync('alarm')">同步报警盒</button>
|
||||
<button type="button" class="dispatch_btn dispatch_btn_secondary_sxt" onclick="handleSync('camera')">同步摄像头</button>
|
||||
<button type="button" class="dispatch_btn dispatch_btn_secondary_qx" id="selectAllBtn" onclick="handleSelectAll()">全选</button>
|
||||
<button type="button" class="dispatch_btn dispatch_btn_secondary_fx" onclick="handleInvertSelect()">反选</button>
|
||||
<button type="submit" form="dispatchForm" class="dispatch_btn dispatch_btn_primary">派单</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 派单表单 -->
|
||||
<form id="dispatchForm" method="POST" action="{{ url_for('dispatch') }}">
|
||||
<!-- 设备表格 -->
|
||||
<table class="dispatch_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>选择</th>
|
||||
<th>设备编号</th>
|
||||
<th>所属乡镇</th>
|
||||
<th>所属支局</th>
|
||||
<th>单位名称</th>
|
||||
<th>项目类型</th>
|
||||
<th>设备类型</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices.items %}
|
||||
<tr>
|
||||
<td><input type="checkbox" class="dispatch_checkbox" name="device_ids" value="{{ device.device_id }}"></td>
|
||||
<td>{{ device.device_id }}</td>
|
||||
<td>{{ device.town }}</td>
|
||||
<td>{{ device.branch }}</td>
|
||||
<td>{{ device.unit_name }}</td>
|
||||
<td>{{ device.project_type }}</td>
|
||||
<td>{{ device.device_type }}</td>
|
||||
<td>
|
||||
<span class="remark-display" onclick="editRemark('{{ device.device_id }}', this)">{{ device.remark or '点击添加备注' }}</span>
|
||||
<input type="text" class="remark-input" style="display: none;" value="{{ device.remark or '' }}"
|
||||
onblur="saveRemark('{{ device.device_id }}', this)"
|
||||
onkeypress="if(event.key==='Enter') saveRemark('{{ device.device_id }}', this)">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 分页导航部分 -->
|
||||
<div class="dispatch_pagination">
|
||||
{% if devices.has_prev %}
|
||||
<a href="{{ url_for('dispatch', page=devices.prev_num, search=request.args.get('search', ''), town=request.args.get('town', ''), branch=request.args.get('branch', ''), per_page=request.args.get('per_page', '50')) }}" class="dispatch_page_link">上一页</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="dispatch_page_info">第 {{ devices.page }} 页,共 {{ devices.pages }} 页</span>
|
||||
|
||||
<select class="per_page_select" onchange="changePerPage(this.value)">
|
||||
<option value="10" {% if request.args.get('per_page', '50') == '10' %}selected{% endif %}>10条/页</option>
|
||||
<option value="20" {% if request.args.get('per_page', '50') == '20' %}selected{% endif %}>20条/页</option>
|
||||
<option value="50" {% if request.args.get('per_page', '50') == '50' %}selected{% endif %}>50条/页</option>
|
||||
<option value="100" {% if request.args.get('per_page', '50') == '100' %}selected{% endif %}>100条/页</option>
|
||||
</select>
|
||||
|
||||
{% if devices.has_next %}
|
||||
<a href="{{ url_for('dispatch', page=devices.next_num, search=request.args.get('search', ''), town=request.args.get('town', ''), branch=request.args.get('branch', ''), per_page=request.args.get('per_page', '50')) }}" class="dispatch_page_link">下一页</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加验证码模态框 -->
|
||||
<div id="captchaModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>请输入验证码</h3>
|
||||
<img id="captchaImage" src="{{ url_for('static', filename='captcha.png') }}" alt="验证码">
|
||||
<input type="text" id="captchaInput" placeholder="请输入验证码">
|
||||
<div class="modal-buttons">
|
||||
<button onclick="submitCaptcha()">确定</button>
|
||||
<button onclick="closeCaptchaModal()">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加一键报警确认模态框 -->
|
||||
<div id="alarmConfirmModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>确认同步</h3>
|
||||
<p>确定要同步一键报警设备状态吗?</p>
|
||||
<div class="modal-buttons">
|
||||
<button onclick="confirmAlarmSync()" class="dispatch_btn dispatch_btn_primary">确定</button>
|
||||
<button onclick="closeAlarmConfirmModal()" class="dispatch_btn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 添加确认模态框 -->
|
||||
<div id="confirmModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>数据更新完成</h3>
|
||||
<p>已更新 <span id="updatedCount">0</span> 个设备的状态</p>
|
||||
<p>是否要进行派单操作?</p>
|
||||
<div class="modal-buttons">
|
||||
<button onclick="handleDispatch(true)">是</button>
|
||||
<button onclick="cancelDispatch()">否</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加结果模态框 -->
|
||||
<div id="resultModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>操作结果</h3>
|
||||
<p id="resultMessage"></p>
|
||||
<div class="modal-buttons">
|
||||
<button onclick="window.location.reload()">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/dispatch.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="history_container">
|
||||
|
||||
<!-- 添加搜索框 -->
|
||||
<div class="history_search">
|
||||
<form method="GET" action="{{ url_for('history') }}" class="history_search_form">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="搜索工单号、设备、支局、处理人等..." class="history_search_input">
|
||||
<button type="submit" class="history_search_button">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- PC端表格视图 -->
|
||||
<table class="history_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工单号</th>
|
||||
<th>所属支局</th>
|
||||
<th>故障设备</th>
|
||||
<th>处理人</th>
|
||||
<th>处理时间</th>
|
||||
<th>故障类型</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for handling in handlings.items %}
|
||||
<tr>
|
||||
<td>{{ handling['order_id'] }}</td>
|
||||
<td>{{ handling['branch'] }}</td>
|
||||
<td>{{ handling['device_id'] }}</td>
|
||||
<td>{{ handling['handler'] }}</td>
|
||||
<td>{{ handling['time'].strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>{{ handling['fault_type'] }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('history_detail', order_id=handling['order_id']) }}" class="view_btn">查看</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 移动端卡片视图 -->
|
||||
<div class="card_container">
|
||||
{% for handling in handlings.items %}
|
||||
<a href="{{ url_for('history_detail', order_id=handling['order_id']) }}" class="history_card {% if handling['type'] == 'manual' %}manual-card{% else %}system-card{% endif %}">
|
||||
<div class="history_card_header">
|
||||
<span class="history_card_title">工单号: {{ handling['order_id'] }}</span>
|
||||
<span class="history_card_time">{{ handling['time'].strftime('%Y-%m-%d') }}</span>
|
||||
</div>
|
||||
<div class="history_card_content">
|
||||
<div class="history_card_item">
|
||||
<span class="history_card_label">所属支局</span>
|
||||
<span class="history_card_value">{{ handling['branch'] }}</span>
|
||||
</div>
|
||||
<div class="history_card_item">
|
||||
<span class="history_card_label">故障设备</span>
|
||||
<span class="history_card_value">{{ handling['device_id'] }}</span>
|
||||
</div>
|
||||
<div class="history_card_item">
|
||||
<span class="history_card_label">处理人</span>
|
||||
<span class="history_card_value">{{ handling['handler'] }}</span>
|
||||
</div>
|
||||
<div class="history_card_item">
|
||||
<span class="history_card_label">故障类型</span>
|
||||
<span class="history_card_value">{{ handling['fault_type'] }}</span>
|
||||
</div>
|
||||
<div class="history_card_item">
|
||||
<span class="history_card_label">工单类型</span>
|
||||
<span class="history_card_value">{{ '手工申报' if handling['type'] == 'manual' else '系统派单' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- 分页导航 -->
|
||||
<div class="history_pagination">
|
||||
{% if handlings.has_prev %}
|
||||
<a href="{{ url_for('history', page=handlings.prev_num) }}" class="history_page_link">上一页</a>
|
||||
{% endif %}
|
||||
<span class="history_page_info">第 {{ handlings.page }} 页,共 {{ handlings.pages }} 页</span>
|
||||
{% if handlings.has_next %}
|
||||
<a href="{{ url_for('history', page=handlings.next_num) }}" class="history_page_link">下一页</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="history_detail_container">
|
||||
<!-- 工单信息卡片 -->
|
||||
<div class="detail_card">
|
||||
<div class="detail_header">
|
||||
<h3 class="detail_title">工单详情 #{{ handling.order_id }}</h3>
|
||||
<span class="detail_status">{{ '申报' if order_type == 'manual' else '系统' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail_section">
|
||||
<h4 class="section_title">基本信息</h4>
|
||||
<div class="detail_grid">
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">所属支局</span>
|
||||
<span class="detail_value">{{ handling.branch }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">故障设备</span>
|
||||
<span class="detail_value">{{ handling.device_id }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">处理人</span>
|
||||
<span class="detail_value highlight">{{ handling.handler }}</span>
|
||||
</div>
|
||||
{% if order_type == 'manual' %}
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">项目类型</span>
|
||||
<span class="detail_value">{{ handling.project_type }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">设备位置</span>
|
||||
<span class="detail_value">{{ handling.device_location }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">故障时间</span>
|
||||
<span class="detail_value">{{ handling.fault_time.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">处理时间</span>
|
||||
<span class="detail_value">{{ handling.restore_time.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail_section">
|
||||
<h4 class="section_title">故障信息</h4>
|
||||
<div class="detail_grid">
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">故障类型</span>
|
||||
<span class="detail_value badge">{{ handling.fault_type }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">故障对接人</span>
|
||||
<span class="detail_value">{{ handling.contact_person }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">联系电话</span>
|
||||
<span class="detail_value">{{ handling.contact_phone }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">距离支局</span>
|
||||
<span class="detail_value">{{ handling.distance_from_branch }} 公里</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail_section">
|
||||
<h4 class="section_title">处理详情</h4>
|
||||
<div class="detail_description">
|
||||
<p>{{ handling.handle_description }}</p>
|
||||
</div>
|
||||
{% if handling.remark %}
|
||||
<div class="detail_remark">
|
||||
<h5>备注信息</h5>
|
||||
<p>{{ handling.remark }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if handling.photo_paths %}
|
||||
<div class="detail_section">
|
||||
<h4 class="section_title">现场图片</h4>
|
||||
<div class="photo_grid">
|
||||
{% for photo in handling.photo_paths.split(',') %}
|
||||
<div class="photo_item">
|
||||
<img src="{{ url_for('static', filename='uploads/' + photo) }}" alt="现场图片"
|
||||
onclick="showFullImage(this.src)">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="detail_actions">
|
||||
<a href="{{ url_for('history') }}" class="detail_btn detail_btn_secondary">
|
||||
<i class="fas fa-arrow-left"></i> 返回列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览模态框 -->
|
||||
<div id="imageModal" class="modal">
|
||||
<span class="modal_close">×</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 %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="login_container">
|
||||
<div class="login_box">
|
||||
<h2 class="login_title">欢迎使用</h2>
|
||||
<div class="login_subtitle">大化重点项目工单管理平台</div>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="login_error">
|
||||
{% for message in messages %}
|
||||
{{ message }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Casdoor 登录跳转 -->
|
||||
<div class="login_casdoor_section">
|
||||
<a href="{{ url_for('casdoor_login') }}" class="login_button login_button_casdoor">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 8px;">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor"/>
|
||||
</svg>
|
||||
使用 Casdoor 登录
|
||||
</a>
|
||||
<p style="margin-top: 12px; color: #666;">正在跳转至 Casdoor 登录页,如未跳转请点击按钮。</p>
|
||||
</div>
|
||||
<script>
|
||||
// 有错误提示时停留在本页,方便用户查看信息
|
||||
const hasError = {{ 'true' if get_flashed_messages() else 'false' }};
|
||||
if (!hasError) {
|
||||
// 自动跳转至 Casdoor 登录
|
||||
window.onload = function() {
|
||||
window.location.href = "{{ url_for('casdoor_login') }}";
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="manual_container">
|
||||
|
||||
<div class="form_tips">
|
||||
<p>注意事项</p>
|
||||
<ul>
|
||||
<li>本页面用于填写用户来电申报或者巡检途中发现的故障、移机,申报前请确保在途工单中没有重复故障</li>
|
||||
<li>新装设备、移机等请在申报类型选择“新装”或“移机”</li>
|
||||
<li>现场照片最多可上传5张,支持jpg、jpeg、png</li>
|
||||
<li>距离支局公里数请填写支局办公室到故障地点实际距离</li>
|
||||
<li>带 <span class="required">*</span> 的字段为必填项</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form method="POST" enctype="multipart/form-data" class="manual_form">
|
||||
<div class="form-group">
|
||||
<label for="project_type">项目类型<span class="required">*</span></label>
|
||||
<select id="project_type" name="project_type" required>
|
||||
<option value="">请选择项目类型</option>
|
||||
<option value='校园安防"4+N"项目'>校园安防"4+N"项目</option>
|
||||
<option value="综治视联网">综治视联网</option>
|
||||
<option value="教育城域网">教育城域网</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="device_id">施工设备类型<span class="required">*</span></label>
|
||||
<input type="text" id="device_id" name="device_id" required placeholder="如:摄像头、一键报警盒、光猫等">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="device_location">施工单位名称<span class="required">*</span></label>
|
||||
<input type="text" id="device_location" name="device_location" required placeholder="请填写前往施工的单位名称如:古河村委、都阳镇中心小学等">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fault_time">施工完成时间<span class="required">*</span></label>
|
||||
<input type="datetime-local" id="fault_time" name="fault_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="handler">施工处理人<span class="required">*</span></label>
|
||||
<input type="text" id="handler" name="handler" value="{{ current_user.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fault_type">申报类型<span class="required">*</span></label>
|
||||
<select id="fault_type" name="fault_type" required>
|
||||
<option value="">请选择申报类型</option>
|
||||
<option value="光猫故障">光猫故障</option>
|
||||
<option value="设备故障">设备故障</option>
|
||||
<option value="电源故障">电源故障</option>
|
||||
<option value="电路故障">电路故障</option>
|
||||
<option value="网线故障">网线故障</option>
|
||||
<option value="皮线故障">皮线故障</option>
|
||||
<option value="数据问题">数据问题</option>
|
||||
<option value="用户欠费">用户欠费</option>
|
||||
<option value="移机">移机申报</option>
|
||||
<option value="新增">新装申报</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="handle_description">处理描述<span class="required">*</span></label>
|
||||
<textarea id="handle_description" name="handle_description" placeholder="请简述处理过程,如:光猫电源适配器坏,更好光猫电源适配器后恢复。" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="photo">现场照片(最多5张)</label>
|
||||
<input type="file" id="photo" name="photo" multiple accept="image/*">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contact_person">施工对接人<span class="required">*</span></label>
|
||||
<input type="text" id="contact_person" name="contact_person" placeholder="请输入本次施工单位对接人" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contact_phone">对接人联系电话</label>
|
||||
<input type="tel" id="contact_phone" name="contact_phone" placeholder="请输入本次施工单位对接人联系电话">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="distance_from_branch">距离支局公里数<span class="required">*</span></label>
|
||||
<input type="number" step="0.1" id="distance_from_branch" name="distance_from_branch" required placeholder="请输入实际路程距离,如:5.5、50">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="remark">备注</label>
|
||||
<textarea id="remark" name="remark" placeholder="如有其他需要说明的信息,请在此填写"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="manual_submit">提交</button>
|
||||
</form>
|
||||
</div>
|
||||
<style>
|
||||
.form_tips {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form_tips ul {
|
||||
margin: 10px 0 0 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form_tips li {
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc3545;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.manual_form input::placeholder,
|
||||
.manual_form textarea::placeholder {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual_form select option:first-child {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/receive.css') }}">
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="receive_stats">
|
||||
<div class="stats_item">
|
||||
<span class="stats_label">在途工单总数</span>
|
||||
<span class="stats_value">{{ total_orders }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 设备类型统计 -->
|
||||
{% if device_type_stats %}
|
||||
<div class="stats_section">
|
||||
<h4 class="stats_section_title">设备类型分布</h4>
|
||||
<div class="stats_grid">
|
||||
{% for device_type, count in device_type_stats.items() %}
|
||||
<a href="{{ url_for('receive', device_type=device_type, branch=selected_branch, per_page=request.args.get('per_page', '50')) }}" class="stats_card device-type-{{ device_type | replace(' ', '-') }} {% if device_type == request.args.get('device_type') %}active{% endif %}">
|
||||
<span class="stats_card_label">{{ device_type }}</span>
|
||||
<span class="stats_card_value">{{ count }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 项目类型统计 -->
|
||||
{% if project_type_stats %}
|
||||
<div class="stats_section">
|
||||
<h4 class="stats_section_title">项目类型分布</h4>
|
||||
<div class="stats_grid">
|
||||
{% for project_type, count in project_type_stats.items() %}
|
||||
<a href="{{ url_for('receive', project_type=project_type, branch=selected_branch, per_page=request.args.get('per_page', '50')) }}" class="stats_card project-type-{{ project_type | replace(' ', '-') }} {% if project_type == request.args.get('project_type') %}active{% endif %}">
|
||||
<span class="stats_card_label">{{ project_type }}</span>
|
||||
<span class="stats_card_value">{{ count }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="receive_filter">
|
||||
<div class="filter_header">
|
||||
<h4 class="filter_title">工单筛选</h4>
|
||||
<div class="filter_actions">
|
||||
{% if current_user.branch == '交付中心' %}
|
||||
<button onclick="recallAllOrders()" class="receive_btn receive_btn_warning">一键追回所有工单</button>
|
||||
{% endif %}
|
||||
{% if request.args.get('device_type') or request.args.get('project_type') or selected_branch %}
|
||||
<a href="{{ url_for('receive', per_page=request.args.get('per_page', '50')) }}" class="receive_btn receive_btn_clear">清除所有筛选</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="GET" action="{{ url_for('receive') }}" class="filter_form">
|
||||
{% if current_user.branch == '交付中心' %}
|
||||
<div class="filter_group">
|
||||
<label class="filter_label">所属支局</label>
|
||||
<select name="branch" class="filter_select">
|
||||
<option value="">所有支局</option>
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch }}" {% if branch == selected_branch %}selected{% endif %}>{{ branch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="filter_group">
|
||||
<label class="filter_label">设备类型</label>
|
||||
<select name="device_type" class="filter_select">
|
||||
<option value="">所有设备类型</option>
|
||||
{% for device_type in device_types %}
|
||||
<option value="{{ device_type }}" {% if device_type == request.args.get('device_type') %}selected{% endif %}>{{ device_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter_group">
|
||||
<label class="filter_label">项目类型</label>
|
||||
<select name="project_type" class="filter_select">
|
||||
<option value="">所有项目类型</option>
|
||||
{% for project_type in project_types %}
|
||||
<option value="{{ project_type }}" {% if project_type == request.args.get('project_type') %}selected{% endif %}>{{ project_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="page" value="1">
|
||||
<input type="hidden" name="per_page" value="{{ request.args.get('per_page', '50') }}">
|
||||
|
||||
<div class="filter_group">
|
||||
<button type="submit" class="receive_btn receive_btn_primary">应用筛选</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table class="receive_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工单号</th>
|
||||
<th>所属支局</th>
|
||||
<th>故障设备</th>
|
||||
<th>设备类型</th>
|
||||
<th>项目类型</th>
|
||||
<th>派单时间</th>
|
||||
<th>派单时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for work_order in work_orders %}
|
||||
<tr>
|
||||
<td>{{ work_order.order_id }}</td>
|
||||
<td>{{ work_order.device.branch }}</td>
|
||||
<td><a href="{{ url_for('receive_detail', work_order_id=work_order.order_id) }}" class="receive_action">{{ work_order.device.device_id }}</a></td>
|
||||
<td>{{ work_order.device.device_type }}</td>
|
||||
<td>{{ work_order.device.project_type }}</td>
|
||||
<td>{{ work_order.dispatch_time }}</td>
|
||||
<td>{{ work_order.duration }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 移动端卡片视图 -->
|
||||
<div class="card_container">
|
||||
{% for work_order in work_orders %}
|
||||
<a href="{{ url_for('receive_detail', work_order_id=work_order.order_id) }}" class="receive_card">
|
||||
<div class="receive_card_header">
|
||||
<span class="receive_card_title">工单号: {{ work_order.order_id }}</span>
|
||||
<span class="receive_card_duration">{{ work_order.duration }}</span>
|
||||
</div>
|
||||
<div class="receive_card_content">
|
||||
<div class="receive_card_item">
|
||||
<span class="receive_card_label">故障设备</span>
|
||||
<span class="receive_card_value">{{ work_order.device.device_id }}</span>
|
||||
</div>
|
||||
<div class="receive_card_item">
|
||||
<span class="receive_card_label">设备类型</span>
|
||||
<span class="receive_card_value" data-device-type="{{ work_order.device.device_type }}">{{ work_order.device.device_type }}</span>
|
||||
</div>
|
||||
<div class="receive_card_item">
|
||||
<span class="receive_card_label">所属支局</span>
|
||||
<span class="receive_card_value">{{ work_order.device.branch }}</span>
|
||||
</div>
|
||||
<div class="receive_card_item">
|
||||
<span class="receive_card_label">项目类型</span>
|
||||
<span class="receive_card_value">{{ work_order.device.project_type }}</span>
|
||||
</div>
|
||||
<div class="receive_card_item">
|
||||
<span class="receive_card_label">派单时间</span>
|
||||
<span class="receive_card_value">{{ work_order.dispatch_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- 添加分页 -->
|
||||
<div class="receive_pagination">
|
||||
{% if work_orders.has_prev %}
|
||||
<a href="{{ url_for('receive', page=work_orders.prev_num, branch=selected_branch, device_type=request.args.get('device_type', ''), project_type=request.args.get('project_type', ''), per_page=request.args.get('per_page', '50')) }}" class="page_link">上一页</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="page_info">第 {{ work_orders.page }} 页,共 {{ work_orders.pages }} 页</span>
|
||||
|
||||
<select class="per_page_select" onchange="changePerPage(this.value)">
|
||||
<option value="10" {% if request.args.get('per_page', '50') == '10' %}selected{% endif %}>10条/页</option>
|
||||
<option value="20" {% if request.args.get('per_page', '50') == '20' %}selected{% endif %}>20条/页</option>
|
||||
<option value="50" {% if request.args.get('per_page', '50') == '50' %}selected{% endif %}>50条/页</option>
|
||||
<option value="100" {% if request.args.get('per_page', '50') == '100' %}selected{% endif %}>100条/页</option>
|
||||
</select>
|
||||
|
||||
{% if work_orders.has_next %}
|
||||
<a href="{{ url_for('receive', page=work_orders.next_num, branch=selected_branch, device_type=request.args.get('device_type', ''), project_type=request.args.get('project_type', ''), per_page=request.args.get('per_page', '50')) }}" class="page_link">下一页</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 确认追回弹窗 -->
|
||||
<div id="confirmModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>确认追回</h3>
|
||||
<div class="modal-body">
|
||||
<i class="modal-icon warning"></i>
|
||||
<p>确定要追回所有在途工单吗?</p>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button onclick="confirmRecall()" class="receive_btn receive_btn_warning">确定</button>
|
||||
<button onclick="closeModal()" class="receive_btn receive_btn_secondary">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>操作结果</h3>
|
||||
<div class="modal-body">
|
||||
<i class="modal-icon info"></i>
|
||||
<p id="resultMessage"></p>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button onclick="closeResultModal()" class="receive_btn receive_btn_primary">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/receive.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/receive_detail.css') }}">
|
||||
<div class="receive_detail_container">
|
||||
<!-- 工单信息卡片 -->
|
||||
<div class="detail_card">
|
||||
<h3 class="detail_title">工单信息</h3>
|
||||
<div class="detail_grid">
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">工单号:</span>
|
||||
<span class="detail_value">{{ work_order.order_id }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">所属支局:</span>
|
||||
<span class="detail_value">{{ work_order.device.branch }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">故障设备:</span>
|
||||
<span class="detail_value">{{ work_order.device.device_id }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">设备类型:</span>
|
||||
<span class="detail_value">{{ work_order.device.device_type }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">项目类型:</span>
|
||||
<span class="detail_value">{{ work_order.device.project_type }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">派单时间:</span>
|
||||
<span class="detail_value">{{ work_order.dispatch_time }}</span>
|
||||
</div>
|
||||
<div class="detail_item">
|
||||
<span class="detail_label">关联账号:</span>
|
||||
<span class="detail_value">{{ work_order.device.account }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处理信息表单 -->
|
||||
<div class="detail_card">
|
||||
<h3 class="detail_title">处理信息</h3>
|
||||
<form method="POST" enctype="multipart/form-data" class="detail_form">
|
||||
<div class="form-group">
|
||||
<label for="handler">故障处理人<span class="required">*</span></label>
|
||||
<input type="text" id="handler" name="handler" value="{{ current_user.name }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fault_type">故障类型<span class="required">*</span></label>
|
||||
<select id="fault_type" name="fault_type" required>
|
||||
<option value="">请选择故障类型</option>
|
||||
<option value="光猫故障">光猫故障</option>
|
||||
<option value="设备故障">设备故障</option>
|
||||
<option value="电源故障">电源故障</option>
|
||||
<option value="电路故障">电路故障</option>
|
||||
<option value="网线故障">网线故障</option>
|
||||
<option value="皮线故障">皮线故障</option>
|
||||
<option value="数据问题">数据问题</option>
|
||||
<option value="用户欠费">用户欠费</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="handle_description">处理描述<span class="required">*</span></label>
|
||||
<textarea id="handle_description" name="handle_description"
|
||||
placeholder="请简述处理过程,如:光猫电源适配器坏,更换光猫电源适配器后恢复。" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="photo">现场图片(最多5张)</label>
|
||||
<input type="file" id="photo" name="photo" multiple accept="image/*" class="detail_file_input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contact_person">故障对接人<span class="required">*</span></label>
|
||||
<input type="text" id="contact_person" name="contact_person"
|
||||
value="{{ work_order.contact_person }}"
|
||||
placeholder="请输入本次故障单位对接人" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contact_phone">联系电话</label>
|
||||
<input type="tel" id="contact_phone" name="contact_phone"
|
||||
value="{{ work_order.contact_phone }}"
|
||||
placeholder="请输入本次故障单位对接人联系电话">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="distance_from_branch">距离支局公里数<span class="required">*</span></label>
|
||||
<input type="number" id="distance_from_branch" name="distance_from_branch"
|
||||
step="0.1" placeholder="请输入支局办公室到故障地点实际距离" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="remark">备注</label>
|
||||
<textarea id="remark" name="remark"
|
||||
placeholder="如有其他需要说明的信息请在此备注"></textarea>
|
||||
</div>
|
||||
<div class="form_tips">
|
||||
<p>注意事项</p>
|
||||
<ul>
|
||||
<li>现场照片最多可上传5张,支持jpg、jpeg、png</li>
|
||||
<li>距离支局公里数请填写支局办公室到故障地点实际距离</li>
|
||||
<li>带 <span class="required">*</span> 的字段为必填项</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="detail_actions">
|
||||
<button type="submit" class="detail_btn detail_btn_primary">提交</button>
|
||||
{% if current_user.role == '统计员' %}
|
||||
<a href="{{ url_for('recall_order', order_id=work_order.order_id) }}"
|
||||
class="detail_btn detail_btn_danger"
|
||||
onclick="return confirm('确定要追回这张工单吗?')">追回</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('receive') }}" class="detail_btn detail_btn_secondary">返回</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('fault_type').addEventListener('change', function() {
|
||||
const faultType = this.value;
|
||||
const descriptionField = document.getElementById('handle_description');
|
||||
|
||||
// 根据故障类型设置默认描述文本
|
||||
const descriptions = {
|
||||
'光猫故障': '检查发现光猫故障,更换光猫后恢复正常。',
|
||||
'设备故障': '检查发现设备故障,重启设备后恢复正常。',
|
||||
'电源故障': '检查发现电源适配器故障,更换电源适配器后恢复正常。',
|
||||
'电路故障': '检查发现电路故障,修复电路后恢复正常。',
|
||||
'网线故障': '检查发现网线故障,更换网线后恢复正常。',
|
||||
'皮线故障': '检查发现皮线故障,更换皮线后恢复正常。',
|
||||
'数据问题': '检查发现数据配置异常,修改配置后恢复正常。',
|
||||
'用户欠费': '检查发现用户已欠费,通知用户缴费后恢复正常。',
|
||||
'其他': ''
|
||||
};
|
||||
|
||||
// 如果选择了故障类型,则填入对应的描述文本
|
||||
if (faultType && descriptions[faultType]) {
|
||||
descriptionField.value = descriptions[faultType];
|
||||
} else {
|
||||
descriptionField.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,255 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||
<div class="statistics_container">
|
||||
<!-- 本周统计 -->
|
||||
<div class="statistics_section">
|
||||
<h2 class="statistics_title">本周统计
|
||||
<a href="{{ url_for('static', filename='file/关于政企交付运营中心2025年项目运维及交付实施激励方案.pdf') }}"
|
||||
class="incentive_button" target="_blank">📄 关于政企交付运营中心2025年项目运维及交付实施激励方案</a>
|
||||
</h2>
|
||||
<!-- 普通工单统计 -->
|
||||
<div class="statistics_card">
|
||||
<h3>普通工单</h3>
|
||||
{% if current_user.role == '统计员' %}
|
||||
<div class="statistics_grid">
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">派单总数</span>
|
||||
<span class="statistics_value">{{ week_work_stats.total }}</span>
|
||||
</div>
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">已处理</span>
|
||||
<span class="statistics_value">{{ week_work_stats.completed }}</span>
|
||||
</div>
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">未处理</span>
|
||||
<span class="statistics_value">{{ week_work_stats.pending }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="statistics_grid">
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">已处理工单</span>
|
||||
<span class="statistics_value">{{ week_work_stats.completed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 手工故障单统计 -->
|
||||
<div class="statistics_card">
|
||||
<h3>手工故障单</h3>
|
||||
<div class="statistics_grid">
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">处理完成数量</span>
|
||||
<span class="statistics_value">{{ week_manual_stats }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运维奖励统计 -->
|
||||
<div class="statistics_card">
|
||||
<h3>运维奖励(元)</h3>
|
||||
<div class="statistics_grid">
|
||||
{% if week_rewards is mapping %}
|
||||
{% for project_type, reward in week_rewards.items() %}
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">{{ project_type }}</span>
|
||||
<span class="statistics_value">{{ "%.2f"|format(reward) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="statistics_item total_reward">
|
||||
<span class="statistics_label">总计</span>
|
||||
<span class="statistics_value">{{ "%.2f"|format(week_rewards.values()|sum) if week_rewards is mapping else "%.2f"|format(week_rewards|default(0)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本月统计 -->
|
||||
<div class="statistics_section">
|
||||
<h2 class="statistics_title">本月统计
|
||||
{% if current_user.role == '统计员' %}
|
||||
<button onclick="showMonthPicker()" class="export_button">📥︎ 导出往月明细</button>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<!-- 普通工单统计 -->
|
||||
<div class="statistics_card">
|
||||
<h3>普通工单</h3>
|
||||
{% if current_user.role == '统计员' %}
|
||||
<div class="statistics_grid">
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">派单总数</span>
|
||||
<span class="statistics_value">{{ month_work_stats.total }}</span>
|
||||
</div>
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">已处理</span>
|
||||
<span class="statistics_value">{{ month_work_stats.completed }}</span>
|
||||
</div>
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">未处理</span>
|
||||
<span class="statistics_value">{{ month_work_stats.pending }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="statistics_grid">
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">已处理工单</span>
|
||||
<span class="statistics_value">{{ month_work_stats.completed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 手工故障单统计 -->
|
||||
<div class="statistics_card">
|
||||
<h3>手工故障单</h3>
|
||||
<div class="statistics_grid">
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">处理完成数量</span>
|
||||
<span class="statistics_value">{{ month_manual_stats }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运维奖励统计 -->
|
||||
<div class="statistics_card">
|
||||
<h3>运维奖励(元)</h3>
|
||||
<div class="statistics_grid">
|
||||
{% if month_rewards is mapping %}
|
||||
{% for project_type, reward in month_rewards.items() %}
|
||||
<div class="statistics_item">
|
||||
<span class="statistics_label">{{ project_type }}</span>
|
||||
<span class="statistics_value">{{ "%.2f"|format(reward) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="statistics_item total_reward">
|
||||
<span class="statistics_label">总计</span>
|
||||
<span class="statistics_value">{{ "%.2f"|format(month_rewards.values()|sum) if month_rewards is mapping else "%.2f"|format(month_rewards|default(0)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.role == '统计员' %}
|
||||
<div class="statistics_section">
|
||||
<h2 class="statistics_title">本周奖励统计</h2>
|
||||
<div class="statistics_card">
|
||||
<div class="handler_stats_table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>处理人</th>
|
||||
<th>普通工单</th>
|
||||
<th>手工工单</th>
|
||||
<th>校园安防"4+N"项目</th>
|
||||
<th>综治视联网</th>
|
||||
<th>教育城域网</th>
|
||||
<th>其他</th>
|
||||
<th>预计奖励</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for handler, stats in week_handler_stats.items() %}
|
||||
<tr>
|
||||
<td>{{ handler }}</td>
|
||||
<td>{{ stats.work_orders }}</td>
|
||||
<td>{{ stats.manual_orders }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['校园安防"4+N"项目']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['综治视联网']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['教育城域网']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['其他']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards.values()|sum) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="statistics_section">
|
||||
<h2 class="statistics_title">本月奖励统计</h2>
|
||||
<div class="statistics_card">
|
||||
<div class="handler_stats_table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>处理人</th>
|
||||
<th>普通工单</th>
|
||||
<th>手工工单</th>
|
||||
<th>校园安防"4+N"项目</th>
|
||||
<th>综治视联网</th>
|
||||
<th>教育城域网</th>
|
||||
<th>其他</th>
|
||||
<th>预计奖励</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for handler, stats in month_handler_stats.items() %}
|
||||
<tr>
|
||||
<td>{{ handler }}</td>
|
||||
<td>{{ stats.work_orders }}</td>
|
||||
<td>{{ stats.manual_orders }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['校园安防"4+N"项目']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['综治视联网']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['教育城域网']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards['其他']|default(0)) }}</td>
|
||||
<td>{{ "%.2f"|format(stats.rewards.values()|sum) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- 月份选择器模态框 -->
|
||||
<div id="monthPickerModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3>选择导出月份</h3>
|
||||
<div class="month-picker-form">
|
||||
<input type="month" id="monthPicker" class="month-input">
|
||||
<div class="modal-buttons">
|
||||
<button onclick="exportMonthlyStats()" class="export_button">导出</button>
|
||||
<button onclick="closeModal()" class="cancel_button">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function showMonthPicker() {
|
||||
document.getElementById('monthPickerModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('monthPickerModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function exportMonthlyStats() {
|
||||
const monthPicker = document.getElementById('monthPicker');
|
||||
const selectedMonth = monthPicker.value; // 格式:YYYY-MM
|
||||
if (!selectedMonth) {
|
||||
alert('请选择月份');
|
||||
return;
|
||||
}
|
||||
// 将 YYYY-MM 格式拆分为年和月
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
window.location.href = `/export_monthly_stats/${year}/${month}`;
|
||||
closeModal();
|
||||
}
|
||||
|
||||
// 点击模态框外部关闭
|
||||
document.getElementById('monthPickerModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-3">用户管理</h2>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-info">
|
||||
{% for message in messages %}
|
||||
{{ message }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>手机号</th>
|
||||
<th>姓名</th>
|
||||
<th>所属支局</th>
|
||||
<th>角色</th>
|
||||
<th style="width: 160px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<form method="POST" action="{{ url_for('update_user', phone=user.phone) }}">
|
||||
<td>{{ user.phone }}</td>
|
||||
<td>{{ user.name }}</td>
|
||||
<td>
|
||||
<select name="branch" class="form-select">
|
||||
<option value="" {% if not user.branch %}selected{% endif %}>未设置</option>
|
||||
{% for branch in branch_options %}
|
||||
<option value="{{ branch }}" {% if user.branch == branch %}selected{% endif %}>{{ branch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="role" class="form-select" required>
|
||||
{% for role in role_options %}
|
||||
<option value="{{ role }}" {% if user.role == role %}selected{% endif %}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button type="submit" class="btn btn-primary btn-sm">保存</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Casdoor 认证工具模块
|
||||
用于处理 Casdoor OAuth/OIDC 认证流程
|
||||
"""
|
||||
import requests
|
||||
import urllib.parse
|
||||
from flask import session, current_app
|
||||
import secrets
|
||||
|
||||
|
||||
class CasdoorAuth:
|
||||
"""Casdoor 认证处理类"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
self.app = app
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""初始化应用配置"""
|
||||
self.endpoint = app.config.get('CASDOOR_ENDPOINT')
|
||||
self.client_id = app.config.get('CASDOOR_CLIENT_ID')
|
||||
self.client_secret = app.config.get('CASDOOR_CLIENT_SECRET')
|
||||
self.organization_name = app.config.get('CASDOOR_ORGANIZATION_NAME', 'built-in')
|
||||
self.application_name = app.config.get('CASDOOR_APPLICATION_NAME', 'app-built-in')
|
||||
self.redirect_uri = app.config.get('CASDOOR_REDIRECT_URI')
|
||||
|
||||
def get_authorization_url(self, state=None):
|
||||
"""
|
||||
生成 Casdoor 授权 URL
|
||||
|
||||
Args:
|
||||
state: 状态参数,用于防止 CSRF 攻击
|
||||
|
||||
Returns:
|
||||
str: 授权 URL
|
||||
"""
|
||||
if not state:
|
||||
state = secrets.token_urlsafe(32)
|
||||
session['casdoor_state'] = state
|
||||
|
||||
params = {
|
||||
'client_id': self.client_id,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'scope': 'openid profile email phone',
|
||||
'state': state
|
||||
}
|
||||
|
||||
auth_url = f"{self.endpoint}/login/oauth/authorize"
|
||||
return f"{auth_url}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def get_token(self, code):
|
||||
"""
|
||||
使用授权码获取访问令牌
|
||||
|
||||
Args:
|
||||
code: 授权码
|
||||
|
||||
Returns:
|
||||
dict: 包含 access_token 和 id_token 的字典
|
||||
"""
|
||||
token_url = f"{self.endpoint}/api/login/oauth/access_token"
|
||||
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(token_url, data=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
try:
|
||||
current_app.logger.error(f"获取 Casdoor token 失败: {str(e)}")
|
||||
except:
|
||||
pass # 如果无法记录日志,忽略错误
|
||||
return None
|
||||
|
||||
def get_user_info(self, access_token):
|
||||
"""
|
||||
使用访问令牌获取用户信息
|
||||
|
||||
Args:
|
||||
access_token: 访问令牌
|
||||
|
||||
Returns:
|
||||
dict: 用户信息字典
|
||||
"""
|
||||
user_info_url = f"{self.endpoint}/api/userinfo"
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(user_info_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
try:
|
||||
current_app.logger.error(f"获取 Casdoor 用户信息失败: {str(e)}")
|
||||
except:
|
||||
pass # 如果无法记录日志,忽略错误
|
||||
return None
|
||||
|
||||
def get_account_info(self, access_token):
|
||||
"""
|
||||
使用访问令牌获取完整账户信息(包含标签等字段)
|
||||
"""
|
||||
account_url = f"{self.endpoint}/api/get-account"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(account_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Casdoor 通常返回 {"status": "ok", "data": {...}}
|
||||
if isinstance(result, dict):
|
||||
if result.get('status') == 'ok' and isinstance(result.get('data'), dict):
|
||||
return result['data']
|
||||
if 'data' in result and isinstance(result.get('data'), dict):
|
||||
return result['data']
|
||||
return result if isinstance(result, dict) else None
|
||||
except requests.exceptions.RequestException as e:
|
||||
try:
|
||||
current_app.logger.error(f"获取 Casdoor 账户信息失败: {str(e)}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def verify_state(self, state):
|
||||
"""
|
||||
验证 state 参数,防止 CSRF 攻击
|
||||
|
||||
Args:
|
||||
state: 从回调中获取的 state 参数
|
||||
|
||||
Returns:
|
||||
bool: 验证是否通过
|
||||
"""
|
||||
stored_state = session.get('casdoor_state')
|
||||
if stored_state and stored_state == state:
|
||||
session.pop('casdoor_state', None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def login_with_password(self, username, password):
|
||||
"""
|
||||
使用用户名和密码通过 API 登录 Casdoor
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
|
||||
Returns:
|
||||
dict: 包含 access_token 和用户信息的字典,失败返回 None
|
||||
"""
|
||||
# Casdoor 使用 OAuth 2.0 password grant 方式登录
|
||||
# 端点应该是 /api/login/oauth/access_token
|
||||
login_url = f"{self.endpoint}/api/login/oauth/access_token"
|
||||
|
||||
# OAuth 2.0 password grant 需要的参数
|
||||
data = {
|
||||
'grant_type': 'password',
|
||||
'username': username,
|
||||
'password': password,
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'scope': 'openid profile email phone'
|
||||
}
|
||||
|
||||
try:
|
||||
current_app.logger.info(f"尝试 Casdoor OAuth password grant 登录: username={username}")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# OAuth 2.0 password grant 通常使用 form-data 格式
|
||||
response = requests.post(login_url, data=data, timeout=10)
|
||||
|
||||
# 如果返回 415,尝试 JSON 格式
|
||||
if response.status_code == 415:
|
||||
response = requests.post(login_url, json=data, timeout=10)
|
||||
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
error_msg = response.text[:200] # 限制错误消息长度
|
||||
current_app.logger.warning(f"Casdoor OAuth 登录失败 (状态码 {response.status_code}): {error_msg}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 尝试解析 JSON 响应
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as e:
|
||||
try:
|
||||
current_app.logger.error(f"Casdoor API 登录响应不是有效的 JSON: {str(e)}, 响应内容: {response.text[:200]}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 检查 result 是否为 None
|
||||
if result is None:
|
||||
try:
|
||||
current_app.logger.error("Casdoor API 登录返回 None")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# OAuth 2.0 返回格式通常是 {"access_token": "...", "token_type": "Bearer", ...}
|
||||
# 或者 Casdoor 格式 {"status": "ok", "data": {"access_token": "..."}}
|
||||
access_token = None
|
||||
if isinstance(result, dict):
|
||||
# 检查是否有错误
|
||||
if result.get('status') == 'error':
|
||||
try:
|
||||
error_msg = result.get('msg', '未知错误')
|
||||
current_app.logger.warning(f"Casdoor OAuth 登录错误: {error_msg}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 标准 OAuth 2.0 格式
|
||||
access_token = result.get('access_token')
|
||||
|
||||
# Casdoor 格式 {"status": "ok", "data": {...}}
|
||||
if not access_token and result.get('status') == 'ok':
|
||||
token_data = result.get('data', {})
|
||||
if isinstance(token_data, dict):
|
||||
access_token = token_data.get('access_token') or token_data.get('token') or token_data.get('accessToken')
|
||||
|
||||
# 如果还是没有,尝试其他可能的字段
|
||||
if not access_token:
|
||||
access_token = result.get('token') or result.get('accessToken')
|
||||
|
||||
# 如果还是没有 token,尝试从其他字段获取
|
||||
if not access_token and 'data' in result and isinstance(result.get('data'), dict):
|
||||
access_token = result['data'].get('access_token') or result['data'].get('token')
|
||||
|
||||
if not access_token:
|
||||
try:
|
||||
current_app.logger.warning(f"Casdoor API 登录返回格式异常,未找到 token: {result}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 使用 token 获取用户信息
|
||||
user_info = self.get_user_info(access_token)
|
||||
if not user_info:
|
||||
return None
|
||||
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'user_info': user_info
|
||||
}
|
||||
except requests.exceptions.Timeout:
|
||||
try:
|
||||
current_app.logger.error("Casdoor API 登录超时")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
try:
|
||||
current_app.logger.error(f"Casdoor API 登录失败: {str(e)}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
except Exception as e:
|
||||
try:
|
||||
current_app.logger.error(f"Casdoor API 登录发生未知错误: {str(e)}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
casdoor_auth = CasdoorAuth()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from functools import wraps
|
||||
import time
|
||||
from flask import current_app
|
||||
import logging
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import text
|
||||
from scripts.models import db
|
||||
from collections import defaultdict
|
||||
|
||||
# 简单的内存缓存实现
|
||||
class QueryCache:
|
||||
def __init__(self, max_size=100, ttl=300): # 默认缓存300秒
|
||||
self.cache = {}
|
||||
self.max_size = max_size
|
||||
self.ttl = ttl
|
||||
self.access_times = defaultdict(int)
|
||||
|
||||
def get(self, key):
|
||||
if key in self.cache:
|
||||
item = self.cache[key]
|
||||
if time.time() - item['time'] < self.ttl:
|
||||
self.access_times[key] += 1
|
||||
return item['value']
|
||||
else:
|
||||
# 缓存过期,删除
|
||||
del self.cache[key]
|
||||
if key in self.access_times:
|
||||
del self.access_times[key]
|
||||
return None
|
||||
|
||||
def set(self, key, value):
|
||||
# 如果缓存满了,删除最少访问的项
|
||||
if len(self.cache) >= self.max_size:
|
||||
# 找出访问次数最少的键
|
||||
if self.access_times:
|
||||
min_key = min(self.access_times, key=self.access_times.get)
|
||||
if min_key in self.cache:
|
||||
del self.cache[min_key]
|
||||
del self.access_times[min_key]
|
||||
|
||||
self.cache[key] = {
|
||||
'value': value,
|
||||
'time': time.time()
|
||||
}
|
||||
self.access_times[key] = 1
|
||||
return value
|
||||
|
||||
# 创建全局缓存实例
|
||||
query_cache = QueryCache()
|
||||
|
||||
# 数据库操作装饰器 - 用于缓存结果
|
||||
def cache_query(ttl=300):
|
||||
"""
|
||||
缓存查询结果的装饰器
|
||||
:param ttl: 缓存有效期(秒)
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 生成缓存键
|
||||
cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached_result = query_cache.get(cache_key)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# 执行查询
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 设置缓存
|
||||
query_cache.set(cache_key, result)
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
# 数据库操作装饰器 - 用于异常处理
|
||||
def db_operation(retries=3, retry_delay=0.5):
|
||||
"""
|
||||
数据库操作的装饰器,提供异常处理和重试功能
|
||||
:param retries: 重试次数
|
||||
:param retry_delay: 重试延迟(秒)
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
last_error = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except SQLAlchemyError as e:
|
||||
last_error = e
|
||||
current_app.logger.warning(f"数据库操作失败: {str(e)},尝试重试 ({attempt+1}/{retries})")
|
||||
|
||||
# 回滚会话
|
||||
db.session.rollback()
|
||||
|
||||
# 如果不是最后一次尝试,则等待后重试
|
||||
if attempt < retries - 1:
|
||||
time.sleep(retry_delay)
|
||||
|
||||
# 所有重试都失败
|
||||
current_app.logger.error(f"数据库操作失败(已重试{retries}次): {str(last_error)}")
|
||||
raise last_error
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
# 辅助函数:清除特定表相关的所有缓存
|
||||
def clear_table_cache(table_name):
|
||||
"""
|
||||
清除与特定表相关的所有缓存
|
||||
:param table_name: 表名
|
||||
"""
|
||||
keys_to_delete = []
|
||||
for key in list(query_cache.cache.keys()):
|
||||
if table_name.lower() in key.lower():
|
||||
keys_to_delete.append(key)
|
||||
|
||||
for key in keys_to_delete:
|
||||
if key in query_cache.cache:
|
||||
del query_cache.cache[key]
|
||||
if key in query_cache.access_times:
|
||||
del query_cache.access_times[key]
|
||||
|
||||
# 数据库连接重试机制
|
||||
def retry_database_connection(db_instance, app, max_retries=10, retry_delay=5):
|
||||
"""
|
||||
尝试连接数据库,在连接失败时进行重试
|
||||
:param db_instance: SQLAlchemy数据库实例
|
||||
:param app: Flask应用实例
|
||||
:param max_retries: 最大重试次数
|
||||
:param retry_delay: 重试间隔(秒)
|
||||
:return: 连接成功返回True,失败返回False
|
||||
"""
|
||||
import sqlalchemy.exc as sa_exc
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
app.logger.info(f"尝试连接数据库... (尝试 {attempt + 1}/{max_retries})")
|
||||
|
||||
# 使用with语句确保应用上下文正确
|
||||
with app.app_context():
|
||||
# 尝试建立连接 - SQLAlchemy 2.0 兼容方式
|
||||
with db_instance.engine.connect() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
connection.commit() # 确保事务提交
|
||||
|
||||
app.logger.info("数据库连接成功!")
|
||||
return True
|
||||
|
||||
except (sa_exc.OperationalError, sa_exc.DatabaseError, Exception) as e:
|
||||
app.logger.warning(f"数据库连接失败: {str(e)}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
app.logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
app.logger.error(f"数据库连接失败,已重试 {max_retries} 次,放弃连接")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
# 通用查询函数,包含异常处理和缓存
|
||||
@db_operation()
|
||||
@cache_query()
|
||||
def get_all_distinct_values(model, column_name):
|
||||
"""
|
||||
获取指定模型指定列的所有不同值
|
||||
:param model: 模型类
|
||||
:param column_name: 列名
|
||||
:return: 所有不同值的列表
|
||||
"""
|
||||
column = getattr(model, column_name)
|
||||
results = db.session.query(column).distinct().all()
|
||||
return [result[0] for result in results if result[0]]
|
||||
@@ -0,0 +1,150 @@
|
||||
from flask import render_template, jsonify, request, flash, redirect, url_for
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
||||
from werkzeug.exceptions import HTTPException, NotFound, Forbidden, BadRequest
|
||||
import traceback
|
||||
|
||||
class ErrorHandler:
|
||||
"""
|
||||
全局异常处理器
|
||||
用于注册Flask应用程序的错误处理函数
|
||||
"""
|
||||
def __init__(self, app=None):
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""
|
||||
初始化应用程序的错误处理器
|
||||
:param app: Flask应用实例
|
||||
"""
|
||||
# 处理SQLAlchemy错误
|
||||
app.register_error_handler(SQLAlchemyError, self.handle_db_error)
|
||||
app.register_error_handler(IntegrityError, self.handle_integrity_error)
|
||||
app.register_error_handler(OperationalError, self.handle_operational_error)
|
||||
|
||||
# 处理HTTP错误
|
||||
app.register_error_handler(NotFound, self.handle_not_found)
|
||||
app.register_error_handler(Forbidden, self.handle_forbidden)
|
||||
app.register_error_handler(BadRequest, self.handle_bad_request)
|
||||
|
||||
# 处理通用错误
|
||||
app.register_error_handler(Exception, self.handle_generic_error)
|
||||
|
||||
# 记录初始化完成
|
||||
app.logger.info("错误处理器初始化完成")
|
||||
|
||||
def handle_db_error(self, error):
|
||||
"""
|
||||
处理一般数据库错误
|
||||
"""
|
||||
error_details = str(error)
|
||||
traceback_details = traceback.format_exc()
|
||||
current_app = self._get_current_app()
|
||||
current_app.logger.error(f"数据库错误: {error_details}\n{traceback_details}")
|
||||
|
||||
return self._handle_error("数据库操作错误",
|
||||
"抱歉,数据库操作失败,请稍后重试。",
|
||||
500)
|
||||
|
||||
def handle_integrity_error(self, error):
|
||||
"""
|
||||
处理数据完整性错误(如违反唯一约束)
|
||||
"""
|
||||
error_details = str(error)
|
||||
current_app = self._get_current_app()
|
||||
current_app.logger.error(f"数据完整性错误: {error_details}")
|
||||
|
||||
# 尝试从错误消息中提取更友好的错误消息
|
||||
friendly_message = "数据冲突,请检查您的输入。"
|
||||
if "Duplicate entry" in error_details:
|
||||
friendly_message = "数据已存在,请检查您的输入。"
|
||||
|
||||
return self._handle_error("数据完整性错误", friendly_message, 400)
|
||||
|
||||
def handle_operational_error(self, error):
|
||||
"""
|
||||
处理数据库操作错误(如连接问题)
|
||||
"""
|
||||
error_details = str(error)
|
||||
current_app = self._get_current_app()
|
||||
current_app.logger.error(f"数据库操作错误: {error_details}")
|
||||
|
||||
return self._handle_error("数据库连接错误",
|
||||
"抱歉,无法连接到数据库,请稍后重试。",
|
||||
503)
|
||||
|
||||
def handle_not_found(self, error):
|
||||
"""
|
||||
处理404错误
|
||||
"""
|
||||
return self._handle_error("页面未找到",
|
||||
"抱歉,您请求的页面不存在。",
|
||||
404)
|
||||
|
||||
def handle_forbidden(self, error):
|
||||
"""
|
||||
处理403错误
|
||||
"""
|
||||
return self._handle_error("访问被拒绝",
|
||||
"抱歉,您没有权限访问此页面。",
|
||||
403)
|
||||
|
||||
def handle_bad_request(self, error):
|
||||
"""
|
||||
处理400错误
|
||||
"""
|
||||
return self._handle_error("请求错误",
|
||||
"抱歉,服务器无法理解您的请求。",
|
||||
400)
|
||||
|
||||
def handle_generic_error(self, error):
|
||||
"""
|
||||
处理未被其他处理程序捕获的通用错误
|
||||
"""
|
||||
error_details = str(error)
|
||||
traceback_details = traceback.format_exc()
|
||||
current_app = self._get_current_app()
|
||||
current_app.logger.error(f"未处理异常: {error_details}\n{traceback_details}")
|
||||
|
||||
if isinstance(error, HTTPException):
|
||||
return self._handle_error("请求错误", error.description, error.code)
|
||||
|
||||
return self._handle_error("服务器错误",
|
||||
"抱歉,服务器遇到了未知错误,请稍后重试。",
|
||||
500)
|
||||
|
||||
def _handle_error(self, title, message, status_code):
|
||||
"""
|
||||
根据请求类型返回适当的错误响应
|
||||
:param title: 错误标题
|
||||
:param message: 错误消息
|
||||
:param status_code: HTTP状态码
|
||||
"""
|
||||
# 根据接受的内容类型决定返回JSON还是HTML
|
||||
# 检查是否是AJAX请求,is_xhr在新版Flask中已被移除
|
||||
is_ajax_request = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
if is_ajax_request or request.headers.get('Accept') == 'application/json':
|
||||
# 返回JSON格式的错误
|
||||
return jsonify({
|
||||
'error': title,
|
||||
'message': message
|
||||
}), status_code
|
||||
else:
|
||||
# 使用Flash消息并重定向到统计页面
|
||||
from flask import current_app
|
||||
flash(message)
|
||||
|
||||
# 根据错误类型决定重定向页面
|
||||
if status_code == 404:
|
||||
return redirect(url_for('statistics'))
|
||||
elif status_code == 403:
|
||||
return redirect(url_for('statistics'))
|
||||
else:
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
def _get_current_app(self):
|
||||
"""
|
||||
获取当前Flask应用实例
|
||||
"""
|
||||
from flask import current_app
|
||||
return current_app
|
||||
@@ -0,0 +1,10 @@
|
||||
from PIL import Image
|
||||
|
||||
def allowed_file(filename, allowed_extensions):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||
|
||||
def compress_image(image_path, max_size=(1500, 1500), quality=85):
|
||||
"""压缩图片"""
|
||||
img = Image.open(image_path)
|
||||
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
img.save(image_path, quality=quality)
|
||||
@@ -0,0 +1,102 @@
|
||||
# utils\lzmx.py
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# 推送系统工单处理信息到量子密信
|
||||
def send_lzmx_message(webhook_url, handler, device_id, device_type, distance_from_branch, restore_time):
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
message = {
|
||||
"type": "text",
|
||||
"textMsg": {
|
||||
"content":
|
||||
f"📢 故障处理通知\n"
|
||||
f"故障设备:{device_id}\n"
|
||||
f"处理时间:{restore_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"故障类型:{device_type}\n"
|
||||
f"距离支局:{distance_from_branch}公里\n"
|
||||
f"处理人:{handler}"
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
webhook_url,
|
||||
headers=headers,
|
||||
data=json.dumps(message)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"发送量子密信消息失败: {str(e)}")
|
||||
return False
|
||||
|
||||
# 推送手工申报工单信息到量子密信
|
||||
def send_manual_lzmx_message(webhook_url, handler, device_id, fault_type, distance_from_branch, fault_time, project_type, device_location):
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
message = {
|
||||
"type": "text",
|
||||
"textMsg": {
|
||||
"content":
|
||||
f"📝 故障处理通知\n"
|
||||
f"项目类型:{project_type}\n"
|
||||
f"故障设备:{device_id}\n"
|
||||
f"设备位置:{device_location}\n"
|
||||
f"故障时间:{fault_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"故障类型:{fault_type}\n"
|
||||
f"距离支局:{distance_from_branch}公里\n"
|
||||
f"处理人:{handler}"
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
webhook_url,
|
||||
headers=headers,
|
||||
data=json.dumps(message)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"发送量子密信消息失败: {str(e)}")
|
||||
return False
|
||||
|
||||
# 每周五统计本周情况推送量子密信
|
||||
def send_weekly_stats_message(webhook_url, stats):
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
# 格式化前三名处理人信息
|
||||
top_handlers = []
|
||||
for handler, amount in stats['top_handlers']:
|
||||
top_handlers.append(f"👤 {handler}: {amount}元")
|
||||
|
||||
message = {
|
||||
"type": "text",
|
||||
"textMsg": {
|
||||
"content":
|
||||
f"📊 本周工单统计报告\n"
|
||||
f"—————————————————\n"
|
||||
f" 本周派单:{stats['total_orders']}单\n"
|
||||
f" 已处理数:{stats['completed_orders']}单\n"
|
||||
f" 未处理数:{stats['pending_orders']}单\n"
|
||||
f" 手工故障:{stats['manual_orders']}个\n\n"
|
||||
f"🏆 前三名预计奖励:\n"
|
||||
f"{chr(10).join(top_handlers)}\n"
|
||||
f"—————————————————\n"
|
||||
f"统计时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
webhook_url,
|
||||
headers=headers,
|
||||
data=json.dumps(message)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"发送量子密信消息失败: {str(e)}")
|
||||
return False
|
||||
@@ -0,0 +1,11 @@
|
||||
class Pagination:
|
||||
def __init__(self, items, page, per_page, total):
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total = total
|
||||
self.pages = (total + per_page - 1) // per_page
|
||||
self.has_prev = page > 1
|
||||
self.has_next = page < self.pages
|
||||
self.prev_num = page - 1
|
||||
self.next_num = page + 1
|
||||
@@ -0,0 +1,66 @@
|
||||
def get_reward_by_distance(project_type, distance):
|
||||
"""
|
||||
根据项目类型和距离计算奖励金额
|
||||
|
||||
Args:
|
||||
project_type (str): 项目类型
|
||||
distance (float): 距离(公里)
|
||||
|
||||
Returns:
|
||||
float: 奖励金额(元)
|
||||
"""
|
||||
# 2025.04.21 by v6ole
|
||||
# 奖励配置说明:
|
||||
# 格式为: '项目类型': [(距离上限1, 奖励金额1), (距离上限2, 奖励金额2), ...]
|
||||
# 距离单位: 公里, 金额单位: 元
|
||||
# 配置按照距离从小到大排序,系统会自动匹配第一个符合条件的奖励金额
|
||||
rewards_config = {
|
||||
# 校园安防"4+N"项目奖励标准:
|
||||
# - 0-5公里: 20元/台
|
||||
# - 5-30公里: 40元/台
|
||||
# - 30-50公里: 50元/台
|
||||
# - 50公里以上: 60元/台
|
||||
'校园安防"4+N"项目': [
|
||||
(5, 20),
|
||||
(30, 40),
|
||||
(50, 50),
|
||||
(float('inf'), 60)
|
||||
],
|
||||
|
||||
# 综治视联网奖励标准:
|
||||
# - 0-10公里: 30元/台
|
||||
# - 10-30公里: 40元/台
|
||||
# - 30-50公里: 50元/台
|
||||
# - 50公里以上: 60元/台
|
||||
'综治视联网': [
|
||||
(10, 30),
|
||||
(30, 40),
|
||||
(50, 50),
|
||||
(float('inf'), 60)
|
||||
],
|
||||
|
||||
# 教育城域网奖励标准:
|
||||
# - 0-5公里: 20元/台
|
||||
# - 5-30公里: 30元/台
|
||||
# - 30-50公里: 40元/台
|
||||
# - 50公里以上: 50元/台
|
||||
'教育城域网': [
|
||||
(5, 20),
|
||||
(30, 30),
|
||||
(50, 40),
|
||||
(float('inf'), 50)
|
||||
],
|
||||
|
||||
# 其他项目统一标准:
|
||||
# - 任何距离: 30元/台
|
||||
'其他': [(float('inf'), 30)]
|
||||
}
|
||||
|
||||
if project_type not in rewards_config:
|
||||
return 0
|
||||
|
||||
for max_distance, reward in rewards_config[project_type]:
|
||||
if distance <= max_distance:
|
||||
return reward
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,99 @@
|
||||
[uwsgi]
|
||||
# 指定 WSGI 应用的入口模块和对象
|
||||
# 格式为 module:callable,例如 app:app 表示模块名为 app.py,应用对象为 app
|
||||
module = app:app
|
||||
|
||||
# 指定 uWSGI 监听的 HTTP 地址和端口
|
||||
# 如果使用 Nginx 反向代理,可以注释掉此行
|
||||
http = 0.0.0.0:18019
|
||||
|
||||
# 设置 uWSGI 启动的进程数
|
||||
# 根据服务器性能和需求调整进程数
|
||||
processes = 4
|
||||
|
||||
# 设置每个进程的线程数
|
||||
# 线程数和进程数共同影响并发处理能力
|
||||
threads = 2
|
||||
|
||||
# 启用主进程
|
||||
master = true
|
||||
|
||||
# 优雅地重启工作进程
|
||||
# 当收到SIGINT信号时,等待当前请求完成后再重启
|
||||
reload-mercy = 10
|
||||
|
||||
# 进程超时时间(秒),如果一个请求处理时间超过这个值,工作进程会被杀死
|
||||
harakiri = 30
|
||||
|
||||
# 接收到请求的队列最大长度,如果队列已满,新的请求将被拒绝
|
||||
listen = 100
|
||||
|
||||
# 最大并发请求数
|
||||
max-requests = 1000
|
||||
|
||||
# 每个工作进程处理完指定数量的请求后自动重启,防止内存泄漏
|
||||
max-worker-lifetime = 3600
|
||||
|
||||
# 缓冲区大小设置
|
||||
buffer-size = 32768
|
||||
|
||||
# 指定日志文件的路径
|
||||
# uWSGI 的运行日志将写入此文件
|
||||
logto = /home/v6ole/pyproject/4an/uwsgi.log
|
||||
|
||||
# 指定虚拟环境的路径
|
||||
# uWSGI 将使用此虚拟环境中的 Python 解释器和依赖库
|
||||
virtualenv = /home/v6ole/pyproject/venv
|
||||
|
||||
# 设置应用的根目录
|
||||
# uWSGI 将切换到此目录运行,确保应用的相对路径正确
|
||||
chdir = /home/v6ole/pyproject/4an/app
|
||||
|
||||
# 设置 PID 文件的路径
|
||||
# PID 文件用于管理 uWSGI 进程(如停止、重启)
|
||||
pidfile = /home/v6ole/pyproject/4an/uwsgi.pid
|
||||
|
||||
# 设置 Unix Socket 文件的路径
|
||||
# 用于与 Nginx 或其他代理服务器通信
|
||||
# 如果使用 HTTP 模式,可以注释掉此行
|
||||
socket = /home/v6ole/pyproject/4an/uwsgi.sock
|
||||
|
||||
# 设置 Socket 文件的权限
|
||||
# 660 表示只有所有者和所属组有读写权限
|
||||
chmod-socket = 660
|
||||
|
||||
# 当 uWSGI 退出时,自动清理其创建的套接字和 PID 文件
|
||||
# 保持环境整洁,避免遗留文件
|
||||
vacuum = true
|
||||
|
||||
# 设置日志文件的最大大小(单位为字节)
|
||||
# 当日志文件达到此大小时,uWSGI 将自动轮转日志
|
||||
# 示例值为 10MB
|
||||
log-maxsize = 10000000
|
||||
|
||||
# 禁用请求日志,减少IO负担
|
||||
disable-logging = true
|
||||
log-slow = 1000 # 只记录处理时间超过1000ms的请求
|
||||
|
||||
# 紧急情况下尝试恢复的时间(秒)
|
||||
worker-reload-mercy = 60
|
||||
|
||||
# 自动重载配置
|
||||
py-autoreload = 1
|
||||
|
||||
# 内存报告
|
||||
memory-report = true
|
||||
|
||||
# 启用统计服务器
|
||||
stats = 127.0.0.1:9191
|
||||
|
||||
# 平滑重启
|
||||
touch-chain-reload = true
|
||||
touch-reload = uwsgi.reload
|
||||
reload-on-exception = true # 当出现异常时自动重启
|
||||
|
||||
# 启用线程支持
|
||||
enable-threads = true
|
||||
|
||||
# 设置自动异常处理
|
||||
catch-exceptions = true
|
||||
@@ -0,0 +1,8 @@
|
||||
from flask import render_template
|
||||
from flask_login import login_required
|
||||
|
||||
def init_about_routes(app):
|
||||
@app.route('/about')
|
||||
@login_required
|
||||
def about():
|
||||
return render_template('about.html')
|
||||
@@ -0,0 +1,137 @@
|
||||
from flask import render_template, request, redirect, url_for, flash, session
|
||||
import urllib.parse
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from scripts.models import db, User
|
||||
from utils.casdoor_auth import casdoor_auth
|
||||
|
||||
def init_auth_routes(app):
|
||||
# 初始化 Casdoor 认证
|
||||
casdoor_auth.init_app(app)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
# 如果已登录,直接跳转到仪表盘
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard'))
|
||||
# 未登录展示登录页(登录页会自动跳转 Casdoor,如有错误提示则不跳转)
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/casdoor/login')
|
||||
def casdoor_login():
|
||||
"""重定向到 Casdoor 登录页面"""
|
||||
if not app.config.get('CASDOOR_CLIENT_ID'):
|
||||
flash('Casdoor 未配置,无法登录')
|
||||
return redirect(url_for('login'))
|
||||
auth_url = casdoor_auth.get_authorization_url()
|
||||
app.logger.info('重定向到 Casdoor 授权页面')
|
||||
return redirect(auth_url)
|
||||
|
||||
@app.route('/callback')
|
||||
def casdoor_callback():
|
||||
"""Casdoor OAuth 回调处理"""
|
||||
code = request.args.get('code')
|
||||
state = request.args.get('state')
|
||||
error = request.args.get('error')
|
||||
|
||||
if error:
|
||||
app.logger.error(f'Casdoor 认证失败: {error}')
|
||||
flash(f'登录失败: {error}')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not code:
|
||||
app.logger.warning('Casdoor 回调缺少授权码')
|
||||
flash('登录失败:未收到授权码')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# 验证 state 参数
|
||||
if not casdoor_auth.verify_state(state):
|
||||
app.logger.warning('Casdoor state 验证失败,可能存在 CSRF 攻击')
|
||||
flash('登录失败:安全验证失败')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# 获取访问令牌
|
||||
token_data = casdoor_auth.get_token(code)
|
||||
if not token_data or 'access_token' not in token_data:
|
||||
app.logger.error('获取 Casdoor token 失败')
|
||||
flash('登录失败:无法获取访问令牌')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
access_token = token_data['access_token']
|
||||
|
||||
# 获取用户信息
|
||||
user_info = casdoor_auth.get_user_info(access_token)
|
||||
if not user_info:
|
||||
app.logger.error('获取 Casdoor 用户信息失败')
|
||||
flash('登录失败:无法获取用户信息')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# 获取完整账户信息(包含标签)
|
||||
account_info = casdoor_auth.get_account_info(access_token)
|
||||
if not account_info:
|
||||
app.logger.error('获取 Casdoor 账户信息失败')
|
||||
flash('登录失败:无法获取账户信息')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# 从 Casdoor 用户信息中提取数据
|
||||
# Casdoor 返回的用户信息可能包含:name, email, phone, id 等字段
|
||||
casdoor_id = user_info.get('sub') or user_info.get('id') or user_info.get('name')
|
||||
phone = user_info.get('phone') or user_info.get('phoneNumber') or casdoor_id
|
||||
name = user_info.get('name') or user_info.get('displayName') or '未知用户'
|
||||
|
||||
# 基于标签的访问控制:用户标签必须等于项目名
|
||||
project_name = app.config.get('PROJECT_NAME')
|
||||
if not project_name:
|
||||
app.logger.error('未配置 PROJECT_NAME 环境变量,拒绝登录')
|
||||
flash('登录失败:未配置项目名')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
raw_tags = account_info.get('tag') or account_info.get('tags')
|
||||
if isinstance(raw_tags, str):
|
||||
user_tags = [t.strip() for t in raw_tags.split(',') if t.strip()]
|
||||
elif isinstance(raw_tags, list):
|
||||
user_tags = [str(t).strip() for t in raw_tags if str(t).strip()]
|
||||
else:
|
||||
user_tags = []
|
||||
|
||||
if project_name not in user_tags:
|
||||
app.logger.warning(f'用户 {name}({phone}) 标签 {user_tags} 不匹配项目 {project_name}')
|
||||
flash('您没有本系统的访问权限,请联系管理员处理')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
app.logger.info(f'Casdoor 用户信息: {user_info}')
|
||||
|
||||
# 查找或创建本地用户
|
||||
user = User.query.filter_by(phone=phone).first()
|
||||
if not user:
|
||||
user = User(
|
||||
phone=phone,
|
||||
name=name,
|
||||
branch=user_info.get('affiliation', ''),
|
||||
role=user_info.get('type', '')
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
app.logger.info(f'创建新用户: {name}({phone})')
|
||||
else:
|
||||
user.name = name
|
||||
if user_info.get('affiliation'):
|
||||
user.branch = user_info.get('affiliation')
|
||||
if user_info.get('type'):
|
||||
user.role = user_info.get('type')
|
||||
db.session.commit()
|
||||
app.logger.info(f'更新用户信息: {name}({phone})')
|
||||
|
||||
login_user(user)
|
||||
app.logger.info(f'用户 {user.name}({user.phone}) 通过 Casdoor 登录成功')
|
||||
|
||||
# 清除可能残留的未登录提示
|
||||
session.pop('_flashes', None)
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@app.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
app.logger.info(f'用户 {current_user.name}({current_user.phone}) 登出')
|
||||
logout_user()
|
||||
# 仅退出本地会话,保留 Casdoor 登录态
|
||||
return redirect(url_for('login'))
|
||||
@@ -0,0 +1,198 @@
|
||||
from flask import render_template, flash, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.models import db, Device, WorkOrder
|
||||
from collections import defaultdict
|
||||
from sqlalchemy import func
|
||||
from utils.db_utils import cache_query, db_operation
|
||||
|
||||
def init_dashboard_routes(app):
|
||||
@app.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问仪表盘页面')
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
try:
|
||||
app.logger.info(f'用户 {current_user.name} 访问仪表盘页面')
|
||||
|
||||
# 获取设备在线率数据
|
||||
overall_stats = get_overall_device_stats()
|
||||
branch_stats = get_branch_device_stats()
|
||||
town_stats = get_town_device_stats()
|
||||
|
||||
return render_template('dashboard.html',
|
||||
overall_stats=overall_stats,
|
||||
branch_stats=branch_stats,
|
||||
town_stats=town_stats)
|
||||
except Exception as e:
|
||||
app.logger.error(f"仪表盘页面加载失败: {str(e)}")
|
||||
flash('数据加载失败,请稍后重试')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@cache_query(ttl=300) # 缓存5分钟
|
||||
def get_overall_device_stats():
|
||||
"""获取总体设备在线率统计"""
|
||||
stats = {}
|
||||
|
||||
# 获取设备类型列表
|
||||
device_types = db.session.query(Device.device_type).distinct().all()
|
||||
device_types = [d[0] for d in device_types if d[0]]
|
||||
|
||||
for device_type in device_types:
|
||||
# 获取该类型的设备总数
|
||||
total_devices = db.session.query(func.count(Device.device_id))\
|
||||
.filter(Device.device_type == device_type)\
|
||||
.scalar() or 0
|
||||
|
||||
# 获取该类型的派单数
|
||||
work_orders = db.session.query(func.count(WorkOrder.order_id))\
|
||||
.join(Device, WorkOrder.device_id == Device.device_id)\
|
||||
.filter(Device.device_type == device_type)\
|
||||
.scalar() or 0
|
||||
|
||||
# 计算在线率
|
||||
if total_devices > 0:
|
||||
online_rate = ((total_devices - work_orders) / total_devices) * 100
|
||||
else:
|
||||
online_rate = 0
|
||||
|
||||
stats[device_type] = {
|
||||
'total': total_devices,
|
||||
'work_orders': work_orders,
|
||||
'online_rate': round(online_rate, 2)
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
@cache_query(ttl=300) # 缓存5分钟
|
||||
def get_branch_device_stats():
|
||||
"""获取各支局设备在线率统计"""
|
||||
stats = {}
|
||||
|
||||
# 获取所有支局
|
||||
branches = db.session.query(Device.branch).distinct().all()
|
||||
branches = [b[0] for b in branches if b[0]]
|
||||
|
||||
# 获取设备类型列表
|
||||
device_types = db.session.query(Device.device_type).distinct().all()
|
||||
device_types = [d[0] for d in device_types if d[0]]
|
||||
|
||||
for branch in branches:
|
||||
branch_stats = {}
|
||||
|
||||
for device_type in device_types:
|
||||
# 获取该支局该类型的设备总数
|
||||
total_devices = db.session.query(func.count(Device.device_id))\
|
||||
.filter(Device.branch == branch, Device.device_type == device_type)\
|
||||
.scalar() or 0
|
||||
|
||||
# 获取该支局该类型的派单数
|
||||
work_orders = db.session.query(func.count(WorkOrder.order_id))\
|
||||
.join(Device, WorkOrder.device_id == Device.device_id)\
|
||||
.filter(Device.branch == branch, Device.device_type == device_type)\
|
||||
.scalar() or 0
|
||||
|
||||
# 计算在线率
|
||||
if total_devices > 0:
|
||||
online_rate = ((total_devices - work_orders) / total_devices) * 100
|
||||
else:
|
||||
online_rate = 0
|
||||
|
||||
branch_stats[device_type] = {
|
||||
'total': total_devices,
|
||||
'work_orders': work_orders,
|
||||
'online_rate': round(online_rate, 2)
|
||||
}
|
||||
|
||||
# 计算该支局总体在线率
|
||||
total_branch_devices = db.session.query(func.count(Device.device_id))\
|
||||
.filter(Device.branch == branch)\
|
||||
.scalar() or 0
|
||||
|
||||
total_branch_orders = db.session.query(func.count(WorkOrder.order_id))\
|
||||
.join(Device, WorkOrder.device_id == Device.device_id)\
|
||||
.filter(Device.branch == branch)\
|
||||
.scalar() or 0
|
||||
|
||||
if total_branch_devices > 0:
|
||||
branch_online_rate = ((total_branch_devices - total_branch_orders) / total_branch_devices) * 100
|
||||
else:
|
||||
branch_online_rate = 0
|
||||
|
||||
branch_stats['总计'] = {
|
||||
'total': total_branch_devices,
|
||||
'work_orders': total_branch_orders,
|
||||
'online_rate': round(branch_online_rate, 2)
|
||||
}
|
||||
|
||||
stats[branch] = branch_stats
|
||||
|
||||
return stats
|
||||
|
||||
@cache_query(ttl=300) # 缓存5分钟
|
||||
def get_town_device_stats():
|
||||
"""获取各乡镇设备在线率统计"""
|
||||
stats = {}
|
||||
|
||||
# 获取所有乡镇
|
||||
towns = db.session.query(Device.town).distinct().all()
|
||||
towns = [t[0] for t in towns if t[0]]
|
||||
|
||||
# 获取设备类型列表
|
||||
device_types = db.session.query(Device.device_type).distinct().all()
|
||||
device_types = [d[0] for d in device_types if d[0]]
|
||||
|
||||
for town in towns:
|
||||
town_stats = {}
|
||||
|
||||
for device_type in device_types:
|
||||
# 获取该乡镇该类型的设备总数
|
||||
total_devices = db.session.query(func.count(Device.device_id))\
|
||||
.filter(Device.town == town, Device.device_type == device_type)\
|
||||
.scalar() or 0
|
||||
|
||||
# 获取该乡镇该类型的派单数
|
||||
work_orders = db.session.query(func.count(WorkOrder.order_id))\
|
||||
.join(Device, WorkOrder.device_id == Device.device_id)\
|
||||
.filter(Device.town == town, Device.device_type == device_type)\
|
||||
.scalar() or 0
|
||||
|
||||
# 计算在线率
|
||||
if total_devices > 0:
|
||||
online_rate = ((total_devices - work_orders) / total_devices) * 100
|
||||
else:
|
||||
online_rate = 0
|
||||
|
||||
town_stats[device_type] = {
|
||||
'total': total_devices,
|
||||
'work_orders': work_orders,
|
||||
'online_rate': round(online_rate, 2)
|
||||
}
|
||||
|
||||
# 计算该乡镇总体在线率
|
||||
total_town_devices = db.session.query(func.count(Device.device_id))\
|
||||
.filter(Device.town == town)\
|
||||
.scalar() or 0
|
||||
|
||||
total_town_orders = db.session.query(func.count(WorkOrder.order_id))\
|
||||
.join(Device, WorkOrder.device_id == Device.device_id)\
|
||||
.filter(Device.town == town)\
|
||||
.scalar() or 0
|
||||
|
||||
if total_town_devices > 0:
|
||||
town_online_rate = ((total_town_devices - total_town_orders) / total_town_devices) * 100
|
||||
else:
|
||||
town_online_rate = 0
|
||||
|
||||
town_stats['总计'] = {
|
||||
'total': total_town_devices,
|
||||
'work_orders': total_town_orders,
|
||||
'online_rate': round(town_online_rate, 2)
|
||||
}
|
||||
|
||||
stats[town] = town_stats
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,298 @@
|
||||
from flask import jsonify, request, send_file, session, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.update.zte import LoginClient
|
||||
from scripts.models import db, Device, WorkOrder, Channel
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text, create_engine
|
||||
import hashlib
|
||||
import pymysql
|
||||
import io
|
||||
import os
|
||||
|
||||
|
||||
def init_device_routes(app):
|
||||
@app.route('/sync_offline_devices', methods=['POST'])
|
||||
@login_required
|
||||
def sync_offline_devices():
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试同步离线设备')
|
||||
return jsonify({'success': False, 'message': '没有权限执行此操作'})
|
||||
|
||||
try:
|
||||
app.logger.info(f'用户 {current_user.name} 开始同步离线设备')
|
||||
# 从配置中获取数据库连接信息
|
||||
db_config = current_app.config['KEY110_DB']
|
||||
conn = pymysql.connect(**db_config)
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取所有教学点的状态
|
||||
cursor.execute("""
|
||||
SELECT 教学点名称, 连通状态
|
||||
FROM IP数据
|
||||
""")
|
||||
|
||||
schools = cursor.fetchall()
|
||||
|
||||
# 分别处理在线和离线的教学点
|
||||
offline_school_names = [school[0].strip().replace(' ', ' ') for school in schools if school[1] == '离线']
|
||||
online_school_names = [school[0].strip().replace(' ', ' ') for school in schools if school[1] == '在线']
|
||||
|
||||
# 关闭1key110数据库连接
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# 处理恢复在线的设备
|
||||
online_devices = Device.query.filter(
|
||||
Device.device_id.in_(online_school_names),
|
||||
Device.is_normal == False # 当前状态为故障的设备
|
||||
).all()
|
||||
|
||||
# 删除恢复在线设备的在途工单
|
||||
recovered_count = 0
|
||||
for device in online_devices:
|
||||
# 查找并删除该设备的在途工单
|
||||
pending_orders = WorkOrder.query.filter_by(
|
||||
device_id=device.device_id,
|
||||
status='待处理'
|
||||
).all()
|
||||
|
||||
for order in pending_orders:
|
||||
db.session.delete(order)
|
||||
recovered_count += 1
|
||||
|
||||
# 更新设备状态为正常
|
||||
device.is_normal = True
|
||||
db.session.add(device)
|
||||
|
||||
# 处理离线设备
|
||||
offline_devices = Device.query.filter(
|
||||
Device.device_id.in_(offline_school_names),
|
||||
Device.in_use == True,
|
||||
Device.is_normal == True
|
||||
).all()
|
||||
|
||||
# 生成工单号
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
last_work_order = WorkOrder.query.filter(
|
||||
WorkOrder.order_id.like(f'{today}%')
|
||||
).order_by(WorkOrder.order_id.desc()).first()
|
||||
|
||||
start_number = int(last_work_order.order_id[-4:]) + 1 if last_work_order else 1
|
||||
|
||||
# 创建工单
|
||||
created_count = 0
|
||||
for index, device in enumerate(offline_devices):
|
||||
# 检查是否已有在途工单
|
||||
existing_order = WorkOrder.query.filter_by(
|
||||
device_id=device.device_id,
|
||||
status='待处理'
|
||||
).first()
|
||||
|
||||
if existing_order:
|
||||
continue
|
||||
|
||||
current_number = start_number + index
|
||||
new_number = f"{today}{str(current_number).zfill(4)}"
|
||||
|
||||
work_order = WorkOrder(
|
||||
order_id=new_number,
|
||||
device_id=device.device_id,
|
||||
dispatch_time=datetime.now(),
|
||||
status='待处理',
|
||||
branch=device.branch
|
||||
)
|
||||
|
||||
db.session.add(work_order)
|
||||
device.is_normal = False
|
||||
db.session.add(device)
|
||||
created_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
app.logger.info(f'同步完成:创建了 {created_count} 个工单,恢复了 {recovered_count} 个设备')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': created_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'同步离线设备失败:{str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': str(e)
|
||||
})
|
||||
|
||||
@app.route('/check_existing_orders', methods=['POST'])
|
||||
@login_required
|
||||
def check_existing_orders():
|
||||
device_ids = request.json.get('device_ids', [])
|
||||
app.logger.info(f'用户 {current_user.name} 检查设备工单状态,设备数量:{len(device_ids)}')
|
||||
|
||||
# 查询设备状态
|
||||
existing_devices = Device.query.filter(
|
||||
Device.device_id.in_(device_ids),
|
||||
Device.is_normal == False # 检查设备是否处于故障状态
|
||||
).with_entities(Device.device_id).all()
|
||||
|
||||
# 提取设备ID列表
|
||||
existing_device_ids = [device.device_id for device in existing_devices]
|
||||
app.logger.info(f'发现 {len(existing_device_ids)} 个设备存在故障工单')
|
||||
|
||||
return jsonify({'existing_devices': existing_device_ids})
|
||||
|
||||
@app.route('/get_captcha', methods=['GET'])
|
||||
def get_captcha():
|
||||
app.logger.info('用户请求验证码')
|
||||
try:
|
||||
client = LoginClient()
|
||||
cid = client.get_captcha()
|
||||
# 将 cid 存储在 session 中供后续使用
|
||||
session['zte_cid'] = cid
|
||||
|
||||
# 读取验证码图片并返回给前端
|
||||
captcha_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||
'static', 'captcha.png')
|
||||
with open(captcha_path, "rb") as f:
|
||||
app.logger.info('验证码获取成功')
|
||||
return send_file(
|
||||
io.BytesIO(f.read()),
|
||||
mimetype='image/png'
|
||||
)
|
||||
except Exception as e:
|
||||
app.logger.error(f'获取验证码失败:{str(e)}')
|
||||
return jsonify({'success': False, 'message': '获取验证码失败'})
|
||||
|
||||
@app.route('/sync_camera_step1', methods=['POST'])
|
||||
def sync_camera_step1():
|
||||
try:
|
||||
app.logger.info(f'用户 {current_user.name if current_user else "未登录"} 开始同步摄像头第一步')
|
||||
data = request.get_json()
|
||||
captcha = data.get('captcha')
|
||||
cid = session.get('zte_cid')
|
||||
|
||||
if not captcha or not cid:
|
||||
app.logger.warning('同步摄像头失败:验证码或会话ID缺失')
|
||||
return jsonify({'success': False, 'message': 'Invalid request'})
|
||||
|
||||
client = LoginClient()
|
||||
# 登录
|
||||
app.logger.info('尝试登录中兴平台')
|
||||
login_result = client.login(
|
||||
account="DHDX001",
|
||||
password=hashlib.sha256("Root1234#".encode()).hexdigest(),
|
||||
validcode=captcha,
|
||||
cid=cid
|
||||
)
|
||||
|
||||
if login_result.get("result") != 0:
|
||||
app.logger.warning('登录中兴平台失败:验证码错误')
|
||||
return jsonify({'success': False, 'message': 'INVALID_CAPTCHA'})
|
||||
|
||||
token = login_result.get("token")
|
||||
app.logger.info('登录成功,开始获取在线信息')
|
||||
# 获取在线信息
|
||||
online_info = client.get_online_info(token)
|
||||
|
||||
if online_info.get("result") == 0:
|
||||
rows = online_info.get("rows", [])
|
||||
updated_count = 0
|
||||
offline_count = 0
|
||||
|
||||
app.logger.info(f'获取到 {len(rows)} 个设备信息,开始更新数据库')
|
||||
# 更新 channels 表
|
||||
for row in rows:
|
||||
db.session.execute(
|
||||
text("""
|
||||
INSERT INTO channels (puid, pu_name, status)
|
||||
VALUES (:puid, :pu_name, :status)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
pu_name = :pu_name, status = :status
|
||||
"""),
|
||||
{
|
||||
'puid': row['puid'],
|
||||
'pu_name': row['pu_name'],
|
||||
'status': row['status']
|
||||
}
|
||||
)
|
||||
updated_count += 1
|
||||
if row['status'] != 'online':
|
||||
offline_count += 1
|
||||
|
||||
db.session.commit()
|
||||
app.logger.info(f'数据库更新完成,更新了 {updated_count} 条记录,其中离线设备 {offline_count} 个')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'updated_count': updated_count,
|
||||
'offline_count': offline_count
|
||||
})
|
||||
|
||||
app.logger.error('获取在线信息失败')
|
||||
return jsonify({'success': False, 'message': 'Failed to get online info'})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'同步摄像头步骤1失败:{str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@app.route('/sync_camera_step2', methods=['POST'])
|
||||
def sync_camera_step2():
|
||||
try:
|
||||
app.logger.info(f'用户 {current_user.name if current_user else "未登录"} 开始同步摄像头第二步')
|
||||
# 更新设备状态
|
||||
devices = Device.query.filter(
|
||||
Device.puid.isnot(None),
|
||||
Device.in_use == True # 添加这个条件,只检查在用设备
|
||||
).all()
|
||||
app.logger.info(f'查询到 {len(devices)} 个需要同步的设备')
|
||||
offline_devices = []
|
||||
|
||||
for device in devices:
|
||||
channel = db.session.query(Channel).filter_by(puid=device.puid).first()
|
||||
if channel and channel.status != 'online':
|
||||
device.is_normal = 0
|
||||
# 检查是否已有在途工单
|
||||
existing_order = WorkOrder.query.filter_by(
|
||||
device_id=device.device_id,
|
||||
status='待处理'
|
||||
).first()
|
||||
|
||||
if not existing_order: # 只添加没有在途工单的设备
|
||||
offline_devices.append(device)
|
||||
|
||||
db.session.commit()
|
||||
app.logger.info(f'发现 {len(offline_devices)} 个离线设备需要创建工单')
|
||||
|
||||
# 为离线设备创建工单
|
||||
created_count = 0
|
||||
if offline_devices:
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
last_work_order = WorkOrder.query.filter(
|
||||
WorkOrder.order_id.like(f'{today}%')
|
||||
).order_by(WorkOrder.order_id.desc()).first()
|
||||
|
||||
start_number = int(last_work_order.order_id[-4:]) + 1 if last_work_order else 1
|
||||
|
||||
for index, device in enumerate(offline_devices):
|
||||
order_id = f"{today}{str(start_number + index).zfill(4)}"
|
||||
work_order = WorkOrder(
|
||||
order_id=order_id,
|
||||
device_id=device.device_id,
|
||||
status='待处理',
|
||||
dispatch_time=datetime.now(),
|
||||
branch=device.branch # 添加支局信息
|
||||
)
|
||||
db.session.add(work_order)
|
||||
created_count += 1
|
||||
|
||||
db.session.commit()
|
||||
app.logger.info(f'成功创建 {created_count} 个工单')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': created_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'同步摄像头步骤2失败:{str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
@@ -0,0 +1,135 @@
|
||||
from flask import render_template, request, flash, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.models import db, WorkOrderHandling, ManualWorkOrder
|
||||
from datetime import datetime
|
||||
|
||||
def init_history_routes(app):
|
||||
@app.route('/history')
|
||||
@login_required
|
||||
def history():
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问历史记录')
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
app.logger.info(f'用户 {current_user.name} 访问历史记录页面')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
search = request.args.get('search', '')
|
||||
|
||||
# 查询普通工单
|
||||
if current_user.role == '统计员':
|
||||
query1 = WorkOrderHandling.query
|
||||
query2 = ManualWorkOrder.query
|
||||
else:
|
||||
# 装维员只能看到自己处理的工单
|
||||
query1 = WorkOrderHandling.query.filter_by(handler=current_user.name)
|
||||
query2 = ManualWorkOrder.query.filter_by(handler=current_user.name)
|
||||
|
||||
# 添加搜索条件
|
||||
if search:
|
||||
query1 = query1.filter(
|
||||
db.or_(
|
||||
WorkOrderHandling.order_id.contains(search),
|
||||
WorkOrderHandling.device_id.contains(search),
|
||||
WorkOrderHandling.branch.contains(search),
|
||||
WorkOrderHandling.handler.contains(search),
|
||||
WorkOrderHandling.fault_type.contains(search),
|
||||
WorkOrderHandling.handle_description.contains(search),
|
||||
WorkOrderHandling.contact_person.contains(search),
|
||||
WorkOrderHandling.remark.contains(search)
|
||||
)
|
||||
)
|
||||
query2 = query2.filter(
|
||||
db.or_(
|
||||
ManualWorkOrder.order_id.contains(search),
|
||||
ManualWorkOrder.device_id.contains(search),
|
||||
ManualWorkOrder.branch.contains(search),
|
||||
ManualWorkOrder.handler.contains(search),
|
||||
ManualWorkOrder.fault_type.contains(search),
|
||||
ManualWorkOrder.handle_description.contains(search),
|
||||
ManualWorkOrder.contact_person.contains(search),
|
||||
ManualWorkOrder.remark.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
# 合并两种工单并按时间排序
|
||||
handlings1 = query1.all()
|
||||
handlings2 = query2.all()
|
||||
|
||||
# 将手工故障单转换为与普通工单相同的格式
|
||||
combined_handlings = []
|
||||
for h in handlings1:
|
||||
combined_handlings.append({
|
||||
'order_id': h.order_id,
|
||||
'branch': h.branch,
|
||||
'device_id': h.device_id,
|
||||
'handler': h.handler,
|
||||
'time': h.restore_time,
|
||||
'fault_type': h.fault_type,
|
||||
'type': 'normal'
|
||||
})
|
||||
|
||||
for h in handlings2:
|
||||
combined_handlings.append({
|
||||
'order_id': h.order_id,
|
||||
'branch': h.branch,
|
||||
'device_id': h.device_id,
|
||||
'handler': h.handler,
|
||||
'time': h.create_time,
|
||||
'fault_type': h.fault_type,
|
||||
'type': 'manual'
|
||||
})
|
||||
|
||||
# 按时间排序
|
||||
combined_handlings.sort(key=lambda x: x['time'], reverse=True)
|
||||
|
||||
# 手动分页
|
||||
total = len(combined_handlings)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
current_page = combined_handlings[start:end]
|
||||
|
||||
# 创建分页对象
|
||||
class Pagination:
|
||||
def __init__(self, items, page, per_page, total):
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total = total
|
||||
self.pages = (total + per_page - 1) // per_page
|
||||
self.has_prev = page > 1
|
||||
self.has_next = page < self.pages
|
||||
self.prev_num = page - 1
|
||||
self.next_num = page + 1
|
||||
|
||||
handlings = Pagination(current_page, page, per_page, total)
|
||||
|
||||
return render_template('history.html', handlings=handlings, search=search)
|
||||
|
||||
@app.route('/history/<string:order_id>')
|
||||
@login_required
|
||||
def history_detail(order_id):
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试查看工单详情 {order_id}')
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
app.logger.info(f'用户 {current_user.name} 查看工单 {order_id} 的详情')
|
||||
# 根据工单号前缀判断是普通工单还是手工故障单
|
||||
if order_id.startswith('M'):
|
||||
handling = ManualWorkOrder.query.get_or_404(order_id)
|
||||
else:
|
||||
handling = WorkOrderHandling.query.filter_by(order_id=order_id).first_or_404()
|
||||
|
||||
# 装维员只能查看自己处理的工单
|
||||
if current_user.role == '装维员' and handling.handler != current_user.name:
|
||||
flash('您没有权限查看此工单')
|
||||
return redirect(url_for('history'))
|
||||
|
||||
# 传递工单类型到模板
|
||||
order_type = 'manual' if order_id.startswith('M') else 'normal'
|
||||
|
||||
return render_template('history_detail.html', handling=handling, order_type=order_type)
|
||||
@@ -0,0 +1,86 @@
|
||||
from flask import render_template, request, jsonify, flash, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.models import db, Device, ManualWorkOrder
|
||||
from datetime import datetime
|
||||
import os
|
||||
from utils.file_utils import allowed_file, compress_image
|
||||
from utils.lzmx import send_manual_lzmx_message
|
||||
from config import Config
|
||||
|
||||
def init_manual_routes(app):
|
||||
@app.route('/manual', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manual():
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问手工故障单页面')
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
if request.method == 'POST':
|
||||
app.logger.info(f'用户 {current_user.name} 开始创建手工故障单')
|
||||
# 处理文件上传
|
||||
photo_paths = []
|
||||
if 'photo' in request.files:
|
||||
files = request.files.getlist('photo')
|
||||
if len(files) > 5:
|
||||
flash('最多只能上传5张图片')
|
||||
return redirect(url_for('manual'))
|
||||
for file in files:
|
||||
if file and allowed_file(file.filename, Config.ALLOWED_EXTENSIONS): # 修改这里
|
||||
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}"
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(file_path)
|
||||
compress_image(file_path)
|
||||
photo_paths.append(filename)
|
||||
|
||||
# 生成工单号
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
last_order = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.order_id.like(f'M{today}%')
|
||||
).order_by(ManualWorkOrder.order_id.desc()).first()
|
||||
|
||||
if last_order:
|
||||
last_number = int(last_order.order_id[-4:])
|
||||
new_number = f"M{today}{str(last_number + 1).zfill(4)}"
|
||||
else:
|
||||
new_number = f"M{today}0001"
|
||||
|
||||
# 创建手工故障单
|
||||
manual_order = ManualWorkOrder(
|
||||
order_id=new_number,
|
||||
project_type=request.form['project_type'],
|
||||
device_id=request.form['device_id'],
|
||||
device_location=request.form['device_location'],
|
||||
fault_time=datetime.strptime(request.form['fault_time'], '%Y-%m-%dT%H:%M'),
|
||||
handler=request.form['handler'],
|
||||
fault_type=request.form['fault_type'],
|
||||
handle_description=request.form['handle_description'],
|
||||
photo_paths=','.join(photo_paths) if photo_paths else None,
|
||||
contact_person=request.form['contact_person'],
|
||||
contact_phone=request.form['contact_phone'],
|
||||
distance_from_branch=request.form['distance_from_branch'],
|
||||
remark=request.form.get('remark', ''),
|
||||
branch=current_user.branch
|
||||
)
|
||||
|
||||
db.session.add(manual_order)
|
||||
db.session.commit()
|
||||
|
||||
# 发送量子密信通知
|
||||
send_manual_lzmx_message(
|
||||
Config.LZMX_WEBHOOK_URL, # 修改这里
|
||||
request.form['handler'],
|
||||
request.form['device_id'],
|
||||
request.form['fault_type'],
|
||||
request.form['distance_from_branch'],
|
||||
manual_order.fault_time,
|
||||
request.form['project_type'],
|
||||
request.form['device_location']
|
||||
)
|
||||
|
||||
app.logger.info(f'用户 {current_user.name} 创建手工故障单成功:{new_number}')
|
||||
flash('手工故障单创建成功')
|
||||
return redirect(url_for('history'))
|
||||
|
||||
return render_template('manual.html')
|
||||
@@ -0,0 +1,326 @@
|
||||
from flask import render_template, flash, redirect, url_for, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.models import db, WorkOrder, WorkOrderHandling, ManualWorkOrder
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment
|
||||
from utils.reward import get_reward_by_distance
|
||||
from scripts.models import Device
|
||||
import io
|
||||
|
||||
def init_statistics_routes(app):
|
||||
@app.route('/statistics')
|
||||
@login_required
|
||||
def statistics():
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问统计页面')
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
app.logger.info(f'用户 {current_user.name} 访问统计页面')
|
||||
# 获取当前时间
|
||||
now = datetime.now()
|
||||
|
||||
# 计算本周开始和结束时间
|
||||
week_start = now - timedelta(days=now.weekday())
|
||||
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_end = week_start + timedelta(days=7)
|
||||
|
||||
# 计算本月开始和结束时间
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if now.month == 12:
|
||||
month_end = now.replace(year=now.year + 1, month=1, day=1)
|
||||
else:
|
||||
month_end = now.replace(month=now.month + 1, day=1)
|
||||
|
||||
# 统计普通工单
|
||||
def get_work_order_stats(start_time, end_time):
|
||||
total = WorkOrder.query.filter(
|
||||
WorkOrder.dispatch_time >= start_time,
|
||||
WorkOrder.dispatch_time < end_time
|
||||
).count()
|
||||
|
||||
completed = WorkOrder.query.filter(
|
||||
WorkOrder.dispatch_time >= start_time,
|
||||
WorkOrder.dispatch_time < end_time,
|
||||
WorkOrder.status == '已处理'
|
||||
).count()
|
||||
|
||||
pending = total - completed
|
||||
return {'total': total, 'completed': completed, 'pending': pending}
|
||||
|
||||
# 统计手工故障单
|
||||
def get_manual_order_stats(start_time, end_time):
|
||||
total = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= start_time,
|
||||
ManualWorkOrder.create_time < end_time
|
||||
).count()
|
||||
return total
|
||||
|
||||
# 计算奖励金额
|
||||
def calculate_reward(start_time, end_time):
|
||||
rewards = defaultdict(float)
|
||||
|
||||
# 查询所有已处理的工单
|
||||
handlings = WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= start_time,
|
||||
WorkOrderHandling.restore_time < end_time
|
||||
).all()
|
||||
|
||||
# 查询所有手工故障单
|
||||
manual_orders = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= start_time,
|
||||
ManualWorkOrder.create_time < end_time
|
||||
).all()
|
||||
|
||||
# 计算普通工单奖励
|
||||
for handling in handlings:
|
||||
device = Device.query.get(handling.device_id)
|
||||
if device:
|
||||
distance = float(handling.distance_from_branch)
|
||||
reward = get_reward_by_distance(device.project_type, distance)
|
||||
rewards[device.project_type] += reward
|
||||
|
||||
# 计算手工故障单奖励
|
||||
for order in manual_orders:
|
||||
distance = float(order.distance_from_branch)
|
||||
reward = get_reward_by_distance(order.project_type, distance)
|
||||
rewards[order.project_type] += reward
|
||||
|
||||
return rewards
|
||||
|
||||
def get_handler_stats(start_time, end_time):
|
||||
handler_stats = defaultdict(lambda: {
|
||||
'work_orders': 0,
|
||||
'manual_orders': 0,
|
||||
'rewards': defaultdict(float)
|
||||
})
|
||||
|
||||
# 统计普通工单
|
||||
handlings = WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= start_time,
|
||||
WorkOrderHandling.restore_time < end_time
|
||||
).all()
|
||||
|
||||
for handling in handlings:
|
||||
handler = handling.handler
|
||||
handler_stats[handler]['work_orders'] += 1
|
||||
device = Device.query.get(handling.device_id)
|
||||
if device:
|
||||
distance = float(handling.distance_from_branch)
|
||||
reward = get_reward_by_distance(device.project_type, distance)
|
||||
handler_stats[handler]['rewards'][device.project_type] += reward
|
||||
|
||||
# 统计手工故障单
|
||||
manual_orders = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= start_time,
|
||||
ManualWorkOrder.create_time < end_time
|
||||
).all()
|
||||
|
||||
for order in manual_orders:
|
||||
handler = order.handler
|
||||
handler_stats[handler]['manual_orders'] += 1
|
||||
distance = float(order.distance_from_branch)
|
||||
reward = get_reward_by_distance(order.project_type, distance)
|
||||
handler_stats[handler]['rewards'][order.project_type] += reward
|
||||
|
||||
return handler_stats
|
||||
|
||||
# 获取统计数据
|
||||
week_work_stats = get_work_order_stats(week_start, week_end)
|
||||
month_work_stats = get_work_order_stats(month_start, month_end)
|
||||
|
||||
week_manual_stats = get_manual_order_stats(week_start, week_end)
|
||||
month_manual_stats = get_manual_order_stats(month_start, month_end)
|
||||
|
||||
week_rewards = calculate_reward(week_start, week_end)
|
||||
month_rewards = calculate_reward(month_start, month_end)
|
||||
|
||||
week_handler_stats = get_handler_stats(week_start, week_end)
|
||||
month_handler_stats = get_handler_stats(month_start, month_end)
|
||||
|
||||
# 如果是装维员,只显示自己的数据
|
||||
if current_user.role == '装维员':
|
||||
week_work_stats = {
|
||||
'completed': WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= week_start,
|
||||
WorkOrderHandling.restore_time < week_end,
|
||||
WorkOrderHandling.handler == current_user.name
|
||||
).count()
|
||||
}
|
||||
month_work_stats = {
|
||||
'completed': WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= month_start,
|
||||
WorkOrderHandling.restore_time < month_end,
|
||||
WorkOrderHandling.handler == current_user.name
|
||||
).count()
|
||||
}
|
||||
week_manual_stats = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= week_start,
|
||||
ManualWorkOrder.create_time < week_end,
|
||||
ManualWorkOrder.handler == current_user.name
|
||||
).count()
|
||||
month_manual_stats = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= month_start,
|
||||
ManualWorkOrder.create_time < month_end,
|
||||
ManualWorkOrder.handler == current_user.name
|
||||
).count()
|
||||
# 修改这里:只获取当前装维员的奖励统计
|
||||
week_rewards = calculate_reward(week_start, week_end)
|
||||
month_rewards = calculate_reward(month_start, month_end)
|
||||
week_handler_stats = {current_user.name: week_handler_stats.get(current_user.name, defaultdict(int))}
|
||||
month_handler_stats = {current_user.name: month_handler_stats.get(current_user.name, defaultdict(int))}
|
||||
|
||||
# 从 handler_stats 中提取当前用户的奖励数据
|
||||
week_rewards = week_handler_stats[current_user.name]['rewards']
|
||||
month_rewards = month_handler_stats[current_user.name]['rewards']
|
||||
|
||||
return render_template('statistics.html',
|
||||
week_work_stats=week_work_stats,
|
||||
month_work_stats=month_work_stats,
|
||||
week_manual_stats=week_manual_stats,
|
||||
month_manual_stats=month_manual_stats,
|
||||
week_rewards=week_rewards,
|
||||
month_rewards=month_rewards,
|
||||
week_handler_stats=week_handler_stats,
|
||||
month_handler_stats=month_handler_stats)
|
||||
@app.route('/export_monthly_stats/<int:year>/<int:month>')
|
||||
@login_required
|
||||
def export_monthly_stats(year, month):
|
||||
if current_user.role != '统计员':
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试导出月度统计')
|
||||
flash('您没有权限执行此操作')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
app.logger.info(f'用户 {current_user.name} 导出 {year}年{month}月 统计报表')
|
||||
|
||||
# 计算指定月份的开始和结束时间
|
||||
month_start = datetime(year, month, 1, 0, 0, 0)
|
||||
if month == 12:
|
||||
month_end = datetime(year + 1, 1, 1, 0, 0, 0)
|
||||
else:
|
||||
month_end = datetime(year, month + 1, 1, 0, 0, 0)
|
||||
|
||||
# 检查是否有数据
|
||||
handlings = WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= month_start,
|
||||
WorkOrderHandling.restore_time < month_end
|
||||
).first()
|
||||
|
||||
if not handlings:
|
||||
flash(f'{year}年{month}月系统无检修工单')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
# 创建工作簿
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "运维计件奖励"
|
||||
|
||||
# 设置标题
|
||||
ws.merge_cells('A1:J1')
|
||||
title = ws.cell(1, 1, f"{year}年{month}月政企重点项目运维各支局检修激励汇总")
|
||||
title.font = Font(size=14, bold=True)
|
||||
title.alignment = Alignment(horizontal='center')
|
||||
|
||||
# 设置表头
|
||||
headers = ['序号', '检修单位名称', '检修项目', '检修设备总数(台)',
|
||||
'单位距支局维护人员所在地址(公里)', '检修设备每个单价(元)',
|
||||
'检修项目合计数(路)', '本月检修金额(元)', '支局', '检修人员','本月个人检修合计','检修时间']
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(2, col, header)
|
||||
cell.font = Font(bold=True)
|
||||
cell.alignment = Alignment(horizontal='center', wrap_text=True)
|
||||
|
||||
# 获取指定月份数据
|
||||
handlings = WorkOrderHandling.query.filter(
|
||||
WorkOrderHandling.restore_time >= month_start,
|
||||
WorkOrderHandling.restore_time < month_end
|
||||
).all()
|
||||
|
||||
# 查询本月所有手工故障单
|
||||
manual_orders = ManualWorkOrder.query.filter(
|
||||
ManualWorkOrder.create_time >= month_start,
|
||||
ManualWorkOrder.create_time < month_end
|
||||
).order_by(ManualWorkOrder.create_time).all()
|
||||
|
||||
# 填充数据
|
||||
row = 3
|
||||
idx = 0 # 初始化序号计数器
|
||||
|
||||
# 处理普通工单
|
||||
for handling in handlings:
|
||||
device = db.session.get(Device, handling.device_id)
|
||||
if not device:
|
||||
continue
|
||||
|
||||
idx += 1 # 增加序号
|
||||
ws.cell(row, 1, idx) # 序号
|
||||
ws.cell(row, 2, device.device_id) # 检修单位名称
|
||||
ws.cell(row, 3, device.project_type) # 检修项目
|
||||
ws.cell(row, 4, 1) # 检修设备总数
|
||||
ws.cell(row, 5, handling.distance_from_branch) # 距离
|
||||
|
||||
# 计算单价
|
||||
distance = float(handling.distance_from_branch)
|
||||
reward = get_reward_by_distance(device.project_type, distance)
|
||||
ws.cell(row, 6, reward) # 单价
|
||||
|
||||
ws.cell(row, 7, 1) # 检修项目合计数
|
||||
ws.cell(row, 8, reward) # 本月检修金额
|
||||
ws.cell(row, 9, handling.branch) # 支局
|
||||
ws.cell(row, 10, handling.handler) # 检修人员
|
||||
ws.cell(row, 12, handling.restore_time.strftime('%Y-%m-%d %H:%M:%S')) # 检修时间
|
||||
|
||||
row += 1
|
||||
|
||||
# 处理手工故障单
|
||||
for manual_order in manual_orders:
|
||||
idx += 1 # 增加序号
|
||||
ws.cell(row, 1, idx) # 序号
|
||||
ws.cell(row, 2, manual_order.device_location) # 检修单位名称
|
||||
ws.cell(row, 3, manual_order.project_type) # 检修项目
|
||||
ws.cell(row, 4, 1) # 检修设备总数
|
||||
ws.cell(row, 5, manual_order.distance_from_branch) # 距离
|
||||
|
||||
# 计算单价
|
||||
distance = float(manual_order.distance_from_branch)
|
||||
reward = get_reward_by_distance(manual_order.project_type, distance)
|
||||
ws.cell(row, 6, reward) # 单价
|
||||
|
||||
ws.cell(row, 7, 1) # 检修项目合计数
|
||||
ws.cell(row, 8, reward) # 本月检修金额
|
||||
ws.cell(row, 9, manual_order.branch) # 支局
|
||||
ws.cell(row, 10, manual_order.handler) # 检修人员
|
||||
ws.cell(row, 12, manual_order.create_time.strftime('%Y-%m-%d %H:%M:%S')) # 检修时间
|
||||
|
||||
row += 1
|
||||
idx += 1
|
||||
|
||||
# 添加合计行
|
||||
ws.cell(row, 7, f"合计")
|
||||
ws.cell(row, 8, f"=SUM(H3:H{row-1})")
|
||||
ws.cell(row, 8).font = Font(bold=True)
|
||||
|
||||
# 设置列宽
|
||||
ws.column_dimensions['B'].width = 20
|
||||
ws.column_dimensions['C'].width = 15
|
||||
ws.column_dimensions['E'].width = 15
|
||||
ws.column_dimensions['F'].width = 15
|
||||
ws.column_dimensions['H'].width = 15
|
||||
|
||||
# 保存到内存中
|
||||
excel_file = io.BytesIO()
|
||||
wb.save(excel_file)
|
||||
excel_file.seek(0)
|
||||
|
||||
# 设置文件名
|
||||
filename = f'{year}年{month}月政企重点项目运维各支局检修激励汇总.xlsx'
|
||||
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.models import db, User, Device
|
||||
|
||||
|
||||
def init_user_routes(app):
|
||||
def is_admin_user():
|
||||
"""允许统计员/管理员或名称为 Admin 的用户管理账号"""
|
||||
return current_user.role in ['统计员', '管理员', 'admin'] or current_user.name == 'Admin'
|
||||
|
||||
@app.route('/users', methods=['GET'])
|
||||
@login_required
|
||||
def user_list():
|
||||
if not is_admin_user():
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试访问用户管理')
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
users = User.query.order_by(User.name).all()
|
||||
branch_options = [b[0] for b in db.session.query(Device.branch).distinct().all() if b[0]]
|
||||
role_options = ['统计员', '装维员', '管理员']
|
||||
|
||||
return render_template(
|
||||
'users.html',
|
||||
users=users,
|
||||
branch_options=branch_options,
|
||||
role_options=role_options
|
||||
)
|
||||
|
||||
@app.route('/users/<phone>', methods=['POST'])
|
||||
@login_required
|
||||
def update_user(phone):
|
||||
if not is_admin_user():
|
||||
app.logger.warning(f'未授权访问:用户 {current_user.name} 尝试修改用户 {phone}')
|
||||
flash('您没有权限执行此操作')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
user = User.query.filter_by(phone=phone).first()
|
||||
if not user:
|
||||
flash('用户不存在')
|
||||
return redirect(url_for('user_list'))
|
||||
|
||||
new_branch = request.form.get('branch', '').strip()
|
||||
new_role = request.form.get('role', '').strip()
|
||||
|
||||
if new_role and new_role not in ['统计员', '装维员', '管理员']:
|
||||
flash('角色不合法')
|
||||
return redirect(url_for('user_list'))
|
||||
|
||||
user.branch = new_branch
|
||||
user.role = new_role
|
||||
db.session.commit()
|
||||
app.logger.info(f'用户信息已更新: {user.name}({user.phone}) -> branch={user.branch}, role={user.role}')
|
||||
flash('用户信息已更新')
|
||||
return redirect(url_for('user_list'))
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from scripts.models import db, Device, WorkOrder, WorkOrderHandling
|
||||
from datetime import datetime
|
||||
import os
|
||||
from utils.file_utils import allowed_file, compress_image
|
||||
from utils.lzmx import send_lzmx_message
|
||||
from config import Config
|
||||
|
||||
def init_work_order_routes(app):
|
||||
# 添加获取支局列表的辅助函数
|
||||
def get_branches():
|
||||
branches = db.session.query(Device.branch).distinct().all()
|
||||
return [branch[0] for branch in branches if branch[0]]
|
||||
|
||||
# 派单平台路由
|
||||
@app.route('/dispatch', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def dispatch():
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
# 处理 POST 请求
|
||||
if request.method == 'POST':
|
||||
device_ids = request.form.getlist('device_ids')
|
||||
|
||||
if not device_ids:
|
||||
flash('请选择至少一个设备')
|
||||
return redirect(url_for('dispatch'))
|
||||
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
last_work_order = WorkOrder.query.filter(WorkOrder.order_id.like(f'{today}%')).order_by(WorkOrder.order_id.desc()).first()
|
||||
|
||||
# 获取起始编号
|
||||
if last_work_order:
|
||||
last_number = int(last_work_order.order_id[-4:])
|
||||
start_number = last_number + 1
|
||||
else:
|
||||
start_number = 1
|
||||
|
||||
# 为每个设备生成唯一的工单号
|
||||
for index, device_id in enumerate(device_ids):
|
||||
device = db.session.get(Device, device_id)
|
||||
if not device:
|
||||
continue
|
||||
|
||||
current_number = start_number + index
|
||||
new_number = f"{today}{str(current_number).zfill(4)}"
|
||||
|
||||
work_order = WorkOrder(
|
||||
order_id=new_number,
|
||||
device_id=device_id,
|
||||
dispatch_time=datetime.now(),
|
||||
status='待处理',
|
||||
branch=device.branch
|
||||
)
|
||||
|
||||
db.session.add(work_order)
|
||||
device.is_normal = False
|
||||
db.session.add(device)
|
||||
|
||||
db.session.commit()
|
||||
flash('派单成功')
|
||||
return redirect(url_for('dispatch'))
|
||||
|
||||
# 处理 GET 请求
|
||||
# 获取分页参数
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 50, type=int) # 从请求参数中获取每页显示数量
|
||||
|
||||
# 获取搜索词和筛选条件
|
||||
search = request.args.get('search', '')
|
||||
selected_town = request.args.get('town', '')
|
||||
selected_branch = request.args.get('branch', '')
|
||||
|
||||
towns = db.session.query(Device.town).distinct().all()
|
||||
towns = [town[0] for town in towns if town[0]]
|
||||
|
||||
branches = db.session.query(Device.branch).distinct().all()
|
||||
branches = [branch[0] for branch in branches if branch[0]]
|
||||
|
||||
query = Device.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
(Device.device_id.contains(search)) |
|
||||
(Device.town.contains(search)) |
|
||||
(Device.branch.contains(search)) |
|
||||
(Device.unit_name.contains(search)) |
|
||||
(Device.project_type.contains(search)) |
|
||||
(Device.device_type.contains(search)) |
|
||||
(Device.remark.contains(search))
|
||||
)
|
||||
|
||||
if selected_town:
|
||||
query = query.filter(Device.town == selected_town)
|
||||
|
||||
if selected_branch:
|
||||
query = query.filter(Device.branch == selected_branch)
|
||||
|
||||
# 分页时使用从请求获取的 per_page 参数
|
||||
devices = query.filter_by(in_use=True).paginate(page=page, per_page=per_page)
|
||||
|
||||
return render_template('dispatch.html', devices=devices, towns=towns, branches=branches)
|
||||
|
||||
@app.route('/update_device_remark', methods=['POST'])
|
||||
@login_required
|
||||
def update_device_remark():
|
||||
try:
|
||||
data = request.get_json()
|
||||
device_id = data.get('device_id')
|
||||
remark = data.get('remark', '')
|
||||
|
||||
device = Device.query.get(device_id)
|
||||
if not device:
|
||||
return jsonify({'success': False, 'message': '设备不存在'})
|
||||
|
||||
device.remark = remark
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
# 接单平台路由
|
||||
@app.route('/receive')
|
||||
@login_required
|
||||
def receive():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 50, type=int)
|
||||
branch = request.args.get('branch', '')
|
||||
device_type = request.args.get('device_type', '')
|
||||
project_type = request.args.get('project_type', '')
|
||||
|
||||
# 基础查询,直接join Device表
|
||||
query = WorkOrder.query.join(Device).filter(WorkOrder.status == '待处理')
|
||||
|
||||
# 根据用户权限筛选工单
|
||||
if current_user.branch != '交付中心':
|
||||
query = query.filter(Device.branch == current_user.branch)
|
||||
|
||||
# 应用分支筛选(仅交付中心可用)
|
||||
if branch and current_user.branch == '交付中心':
|
||||
query = query.filter(Device.branch == branch)
|
||||
|
||||
# 应用设备类型筛选
|
||||
if device_type:
|
||||
query = query.filter(Device.device_type == device_type)
|
||||
|
||||
# 应用项目类型筛选
|
||||
if project_type:
|
||||
query = query.filter(Device.project_type == project_type)
|
||||
|
||||
# 获取总数统计(使用相同的筛选条件)
|
||||
total_orders = query.count()
|
||||
|
||||
# 获取各支局统计
|
||||
branch_stats = {}
|
||||
if current_user.branch == '交付中心':
|
||||
branch_counts = db.session.query(
|
||||
Device.branch,
|
||||
db.func.count(WorkOrder.order_id)
|
||||
).join(WorkOrder, WorkOrder.device_id == Device.device_id
|
||||
).filter(WorkOrder.status == '待处理'
|
||||
).group_by(Device.branch).all()
|
||||
|
||||
branch_stats = dict(branch_counts)
|
||||
|
||||
# 获取筛选选项 - 修正查询方向
|
||||
device_types_query = db.session.query(Device.device_type).distinct().filter(
|
||||
Device.device_id.in_(
|
||||
db.session.query(WorkOrder.device_id).filter(WorkOrder.status == '待处理')
|
||||
)
|
||||
)
|
||||
project_types_query = db.session.query(Device.project_type).distinct().filter(
|
||||
Device.device_id.in_(
|
||||
db.session.query(WorkOrder.device_id).filter(WorkOrder.status == '待处理')
|
||||
)
|
||||
)
|
||||
|
||||
# 根据用户权限进一步筛选选项
|
||||
if current_user.branch != '交付中心':
|
||||
device_types_query = device_types_query.filter(Device.branch == current_user.branch)
|
||||
project_types_query = project_types_query.filter(Device.branch == current_user.branch)
|
||||
elif branch:
|
||||
device_types_query = device_types_query.filter(Device.branch == branch)
|
||||
project_types_query = project_types_query.filter(Device.branch == branch)
|
||||
|
||||
device_types = [dt[0] for dt in device_types_query.all() if dt[0]]
|
||||
project_types = [pt[0] for pt in project_types_query.all() if pt[0]]
|
||||
|
||||
# 获取设备类型统计
|
||||
device_type_stats = {}
|
||||
if device_types:
|
||||
device_type_counts = db.session.query(
|
||||
Device.device_type,
|
||||
db.func.count(WorkOrder.order_id)
|
||||
).join(WorkOrder, WorkOrder.device_id == Device.device_id
|
||||
).filter(WorkOrder.status == '待处理')
|
||||
|
||||
# 应用同样的权限筛选
|
||||
if current_user.branch != '交付中心':
|
||||
device_type_counts = device_type_counts.filter(Device.branch == current_user.branch)
|
||||
elif branch:
|
||||
device_type_counts = device_type_counts.filter(Device.branch == branch)
|
||||
|
||||
device_type_counts = device_type_counts.group_by(Device.device_type).all()
|
||||
device_type_stats = dict(device_type_counts)
|
||||
|
||||
# 获取项目类型统计
|
||||
project_type_stats = {}
|
||||
if project_types:
|
||||
project_type_counts = db.session.query(
|
||||
Device.project_type,
|
||||
db.func.count(WorkOrder.order_id)
|
||||
).join(WorkOrder, WorkOrder.device_id == Device.device_id
|
||||
).filter(WorkOrder.status == '待处理')
|
||||
|
||||
# 应用同样的权限筛选
|
||||
if current_user.branch != '交付中心':
|
||||
project_type_counts = project_type_counts.filter(Device.branch == current_user.branch)
|
||||
elif branch:
|
||||
project_type_counts = project_type_counts.filter(Device.branch == branch)
|
||||
|
||||
project_type_counts = project_type_counts.group_by(Device.project_type).all()
|
||||
project_type_stats = dict(project_type_counts)
|
||||
|
||||
# 获取分页数据
|
||||
work_orders = query.paginate(page=page, per_page=per_page)
|
||||
|
||||
# 计算派单时长
|
||||
for work_order in work_orders.items:
|
||||
dispatch_time = work_order.dispatch_time
|
||||
now = datetime.now()
|
||||
delta = now - dispatch_time
|
||||
work_order.duration = f"{delta.days}天{delta.seconds // 3600}小时{(delta.seconds % 3600) // 60}分钟"
|
||||
|
||||
return render_template('receive.html',
|
||||
work_orders=work_orders,
|
||||
total_orders=total_orders,
|
||||
branch_stats=branch_stats,
|
||||
device_type_stats=device_type_stats,
|
||||
project_type_stats=project_type_stats,
|
||||
show_filter=current_user.branch == '交付中心', # 只有交付中心可以看到筛选
|
||||
branches=get_branches() if current_user.branch == '交付中心' else [], # 只有交付中心可以看到支局列表
|
||||
selected_branch=branch,
|
||||
device_types=device_types,
|
||||
project_types=project_types
|
||||
)
|
||||
|
||||
@app.route('/recall_all_orders', methods=['POST'])
|
||||
@login_required
|
||||
def recall_all_orders():
|
||||
try:
|
||||
# 获取所有在途工单
|
||||
orders = WorkOrder.query.filter_by(status='待处理').all()
|
||||
count = len(orders)
|
||||
|
||||
# 恢复设备状态并删除工单
|
||||
for order in orders:
|
||||
# 恢复设备状态
|
||||
device = Device.query.get(order.device_id)
|
||||
if device:
|
||||
device.is_normal = True
|
||||
db.session.add(device)
|
||||
|
||||
# 直接删除工单
|
||||
db.session.delete(order)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': count
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': str(e)
|
||||
})
|
||||
|
||||
# 处理工单
|
||||
@app.route('/receive/<string:work_order_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def receive_detail(work_order_id):
|
||||
allowed_roles = ['装维员', '统计员', '管理员', 'admin']
|
||||
if current_user.role not in allowed_roles and current_user.name != 'Admin':
|
||||
flash('您没有权限访问此页面')
|
||||
return redirect(url_for('statistics'))
|
||||
|
||||
work_order = WorkOrder.query.get_or_404(work_order_id)
|
||||
device = Device.query.filter_by(device_id=work_order.device_id).first()
|
||||
|
||||
if request.method == 'POST':
|
||||
photo_paths = []
|
||||
if 'photo' in request.files:
|
||||
files = request.files.getlist('photo')
|
||||
if len(files) > 5:
|
||||
flash('最多只能上传5张图片')
|
||||
return redirect(url_for('receive_detail', work_order_id=work_order_id))
|
||||
for file in files:
|
||||
if file and allowed_file(file.filename, Config.ALLOWED_EXTENSIONS): # 修改这里
|
||||
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}"
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(file_path)
|
||||
compress_image(file_path)
|
||||
photo_paths.append(filename)
|
||||
|
||||
# 创建处理工单信息
|
||||
handling_info = WorkOrderHandling(
|
||||
order_id=work_order.order_id,
|
||||
device_id=work_order.device_id,
|
||||
branch=device.branch if device else None,
|
||||
restore_time=datetime.now(),
|
||||
fault_type=request.form['fault_type'],
|
||||
contact_person=request.form['contact_person'],
|
||||
contact_phone=request.form['contact_phone'],
|
||||
handler=request.form['handler'],
|
||||
handle_description=request.form['handle_description'],
|
||||
photo_paths=','.join(photo_paths) if photo_paths else None,
|
||||
distance_from_branch=request.form['distance_from_branch'],
|
||||
remark=request.form.get('remark', '')
|
||||
)
|
||||
|
||||
# 更新工单状态为已处理
|
||||
work_order.status = '已处理'
|
||||
|
||||
# 更新设备状态为正常
|
||||
if device:
|
||||
device.is_normal = True
|
||||
|
||||
# 将所有更改添加到会话
|
||||
db.session.add(handling_info)
|
||||
db.session.add(work_order)
|
||||
if device:
|
||||
db.session.add(device)
|
||||
|
||||
# 一次性提交所有更改
|
||||
db.session.commit()
|
||||
|
||||
# 发送量子密信通知
|
||||
send_lzmx_message(
|
||||
Config.LZMX_WEBHOOK_URL, # 修改这里
|
||||
request.form['handler'],
|
||||
work_order.device_id,
|
||||
request.form['fault_type'],
|
||||
request.form['distance_from_branch'],
|
||||
datetime.now()
|
||||
)
|
||||
|
||||
flash('工单处理成功')
|
||||
return redirect(url_for('receive'))
|
||||
|
||||
return render_template('receive_detail.html', work_order=work_order, device=device)
|
||||
|
||||
@app.route('/recall_order/<order_id>')
|
||||
@login_required
|
||||
def recall_order(order_id):
|
||||
# 检查用户权限
|
||||
if current_user.role != '统计员':
|
||||
flash('没有权限执行此操作')
|
||||
return redirect(url_for('receive'))
|
||||
|
||||
# 获取工单
|
||||
work_order = WorkOrder.query.get_or_404(order_id)
|
||||
|
||||
# 更新设备状态
|
||||
device = Device.query.get(work_order.device_id)
|
||||
if device:
|
||||
device.is_normal = True
|
||||
db.session.add(device)
|
||||
|
||||
# 删除工单
|
||||
db.session.delete(work_order)
|
||||
db.session.commit()
|
||||
|
||||
flash('工单已成功追回')
|
||||
return redirect(url_for('receive'))
|
||||
@@ -0,0 +1,9 @@
|
||||
Flask>=2.0.0
|
||||
Flask-Login>=0.6.0
|
||||
Flask-Migrate>=3.0.0
|
||||
Flask-SQLAlchemy>=2.5.0
|
||||
SQLAlchemy>=1.4.0
|
||||
python-dotenv>=0.19.0
|
||||
requests>=2.25.0
|
||||
PyMySQL>=1.0.0
|
||||
|
||||