feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。
|
||||
用法: python3 cms_fill_image.py <mapping.json> <result.json>
|
||||
mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...]
|
||||
"""
|
||||
import os, json, sys, subprocess, time, urllib.request, urllib.error
|
||||
|
||||
TOKEN = os.environ.get("TOKEN", "")
|
||||
UPLOAD = "https://server.websoft.top/api/file/upload"
|
||||
CMS = "https://cms-api.websoft.top/api"
|
||||
FILE_BASE = "https://file.websoft.top/api/file/"
|
||||
HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN}
|
||||
|
||||
|
||||
def _retry(fn, tries=4, sleep=2):
|
||||
last = None
|
||||
for i in range(tries):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
last = e
|
||||
time.sleep(sleep * (i + 1))
|
||||
raise last
|
||||
|
||||
|
||||
def upload(local_path: str) -> str:
|
||||
def _do():
|
||||
out = subprocess.run(
|
||||
["curl", "-s", "-m", "60", "-X", "POST", UPLOAD,
|
||||
"-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}",
|
||||
"-F", f"file=@{local_path}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
try:
|
||||
d = json.loads(out.stdout)
|
||||
except Exception:
|
||||
raise RuntimeError(f"upload non-json: {out.stdout[:200]}")
|
||||
if d.get("code") != 0:
|
||||
raise RuntimeError(f"upload code={d.get('code')} msg={d.get('message')} err={str(d.get('error'))[:120]}")
|
||||
p = (d.get("data") or {}).get("path", "").lstrip("/")
|
||||
if not p:
|
||||
raise RuntimeError("upload returned empty path")
|
||||
return FILE_BASE + p
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def get_item(module: str, id_: int):
|
||||
def _do():
|
||||
url = f"{CMS}/cms/{module}/{id_}"
|
||||
req = urllib.request.Request(url, headers=HDR)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())["data"]
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()[:300]
|
||||
raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}")
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def put_item(module: str, obj: dict):
|
||||
def _do():
|
||||
url = f"{CMS}/cms/{module}"
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(obj).encode(),
|
||||
headers={**HDR, "Content-Type": "application/json"}, method="PUT",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
return _retry(_do)
|
||||
|
||||
|
||||
def main():
|
||||
mapping = json.load(open(sys.argv[1]))
|
||||
result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json"
|
||||
results = []
|
||||
for it in mapping:
|
||||
mod, iid, fpath = it["module"], it["id"], it["file"]
|
||||
rec = {"module": mod, "id": iid}
|
||||
try:
|
||||
url = upload(fpath)
|
||||
obj = get_item(mod, iid)
|
||||
obj["image"] = url
|
||||
out = put_item(mod, obj)
|
||||
rec["url"] = url
|
||||
rec["put_code"] = out.get("code")
|
||||
rec["ok"] = out.get("code") == 0
|
||||
print(f"[{mod} {iid}] upload OK -> PUT code={out.get('code')} {'OK' if rec['ok'] else 'FAIL'} | {url[:70]}")
|
||||
except Exception as e:
|
||||
rec["error"] = str(e)[:300]
|
||||
print(f"[{mod} {iid}] ERROR: {e}")
|
||||
results.append(rec)
|
||||
time.sleep(1)
|
||||
json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2)
|
||||
print(f"\n=== done {len(results)} items, saved {result_path} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
if (typeof crypto.hash !== 'function') {
|
||||
crypto.hash = (algorithm, data, outputEncoding) => {
|
||||
const hash = crypto.createHash(algorithm)
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
hash.update(Buffer.from(data))
|
||||
} else if (ArrayBuffer.isView(data)) {
|
||||
hash.update(Buffer.from(data.buffer, data.byteOffset, data.byteLength))
|
||||
} else {
|
||||
hash.update(data)
|
||||
}
|
||||
|
||||
return hash.digest(outputEncoding)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CMS 内容批量发布脚本
|
||||
* ============================================================================
|
||||
* 背景(2026-08-05):
|
||||
* website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入
|
||||
* status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线,
|
||||
* C 端 website-template(server/api/{article,product,case}/list.get.ts)只查
|
||||
* status=0,于是后台明明「已发布」,官网却一条都不显示。
|
||||
*
|
||||
* 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。
|
||||
*
|
||||
* 用法:
|
||||
* # 1) 先空跑,只打印将要修改的内容,不写任何数据
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* # 2) 确认无误后真正执行
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 APPLY=1 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* # 只处理文章
|
||||
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 MODULES=article APPLY=1 node scripts/publish-cms-content.mjs
|
||||
*
|
||||
* token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization
|
||||
* (连同 "Bearer " 前缀一起复制即可,脚本会自动补全)
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.CMS_API_BASE || 'https://cms-api.websoft.top/api'
|
||||
const TOKEN_RAW = process.env.ADMIN_TOKEN || ''
|
||||
const TENANT_ID = process.env.TENANT_ID || '10546'
|
||||
const APPLY = process.env.APPLY === '1'
|
||||
const MODULES = (process.env.MODULES || 'article,product,case')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
/** CMS 权威语义:0=已发布 / 在售(C 端可见) */
|
||||
const PUBLISHED = 0
|
||||
|
||||
/** 各模块的接口路径与主键字段 */
|
||||
const MODULE_CONF = {
|
||||
article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' },
|
||||
product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' },
|
||||
case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' }
|
||||
}
|
||||
|
||||
if (!TOKEN_RAW) {
|
||||
console.error('❌ 缺少 ADMIN_TOKEN。')
|
||||
console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}`
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: TOKEN,
|
||||
TenantId: String(TENANT_ID)
|
||||
}
|
||||
|
||||
async function fetchList(conf) {
|
||||
const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200`
|
||||
const res = await fetch(url, { headers })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!json || json.code !== 0) {
|
||||
throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`)
|
||||
}
|
||||
return json.data?.list || []
|
||||
}
|
||||
|
||||
async function updateStatus(conf, id) {
|
||||
const url = `${API_BASE}/cms/${conf.path}/status`
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED })
|
||||
})
|
||||
const text = await res.text()
|
||||
let json = null
|
||||
try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ }
|
||||
return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`)
|
||||
console.log('─'.repeat(70))
|
||||
|
||||
let totalPending = 0
|
||||
let totalOk = 0
|
||||
let totalFail = 0
|
||||
|
||||
for (const mod of MODULES) {
|
||||
const conf = MODULE_CONF[mod]
|
||||
if (!conf) {
|
||||
console.log(`跳过未知模块 ${mod}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let list
|
||||
try {
|
||||
list = await fetchList(conf)
|
||||
} catch (e) {
|
||||
console.log(`\n[${mod}] 拉取失败:${e.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pending = list.filter((it) => Number(it.status) !== PUBLISHED)
|
||||
console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`)
|
||||
totalPending += pending.length
|
||||
|
||||
for (const it of pending) {
|
||||
const id = it[conf.idKey] ?? it.id
|
||||
const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30)
|
||||
if (!APPLY) {
|
||||
console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`)
|
||||
continue
|
||||
}
|
||||
const r = await updateStatus(conf, id)
|
||||
if (r.ok) {
|
||||
totalOk++
|
||||
console.log(` ✓ ${id} → 已发布 | ${title}`)
|
||||
} else {
|
||||
totalFail++
|
||||
console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(70))
|
||||
if (APPLY) {
|
||||
console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`)
|
||||
console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。')
|
||||
} else {
|
||||
console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`)
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('执行出错:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# OSS 上传脚本(汇吉采官网 / 模板 07)
|
||||
# 把本地图片上传到阿里云 OSS,打印可直接粘贴到后台的 URL
|
||||
#
|
||||
# 关键发现(2026-07-30 实测):
|
||||
# /api/oss/upload 是 multipart 文件上传,必填:
|
||||
# - Authorization: Bearer <token> (admin JWT)
|
||||
# - tenantId: <租户ID> ← 最关键的请求头!缺它会报 "传参错误"
|
||||
# - form 字段 file=@<图片> (application/octet-stream 也行,但 multipart 最稳)
|
||||
# 其余 module/bizType/path 等字段都是可选的,不传也能成功。
|
||||
# 返回 data.path / data.url 即访问地址(url 带 ?x-oss-process 缩略参数)。
|
||||
#
|
||||
# 用法:
|
||||
# ADMIN_TOKEN="eyJ..." bash scripts/upload-to-oss.sh
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# ===== 1. 配置 =====
|
||||
ADMIN_TOKEN="${ADMIN_TOKEN:-}"
|
||||
TENANT_ID="${TENANT_ID:-10626}" # 当前租户 10626(token 的 subject.tenantId)
|
||||
UPLOAD_URL="https://server.websoft.top/api/oss/upload"
|
||||
|
||||
# 要上传的文件
|
||||
FILES=(
|
||||
"/Users/gxwebsoft/VUE/website-template/outputs/01-about-company-team.jpg"
|
||||
"/Users/gxwebsoft/VUE/website-template/outputs/02-news-zoujinhuiicai-juyun.jpg"
|
||||
)
|
||||
|
||||
# ===== 2. 校验 token =====
|
||||
if [ -z "$ADMIN_TOKEN" ]; then
|
||||
echo "❌ 请设置 ADMIN_TOKEN:"
|
||||
echo " ADMIN_TOKEN=\"eyJ...\" bash scripts/upload-to-oss.sh"
|
||||
echo " token 从 admin 后台 DevTools → Network → 任意 XHR → Request Headers → Authorization: Bearer xxx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ===== 3. 循环上传(tenantId 请求头是必填)=====
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "⚠️ 文件不存在: $f"
|
||||
continue
|
||||
fi
|
||||
echo ""
|
||||
echo "📤 上传: $f"
|
||||
resp=$(curl -sX POST "$UPLOAD_URL" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "tenantId: $TENANT_ID" \
|
||||
-F "file=@${f};type=image/jpeg")
|
||||
echo " 响应: $resp"
|
||||
|
||||
# 解析 URL(优先 data.url,其次 data.path)
|
||||
url=$(echo "$resp" | sed -n 's/.*"url":"\([^"?]*\).*/\1/p' | head -1)
|
||||
[ -z "$url" ] && url=$(echo "$resp" | sed -n 's/.*"path":"\([^"?]*\).*/\1/p' | head -1)
|
||||
if [ -n "$url" ]; then
|
||||
echo " ✅ 访问地址: $url"
|
||||
else
|
||||
echo " ⚠️ 未解析到 URL,上面是原始响应"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "✅ 上传完成。URL 用法:"
|
||||
echo " - 01-about-company-team.jpg → 关于我们右侧图"
|
||||
echo " (后端无独立 aboutImage 字段,模板用 public/images/template-07/about.jpg 兜底;"
|
||||
echo " 若要后台托管,需在站点配置加 aboutImage 字段并由前端读 siteInfo.config.aboutImage)"
|
||||
echo " - 02-news-zoujinhuiicai-juyun.jpg → 最新动态首图:写到 cms-article.image 字段"
|
||||
echo "============================================================"
|
||||
Reference in New Issue
Block a user