feat: 服务端 promotion 视频自动转码;首页宣传册预览与 mp4 配置

Made-with: Cursor
This commit is contained in:
whm
2026-03-23 16:12:43 +08:00
parent d37e9a3663
commit ea90052e7e
15 changed files with 965 additions and 279 deletions

View File

@@ -0,0 +1,40 @@
# 将 web\promotion\social 下产品视频从 .mov 转为网页通用 .mp4H.264 + AACfaststart
# 依赖:已安装 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。请安装并加入 PATHhttps://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

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 将 web/promotion/social 下产品视频从 .mov 转为网页通用 .mp4H.264 + AACfaststart
# 依赖:已安装 ffmpegmacOS: brew install ffmpegUbuntu: 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 以减小体积(可选)。"

View File

@@ -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_assetscompose up 后执行)
# 官网站点 MongoDB _idpull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env一般无需手改
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef

View File

@@ -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

View File

@@ -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

View File

@@ -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`

View File

@@ -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 删除站点资源

View 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
})
}
}

View File

@@ -225,5 +225,9 @@ func main() {
if port == "" {
port = "8080"
}
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
go handlers.SweepPromotionTranscodeOnStartup()
r.Run(":" + port)
}

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"transcode": "node scripts/auto-transcode-promotion.mjs",
"build": "vite build",
"preview": "vite preview"
},

View File

@@ -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`。)

View 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。请安装并加入 PATHhttps://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()

View 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>

View File

@@ -5,7 +5,8 @@ const SOCIAL = 'social'
/**
* 产品视频与封面统一使用 promotion/social/ 下英文文件名(无空格、无中文路径),便于线上 URL 与 Nginx。
* 静态:/promotion/social/xxx.mov后台上传路径promotion/social/ + 下列文件名,勾选「保留原文件名」
* 视频请使用 **.mp4H.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()

View File

@@ -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,41 +82,22 @@
<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>
</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>
:to="'/brochure/' + selectedBrochureTopic"
class="portal-read-all"
>当前章节完整页 </router-link>
</div>
</div>
</section>
<!-- 统计数据 promotion/index.html 一致 -->
<section class="stats-section portal-card-lite">
@@ -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
}
/** 宣传资料卡片序号(与宣传册侧栏目录 0110 一致) */
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 {