Upload 67 files
Browse files- .env.example +5 -1
- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/database.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/config.py +26 -9
- app/database.py +1 -5
- app/main.py +3 -38
- app/routes/__pycache__/admin.cpython-313.pyc +0 -0
- app/routes/__pycache__/auth.cpython-313.pyc +0 -0
- app/routes/admin.py +1 -17
- app/routes/auth.py +16 -11
- app/templates/activity_detail.html +50 -2
- app/templates/admin_admins.html +14 -55
- app/templates/admin_reviews.html +39 -2
- app/templates/base.html +2 -1
.env.example
CHANGED
|
@@ -8,4 +8,8 @@ SQL_PORT=21260
|
|
| 8 |
SQL_DATABASE=CAM
|
| 9 |
MYSQL_CA_FILE=ca.pem
|
| 10 |
APP_TIMEZONE=Asia/Shanghai
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
SQL_DATABASE=CAM
|
| 9 |
MYSQL_CA_FILE=ca.pem
|
| 10 |
APP_TIMEZONE=Asia/Shanghai
|
| 11 |
+
DOCKER_ROOT=docker_data
|
| 12 |
+
DB_POOL_SIZE=12
|
| 13 |
+
DB_MAX_OVERFLOW=24
|
| 14 |
+
DB_POOL_TIMEOUT=30
|
| 15 |
+
DB_POOL_RECYCLE=1800
|
app/__pycache__/config.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
|
|
|
app/__pycache__/database.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/database.cpython-313.pyc and b/app/__pycache__/database.cpython-313.pyc differ
|
|
|
app/__pycache__/main.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ
|
|
|
app/config.py
CHANGED
|
@@ -21,13 +21,16 @@ class Settings:
|
|
| 21 |
|
| 22 |
mysql_user: str = os.getenv("SQL_USER", "avnadmin")
|
| 23 |
mysql_password: str = os.getenv("SQL_PASSWORD", "")
|
| 24 |
-
mysql_host: str = os.getenv(
|
| 25 |
-
"SQL_HOST", "mysql-2bace9cd-cacode.i.aivencloud.com"
|
| 26 |
-
)
|
| 27 |
mysql_port: int = int(os.getenv("SQL_PORT", "21260"))
|
| 28 |
mysql_db: str = os.getenv("SQL_DATABASE", "CAM")
|
| 29 |
mysql_ca_file: Path = ROOT_DIR / os.getenv("MYSQL_CA_FILE", "ca.pem")
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
docker_root: Path = ROOT_DIR / os.getenv("DOCKER_ROOT", "docker_data")
|
| 32 |
|
| 33 |
@property
|
|
@@ -44,10 +47,7 @@ class Settings:
|
|
| 44 |
return raw_url
|
| 45 |
user = quote_plus(self.mysql_user)
|
| 46 |
password = quote_plus(self.mysql_password)
|
| 47 |
-
return
|
| 48 |
-
f"mysql+pymysql://{user}:{password}@{self.mysql_host}:"
|
| 49 |
-
f"{self.mysql_port}/{self.mysql_db}"
|
| 50 |
-
)
|
| 51 |
|
| 52 |
@property
|
| 53 |
def database_connect_args(self) -> dict:
|
|
@@ -57,6 +57,23 @@ class Settings:
|
|
| 57 |
return {"ssl": {"ca": str(self.mysql_ca_file)}}
|
| 58 |
return {}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
settings = Settings()
|
| 62 |
-
|
|
|
|
| 21 |
|
| 22 |
mysql_user: str = os.getenv("SQL_USER", "avnadmin")
|
| 23 |
mysql_password: str = os.getenv("SQL_PASSWORD", "")
|
| 24 |
+
mysql_host: str = os.getenv("SQL_HOST", "mysql-2bace9cd-cacode.i.aivencloud.com")
|
|
|
|
|
|
|
| 25 |
mysql_port: int = int(os.getenv("SQL_PORT", "21260"))
|
| 26 |
mysql_db: str = os.getenv("SQL_DATABASE", "CAM")
|
| 27 |
mysql_ca_file: Path = ROOT_DIR / os.getenv("MYSQL_CA_FILE", "ca.pem")
|
| 28 |
|
| 29 |
+
db_pool_size: int = int(os.getenv("DB_POOL_SIZE", "12"))
|
| 30 |
+
db_max_overflow: int = int(os.getenv("DB_MAX_OVERFLOW", "24"))
|
| 31 |
+
db_pool_timeout: int = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
| 32 |
+
db_pool_recycle: int = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
| 33 |
+
|
| 34 |
docker_root: Path = ROOT_DIR / os.getenv("DOCKER_ROOT", "docker_data")
|
| 35 |
|
| 36 |
@property
|
|
|
|
| 47 |
return raw_url
|
| 48 |
user = quote_plus(self.mysql_user)
|
| 49 |
password = quote_plus(self.mysql_password)
|
| 50 |
+
return f"mysql+pymysql://{user}:{password}@{self.mysql_host}:{self.mysql_port}/{self.mysql_db}"
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
@property
|
| 53 |
def database_connect_args(self) -> dict:
|
|
|
|
| 57 |
return {"ssl": {"ca": str(self.mysql_ca_file)}}
|
| 58 |
return {}
|
| 59 |
|
| 60 |
+
@property
|
| 61 |
+
def database_engine_kwargs(self) -> dict:
|
| 62 |
+
kwargs = {
|
| 63 |
+
"pool_pre_ping": True,
|
| 64 |
+
"connect_args": self.database_connect_args,
|
| 65 |
+
}
|
| 66 |
+
if not self.database_url.startswith("sqlite"):
|
| 67 |
+
kwargs.update(
|
| 68 |
+
{
|
| 69 |
+
"pool_size": self.db_pool_size,
|
| 70 |
+
"max_overflow": self.db_max_overflow,
|
| 71 |
+
"pool_timeout": self.db_pool_timeout,
|
| 72 |
+
"pool_recycle": self.db_pool_recycle,
|
| 73 |
+
"pool_use_lifo": True,
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
return kwargs
|
| 77 |
+
|
| 78 |
|
| 79 |
+
settings = Settings()
|
|
|
app/database.py
CHANGED
|
@@ -12,11 +12,7 @@ class Base(DeclarativeBase):
|
|
| 12 |
pass
|
| 13 |
|
| 14 |
|
| 15 |
-
engine = create_engine(
|
| 16 |
-
settings.database_url,
|
| 17 |
-
pool_pre_ping=True,
|
| 18 |
-
connect_args=settings.database_connect_args,
|
| 19 |
-
)
|
| 20 |
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
| 21 |
|
| 22 |
|
|
|
|
| 12 |
pass
|
| 13 |
|
| 14 |
|
| 15 |
+
engine = create_engine(settings.database_url, **settings.database_engine_kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
| 17 |
|
| 18 |
|
app/main.py
CHANGED
|
@@ -9,11 +9,9 @@ from starlette.middleware.sessions import SessionMiddleware
|
|
| 9 |
from app.auth import get_current_admin, get_current_user
|
| 10 |
from app.config import ROOT_DIR, settings
|
| 11 |
from app.database import SessionLocal
|
| 12 |
-
from app.models import Admin, User
|
| 13 |
from app.routes import admin, auth, media, user
|
| 14 |
from app.services.bootstrap import initialize_database, seed_super_admin
|
| 15 |
-
from app.
|
| 16 |
-
from app.web import local_now, redirect, templates
|
| 17 |
|
| 18 |
|
| 19 |
app = FastAPI(title=settings.app_name)
|
|
@@ -32,40 +30,10 @@ def on_startup() -> None:
|
|
| 32 |
|
| 33 |
|
| 34 |
@app.middleware("http")
|
| 35 |
-
async def
|
| 36 |
response = await call_next(request)
|
| 37 |
if request.url.path.startswith("/static"):
|
| 38 |
response.headers["Cache-Control"] = "no-store"
|
| 39 |
-
return response
|
| 40 |
-
if request.url.path.startswith("/media"):
|
| 41 |
-
return response
|
| 42 |
-
if request.url.path.startswith("/api"):
|
| 43 |
-
return response
|
| 44 |
-
|
| 45 |
-
admin_id = request.session.get("admin_id")
|
| 46 |
-
user_id = request.session.get("user_id")
|
| 47 |
-
if not admin_id and not user_id:
|
| 48 |
-
return response
|
| 49 |
-
|
| 50 |
-
now = local_now()
|
| 51 |
-
if not should_write_presence(request.session, now):
|
| 52 |
-
return response
|
| 53 |
-
|
| 54 |
-
db = SessionLocal()
|
| 55 |
-
try:
|
| 56 |
-
if admin_id:
|
| 57 |
-
admin = db.get(Admin, admin_id)
|
| 58 |
-
if admin:
|
| 59 |
-
admin.last_seen_at = now
|
| 60 |
-
db.add(admin)
|
| 61 |
-
elif user_id:
|
| 62 |
-
user = db.get(User, user_id)
|
| 63 |
-
if user:
|
| 64 |
-
user.last_seen_at = now
|
| 65 |
-
db.add(user)
|
| 66 |
-
db.commit()
|
| 67 |
-
finally:
|
| 68 |
-
db.close()
|
| 69 |
return response
|
| 70 |
|
| 71 |
|
|
@@ -120,7 +88,4 @@ def format_timedelta(value):
|
|
| 120 |
|
| 121 |
|
| 122 |
templates.env.filters["datetime_local"] = format_datetime
|
| 123 |
-
templates.env.filters["duration_human"] = format_timedelta
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
| 9 |
from app.auth import get_current_admin, get_current_user
|
| 10 |
from app.config import ROOT_DIR, settings
|
| 11 |
from app.database import SessionLocal
|
|
|
|
| 12 |
from app.routes import admin, auth, media, user
|
| 13 |
from app.services.bootstrap import initialize_database, seed_super_admin
|
| 14 |
+
from app.web import redirect, templates
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
app = FastAPI(title=settings.app_name)
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
@app.middleware("http")
|
| 33 |
+
async def apply_response_policies(request: Request, call_next):
|
| 34 |
response = await call_next(request)
|
| 35 |
if request.url.path.startswith("/static"):
|
| 36 |
response.headers["Cache-Control"] = "no-store"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return response
|
| 38 |
|
| 39 |
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
templates.env.filters["datetime_local"] = format_datetime
|
| 91 |
+
templates.env.filters["duration_human"] = format_timedelta
|
|
|
|
|
|
|
|
|
app/routes/__pycache__/admin.cpython-313.pyc
CHANGED
|
Binary files a/app/routes/__pycache__/admin.cpython-313.pyc and b/app/routes/__pycache__/admin.cpython-313.pyc differ
|
|
|
app/routes/__pycache__/auth.cpython-313.pyc
CHANGED
|
Binary files a/app/routes/__pycache__/auth.cpython-313.pyc and b/app/routes/__pycache__/auth.cpython-313.pyc differ
|
|
|
app/routes/admin.py
CHANGED
|
@@ -659,7 +659,6 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
|
|
| 659 |
now = local_now()
|
| 660 |
server_ts = unix_seconds(now)
|
| 661 |
admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
|
| 662 |
-
users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
|
| 663 |
admin_statuses = [
|
| 664 |
{
|
| 665 |
**serialize_presence_entry(item.display_name, display_admin_role(item.role), item.last_seen_at, now),
|
|
@@ -667,13 +666,6 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
|
|
| 667 |
}
|
| 668 |
for item in admins
|
| 669 |
]
|
| 670 |
-
user_statuses = [
|
| 671 |
-
{
|
| 672 |
-
**serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now),
|
| 673 |
-
"group_name": item.group.name if item.group else "未分组",
|
| 674 |
-
}
|
| 675 |
-
for item in users
|
| 676 |
-
]
|
| 677 |
return render(
|
| 678 |
request,
|
| 679 |
"admin_admins.html",
|
|
@@ -682,7 +674,6 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
|
|
| 682 |
"admin": admin,
|
| 683 |
"admins": admins,
|
| 684 |
"admin_statuses": admin_statuses,
|
| 685 |
-
"user_statuses": user_statuses,
|
| 686 |
"presence_server_ts": server_ts,
|
| 687 |
"online_window_seconds": ONLINE_WINDOW_SECONDS,
|
| 688 |
},
|
|
@@ -698,7 +689,6 @@ def presence_overview(request: Request, db: Session = Depends(get_db)):
|
|
| 698 |
now = local_now()
|
| 699 |
server_ts = unix_seconds(now)
|
| 700 |
admins = db.query(Admin).order_by(Admin.display_name.asc()).all()
|
| 701 |
-
users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
|
| 702 |
return JSONResponse(
|
| 703 |
{
|
| 704 |
"server_ts": server_ts,
|
|
@@ -710,13 +700,6 @@ def presence_overview(request: Request, db: Session = Depends(get_db)):
|
|
| 710 |
}
|
| 711 |
for item in admins
|
| 712 |
],
|
| 713 |
-
"users": [
|
| 714 |
-
{
|
| 715 |
-
**serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now),
|
| 716 |
-
"group_name": item.group.name if item.group else "未分组",
|
| 717 |
-
}
|
| 718 |
-
for item in users
|
| 719 |
-
],
|
| 720 |
},
|
| 721 |
headers={"Cache-Control": "no-store"},
|
| 722 |
)
|
|
@@ -1377,3 +1360,4 @@ async def delete_managed_image(request: Request, db: Session = Depends(get_db)):
|
|
| 1377 |
|
| 1378 |
|
| 1379 |
|
|
|
|
|
|
| 659 |
now = local_now()
|
| 660 |
server_ts = unix_seconds(now)
|
| 661 |
admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
|
|
|
|
| 662 |
admin_statuses = [
|
| 663 |
{
|
| 664 |
**serialize_presence_entry(item.display_name, display_admin_role(item.role), item.last_seen_at, now),
|
|
|
|
| 666 |
}
|
| 667 |
for item in admins
|
| 668 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
return render(
|
| 670 |
request,
|
| 671 |
"admin_admins.html",
|
|
|
|
| 674 |
"admin": admin,
|
| 675 |
"admins": admins,
|
| 676 |
"admin_statuses": admin_statuses,
|
|
|
|
| 677 |
"presence_server_ts": server_ts,
|
| 678 |
"online_window_seconds": ONLINE_WINDOW_SECONDS,
|
| 679 |
},
|
|
|
|
| 689 |
now = local_now()
|
| 690 |
server_ts = unix_seconds(now)
|
| 691 |
admins = db.query(Admin).order_by(Admin.display_name.asc()).all()
|
|
|
|
| 692 |
return JSONResponse(
|
| 693 |
{
|
| 694 |
"server_ts": server_ts,
|
|
|
|
| 700 |
}
|
| 701 |
for item in admins
|
| 702 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
},
|
| 704 |
headers={"Cache-Control": "no-store"},
|
| 705 |
)
|
|
|
|
| 1360 |
|
| 1361 |
|
| 1362 |
|
| 1363 |
+
|
app/routes/auth.py
CHANGED
|
@@ -107,7 +107,13 @@ def admin_login_submit(
|
|
| 107 |
if not admin or not admin.is_active or not verify_password(password, admin.password_hash):
|
| 108 |
add_flash(request, "error", "管理员账号或密码错误。")
|
| 109 |
return redirect("/admin")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
sign_in_admin(request, admin)
|
|
|
|
| 111 |
add_flash(request, "success", f"管理员 {admin.display_name} 已登录。")
|
| 112 |
return redirect("/admin/dashboard")
|
| 113 |
|
|
@@ -168,26 +174,24 @@ def change_password(
|
|
| 168 |
@router.api_route("/api/presence/ping", methods=["GET", "POST"])
|
| 169 |
def presence_ping(request: Request, db: Session = Depends(get_db)):
|
| 170 |
admin = get_current_admin(request, db)
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
|
| 175 |
now = local_now()
|
| 176 |
wrote = False
|
| 177 |
-
admin_was_online = bool(
|
| 178 |
if should_write_presence(request.session, now):
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
db.add(admin)
|
| 182 |
-
elif user:
|
| 183 |
-
user.last_seen_at = now
|
| 184 |
-
db.add(user)
|
| 185 |
db.commit()
|
| 186 |
wrote = True
|
| 187 |
|
| 188 |
# Only rebalance on an offline -> online transition. Fresh uploads are handled
|
| 189 |
# at submit time, so this keeps the 5s heartbeat lightweight.
|
| 190 |
-
if
|
| 191 |
rebalance_pending_reviews(db)
|
| 192 |
|
| 193 |
return JSONResponse(
|
|
@@ -201,3 +205,4 @@ def presence_ping(request: Request, db: Session = Depends(get_db)):
|
|
| 201 |
|
| 202 |
|
| 203 |
|
|
|
|
|
|
| 107 |
if not admin or not admin.is_active or not verify_password(password, admin.password_hash):
|
| 108 |
add_flash(request, "error", "管理员账号或密码错误。")
|
| 109 |
return redirect("/admin")
|
| 110 |
+
|
| 111 |
+
admin.last_seen_at = local_now()
|
| 112 |
+
db.add(admin)
|
| 113 |
+
db.commit()
|
| 114 |
+
|
| 115 |
sign_in_admin(request, admin)
|
| 116 |
+
rebalance_pending_reviews(db)
|
| 117 |
add_flash(request, "success", f"管理员 {admin.display_name} 已登录。")
|
| 118 |
return redirect("/admin/dashboard")
|
| 119 |
|
|
|
|
| 174 |
@router.api_route("/api/presence/ping", methods=["GET", "POST"])
|
| 175 |
def presence_ping(request: Request, db: Session = Depends(get_db)):
|
| 176 |
admin = get_current_admin(request, db)
|
| 177 |
+
if not admin:
|
| 178 |
+
return JSONResponse(
|
| 179 |
+
{"ok": False, "skipped": True},
|
| 180 |
+
headers={"Cache-Control": "no-store"},
|
| 181 |
+
)
|
| 182 |
|
| 183 |
now = local_now()
|
| 184 |
wrote = False
|
| 185 |
+
admin_was_online = bool(is_online(admin.last_seen_at, now))
|
| 186 |
if should_write_presence(request.session, now):
|
| 187 |
+
admin.last_seen_at = now
|
| 188 |
+
db.add(admin)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
db.commit()
|
| 190 |
wrote = True
|
| 191 |
|
| 192 |
# Only rebalance on an offline -> online transition. Fresh uploads are handled
|
| 193 |
# at submit time, so this keeps the 5s heartbeat lightweight.
|
| 194 |
+
if not admin_was_online:
|
| 195 |
rebalance_pending_reviews(db)
|
| 196 |
|
| 197 |
return JSONResponse(
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
|
| 208 |
+
|
app/templates/activity_detail.html
CHANGED
|
@@ -320,6 +320,38 @@
|
|
| 320 |
</tr>`).join('');
|
| 321 |
};
|
| 322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
const refreshStatuses = async () => {
|
| 324 |
try {
|
| 325 |
const statusResponse = await fetch('/api/activities/{{ activity.id }}/status', {
|
|
@@ -403,9 +435,25 @@
|
|
| 403 |
}
|
| 404 |
};
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
})();
|
| 409 |
</script>
|
| 410 |
{% endblock %}
|
| 411 |
|
|
|
|
|
|
| 320 |
</tr>`).join('');
|
| 321 |
};
|
| 322 |
|
| 323 |
+
const STATUS_POLL_INTERVAL_MS = 15000;
|
| 324 |
+
let statusTimerId = null;
|
| 325 |
+
let statusInFlight = false;
|
| 326 |
+
|
| 327 |
+
const canRefreshStatuses = () => !document.hidden && navigator.onLine;
|
| 328 |
+
|
| 329 |
+
const scheduleStatusRefresh = (delay = STATUS_POLL_INTERVAL_MS) => {
|
| 330 |
+
if (statusTimerId) {
|
| 331 |
+
window.clearTimeout(statusTimerId);
|
| 332 |
+
}
|
| 333 |
+
statusTimerId = window.setTimeout(runStatusRefresh, delay);
|
| 334 |
+
};
|
| 335 |
+
|
| 336 |
+
const runStatusRefresh = async () => {
|
| 337 |
+
if (!canRefreshStatuses()) {
|
| 338 |
+
statusTimerId = null;
|
| 339 |
+
return;
|
| 340 |
+
}
|
| 341 |
+
if (statusInFlight) {
|
| 342 |
+
scheduleStatusRefresh(1500);
|
| 343 |
+
return;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
statusInFlight = true;
|
| 347 |
+
try {
|
| 348 |
+
await refreshStatuses();
|
| 349 |
+
} finally {
|
| 350 |
+
statusInFlight = false;
|
| 351 |
+
scheduleStatusRefresh(STATUS_POLL_INTERVAL_MS + Math.floor(Math.random() * 1200));
|
| 352 |
+
}
|
| 353 |
+
};
|
| 354 |
+
|
| 355 |
const refreshStatuses = async () => {
|
| 356 |
try {
|
| 357 |
const statusResponse = await fetch('/api/activities/{{ activity.id }}/status', {
|
|
|
|
| 435 |
}
|
| 436 |
};
|
| 437 |
|
| 438 |
+
runStatusRefresh();
|
| 439 |
+
document.addEventListener('visibilitychange', () => {
|
| 440 |
+
if (document.hidden) {
|
| 441 |
+
if (statusTimerId) {
|
| 442 |
+
window.clearTimeout(statusTimerId);
|
| 443 |
+
statusTimerId = null;
|
| 444 |
+
}
|
| 445 |
+
return;
|
| 446 |
+
}
|
| 447 |
+
runStatusRefresh();
|
| 448 |
+
});
|
| 449 |
+
window.addEventListener('focus', () => {
|
| 450 |
+
if (!document.hidden) {
|
| 451 |
+
runStatusRefresh();
|
| 452 |
+
}
|
| 453 |
+
});
|
| 454 |
+
window.addEventListener('online', runStatusRefresh);
|
| 455 |
})();
|
| 456 |
</script>
|
| 457 |
{% endblock %}
|
| 458 |
|
| 459 |
+
|
app/templates/admin_admins.html
CHANGED
|
@@ -91,33 +91,22 @@
|
|
| 91 |
</div>
|
| 92 |
</article>
|
| 93 |
|
| 94 |
-
<article class="glass-card
|
| 95 |
<div class="section-head">
|
| 96 |
<div>
|
| 97 |
-
<p class="eyebrow">
|
| 98 |
-
<h3>
|
| 99 |
-
<p class="mini-note">仅超级管理员可见,适合现场查看整体在线情况。</p>
|
| 100 |
</div>
|
| 101 |
</div>
|
| 102 |
-
<div class="
|
| 103 |
-
|
| 104 |
-
<
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
data-presence-badge
|
| 112 |
-
data-last-seen-ts="{{ item.last_seen_ts or '' }}"
|
| 113 |
-
title="最近心跳 {{ item.last_seen_at }}"
|
| 114 |
-
>
|
| 115 |
-
{{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
|
| 116 |
-
</span>
|
| 117 |
-
</div>
|
| 118 |
-
{% else %}
|
| 119 |
-
<p class="muted">暂无用户在线状态。</p>
|
| 120 |
-
{% endfor %}
|
| 121 |
</div>
|
| 122 |
</article>
|
| 123 |
</section>
|
|
@@ -125,8 +114,7 @@
|
|
| 125 |
<script>
|
| 126 |
(() => {
|
| 127 |
const adminList = document.getElementById('admin-presence-list');
|
| 128 |
-
|
| 129 |
-
if (!adminList || !userList) return;
|
| 130 |
|
| 131 |
let serverTs = Number('{{ presence_server_ts or 0 }}') || Math.floor(Date.now() / 1000);
|
| 132 |
let syncClientTs = Date.now();
|
|
@@ -197,31 +185,6 @@
|
|
| 197 |
}).join('');
|
| 198 |
};
|
| 199 |
|
| 200 |
-
const renderUserItems = (items) => {
|
| 201 |
-
if (!items.length) {
|
| 202 |
-
userList.innerHTML = '<p class="muted">暂无用户在线状态。</p>';
|
| 203 |
-
return;
|
| 204 |
-
}
|
| 205 |
-
userList.innerHTML = items.map((item) => {
|
| 206 |
-
const status = getStatusMeta(Number(item.last_seen_ts || 0));
|
| 207 |
-
return `
|
| 208 |
-
<div class="stack-item presence-item">
|
| 209 |
-
<div>
|
| 210 |
-
<strong>${escapeHtml(item.name)}</strong>
|
| 211 |
-
<p class="muted">${escapeHtml(item.group_name)}</p>
|
| 212 |
-
</div>
|
| 213 |
-
<span
|
| 214 |
-
class="status-badge ${status.online ? 'status-approved' : 'status-rejected'}"
|
| 215 |
-
data-presence-badge
|
| 216 |
-
data-last-seen-ts="${item.last_seen_ts || ''}"
|
| 217 |
-
title="最近心跳 ${escapeHtml(item.last_seen_at || '-') }"
|
| 218 |
-
>
|
| 219 |
-
${status.text}
|
| 220 |
-
</span>
|
| 221 |
-
</div>`;
|
| 222 |
-
}).join('');
|
| 223 |
-
};
|
| 224 |
-
|
| 225 |
const refreshPresenceBadges = () => {
|
| 226 |
if (document.hidden) return;
|
| 227 |
document.querySelectorAll('[data-presence-badge]').forEach((badge) => {
|
|
@@ -248,7 +211,6 @@
|
|
| 248 |
syncClientTs = Date.now();
|
| 249 |
onlineWindowSeconds = Number(payload.online_window_seconds || 0) || onlineWindowSeconds;
|
| 250 |
renderAdminItems(payload.admins || []);
|
| 251 |
-
renderUserItems(payload.users || []);
|
| 252 |
refreshPresenceBadges();
|
| 253 |
} catch (error) {
|
| 254 |
console.debug('presence refresh skipped', error);
|
|
@@ -269,7 +231,4 @@
|
|
| 269 |
window.addEventListener('focus', refreshPresence);
|
| 270 |
})();
|
| 271 |
</script>
|
| 272 |
-
{% endblock %}
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
| 91 |
</div>
|
| 92 |
</article>
|
| 93 |
|
| 94 |
+
<article class="glass-card form-panel">
|
| 95 |
<div class="section-head">
|
| 96 |
<div>
|
| 97 |
+
<p class="eyebrow">Notice</p>
|
| 98 |
+
<h3>在线检测说明</h3>
|
|
|
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
+
<div class="info-box">
|
| 102 |
+
<div class="stack-item stack-item-block">
|
| 103 |
+
<strong>普通用户在线心跳已关闭</strong>
|
| 104 |
+
<p class="muted">为降低数据库连接占用与轮询压力,系统不再维护普通用户的实时在线状态,只保留管理员在线检测与审核分发所需的心跳。</p>
|
| 105 |
+
</div>
|
| 106 |
+
<div class="stack-item stack-item-block">
|
| 107 |
+
<strong>当前策略</strong>
|
| 108 |
+
<p class="muted">管理员登录时会立即标记在线,之后仅由管理员端每 5 秒心跳续期;页面隐藏或离线时会自动暂停心跳,减少无效请求。</p>
|
| 109 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
</div>
|
| 111 |
</article>
|
| 112 |
</section>
|
|
|
|
| 114 |
<script>
|
| 115 |
(() => {
|
| 116 |
const adminList = document.getElementById('admin-presence-list');
|
| 117 |
+
if (!adminList) return;
|
|
|
|
| 118 |
|
| 119 |
let serverTs = Number('{{ presence_server_ts or 0 }}') || Math.floor(Date.now() / 1000);
|
| 120 |
let syncClientTs = Date.now();
|
|
|
|
| 185 |
}).join('');
|
| 186 |
};
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
const refreshPresenceBadges = () => {
|
| 189 |
if (document.hidden) return;
|
| 190 |
document.querySelectorAll('[data-presence-badge]').forEach((badge) => {
|
|
|
|
| 211 |
syncClientTs = Date.now();
|
| 212 |
onlineWindowSeconds = Number(payload.online_window_seconds || 0) || onlineWindowSeconds;
|
| 213 |
renderAdminItems(payload.admins || []);
|
|
|
|
| 214 |
refreshPresenceBadges();
|
| 215 |
} catch (error) {
|
| 216 |
console.debug('presence refresh skipped', error);
|
|
|
|
| 231 |
window.addEventListener('focus', refreshPresence);
|
| 232 |
})();
|
| 233 |
</script>
|
| 234 |
+
{% endblock %}
|
|
|
|
|
|
|
|
|
app/templates/admin_reviews.html
CHANGED
|
@@ -112,6 +112,9 @@
|
|
| 112 |
const recentList = document.getElementById('recent-review-list');
|
| 113 |
const onlineAdminPill = document.getElementById('online-admin-pill');
|
| 114 |
const activityFilter = '{{ activity_filter }}';
|
|
|
|
|
|
|
|
|
|
| 115 |
const selectedIds = new Set(
|
| 116 |
Array.from(document.querySelectorAll('input[name="submission_ids"]:checked')).map((item) => item.value)
|
| 117 |
);
|
|
@@ -196,6 +199,28 @@
|
|
| 196 |
});
|
| 197 |
};
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
const refreshFeed = async () => {
|
| 200 |
try {
|
| 201 |
const search = activityFilter ? `?activity_id=${activityFilter}` : '';
|
|
@@ -229,8 +254,20 @@
|
|
| 229 |
};
|
| 230 |
|
| 231 |
attachReviewForms();
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
})();
|
| 235 |
</script>
|
| 236 |
{% endblock %}
|
|
|
|
|
|
| 112 |
const recentList = document.getElementById('recent-review-list');
|
| 113 |
const onlineAdminPill = document.getElementById('online-admin-pill');
|
| 114 |
const activityFilter = '{{ activity_filter }}';
|
| 115 |
+
const FEED_POLL_INTERVAL_MS = 5000;
|
| 116 |
+
let feedTimerId = null;
|
| 117 |
+
let feedInFlight = false;
|
| 118 |
const selectedIds = new Set(
|
| 119 |
Array.from(document.querySelectorAll('input[name="submission_ids"]:checked')).map((item) => item.value)
|
| 120 |
);
|
|
|
|
| 199 |
});
|
| 200 |
};
|
| 201 |
|
| 202 |
+
const scheduleFeedRefresh = (delay = FEED_POLL_INTERVAL_MS) => {
|
| 203 |
+
if (feedTimerId) {
|
| 204 |
+
window.clearTimeout(feedTimerId);
|
| 205 |
+
}
|
| 206 |
+
feedTimerId = window.setTimeout(runFeedRefresh, delay);
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
const runFeedRefresh = async () => {
|
| 210 |
+
if (document.hidden || feedInFlight) {
|
| 211 |
+
scheduleFeedRefresh(document.hidden ? FEED_POLL_INTERVAL_MS : 1000);
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
feedInFlight = true;
|
| 216 |
+
try {
|
| 217 |
+
await refreshFeed();
|
| 218 |
+
} finally {
|
| 219 |
+
feedInFlight = false;
|
| 220 |
+
scheduleFeedRefresh(FEED_POLL_INTERVAL_MS + Math.floor(Math.random() * 800));
|
| 221 |
+
}
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
const refreshFeed = async () => {
|
| 225 |
try {
|
| 226 |
const search = activityFilter ? `?activity_id=${activityFilter}` : '';
|
|
|
|
| 254 |
};
|
| 255 |
|
| 256 |
attachReviewForms();
|
| 257 |
+
runFeedRefresh();
|
| 258 |
+
document.addEventListener('visibilitychange', () => {
|
| 259 |
+
if (document.hidden) {
|
| 260 |
+
if (feedTimerId) {
|
| 261 |
+
window.clearTimeout(feedTimerId);
|
| 262 |
+
feedTimerId = null;
|
| 263 |
+
}
|
| 264 |
+
return;
|
| 265 |
+
}
|
| 266 |
+
runFeedRefresh();
|
| 267 |
+
});
|
| 268 |
+
window.addEventListener('focus', runFeedRefresh);
|
| 269 |
+
window.addEventListener('online', runFeedRefresh);
|
| 270 |
})();
|
| 271 |
</script>
|
| 272 |
{% endblock %}
|
| 273 |
+
|
app/templates/base.html
CHANGED
|
@@ -46,7 +46,7 @@
|
|
| 46 |
</main>
|
| 47 |
</div>
|
| 48 |
|
| 49 |
-
{% if
|
| 50 |
<script>
|
| 51 |
(() => {
|
| 52 |
const PRESENCE_INTERVAL_MS = 5000;
|
|
@@ -124,3 +124,4 @@
|
|
| 124 |
|
| 125 |
|
| 126 |
|
|
|
|
|
|
| 46 |
</main>
|
| 47 |
</div>
|
| 48 |
|
| 49 |
+
{% if admin %}
|
| 50 |
<script>
|
| 51 |
(() => {
|
| 52 |
const PRESENCE_INTERVAL_MS = 5000;
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
|
| 127 |
+
|