feat: 产品视频改后台上传,promotion-media 公开访问;gitignore 大文件;保留原文件名上传
Made-with: Cursor
This commit is contained in:
@@ -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 })
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -153,7 +153,61 @@ func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/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(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
// CORS(ALLOWED_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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
web/promotion/视频发布/README.md
Normal file
26
web/promotion/视频发布/README.md
Normal 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.
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
Binary file not shown.
@@ -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('')
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_id(bootstrap 已拉路由时此处已有值;为空则产品视频走本地 /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
|
||||||
|
return {
|
||||||
|
backgroundImage: v ? 'url(' + v.cover + ')' : 'none',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center'
|
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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user