1616 lines
58 KiB
Python
1616 lines
58 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, delete, func, or_, select, text
|
|
from sqlalchemy.dialects.mysql import MEDIUMTEXT
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.db import Base
|
|
|
|
def _mysql_args() -> dict:
|
|
return {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
|
|
|
|
TECH = "科技蓝"
|
|
BRAND = "LOGO蓝"
|
|
TEMPLATE_NAMES = (TECH, BRAND)
|
|
|
|
SLOGAN_EMPTY = "协会定位整理中"
|
|
VALUE_EMPTY = "价值主张整理中"
|
|
|
|
EMPTY_REGIONS = {
|
|
"carousel": "轮播内容准备中",
|
|
"news": "新闻动态即将发布",
|
|
"notices": "暂无新的通知公告",
|
|
"policies": "政策法规整理中",
|
|
"dashboard": "数据看板整理中",
|
|
"audiences": "估价师、机构与公众入口即将开放",
|
|
"hotLinks": "报告查询、报告审核与投诉举报入口即将开放",
|
|
"honors": "协会荣誉与资质整理中",
|
|
"members": "会员单位名录整理中",
|
|
"compliance": "隐私政策、使用条款与网站声明整理中",
|
|
"annual_reports": "暂无年度报告或工作计划",
|
|
"work_briefs": "暂无工作简报",
|
|
"qualifications": "暂无资质与荣誉",
|
|
"tech_seminars": "暂无技术研讨",
|
|
"tech_exchange": "暂无技术交流",
|
|
"party_activities": "暂无党建活动",
|
|
"appraisal_guides": "暂无估价指南",
|
|
"institution_directories": "暂无机构名录",
|
|
"policy_analyses": "暂无政策分析",
|
|
"case_library": "暂无案例库",
|
|
"edu_notices": "暂无教育公告",
|
|
"warning_cases": "暂无警示案例",
|
|
"integrity_records": "暂无诚信档案",
|
|
"discipline_results": "暂无结果公示",
|
|
}
|
|
|
|
HOT_LINK_SLOTS = (
|
|
("reportQuery", "报告查询", "/reports/query"),
|
|
("reportReview", "报告审核", "/reports/review"),
|
|
("complaint", "投诉举报", "/discipline"),
|
|
)
|
|
FIXED_HOT_URLS = {
|
|
"reportQuery": "/reports/query",
|
|
"reportReview": "/reports/review",
|
|
}
|
|
HOT_LINK_KEYS = {key for key, _label, _url in HOT_LINK_SLOTS}
|
|
|
|
|
|
class HomeSlide(Base):
|
|
__tablename__ = "home_slide"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
|
image_file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
body_html: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
|
|
class HomeSlideAttachment(Base):
|
|
__tablename__ = "home_slide_attachment"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
slide_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
HOME_ARTICLE_CATEGORIES = ("news", "notices", "policies", "dashboard")
|
|
COLUMN_ARTICLE_CATEGORIES = (
|
|
"annual_reports",
|
|
"work_briefs",
|
|
"qualifications",
|
|
"tech_seminars",
|
|
"tech_exchange",
|
|
"party_activities",
|
|
"appraisal_guides",
|
|
"institution_directories",
|
|
"policy_analyses",
|
|
"case_library",
|
|
"edu_notices",
|
|
"warning_cases",
|
|
"integrity_records",
|
|
"discipline_results",
|
|
)
|
|
ARTICLE_CATEGORIES = HOME_ARTICLE_CATEGORIES + COLUMN_ARTICLE_CATEGORIES
|
|
|
|
|
|
def ensure_article_body_capacity(db) -> None:
|
|
bind = db.get_bind()
|
|
if bind.dialect.name != "mysql":
|
|
return
|
|
db.execute(text("ALTER TABLE home_article MODIFY body_html MEDIUMTEXT NOT NULL"))
|
|
db.commit()
|
|
|
|
|
|
RELATED_CATEGORIES = ("主管部门", "相关部门", "合作协会")
|
|
AUDIENCE_NAMES = ("估价师", "估价机构", "公众")
|
|
MENU_KEYS = ("member",)
|
|
EDU_PLATFORM_EMPTY = "暂无在线教育"
|
|
DEFAULT_DISPLAY_COUNT = 8
|
|
COLUMN_LIST_PAGE_SIZES = (10, 20, 100)
|
|
DEFAULT_COLUMN_PAGE_SIZE = 10
|
|
APPRAISAL_GUIDE_KINDS = ("residential", "commercial", "industrial", "specialty")
|
|
APPRAISAL_GUIDE_KIND_LABELS = {
|
|
"residential": "住宅",
|
|
"commercial": "商业",
|
|
"industrial": "工业",
|
|
"specialty": "特色地产",
|
|
}
|
|
POLICY_ANALYSIS_KINDS = ("national", "local", "industry", "expert", "digest")
|
|
POLICY_ANALYSIS_KIND_LABELS = {
|
|
"national": "国家级",
|
|
"local": "地方级",
|
|
"industry": "行业级",
|
|
"expert": "专家解读",
|
|
"digest": "政策汇编",
|
|
}
|
|
POLICY_SEARCH_EMPTY = "未找到相关政策"
|
|
|
|
|
|
class HomeArticleList(Base):
|
|
__tablename__ = "home_article_list"
|
|
__table_args__ = _mysql_args()
|
|
|
|
category: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
display_count: Mapped[int] = mapped_column(Integer, nullable=False, default=DEFAULT_DISPLAY_COUNT)
|
|
|
|
|
|
class HomeArticle(Base):
|
|
__tablename__ = "home_article"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
category: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
|
body_html: Mapped[str] = mapped_column(
|
|
Text().with_variant(MEDIUMTEXT(), "mysql"), nullable=False, default=""
|
|
)
|
|
image_file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
|
|
class HomeArticleAttachment(Base):
|
|
__tablename__ = "home_article_attachment"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
article_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeArticleKind(Base):
|
|
"""栏目文章的附加分类。估价指南与政策分析使用。"""
|
|
|
|
__tablename__ = "home_article_kind"
|
|
__table_args__ = _mysql_args()
|
|
|
|
article_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
kind: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
|
|
|
|
class HomeAudienceLink(Base):
|
|
__tablename__ = "home_audience_link"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
category: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeHotLink(Base):
|
|
__tablename__ = "home_hot_link"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
key: Mapped[str] = mapped_column(String(32), nullable=False, default="", unique=True)
|
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeMenuItem(Base):
|
|
__tablename__ = "home_menu_item"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
key: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
|
label: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeMarquee(Base):
|
|
__tablename__ = "home_marquee"
|
|
__table_args__ = _mysql_args()
|
|
|
|
kind: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
|
class HomeHonor(Base):
|
|
__tablename__ = "home_honor"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
image_file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeMember(Base):
|
|
__tablename__ = "home_member"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
image_file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class EduPlatform(Base):
|
|
__tablename__ = "edu_platform"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
image_file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeContact(Base):
|
|
__tablename__ = "home_contact"
|
|
__table_args__ = _mysql_args()
|
|
|
|
template_name: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
address: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
phone: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
email: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
|
|
class HomeRelatedLink(Base):
|
|
__tablename__ = "home_related_link"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
template_name: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeComplianceLink(Base):
|
|
__tablename__ = "home_compliance_link"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
template_name: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class HomeTemplate(Base):
|
|
__tablename__ = "home_template"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
name: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
|
slogan: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
value_proposition: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
|
|
|
|
def ensure_home_templates(db) -> None:
|
|
existing = set(db.scalars(select(HomeTemplate.name)))
|
|
missing = [
|
|
HomeTemplate(name=name, slogan="", value_proposition="", is_active=name == TECH)
|
|
for name in TEMPLATE_NAMES
|
|
if name not in existing
|
|
]
|
|
if missing:
|
|
db.add_all(missing)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
rows = db.scalars(select(HomeTemplate)).all()
|
|
if rows and not any(row.is_active for row in rows):
|
|
for row in rows:
|
|
row.is_active = row.name == TECH
|
|
db.commit()
|
|
|
|
|
|
def _text(value: str, empty: str) -> str:
|
|
text = "" if value is None else str(value).strip()
|
|
return text or empty
|
|
|
|
|
|
def public_home(db) -> dict:
|
|
from app.models.content import settings_view
|
|
|
|
ensure_home_templates(db)
|
|
rows = db.scalars(select(HomeTemplate)).all()
|
|
active = next((row for row in rows if row.is_active), None)
|
|
if active is None and rows:
|
|
active = next(row for row in rows if row.name == TECH)
|
|
settings = settings_view(db)
|
|
payload = {
|
|
"templateName": active.name if active else TECH,
|
|
"slogan": _text(active.slogan if active else "", SLOGAN_EMPTY),
|
|
"valueProposition": _text(active.value_proposition if active else "", VALUE_EMPTY),
|
|
"icpText": settings["icp_text"],
|
|
"policeText": settings["police_text"],
|
|
}
|
|
template_name = active.name if active else TECH
|
|
for key, sentence in EMPTY_REGIONS.items():
|
|
if key == "carousel" or key in HOME_ARTICLE_CATEGORIES or key in COLUMN_ARTICLE_CATEGORIES or key in {
|
|
"audiences",
|
|
"hotLinks",
|
|
"honors",
|
|
"members",
|
|
"compliance",
|
|
}:
|
|
continue
|
|
payload[key] = {"placeholder": sentence}
|
|
payload["carousel"] = _carousel(db)
|
|
for key in HOME_ARTICLE_CATEGORIES:
|
|
payload[key] = _articles(db, key, EMPTY_REGIONS[key])
|
|
payload["audiences"] = _public_audiences(db)
|
|
payload["hotLinks"] = _public_hot_links(db)
|
|
payload["shortcutMenu"] = _shortcut_menu(db)
|
|
payload["honors"] = _public_honors(db)
|
|
payload["members"] = _public_members(db)
|
|
payload["contact"] = _public_contact(db, template_name)
|
|
payload["relatedLinks"] = _public_related(db, template_name)
|
|
payload["compliance"] = _public_compliance(db, template_name)
|
|
return payload
|
|
|
|
|
|
def _public_file(db, file_id: int):
|
|
from app.models.content import AdminFile
|
|
|
|
if isinstance(file_id, bool) or not isinstance(file_id, int) or file_id <= 0:
|
|
return None
|
|
row = db.get(AdminFile, file_id)
|
|
if row is None or row.bucket != "public" or not row.object_key:
|
|
return None
|
|
return row
|
|
|
|
|
|
def _carousel(db) -> dict:
|
|
from app.services.storage import public_object_url
|
|
|
|
rows = db.scalars(
|
|
select(HomeSlide)
|
|
.where(HomeSlide.published.is_(True), HomeSlide.enabled.is_(True))
|
|
.order_by(HomeSlide.sort_order.asc(), HomeSlide.id.asc())
|
|
).all()
|
|
items = []
|
|
for row in rows:
|
|
image = _public_file(db, row.image_file_id)
|
|
if image is None:
|
|
continue
|
|
items.append(
|
|
{
|
|
"id": row.id,
|
|
"title": row.title,
|
|
"imageUrl": public_object_url(image.object_key),
|
|
"sortOrder": row.sort_order,
|
|
"intervalSeconds": row.interval_seconds,
|
|
}
|
|
)
|
|
if not items:
|
|
return {"placeholder": EMPTY_REGIONS["carousel"]}
|
|
return {"items": items}
|
|
|
|
|
|
def _prepare_slide(db, payload: dict) -> tuple[dict, list]:
|
|
from app.core.config import get_settings
|
|
from app.models.content import AdminFile
|
|
from app.services.html_filter import filter_html
|
|
|
|
image = _public_file(db, payload["imageFileId"])
|
|
if image is None:
|
|
raise ValueError("轮播图必须是已上传的公开图片")
|
|
interval = payload["intervalSeconds"]
|
|
if isinstance(interval, bool) or not isinstance(interval, int) or interval <= 0:
|
|
raise ValueError("间隔秒数必须是正整数")
|
|
attachments = []
|
|
for file_id in payload["attachmentFileIds"]:
|
|
row = _public_file(db, file_id)
|
|
if row is None:
|
|
raise ValueError("附件必须是已上传的公开文件")
|
|
attachments.append(row)
|
|
keys = list(db.scalars(select(AdminFile.object_key).where(AdminFile.bucket == "public")))
|
|
cleaned = filter_html(payload.get("bodyHtml") or "", list(keys), get_settings().minio_base_url)
|
|
fields = {
|
|
"title": "" if payload.get("title") is None else str(payload["title"]),
|
|
"image_file_id": image.id,
|
|
"sort_order": int(payload.get("sortOrder") or 0),
|
|
"enabled": bool(payload.get("enabled", True)),
|
|
"interval_seconds": interval,
|
|
"published": bool(payload.get("published", False)),
|
|
"body_html": cleaned,
|
|
}
|
|
return fields, attachments
|
|
|
|
|
|
def _replace_slide_attachments(db, slide_id: int, attachments: list) -> None:
|
|
links = db.scalars(
|
|
select(HomeSlideAttachment).where(HomeSlideAttachment.slide_id == slide_id)
|
|
).all()
|
|
for link in links:
|
|
db.delete(link)
|
|
db.flush()
|
|
for index, row in enumerate(attachments):
|
|
db.add(HomeSlideAttachment(slide_id=slide_id, file_id=row.id, position=index))
|
|
|
|
|
|
def create_slide(db, payload: dict) -> int:
|
|
fields, attachments = _prepare_slide(db, payload)
|
|
slide = HomeSlide(**fields)
|
|
db.add(slide)
|
|
db.flush()
|
|
_replace_slide_attachments(db, slide.id, attachments)
|
|
db.commit()
|
|
db.refresh(slide)
|
|
return slide.id
|
|
|
|
|
|
def update_slide(db, slide_id: int, payload: dict) -> None:
|
|
slide = db.get(HomeSlide, slide_id)
|
|
if slide is None:
|
|
raise ValueError("轮播不存在")
|
|
fields, attachments = _prepare_slide(db, payload)
|
|
for key, value in fields.items():
|
|
setattr(slide, key, value)
|
|
_replace_slide_attachments(db, slide.id, attachments)
|
|
db.commit()
|
|
|
|
|
|
def public_slide(db, slide_id: int) -> dict | None:
|
|
from app.services.storage import public_object_url
|
|
|
|
row = db.get(HomeSlide, slide_id)
|
|
if row is None or not row.published or not row.enabled:
|
|
return None
|
|
image = _public_file(db, row.image_file_id)
|
|
links = db.scalars(
|
|
select(HomeSlideAttachment)
|
|
.where(HomeSlideAttachment.slide_id == row.id)
|
|
.order_by(HomeSlideAttachment.position.asc(), HomeSlideAttachment.id.asc())
|
|
).all()
|
|
attachments = []
|
|
for link in links:
|
|
file_row = _public_file(db, link.file_id)
|
|
if file_row is None:
|
|
continue
|
|
attachments.append(
|
|
{"filename": file_row.filename, "url": public_object_url(file_row.object_key)}
|
|
)
|
|
return {
|
|
"id": row.id,
|
|
"title": row.title,
|
|
"bodyHtml": row.body_html,
|
|
"imageUrl": public_object_url(image.object_key) if image else "",
|
|
"intervalSeconds": row.interval_seconds,
|
|
"attachments": attachments,
|
|
}
|
|
|
|
|
|
def delete_slide(db, slide_id: int) -> None:
|
|
row = db.get(HomeSlide, slide_id)
|
|
if row is None:
|
|
raise ValueError("轮播不存在")
|
|
links = db.scalars(
|
|
select(HomeSlideAttachment).where(HomeSlideAttachment.slide_id == row.id)
|
|
).all()
|
|
for link in links:
|
|
db.delete(link)
|
|
db.delete(row)
|
|
db.commit()
|
|
|
|
|
|
def _display_count(db, category: str) -> int:
|
|
row = db.get(HomeArticleList, category)
|
|
if row is None or row.display_count <= 0:
|
|
return DEFAULT_DISPLAY_COUNT
|
|
return row.display_count
|
|
|
|
|
|
def set_article_display(db, limits: dict) -> None:
|
|
unknown = [key for key in limits if key not in HOME_ARTICLE_CATEGORIES]
|
|
if unknown:
|
|
raise ValueError("文章类别不正确")
|
|
for category, count in limits.items():
|
|
if count is None:
|
|
continue
|
|
if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
|
|
raise ValueError("展示条数必须是正整数")
|
|
row = db.get(HomeArticleList, category)
|
|
if row is None:
|
|
db.add(HomeArticleList(category=category, display_count=count))
|
|
else:
|
|
row.display_count = count
|
|
db.commit()
|
|
|
|
|
|
def _published_at(value) -> datetime | None:
|
|
if value is None or value == "":
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise ValueError("发布时间格式不正确")
|
|
try:
|
|
parsed = datetime.fromisoformat(value)
|
|
except ValueError as exc:
|
|
raise ValueError("发布时间格式不正确") from exc
|
|
if parsed.tzinfo is not None:
|
|
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
|
return parsed
|
|
|
|
|
|
def _articles(db, category: str, placeholder: str, *, limit: int | None = None) -> dict:
|
|
from app.services.storage import public_object_url
|
|
|
|
query = (
|
|
select(HomeArticle)
|
|
.where(HomeArticle.category == category, HomeArticle.published.is_(True))
|
|
.order_by(HomeArticle.published_at.desc(), HomeArticle.id.desc())
|
|
)
|
|
if limit is None:
|
|
query = query.limit(_display_count(db, category))
|
|
else:
|
|
query = query.limit(limit)
|
|
rows = db.scalars(query).all()
|
|
items = []
|
|
for row in rows:
|
|
item = {"id": row.id, "title": row.title}
|
|
image = _public_file(db, row.image_file_id) if row.image_file_id else None
|
|
if image is not None:
|
|
item["imageUrl"] = public_object_url(image.object_key)
|
|
items.append(item)
|
|
if not items:
|
|
return {"placeholder": placeholder}
|
|
return {"items": items}
|
|
|
|
|
|
def _like_contains(value: str) -> str:
|
|
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
return f"%{escaped}%"
|
|
|
|
|
|
def public_column_articles(
|
|
db,
|
|
category: str,
|
|
*,
|
|
current: int = 1,
|
|
size: int = DEFAULT_COLUMN_PAGE_SIZE,
|
|
kind: str | None = None,
|
|
q: str | None = None,
|
|
) -> dict | None:
|
|
if category not in COLUMN_ARTICLE_CATEGORIES:
|
|
return None
|
|
from app.services.storage import public_object_url
|
|
|
|
page_size = size if size in COLUMN_LIST_PAGE_SIZES else DEFAULT_COLUMN_PAGE_SIZE
|
|
page = current if current >= 1 else 1
|
|
placeholder = EMPTY_REGIONS[category]
|
|
filters = [HomeArticle.category == category, HomeArticle.published.is_(True)]
|
|
kind_filter = (kind or "").strip()
|
|
kind_sets = {
|
|
"appraisal_guides": APPRAISAL_GUIDE_KINDS,
|
|
"policy_analyses": POLICY_ANALYSIS_KINDS,
|
|
}
|
|
allowed_kinds = kind_sets.get(category)
|
|
if allowed_kinds is not None and kind_filter and kind_filter != "all":
|
|
if kind_filter not in allowed_kinds:
|
|
return None
|
|
filters.append(
|
|
HomeArticle.id.in_(
|
|
select(HomeArticleKind.article_id).where(HomeArticleKind.kind == kind_filter)
|
|
)
|
|
)
|
|
keyword = (q or "").strip()[:100]
|
|
if keyword and category == "policy_analyses":
|
|
needle = _like_contains(keyword)
|
|
filters.append(
|
|
or_(
|
|
HomeArticle.title.like(needle, escape="\\"),
|
|
HomeArticle.body_html.like(needle, escape="\\"),
|
|
)
|
|
)
|
|
total = db.scalar(select(func.count()).select_from(HomeArticle).where(*filters)) or 0
|
|
if total == 0:
|
|
if keyword and category == "policy_analyses":
|
|
placeholder = POLICY_SEARCH_EMPTY
|
|
return {"placeholder": placeholder, "items": [], "total": 0, "current": page, "size": page_size}
|
|
pages = max(1, (total + page_size - 1) // page_size)
|
|
if page > pages:
|
|
page = pages
|
|
rows = db.scalars(
|
|
select(HomeArticle)
|
|
.where(*filters)
|
|
.order_by(HomeArticle.published_at.desc(), HomeArticle.id.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
).all()
|
|
kinds = _article_kinds(db, [row.id for row in rows])
|
|
items = []
|
|
for row in rows:
|
|
item = {"id": row.id, "title": row.title}
|
|
image = _public_file(db, row.image_file_id) if row.image_file_id else None
|
|
if image is not None:
|
|
item["imageUrl"] = public_object_url(image.object_key)
|
|
article_kind = kinds.get(row.id)
|
|
if article_kind:
|
|
item["kind"] = article_kind
|
|
item["kindLabel"] = _kind_label(category, article_kind)
|
|
items.append(item)
|
|
return {"items": items, "total": total, "current": page, "size": page_size}
|
|
|
|
|
|
def admin_column_articles(db, category: str) -> dict | None:
|
|
if category not in COLUMN_ARTICLE_CATEGORIES:
|
|
return None
|
|
from app.services.storage import public_object_url
|
|
|
|
rows = db.scalars(
|
|
select(HomeArticle)
|
|
.where(HomeArticle.category == category)
|
|
.order_by(HomeArticle.published_at.desc(), HomeArticle.id.desc())
|
|
).all()
|
|
kinds = _article_kinds(db, [row.id for row in rows])
|
|
items = []
|
|
for row in rows:
|
|
image_id, image_url = _editor_image(db, row.image_file_id)
|
|
attachments = db.scalars(
|
|
select(HomeArticleAttachment)
|
|
.where(HomeArticleAttachment.article_id == row.id)
|
|
.order_by(HomeArticleAttachment.position.asc(), HomeArticleAttachment.id.asc())
|
|
).all()
|
|
files = _editor_attachments(db, attachments)
|
|
item = {
|
|
"id": row.id,
|
|
"category": row.category,
|
|
"title": row.title,
|
|
"publishedAt": row.published_at.isoformat(timespec="seconds") if row.published_at else "",
|
|
"published": bool(row.published),
|
|
"bodyHtml": row.body_html,
|
|
"imageFileId": image_id,
|
|
"imageUrl": image_url,
|
|
"attachmentFileIds": [item["id"] for item in files],
|
|
"attachments": files,
|
|
}
|
|
article_kind = kinds.get(row.id)
|
|
if article_kind:
|
|
item["kind"] = article_kind
|
|
item["kindLabel"] = _kind_label(category, article_kind)
|
|
items.append(item)
|
|
return {"category": category, "items": items}
|
|
|
|
|
|
def _kind_label(category: str, kind: str) -> str:
|
|
labels = {
|
|
"appraisal_guides": APPRAISAL_GUIDE_KIND_LABELS,
|
|
"policy_analyses": POLICY_ANALYSIS_KIND_LABELS,
|
|
}
|
|
return labels.get(category, {}).get(kind, kind)
|
|
|
|
|
|
def _article_kinds(db, article_ids: list[int]) -> dict[int, str]:
|
|
if not article_ids:
|
|
return {}
|
|
rows = db.scalars(select(HomeArticleKind).where(HomeArticleKind.article_id.in_(article_ids))).all()
|
|
return {row.article_id: row.kind for row in rows}
|
|
|
|
|
|
def _normalize_article_kind(category: str, payload: dict) -> str | None:
|
|
raw = payload.get("kind")
|
|
kind = "" if raw is None else str(raw).strip()
|
|
if category == "appraisal_guides":
|
|
if kind not in APPRAISAL_GUIDE_KINDS:
|
|
raise ValueError("估价指南分类不正确")
|
|
return kind
|
|
if category == "policy_analyses":
|
|
if kind not in POLICY_ANALYSIS_KINDS:
|
|
raise ValueError("政策分类不正确")
|
|
return kind
|
|
if raw not in (None, ""):
|
|
raise ValueError("该类文章不支持分类")
|
|
return None
|
|
|
|
|
|
def _set_article_kind(db, article_id: int, kind: str | None) -> None:
|
|
existing = db.get(HomeArticleKind, article_id)
|
|
if kind is None:
|
|
if existing is not None:
|
|
db.delete(existing)
|
|
return
|
|
if existing is None:
|
|
db.add(HomeArticleKind(article_id=article_id, kind=kind))
|
|
return
|
|
existing.kind = kind
|
|
|
|
|
|
def _prepare_article(db, payload: dict) -> tuple[dict, list, str | None]:
|
|
from app.core.config import get_settings
|
|
from app.models.content import AdminFile
|
|
from app.services.html_filter import filter_html
|
|
|
|
category = payload.get("category")
|
|
if category not in ARTICLE_CATEGORIES:
|
|
raise ValueError("文章类别不正确")
|
|
kind = _normalize_article_kind(category, payload)
|
|
image_id = payload.get("imageFileId")
|
|
image = None
|
|
if image_id is not None:
|
|
image = _public_file(db, image_id)
|
|
if image is None:
|
|
raise ValueError("配图必须是已上传的公开图片")
|
|
attachments = []
|
|
for file_id in payload.get("attachmentFileIds") or []:
|
|
row = _public_file(db, file_id)
|
|
if row is None:
|
|
raise ValueError("附件必须是已上传的公开文件")
|
|
attachments.append(row)
|
|
keys = list(db.scalars(select(AdminFile.object_key).where(AdminFile.bucket == "public")))
|
|
cleaned = filter_html(payload.get("bodyHtml") or "", list(keys), get_settings().minio_base_url)
|
|
fields = {
|
|
"category": category,
|
|
"title": "" if payload.get("title") is None else str(payload["title"]),
|
|
"body_html": cleaned,
|
|
"image_file_id": image.id if image else None,
|
|
"published": bool(payload.get("published", False)),
|
|
"published_at": _published_at(payload.get("publishedAt")),
|
|
}
|
|
return fields, attachments, kind
|
|
|
|
|
|
def _replace_article_attachments(db, article_id: int, attachments: list) -> None:
|
|
links = db.scalars(
|
|
select(HomeArticleAttachment).where(HomeArticleAttachment.article_id == article_id)
|
|
).all()
|
|
for link in links:
|
|
db.delete(link)
|
|
db.flush()
|
|
for index, row in enumerate(attachments):
|
|
db.add(HomeArticleAttachment(article_id=article_id, file_id=row.id, position=index))
|
|
|
|
|
|
def create_article(db, payload: dict) -> int:
|
|
fields, attachments, kind = _prepare_article(db, payload)
|
|
article = HomeArticle(**fields)
|
|
db.add(article)
|
|
db.flush()
|
|
_replace_article_attachments(db, article.id, attachments)
|
|
_set_article_kind(db, article.id, kind)
|
|
db.commit()
|
|
db.refresh(article)
|
|
return article.id
|
|
|
|
|
|
def update_article(db, article_id: int, payload: dict) -> None:
|
|
article = db.get(HomeArticle, article_id)
|
|
if article is None:
|
|
raise ValueError("文章不存在")
|
|
fields, attachments, kind = _prepare_article(db, payload)
|
|
for key, value in fields.items():
|
|
setattr(article, key, value)
|
|
_replace_article_attachments(db, article.id, attachments)
|
|
_set_article_kind(db, article.id, kind)
|
|
db.commit()
|
|
|
|
|
|
def public_article(db, article_id: int) -> dict | None:
|
|
from app.services.storage import public_object_url
|
|
|
|
row = db.get(HomeArticle, article_id)
|
|
if row is None or not row.published:
|
|
return None
|
|
image = _public_file(db, row.image_file_id) if row.image_file_id else None
|
|
links = db.scalars(
|
|
select(HomeArticleAttachment)
|
|
.where(HomeArticleAttachment.article_id == row.id)
|
|
.order_by(HomeArticleAttachment.position.asc(), HomeArticleAttachment.id.asc())
|
|
).all()
|
|
attachments = []
|
|
for link in links:
|
|
file_row = _public_file(db, link.file_id)
|
|
if file_row is None:
|
|
continue
|
|
attachments.append(
|
|
{"filename": file_row.filename, "url": public_object_url(file_row.object_key)}
|
|
)
|
|
body = {
|
|
"id": row.id,
|
|
"category": row.category,
|
|
"title": row.title,
|
|
"bodyHtml": row.body_html,
|
|
"attachments": attachments,
|
|
}
|
|
if image is not None:
|
|
body["imageUrl"] = public_object_url(image.object_key)
|
|
kinds = _article_kinds(db, [row.id])
|
|
article_kind = kinds.get(row.id)
|
|
if article_kind:
|
|
body["kind"] = article_kind
|
|
body["kindLabel"] = APPRAISAL_GUIDE_KIND_LABELS.get(article_kind, article_kind)
|
|
return body
|
|
|
|
|
|
def delete_article(db, article_id: int) -> None:
|
|
row = db.get(HomeArticle, article_id)
|
|
if row is None:
|
|
raise ValueError("文章不存在")
|
|
links = db.scalars(
|
|
select(HomeArticleAttachment).where(HomeArticleAttachment.article_id == row.id)
|
|
).all()
|
|
for link in links:
|
|
db.delete(link)
|
|
kind_row = db.get(HomeArticleKind, row.id)
|
|
if kind_row is not None:
|
|
db.delete(kind_row)
|
|
db.delete(row)
|
|
db.commit()
|
|
|
|
|
|
def _link_label(link: dict) -> str:
|
|
label = link.get("label")
|
|
if not isinstance(label, str) or not label.strip():
|
|
raise ValueError("链接文字不能为空")
|
|
return label.strip()
|
|
|
|
|
|
def _link_url(value) -> str:
|
|
from app.services.html_filter import is_javascript_url
|
|
|
|
if value is None:
|
|
return ""
|
|
if not isinstance(value, str):
|
|
raise ValueError("链接地址不正确")
|
|
text = value.strip()
|
|
if is_javascript_url(text):
|
|
raise ValueError("链接地址不正确")
|
|
return text
|
|
|
|
|
|
def _sort_order(link: dict) -> int:
|
|
value = link.get("sortOrder", 0)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError("顺序必须是整数")
|
|
return value
|
|
|
|
|
|
def _navigation(url: str) -> str:
|
|
if not url:
|
|
return "none"
|
|
if url.startswith("/") and not url.startswith("//"):
|
|
return "sameWindow"
|
|
return "newWindow"
|
|
|
|
|
|
def _public_link(row) -> dict:
|
|
return {"label": row.label, "url": row.url, "navigation": _navigation(row.url)}
|
|
|
|
|
|
def _public_audiences(db) -> dict:
|
|
rows = db.scalars(
|
|
select(HomeAudienceLink)
|
|
.where(HomeAudienceLink.visible.is_(True))
|
|
.order_by(HomeAudienceLink.sort_order.asc(), HomeAudienceLink.id.asc())
|
|
).all()
|
|
if not rows:
|
|
return {"placeholder": EMPTY_REGIONS["audiences"]}
|
|
grouped = {name: [] for name in AUDIENCE_NAMES}
|
|
for row in rows:
|
|
if row.category in grouped:
|
|
grouped[row.category].append(_public_link(row))
|
|
return {
|
|
"categories": [{"name": name, "links": grouped[name]} for name in AUDIENCE_NAMES]
|
|
}
|
|
|
|
|
|
def _public_hot_link(row: HomeHotLink) -> dict:
|
|
url = FIXED_HOT_URLS.get(row.key, row.url)
|
|
return {"label": row.label, "url": url, "navigation": _navigation(url)}
|
|
|
|
|
|
def _public_hot_links(db) -> dict:
|
|
rows = db.scalars(
|
|
select(HomeHotLink)
|
|
.where(HomeHotLink.visible.is_(True))
|
|
.order_by(HomeHotLink.sort_order.asc(), HomeHotLink.id.asc())
|
|
).all()
|
|
if not rows:
|
|
return {"placeholder": EMPTY_REGIONS["hotLinks"]}
|
|
return {"links": [_public_hot_link(row) for row in rows]}
|
|
|
|
|
|
def _shortcut_menu(db) -> dict:
|
|
rows = db.scalars(
|
|
select(HomeMenuItem)
|
|
.where(HomeMenuItem.visible.is_(True), HomeMenuItem.key.in_(MENU_KEYS))
|
|
.order_by(HomeMenuItem.sort_order.asc(), HomeMenuItem.id.asc())
|
|
).all()
|
|
items = [{"label": "首页", "url": "/", "navigation": "sameWindow"}]
|
|
items.extend(_public_link(row) for row in rows)
|
|
return {"items": items}
|
|
|
|
|
|
def save_audiences(db, categories: list[dict]) -> None:
|
|
names = [item.get("name") for item in categories]
|
|
if names != list(AUDIENCE_NAMES):
|
|
raise ValueError("服务对象类别必须是估价师、估价机构、公众")
|
|
prepared: list[HomeAudienceLink] = []
|
|
for category in categories:
|
|
for link in category.get("links") or []:
|
|
prepared.append(
|
|
HomeAudienceLink(
|
|
category=category["name"],
|
|
label=_link_label(link),
|
|
url=_link_url(link.get("url")),
|
|
visible=bool(link.get("visible", True)),
|
|
sort_order=_sort_order(link),
|
|
)
|
|
)
|
|
db.execute(delete(HomeAudienceLink))
|
|
db.add_all(prepared)
|
|
db.commit()
|
|
|
|
|
|
def save_hot_links(db, links: list[dict]) -> None:
|
|
if len(links) != len(HOT_LINK_SLOTS):
|
|
raise ValueError("高频功能必须是报告查询、报告审核和投诉举报")
|
|
seen: set[str] = set()
|
|
prepared: list[HomeHotLink] = []
|
|
for link in links:
|
|
key = link.get("key")
|
|
if key not in HOT_LINK_KEYS or key in seen:
|
|
raise ValueError("高频功能必须是报告查询、报告审核和投诉举报")
|
|
seen.add(key)
|
|
url = FIXED_HOT_URLS.get(key)
|
|
if url is None:
|
|
url = _link_url(link.get("url"))
|
|
prepared.append(
|
|
HomeHotLink(
|
|
key=key,
|
|
label=_link_label(link),
|
|
url=url,
|
|
visible=bool(link.get("visible", True)),
|
|
sort_order=_sort_order(link),
|
|
)
|
|
)
|
|
db.execute(delete(HomeHotLink))
|
|
db.add_all(prepared)
|
|
db.commit()
|
|
|
|
|
|
def ensure_hot_link_slots(db) -> None:
|
|
bind = db.get_bind()
|
|
if bind.dialect.name == "mysql":
|
|
from sqlalchemy import inspect
|
|
|
|
columns = {column["name"] for column in inspect(bind).get_columns("home_hot_link")}
|
|
if "key" not in columns:
|
|
db.execute(text("ALTER TABLE home_hot_link ADD COLUMN `key` VARCHAR(32) NOT NULL DEFAULT ''"))
|
|
db.commit()
|
|
label_to_key = {label: key for key, label, _url in HOT_LINK_SLOTS}
|
|
rows = db.scalars(select(HomeHotLink).order_by(HomeHotLink.sort_order.asc(), HomeHotLink.id.asc())).all()
|
|
used: set[str] = set()
|
|
for row in rows:
|
|
if row.key in HOT_LINK_KEYS:
|
|
if row.key in used:
|
|
db.delete(row)
|
|
continue
|
|
used.add(row.key)
|
|
if row.key in FIXED_HOT_URLS:
|
|
row.url = FIXED_HOT_URLS[row.key]
|
|
continue
|
|
mapped = label_to_key.get(row.label)
|
|
if mapped and mapped not in used:
|
|
row.key = mapped
|
|
if mapped in FIXED_HOT_URLS:
|
|
row.url = FIXED_HOT_URLS[mapped]
|
|
used.add(mapped)
|
|
continue
|
|
db.delete(row)
|
|
db.commit()
|
|
if bind.dialect.name != "mysql":
|
|
return
|
|
from sqlalchemy import inspect
|
|
|
|
indexes = inspect(bind).get_indexes("home_hot_link")
|
|
has_unique = any(item.get("unique") and "key" in (item.get("column_names") or []) for item in indexes)
|
|
if not has_unique:
|
|
db.execute(text("ALTER TABLE home_hot_link ADD UNIQUE INDEX uq_home_hot_link_key (`key`)"))
|
|
db.commit()
|
|
|
|
|
|
def save_menu(db, items: list[dict]) -> None:
|
|
seen: set[str] = set()
|
|
for item in items:
|
|
key = item.get("key")
|
|
if key not in MENU_KEYS or key in seen:
|
|
raise ValueError("快捷菜单只能配置会员服务")
|
|
seen.add(key)
|
|
row = db.scalar(select(HomeMenuItem).where(HomeMenuItem.key == key))
|
|
if row is None:
|
|
row = HomeMenuItem(key=key)
|
|
db.add(row)
|
|
row.label = _link_label(item)
|
|
row.url = _link_url(item.get("url"))
|
|
row.visible = bool(item.get("visible", False))
|
|
row.sort_order = _sort_order(item)
|
|
db.commit()
|
|
|
|
|
|
def retire_removed_shortcut_keys(db) -> None:
|
|
db.execute(delete(HomeMenuItem).where(HomeMenuItem.key.notin_(MENU_KEYS)))
|
|
db.commit()
|
|
|
|
|
|
def _interval(value) -> int:
|
|
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
raise ValueError("间隔秒数必须是正整数")
|
|
return value
|
|
|
|
|
|
def _image_id(db, file_id, message: str) -> int:
|
|
image = _public_file(db, file_id)
|
|
if image is None:
|
|
raise ValueError(message)
|
|
return image.id
|
|
|
|
|
|
def _store_interval(db, kind: str, seconds: int) -> None:
|
|
row = db.get(HomeMarquee, kind)
|
|
if row is None:
|
|
db.add(HomeMarquee(kind=kind, interval_seconds=seconds))
|
|
else:
|
|
row.interval_seconds = seconds
|
|
|
|
|
|
def _public_honors(db) -> dict:
|
|
from app.services.storage import public_object_url
|
|
|
|
rows = db.scalars(select(HomeHonor).order_by(HomeHonor.sort_order.asc(), HomeHonor.id.asc())).all()
|
|
items = []
|
|
for row in rows:
|
|
image = _public_file(db, row.image_file_id)
|
|
if image is None:
|
|
continue
|
|
items.append({"imageUrl": public_object_url(image.object_key)})
|
|
if not items:
|
|
return {"placeholder": EMPTY_REGIONS["honors"]}
|
|
setting = db.get(HomeMarquee, "honors")
|
|
return {"intervalSeconds": setting.interval_seconds if setting else 0, "items": items}
|
|
|
|
|
|
def _public_members(db) -> dict:
|
|
from app.services.storage import public_object_url
|
|
|
|
rows = db.scalars(
|
|
select(HomeMember).order_by(HomeMember.sort_order.asc(), HomeMember.id.asc())
|
|
).all()
|
|
items = []
|
|
for row in rows:
|
|
image = _public_file(db, row.image_file_id)
|
|
if image is None:
|
|
continue
|
|
items.append(
|
|
{
|
|
"name": row.name,
|
|
"url": row.url,
|
|
"navigation": _navigation(row.url),
|
|
"imageUrl": public_object_url(image.object_key),
|
|
}
|
|
)
|
|
if not items:
|
|
return {"placeholder": EMPTY_REGIONS["members"]}
|
|
setting = db.get(HomeMarquee, "members")
|
|
return {"intervalSeconds": setting.interval_seconds if setting else 0, "items": items}
|
|
|
|
|
|
def save_honors(db, interval_seconds: int, items: list[dict]) -> None:
|
|
seconds = _interval(interval_seconds)
|
|
prepared = [
|
|
HomeHonor(
|
|
image_file_id=_image_id(db, item.get("imageFileId"), "荣誉图片必须是已上传的公开图片"),
|
|
sort_order=_sort_order(item),
|
|
)
|
|
for item in items
|
|
]
|
|
db.execute(delete(HomeHonor))
|
|
db.add_all(prepared)
|
|
_store_interval(db, "honors", seconds)
|
|
db.commit()
|
|
|
|
|
|
def save_members(db, interval_seconds: int, items: list[dict]) -> None:
|
|
seconds = _interval(interval_seconds)
|
|
prepared = []
|
|
for item in items:
|
|
name = item.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ValueError("会员单位名称不能为空")
|
|
prepared.append(
|
|
HomeMember(
|
|
name=name.strip(),
|
|
url=_link_url(item.get("url")),
|
|
image_file_id=_image_id(db, item.get("imageFileId"), "会员标志必须是已上传的公开图片"),
|
|
sort_order=_sort_order(item),
|
|
)
|
|
)
|
|
db.execute(delete(HomeMember))
|
|
db.add_all(prepared)
|
|
_store_interval(db, "members", seconds)
|
|
db.commit()
|
|
|
|
|
|
def _edu_site_name(item: dict) -> str:
|
|
name = item.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ValueError("教育网站名称不能为空")
|
|
return name.strip()
|
|
|
|
|
|
def _edu_site_url(value) -> str:
|
|
text = _link_url(value)
|
|
if not text:
|
|
raise ValueError("教育网站地址不能为空")
|
|
if not text.lower().startswith(("http://", "https://")):
|
|
raise ValueError("教育网站地址不正确")
|
|
return text
|
|
|
|
|
|
def public_edu_platforms(db) -> dict:
|
|
from app.services.storage import public_object_url
|
|
|
|
rows = db.scalars(
|
|
select(EduPlatform).order_by(EduPlatform.sort_order.asc(), EduPlatform.id.asc())
|
|
).all()
|
|
items = []
|
|
for row in rows:
|
|
image = _public_file(db, row.image_file_id)
|
|
if image is None:
|
|
continue
|
|
items.append(
|
|
{
|
|
"name": row.name,
|
|
"url": row.url,
|
|
"navigation": "newWindow",
|
|
"imageUrl": public_object_url(image.object_key),
|
|
}
|
|
)
|
|
if not items:
|
|
return {"placeholder": EDU_PLATFORM_EMPTY}
|
|
return {"items": items}
|
|
|
|
|
|
def admin_edu_platforms(db) -> dict:
|
|
rows = db.scalars(
|
|
select(EduPlatform).order_by(EduPlatform.sort_order.asc(), EduPlatform.id.asc())
|
|
).all()
|
|
items = []
|
|
for row in rows:
|
|
image_id, image_url = _editor_image(db, row.image_file_id)
|
|
items.append(
|
|
{
|
|
"name": row.name,
|
|
"url": row.url,
|
|
"imageFileId": image_id,
|
|
"imageUrl": image_url,
|
|
"sortOrder": row.sort_order,
|
|
}
|
|
)
|
|
return {"items": items}
|
|
|
|
|
|
def save_edu_platforms(db, items: list[dict]) -> None:
|
|
prepared = [
|
|
EduPlatform(
|
|
name=_edu_site_name(item),
|
|
url=_edu_site_url(item.get("url")),
|
|
image_file_id=_image_id(db, item.get("imageFileId"), "教育网站图片必须是已上传的公开图片"),
|
|
sort_order=_sort_order(item),
|
|
)
|
|
for item in items
|
|
]
|
|
db.execute(delete(EduPlatform))
|
|
db.add_all(prepared)
|
|
db.commit()
|
|
|
|
|
|
def _coordinate(value, low: float, high: float, message: str) -> float | None:
|
|
if value is None or value == "":
|
|
return None
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError(message)
|
|
number = float(value)
|
|
if number < low or number > high:
|
|
raise ValueError(message)
|
|
return number
|
|
|
|
|
|
def _coordinate_pair(latitude, longitude) -> tuple[float | None, float | None]:
|
|
lat = _coordinate(latitude, -90, 90, "纬度不正确")
|
|
lng = _coordinate(longitude, -180, 180, "经度不正确")
|
|
if (lat is None) != (lng is None):
|
|
raise ValueError("纬度和经度需要同时填写")
|
|
return lat, lng
|
|
|
|
|
|
def staff_map_picker() -> dict | None:
|
|
from app.core.config import get_settings
|
|
|
|
settings = get_settings()
|
|
key = settings.gaode_key.strip()
|
|
secret = settings.gaode_security_js_code.strip()
|
|
if not key or not secret:
|
|
return None
|
|
return {"key": key, "securityJsCode": secret}
|
|
|
|
|
|
def _public_map(row) -> str | dict:
|
|
from app.core.config import get_settings
|
|
|
|
settings = get_settings()
|
|
key = settings.gaode_key.strip()
|
|
secret = settings.gaode_security_js_code.strip()
|
|
if row is None or row.latitude is None or row.longitude is None or not key or not secret:
|
|
return "地图位置整理中"
|
|
return {
|
|
"latitude": float(row.latitude),
|
|
"longitude": float(row.longitude),
|
|
"key": key,
|
|
"securityJsCode": secret,
|
|
}
|
|
|
|
|
|
def _public_contact(db, template_name: str) -> dict:
|
|
row = db.get(HomeContact, template_name)
|
|
return {
|
|
"address": _text(row.address if row else "", "办公地址整理中"),
|
|
"phone": _text(row.phone if row else "", "联系电话整理中"),
|
|
"email": _text(row.email if row else "", "联系邮箱整理中"),
|
|
"map": _public_map(row),
|
|
}
|
|
|
|
|
|
def _visible_links(db, model, template_name: str, category: str | None = None) -> list:
|
|
stmt = select(model).where(model.template_name == template_name, model.visible.is_(True))
|
|
if category is not None:
|
|
stmt = stmt.where(model.category == category)
|
|
stmt = stmt.order_by(model.sort_order.asc(), model.id.asc())
|
|
return list(db.scalars(stmt).all())
|
|
|
|
|
|
def _public_related(db, template_name: str) -> dict:
|
|
result = {}
|
|
for category in RELATED_CATEGORIES:
|
|
rows = _visible_links(db, HomeRelatedLink, template_name, category)
|
|
if not rows:
|
|
result[category] = {"placeholder": f"{category}链接整理中"}
|
|
else:
|
|
result[category] = {"links": [_public_link(row) for row in rows]}
|
|
return result
|
|
|
|
|
|
def _public_compliance(db, template_name: str) -> dict:
|
|
# 页脚固定三项,正文由 legal_page 管理;不再使用外链列表占位。
|
|
del db, template_name
|
|
from app.models.legal import public_compliance_links
|
|
|
|
return public_compliance_links()
|
|
|
|
|
|
def _template_names(items: list[dict]) -> None:
|
|
names = [item.get("name") for item in items]
|
|
if sorted(names) != sorted(TEMPLATE_NAMES):
|
|
raise ValueError("模板必须是科技蓝和LOGO蓝")
|
|
|
|
|
|
def save_contact(db, templates: list[dict]) -> None:
|
|
_template_names(templates)
|
|
for item in templates:
|
|
latitude, longitude = _coordinate_pair(item.get("latitude"), item.get("longitude"))
|
|
row = db.get(HomeContact, item["name"])
|
|
if row is None:
|
|
row = HomeContact(template_name=item["name"])
|
|
db.add(row)
|
|
row.address = "" if item.get("address") is None else str(item.get("address"))
|
|
row.phone = "" if item.get("phone") is None else str(item.get("phone"))
|
|
row.email = "" if item.get("email") is None else str(item.get("email"))
|
|
row.latitude = latitude
|
|
row.longitude = longitude
|
|
db.commit()
|
|
|
|
|
|
def _prepared_links(db, model, template_name: str, links: list[dict], category: str | None = None):
|
|
prepared = []
|
|
for link in links:
|
|
values = {
|
|
"template_name": template_name,
|
|
"label": _link_label(link),
|
|
"url": _link_url(link.get("url")),
|
|
"visible": bool(link.get("visible", True)),
|
|
"sort_order": _sort_order(link),
|
|
}
|
|
if category is not None:
|
|
values["category"] = category
|
|
prepared.append(model(**values))
|
|
return prepared
|
|
|
|
|
|
def save_related_links(db, template_name: str, categories: list[dict]) -> None:
|
|
if template_name not in TEMPLATE_NAMES:
|
|
raise ValueError("模板必须是科技蓝和LOGO蓝")
|
|
names = [item.get("name") for item in categories]
|
|
if names != list(RELATED_CATEGORIES):
|
|
raise ValueError("相关链接类别必须是主管部门、相关部门、合作协会")
|
|
prepared = []
|
|
for category in categories:
|
|
prepared.extend(
|
|
_prepared_links(db, HomeRelatedLink, template_name, category.get("links") or [], category["name"])
|
|
)
|
|
db.execute(delete(HomeRelatedLink).where(HomeRelatedLink.template_name == template_name))
|
|
db.add_all(prepared)
|
|
db.commit()
|
|
|
|
|
|
def save_compliance(db, template_name: str, links: list[dict]) -> None:
|
|
if template_name not in TEMPLATE_NAMES:
|
|
raise ValueError("模板必须是科技蓝和LOGO蓝")
|
|
prepared = _prepared_links(db, HomeComplianceLink, template_name, links)
|
|
db.execute(delete(HomeComplianceLink).where(HomeComplianceLink.template_name == template_name))
|
|
db.add_all(prepared)
|
|
db.commit()
|
|
|
|
|
|
def save_home(db, active_name: str, templates: list[dict]) -> None:
|
|
ensure_home_templates(db)
|
|
names = [item["name"] for item in templates]
|
|
if active_name not in TEMPLATE_NAMES or sorted(names) != sorted(TEMPLATE_NAMES):
|
|
raise ValueError("模板必须是科技蓝和LOGO蓝")
|
|
by_name = {item["name"]: item for item in templates}
|
|
rows = db.scalars(select(HomeTemplate)).all()
|
|
found = {row.name: row for row in rows}
|
|
for name in TEMPLATE_NAMES:
|
|
row = found[name]
|
|
incoming = by_name[name]
|
|
row.slogan = "" if incoming["slogan"] is None else str(incoming["slogan"])
|
|
row.value_proposition = (
|
|
"" if incoming["valueProposition"] is None else str(incoming["valueProposition"])
|
|
)
|
|
row.is_active = name == active_name
|
|
db.commit()
|
|
|
|
|
|
def _editor_attachments(db, links) -> list[dict]:
|
|
from app.services.storage import public_object_url
|
|
|
|
items = []
|
|
for link in links:
|
|
row = _public_file(db, link.file_id)
|
|
if row is None:
|
|
continue
|
|
items.append(
|
|
{
|
|
"id": row.id,
|
|
"filename": row.filename,
|
|
"url": public_object_url(row.object_key),
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def _editor_image(db, file_id: int | None) -> tuple[int | None, str]:
|
|
from app.services.storage import public_object_url
|
|
|
|
row = _public_file(db, file_id) if file_id else None
|
|
if row is None:
|
|
return None, ""
|
|
return row.id, public_object_url(row.object_key)
|
|
|
|
|
|
def _link_editor(row) -> dict:
|
|
return {
|
|
"label": row.label,
|
|
"url": row.url,
|
|
"visible": bool(row.visible),
|
|
"sortOrder": row.sort_order,
|
|
}
|
|
|
|
|
|
def _hot_link_editor(rows: list[HomeHotLink]) -> list[dict]:
|
|
present: list[dict] = []
|
|
seen: set[str] = set()
|
|
for row in rows:
|
|
if row.key not in HOT_LINK_KEYS or row.key in seen:
|
|
continue
|
|
seen.add(row.key)
|
|
present.append(
|
|
{
|
|
"key": row.key,
|
|
"label": row.label,
|
|
"url": FIXED_HOT_URLS.get(row.key, row.url),
|
|
"visible": bool(row.visible),
|
|
"sortOrder": row.sort_order,
|
|
"urlLocked": row.key in FIXED_HOT_URLS,
|
|
}
|
|
)
|
|
for index, (key, label, url) in enumerate(HOT_LINK_SLOTS):
|
|
if key in seen:
|
|
continue
|
|
present.append(
|
|
{
|
|
"key": key,
|
|
"label": label,
|
|
"url": url,
|
|
"visible": False,
|
|
"sortOrder": index,
|
|
"urlLocked": key in FIXED_HOT_URLS,
|
|
}
|
|
)
|
|
return present
|
|
|
|
|
|
def admin_home_view(db) -> dict:
|
|
ensure_home_templates(db)
|
|
templates = db.scalars(select(HomeTemplate).order_by(HomeTemplate.id.asc())).all()
|
|
active = next((row.name for row in templates if row.is_active), TECH)
|
|
slides = []
|
|
for row in db.scalars(select(HomeSlide).order_by(HomeSlide.sort_order.asc(), HomeSlide.id.asc())).all():
|
|
image_id, image_url = _editor_image(db, row.image_file_id)
|
|
attachments = db.scalars(
|
|
select(HomeSlideAttachment)
|
|
.where(HomeSlideAttachment.slide_id == row.id)
|
|
.order_by(HomeSlideAttachment.position.asc(), HomeSlideAttachment.id.asc())
|
|
).all()
|
|
files = _editor_attachments(db, attachments)
|
|
slides.append(
|
|
{
|
|
"id": row.id,
|
|
"title": row.title,
|
|
"imageFileId": image_id,
|
|
"imageUrl": image_url,
|
|
"sortOrder": row.sort_order,
|
|
"enabled": bool(row.enabled),
|
|
"intervalSeconds": row.interval_seconds,
|
|
"published": bool(row.published),
|
|
"bodyHtml": row.body_html,
|
|
"attachmentFileIds": [item["id"] for item in files],
|
|
"attachments": files,
|
|
}
|
|
)
|
|
articles = []
|
|
for row in db.scalars(
|
|
select(HomeArticle)
|
|
.where(HomeArticle.category.in_(HOME_ARTICLE_CATEGORIES))
|
|
.order_by(HomeArticle.published_at.desc(), HomeArticle.id.desc())
|
|
).all():
|
|
image_id, image_url = _editor_image(db, row.image_file_id)
|
|
attachments = db.scalars(
|
|
select(HomeArticleAttachment)
|
|
.where(HomeArticleAttachment.article_id == row.id)
|
|
.order_by(HomeArticleAttachment.position.asc(), HomeArticleAttachment.id.asc())
|
|
).all()
|
|
files = _editor_attachments(db, attachments)
|
|
articles.append(
|
|
{
|
|
"id": row.id,
|
|
"category": row.category,
|
|
"title": row.title,
|
|
"publishedAt": row.published_at.isoformat(timespec="seconds") if row.published_at else "",
|
|
"published": bool(row.published),
|
|
"bodyHtml": row.body_html,
|
|
"imageFileId": image_id,
|
|
"imageUrl": image_url,
|
|
"attachmentFileIds": [item["id"] for item in files],
|
|
"attachments": files,
|
|
}
|
|
)
|
|
audience_rows = db.scalars(
|
|
select(HomeAudienceLink).order_by(HomeAudienceLink.sort_order.asc(), HomeAudienceLink.id.asc())
|
|
).all()
|
|
grouped = {name: [] for name in AUDIENCE_NAMES}
|
|
for row in audience_rows:
|
|
if row.category in grouped:
|
|
grouped[row.category].append(_link_editor(row))
|
|
hot_rows = db.scalars(select(HomeHotLink).order_by(HomeHotLink.sort_order.asc(), HomeHotLink.id.asc())).all()
|
|
menu_rows = db.scalars(select(HomeMenuItem).order_by(HomeMenuItem.sort_order.asc(), HomeMenuItem.id.asc())).all()
|
|
honors = db.scalars(select(HomeHonor).order_by(HomeHonor.sort_order.asc(), HomeHonor.id.asc())).all()
|
|
members = db.scalars(select(HomeMember).order_by(HomeMember.sort_order.asc(), HomeMember.id.asc())).all()
|
|
honor_setting = db.get(HomeMarquee, "honors")
|
|
member_setting = db.get(HomeMarquee, "members")
|
|
contacts = []
|
|
for name in TEMPLATE_NAMES:
|
|
row = db.get(HomeContact, name)
|
|
contacts.append(
|
|
{
|
|
"name": name,
|
|
"address": row.address if row else "",
|
|
"phone": row.phone if row else "",
|
|
"email": row.email if row else "",
|
|
"latitude": float(row.latitude) if row and row.latitude is not None else None,
|
|
"longitude": float(row.longitude) if row and row.longitude is not None else None,
|
|
}
|
|
)
|
|
related = []
|
|
compliance = []
|
|
for name in TEMPLATE_NAMES:
|
|
categories = []
|
|
for category in RELATED_CATEGORIES:
|
|
rows = db.scalars(
|
|
select(HomeRelatedLink)
|
|
.where(HomeRelatedLink.template_name == name, HomeRelatedLink.category == category)
|
|
.order_by(HomeRelatedLink.sort_order.asc(), HomeRelatedLink.id.asc())
|
|
).all()
|
|
categories.append({"name": category, "links": [_link_editor(item) for item in rows]})
|
|
related.append({"templateName": name, "categories": categories})
|
|
rows = db.scalars(
|
|
select(HomeComplianceLink)
|
|
.where(HomeComplianceLink.template_name == name)
|
|
.order_by(HomeComplianceLink.sort_order.asc(), HomeComplianceLink.id.asc())
|
|
).all()
|
|
compliance.append({"templateName": name, "links": [_link_editor(item) for item in rows]})
|
|
return {
|
|
"activeTemplate": active,
|
|
"templates": [
|
|
{
|
|
"name": row.name,
|
|
"slogan": row.slogan,
|
|
"valueProposition": row.value_proposition,
|
|
}
|
|
for row in templates
|
|
if row.name in TEMPLATE_NAMES
|
|
],
|
|
"slides": slides,
|
|
"articleDisplay": {key: _display_count(db, key) for key in HOME_ARTICLE_CATEGORIES},
|
|
"articles": articles,
|
|
"audiences": {
|
|
"categories": [{"name": name, "links": grouped[name]} for name in AUDIENCE_NAMES]
|
|
},
|
|
"hotLinks": {"links": _hot_link_editor(hot_rows)},
|
|
"menu": {
|
|
"items": [
|
|
{
|
|
"key": row.key,
|
|
"label": row.label,
|
|
"url": row.url,
|
|
"visible": bool(row.visible),
|
|
"sortOrder": row.sort_order,
|
|
}
|
|
for row in menu_rows
|
|
if row.key in MENU_KEYS
|
|
]
|
|
},
|
|
"honors": {
|
|
"intervalSeconds": honor_setting.interval_seconds if honor_setting else 8,
|
|
"items": [
|
|
{
|
|
"imageFileId": _editor_image(db, row.image_file_id)[0],
|
|
"imageUrl": _editor_image(db, row.image_file_id)[1],
|
|
"sortOrder": row.sort_order,
|
|
}
|
|
for row in honors
|
|
],
|
|
},
|
|
"members": {
|
|
"intervalSeconds": member_setting.interval_seconds if member_setting else 8,
|
|
"items": [
|
|
{
|
|
"name": row.name,
|
|
"url": row.url,
|
|
"imageFileId": _editor_image(db, row.image_file_id)[0],
|
|
"imageUrl": _editor_image(db, row.image_file_id)[1],
|
|
"sortOrder": row.sort_order,
|
|
}
|
|
for row in members
|
|
],
|
|
},
|
|
"contacts": contacts,
|
|
"relatedLinks": related,
|
|
"compliance": compliance,
|
|
"mapPicker": staff_map_picker(),
|
|
}
|