feat: 产品视频改后台上传,promotion-media 公开访问;gitignore 大文件;保留原文件名上传

Made-with: Cursor
This commit is contained in:
whm
2026-03-20 17:10:56 +08:00
parent 5067fb6f76
commit 654b683067
20 changed files with 225 additions and 47 deletions

View File

@@ -78,6 +78,7 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
form.append('file', file) form.append('file', file)
if (opts.folder != null) form.append('folder', opts.folder) if (opts.folder != null) form.append('folder', opts.folder)
form.append('downloadable', opts.downloadable ? 'true' : 'false') form.append('downloadable', opts.downloadable ? 'true' : 'false')
if (opts.preserveFilename) form.append('preserve_filename', 'true')
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
} }
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path }) export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })

View File

@@ -56,11 +56,15 @@
</el-card> </el-card>
<!-- 上传前选择是否可下载 --> <!-- 上传前选择是否可下载 -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="400px" :close-on-click-modal="false"> <el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
<el-form label-width="100px"> <el-form label-width="112px">
<el-form-item label="当前目录"> <el-form-item label="当前目录">
<span>{{ currentPath || '根目录' }}</span> <span>{{ currentPath || '根目录' }}</span>
</el-form-item> </el-form-item>
<el-form-item label="保留原文件名">
<el-switch v-model="uploadPreserveFilename" />
<span class="form-hint">开启后覆盖同路径同名文件首页产品视频须上传到 <code>promotion/视频发布/</code> 并开启此项</span>
</el-form-item>
<el-form-item label="允许下载"> <el-form-item label="允许下载">
<el-switch v-model="uploadDownloadable" /> <el-switch v-model="uploadDownloadable" />
</el-form-item> </el-form-item>
@@ -100,7 +104,8 @@ const loading = ref(false)
const currentPath = ref('') const currentPath = ref('')
const uploading = ref(false) const uploading = ref(false)
const uploadDialogVisible = ref(false) const uploadDialogVisible = ref(false)
const uploadDownloadable = ref(true) const uploadDownloadable = ref(false)
const uploadPreserveFilename = ref(false)
const pendingFile = ref(null) const pendingFile = ref(null)
const showNewFolder = ref(false) const showNewFolder = ref(false)
const newFolderName = ref('') const newFolderName = ref('')
@@ -152,7 +157,9 @@ watch([siteId, currentPath], fetchList)
const beforeUpload = (file) => { const beforeUpload = (file) => {
pendingFile.value = file pendingFile.value = file
uploadDownloadable.value = true const p = (currentPath.value || '').replace(/^\//, '')
uploadPreserveFilename.value = p.startsWith('promotion/')
uploadDownloadable.value = !uploadPreserveFilename.value
uploadDialogVisible.value = true uploadDialogVisible.value = true
return false return false
} }
@@ -163,7 +170,8 @@ const doUpload = async () => {
try { try {
await uploadSiteAsset(siteId.value, pendingFile.value, { await uploadSiteAsset(siteId.value, pendingFile.value, {
folder: currentPath.value || undefined, folder: currentPath.value || undefined,
downloadable: uploadDownloadable.value downloadable: uploadDownloadable.value,
preserveFilename: uploadPreserveFilename.value
}) })
ElMessage.success('上传成功') ElMessage.success('上传成功')
uploadDialogVisible.value = false uploadDialogVisible.value = false
@@ -216,6 +224,8 @@ onMounted(() => fetchSites().then(() => fetchList()))
<style scoped> <style scoped>
.file-manage .tip { color: #666; font-size: 14px; } .file-manage .tip { color: #666; font-size: 14px; }
.form-hint { display: block; margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.4; }
.form-hint code { font-size: 11px; }
.module-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .module-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.breadcrumb-wrap { margin-top: 12px; } .breadcrumb-wrap { margin-top: 12px; }
.subdirs { margin-top: 8px; font-size: 13px; color: #666; } .subdirs { margin-top: 8px; font-size: 13px; color: #666; }

View File

@@ -4,7 +4,7 @@ server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name yuheng.yuxindazhineng.com; server_name yuheng.yuxindazhineng.com;
client_max_body_size 200m; client_max_body_size 800m;
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem; ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
ssl_certificate_key /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem; ssl_certificate_key /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem;

View File

@@ -153,7 +153,61 @@ func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
return names return names
} }
// UploadSiteAsset 上传功能模块/文件form 可选folder当前目录相对路径、downloadabletrue/false func promotionMimeType(ext string) string {
switch strings.ToLower(ext) {
case ".mov":
return "video/quicktime"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
default:
return ""
}
}
// ServePromotionMedia 公开读取站点 uploads/sites/{site_id}/promotion/ 下文件(与后台上传到 promotion/… 目录对应)
func ServePromotionMedia(c *gin.Context) {
siteID := c.Param("site_id")
raw := strings.TrimPrefix(c.Param("filepath"), "/")
if siteID == "" || raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
rel := filepath.ToSlash(filepath.Clean(raw))
if rel == "." || strings.HasPrefix(rel, "../") || strings.Contains(rel, "/../") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效路径"})
return
}
baseDir := filepath.Join(getUploadDir(), "sites", siteID, "promotion")
fullPath := filepath.Join(baseDir, filepath.FromSlash(rel))
relBack, err := filepath.Rel(baseDir, fullPath)
if err != nil || strings.HasPrefix(relBack, "..") {
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
return
}
fi, err := os.Stat(fullPath)
if err != nil || fi.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
ext := filepath.Ext(fullPath)
ct := promotionMimeType(ext)
if ct == "" {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
c.Header("Cache-Control", "public, max-age=86400")
c.File(fullPath)
}
// UploadSiteAsset 上传功能模块/文件form 可选folder当前目录相对路径、downloadabletrue/false、preserve_filenametrue 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL
func UploadSiteAsset(c *gin.Context) { func UploadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id") siteID := c.Param("site_id")
if siteID == "" { if siteID == "" {
@@ -167,22 +221,59 @@ func UploadSiteAsset(c *gin.Context) {
return return
} }
folder := c.PostForm("folder") folder := strings.TrimSpace(c.PostForm("folder"))
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1" downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
baseDir := filepath.Join(getUploadDir(), "sites", siteID, filepath.Clean(folder)) preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
name := file.Filename
ext := filepath.Ext(name)
nameNoExt := strings.TrimSuffix(name, ext)
var saveName string
if preserve {
saveName = filepath.Base(name)
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
return
}
} else {
if len(ext) == 0 {
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
} else {
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
}
}
folderClean := ""
if folder != "" {
folderClean = filepath.ToSlash(filepath.Clean(folder))
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
return
}
}
var relPath string
if folderClean != "" {
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
} else {
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
}
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
if preserve {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancelDel()
coll := config.GetDB(config.DBName).Collection("site_assets")
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
_ = os.Remove(destPath)
}
baseDir := filepath.Dir(destPath)
if err := os.MkdirAll(baseDir, 0755); err != nil { if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return return
} }
name := file.Filename
ext := filepath.Ext(name)
nameNoExt := name[:len(name)-len(ext)]
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
relPath := filepath.Join("sites", siteID, filepath.Clean(folder), saveName)
relPath = filepath.ToSlash(relPath)
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
if err := c.SaveUploadedFile(file, destPath); err != nil { if err := c.SaveUploadedFile(file, destPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return return

View File

@@ -79,7 +79,7 @@ func main() {
} }
r := gin.Default() r := gin.Default()
r.MaxMultipartMemory = 200 << 20 // 200MB Nginx client_max_body_size 一致,避免上传 413 r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
r.Use(middleware.ErrorLogger()) r.Use(middleware.ErrorLogger())
// CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名) // CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
@@ -215,6 +215,8 @@ func main() {
web.GET("/info", func(c *gin.Context) { web.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "web api"}) c.JSON(http.StatusOK, gin.H{"message": "web api"})
}) })
// 推广/产品视频:站点 uploads 下 promotion 目录(无需鉴权,与后台上传到 promotion/… 对应)
web.GET("/sites/:site_id/promotion-media/*filepath", handlers.ServePromotionMedia)
// 可下载资源公开下载(首页等链接指向此路径) // 可下载资源公开下载(首页等链接指向此路径)
web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset) web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset)
} }

View File

@@ -1,8 +1,7 @@
# 推广素材(首页与视频源) # 推广素材(首页与视频源)
- `index.html`:静态完整落地页参考;线上 Vue 首页已与之对齐,素材路径以本目录为准。 - `index.html`:静态完整落地页参考;线上 Vue 首页已与之对齐,素材路径以本目录为准。
- `视频发布/`:产品视频与封面,首页「产品视频」区块使用下列相对路径(经 `promotionUrl` 转为 URL - `视频发布/`:产品视频与封面。**`.mov` 等大文件默认不入 Git**;生产环境请在 **后台 → 文件管理** 上传到 `promotion/视频发布/…`,勾选 **保留原文件名**(路径与文件名见 `视频发布/README.md`)。官网解析到站点后,首页通过 `/api/web/sites/{site_id}/promotion-media/视频发布/...` 拉取。本地开发仍可将文件放在本目录,走 `/promotion/视频发布/...`
- `/promotion/视频发布/...`
- `social/`**关注我们** 统一资源包(建议只用此目录上线),首页读取: - `social/`**关注我们** 统一资源包(建议只用此目录上线),首页读取:
- `social/xiaohongshu.png``social/douyin.png``social/wechat-official.png``social/wechat-channels.jpg` - `social/xiaohongshu.png``social/douyin.png``social/wechat-official.png``social/wechat-channels.jpg`
- 源文件可从根目录 `小红书.png``抖音.png``公众号.png``视频号.jpg` 同步复制进来ASCII 文件名利于网关与日志)。 - 源文件可从根目录 `小红书.png``抖音.png``公众号.png``视频号.jpg` 同步复制进来ASCII 文件名利于网关与日志)。
@@ -10,7 +9,7 @@
## 生产部署 ## 生产部署
1. **前端构建产物**`dist/`)不包含本目录。部署时请把本文件夹 **完整复制** 到站点根下,与 `index.html` 同级,目录名为 `promotion`即能通过 `https://你的域名/promotion/视频发布/...` 访问视频) 1. **前端构建产物**`dist/`)不包含本目录。除视频外请把需要的素材 **复制** 到站点根下 `promotion/``social/`、宣传册相关),或通过 Nginx alias。**视频**推荐仅通过后台上传到 API 存储目录,无需再拷 `.mov` 到静态服务器
2. 或使用 Nginx 2. 或使用 Nginx
```nginx ```nginx

View File

@@ -0,0 +1,26 @@
# 产品视频目录
大体积 **`.mov` 视频不入 Git**。线上由官网 **后台 → 文件管理** 上传到站点下的 `promotion/视频发布/...`,与首页「产品视频」使用的路径一致。
## 上传步骤
1. 打开 **后台**,进入 **文件管理 → 功能模块**
2. 选择 **官网站点**(与系统设置中的「官网」站点一致)。
3. 新建或使用目录:`promotion/视频发布/<子目录名>/`(与下列清单一致)。
4. 上传对应 **封面 `.jpg`****视频 `.mov`** 时,请勾选 **「保留原文件名」**,文件名必须与下列清单完全一致(否则首页无法匹配)。
## 文件清单(相对 `promotion/`
| 子目录 | 封面文件 | 视频文件 |
|--------|----------|----------|
| `视频发布/宇恒一号操作计算软件实例(一)` | `宣传片-封面.jpg` | `宣传片.mov` |
| `视频发布/宇恒一号操作计算软件实例(二)` | `宇恒一号操作计算软件实例(二)-封面.jpg` | `宇恒一号操作计算软件实例(二).mov` |
| `视频发布/宇恒一号AIWord简介` | `宇恒一号AIWord简介-封面.jpg` | `宇恒一号AIWord简介.mov` |
| `视频发布/宇恒一号语音办公实例` | `宇恒一号语音办公实例-封面.jpg` | `宇恒一号语音办公实例.mov` |
| `视频发布/宇恒一号AI 全自动办发票` | `宇恒一号AI 全自动办发票-封面.jpg` | `宇恒一号AI 全自动办发票.mov` |
(若表内「封面」文件名与代码中 `web/src/data/promotionVideos.js` 不一致,以代码中的 `relCover` / `relVideo` 为准。)
## 本地开发
若仅在本地调试仍可将视频放在本目录Vite 开发服务器会通过 `/promotion/...` 直接读本地文件。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

View File

@@ -1,42 +1,56 @@
import { promotionUrl } from '../utils/promotionAssets' import { promotionUrl, promotionMediaApiUrl } from '../utils/promotionAssets'
const ROOT = '视频发布' const ROOT = '视频发布'
/** 与 web/promotion/视频发布 目录结构一致 */ /** 相对 `promotion/` 的路径;与后台上传目录 promotion/视频发布/… +「保留原文件名」一致 */
export const PROMOTION_VIDEOS = [ export const PROMOTION_VIDEOS_BASE = [
{ {
id: 'calc-demo-1', id: 'calc-demo-1',
title: '操作与计算软件实例(一)', title: '操作与计算软件实例(一)',
desc: '宇恒一号宣传片', desc: '宇恒一号宣传片',
cover: promotionUrl(`${ROOT}/宇恒一号操作计算软件实例(一)/宣传片-封面.jpg`), relCover: `${ROOT}/宇恒一号操作计算软件实例(一)/宣传片-封面.jpg`,
src: promotionUrl(`${ROOT}/宇恒一号操作计算软件实例(一)/宣传片.mov`) relVideo: `${ROOT}/宇恒一号操作计算软件实例(一)/宣传片.mov`
}, },
{ {
id: 'calc-demo-2', id: 'calc-demo-2',
title: '操作与计算软件实例(二)', title: '操作与计算软件实例(二)',
desc: '进阶操作与计算演示', desc: '进阶操作与计算演示',
cover: promotionUrl(`${ROOT}/宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg`), relCover: `${ROOT}/宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg`,
src: promotionUrl(`${ROOT}/宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov`) relVideo: `${ROOT}/宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov`
}, },
{ {
id: 'aiword', id: 'aiword',
title: '宇恒一号 AI Word 简介', title: '宇恒一号 AI Word 简介',
desc: 'AI Word 能力介绍', desc: 'AI Word 能力介绍',
cover: promotionUrl(`${ROOT}/宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg`), relCover: `${ROOT}/宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg`,
src: promotionUrl(`${ROOT}/宇恒一号AIWord简介/宇恒一号AIWord简介.mov`) relVideo: `${ROOT}/宇恒一号AIWord简介/宇恒一号AIWord简介.mov`
}, },
{ {
id: 'voice', id: 'voice',
title: '语音办公实例', title: '语音办公实例',
desc: '语音驱动办公流程', desc: '语音驱动办公流程',
cover: promotionUrl(`${ROOT}/宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg`), relCover: `${ROOT}/宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg`,
src: promotionUrl(`${ROOT}/宇恒一号语音办公实例/宇恒一号语音办公实例.mov`) relVideo: `${ROOT}/宇恒一号语音办公实例/宇恒一号语音办公实例.mov`
}, },
{ {
id: 'invoice', id: 'invoice',
title: 'AI 全自动办发票', title: 'AI 全自动办发票',
desc: '发票场景自动化演示', desc: '发票场景自动化演示',
cover: promotionUrl(`${ROOT}/宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票-封面.jpg`), relCover: `${ROOT}/宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票-封面.jpg`,
src: promotionUrl(`${ROOT}/宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票.mov`) relVideo: `${ROOT}/宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票.mov`
} }
] ]
/** @param {string} siteId 空串时走本地/静态 /promotion/… */
export function buildPromotionVideos(siteId) {
return PROMOTION_VIDEOS_BASE.map((v) => ({
id: v.id,
title: v.title,
desc: v.desc,
cover: promotionMediaApiUrl(siteId, v.relCover),
src: promotionMediaApiUrl(siteId, v.relVideo)
}))
}
/** @deprecated 请用 buildPromotionVideos(siteId);保留兼容旧引用 */
export const PROMOTION_VIDEOS = buildPromotionVideos('')

View File

@@ -1,8 +1,23 @@
import { apiBase } from '../config'
/** /**
* 推广素材根路径。开发时由 Vite 插件映射到 web/promotion * 推广素材根路径。开发时由 Vite 插件映射到 web/promotion
* 生产构建请把 web/promotion 整目录同步到站点 /promotion或配置 Nginx alias * 生产环境视频默认走后台上传:`/api/web/sites/{siteId}/promotion-media/...`(见 buildPromotionVideos
*/ */
export function promotionUrl(relativePath) { export function promotionUrl(relativePath) {
const parts = String(relativePath).split('/').filter(Boolean) const parts = String(relativePath).split('/').filter(Boolean)
return '/promotion/' + parts.map(encodeURIComponent).join('/') return '/promotion/' + parts.map(encodeURIComponent).join('/')
} }
/**
* 官网产品视频/封面:读取站点 uploads 下 `promotion/` 目录(与后台上传路径一致)
* @param {string} siteId Mongo 站点 id与 /api/web/routes 返回的 site_id 一致)
* @param {string} relativePath 相对 promotion/ 的路径,如 `视频发布/xxx/yyy.mov`
*/
export function promotionMediaApiUrl(siteId, relativePath) {
if (!siteId) return promotionUrl(relativePath)
const root = (apiBase || '').replace(/\/$/, '')
const prefix = root ? `${root}/api` : '/api'
const parts = String(relativePath).split(/[/\\]/).filter(Boolean).map(encodeURIComponent).join('/')
return `${prefix}/web/sites/${siteId}/promotion-media/${parts}`
}

View File

@@ -213,11 +213,11 @@
<section class="video-section" id="videos"> <section class="video-section" id="videos">
<div class="section-title"> <div class="section-title">
<h2>产品视频</h2> <h2>产品视频</h2>
<p>内容为视频发布文件夹中的正式素材点击即可播放</p> <p>视频由后台上传至 <code class="social-code">promotion/视频发布/</code>需勾选保留原文件名未上传时本地开发可走 <code class="social-code">/promotion/</code></p>
</div> </div>
<div class="video-container"> <div class="video-container">
<div class="video-wrapper"> <div class="video-wrapper">
<div class="video-card main-video"> <div v-if="activeVideo" class="video-card main-video">
<div <div
class="video-thumbnail main-thumb" class="video-thumbnail main-thumb"
:style="mainVideoCoverStyle" :style="mainVideoCoverStyle"
@@ -238,7 +238,7 @@
v-for="v in promoVideos" v-for="v in promoVideos"
:key="v.id" :key="v.id"
class="video-card small-video" class="video-card small-video"
:class="{ 'is-active': activeVideo.id === v.id }" :class="{ 'is-active': activeVideo && activeVideo.id === v.id }"
@click="selectVideo(v)" @click="selectVideo(v)"
> >
<div <div
@@ -309,11 +309,12 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { apiBase } from '../config' import { apiBase } from '../config'
import BlockRenderer from '../components/blocks/BlockRenderer.vue' import BlockRenderer from '../components/blocks/BlockRenderer.vue'
import { PROMOTION_VIDEOS } from '../data/promotionVideos' import { buildPromotionVideos } from '../data/promotionVideos'
import { PROMOTION_SOCIAL_FOLLOW } from '../data/promotionSocial' import { PROMOTION_SOCIAL_FOLLOW } from '../data/promotionSocial'
import { getCachedWebSiteId, fetchWebRoutes } from '../api/webPages'
const socialFollowItems = PROMOTION_SOCIAL_FOLLOW const socialFollowItems = PROMOTION_SOCIAL_FOLLOW
@@ -321,8 +322,19 @@ const starsEl = ref(null)
let cometTimer = null let cometTimer = null
const scrollY = ref(0) const scrollY = ref(0)
const openFaq = ref(0) const openFaq = ref(0)
const promoVideos = PROMOTION_VIDEOS /** 官网 site_idbootstrap 已拉路由时此处已有值;为空则产品视频走本地 /promotion */
const activeVideo = ref(promoVideos[0]) const webSiteId = ref(getCachedWebSiteId() || '')
const promoVideos = computed(() => buildPromotionVideos(webSiteId.value))
const activeVideo = ref(null)
watch(
promoVideos,
(list) => {
if (!list.length) return
const cur = activeVideo.value
if (!cur || !list.some((v) => v.id === cur.id)) activeVideo.value = list[0]
},
{ immediate: true }
)
const videoModalOpen = ref(false) const videoModalOpen = ref(false)
const modalVideoSrc = ref('') const modalVideoSrc = ref('')
const modalCaption = ref('') const modalCaption = ref('')
@@ -385,11 +397,14 @@ const navbarStyle = computed(() => {
return { background: 'linear-gradient(180deg, rgba(10,10,18,0.95) 0%, transparent 100%)', boxShadow: 'none' } return { background: 'linear-gradient(180deg, rgba(10,10,18,0.95) 0%, transparent 100%)', boxShadow: 'none' }
}) })
const mainVideoCoverStyle = computed(() => ({ const mainVideoCoverStyle = computed(() => {
backgroundImage: 'url(' + activeVideo.value.cover + ')', const v = activeVideo.value
backgroundSize: 'cover', return {
backgroundPosition: 'center' backgroundImage: v ? 'url(' + v.cover + ')' : 'none',
})) backgroundSize: 'cover',
backgroundPosition: 'center'
}
})
const bodyBuilderBlocks = computed(() => { const bodyBuilderBlocks = computed(() => {
const raw = data.body_builder const raw = data.body_builder
@@ -569,6 +584,11 @@ function onDocKeydown(e) {
} }
onMounted(() => { onMounted(() => {
if (!webSiteId.value) {
fetchWebRoutes().then(() => {
webSiteId.value = getCachedWebSiteId() || ''
})
}
fetchHomepage() fetchHomepage()
document.title = (data.title || '宇恒一号') + ' - 星际探索版' document.title = (data.title || '宇恒一号') + ' - 星际探索版'
window.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true })