feat: 服务端 promotion 视频自动转码;首页宣传册预览与 mp4 配置
Made-with: Cursor
This commit is contained in:
40
scripts/transcode-promotion-videos.ps1
Normal file
40
scripts/transcode-promotion-videos.ps1
Normal file
@@ -0,0 +1,40 @@
|
||||
# 将 web\promotion\social 下产品视频从 .mov 转为网页通用 .mp4(H.264 + AAC,faststart)
|
||||
# 依赖:已安装 ffmpeg 并在 PATH 中(Windows: https://www.gyan.dev/ffmpeg/builds/ 或 winget install ffmpeg)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
$Dir = Join-Path $Root "web\promotion\social"
|
||||
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Write-Host "未找到 ffmpeg。请安装并加入 PATH:https://ffmpeg.org/download.html" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$files = @(
|
||||
"video-calc-demo-1.mov",
|
||||
"video-calc-demo-2.mov",
|
||||
"video-aiword.mov",
|
||||
"video-voice-office.mov",
|
||||
"video-invoice-ai.mov"
|
||||
)
|
||||
|
||||
foreach ($f in $files) {
|
||||
$src = Join-Path $Dir $f
|
||||
$base = [System.IO.Path]::GetFileNameWithoutExtension($f)
|
||||
$dst = Join-Path $Dir "$base.mp4"
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
Write-Host "[跳过] 无源文件: $src" -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
Write-Host "[转码] $src -> $dst"
|
||||
& ffmpeg -y -i $src `
|
||||
-c:v libx264 -profile:v high -pix_fmt yuv420p `
|
||||
-c:a aac -b:a 128k `
|
||||
-movflags +faststart `
|
||||
$dst
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Write-Host "[完成] $dst" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "全部处理结束。请部署生成的 .mp4;确认后可删除本地 .mov(可选)。" -ForegroundColor Cyan
|
||||
40
scripts/transcode-promotion-videos.sh
Normal file
40
scripts/transcode-promotion-videos.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将 web/promotion/social 下产品视频从 .mov 转为网页通用 .mp4(H.264 + AAC,faststart)
|
||||
# 依赖:已安装 ffmpeg(macOS: brew install ffmpeg;Ubuntu: apt install ffmpeg)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DIR="$ROOT/web/promotion/social"
|
||||
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "未找到 ffmpeg,请先安装:https://ffmpeg.org/download.html"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 与 web/src/data/promotionVideos.js 中 relVideo 基名一致(输出为同名 .mp4)
|
||||
FILES=(
|
||||
"video-calc-demo-1.mov"
|
||||
"video-calc-demo-2.mov"
|
||||
"video-aiword.mov"
|
||||
"video-voice-office.mov"
|
||||
"video-invoice-ai.mov"
|
||||
)
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
src="$DIR/$f"
|
||||
base="${f%.mov}"
|
||||
dst="$DIR/${base}.mp4"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "[跳过] 无源文件: $src"
|
||||
continue
|
||||
fi
|
||||
echo "[转码] $src -> $dst"
|
||||
ffmpeg -y -i "$src" \
|
||||
-c:v libx264 -profile:v high -pix_fmt yuv420p \
|
||||
-c:a aac -b:a 128k \
|
||||
-movflags +faststart \
|
||||
"$dst"
|
||||
echo "[完成] $dst"
|
||||
done
|
||||
|
||||
echo "全部处理结束。请部署生成的 .mp4;确认后可删除本地 .mov 以减小体积(可选)。"
|
||||
@@ -9,6 +9,9 @@ GIN_MODE=release
|
||||
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
|
||||
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
|
||||
|
||||
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
|
||||
# SKIP_PROMOTION_TRANSCODE=1
|
||||
|
||||
# 部署时自动导入「视频发布」到 data/uploads + site_assets(compose up 后执行)
|
||||
# 官网站点 MongoDB _id;pull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env,一般无需手改
|
||||
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef
|
||||
|
||||
@@ -13,7 +13,8 @@ RUN CGO_ENABLED=0 go build -mod=vendor -o /app/server .
|
||||
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||
FROM ${REGISTRY_MIRROR}alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# 产品视频 .mov → .mp4 服务端转码(handlers/promotion_transcode.go)
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 8088
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app,/app/server 由宿主机构建
|
||||
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||
FROM ${REGISTRY_MIRROR}alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# 与编译镜像一致:挂载的二进制需在容器内调用 ffmpeg 做推广视频转码
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||
ENV TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
EXPOSE 8088
|
||||
|
||||
@@ -22,3 +22,8 @@ go run main.go
|
||||
```
|
||||
|
||||
默认端口 8080
|
||||
|
||||
## 推广视频转码(promotion 目录)
|
||||
|
||||
上传到 `sites/{site_id}/promotion/**.mov` 后,服务会异步转 **MP4**(需本机安装 **ffmpeg**,与 Docker 镜像一致)。启动时也会扫描遗留 `.mov` 补转码。详见 `handlers/promotion_transcode.go`。
|
||||
关闭:`SKIP_PROMOTION_TRANSCODE=1`。
|
||||
|
||||
@@ -306,6 +306,8 @@ func UploadSiteAsset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
||||
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg)
|
||||
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
|
||||
}
|
||||
|
||||
// DeleteSiteAsset 删除站点资源
|
||||
|
||||
197
server/handlers/promotion_transcode.go
Normal file
197
server/handlers/promotion_transcode.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"yh_web/server/config"
|
||||
)
|
||||
|
||||
func skipPromotionTranscode() bool {
|
||||
v := strings.TrimSpace(os.Getenv("SKIP_PROMOTION_TRANSCODE"))
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
}
|
||||
|
||||
func ffmpegAvailable() bool {
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isMOVUnderPromotion(relPath string, ext string) bool {
|
||||
if strings.ToLower(ext) != ".mov" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(filepath.ToSlash(relPath), "/promotion/")
|
||||
}
|
||||
|
||||
func mp4PathForMOV(movPath string) string {
|
||||
return strings.TrimSuffix(movPath, filepath.Ext(movPath)) + ".mp4"
|
||||
}
|
||||
|
||||
func needsTranscode(movPath, mp4Path string) bool {
|
||||
mi, err1 := os.Stat(movPath)
|
||||
if err1 != nil || mi.IsDir() {
|
||||
return false
|
||||
}
|
||||
pi, err2 := os.Stat(mp4Path)
|
||||
if err2 != nil {
|
||||
return true
|
||||
}
|
||||
return mi.ModTime().After(pi.ModTime())
|
||||
}
|
||||
|
||||
// runFFmpegMOVToMP4 将 mov 转为浏览器通用 mp4(与前端/脚本参数一致)
|
||||
func runFFmpegMOVToMP4(ctx context.Context, movPath, mp4Path string) error {
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-y", "-i", movPath,
|
||||
"-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
mp4Path,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relPathFromUploadRoot(uploadRoot, fullPath string) (string, error) {
|
||||
r, err := filepath.Rel(uploadRoot, fullPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.ToSlash(r), nil
|
||||
}
|
||||
|
||||
// replaceMOVWithMP4InDB 将 site_assets 中对应 .mov 记录更新为 .mp4(转码成功后调用)
|
||||
func replaceMOVWithMP4InDB(siteID, oldRelPath, mp4FullPath string, insertedID any) {
|
||||
if config.MongoClient == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||
|
||||
newRel := strings.TrimSuffix(oldRelPath, filepath.Ext(oldRelPath)) + ".mp4"
|
||||
fi, err := os.Stat(mp4FullPath)
|
||||
if err != nil {
|
||||
log.Printf("[promotion-transcode] stat mp4: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"file_path": newRel,
|
||||
"name": filepath.Base(newRel),
|
||||
"size": fi.Size(),
|
||||
"content_type": "video/mp4",
|
||||
}
|
||||
|
||||
filter := bson.M{"site_id": siteID, "file_path": oldRelPath}
|
||||
if oid, ok := insertedID.(bson.ObjectID); ok && !oid.IsZero() {
|
||||
filter = bson.M{"_id": oid, "site_id": siteID}
|
||||
}
|
||||
_, err = coll.UpdateOne(ctx, filter, bson.M{"$set": set})
|
||||
if err != nil {
|
||||
log.Printf("[promotion-transcode] 更新数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ScheduleTranscodeAfterUpload 上传保存成功后异步:promotion 下 .mov -> .mp4,并更新本条 site_assets
|
||||
func ScheduleTranscodeAfterUpload(siteID, relPath, movFullPath string, insertedID any) {
|
||||
if skipPromotionTranscode() || !isMOVUnderPromotion(relPath, filepath.Ext(movFullPath)) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if !ffmpegAvailable() {
|
||||
log.Printf("[promotion-transcode] 已上传 .mov 但未安装 ffmpeg,无法转码: %s", relPath)
|
||||
return
|
||||
}
|
||||
mp4Full := mp4PathForMOV(movFullPath)
|
||||
if !needsTranscode(movFullPath, mp4Full) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||
defer cancel()
|
||||
log.Printf("[promotion-transcode] 开始转码: %s -> %s", movFullPath, mp4Full)
|
||||
if err := runFFmpegMOVToMP4(ctx, movFullPath, mp4Full); err != nil {
|
||||
log.Printf("[promotion-transcode] 转码失败 %s: %v", relPath, err)
|
||||
return
|
||||
}
|
||||
if err := os.Remove(movFullPath); err != nil {
|
||||
log.Printf("[promotion-transcode] 删除原 .mov 失败(可手动删): %v", err)
|
||||
}
|
||||
replaceMOVWithMP4InDB(siteID, relPath, mp4Full, insertedID)
|
||||
log.Printf("[promotion-transcode] 完成: %s", newRelLog(relPath))
|
||||
}()
|
||||
}
|
||||
|
||||
func newRelLog(oldRel string) string {
|
||||
return strings.TrimSuffix(oldRel, filepath.Ext(oldRel)) + ".mp4"
|
||||
}
|
||||
|
||||
// SweepPromotionTranscodeOnStartup 扫描 uploads/sites/*/promotion/**.mov,补转码并同步数据库(已有文件)
|
||||
func SweepPromotionTranscodeOnStartup() {
|
||||
time.Sleep(3 * time.Second)
|
||||
if skipPromotionTranscode() {
|
||||
log.Println("[promotion-transcode] 启动扫描已跳过 SKIP_PROMOTION_TRANSCODE=1")
|
||||
return
|
||||
}
|
||||
if !ffmpegAvailable() {
|
||||
log.Println("[promotion-transcode] 启动扫描跳过:未找到 ffmpeg(安装后可重启服务)")
|
||||
return
|
||||
}
|
||||
root := getUploadDir()
|
||||
sitesDir := filepath.Join(root, "sites")
|
||||
fi, err := os.Stat(sitesDir)
|
||||
if err != nil || !fi.IsDir() {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(sitesDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
siteID := e.Name()
|
||||
promoRoot := filepath.Join(sitesDir, siteID, "promotion")
|
||||
_ = filepath.WalkDir(promoRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(path)) != ".mov" {
|
||||
return nil
|
||||
}
|
||||
mp4Full := mp4PathForMOV(path)
|
||||
if !needsTranscode(path, mp4Full) {
|
||||
return nil
|
||||
}
|
||||
rel, err := relPathFromUploadRoot(root, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||
log.Printf("[promotion-transcode] [启动补转] %s", rel)
|
||||
err = runFFmpegMOVToMP4(ctx, path, mp4Full)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[promotion-transcode] [启动补转] 失败 %s: %v", rel, err)
|
||||
return nil
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
replaceMOVWithMP4InDB(siteID, rel, mp4Full, nil)
|
||||
log.Printf("[promotion-transcode] [启动补转] 完成 %s", newRelLog(rel))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -225,5 +225,9 @@ func main() {
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||
go handlers.SweepPromotionTranscodeOnStartup()
|
||||
|
||||
r.Run(":" + port)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"transcode": "node scripts/auto-transcode-promotion.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -15,11 +15,28 @@
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `video-calc-demo-1-cover.jpg` / `video-calc-demo-1.mov` | 操作与计算(一) |
|
||||
| `video-calc-demo-2-cover.jpg` / `video-calc-demo-2.mov` | 操作与计算(二) |
|
||||
| `video-aiword-cover.jpg` / `video-aiword.mov` | AI Word |
|
||||
| `video-voice-office-cover.jpg` / `video-voice-office.mov` | 语音办公 |
|
||||
| `video-invoice-ai-cover.jpg` / `video-invoice-ai.mov` | 办发票 |
|
||||
| `video-calc-demo-1-cover.jpg` / `video-calc-demo-1.mp4` | 操作与计算(一) |
|
||||
| `video-calc-demo-2-cover.jpg` / `video-calc-demo-2.mp4` | 操作与计算(二) |
|
||||
| `video-aiword-cover.jpg` / `video-aiword.mp4` | AI Word |
|
||||
| `video-voice-office-cover.jpg` / `video-voice-office.mp4` | 语音办公 |
|
||||
| `video-invoice-ai-cover.jpg` / `video-invoice-ai.mp4` | 办发票 |
|
||||
|
||||
### 从 .mov 转码为 .mp4(推荐)
|
||||
|
||||
剪辑软件常导出 **.mov**,浏览器兼容性差;前台统一使用 **`.mp4`(H.264+AAC)**(见 `src/data/promotionVideos.js`)。
|
||||
|
||||
**服务端自动转码(推荐,线上)**
|
||||
上传到 **`promotion/...`** 的 **`.mov`** 时,API 容器/主机需在 PATH 中有 **`ffmpeg`**(`server/Dockerfile`、`Dockerfile.run` 已 `apk add ffmpeg`)。服务会:
|
||||
|
||||
1. **上传后**:异步将 `.mov` 转为同名 `.mp4`,删除原 `.mov`,并更新 `site_assets` 中路径与大小。
|
||||
2. **启动后约 3 秒**:扫描 `uploads/sites/*/promotion/**/*.mov`,对遗留文件补转码并写库。
|
||||
|
||||
环境变量 **`SKIP_PROMOTION_TRANSCODE=1`** 可关闭转码(无 ffmpeg 时避免报错日志)。
|
||||
|
||||
部署 API 镜像更新后请执行 **`docker compose build api`**(或等价命令),确保运行环境含 ffmpeg。
|
||||
|
||||
**仅静态站、无 API 时(本地可选)**
|
||||
可在 `web/` 执行 `npm run transcode`(见 `scripts/auto-transcode-promotion.mjs`),或仓库根目录 `scripts/transcode-promotion-videos.sh` / `.ps1`。
|
||||
|
||||
线上访问示例:`https://你的域名/promotion/social/douyin.png`
|
||||
(须将 `web/promotion` 同步到 **`deploy/web/dist/promotion`**,见 `pull-and-restart.sh`。)
|
||||
|
||||
113
web/scripts/auto-transcode-promotion.mjs
Normal file
113
web/scripts/auto-transcode-promotion.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 构建前自动:若 promotion/social 存在 .mov 且比对应 .mp4 新(或尚无 .mp4),则用 ffmpeg 转码。
|
||||
* - 无 .mov:直接通过
|
||||
* - 有 .mov 需更新但无 ffmpeg:失败(避免打出缺 mp4 的包);可设 SKIP_PROMOTION_TRANSCODE=1 跳过
|
||||
*/
|
||||
import { existsSync, statSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { spawnSync } from 'child_process'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const SOCIAL = join(__dirname, '..', 'promotion', 'social')
|
||||
|
||||
/** 与 src/data/promotionVideos.js 中视频基名一致(不含扩展名) */
|
||||
const VIDEO_BASES = [
|
||||
'video-calc-demo-1',
|
||||
'video-calc-demo-2',
|
||||
'video-aiword',
|
||||
'video-voice-office',
|
||||
'video-invoice-ai'
|
||||
]
|
||||
|
||||
const FFMPEG_ARGS = (src, dst) => [
|
||||
'-y',
|
||||
'-i',
|
||||
src,
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-profile:v',
|
||||
'high',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'128k',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
dst
|
||||
]
|
||||
|
||||
function mtime(p) {
|
||||
try {
|
||||
return statSync(p).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function needsTranscode(movPath, mp4Path) {
|
||||
if (!existsSync(movPath)) return false
|
||||
if (!existsSync(mp4Path)) return true
|
||||
return mtime(movPath) > mtime(mp4Path)
|
||||
}
|
||||
|
||||
function ffmpegWorks() {
|
||||
const r = spawnSync('ffmpeg', ['-hide_banner', '-version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
function runTranscode(movPath, mp4Path) {
|
||||
console.log(`[transcode] ${movPath} -> ${mp4Path}`)
|
||||
const r = spawnSync('ffmpeg', FFMPEG_ARGS(movPath, mp4Path), {
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
})
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.SKIP_PROMOTION_TRANSCODE === '1' || process.env.SKIP_PROMOTION_TRANSCODE === 'true') {
|
||||
console.log('[transcode] 已跳过(SKIP_PROMOTION_TRANSCODE)')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const todo = []
|
||||
for (const base of VIDEO_BASES) {
|
||||
const movPath = join(SOCIAL, `${base}.mov`)
|
||||
const mp4Path = join(SOCIAL, `${base}.mp4`)
|
||||
if (needsTranscode(movPath, mp4Path)) {
|
||||
todo.push({ movPath, mp4Path, base })
|
||||
}
|
||||
}
|
||||
|
||||
if (todo.length === 0) {
|
||||
console.log('[transcode] 无需转码(无 .mov 或 .mp4 已最新)')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!ffmpegWorks()) {
|
||||
console.error(
|
||||
'[transcode] 需要转码但未找到 ffmpeg。请安装并加入 PATH:https://ffmpeg.org/download.html\n' +
|
||||
'或设置 SKIP_PROMOTION_TRANSCODE=1 跳过(将依赖已存在的 .mp4)'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const { movPath, mp4Path, base } of todo) {
|
||||
if (!runTranscode(movPath, mp4Path)) {
|
||||
console.error(`[transcode] 失败: ${base}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`[transcode] 完成: ${base}.mp4`)
|
||||
}
|
||||
|
||||
console.log('[transcode] 全部完成')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main()
|
||||
466
web/src/components/HomeBrochurePreview.vue
Normal file
466
web/src/components/HomeBrochurePreview.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<article v-if="block" class="home-bro-preview">
|
||||
<p class="breadcrumb">
|
||||
<router-link to="/">首页</router-link>
|
||||
<span class="sep">/</span>
|
||||
<span>宣传册预览</span>
|
||||
<span class="sep">/</span>
|
||||
<span>{{ neighbors.index }} / {{ neighbors.total }} {{ block.title }}</span>
|
||||
</p>
|
||||
|
||||
<h1>{{ block.title }}</h1>
|
||||
<BrochureRichText
|
||||
v-if="block.subtitle"
|
||||
:text="block.subtitle"
|
||||
as="p"
|
||||
wrapper-class="lead"
|
||||
/>
|
||||
|
||||
<div v-if="extendLinks.length" class="extend-links" aria-label="同页切换章节">
|
||||
<span class="extend-label">延伸 · 切到</span>
|
||||
<button
|
||||
v-for="l in extendLinks"
|
||||
:key="l.topic"
|
||||
type="button"
|
||||
class="extend-chip"
|
||||
@click="emitTopic(l.topic)"
|
||||
>{{ l.text }}</button>
|
||||
</div>
|
||||
|
||||
<p class="kw-hint kw-hint--short" role="note">
|
||||
文中青蓝色下划线可跳转完整宣传册页或首页锚点;本区域点击右侧列表即可切换章节预览。
|
||||
</p>
|
||||
|
||||
<template v-if="block.paragraphs">
|
||||
<BrochureRichText
|
||||
v-for="(p, i) in block.paragraphs"
|
||||
:key="'p' + i"
|
||||
:text="p"
|
||||
as="p"
|
||||
wrapper-class="para"
|
||||
/>
|
||||
<ul v-if="block.highlights" class="hl-list">
|
||||
<li v-for="(h, i) in block.highlights" :key="'h' + i">
|
||||
<BrochureRichText :text="h" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-if="block.lists">
|
||||
<section v-for="(sec, i) in block.lists" :key="'ls' + i" class="subsec">
|
||||
<h3>{{ sec.heading }}</h3>
|
||||
<ul>
|
||||
<li v-for="(it, j) in sec.items" :key="j">
|
||||
<BrochureRichText :text="it" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<BrochureRichText v-if="block.note" :text="block.note" as="p" wrapper-class="note" />
|
||||
</template>
|
||||
|
||||
<ul v-if="block.bullets" class="bullet-list">
|
||||
<li v-for="(b, i) in block.bullets" :key="'b' + i">
|
||||
<BrochureRichText :text="b" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="block.cards">
|
||||
<div v-for="(c, i) in block.cards" :key="'c' + i" class="card">
|
||||
<h3>{{ c.h }}</h3>
|
||||
<BrochureRichText :text="c.t" as="p" wrapper-class="card-body-rich" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="block.groups">
|
||||
<section v-for="(g, i) in block.groups" :key="'g' + i" class="subsec">
|
||||
<h3>{{ g.h }}</h3>
|
||||
<ul>
|
||||
<li v-for="(it, j) in g.items" :key="j">
|
||||
<BrochureRichText :text="it" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="block.sections">
|
||||
<section v-for="(s, i) in block.sections" :key="'s' + i" class="subsec">
|
||||
<h3>{{ s.h }}</h3>
|
||||
<BrochureRichText :text="s.p" as="p" wrapper-class="para" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="block.blocks">
|
||||
<section v-for="(b, i) in block.blocks" :key="'bk' + i" class="subsec">
|
||||
<h3>{{ b.h }}</h3>
|
||||
<ul>
|
||||
<li v-for="(it, j) in b.items" :key="j">
|
||||
<BrochureRichText :text="it" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="block.items">
|
||||
<div v-for="(it, i) in block.items" :key="'it' + i" class="card">
|
||||
<h3>{{ it.h }}</h3>
|
||||
<BrochureRichText :text="it.p" as="p" wrapper-class="card-body-rich" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="block.contacts">
|
||||
<div class="contacts">
|
||||
<div v-for="(c, i) in block.contacts" :key="i" class="contact-row">
|
||||
<span class="label">{{ c.label }}</span>
|
||||
<a v-if="c.href" :href="c.href" target="_blank" rel="noopener">{{ c.value }}</a>
|
||||
<span v-else>{{ c.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span v-for="(t, i) in block.tags" :key="i" class="tag">{{ t }}</span>
|
||||
</div>
|
||||
<BrochureRichText :text="block.closing" as="p" wrapper-class="para closing" />
|
||||
<p class="para home-contact-hint">
|
||||
需要电话或地址?
|
||||
<a href="#" @click.prevent="scrollToHash('contact')">跳转首页「联系我们」→</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p class="para page-cross-links">
|
||||
与首页联动:
|
||||
<a href="#" @click.prevent="scrollToHash('videos')">产品视频</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="#" @click.prevent="scrollToHash('contact')">联系我们</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="#" @click.prevent="scrollToHash('download')">下载入口</a>
|
||||
</p>
|
||||
|
||||
<nav class="chapter-nav" aria-label="章节切换(本页预览)">
|
||||
<button
|
||||
v-if="neighbors.prev"
|
||||
type="button"
|
||||
class="chapter-btn prev"
|
||||
@click="emitTopic(neighbors.prev.topic)"
|
||||
>← {{ neighbors.prev.short }}</button>
|
||||
<span v-else class="chapter-btn prev disabled">← 已是第一章</span>
|
||||
|
||||
<router-link :to="{ name: 'Brochure', params: { topic: currentTopic } }" class="chapter-btn mid">完整页面</router-link>
|
||||
|
||||
<button
|
||||
v-if="neighbors.next"
|
||||
type="button"
|
||||
class="chapter-btn next"
|
||||
@click="emitTopic(neighbors.next.topic)"
|
||||
>{{ neighbors.next.short }} →</button>
|
||||
<span v-else class="chapter-btn next disabled">已是最后一章 →</span>
|
||||
</nav>
|
||||
|
||||
<div class="all-chapters" aria-label="全部章节(本页切换)">
|
||||
<span class="all-chapters-label">全部章节</span>
|
||||
<div class="chapter-chips">
|
||||
<button
|
||||
v-for="item in BROCHURE_NAV"
|
||||
:key="'chip-' + item.topic"
|
||||
type="button"
|
||||
class="chapter-chip"
|
||||
:class="{ current: item.topic === currentTopic }"
|
||||
@click="emitTopic(item.topic)"
|
||||
>{{ item.short }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
BROCHURE_NAV,
|
||||
BROCHURE_CONTENT,
|
||||
BROCH_EXTEND_LINKS,
|
||||
getBrochureTopic,
|
||||
getBrochureNeighbors
|
||||
} from '../data/brochureContent'
|
||||
import BrochureRichText from './BrochureRichText.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, default: 'company' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:topic'])
|
||||
|
||||
const currentTopic = computed(() => getBrochureTopic(props.topic))
|
||||
|
||||
const block = computed(() => BROCHURE_CONTENT[currentTopic.value] || BROCHURE_CONTENT.company)
|
||||
|
||||
const neighbors = computed(() => getBrochureNeighbors(currentTopic.value))
|
||||
|
||||
const extendLinks = computed(() => BROCH_EXTEND_LINKS[currentTopic.value] || [])
|
||||
|
||||
function emitTopic(t) {
|
||||
emit('update:topic', getBrochureTopic(t))
|
||||
}
|
||||
|
||||
function scrollToHash(hash) {
|
||||
const el = document.getElementById(hash)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-bro-preview {
|
||||
padding: 4px 4px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #00d4ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.breadcrumb .sep {
|
||||
margin: 0 6px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.home-bro-preview h1 {
|
||||
font-size: clamp(20px, 3.2vw, 28px);
|
||||
margin-bottom: 8px;
|
||||
font-family: 'Exo 2', sans-serif;
|
||||
}
|
||||
.lead {
|
||||
color: #00d4ff;
|
||||
font-size: 15px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.para {
|
||||
line-height: 1.75;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.subsec {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.subsec h3 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
font-family: 'Exo 2', sans-serif;
|
||||
}
|
||||
.subsec ul,
|
||||
.bullet-list,
|
||||
.hl-list {
|
||||
padding-left: 1.2em;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
.subsec li,
|
||||
.bullet-list li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.note {
|
||||
margin-top: 12px;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 14px;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(30, 58, 95, 0.22);
|
||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
.card h3 {
|
||||
color: #00d4ff;
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.contacts {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.contact-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.contact-row .label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
min-width: 72px;
|
||||
}
|
||||
.contact-row a {
|
||||
color: #00d4ff;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.tag {
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 11px;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||
}
|
||||
.closing {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.extend-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 212, 255, 0.06);
|
||||
border: 1px solid rgba(0, 212, 255, 0.18);
|
||||
}
|
||||
.extend-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.extend-chip {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 212, 255, 0.35);
|
||||
background: rgba(30, 58, 95, 0.45);
|
||||
font-family: inherit;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.extend-chip:hover {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
.kw-hint {
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 212, 255, 0.04);
|
||||
border: 1px dashed rgba(0, 212, 255, 0.18);
|
||||
}
|
||||
.page-cross-links {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-cross-links a {
|
||||
color: #00d4ff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.page-cross-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.page-cross-links .dot {
|
||||
margin: 0 5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.home-contact-hint a {
|
||||
color: #00d4ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.home-contact-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chapter-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 20px 0 16px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(30, 58, 95, 0.2);
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
.chapter-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.chapter-btn:hover:not(.disabled) {
|
||||
background: rgba(0, 212, 255, 0.22);
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
.chapter-btn.mid {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chapter-btn.disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.all-chapters {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.all-chapters-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chapter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chapter-chip {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
background: rgba(10, 10, 18, 0.65);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.chapter-chip:hover {
|
||||
border-color: #00d4ff;
|
||||
color: #00d4ff;
|
||||
}
|
||||
.chapter-chip.current {
|
||||
border-color: #00d4ff;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
color: #00d4ff;
|
||||
}
|
||||
:deep(.card-body-rich) {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,8 @@ const SOCIAL = 'social'
|
||||
|
||||
/**
|
||||
* 产品视频与封面统一使用 promotion/social/ 下英文文件名(无空格、无中文路径),便于线上 URL 与 Nginx。
|
||||
* 静态:/promotion/social/xxx.mov;后台上传路径:promotion/social/ + 下列文件名,勾选「保留原文件名」。
|
||||
* 视频请使用 **.mp4(H.264+AAC)** 以保证各系统浏览器可播;剪辑导出多为 .mov 时,用仓库 `scripts/transcode-promotion-videos.*` 转码后再部署。
|
||||
* 静态:/promotion/social/xxx.mp4;后台上传路径:promotion/social/ + 下列文件名,勾选「保留原文件名」。
|
||||
*/
|
||||
export const PROMOTION_VIDEOS_BASE = [
|
||||
{
|
||||
@@ -13,35 +14,35 @@ export const PROMOTION_VIDEOS_BASE = [
|
||||
title: '操作与计算软件实例(一)',
|
||||
desc: '宇恒一号宣传片',
|
||||
relCover: `${SOCIAL}/video-calc-demo-1-cover.jpg`,
|
||||
relVideo: `${SOCIAL}/video-calc-demo-1.mov`
|
||||
relVideo: `${SOCIAL}/video-calc-demo-1.mp4`
|
||||
},
|
||||
{
|
||||
id: 'calc-demo-2',
|
||||
title: '操作与计算软件实例(二)',
|
||||
desc: '进阶操作与计算演示',
|
||||
relCover: `${SOCIAL}/video-calc-demo-2-cover.jpg`,
|
||||
relVideo: `${SOCIAL}/video-calc-demo-2.mov`
|
||||
relVideo: `${SOCIAL}/video-calc-demo-2.mp4`
|
||||
},
|
||||
{
|
||||
id: 'aiword',
|
||||
title: '宇恒一号 AI Word 简介',
|
||||
desc: 'AI Word 能力介绍',
|
||||
relCover: `${SOCIAL}/video-aiword-cover.jpg`,
|
||||
relVideo: `${SOCIAL}/video-aiword.mov`
|
||||
relVideo: `${SOCIAL}/video-aiword.mp4`
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
title: '语音办公实例',
|
||||
desc: '语音驱动办公流程',
|
||||
relCover: `${SOCIAL}/video-voice-office-cover.jpg`,
|
||||
relVideo: `${SOCIAL}/video-voice-office.mov`
|
||||
relVideo: `${SOCIAL}/video-voice-office.mp4`
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
title: 'AI 全自动办发票',
|
||||
desc: '发票场景自动化演示',
|
||||
relCover: `${SOCIAL}/video-invoice-ai-cover.jpg`,
|
||||
relVideo: `${SOCIAL}/video-invoice-ai.mov`
|
||||
relVideo: `${SOCIAL}/video-invoice-ai.mp4`
|
||||
}
|
||||
]
|
||||
|
||||
@@ -58,7 +59,7 @@ function responseIsUsableAsset(res) {
|
||||
/**
|
||||
* 检测同域静态 /promotion/ 文件是否真实存在。
|
||||
* - 需配合 Nginx:`location ^~ /promotion/ { try_files $uri =404; }`,避免缺失时返回 index.html 误判为 200。
|
||||
* - HEAD 非 2xx 或疑似 SPA 回退时,再用 Range GET 探测(部分环境对 .mov 的 HEAD 不友好)。
|
||||
* - HEAD 非 2xx 或疑似 SPA 回退时,再用 Range GET 探测(部分环境对大视频 HEAD 不友好)。
|
||||
* @param {string} url
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
@@ -94,7 +95,7 @@ const apiOnly =
|
||||
/**
|
||||
* 有静态则静态,否则(需 siteId)走 promotion-media API(单资源;列表请用 buildPromotionVideosAsync 批量逻辑)
|
||||
* @param {string} siteId
|
||||
* @param {string} relPath promotion 下相对路径,如 social/xxx.mov
|
||||
* @param {string} relPath promotion 下相对路径,如 social/xxx.mp4
|
||||
*/
|
||||
export async function pickPromotionAssetUrl(siteId, relPath) {
|
||||
const sid = String(siteId || defaultWebSiteId || '').trim()
|
||||
|
||||
@@ -68,35 +68,13 @@
|
||||
<div class="portal-shell">
|
||||
<div class="portal-inner portal-inner--main">
|
||||
<main class="portal-main">
|
||||
<!-- 参考政务门户:左主栏首行 = 轮播 + 右侧列表 -->
|
||||
<!-- 左:当前章节正文预览;右:章节列表点击切换(不跳转路由) -->
|
||||
<div class="portal-row-split portal-card">
|
||||
<div class="portal-carousel-wrap">
|
||||
<h3 class="portal-block-title">产品亮点</h3>
|
||||
<div class="portal-carousel">
|
||||
<router-link v-if="currentSpotlight" :to="currentSpotlight.to" class="portal-carousel-slide">
|
||||
<div class="portal-carousel-visual">
|
||||
<div class="portal-carousel-icon">
|
||||
<svg viewBox="0 0 24 24"><path :d="currentSpotlight.icon"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-carousel-caption">
|
||||
<h4>{{ currentSpotlight.title }}</h4>
|
||||
<p>{{ currentSpotlight.desc }}</p>
|
||||
<span class="portal-carousel-more">进入宣传册:{{ currentSpotlight.linkLabel }} →</span>
|
||||
</div>
|
||||
</router-link>
|
||||
<button type="button" class="portal-carousel-nav prev" aria-label="上一则" @click="spotlightPrev">‹</button>
|
||||
<button type="button" class="portal-carousel-nav next" aria-label="下一则" @click="spotlightNext">›</button>
|
||||
<div class="portal-carousel-dots">
|
||||
<button
|
||||
v-for="(_, i) in spotlightSlides"
|
||||
:key="i"
|
||||
type="button"
|
||||
:class="{ active: i === spotlightIndex }"
|
||||
:aria-label="'切换到第' + (i + 1) + '则'"
|
||||
@click="spotlightIndex = i"
|
||||
/>
|
||||
</div>
|
||||
<div class="portal-preview-wrap">
|
||||
<h3 class="portal-block-title">宣传册预览</h3>
|
||||
<p class="portal-preview-tip">在右侧选择章节,此处即时展示正文(与完整宣传册同源文案)</p>
|
||||
<div class="portal-preview-body">
|
||||
<HomeBrochurePreview :topic="selectedBrochureTopic" @update:topic="selectedBrochureTopic = $event" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-news-panel">
|
||||
@@ -104,42 +82,23 @@
|
||||
<div class="portal-news-scroll-wrap">
|
||||
<ul class="portal-news-list">
|
||||
<li v-for="(b, idx) in brochureEntryLinks" :key="b.topic">
|
||||
<router-link :to="'/brochure/' + b.topic">{{ b.label }}</router-link>
|
||||
<button
|
||||
type="button"
|
||||
class="portal-news-link-btn"
|
||||
:class="{ active: selectedBrochureTopic === b.topic }"
|
||||
@click="selectedBrochureTopic = b.topic"
|
||||
>{{ b.label }}</button>
|
||||
<span class="portal-news-date">{{ formatBrochureListDate(idx) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<router-link to="/brochure/company" class="portal-read-all">阅读完整宣传册 →</router-link>
|
||||
<router-link
|
||||
:to="'/brochure/' + selectedBrochureTopic"
|
||||
class="portal-read-all"
|
||||
>当前章节完整页 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立宣传资料栏:与宣传册目录式布局呼应,右侧超出可横向滑动 -->
|
||||
<section class="promo-materials portal-card" id="promo-materials" aria-label="宣传资料">
|
||||
<div class="promo-materials-head">
|
||||
<h3 class="portal-block-title">宣传资料</h3>
|
||||
<p class="promo-materials-sub">章节入口与线上海报册一致;卡片过多时可左右滑动</p>
|
||||
</div>
|
||||
<p class="promo-materials-hint" aria-hidden="true">← 横向滑动查看更多 →</p>
|
||||
<div class="promo-materials-track-outer">
|
||||
<div class="promo-materials-track">
|
||||
<router-link
|
||||
v-for="(b, idx) in brochureEntryLinks"
|
||||
:key="'pm-' + b.topic"
|
||||
:to="'/brochure/' + b.topic"
|
||||
class="promo-material-card"
|
||||
>
|
||||
<span class="promo-material-num">{{ promoIndexLabel(idx) }}</span>
|
||||
<span class="promo-material-title">{{ b.label }}</span>
|
||||
<span class="promo-material-arrow" aria-hidden="true">→</span>
|
||||
</router-link>
|
||||
<router-link to="/brochure/company" class="promo-material-card promo-material-card--all">
|
||||
<span class="promo-material-title">完整宣传册</span>
|
||||
<span class="promo-material-arrow" aria-hidden="true">→</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 统计数据(与 promotion/index.html 一致) -->
|
||||
<section class="stats-section portal-card-lite">
|
||||
<div class="stats-container">
|
||||
@@ -367,7 +326,6 @@
|
||||
<a href="#features" @click.prevent="scrollToSel('#features')">功能特性</a>
|
||||
<a href="#scenarios" @click.prevent="scrollToSel('#scenarios')">应用场景</a>
|
||||
<router-link to="/brochure/company">宣传册</router-link>
|
||||
<a href="#promo-materials" @click.prevent="scrollToSel('#promo-materials')">宣传资料</a>
|
||||
<a href="#faq" @click.prevent="scrollToSel('#faq')">常见问题</a>
|
||||
<a href="#contact" @click.prevent="scrollToSel('#contact')">联系我们</a>
|
||||
<a href="#videos" @click.prevent="scrollToSel('#videos')">产品视频</a>
|
||||
@@ -414,12 +372,16 @@
|
||||
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { apiBase } from '../config'
|
||||
import BlockRenderer from '../components/blocks/BlockRenderer.vue'
|
||||
import HomeBrochurePreview from '../components/HomeBrochurePreview.vue'
|
||||
import { buildPromotionVideos, buildPromotionVideosAsync } from '../data/promotionVideos'
|
||||
import { PROMOTION_SOCIAL_FOLLOW } from '../data/promotionSocial'
|
||||
import { getCachedWebSiteId, fetchWebRoutes } from '../api/webPages'
|
||||
|
||||
const socialFollowItems = PROMOTION_SOCIAL_FOLLOW
|
||||
|
||||
/** 首页门户:宣传册预览当前章节(与 brochureContent 同源) */
|
||||
const selectedBrochureTopic = ref('company')
|
||||
|
||||
/** 「关注我们」列表图标:必须用响应式候选下标驱动 :src,勿在 @error 里只改 DOM,否则重渲染会回到 candidates[0] 裂图 */
|
||||
const socialListImgIdx = reactive(Object.fromEntries(PROMOTION_SOCIAL_FOLLOW.map((x) => [x.id, 0])))
|
||||
function socialListImgSrc(item) {
|
||||
@@ -480,7 +442,6 @@ const defaultData = () => ({
|
||||
{ label: '应用场景', url: '#scenarios' },
|
||||
{ label: '产品视频', url: '#videos' },
|
||||
{ label: '宣传册', url: '/brochure/company' },
|
||||
{ label: '宣传资料', url: '#promo-materials' },
|
||||
{ label: '关注我们', url: '#social-follow' },
|
||||
{ label: '联系我们', url: '#contact' }
|
||||
],
|
||||
@@ -581,11 +542,6 @@ function formatBrochureListDate(idx) {
|
||||
return mm + '-' + dd
|
||||
}
|
||||
|
||||
/** 宣传资料卡片序号(与宣传册侧栏目录 01–10 一致) */
|
||||
function promoIndexLabel(i) {
|
||||
return String(i + 1).padStart(2, '0')
|
||||
}
|
||||
|
||||
const scenarioCards = [
|
||||
{ title: '企业办公', desc: '文档管理、数据报表、会议纪要、任务追踪', icon: 'M20 6h-4V4c0-1.1-.9-2-2-2h-4c-1.1 0-2 .9-2 2v2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-6 0h-4v2h4v2h-4v2h4v2H8V6h6v2h4V6z', to: '/brochure/scenarios' },
|
||||
{ title: '数据分析', desc: '销售分析、用户统计、财务对账、批量处理', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z', to: '/brochure/scenarios' },
|
||||
@@ -1055,111 +1011,34 @@ onUnmounted(() => {
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.portal-carousel-wrap { min-width: 0; }
|
||||
.portal-carousel {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, rgba(30, 58, 95, 0.35), rgba(10, 10, 18, 0.9));
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
min-height: 220px;
|
||||
}
|
||||
.portal-carousel-slide {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.portal-carousel-visual {
|
||||
min-height: 120px;
|
||||
.portal-preview-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, rgba(0, 212, 255, 0.08), transparent);
|
||||
flex-direction: column;
|
||||
}
|
||||
.portal-carousel-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, var(--plasma-cyan), var(--plasma-pink));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
.portal-carousel-icon svg { width: 40px; height: 40px; fill: var(--space-dark); }
|
||||
.portal-carousel-caption {
|
||||
padding: 16px 20px 48px;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.portal-carousel-caption h4 {
|
||||
font-family: 'Exo 2', sans-serif;
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--star-white);
|
||||
}
|
||||
.portal-carousel-caption p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.portal-carousel-more {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
.portal-preview-tip {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--plasma-cyan);
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
.portal-carousel-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
opacity: 0.38;
|
||||
transition: background 0.2s, border-color 0.2s, opacity 0.25s;
|
||||
.portal-preview-body {
|
||||
max-height: min(72vh, 620px);
|
||||
overflow: auto;
|
||||
padding: 12px 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.portal-carousel:hover .portal-carousel-nav {
|
||||
opacity: 1;
|
||||
}
|
||||
.portal-carousel-nav:hover {
|
||||
background: rgba(0, 212, 255, 0.25);
|
||||
border-color: var(--plasma-cyan);
|
||||
}
|
||||
.portal-carousel-nav.prev { left: 10px; }
|
||||
.portal-carousel-nav.next { right: 10px; }
|
||||
.portal-carousel-dots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
.portal-carousel-dots button {
|
||||
.portal-preview-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.portal-carousel-dots button.active {
|
||||
background: var(--plasma-cyan);
|
||||
transform: scale(1.15);
|
||||
.portal-preview-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 255, 0.35);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.portal-news-panel { min-width: 0; display: flex; flex-direction: column; }
|
||||
.portal-news-scroll-wrap {
|
||||
@@ -1194,16 +1073,21 @@ onUnmounted(() => {
|
||||
font-size: 14px;
|
||||
}
|
||||
.portal-news-list li:last-child { border-bottom: none; }
|
||||
.portal-news-list a {
|
||||
.portal-news-link-btn {
|
||||
position: relative;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
line-height: 1.5;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.portal-news-list a::after {
|
||||
.portal-news-link-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -1214,10 +1098,17 @@ onUnmounted(() => {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.85), var(--plasma-cyan));
|
||||
transition: width 0.28s ease;
|
||||
}
|
||||
.portal-news-list a:hover {
|
||||
.portal-news-link-btn:hover {
|
||||
color: var(--plasma-cyan);
|
||||
}
|
||||
.portal-news-list a:hover::after {
|
||||
.portal-news-link-btn:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
.portal-news-link-btn.active {
|
||||
color: var(--plasma-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
.portal-news-link-btn.active::after {
|
||||
width: 100%;
|
||||
}
|
||||
.portal-news-date {
|
||||
@@ -1235,102 +1126,6 @@ onUnmounted(() => {
|
||||
}
|
||||
.portal-read-all:hover { text-decoration: underline; }
|
||||
|
||||
/* 宣传资料:横向轨道(与宣传册页目录编号风格一致) */
|
||||
.promo-materials { margin-bottom: 28px; }
|
||||
.promo-materials-head { margin-bottom: 8px; }
|
||||
.promo-materials-sub {
|
||||
margin: 0 0 6px;
|
||||
padding-left: 13px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
.promo-materials-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-align: right;
|
||||
}
|
||||
.promo-materials-track-outer {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 6px;
|
||||
margin: 0 -6px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.promo-materials-track-outer::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.promo-materials-track-outer::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.promo-materials-track {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 14px;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding: 6px 2px 10px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
.promo-material-card {
|
||||
flex: 0 0 auto;
|
||||
width: 168px;
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 16px 14px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: linear-gradient(165deg, rgba(30, 58, 95, 0.45), rgba(10, 12, 22, 0.92));
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
|
||||
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.promo-material-card:hover {
|
||||
border-color: rgba(0, 212, 255, 0.55);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 28px rgba(0, 212, 255, 0.12);
|
||||
}
|
||||
.promo-material-num {
|
||||
font-family: 'Exo 2', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--plasma-cyan);
|
||||
opacity: 0.95;
|
||||
}
|
||||
.promo-material-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.promo-material-arrow {
|
||||
margin-top: auto;
|
||||
font-size: 13px;
|
||||
color: var(--plasma-cyan);
|
||||
}
|
||||
.promo-material-card--all {
|
||||
width: auto;
|
||||
min-width: 148px;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.18), rgba(255, 45, 149, 0.12));
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.promo-material-card--all .promo-material-title {
|
||||
font-family: 'Exo 2', sans-serif;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.portal-aside { min-width: 0; }
|
||||
/* 右侧悬停:固定于视口最右侧,滚动时保持可见 */
|
||||
.portal-aside--fixed {
|
||||
|
||||
Reference in New Issue
Block a user