fix(site): 精简站点配置,移除名称/Banner/相关链接 (#15)

fix(site): 精简站点配置,移除名称/Banner/相关链接 (#15)

Merged PR #16
This commit was merged in pull request #16.
This commit is contained in:
cykj committed 2026-11-10 09:56:31 +00:00
commit 73237eff6f
8 files changed
+24 -238

No files matched your search

-8
View File
@@ -173,18 +173,10 @@ export interface AdminFileQuery extends PageQuery {
q?: string
}
export interface FriendLink {
name: string
url: string
}
export interface SiteSettings {
site_name?: string | null
logo_file_id?: number | null
banner_file_ids?: number[] | null
icp_text?: string | null
police_text?: string | null
friend_links?: FriendLink[] | null
link_cirea?: string | null
link_housing?: string | null
complaint_workdays?: number | null
+1 -146
View File
@@ -3,9 +3,6 @@
<div>
<ElCard v-loading="loading">
<ElForm label-width="140px" class="max-w-3xl">
<ElFormItem label="站点名称">
<ElInput v-model="form.site_name" :disabled="!canEdit" />
</ElFormItem>
<ElFormItem label="LOGO">
<div class="flex items-center gap-3">
<ElImage
@@ -24,62 +21,12 @@
<ElProgress v-if="logoUploading" class="mt-2 max-w-xs" :percentage="logoPercent" />
<input ref="logoInput" class="hidden" type="file" accept="image/*" @change="onLogo" />
</ElFormItem>
<ElFormItem label="Banner">
<div class="flex flex-wrap gap-3">
<div v-for="item in banners" :key="item.id" class="relative">
<ElImage :src="item.url" fit="cover" class="w-40 h-20 border border-g-300 rounded" />
<ElButton
v-if="canEdit"
class="absolute top-1 right-1"
circle
size="small"
type="danger"
@click="removeBanner(item.id)"
>
×
</ElButton>
</div>
<ElButton v-auth="'site:edit'" :loading="bannerUploading" @click="pickBanner"
>上传</ElButton
>
</div>
<ElProgress v-if="bannerUploading" class="mt-2 max-w-xs" :percentage="bannerPercent" />
<input
ref="bannerInput"
class="hidden"
type="file"
accept="image/*"
multiple
@change="onBanner"
/>
</ElFormItem>
<ElFormItem label="ICP 备案号">
<ElInput v-model="form.icp_text" :disabled="!canEdit" />
</ElFormItem>
<ElFormItem label="公安备案号">
<ElInput v-model="form.police_text" :disabled="!canEdit" />
</ElFormItem>
<ElFormItem label="相关链接">
<div class="w-full flex flex-col gap-2">
<div v-for="(link, index) in form.friend_links" :key="index" class="flex gap-2">
<ElInput v-model="link.name" placeholder="名称" :disabled="!canEdit" />
<ElInput v-model="link.url" placeholder="地址" :disabled="!canEdit" />
<ElButton
v-if="canEdit"
link
type="danger"
@click="form.friend_links.splice(index, 1)"
>
删除
</ElButton>
</div>
<div>
<ElButton v-if="canEdit" @click="form.friend_links.push({ name: '', url: '' })">
添加链接
</ElButton>
</div>
</div>
</ElFormItem>
<ElFormItem label="中房学">
<ElInput v-model="form.link_cirea" placeholder="外链地址" :disabled="!canEdit" />
</ElFormItem>
@@ -127,7 +74,7 @@
fetchUpdateSiteSettings,
uploadAdminFile
} from '@/api/system-manage'
import type { FriendLink, SiteSettings } from '@/types/admin'
import type { SiteSettings } from '@/types/admin'
defineOptions({ name: 'SiteSetting' })
@@ -136,21 +83,14 @@
const loading = ref(false)
const saving = ref(false)
const logoUploading = ref(false)
const bannerUploading = ref(false)
const logoPercent = ref(0)
const bannerPercent = ref(0)
const logoUrl = ref('')
const banners = ref<{ id: number; url: string }[]>([])
const logoInput = ref<HTMLInputElement>()
const bannerInput = ref<HTMLInputElement>()
const form = reactive({
site_name: '',
logo_file_id: null as number | null,
banner_file_ids: [] as number[],
icp_text: '',
police_text: '',
friend_links: [] as FriendLink[],
link_cirea: '',
link_housing: '',
complaint_workdays: null as number | null,
@@ -166,38 +106,6 @@
return null
}
const asIdList = (value: unknown): number[] => {
let source = value
if (typeof value === 'string' && value.trim()) {
try {
source = JSON.parse(value)
} catch {
source = []
}
}
if (!Array.isArray(source)) return []
return source.map((item) => asNumber(item)).filter((item): item is number => item !== null)
}
const asLinks = (value: unknown): FriendLink[] => {
let source = value
if (typeof value === 'string' && value.trim()) {
try {
source = JSON.parse(value)
} catch {
source = []
}
}
if (!Array.isArray(source)) return []
return source.map((item) => {
const row = item && typeof item === 'object' ? (item as Record<string, unknown>) : {}
return {
name: asText(row.name),
url: asText(row.url || row.address)
}
})
}
const asSettings = (payload: unknown): Record<string, unknown> => {
if (Array.isArray(payload)) return listToMap(payload)
if (!payload || typeof payload !== 'object') return {}
@@ -232,29 +140,15 @@
loading.value = true
try {
const raw = asSettings(await fetchGetSiteSettings())
form.site_name = asText(raw.site_name)
form.logo_file_id = asNumber(raw.logo_file_id)
form.banner_file_ids = asIdList(raw.banner_file_ids)
form.icp_text = asText(raw.icp_text)
form.police_text = asText(raw.police_text)
form.friend_links = asLinks(raw.friend_links)
form.link_cirea = asText(raw.link_cirea)
form.link_housing = asText(raw.link_housing)
form.complaint_workdays = asNumber(raw.complaint_workdays)
form.expert_reply_workdays = asNumber(raw.expert_reply_workdays)
const directLogo = asText(raw.logoUrl)
logoUrl.value = directLogo || (form.logo_file_id ? await fileUrl(form.logo_file_id) : '')
const bannerUrls = Array.isArray(raw.bannerUrls)
? raw.bannerUrls.map((item) => asText(item))
: []
if (bannerUrls.length === form.banner_file_ids.length) {
banners.value = form.banner_file_ids.map((id, index) => ({ id, url: bannerUrls[index] }))
} else {
banners.value = []
for (const id of form.banner_file_ids) {
banners.value.push({ id, url: await fileUrl(id) })
}
}
} finally {
loading.value = false
}
@@ -263,7 +157,6 @@
onMounted(load)
const pickLogo = () => logoInput.value?.click()
const pickBanner = () => bannerInput.value?.click()
const onLogo = async (event: Event) => {
const input = event.target as HTMLInputElement
@@ -285,54 +178,16 @@
}
}
const onBanner = async (event: Event) => {
const input = event.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
if (!files.length) return
bannerUploading.value = true
try {
for (const file of files) {
bannerPercent.value = 0
const saved = await uploadAdminFile(file, 'public', (percent) => {
bannerPercent.value = percent
})
form.banner_file_ids.push(saved.id)
banners.value.push({ id: saved.id, url: saved.url || '' })
}
} catch {
// 失败文案由接口层展示
} finally {
bannerUploading.value = false
}
}
const clearLogo = () => {
form.logo_file_id = null
logoUrl.value = ''
}
const removeBanner = (id: number) => {
form.banner_file_ids = form.banner_file_ids.filter((item) => item !== id)
banners.value = banners.value.filter((item) => item.id !== id)
}
const save = async () => {
const links = form.friend_links.map((item) => ({
name: item.name.trim(),
url: item.url.trim()
}))
if (links.some((item) => (item.name && !item.url) || (!item.name && item.url))) {
ElMessage.warning('相关链接需要同时填写名称和地址')
return
}
const payload: Partial<SiteSettings> = {
site_name: form.site_name,
logo_file_id: form.logo_file_id,
banner_file_ids: form.banner_file_ids,
icp_text: form.icp_text,
police_text: form.police_text,
friend_links: links.filter((item) => item.name && item.url),
link_cirea: form.link_cirea.trim(),
link_housing: form.link_housing.trim(),
complaint_workdays: form.complaint_workdays,
+5 -8
View File
@@ -35,8 +35,8 @@
### 站点配置
16. 可维护站点名称、LOGO、Banner、页脚备案号、相关链接。
17. 中房学、住建厅只保存外链地址,不做数据对接。
16. 可维护 LOGO、页脚备案号。
17. 中房学、住建厅只保存外链地址,不做数据对接。站点名称、Banner、相关链接不在本模块维护(首页轮播与页脚相关链接另管)。
18. 投诉处理时限、专家回复时限做成配置项,单位为工作日,不写死在代码里。
19. 门户以后通过公开接口读取已保存的配置。本批不建设门户页面。
@@ -73,7 +73,7 @@
| 操作日志 | `views/system/audit-log` | 按时间、用户检索。只读 |
| 登录安全 | `views/system/login-security` | 失败次数、锁定分钟数、IP 黑名单 |
| 附件 | `views/system/file` | 选择公开或私有、上传、删除、下载。富文本上传走同一套接口 |
| 站点配置 | `views/system/site` | 名称、LOGO、Banner、备案号、外链、时限 |
| 站点配置 | `views/system/site` | LOGO、备案号、外链、时限 |
| 敏感词 | `views/system/sensitive-word` | 词库维护,以及一段 HTML 的过滤预览 |
用户弹窗里的角色下拉改为接口数据,不再读 `ROLE_LIST_DATA`。角色权限树改为保存到后端,去掉「获取选中数据」这类演示按钮。个人中心只保留修改自己的密码。
@@ -175,12 +175,9 @@
| key | 含义 |
|---|---|
| site_name | 站点名称 |
| logo_file_id | LOGO,对应公开附件 |
| banner_file_ids | Banner 图片 id 列表 |
| icp_text | 陇ICP备19001227号 |
| police_text | 甘公网安备62010202001984号 |
| friend_links | 相关链接,每项含名称和地址 |
| link_cirea | 中房学外链 |
| link_housing | 住建厅外链 |
| complaint_workdays | 投诉处理时限,工作日 |
@@ -271,7 +268,7 @@
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/api/site/settings` | 返回站点名称、LOGO、Banner、备案号、相关链接、中房学外链、住建厅外链。不返回上传上限和登录锁定参数 |
| GET | `/api/site/settings` | 返回 LOGO、备案号、中房学外链、住建厅外链。不返回上传上限和登录锁定参数 |
登录失败、验证码错误、账号停用或注销、账号锁定,都返回「用户名或密码错误」,不说明具体原因。验证码错误时同样增加失败次数,避免用验证码接口探测账号。
@@ -304,5 +301,5 @@
附件、配置、过滤:
13. 上传超过上限的文件被拒绝。公开图片能直接访问。私有文件的链接过期后不能访问。
14. 修改站点名称、备案号和一条外链后,`/api/site/settings` 返回新值。响应里没有登录锁定参数。
14. 修改备案号和一条外链后,`/api/site/settings` 返回新值。响应里没有登录锁定参数,也不再包含站点名称、Banner、相关链接。
15. 过滤预览会去掉脚本和事件属性,并标出已录入的敏感词。预览结果不产生可被门户读取的文章。
+1 -42
View File
@@ -1,5 +1,3 @@
import json
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
from pydantic import BaseModel
from sqlalchemy import func, select
@@ -28,7 +26,7 @@ from app.services.storage import (
router = APIRouter(dependencies=[Depends(require_staff)])
_TEXT_KEYS = {"site_name", "icp_text", "police_text", "link_cirea", "link_housing"}
_TEXT_KEYS = {"icp_text", "police_text", "link_cirea", "link_housing"}
_LINK_KEYS = {"link_cirea", "link_housing"}
_POSITIVE = {
"upload_max_bytes": "上传大小上限必须是正整数",
@@ -97,41 +95,6 @@ def _logo_id(value) -> str:
return str(int(text))
def _banner_ids(value) -> str:
if not isinstance(value, list):
_bad("banner_file_ids 必须是数组")
numbers: list[int] = []
for item in value:
if isinstance(item, bool) or not isinstance(item, (int, str)):
_bad("banner_file_ids 必须是附件编号")
text = str(item).strip()
if not text.isdigit() or int(text) <= 0:
_bad("banner_file_ids 必须是附件编号")
numbers.append(int(text))
return json.dumps(numbers, separators=(",", ":"))
def _friend_links(value) -> str:
if not isinstance(value, list):
_bad("friend_links 必须是数组")
cleaned: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
_bad("相关链接必须包含名称和地址")
name = item.get("name")
url = item.get("url")
if not isinstance(name, str) or not isinstance(url, str):
_bad("相关链接必须包含名称和地址")
name = name.strip()
url = url.strip()
if not name or not url:
_bad("相关链接必须包含名称和地址")
if is_javascript_url(url):
_bad("链接地址不正确")
cleaned.append({"name": name, "url": url})
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
def _positive(value, message: str) -> str:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > _INT_MAX:
_bad(message)
@@ -155,10 +118,6 @@ def _normalize_settings(payload: dict) -> dict[str, str]:
stored[key] = _text_setting(key, value)
elif key == "logo_file_id":
stored[key] = _logo_id(value)
elif key == "banner_file_ids":
stored[key] = _banner_ids(value)
elif key == "friend_links":
stored[key] = _friend_links(value)
elif key in _POSITIVE:
stored[key] = _positive(value, _POSITIVE[key])
elif key in _WORKDAYS:
-3
View File
@@ -12,12 +12,9 @@ def read_public_settings(db: Session = Depends(get_db)) -> dict:
# 只返回门户展示字段。上传上限和登录锁定参数留在管理端。
data = settings_view(db)
return {
"siteName": data["site_name"],
"logoUrl": data["logoUrl"],
"bannerUrls": data["bannerUrls"],
"icpText": data["icp_text"],
"policeText": data["police_text"],
"friendLinks": data["friend_links"],
"linkCirea": data["link_cirea"],
"linkHousing": data["link_housing"],
}
-15
View File
@@ -1,4 +1,3 @@
import json
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String, Text, select
@@ -11,12 +10,9 @@ def _mysql_args() -> dict:
return {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
DEFAULTS: dict[str, str] = {
"site_name": "甘肃省房地产价格评估行业协会",
"logo_file_id": "",
"banner_file_ids": "[]",
"icp_text": "陇ICP备19001227号",
"police_text": "甘公网安备62010202001984号",
"friend_links": "[]",
"link_cirea": "",
"link_housing": "",
"complaint_workdays": "",
@@ -27,7 +23,6 @@ DEFAULTS: dict[str, str] = {
}
SETTING_KEYS = tuple(DEFAULTS)
_JSON_KEYS = {"banner_file_ids", "friend_links"}
_NUMBER_KEYS = {"upload_max_bytes", "login_max_failures", "login_lock_minutes"}
_OPTIONAL_NUMBER_KEYS = {"complaint_workdays", "expert_reply_workdays"}
@@ -109,14 +104,6 @@ def save_settings(db, values: dict[str, str]) -> None:
def _present(key: str, raw: str):
if key in _JSON_KEYS:
if not raw:
return []
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return []
return parsed if isinstance(parsed, list) else []
if key in _NUMBER_KEYS:
text = str(raw).strip()
if text.isdigit():
@@ -160,6 +147,4 @@ def settings_view(db) -> dict:
logo_id = raw.get("logo_file_id", "")
logo_urls = _public_urls(db, [logo_id]) if str(logo_id).strip() else []
view["logoUrl"] = logo_urls[0] if logo_urls else ""
banners = view["banner_file_ids"] if isinstance(view["banner_file_ids"], list) else []
view["bannerUrls"] = _public_urls(db, banners)
return view
+4 -3
View File
@@ -301,7 +301,6 @@ def main() -> None:
"/api/admin/site-settings",
headers=headers,
json={
"site_name": "验收站点",
"icp_text": "陇ICP备19001227号",
"link_cirea": "https://www.cirea.org.cn/",
},
@@ -310,10 +309,12 @@ def main() -> None:
fail(f"site {site.status_code} {site.text[:200]}")
public_site = client.get("/api/site/settings")
public_body = public_site.json()
if public_body.get("siteName") != "验收站点" or public_body.get("linkCirea") != "https://www.cirea.org.cn/":
if public_body.get("icpText") != "陇ICP备19001227号" or public_body.get("linkCirea") != "https://www.cirea.org.cn/":
fail(f"public site {public_body}")
if "loginMaxFailures" in public_body or "login_max_failures" in public_body:
fail("public site leaked lock settings")
if "siteName" in public_body or "bannerUrls" in public_body or "friendLinks" in public_body:
fail(f"public site kept removed keys {public_body}")
probe_word = f"验收敏感词{uuid.uuid4().hex[:6]}"
word = client.post("/api/admin/sensitive-words", headers=headers, json={"word": probe_word})
@@ -340,7 +341,7 @@ def main() -> None:
client.put(
"/api/admin/site-settings",
headers=headers,
json={"site_name": "甘肃省房地产价格评估行业协会", "link_cirea": ""},
json={"link_cirea": ""},
)
db = SessionLocal()
+13 -13
View File
@@ -13,12 +13,9 @@ from app.core.config import get_settings
from app.services.html_filter import filter_html
_PUBLIC_KEYS = {
"siteName",
"logoUrl",
"bannerUrls",
"icpText",
"policeText",
"friendLinks",
"linkCirea",
"linkHousing",
}
@@ -224,7 +221,7 @@ def test_admin_content_requires_login(api_client):
("get", "/api/admin/files/1", None),
("get", "/api/admin/site-settings", None),
("get", "/api/admin/sensitive-words", None),
("put", "/api/admin/site-settings", {"site_name": "x"}),
("put", "/api/admin/site-settings", {"icp_text": "x"}),
("post", "/api/admin/sensitive-words", {"word": "x"}),
("post", "/api/admin/content/preview-filter", {"html": "<p>a</p>"}),
("delete", "/api/admin/sensitive-words/1", None),
@@ -309,8 +306,6 @@ def test_public_settings_hide_login_lock(ready_client):
assert isinstance(original["login_max_failures"], int)
assert isinstance(original["login_lock_minutes"], int)
assert isinstance(original["logoUrl"], str)
assert isinstance(original["bannerUrls"], list)
link = {"name": "示例链接", "url": "https://example.com/gsrea"}
try:
rejected = ready_client.put(
"/api/admin/site-settings",
@@ -318,12 +313,17 @@ def test_public_settings_hide_login_lock(ready_client):
headers=headers,
)
assert rejected.status_code == 400
unknown = ready_client.put(
"/api/admin/site-settings",
json={"site_name": "甘肃价协测试站点"},
headers=headers,
)
assert unknown.status_code == 400
saved = ready_client.put(
"/api/admin/site-settings",
json={
"site_name": "甘肃价协测试站点",
"icp_text": "陇ICP备测试号",
"friend_links": [link],
"link_cirea": "https://example.com/cirea",
},
headers=headers,
)
@@ -332,11 +332,9 @@ def test_public_settings_hide_login_lock(ready_client):
assert response.status_code == 200, response.text
body = response.json()
assert set(body) == _PUBLIC_KEYS
assert body["siteName"] == "甘肃价协测试站点"
assert body["icpText"] == "陇ICP备测试号"
assert body["friendLinks"] == [link]
assert body["linkCirea"] == "https://example.com/cirea"
assert isinstance(body["logoUrl"], str)
assert isinstance(body["bannerUrls"], list)
text = response.text
assert "login_max_failures" not in text
assert "login_lock_minutes" not in text
@@ -344,13 +342,15 @@ def test_public_settings_hide_login_lock(ready_client):
assert "loginLockMinutes" not in text
assert "upload_max_bytes" not in text
assert "uploadMaxBytes" not in text
assert "siteName" not in text
assert "bannerUrls" not in text
assert "friendLinks" not in text
finally:
ready_client.put(
"/api/admin/site-settings",
json={
"site_name": original["site_name"],
"icp_text": original["icp_text"],
"friend_links": original["friend_links"],
"link_cirea": original["link_cirea"],
},
headers=headers,
)