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/)
|
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
|
||||||
ALLOWED_ORIGINS=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 后执行)
|
# 部署时自动导入「视频发布」到 data/uploads + site_assets(compose up 后执行)
|
||||||
# 官网站点 MongoDB _id;pull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env,一般无需手改
|
# 官网站点 MongoDB _id;pull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env,一般无需手改
|
||||||
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef
|
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/
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
FROM ${REGISTRY_MIRROR}alpine:3.19
|
FROM ${REGISTRY_MIRROR}alpine:3.19
|
||||||
WORKDIR /app
|
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
|
ENV TZ=Asia/Shanghai
|
||||||
COPY --from=builder /app/server .
|
COPY --from=builder /app/server .
|
||||||
EXPOSE 8088
|
EXPOSE 8088
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app,/app/server 由宿主机构建
|
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app,/app/server 由宿主机构建
|
||||||
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
FROM ${REGISTRY_MIRROR}alpine:3.19
|
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
|
ENV TZ=Asia/Shanghai
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8088
|
EXPOSE 8088
|
||||||
|
|||||||
@@ -22,3 +22,8 @@ go run main.go
|
|||||||
```
|
```
|
||||||
|
|
||||||
默认端口 8080
|
默认端口 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
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
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 删除站点资源
|
// 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 == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||||
|
go handlers.SweepPromotionTranscodeOnStartup()
|
||||||
|
|
||||||
r.Run(":" + port)
|
r.Run(":" + port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"transcode": "node scripts/auto-transcode-promotion.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,11 +15,28 @@
|
|||||||
|
|
||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `video-calc-demo-1-cover.jpg` / `video-calc-demo-1.mov` | 操作与计算(一) |
|
| `video-calc-demo-1-cover.jpg` / `video-calc-demo-1.mp4` | 操作与计算(一) |
|
||||||
| `video-calc-demo-2-cover.jpg` / `video-calc-demo-2.mov` | 操作与计算(二) |
|
| `video-calc-demo-2-cover.jpg` / `video-calc-demo-2.mp4` | 操作与计算(二) |
|
||||||
| `video-aiword-cover.jpg` / `video-aiword.mov` | AI Word |
|
| `video-aiword-cover.jpg` / `video-aiword.mp4` | AI Word |
|
||||||
| `video-voice-office-cover.jpg` / `video-voice-office.mov` | 语音办公 |
|
| `video-voice-office-cover.jpg` / `video-voice-office.mp4` | 语音办公 |
|
||||||
| `video-invoice-ai-cover.jpg` / `video-invoice-ai.mov` | 办发票 |
|
| `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`
|
线上访问示例:`https://你的域名/promotion/social/douyin.png`
|
||||||
(须将 `web/promotion` 同步到 **`deploy/web/dist/promotion`**,见 `pull-and-restart.sh`。)
|
(须将 `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/ 下英文文件名(无空格、无中文路径),便于线上 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 = [
|
export const PROMOTION_VIDEOS_BASE = [
|
||||||
{
|
{
|
||||||
@@ -13,35 +14,35 @@ export const PROMOTION_VIDEOS_BASE = [
|
|||||||
title: '操作与计算软件实例(一)',
|
title: '操作与计算软件实例(一)',
|
||||||
desc: '宇恒一号宣传片',
|
desc: '宇恒一号宣传片',
|
||||||
relCover: `${SOCIAL}/video-calc-demo-1-cover.jpg`,
|
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',
|
id: 'calc-demo-2',
|
||||||
title: '操作与计算软件实例(二)',
|
title: '操作与计算软件实例(二)',
|
||||||
desc: '进阶操作与计算演示',
|
desc: '进阶操作与计算演示',
|
||||||
relCover: `${SOCIAL}/video-calc-demo-2-cover.jpg`,
|
relCover: `${SOCIAL}/video-calc-demo-2-cover.jpg`,
|
||||||
relVideo: `${SOCIAL}/video-calc-demo-2.mov`
|
relVideo: `${SOCIAL}/video-calc-demo-2.mp4`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'aiword',
|
id: 'aiword',
|
||||||
title: '宇恒一号 AI Word 简介',
|
title: '宇恒一号 AI Word 简介',
|
||||||
desc: 'AI Word 能力介绍',
|
desc: 'AI Word 能力介绍',
|
||||||
relCover: `${SOCIAL}/video-aiword-cover.jpg`,
|
relCover: `${SOCIAL}/video-aiword-cover.jpg`,
|
||||||
relVideo: `${SOCIAL}/video-aiword.mov`
|
relVideo: `${SOCIAL}/video-aiword.mp4`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'voice',
|
id: 'voice',
|
||||||
title: '语音办公实例',
|
title: '语音办公实例',
|
||||||
desc: '语音驱动办公流程',
|
desc: '语音驱动办公流程',
|
||||||
relCover: `${SOCIAL}/video-voice-office-cover.jpg`,
|
relCover: `${SOCIAL}/video-voice-office-cover.jpg`,
|
||||||
relVideo: `${SOCIAL}/video-voice-office.mov`
|
relVideo: `${SOCIAL}/video-voice-office.mp4`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'invoice',
|
id: 'invoice',
|
||||||
title: 'AI 全自动办发票',
|
title: 'AI 全自动办发票',
|
||||||
desc: '发票场景自动化演示',
|
desc: '发票场景自动化演示',
|
||||||
relCover: `${SOCIAL}/video-invoice-ai-cover.jpg`,
|
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/ 文件是否真实存在。
|
* 检测同域静态 /promotion/ 文件是否真实存在。
|
||||||
* - 需配合 Nginx:`location ^~ /promotion/ { try_files $uri =404; }`,避免缺失时返回 index.html 误判为 200。
|
* - 需配合 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
|
* @param {string} url
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
@@ -94,7 +95,7 @@ const apiOnly =
|
|||||||
/**
|
/**
|
||||||
* 有静态则静态,否则(需 siteId)走 promotion-media API(单资源;列表请用 buildPromotionVideosAsync 批量逻辑)
|
* 有静态则静态,否则(需 siteId)走 promotion-media API(单资源;列表请用 buildPromotionVideosAsync 批量逻辑)
|
||||||
* @param {string} siteId
|
* @param {string} siteId
|
||||||
* @param {string} relPath promotion 下相对路径,如 social/xxx.mov
|
* @param {string} relPath promotion 下相对路径,如 social/xxx.mp4
|
||||||
*/
|
*/
|
||||||
export async function pickPromotionAssetUrl(siteId, relPath) {
|
export async function pickPromotionAssetUrl(siteId, relPath) {
|
||||||
const sid = String(siteId || defaultWebSiteId || '').trim()
|
const sid = String(siteId || defaultWebSiteId || '').trim()
|
||||||
|
|||||||
@@ -68,35 +68,13 @@
|
|||||||
<div class="portal-shell">
|
<div class="portal-shell">
|
||||||
<div class="portal-inner portal-inner--main">
|
<div class="portal-inner portal-inner--main">
|
||||||
<main class="portal-main">
|
<main class="portal-main">
|
||||||
<!-- 参考政务门户:左主栏首行 = 轮播 + 右侧列表 -->
|
<!-- 左:当前章节正文预览;右:章节列表点击切换(不跳转路由) -->
|
||||||
<div class="portal-row-split portal-card">
|
<div class="portal-row-split portal-card">
|
||||||
<div class="portal-carousel-wrap">
|
<div class="portal-preview-wrap">
|
||||||
<h3 class="portal-block-title">产品亮点</h3>
|
<h3 class="portal-block-title">宣传册预览</h3>
|
||||||
<div class="portal-carousel">
|
<p class="portal-preview-tip">在右侧选择章节,此处即时展示正文(与完整宣传册同源文案)</p>
|
||||||
<router-link v-if="currentSpotlight" :to="currentSpotlight.to" class="portal-carousel-slide">
|
<div class="portal-preview-body">
|
||||||
<div class="portal-carousel-visual">
|
<HomeBrochurePreview :topic="selectedBrochureTopic" @update:topic="selectedBrochureTopic = $event" />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="portal-news-panel">
|
<div class="portal-news-panel">
|
||||||
@@ -104,41 +82,22 @@
|
|||||||
<div class="portal-news-scroll-wrap">
|
<div class="portal-news-scroll-wrap">
|
||||||
<ul class="portal-news-list">
|
<ul class="portal-news-list">
|
||||||
<li v-for="(b, idx) in brochureEntryLinks" :key="b.topic">
|
<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>
|
<span class="portal-news-date">{{ formatBrochureListDate(idx) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/brochure/company" 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
|
<router-link
|
||||||
v-for="(b, idx) in brochureEntryLinks"
|
:to="'/brochure/' + selectedBrochureTopic"
|
||||||
:key="'pm-' + b.topic"
|
class="portal-read-all"
|
||||||
:to="'/brochure/' + b.topic"
|
>当前章节完整页 →</router-link>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 统计数据(与 promotion/index.html 一致) -->
|
<!-- 统计数据(与 promotion/index.html 一致) -->
|
||||||
<section class="stats-section portal-card-lite">
|
<section class="stats-section portal-card-lite">
|
||||||
@@ -367,7 +326,6 @@
|
|||||||
<a href="#features" @click.prevent="scrollToSel('#features')">功能特性</a>
|
<a href="#features" @click.prevent="scrollToSel('#features')">功能特性</a>
|
||||||
<a href="#scenarios" @click.prevent="scrollToSel('#scenarios')">应用场景</a>
|
<a href="#scenarios" @click.prevent="scrollToSel('#scenarios')">应用场景</a>
|
||||||
<router-link to="/brochure/company">宣传册</router-link>
|
<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="#faq" @click.prevent="scrollToSel('#faq')">常见问题</a>
|
||||||
<a href="#contact" @click.prevent="scrollToSel('#contact')">联系我们</a>
|
<a href="#contact" @click.prevent="scrollToSel('#contact')">联系我们</a>
|
||||||
<a href="#videos" @click.prevent="scrollToSel('#videos')">产品视频</a>
|
<a href="#videos" @click.prevent="scrollToSel('#videos')">产品视频</a>
|
||||||
@@ -414,12 +372,16 @@
|
|||||||
import { ref, reactive, computed, watch, 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 HomeBrochurePreview from '../components/HomeBrochurePreview.vue'
|
||||||
import { buildPromotionVideos, buildPromotionVideosAsync } from '../data/promotionVideos'
|
import { buildPromotionVideos, buildPromotionVideosAsync } from '../data/promotionVideos'
|
||||||
import { PROMOTION_SOCIAL_FOLLOW } from '../data/promotionSocial'
|
import { PROMOTION_SOCIAL_FOLLOW } from '../data/promotionSocial'
|
||||||
import { getCachedWebSiteId, fetchWebRoutes } from '../api/webPages'
|
import { getCachedWebSiteId, fetchWebRoutes } from '../api/webPages'
|
||||||
|
|
||||||
const socialFollowItems = PROMOTION_SOCIAL_FOLLOW
|
const socialFollowItems = PROMOTION_SOCIAL_FOLLOW
|
||||||
|
|
||||||
|
/** 首页门户:宣传册预览当前章节(与 brochureContent 同源) */
|
||||||
|
const selectedBrochureTopic = ref('company')
|
||||||
|
|
||||||
/** 「关注我们」列表图标:必须用响应式候选下标驱动 :src,勿在 @error 里只改 DOM,否则重渲染会回到 candidates[0] 裂图 */
|
/** 「关注我们」列表图标:必须用响应式候选下标驱动 :src,勿在 @error 里只改 DOM,否则重渲染会回到 candidates[0] 裂图 */
|
||||||
const socialListImgIdx = reactive(Object.fromEntries(PROMOTION_SOCIAL_FOLLOW.map((x) => [x.id, 0])))
|
const socialListImgIdx = reactive(Object.fromEntries(PROMOTION_SOCIAL_FOLLOW.map((x) => [x.id, 0])))
|
||||||
function socialListImgSrc(item) {
|
function socialListImgSrc(item) {
|
||||||
@@ -480,7 +442,6 @@ const defaultData = () => ({
|
|||||||
{ label: '应用场景', url: '#scenarios' },
|
{ label: '应用场景', url: '#scenarios' },
|
||||||
{ label: '产品视频', url: '#videos' },
|
{ label: '产品视频', url: '#videos' },
|
||||||
{ label: '宣传册', url: '/brochure/company' },
|
{ label: '宣传册', url: '/brochure/company' },
|
||||||
{ label: '宣传资料', url: '#promo-materials' },
|
|
||||||
{ label: '关注我们', url: '#social-follow' },
|
{ label: '关注我们', url: '#social-follow' },
|
||||||
{ label: '联系我们', url: '#contact' }
|
{ label: '联系我们', url: '#contact' }
|
||||||
],
|
],
|
||||||
@@ -581,11 +542,6 @@ function formatBrochureListDate(idx) {
|
|||||||
return mm + '-' + dd
|
return mm + '-' + dd
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 宣传资料卡片序号(与宣传册侧栏目录 01–10 一致) */
|
|
||||||
function promoIndexLabel(i) {
|
|
||||||
return String(i + 1).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenarioCards = [
|
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: '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' },
|
{ 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;
|
gap: 24px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.portal-carousel-wrap { min-width: 0; }
|
.portal-preview-wrap {
|
||||||
.portal-carousel {
|
min-width: 0;
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(180deg, rgba(0, 212, 255, 0.08), transparent);
|
|
||||||
}
|
}
|
||||||
.portal-carousel-icon {
|
.portal-preview-tip {
|
||||||
width: 80px;
|
margin: 0 0 12px;
|
||||||
height: 80px;
|
padding-left: 13px;
|
||||||
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;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--plasma-cyan);
|
line-height: 1.55;
|
||||||
letter-spacing: 0.5px;
|
color: rgba(255, 255, 255, 0.48);
|
||||||
}
|
}
|
||||||
.portal-carousel-nav {
|
.portal-preview-body {
|
||||||
position: absolute;
|
max-height: min(72vh, 620px);
|
||||||
top: 50%;
|
overflow: auto;
|
||||||
transform: translateY(-50%);
|
padding: 12px 14px 16px;
|
||||||
width: 36px;
|
border-radius: 12px;
|
||||||
height: 36px;
|
border: 1px solid rgba(0, 212, 255, 0.12);
|
||||||
border-radius: 50%;
|
background: rgba(0, 0, 0, 0.22);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
-webkit-overflow-scrolling: touch;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
scrollbar-width: thin;
|
||||||
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-carousel:hover .portal-carousel-nav {
|
.portal-preview-body::-webkit-scrollbar {
|
||||||
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 {
|
|
||||||
width: 8px;
|
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 {
|
.portal-preview-body::-webkit-scrollbar-thumb {
|
||||||
background: var(--plasma-cyan);
|
background: rgba(0, 212, 255, 0.35);
|
||||||
transform: scale(1.15);
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.portal-news-panel { min-width: 0; display: flex; flex-direction: column; }
|
.portal-news-panel { min-width: 0; display: flex; flex-direction: column; }
|
||||||
.portal-news-scroll-wrap {
|
.portal-news-scroll-wrap {
|
||||||
@@ -1194,16 +1073,21 @@ onUnmounted(() => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.portal-news-list li:last-child { border-bottom: none; }
|
.portal-news-list li:last-child { border-bottom: none; }
|
||||||
.portal-news-list a {
|
.portal-news-link-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
|
||||||
text-decoration: none;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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;
|
line-height: 1.5;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
.portal-news-list a::after {
|
.portal-news-link-btn::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -1214,10 +1098,17 @@ onUnmounted(() => {
|
|||||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.85), var(--plasma-cyan));
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0.85), var(--plasma-cyan));
|
||||||
transition: width 0.28s ease;
|
transition: width 0.28s ease;
|
||||||
}
|
}
|
||||||
.portal-news-list a:hover {
|
.portal-news-link-btn:hover {
|
||||||
color: var(--plasma-cyan);
|
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%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.portal-news-date {
|
.portal-news-date {
|
||||||
@@ -1235,102 +1126,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.portal-read-all:hover { text-decoration: underline; }
|
.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 { min-width: 0; }
|
||||||
/* 右侧悬停:固定于视口最右侧,滚动时保持可见 */
|
/* 右侧悬停:固定于视口最右侧,滚动时保持可见 */
|
||||||
.portal-aside--fixed {
|
.portal-aside--fixed {
|
||||||
|
|||||||
Reference in New Issue
Block a user