Compare commits
83 Commits
v1.5.18-debug
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 818fe7cc03 | |||
| c6e3e6dd25 | |||
| daba6dcc14 | |||
| 6a2302401f | |||
| b76252b0ac | |||
| bb84cb5604 | |||
| 184ab8bce3 | |||
| 2c9e0f0015 | |||
| 06ec0ee202 | |||
| 616649c872 | |||
| fae2a77734 | |||
| 53371aabcd | |||
| 9589d7c78a | |||
| bf51d8b423 | |||
| db34090d5d | |||
| d18e705a99 | |||
| 6011225eec | |||
| 222c468681 | |||
| 430aea4aa8 | |||
| df6915191a | |||
| 9733a7f311 | |||
| 29f74f7afc | |||
| 8a5f0ace34 | |||
| f01f2c366a | |||
| c55c256dc7 | |||
| a7c81c14eb | |||
| 7f43795b2e | |||
| 9870a8cbc8 | |||
| 538cb1a367 | |||
| a50c61bbb5 | |||
| 19a166a873 | |||
| 1cb1c0b387 | |||
| 1a0679049e | |||
| 91774f52ee | |||
| 34d6f671fe | |||
| 5386a1dbf4 | |||
| abf03712a5 | |||
| 31fec11c44 | |||
| 0a52195490 | |||
| aebc9f6bcc | |||
| 574874c856 | |||
| 497e65d86d | |||
| 372b36becc | |||
| 582068b599 | |||
| 1448cd54ab | |||
| 59237f1098 | |||
| d6fe43b7c3 | |||
| e52513f452 | |||
| 4123b66ab9 | |||
| 54fc6b2638 | |||
| 2cece72abe | |||
| 44ec2dceb7 | |||
| 6def12995e | |||
| ec3b2b87ed | |||
| 59bfadcb99 | |||
| 666842ce2b | |||
| 5250381579 | |||
| c4a9c9c2eb | |||
| 0e876384d6 | |||
| 81145fb9d0 | |||
| a913c6e3da | |||
| 2720dacc1d | |||
| 3c4c765f2a | |||
| 2be938d0a3 | |||
| 71bad49710 | |||
| 30396543ee | |||
| ec428ba1c8 | |||
| f8ee7c61b9 | |||
| d7fa20a890 | |||
| 4fc8ee58cb | |||
| 3ce29d5333 | |||
| c42500d256 | |||
| 1dd934e0a2 | |||
| 2a4a9511d6 | |||
| 20cca6e631 | |||
| 501c5e8221 | |||
| 9f3ea6dece | |||
| 837fbc997d | |||
| b6311bec9d | |||
| 41e495f0f0 | |||
| b98df5a1a4 | |||
| 98c14582d4 | |||
| f7b57d9fd8 |
@@ -96,6 +96,7 @@ jobs:
|
||||
env:
|
||||
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -103,7 +104,18 @@ jobs:
|
||||
name: macos-universal
|
||||
path: |
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg.sig
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
|
||||
tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
|
||||
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
@@ -144,6 +156,7 @@ jobs:
|
||||
New-Item -ItemType Directory -Force -Path tauri-app/src-tauri/binaries
|
||||
gh release download v0.0.0-sidecar --repo ${{ github.repository }} --pattern "sidecar-binaries.tar.gz" --dir $env:TEMP
|
||||
tar xzf "$env:TEMP\sidecar-binaries.tar.gz" -C tauri-app/src-tauri/binaries/
|
||||
Get-ChildItem tauri-app/src-tauri/binaries/
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -170,6 +183,7 @@ jobs:
|
||||
env:
|
||||
VITE_API_BASE_URL: https://dev.tapi.meijiaka.cn/api/v1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -178,4 +192,15 @@ jobs:
|
||||
path: |
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
|
||||
|
||||
@@ -27,3 +27,7 @@ test_kick.sh
|
||||
.qiniu_pythonsdk_hostscache.json
|
||||
tauri-app/src-tauri/binaries/*
|
||||
.tauri-signing-key
|
||||
*.key
|
||||
*test*.key*
|
||||
.atomcode/
|
||||
mixkit_bgm/
|
||||
|
||||
@@ -80,8 +80,9 @@ build-frontend-macos:
|
||||
paths:
|
||||
# DMG 安装包 (推荐用户下载)
|
||||
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
|
||||
# .app bundle (供进一步分发或公证使用)
|
||||
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
|
||||
# Updater 专用包 + 签名
|
||||
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz
|
||||
- tauri-app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig
|
||||
expire_in: "${ARTIFACT_EXPIRE_DAYS} days"
|
||||
timeout: 45 minutes
|
||||
retry:
|
||||
@@ -114,8 +115,11 @@ build-frontend-windows:
|
||||
artifacts:
|
||||
name: "meijiaka-windows-${CI_COMMIT_REF_NAME}-${CI_COMMIT_SHORT_SHA}"
|
||||
paths:
|
||||
# NSIS 安装包 (推荐用户下载)
|
||||
# Updater 专用包 + 签名
|
||||
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
# NSIS 安装包 (推荐用户下载)
|
||||
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
# MSI 安装包 (企业部署场景)
|
||||
- tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
expire_in: "${ARTIFACT_EXPIRE_DAYS} days"
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
# Mixkit 免版权音乐清单(装修行业口播短视频)
|
||||
|
||||
> 来源: Mixkit.co(免版税、无需署名、可商用)
|
||||
> 下载时间: 2026-05-23
|
||||
> 总计: 129 首 / 616 MB
|
||||
|
||||
---
|
||||
|
||||
## 分类说明
|
||||
|
||||
| 分类 | 适用场景 | 数量 |
|
||||
|------|----------|------|
|
||||
| **知识科普** | 装修避坑、材料选择、流程科普 | 66 首 |
|
||||
| **案例展示** | 完工验收、前后对比、实景展示 | 49 首 |
|
||||
| **促销活动** | 开业促销、团购活动、限时优惠 | 49 首 |
|
||||
| **家居生活** | 软装搭配、生活 vlog、温馨家庭 | 54 首 |
|
||||
| **智能家居** | 全屋智能、现代设计、灯光系统 | 48 首 |
|
||||
|
||||
---
|
||||
|
||||
## 一、知识科普(专业可信,不抢戏)
|
||||
|
||||
| ID | 音乐名 | 艺术家 |
|
||||
|----|--------|--------|
|
||||
| 22 | Piano Reflections | Ahjay Stelino |
|
||||
| 105 | See Line Funk | Alejandro Magaña |
|
||||
| 113 | House Fest | Alejandro Magaña (A. M.) |
|
||||
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
|
||||
| 1167 | Close Up | Michael Ramir C. |
|
||||
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
|
||||
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
|
||||
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
|
||||
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
|
||||
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
|
||||
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
|
||||
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
|
||||
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
|
||||
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
|
||||
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
|
||||
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
|
||||
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
|
||||
| 184 | Vastness | Andrew Ev |
|
||||
| 251 | Ambient | Arulo |
|
||||
| 292 | Relax Beat | Arulo |
|
||||
| 324 | Smooth Meditation | Arulo |
|
||||
| 340 | Nap Time | Arulo |
|
||||
| 371 | Cat Walk | Arulo |
|
||||
| 416 | Young Trizzy | Arulo |
|
||||
| 441 | Meditation | Arulo |
|
||||
| 443 | Serene View | Arulo |
|
||||
| 470 | Golden Storm | Diego Nava |
|
||||
| 471 | Rising Forest | Diego Nava |
|
||||
| 480 | Curiosity | Diego Nava |
|
||||
| 493 | Beautiful Dream | Diego Nava |
|
||||
| 568 | Focus on Yourself | Eugenio Mininni |
|
||||
| 584 | Rest Now | Eugenio Mininni |
|
||||
| 593 | Opalescent | Eugenio Mininni |
|
||||
| 594 | River Flow | Eugenio Mininni |
|
||||
| 616 | What it Takes | Eugenio Mininni |
|
||||
| 617 | Wind Leaves | Eugenio Mininni |
|
||||
| 620 | B.O.R.N | Eugenio Mininni |
|
||||
| 623 | Deep Urban | Eugenio Mininni |
|
||||
| 628 | Summer Dream | Eugenio Mininni |
|
||||
| 629 | Skeeomaver Sound | Eugenio Mininni |
|
||||
| 633 | Xanthos | Eugenio Mininni |
|
||||
| 652 | Soul Jazz | Francisco Alvear |
|
||||
| 664 | Pop One | Francisco Alvear |
|
||||
| 695 | Pop 05 | Grigoriy Nuzhny |
|
||||
| 700 | Pop 03 | Grigoriy Nuzhny |
|
||||
| 713 | Classical 6 | Jonny S. |
|
||||
| 720 | New Bass 01 | Lily J |
|
||||
| 726 | Uplifting Bass | Lily J |
|
||||
| 729 | Pop Track 03 | Lily J |
|
||||
| 738 | Hip Hop 02 | Lily J |
|
||||
| 744 | House 02 | Lily J |
|
||||
| 749 | Relaxation 05 | Lily J |
|
||||
| 759 | Romantic 05 | Lily J |
|
||||
| 770 | Autofahren | Mauro Urbina |
|
||||
| 779 | Oh | Michael Ramir C. |
|
||||
| 801 | Happy Home | Michael Ramir C. |
|
||||
| 802 | Here Comes The Train | Michael Ramir C. |
|
||||
| 804 | I Love You Grandma | Michael Ramir C. |
|
||||
| 813 | Magical moment | Michael Ramir C. |
|
||||
| 816 | Please | Michael Ramir C. |
|
||||
| 821 | At the Playhouse | Michael Ramir C. |
|
||||
| 832 | I'm Going Home | Michael Ramir C. |
|
||||
| 834 | It's Love | Michael Ramir C. |
|
||||
| 837 | Life is a Dream | Michael Ramir C. |
|
||||
| 839 | Tears of Joy | Michael Ramir C. |
|
||||
| 840 | That's the Way of Life | Michael Ramir C. |
|
||||
| 847 | It's April | Michael Ramir C. |
|
||||
| 852 | Music and Life | Michael Ramir C. |
|
||||
| 856 | Salty and Sweet | Michael Ramir C. |
|
||||
| 872 | Gimme that Groove! | Michael Ramir C. |
|
||||
| 897 | A Very Happy Christmas | Michael Ramir C. |
|
||||
| 953 | Feel Alive | Michael Ramir C. |
|
||||
| 963 | Just Keep Walking | Michael Ramir C. |
|
||||
| 970 | Night Sky Hip Hop | Michael Ramir C. |
|
||||
| 993 | Finding Myself | Michael Ramir C. |
|
||||
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
|
||||
| 1001 | I Do! | Michael Ramir C. |
|
||||
| 1052 | Baby Yohan | Michael Ramir C. |
|
||||
| 1081 | We'll Be Okay | Michael Ramir C. |
|
||||
| 1140 | Funkee Monkeee | Michael Ramir C. |
|
||||
| 1183 | Karma | Michael Ramir C. |
|
||||
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
|
||||
|
||||
## 二、案例展示(有成就感、积极)
|
||||
|
||||
| ID | 音乐名 | 艺术家 |
|
||||
|----|--------|--------|
|
||||
| 3 | Dance with Me | Ahjay Stelino |
|
||||
| 4 | Delightful | Ahjay Stelino |
|
||||
| 5 | Feeling Happy | Ahjay Stelino |
|
||||
| 8 | Jumping Around | Ahjay Stelino |
|
||||
| 11 | Just Kidding | Ahjay Stelino |
|
||||
| 12 | Playground Fun | Ahjay Stelino |
|
||||
| 13 | Summer Fun | Ahjay Stelino |
|
||||
| 31 | Dreaming Big | Ahjay Stelino |
|
||||
| 32 | Driving Ambition | Ahjay Stelino |
|
||||
| 34 | Raising Me Higher | Ahjay Stelino |
|
||||
| 91 | Summer's Here | Ahjay Stelino |
|
||||
| 288 | One More Dance | Arulo |
|
||||
| 339 | Villa Penthouse | Arulo |
|
||||
| 350 | Follow Me Home | Arulo |
|
||||
| 528 | You Got Jazz | Diego Nava |
|
||||
| 529 | Walking in the Park | Diego Nava |
|
||||
| 532 | A Happy Child | Diego Nava |
|
||||
| 621 | BRIDGE No 98 | Eugenio Mininni |
|
||||
| 684 | Classical vibes 4 | Grigoriy Nuzhny |
|
||||
| 801 | Happy Home | Michael Ramir C. |
|
||||
| 823 | Be Happy 2 | Michael Ramir C. |
|
||||
| 839 | Tears of Joy | Michael Ramir C. |
|
||||
| 872 | Gimme that Groove! | Michael Ramir C. |
|
||||
| 897 | A Very Happy Christmas | Michael Ramir C. |
|
||||
| 953 | Feel Alive | Michael Ramir C. |
|
||||
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
|
||||
| 1001 | I Do! | Michael Ramir C. |
|
||||
| 1052 | Baby Yohan | Michael Ramir C. |
|
||||
| 1140 | Funkee Monkeee | Michael Ramir C. |
|
||||
| 1183 | Karma | Michael Ramir C. |
|
||||
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
|
||||
|
||||
## 三、促销活动(轻快、有能量)
|
||||
|
||||
同案例展示分类,推荐节奏更欢快的:
|
||||
- 3.mp3, 4.mp3, 5.mp3, 8.mp3, 11.mp3, 12.mp3, 13.mp3, 31.mp3, 32.mp3, 34.mp3, 91.mp3
|
||||
- 288.mp3, 339.mp3, 350.mp3, 528.mp3, 529.mp3, 532.mp3, 621.mp3
|
||||
- 801.mp3, 823.mp3, 839.mp3, 872.mp3, 897.mp3, 953.mp3
|
||||
- 1000.mp3, 1001.mp3, 1052.mp3, 1140.mp3, 1183.mp3, 1210.mp3
|
||||
|
||||
## 四、家居生活(温馨、治愈)
|
||||
|
||||
| ID | 音乐名 | 艺术家 |
|
||||
|----|--------|--------|
|
||||
| 22 | Piano Reflections | Ahjay Stelino |
|
||||
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
|
||||
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
|
||||
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
|
||||
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
|
||||
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
|
||||
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
|
||||
| 199 | Loner | Arulo |
|
||||
| 250 | Island Beat | Arulo |
|
||||
| 282 | Sweet September | Arulo |
|
||||
| 292 | Relax Beat | Arulo |
|
||||
| 322 | Life's a Movie | Arulo |
|
||||
| 324 | Smooth Meditation | Arulo |
|
||||
| 340 | Nap Time | Arulo |
|
||||
| 345 | Nature Meditation | Arulo |
|
||||
| 350 | Follow Me Home | Arulo |
|
||||
| 416 | Young Trizzy | Arulo |
|
||||
| 441 | Meditation | Arulo |
|
||||
| 442 | Nature Yoga | Arulo |
|
||||
| 443 | Serene View | Arulo |
|
||||
| 444 | Yoga Song | Arulo |
|
||||
| 493 | Beautiful Dream | Diego Nava |
|
||||
| 528 | You Got Jazz | Diego Nava |
|
||||
| 529 | Walking in the Park | Diego Nava |
|
||||
| 532 | A Happy Child | Diego Nava |
|
||||
| 568 | Focus on Yourself | Eugenio Mininni |
|
||||
| 584 | Rest Now | Eugenio Mininni |
|
||||
| 593 | Opalescent | Eugenio Mininni |
|
||||
| 594 | River Flow | Eugenio Mininni |
|
||||
| 617 | Wind Leaves | Eugenio Mininni |
|
||||
| 620 | B.O.R.N | Eugenio Mininni |
|
||||
| 623 | Deep Urban | Eugenio Mininni |
|
||||
| 628 | Summer Dream | Eugenio Mininni |
|
||||
| 629 | Skeeomaver Sound | Eugenio Mininni |
|
||||
| 633 | Xanthos | Eugenio Mininni |
|
||||
| 652 | Soul Jazz | Francisco Alvear |
|
||||
| 664 | Pop One | Francisco Alvear |
|
||||
| 695 | Pop 05 | Grigoriy Nuzhny |
|
||||
| 700 | Pop 03 | Grigoriy Nuzhny |
|
||||
| 713 | Classical 6 | Jonny S. |
|
||||
| 720 | New Bass 01 | Lily J |
|
||||
| 726 | Uplifting Bass | Lily J |
|
||||
| 729 | Pop Track 03 | Lily J |
|
||||
| 738 | Hip Hop 02 | Lily J |
|
||||
| 744 | House 02 | Lily J |
|
||||
| 749 | Relaxation 05 | Lily J |
|
||||
| 759 | Romantic 05 | Lily J |
|
||||
| 770 | Autofahren | Mauro Urbina |
|
||||
| 779 | Oh | Michael Ramir C. |
|
||||
| 801 | Happy Home | Michael Ramir C. |
|
||||
| 802 | Here Comes The Train | Michael Ramir C. |
|
||||
| 804 | I Love You Grandma | Michael Ramir C. |
|
||||
| 813 | Magical moment | Michael Ramir C. |
|
||||
| 816 | Please | Michael Ramir C. |
|
||||
| 821 | At the Playhouse | Michael Ramir C. |
|
||||
| 832 | I'm Going Home | Michael Ramir C. |
|
||||
| 834 | It's Love | Michael Ramir C. |
|
||||
| 837 | Life is a Dream | Michael Ramir C. |
|
||||
| 839 | Tears of Joy | Michael Ramir C. |
|
||||
| 840 | That's the Way of Life | Michael Ramir C. |
|
||||
| 847 | It's April | Michael Ramir C. |
|
||||
| 852 | Music and Life | Michael Ramir C. |
|
||||
| 856 | Salty and Sweet | Michael Ramir C. |
|
||||
| 872 | Gimme that Groove! | Michael Ramir C. |
|
||||
| 897 | A Very Happy Christmas | Michael Ramir C. |
|
||||
| 953 | Feel Alive | Michael Ramir C. |
|
||||
| 963 | Just Keep Walking | Michael Ramir C. |
|
||||
| 970 | Night Sky Hip Hop | Michael Ramir C. |
|
||||
| 993 | Finding Myself | Michael Ramir C. |
|
||||
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
|
||||
| 1001 | I Do! | Michael Ramir C. |
|
||||
| 1052 | Baby Yohan | Michael Ramir C. |
|
||||
| 1081 | We'll Be Okay | Michael Ramir C. |
|
||||
| 1140 | Funkee Monkeee | Michael Ramir C. |
|
||||
| 1167 | Close Up | Michael Ramir C. |
|
||||
| 1183 | Karma | Michael Ramir C. |
|
||||
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
|
||||
|
||||
## 五、智能家居(科技感、高级感)
|
||||
|
||||
| ID | 音乐名 | 艺术家 |
|
||||
|----|--------|--------|
|
||||
| 105 | See Line Funk | Alejandro Magaña |
|
||||
| 113 | House Fest | Alejandro Magaña (A. M.) |
|
||||
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
|
||||
| 122 | Slow Rain | Alejandro Magaña (A. M.) |
|
||||
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
|
||||
| 130 | Tech House vibes | Alejandro Magaña (A. M.) |
|
||||
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
|
||||
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
|
||||
| 136 | Infected Mushroom Vibes | Alejandro Magaña (A. M.) |
|
||||
| 137 | Goa Trance Mantra | Alejandro Magaña (A. M.) |
|
||||
| 140 | Cyberpunk City | Alejandro Magaña (A. M.) |
|
||||
| 157 | Infected Vibes | Alejandro Magaña (A. M.) |
|
||||
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
|
||||
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
|
||||
| 166 | Trance Party | Alejandro Magaña (A. M.) |
|
||||
| 173 | Better Times are Coming | Alejandro Magaña (A. M.) |
|
||||
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
|
||||
| 180 | Gear | Andrew Ev |
|
||||
| 181 | Pop | Andrew Ev |
|
||||
| 184 | Vastness | Andrew Ev |
|
||||
| 199 | Loner | Arulo |
|
||||
| 251 | Ambient | Arulo |
|
||||
| 292 | Relax Beat | Arulo |
|
||||
| 324 | Smooth Meditation | Arulo |
|
||||
| 340 | Nap Time | Arulo |
|
||||
| 371 | Cat Walk | Arulo |
|
||||
| 416 | Young Trizzy | Arulo |
|
||||
| 441 | Meditation | Arulo |
|
||||
| 442 | Nature Yoga | Arulo |
|
||||
| 443 | Serene View | Arulo |
|
||||
| 444 | Yoga Song | Arulo |
|
||||
| 464 | Sci-Fi Score | Arulo |
|
||||
| 470 | Golden Storm | Diego Nava |
|
||||
| 471 | Rising Forest | Diego Nava |
|
||||
| 480 | Curiosity | Diego Nava |
|
||||
| 517 | Jungle Voices | Diego Nava |
|
||||
| 568 | Focus on Yourself | Eugenio Mininni |
|
||||
| 584 | Rest Now | Eugenio Mininni |
|
||||
| 593 | Opalescent | Eugenio Mininni |
|
||||
| 594 | River Flow | Eugenio Mininni |
|
||||
| 609 | Moon Walk | Eugenio Mininni |
|
||||
| 616 | What it Takes | Eugenio Mininni |
|
||||
| 617 | Wind Leaves | Eugenio Mininni |
|
||||
| 620 | B.O.R.N | Eugenio Mininni |
|
||||
| 623 | Deep Urban | Eugenio Mininni |
|
||||
| 628 | Summer Dream | Eugenio Mininni |
|
||||
| 629 | Skeeomaver Sound | Eugenio Mininni |
|
||||
| 633 | Xanthos | Eugenio Mininni |
|
||||
| 652 | Soul Jazz | Francisco Alvear |
|
||||
| 664 | Pop One | Francisco Alvear |
|
||||
| 695 | Pop 05 | Grigoriy Nuzhny |
|
||||
| 700 | Pop 03 | Grigoriy Nuzhny |
|
||||
| 713 | Classical 6 | Jonny S. |
|
||||
| 720 | New Bass 01 | Lily J |
|
||||
| 726 | Uplifting Bass | Lily J |
|
||||
| 729 | Pop Track 03 | Lily J |
|
||||
| 738 | Hip Hop 02 | Lily J |
|
||||
| 744 | House 02 | Lily J |
|
||||
| 749 | Relaxation 05 | Lily J |
|
||||
| 759 | Romantic 05 | Lily J |
|
||||
| 770 | Autofahren | Mauro Urbina |
|
||||
| 779 | Oh | Michael Ramir C. |
|
||||
| 801 | Happy Home | Michael Ramir C. |
|
||||
| 802 | Here Comes The Train | Michael Ramir C. |
|
||||
| 804 | I Love You Grandma | Michael Ramir C. |
|
||||
| 813 | Magical moment | Michael Ramir C. |
|
||||
| 816 | Please | Michael Ramir C. |
|
||||
| 821 | At the Playhouse | Michael Ramir C. |
|
||||
| 832 | I'm Going Home | Michael Ramir C. |
|
||||
| 834 | It's Love | Michael Ramir C. |
|
||||
| 837 | Life is a Dream | Michael Ramir C. |
|
||||
| 839 | Tears of Joy | Michael Ramir C. |
|
||||
| 840 | That's the Way of Life | Michael Ramir C. |
|
||||
| 847 | It's April | Michael Ramir C. |
|
||||
| 852 | Music and Life | Michael Ramir C. |
|
||||
| 856 | Salty and Sweet | Michael Ramir C. |
|
||||
| 872 | Gimme that Groove! | Michael Ramir C. |
|
||||
| 897 | A Very Happy Christmas | Michael Ramir C. |
|
||||
| 953 | Feel Alive | Michael Ramir C. |
|
||||
| 963 | Just Keep Walking | Michael Ramir C. |
|
||||
| 970 | Night Sky Hip Hop | Michael Ramir C. |
|
||||
| 993 | Finding Myself | Michael Ramir C. |
|
||||
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
|
||||
| 1001 | I Do! | Michael Ramir C. |
|
||||
| 1052 | Baby Yohan | Michael Ramir C. |
|
||||
| 1081 | We'll Be Okay | Michael Ramir C. |
|
||||
| 1140 | Funkee Monkeee | Michael Ramir C. |
|
||||
| 1167 | Close Up | Michael Ramir C. |
|
||||
| 1183 | Karma | Michael Ramir C. |
|
||||
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
|
||||
|
||||
---
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 所有音乐文件在 `mixkit_bgm/` 目录下,文件名为 `{ID}.mp3`
|
||||
2. 运营人员可试听挑选,将选中的音乐上传到七牛云 CDN
|
||||
3. 上传后通过 `POST /api/v1/update/releases` 或直接写 SQL 入库
|
||||
4. 分类字段建议: `knowledge` | `showcase` | `promotion` | `lifestyle` | `tech`
|
||||
@@ -47,6 +47,9 @@ VOLCENGINE_API_KEY=your-volcengine-api-key
|
||||
VOLCENGINE_CAPTION_APPID=your-caption-appid
|
||||
VOLCENGINE_CAPTION_TOKEN=your-caption-token
|
||||
|
||||
# 火山 MediaKit
|
||||
VOLCENGINE_MEDIAKIT_TOKEN=your-mediakit-token
|
||||
|
||||
# Vidu(TTS、声音复刻、对口型)
|
||||
VIDU_API_KEY=your-vidu-api-key
|
||||
|
||||
@@ -71,6 +74,8 @@ SMS_APP_ID=your-sms-app-id
|
||||
SMS_SECRET_KEY=your-16-24-32-byte-aes-key
|
||||
SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
|
||||
# SMS_EXTENDED_CODE= # 扩展码(选填)
|
||||
# 免验证码登录白名单(逗号分隔),名单内的手机号登录时跳过验证码校验
|
||||
# SMS_CODE_WHITELIST=13800138000,13900139000
|
||||
|
||||
# === 日志配置 ===
|
||||
# 生产环境建议 INFO
|
||||
|
||||
@@ -20,17 +20,18 @@ load_dotenv()
|
||||
|
||||
# 导入模型
|
||||
from app.db.session import Base
|
||||
from app.models.bgm_music import BgmMusic # noqa
|
||||
from app.models.broll_category import BrollCategory # noqa
|
||||
from app.models.broll_material import BrollMaterial # noqa
|
||||
from app.models.broll_tag import BrollTag # noqa
|
||||
from app.models.cover_background import CoverBackground # noqa
|
||||
from app.models.point_batch import PointBatch # noqa
|
||||
from app.models.point_recharge_order import PointRechargeOrder # noqa
|
||||
from app.models.point_transaction import PointTransaction # noqa
|
||||
from app.models.update import AppRelease, ReleasePackage # noqa
|
||||
from app.models.user import User # noqa
|
||||
from app.models.user_device import UserDevice # noqa
|
||||
from app.models.user_point import UserPoint # noqa
|
||||
from app.models.cover_background import CoverBackground # noqa
|
||||
from app.models.update import AppRelease, ReleasePackage # noqa
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add_url_to_bgm_music
|
||||
|
||||
Revision ID: 100366516fbd
|
||||
Revises: 7172a476e5b2
|
||||
Create Date: 2026-05-24 15:24:11.076162
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '100366516fbd'
|
||||
down_revision: Union[str, Sequence[str], None] = '7172a476e5b2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('mjk_bgm_musics', sa.Column('url', sa.String(length=1024), nullable=True, comment='七牛云 URL'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('mjk_bgm_musics', 'url')
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add_bgm_music_table
|
||||
|
||||
Revision ID: 7172a476e5b2
|
||||
Revises: d8f4912d7a52
|
||||
Create Date: 2026-05-23 13:56:46.013156
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7172a476e5b2'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd8f4912d7a52'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('mjk_bgm_musics',
|
||||
sa.Column('title', sa.String(length=255), nullable=False, comment='音乐名称'),
|
||||
sa.Column('artist', sa.String(length=255), nullable=True, comment='艺术家'),
|
||||
sa.Column('category', sa.String(length=32), nullable=False, comment='场景分类'),
|
||||
sa.Column('file_path', sa.String(length=512), nullable=False, comment='相对文件路径'),
|
||||
sa.Column('duration', sa.Float(), nullable=True, comment='时长(秒)'),
|
||||
sa.Column('status', sa.String(length=16), nullable=False, comment='状态: active/inactive'),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_mjk_bgm_musics_category'), 'mjk_bgm_musics', ['category'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_mjk_bgm_musics_category'), table_name='mjk_bgm_musics')
|
||||
op.drop_table('mjk_bgm_musics')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,48 @@
|
||||
"""rename_old_table_prefix_for_update_tables
|
||||
|
||||
Revision ID: d8f4912d7a52
|
||||
Revises: c3a0e1c71ce6
|
||||
Create Date: 2026-05-20 18:02:45.186600
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd8f4912d7a52'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c3a0e1c71ce6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# 将旧环境(cbd4068 前)创建的 app_releases / release_packages 重命名为 mjk_ 前缀
|
||||
# 使用 IF EXISTS 兼容:新环境已在 initial_schema 中创建了正确前缀的表名
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS app_releases RENAME TO mjk_app_releases"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER INDEX IF EXISTS ix_app_releases_version "
|
||||
"RENAME TO ix_mjk_app_releases_version"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS release_packages RENAME TO mjk_release_packages"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS mjk_app_releases RENAME TO app_releases"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER INDEX IF EXISTS ix_mjk_app_releases_version "
|
||||
"RENAME TO ix_app_releases_version"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS mjk_release_packages RENAME TO release_packages"
|
||||
)
|
||||
@@ -20,3 +20,4 @@ class Method:
|
||||
CAPTION = "caption"
|
||||
AUTO_ALIGN = "auto_align"
|
||||
VIDEO_GENERATE = "video_generate"
|
||||
REMOVE_BACKGROUND = "remove_background"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
火山引擎 MediaKit Adapter
|
||||
==========================
|
||||
|
||||
实现 PlatformAdapter + SyncCapable。
|
||||
直接接入 VolcengineMediakitProvider,提供标准 Protocol 接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.adapters.base import AdapterResponse, PlatformAdapter, SyncCapable
|
||||
from app.ai.adapters.constants import Method
|
||||
from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider
|
||||
from app.core.exceptions import PlatformError, PlatformErrorType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VolcengineMediakitAdapter(PlatformAdapter, SyncCapable):
|
||||
"""火山引擎 MediaKit 平台标准 Adapter"""
|
||||
|
||||
platform_id = "volcengine_mediakit"
|
||||
|
||||
def __init__(self, provider: VolcengineMediakitProvider):
|
||||
self.provider = provider
|
||||
|
||||
# ── PlatformAdapter ──
|
||||
|
||||
async def health(self) -> AdapterResponse:
|
||||
try:
|
||||
# 用无效 URL 测试连通性(400 说明网络通且认证通过)
|
||||
await self.provider.remove_background(
|
||||
image_url="https://example.com/health-check.jpg",
|
||||
scene="general",
|
||||
)
|
||||
return AdapterResponse(success=True)
|
||||
except PlatformError as e:
|
||||
if e.error_type in (
|
||||
PlatformErrorType.AUTH_FAILED,
|
||||
PlatformErrorType.BAD_REQUEST,
|
||||
):
|
||||
return AdapterResponse(success=True)
|
||||
return AdapterResponse(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
retryable=e.retryable,
|
||||
)
|
||||
except Exception as e:
|
||||
return AdapterResponse(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.provider.close()
|
||||
|
||||
# ── SyncCapable ──
|
||||
|
||||
async def call(self, method: str, payload: dict[str, Any]) -> AdapterResponse:
|
||||
try:
|
||||
if method == Method.REMOVE_BACKGROUND:
|
||||
result = await self.provider.remove_background(
|
||||
image_url=payload["image_url"],
|
||||
scene=payload.get("scene", "general"),
|
||||
need_contour=payload.get("need_contour", False),
|
||||
contour_color=payload.get("contour_color", "#FFFFFF"),
|
||||
contour_size=payload.get("contour_size", 10),
|
||||
need_crop_background=payload.get("need_crop_background", False),
|
||||
)
|
||||
data = result.get("data", {})
|
||||
return AdapterResponse(
|
||||
success=True,
|
||||
data={"image_url": data.get("image_url")},
|
||||
)
|
||||
|
||||
else:
|
||||
return AdapterResponse(
|
||||
success=False,
|
||||
error_message=f"不支持的方法: {method}",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
except PlatformError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PlatformError(
|
||||
f"MediaKit {method} 调用失败: {e}",
|
||||
platform="volcengine_mediakit",
|
||||
retryable=False,
|
||||
error_type=PlatformErrorType.UNKNOWN,
|
||||
) from e
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
火山引擎 MediaKit Provider
|
||||
===========================
|
||||
|
||||
直接封装火山引擎 MediaKit HTTP API:
|
||||
- 图像背景移除(/api/v1/tools/remove-image-background/sync)
|
||||
|
||||
使用 httpx.AsyncClient,支持外部注入(由 lifespan 管理生命周期)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import PlatformError, PlatformErrorType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _map_mediakit_error(status: int, message: str, code: int | None = None) -> PlatformError:
|
||||
"""把 MediaKit 错误映射为标准 PlatformError"""
|
||||
error_mapping = {
|
||||
400: (PlatformErrorType.BAD_REQUEST, False),
|
||||
401: (PlatformErrorType.AUTH_FAILED, False),
|
||||
403: (PlatformErrorType.AUTH_FAILED, False),
|
||||
429: (PlatformErrorType.RATE_LIMIT, True),
|
||||
500: (PlatformErrorType.SERVER_ERROR, True),
|
||||
502: (PlatformErrorType.SERVER_ERROR, True),
|
||||
503: (PlatformErrorType.SERVER_ERROR, True),
|
||||
}
|
||||
error_type, retryable = error_mapping.get(status, (PlatformErrorType.UNKNOWN, False))
|
||||
return PlatformError(
|
||||
message, platform="volcengine_mediakit",
|
||||
retryable=retryable, error_type=error_type,
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
|
||||
class VolcengineMediakitProvider:
|
||||
"""火山引擎 MediaKit Provider
|
||||
|
||||
直接调用 MediaKit HTTP API,不做业务层处理。
|
||||
"""
|
||||
|
||||
BASE_URL = "https://mediakit.cn-beijing.volces.com"
|
||||
REMOVE_BG_PATH = "/api/v1/tools/remove-image-background/sync"
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
settings = get_settings()
|
||||
self.token = token or settings.VOLCENGINE_MEDIAKIT_TOKEN or ""
|
||||
|
||||
if not self.token:
|
||||
raise PlatformError(
|
||||
"VOLCENGINE_MEDIAKIT_TOKEN 未配置",
|
||||
platform="volcengine_mediakit",
|
||||
retryable=False,
|
||||
error_type=PlatformErrorType.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if client is not None:
|
||||
self.client = client
|
||||
self._owns_client = False
|
||||
else:
|
||||
self.client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT)
|
||||
self._owns_client = True
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer; {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 HTTP 客户端"""
|
||||
if self._owns_client and self.client and not self.client.is_closed:
|
||||
await self.client.aclose()
|
||||
|
||||
async def remove_background(
|
||||
self,
|
||||
image_url: str,
|
||||
scene: str = "general",
|
||||
need_contour: bool = False,
|
||||
contour_color: str = "#FFFFFF",
|
||||
contour_size: int = 10,
|
||||
need_crop_background: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""同步抠图,返回原始 JSON
|
||||
|
||||
Args:
|
||||
image_url: 原始图片 URL
|
||||
scene: 场景类型
|
||||
need_contour: 是否为主体生成描边(仅 human/product 场景生效)
|
||||
contour_color: 描边颜色,十六进制 RGB 格式
|
||||
contour_size: 描边宽度(px),范围 [1, 100]
|
||||
need_crop_background: 是否裁剪透明背景到刚好包裹主体
|
||||
|
||||
Returns:
|
||||
{"code": 0, "message": "Success", "data": {"image_url": "https://..."}}
|
||||
"""
|
||||
payload: dict[str, Any] = {"image_url": image_url, "scene": scene}
|
||||
if need_contour:
|
||||
payload["need_contour"] = True
|
||||
payload["contour_color"] = contour_color
|
||||
payload["contour_size"] = max(1, min(100, contour_size))
|
||||
if need_crop_background:
|
||||
payload["need_crop_background"] = True
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.BASE_URL}{self.REMOVE_BG_PATH}",
|
||||
json=payload,
|
||||
headers=self._get_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 火山引擎 MediaKit 有两种响应格式:
|
||||
# 格式1: {"code": 0, "message": "...", "data": {...}}
|
||||
# 格式2: {"success": true, "result": {...}, "expires_at": ...}
|
||||
code = data.get("code")
|
||||
if code is not None:
|
||||
# 格式1
|
||||
if code != 0:
|
||||
logger.warning(
|
||||
f"[MediaKit] 抠图业务失败: code={code}, "
|
||||
f"message={data.get('message', 'N/A')}, "
|
||||
f"raw_response={data}, image_url={image_url[:80]}..."
|
||||
)
|
||||
raise _map_mediakit_error(
|
||||
response.status_code,
|
||||
data.get("message", f"抠图失败: code={code}"),
|
||||
code=code,
|
||||
)
|
||||
return data
|
||||
else:
|
||||
# 格式2
|
||||
if not data.get("success", False):
|
||||
logger.warning(
|
||||
f"[MediaKit] 抠图业务失败: success=false, "
|
||||
f"raw_response={data}, image_url={image_url[:80]}..."
|
||||
)
|
||||
raise _map_mediakit_error(
|
||||
response.status_code,
|
||||
"抠图失败: 平台返回失败状态",
|
||||
)
|
||||
# 将格式2标准化为格式1,方便上层统一处理
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "Success",
|
||||
"data": data.get("result", {}),
|
||||
}
|
||||
|
||||
except PlatformError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise _map_mediakit_error(
|
||||
e.response.status_code, f"HTTP错误: {e.response.status_code}"
|
||||
) from e
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
raise PlatformError(
|
||||
f"MediaKit 网络错误: {e}", platform="volcengine_mediakit",
|
||||
retryable=True, error_type=PlatformErrorType.TIMEOUT,
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise _map_mediakit_error(500, f"抠图失败: {str(e)}") from e
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
背景音乐 API
|
||||
===========
|
||||
|
||||
提供装修行业场景化 BGM 列表查询。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.crud.bgm_music import bgm_music
|
||||
from app.schemas.bgm_music import BgmMusicItem, BgmMusicListResponse
|
||||
from app.schemas.common import ApiResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/bgm-musics", response_model=ApiResponse[BgmMusicListResponse])
|
||||
async def list_bgm_musics(
|
||||
category: str | None = Query(None, description="场景分类筛选"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[BgmMusicListResponse]:
|
||||
"""
|
||||
获取背景音乐列表
|
||||
|
||||
按场景分类返回可用的背景音乐列表。
|
||||
分类说明:
|
||||
- knowledge: 知识科普(极简、低频)
|
||||
- showcase: 案例展示(积极、有成就感)
|
||||
- promotion: 促销活动(轻快、有能量)
|
||||
- lifestyle: 家居生活(温馨、治愈)
|
||||
- tech: 智能家居(科技感、高级感)
|
||||
"""
|
||||
items = await bgm_music.get_active_by_category(db, category=category)
|
||||
return ApiResponse(
|
||||
code=200,
|
||||
message="success",
|
||||
data=BgmMusicListResponse(
|
||||
items=[BgmMusicItem.model_validate(item) for item in items],
|
||||
total=len(items),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
图片处理 API
|
||||
============
|
||||
|
||||
提供图片上传(七牛云)和 AI 抠图(火山引擎 MediaKit)功能。
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.services import point_service as ps
|
||||
from app.services.qiniu_service import get_qiniu_service
|
||||
from app.services.volcengine_mediakit_service import VolcengineMediakitService
|
||||
from app.utils.file_validation import check_upload_file
|
||||
|
||||
router = APIRouter(tags=["Image"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ── Dependencies ──
|
||||
|
||||
async def get_mediakit_service(request: Request) -> VolcengineMediakitService:
|
||||
"""FastAPI Depends:从 app.state 获取全局 VolcengineMediakitService 实例。"""
|
||||
service = getattr(request.app.state, "volcengine_mediakit_service", None)
|
||||
if service is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="MediaKit 服务未初始化,请检查配置",
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
# ── Schemas ──
|
||||
|
||||
class ImageUploadResponse(BaseModel):
|
||||
"""图片上传响应"""
|
||||
|
||||
url: str = Field(..., description="七牛云图片 URL")
|
||||
key: str = Field(..., description="七牛云文件 key")
|
||||
size: int = Field(..., description="文件大小(字节)")
|
||||
|
||||
|
||||
class RemoveBackgroundResponse(BaseModel):
|
||||
"""抠图响应"""
|
||||
|
||||
url: str = Field(..., description="抠图结果图片 URL")
|
||||
|
||||
|
||||
class RemoveBackgroundRequest(BaseModel):
|
||||
"""抠图请求"""
|
||||
|
||||
image_url: str = Field(..., description="原始图片 URL")
|
||||
scene: str = Field(default="human", description="场景类型:general(通用)、human(人物,默认白色描边)或 product(商品)")
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
@router.post("/upload/image", response_model=ApiResponse[ImageUploadResponse])
|
||||
async def upload_image(
|
||||
file: UploadFile = File(..., description="图片文件"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ApiResponse[ImageUploadResponse]:
|
||||
"""
|
||||
上传图片到七牛云
|
||||
|
||||
支持格式:jpg, jpeg, png, gif, webp
|
||||
返回七牛云永久访问 URL。
|
||||
"""
|
||||
try:
|
||||
allowed_types = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
}
|
||||
content_type = file.content_type or ""
|
||||
|
||||
# 如果 content_type 为空,尝试从文件名推断
|
||||
if not content_type:
|
||||
ext = Path(file.filename or "").suffix.lower()
|
||||
ext_to_mime = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
content_type = ext_to_mime.get(ext, "")
|
||||
|
||||
if content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的图片格式: {content_type},请上传 jpg/png/gif/webp 图片",
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 校验大小和魔数
|
||||
check_upload_file(
|
||||
content,
|
||||
settings.UPLOAD_MAX_IMAGE_SIZE,
|
||||
content_type,
|
||||
"图片",
|
||||
)
|
||||
|
||||
# 生成唯一文件名
|
||||
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
|
||||
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
|
||||
|
||||
# 上传到七牛云
|
||||
qiniu = get_qiniu_service()
|
||||
bucket, domain = qiniu._get_bucket_and_domain("image")
|
||||
file_key = qiniu.generate_key("image", unique_name)
|
||||
stream = io.BytesIO(content)
|
||||
result = await qiniu.upload_stream_async(
|
||||
stream=stream,
|
||||
key=file_key,
|
||||
mime_type=content_type or "image/jpeg",
|
||||
bucket=bucket,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
url = result.get("url")
|
||||
returned_key = result.get("key")
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
|
||||
|
||||
logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}")
|
||||
|
||||
return success_response(
|
||||
data=ImageUploadResponse(
|
||||
url=url,
|
||||
key=returned_key or file_key,
|
||||
size=len(content),
|
||||
)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Upload] 图片上传失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
|
||||
|
||||
|
||||
@router.post("/image/remove-background", response_model=ApiResponse[RemoveBackgroundResponse])
|
||||
async def remove_background(
|
||||
req: RemoveBackgroundRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
mediakit_service: VolcengineMediakitService = Depends(get_mediakit_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[RemoveBackgroundResponse]:
|
||||
"""
|
||||
AI 抠图(火山引擎 MediaKit)
|
||||
|
||||
移除图片背景,返回透明背景图片 URL。
|
||||
每次调用消耗 10 积分。
|
||||
"""
|
||||
# 前置积分检查
|
||||
required_points = ps._calculate_cost("cover_avatar")
|
||||
check = await ps.check_balance(db, current_user.id, required_points)
|
||||
if not check["sufficient"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"[RemoveBackground] 开始抠图: image_url={req.image_url[:80]}..., scene={req.scene}"
|
||||
)
|
||||
result = await mediakit_service.remove_background(
|
||||
image_url=req.image_url,
|
||||
scene=req.scene,
|
||||
)
|
||||
|
||||
if not result.image_url:
|
||||
logger.error(
|
||||
f"[RemoveBackground] 抠图返回空 URL: raw={result.raw}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="抠图失败:未返回结果图片 URL")
|
||||
|
||||
logger.info(f"[RemoveBackground] 抠图成功: {result.image_url[:80]}...")
|
||||
|
||||
# 下载抠图结果并转存到七牛云(避免前端 CORS 问题)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
img_resp = await client.get(result.image_url, follow_redirects=True)
|
||||
img_resp.raise_for_status()
|
||||
img_content = img_resp.content
|
||||
|
||||
if not img_content:
|
||||
raise HTTPException(status_code=500, detail="抠图结果下载失败:内容为空")
|
||||
|
||||
# 上传到七牛云 image bucket
|
||||
qiniu = get_qiniu_service()
|
||||
bucket, domain = qiniu._get_bucket_and_domain("image")
|
||||
unique_name = f"{uuid.uuid4().hex[:16]}.png"
|
||||
file_key = qiniu.generate_key("image", unique_name)
|
||||
stream = io.BytesIO(img_content)
|
||||
upload_result = await qiniu.upload_stream_async(
|
||||
stream=stream,
|
||||
key=file_key,
|
||||
mime_type="image/png",
|
||||
bucket=bucket,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
qiniu_url = upload_result.get("url")
|
||||
if not qiniu_url:
|
||||
raise HTTPException(status_code=500, detail="抠图结果转存到七牛云失败")
|
||||
|
||||
logger.info(f"[RemoveBackground] 结果已转存七牛云: {qiniu_url[:80]}...")
|
||||
|
||||
# 后置扣费(服务已调用成功)
|
||||
await ps.consume(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
points=required_points,
|
||||
source_type="cover_avatar",
|
||||
source_id=f"cover_avatar_{current_user.id}_{int(time.time() * 1000)}",
|
||||
description="【封面形象抠图】",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(
|
||||
data=RemoveBackgroundResponse(url=qiniu_url),
|
||||
message="抠图成功",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[RemoveBackground] 结果转存失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"抠图结果转存失败: {e}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[RemoveBackground] 抠图失败: image_url={req.image_url[:80]}..., error={e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"抠图失败: {e}")
|
||||
@@ -7,9 +7,11 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
bgm_music,
|
||||
caption,
|
||||
cover_background,
|
||||
events,
|
||||
image,
|
||||
materials,
|
||||
points,
|
||||
script,
|
||||
@@ -59,5 +61,11 @@ api_router.include_router(cover_background.router, tags=["Cover Background"])
|
||||
# 积分系统模块
|
||||
api_router.include_router(points.router, tags=["Points"])
|
||||
|
||||
# 图片处理模块(上传 + 抠图)
|
||||
api_router.include_router(image.router, tags=["Image"])
|
||||
|
||||
# 背景音乐模块
|
||||
api_router.include_router(bgm_music.router, tags=["BGM Music"])
|
||||
|
||||
# 应用更新模块
|
||||
api_router.include_router(update.router, prefix="/update", tags=["Update"])
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -44,11 +44,11 @@ async def check_update(
|
||||
latest: AppRelease | None = result.scalar_one_or_none()
|
||||
|
||||
if not latest:
|
||||
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# 已是最新版本(或更高)
|
||||
if latest.version == version:
|
||||
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# 查询对应平台的包
|
||||
result = await db.execute(
|
||||
@@ -62,7 +62,7 @@ async def check_update(
|
||||
|
||||
if not pkg:
|
||||
# 该平台无包,返回 204(避免报错阻断用户)
|
||||
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# 构建 Tauri 格式的响应
|
||||
platform_key = f"{target}-{arch}"
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.services.qiniu_service import get_qiniu_service
|
||||
from app.utils.file_validation import check_upload_file
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Upload"])
|
||||
|
||||
@@ -25,101 +26,6 @@ logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _validate_file_magic(content: bytes, expected_content_type: str) -> bool:
|
||||
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
|
||||
if len(content) < 12:
|
||||
return False
|
||||
|
||||
# 拒绝常见危险文件头
|
||||
dangerous_signatures = [
|
||||
(b"MZ", "Windows 可执行文件"), # .exe, .dll
|
||||
(b"#!", "Shell 脚本"), # bash, python, etc
|
||||
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
|
||||
(b"<?xml", "XML 文件"),
|
||||
(b"<html", "HTML 文件"),
|
||||
(b"<!DO", "HTML 文档"),
|
||||
(b"%PDF", "PDF 文件"),
|
||||
]
|
||||
for sig, _ in dangerous_signatures:
|
||||
if content.startswith(sig):
|
||||
return False
|
||||
if b"<script" in content[:512].lower():
|
||||
return False
|
||||
|
||||
main_type = expected_content_type.split("/")[0]
|
||||
|
||||
# 图片校验
|
||||
if main_type == "image":
|
||||
if content.startswith(b"\xff\xd8\xff"):
|
||||
return expected_content_type in ("image/jpeg", "image/jpg")
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return expected_content_type == "image/png"
|
||||
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
|
||||
return expected_content_type == "image/gif"
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
|
||||
return expected_content_type == "image/webp"
|
||||
return False
|
||||
|
||||
# 视频校验
|
||||
if main_type == "video":
|
||||
# MP4 / MOV / M4V 等 ISO Base Media File Format
|
||||
if content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in (b"qt ", b"qtw "):
|
||||
return expected_content_type in ("video/quicktime",)
|
||||
# mp4, isom, avc1, mp41, mp42 等
|
||||
return expected_content_type in (
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
)
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
|
||||
return expected_content_type == "video/x-msvideo"
|
||||
if content.startswith(b"\x1aE\xdf\xa3"):
|
||||
return expected_content_type == "video/webm"
|
||||
return False
|
||||
|
||||
# 音频校验
|
||||
if main_type == "audio":
|
||||
if content[:3] == b"ID3" or content[:2] in (
|
||||
b"\xff\xfb",
|
||||
b"\xff\xf3",
|
||||
b"\xff\xf2",
|
||||
):
|
||||
return expected_content_type in ("audio/mpeg", "audio/mp3")
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
|
||||
return expected_content_type in ("audio/wav", "audio/x-wav")
|
||||
if content.startswith(b"fLaC"):
|
||||
return expected_content_type == "audio/flac"
|
||||
if content.startswith(b"OggS"):
|
||||
return expected_content_type == "audio/ogg"
|
||||
# AAC / M4A(也是 ftyp 格式)
|
||||
if content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
|
||||
return expected_content_type in (
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
|
||||
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
|
||||
if len(content) > max_size:
|
||||
max_mb = max_size // 1024 // 1024
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
|
||||
)
|
||||
if not _validate_file_magic(content, content_type):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
|
||||
)
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
"""上传响应"""
|
||||
@@ -173,7 +79,7 @@ async def upload_video(
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 校验大小和魔数
|
||||
_check_upload_file(
|
||||
check_upload_file(
|
||||
content,
|
||||
settings.UPLOAD_MAX_VIDEO_SIZE,
|
||||
content_type,
|
||||
@@ -269,7 +175,7 @@ async def upload_audio(
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 校验大小和魔数
|
||||
_check_upload_file(
|
||||
check_upload_file(
|
||||
content,
|
||||
settings.UPLOAD_MAX_AUDIO_SIZE,
|
||||
content_type,
|
||||
|
||||
@@ -106,6 +106,9 @@ class Settings(BaseSettings):
|
||||
VOLCENGINE_CAPTION_APPID: str | None = Field(default=None, description="火山字幕 AppID")
|
||||
VOLCENGINE_CAPTION_TOKEN: str | None = Field(default=None, description="火山字幕 Token")
|
||||
|
||||
# 火山引擎 MediaKit 服务(背景移除等多媒体处理)
|
||||
VOLCENGINE_MEDIAKIT_TOKEN: str | None = Field(default=None, description="火山引擎 MediaKit Token")
|
||||
|
||||
# Vidu 密钥(base_url 已从 Settings 移除,改用 config/platform-config.yaml 配置)
|
||||
VIDU_API_KEY: str | None = Field(default=None, description="Vidu API Key")
|
||||
|
||||
@@ -134,6 +137,10 @@ class Settings(BaseSettings):
|
||||
SMS_EXTENDED_CODE: str | None = Field(
|
||||
default=None, description="B2M 短信平台扩展码(选填)"
|
||||
)
|
||||
SMS_CODE_WHITELIST: str = Field(
|
||||
default="",
|
||||
description="免验证码登录白名单(逗号分隔的手机号,如 13800138000,13900139000)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -175,6 +182,17 @@ class Settings(BaseSettings):
|
||||
"""是否使用 Redis"""
|
||||
return bool(self.REDIS_HOST)
|
||||
|
||||
@property
|
||||
def sms_code_whitelist_set(self) -> set[str]:
|
||||
"""免验证码登录白名单(去重、去空格)"""
|
||||
if not self.SMS_CODE_WHITELIST:
|
||||
return set()
|
||||
return {
|
||||
mobile.strip()
|
||||
for mobile in self.SMS_CODE_WHITELIST.split(",")
|
||||
if mobile.strip()
|
||||
}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -10,6 +10,7 @@ CRUD 模块
|
||||
user_obj = await user.get(db, id="xxx")
|
||||
"""
|
||||
|
||||
from app.crud.bgm_music import bgm_music
|
||||
from app.crud.broll_category import broll_category
|
||||
from app.crud.broll_material import broll_material
|
||||
from app.crud.cover_background import cover_background
|
||||
@@ -17,6 +18,7 @@ from app.crud.user import user
|
||||
|
||||
__all__ = [
|
||||
"user",
|
||||
"bgm_music",
|
||||
"broll_category",
|
||||
"broll_material",
|
||||
"cover_background",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
背景音乐 CRUD
|
||||
============
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.bgm_music import BgmMusic
|
||||
|
||||
|
||||
class BgmMusicCRUD(CRUDBase[BgmMusic]):
|
||||
"""背景音乐数据访问"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(BgmMusic)
|
||||
|
||||
async def get_active_by_category(
|
||||
self, db: AsyncSession, *, category: str | None = None
|
||||
) -> list[BgmMusic]:
|
||||
"""
|
||||
获取指定分类下状态为 active 的音乐列表
|
||||
|
||||
Args:
|
||||
category: 场景分类,None 表示获取全部
|
||||
"""
|
||||
query = select(BgmMusic).where(BgmMusic.status == "active")
|
||||
if category:
|
||||
query = query.where(BgmMusic.category == category)
|
||||
query = query.order_by(BgmMusic.sort_order.asc(), BgmMusic.id.asc())
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# 导出实例
|
||||
bgm_music = BgmMusicCRUD()
|
||||
@@ -66,6 +66,10 @@ async def lifespan(app: FastAPI):
|
||||
timeout=httpx.Timeout(60.0, connect=5.0),
|
||||
limits=httpx.Limits(max_connections=10, max_keepalive_connections=10),
|
||||
),
|
||||
"volcengine_mediakit": httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0, connect=5.0),
|
||||
limits=httpx.Limits(max_connections=10, max_keepalive_connections=10),
|
||||
),
|
||||
"default": httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0, connect=5.0),
|
||||
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
|
||||
@@ -90,6 +94,18 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"Volcengine Caption Provider 初始化跳过: {e}")
|
||||
app.state.volcengine_caption_provider = None
|
||||
|
||||
# 火山 Mediakit Provider
|
||||
from app.ai.providers.volcengine_mediakit_provider import VolcengineMediakitProvider
|
||||
|
||||
try:
|
||||
app.state.volcengine_mediakit_provider = VolcengineMediakitProvider(
|
||||
client=app.state.http_clients["volcengine_mediakit"]
|
||||
)
|
||||
logger.info("Volcengine Mediakit Provider initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Volcengine Mediakit Provider 初始化跳过: {e}")
|
||||
app.state.volcengine_mediakit_provider = None
|
||||
|
||||
# 火山方舟 Provider(可选,需要 API Key)
|
||||
try:
|
||||
from app.ai.providers.volcengine_provider import VolcengineProvider
|
||||
@@ -104,6 +120,7 @@ async def lifespan(app: FastAPI):
|
||||
from app.ai.adapters.vidu_adapter import ViduAdapter
|
||||
from app.ai.adapters.volcengine_ark_adapter import VolcengineArkAdapter
|
||||
from app.ai.adapters.volcengine_caption_adapter import VolcengineCaptionAdapter
|
||||
from app.ai.adapters.volcengine_mediakit_adapter import VolcengineMediakitAdapter
|
||||
from app.platform_gateway import PlatformGateway
|
||||
|
||||
app.state.vidu_adapter = ViduAdapter(app.state.vidu_provider)
|
||||
@@ -122,6 +139,15 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
logger.info("VolcengineCaptionAdapter initialized")
|
||||
|
||||
if app.state.volcengine_mediakit_provider:
|
||||
app.state.volcengine_mediakit_adapter = VolcengineMediakitAdapter(
|
||||
app.state.volcengine_mediakit_provider
|
||||
)
|
||||
app.state.platform_gateway.register(
|
||||
"volcengine_mediakit", app.state.volcengine_mediakit_adapter
|
||||
)
|
||||
logger.info("VolcengineMediakitAdapter initialized")
|
||||
|
||||
if app.state.volcengine_provider:
|
||||
app.state.volcengine_ark_adapter = VolcengineArkAdapter(
|
||||
app.state.volcengine_provider
|
||||
@@ -145,6 +171,7 @@ async def lifespan(app: FastAPI):
|
||||
# 初始化 Service(传入 Gateway)
|
||||
from app.services.vidu_service import ViduService
|
||||
from app.services.volcengine_caption_service import VolcengineCaptionService
|
||||
from app.services.volcengine_mediakit_service import VolcengineMediakitService
|
||||
|
||||
app.state.vidu_service = ViduService(app.state.platform_gateway)
|
||||
logger.info("Vidu Service initialized")
|
||||
@@ -157,6 +184,14 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
app.state.volcengine_caption_service = None
|
||||
|
||||
if app.state.volcengine_mediakit_provider:
|
||||
app.state.volcengine_mediakit_service = VolcengineMediakitService(
|
||||
app.state.platform_gateway
|
||||
)
|
||||
logger.info("Volcengine Mediakit Service initialized")
|
||||
else:
|
||||
app.state.volcengine_mediakit_service = None
|
||||
|
||||
# LLM Gateway(可选,向后兼容)
|
||||
if app.state.volcengine_provider:
|
||||
from app.ai.gateways.llm_gateway import LLMGateway
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"""
|
||||
|
||||
from app.models.base import BaseModel, BaseModelBigInt
|
||||
from app.models.bgm_music import BgmMusic
|
||||
from app.models.broll_category import BrollCategory
|
||||
from app.models.broll_material import BrollMaterial
|
||||
from app.models.broll_tag import BrollTag
|
||||
@@ -21,6 +22,7 @@ from app.models.user_point import UserPoint
|
||||
__all__ = [
|
||||
"BaseModel",
|
||||
"BaseModelBigInt",
|
||||
"BgmMusic",
|
||||
"User",
|
||||
"UserDevice",
|
||||
"UserPoint",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
背景音乐模型
|
||||
==========
|
||||
|
||||
装修行业场景化 BGM 库,按知识科普/案例展示/促销活动/家居生活/智能家居
|
||||
五个场景分类管理。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Float, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BaseModelBigInt
|
||||
|
||||
|
||||
class BgmMusic(BaseModelBigInt):
|
||||
"""
|
||||
背景音乐表
|
||||
|
||||
Attributes:
|
||||
title: 音乐名称
|
||||
artist: 艺术家
|
||||
category: 场景分类 (knowledge/showcase/promotion/lifestyle/tech)
|
||||
file_path: 相对文件路径(如 knowledge/3_Dance_with_Me.mp3)
|
||||
duration: 时长(秒)
|
||||
status: 状态 (active/inactive)
|
||||
sort_order: 排序权重(越小越靠前)
|
||||
"""
|
||||
|
||||
__tablename__ = "mjk_bgm_musics"
|
||||
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="音乐名称")
|
||||
artist: Mapped[str] = mapped_column(String(255), nullable=True, comment="艺术家")
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, index=True, comment="场景分类"
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False, comment="相对文件路径"
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True, comment="七牛云 URL"
|
||||
)
|
||||
duration: Mapped[float] = mapped_column(
|
||||
Float, nullable=True, comment="时长(秒)"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), default="active", nullable=False, comment="状态: active/inactive"
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False, comment="排序权重"
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
背景音乐 Schema
|
||||
==============
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BgmMusicItem(BaseModel):
|
||||
"""背景音乐项"""
|
||||
|
||||
id: int = Field(description="音乐ID")
|
||||
title: str = Field(description="音乐名称")
|
||||
artist: str | None = Field(default=None, description="艺术家")
|
||||
category: str = Field(description="场景分类")
|
||||
file_path: str = Field(description="相对文件路径")
|
||||
url: str | None = Field(default=None, description="七牛云 URL")
|
||||
duration: float | None = Field(default=None, description="时长(秒)")
|
||||
sort_order: int = Field(default=0, description="排序权重")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BgmMusicListResponse(BaseModel):
|
||||
"""背景音乐列表响应"""
|
||||
|
||||
items: list[BgmMusicItem] = Field(description="音乐列表")
|
||||
total: int = Field(description="总数")
|
||||
@@ -18,6 +18,7 @@ from typing import Any
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.redis_client import get_redis_client
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
@@ -188,8 +189,9 @@ async def login_with_sms(
|
||||
5. 创建/覆盖设备记录
|
||||
6. 签发双 Token
|
||||
"""
|
||||
# 1. 校验验证码
|
||||
if not await verify_sms_code(mobile, code):
|
||||
# 1. 校验验证码(白名单内的手机号跳过校验)
|
||||
settings = get_settings()
|
||||
if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code):
|
||||
raise ValueError("验证码错误或已过期")
|
||||
|
||||
# 2. 查询用户(不再自动注册)
|
||||
|
||||
@@ -329,6 +329,7 @@ _CATEGORY_MAP: dict[str, str] = {
|
||||
"compose": "压制成片",
|
||||
"subtitle_burn": "字幕烧录",
|
||||
"cover_design": "封面设计",
|
||||
"cover_avatar": "封面形象",
|
||||
"wxpay": "充值",
|
||||
"compensation": "充值",
|
||||
"invite": "充值",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
火山引擎 MediaKit Service
|
||||
=========================
|
||||
|
||||
通过 PlatformGateway 调用 MediaKit 第三方 API,自身负责:
|
||||
- 参数校验
|
||||
- 结果提取与格式化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.ai.adapters.constants import Method
|
||||
from app.platform_gateway import PlatformGateway
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoveBackgroundResult:
|
||||
"""抠图结果"""
|
||||
|
||||
image_url: str = ""
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class VolcengineMediakitService:
|
||||
"""火山引擎 MediaKit 服务封装"""
|
||||
|
||||
# 支持的场景
|
||||
SUPPORTED_SCENES = {"general", "human", "product"}
|
||||
|
||||
def __init__(self, gateway: PlatformGateway):
|
||||
self.gateway = gateway
|
||||
|
||||
async def remove_background(
|
||||
self,
|
||||
image_url: str,
|
||||
scene: str = "human",
|
||||
) -> RemoveBackgroundResult:
|
||||
"""同步抠图
|
||||
|
||||
Args:
|
||||
image_url: 原始图片 URL
|
||||
scene: 场景类型,"general"(通用)、"human"(人物)或 "product"(商品)
|
||||
|
||||
Returns:
|
||||
RemoveBackgroundResult: 包含抠图结果图片 URL
|
||||
|
||||
Raises:
|
||||
ValueError: 参数校验失败
|
||||
PlatformError: 平台调用失败
|
||||
"""
|
||||
if not image_url:
|
||||
raise ValueError("image_url 不能为空")
|
||||
|
||||
if scene not in self.SUPPORTED_SCENES:
|
||||
raise ValueError(f"不支持的场景: {scene},可选: {self.SUPPORTED_SCENES}")
|
||||
|
||||
# 人物/商品场景默认启用白色描边 + 裁剪背景
|
||||
enable_contour = scene in ("human", "product")
|
||||
payload = {
|
||||
"image_url": image_url,
|
||||
"scene": scene,
|
||||
"need_contour": enable_contour,
|
||||
"contour_color": "#FFFFFF",
|
||||
"contour_size": 20,
|
||||
"need_crop_background": enable_contour,
|
||||
}
|
||||
|
||||
response = await self.gateway.call_sync(
|
||||
platform="volcengine_mediakit",
|
||||
method=Method.REMOVE_BACKGROUND,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
if not response.success:
|
||||
raise RuntimeError(
|
||||
response.error_message or "抠图失败"
|
||||
)
|
||||
|
||||
result_image_url = (response.data or {}).get("image_url", "")
|
||||
return RemoveBackgroundResult(
|
||||
image_url=result_image_url,
|
||||
raw=response.data or {},
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
文件校验工具
|
||||
==========
|
||||
|
||||
提供文件头魔数校验和上传文件统一校验功能,
|
||||
防止 MIME 伪造攻击和危险文件上传。
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def validate_file_magic(content: bytes, expected_content_type: str) -> bool:
|
||||
"""通过文件头魔数校验文件真实类型,防止 MIME 伪造攻击。"""
|
||||
if len(content) < 12:
|
||||
return False
|
||||
|
||||
# 拒绝常见危险文件头
|
||||
dangerous_signatures = [
|
||||
(b"MZ", "Windows 可执行文件"), # .exe, .dll
|
||||
(b"#!", "Shell 脚本"), # bash, python, etc
|
||||
(b"PK\x03\x04", "ZIP 压缩包"), # .zip, .jar, .docx
|
||||
(b"<?xml", "XML 文件"),
|
||||
(b"<html", "HTML 文件"),
|
||||
(b"<!DO", "HTML 文档"),
|
||||
(b"%PDF", "PDF 文件"),
|
||||
]
|
||||
for sig, _ in dangerous_signatures:
|
||||
if content.startswith(sig):
|
||||
return False
|
||||
if b"<script" in content[:512].lower():
|
||||
return False
|
||||
|
||||
main_type = expected_content_type.split("/")[0]
|
||||
|
||||
# 图片校验
|
||||
if main_type == "image":
|
||||
if content.startswith(b"\xff\xd8\xff"):
|
||||
return expected_content_type in ("image/jpeg", "image/jpg")
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return expected_content_type == "image/png"
|
||||
if content.startswith(b"GIF89a") or content.startswith(b"GIF87a"):
|
||||
return expected_content_type == "image/gif"
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
|
||||
return expected_content_type == "image/webp"
|
||||
return False
|
||||
|
||||
# 视频校验
|
||||
if main_type == "video":
|
||||
# MP4 / MOV / M4V 等 ISO Base Media File Format
|
||||
if content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in (b"qt ", b"qtw "):
|
||||
return expected_content_type in ("video/quicktime",)
|
||||
# mp4, isom, avc1, mp41, mp42 等
|
||||
return expected_content_type in (
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
)
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"AVI ":
|
||||
return expected_content_type == "video/x-msvideo"
|
||||
if content.startswith(b"\x1aE\xdf\xa3"):
|
||||
return expected_content_type == "video/webm"
|
||||
return False
|
||||
|
||||
# 音频校验
|
||||
if main_type == "audio":
|
||||
if content[:3] == b"ID3" or content[:2] in (
|
||||
b"\xff\xfb",
|
||||
b"\xff\xf3",
|
||||
b"\xff\xf2",
|
||||
):
|
||||
return expected_content_type in ("audio/mpeg", "audio/mp3")
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WAVE":
|
||||
return expected_content_type in ("audio/wav", "audio/x-wav")
|
||||
if content.startswith(b"fLaC"):
|
||||
return expected_content_type == "audio/flac"
|
||||
if content.startswith(b"OggS"):
|
||||
return expected_content_type == "audio/ogg"
|
||||
# AAC / M4A(也是 ftyp 格式)
|
||||
if content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in (b"M4A ", b"m4a ", b"mp42", b"isom", b"M4P "):
|
||||
return expected_content_type in (
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_upload_file(content: bytes, max_size: int, content_type: str, type_label: str) -> None:
|
||||
"""统一校验文件大小和魔数,失败时直接抛 HTTPException。"""
|
||||
if len(content) > max_size:
|
||||
max_mb = max_size // 1024 // 1024
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"{type_label}文件大小不能超过 {max_mb}MB",
|
||||
)
|
||||
if not validate_file_magic(content, content_type):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{type_label}文件内容与实际格式不符,可能存在安全风险",
|
||||
)
|
||||
@@ -87,6 +87,23 @@ platforms:
|
||||
qps: 5
|
||||
burst: 10
|
||||
|
||||
# ── 火山引擎媒体处理(MediaKit)──
|
||||
volcengine_mediakit:
|
||||
name: "火山引擎媒体处理"
|
||||
provider: "volcengine_mediakit"
|
||||
base_url: "https://mediakit.cn-beijing.volces.com"
|
||||
rate_limit:
|
||||
qps: 5
|
||||
burst: 10
|
||||
models: []
|
||||
methods:
|
||||
remove_background:
|
||||
timeout: 120
|
||||
max_connections: 10
|
||||
rate_limit:
|
||||
qps: 5
|
||||
burst: 10
|
||||
|
||||
# ── 任务默认模型映射 ──
|
||||
# 当调用方未指定模型时,按任务类型选择默认模型
|
||||
task_defaults:
|
||||
|
||||
@@ -24,10 +24,13 @@ fixed_costs:
|
||||
voice_clone: 200
|
||||
|
||||
# 字幕烧录:将生成的字幕文件烧录到视频中(FFmpeg 合成)
|
||||
subtitle_burn: 2
|
||||
subtitle_burn: 5
|
||||
|
||||
# 封面设计:根据视频内容自动生成封面图
|
||||
cover_design: 2
|
||||
cover_design: 5
|
||||
|
||||
# 封面形象:上传人物照片 AI 抠图生成透明背景形象
|
||||
cover_avatar: 10
|
||||
|
||||
# 压制成片:将多个素材片段合并为最终视频(FFmpeg 拼接)
|
||||
compose: 5
|
||||
@@ -52,11 +55,11 @@ duration_based_costs:
|
||||
# 计算依据:中文正常朗读语速约 200~250 字/分钟,取 240 字/分钟:
|
||||
# 60秒 ÷ 240字 = 0.25 秒/字
|
||||
# 注意:此为经验值,未经过 Vidu TTS 实测校准。实际时长受标点停顿、
|
||||
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
|
||||
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
|
||||
# TODO: 收集实测数据后校准此值。
|
||||
seconds_per_char: 0.25
|
||||
|
||||
# ── 视频生成(对口型)──
|
||||
# ── 视频生成 ──
|
||||
# 计费公式:max(min_points, ceil(实际视频秒数) × multiplier)
|
||||
# 说明:秒数先向上取整为整数,再乘以 multiplier。不足 1 秒按 1 秒计算。
|
||||
# 示例:4.5秒视频 → ceil(4.5) × 1 = 5 积分;0.8秒 → ceil(0.8) × 1 = 1 积分
|
||||
@@ -66,8 +69,7 @@ duration_based_costs:
|
||||
|
||||
# 预估参数(执行业务前检查余额时使用)
|
||||
estimation:
|
||||
# 是否直接使用输入素材秒数作为预估上限。
|
||||
# 视频生成时长 = 输入音频/素材时长,因此用 input_seconds 预估最准确。
|
||||
# 视频生成时长 = 输入音频时长,因此用 input_seconds 预估最准确。
|
||||
# 调用方需传入 input_seconds 参数。
|
||||
use_input_seconds: true
|
||||
|
||||
@@ -77,10 +79,10 @@ duration_based_costs:
|
||||
# 支持积分赠送:points 为实际到账积分数,amount_rmb 为支付金额(分)。
|
||||
# label 为空时不显示标签角标。
|
||||
recharge_options:
|
||||
- { price: 10000, points: 2000, label: "", validity_days: 180 }
|
||||
- { price: 10000, points: 2000, label: "", validity_days: 90 }
|
||||
- { price: 50000, points: 11000, label: "热销", validity_days: 180 }
|
||||
- { price: 100000, points: 23000, label: "推荐", validity_days: 365 }
|
||||
- { price: 500000, points: 125000, label: "超值", validity_days: 0 }
|
||||
- { price: 100000, points: 22500, label: "推荐", validity_days: 180 }
|
||||
- { price: 500000, points: 120000, label: "超值", validity_days: 365 }
|
||||
|
||||
|
||||
# ── 免费业务(不扣积分)───────────────────────────────
|
||||
|
||||
@@ -15,6 +15,7 @@ echo "========================================"
|
||||
PROJECT_DIR="/opt/meijiaka-zy"
|
||||
GIT_REPO="http://git2.haodian.cn/xiaoyu/meijiaka-zy.git"
|
||||
API_PORT=8081
|
||||
COMPOSE_FILE="docker-compose.test.yml"
|
||||
|
||||
# 1. 检查 Docker
|
||||
echo "[1/7] 检查 Docker 环境..."
|
||||
@@ -34,7 +35,8 @@ docker compose version || echo "docker-compose 版本: $(docker-compose --versio
|
||||
echo "[2/7] 更新代码..."
|
||||
if [ -d "$PROJECT_DIR/.git" ]; then
|
||||
cd "$PROJECT_DIR"
|
||||
git pull origin master
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
else
|
||||
git clone "$GIT_REPO" "$PROJECT_DIR"
|
||||
cd "$PROJECT_DIR"
|
||||
@@ -83,12 +85,12 @@ echo "✅ 环境变量检查通过"
|
||||
|
||||
# 5. 构建镜像
|
||||
echo "[5/7] 构建 Docker 镜像..."
|
||||
docker compose -f docker-compose.test.yml build --no-cache
|
||||
docker compose -f "$COMPOSE_FILE" build --pull
|
||||
|
||||
# 6. 启动服务
|
||||
echo "[6/7] 启动服务..."
|
||||
docker compose -f docker-compose.test.yml down 2>/dev/null || true
|
||||
docker compose -f docker-compose.test.yml up -d
|
||||
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
# 7. 等待并验证
|
||||
echo "[7/7] 等待服务启动..."
|
||||
@@ -98,6 +100,10 @@ MAX_RETRY=12
|
||||
RETRY=0
|
||||
while [ $RETRY -lt $MAX_RETRY ]; do
|
||||
if curl -s http://localhost:$API_PORT/health | grep -q "healthy"; then
|
||||
# 验证 scheduler 容器也在运行
|
||||
if ! docker ps --filter "name=meijiaka-zy-scheduler" --filter "status=running" -q | grep -q .; then
|
||||
echo "⚠️ API 已就绪,但 scheduler 容器未运行,请检查日志"
|
||||
fi
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " ✅ 测试服部署成功!"
|
||||
@@ -114,8 +120,8 @@ while [ $RETRY -lt $MAX_RETRY ]; do
|
||||
echo ""
|
||||
echo " 常用命令:"
|
||||
echo " cd $PROJECT_DIR/python-api"
|
||||
echo " docker compose -f docker-compose.test.yml logs -f"
|
||||
echo " docker compose -f docker-compose.test.yml restart api"
|
||||
echo " docker compose -f $COMPOSE_FILE logs -f"
|
||||
echo " docker compose -f $COMPOSE_FILE restart api"
|
||||
echo "========================================"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -61,6 +61,9 @@ dependencies = [
|
||||
|
||||
# 音频时长探测(TTS 扣费用)
|
||||
"mutagen~=1.47.0",
|
||||
|
||||
# 图像处理(智能抠图合成封面)
|
||||
"Pillow>=11.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
导入 Mixkit BGM 音频文件到数据库并上传七牛云
|
||||
扫描 mixkit_bgm/ 目录下的分类文件夹,读取音频元数据并插入 mjk_bgm_musics 表
|
||||
同时上传音频到七牛云视频/音频 bucket,保存 URL。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 将项目根目录加入 Python 路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# 加载环境变量
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from mutagen.mp3 import MP3
|
||||
from sqlalchemy import select
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.bgm_music import BgmMusic
|
||||
from app.services.qiniu_service import get_qiniu_service
|
||||
|
||||
BGM_BASE_DIR = Path("/Users/0fun/work/meijiaka-zy/mixkit_bgm")
|
||||
|
||||
|
||||
def parse_title(filename: str) -> str:
|
||||
"""从文件名解析标题,如 '105_See_Line_Funk.mp3' -> 'See Line Funk'"""
|
||||
# 去掉扩展名
|
||||
name = filename.rsplit(".", 1)[0]
|
||||
# 去掉开头的编号前缀(如 105_)
|
||||
parts = name.split("_", 1)
|
||||
if len(parts) > 1 and parts[0].isdigit():
|
||||
title_part = parts[1]
|
||||
else:
|
||||
title_part = name
|
||||
# 将下划线替换为空格
|
||||
return title_part.replace("_", " ")
|
||||
|
||||
|
||||
def get_duration(filepath: Path) -> float | None:
|
||||
"""获取音频文件时长(秒)"""
|
||||
try:
|
||||
audio = MP3(str(filepath))
|
||||
return audio.info.length
|
||||
except Exception as e:
|
||||
print(f" 无法读取时长: {filepath.name} ({e})")
|
||||
return None
|
||||
|
||||
|
||||
async def import_bgm():
|
||||
"""扫描目录并导入数据库,同时上传七牛云"""
|
||||
if not BGM_BASE_DIR.exists():
|
||||
print(f"错误: BGM 目录不存在: {BGM_BASE_DIR}")
|
||||
return
|
||||
|
||||
categories = [d.name for d in BGM_BASE_DIR.iterdir() if d.is_dir()]
|
||||
print(f"发现分类: {categories}")
|
||||
|
||||
# 初始化七牛云服务
|
||||
try:
|
||||
qiniu = get_qiniu_service()
|
||||
print("七牛云服务初始化成功")
|
||||
except ValueError as e:
|
||||
print(f"七牛云配置错误: {e}")
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 清空现有数据(可选)
|
||||
result = await session.execute(select(BgmMusic))
|
||||
existing = result.scalars().all()
|
||||
if existing:
|
||||
print(f"数据库中已有 {len(existing)} 条 BGM 记录,将删除后重新导入")
|
||||
for row in existing:
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
|
||||
total = 0
|
||||
upload_ok = 0
|
||||
upload_fail = 0
|
||||
for category in sorted(categories):
|
||||
cat_dir = BGM_BASE_DIR / category
|
||||
files = sorted([f for f in cat_dir.iterdir() if f.suffix.lower() in (".mp3", ".wav", ".m4a")])
|
||||
print(f"\n分类 [{category}]: {len(files)} 首")
|
||||
|
||||
for idx, filepath in enumerate(files):
|
||||
title = parse_title(filepath.name)
|
||||
relative_path = f"{category}/{filepath.name}"
|
||||
duration = get_duration(filepath)
|
||||
|
||||
# 上传七牛云
|
||||
qiniu_key = f"meijiaka-zy/bgm/{relative_path}"
|
||||
url = None
|
||||
try:
|
||||
upload_result = qiniu.upload_file(
|
||||
local_path=str(filepath),
|
||||
key=qiniu_key,
|
||||
file_type="audio",
|
||||
check_duplicate=True,
|
||||
)
|
||||
url = upload_result.get("url")
|
||||
if upload_result.get("isDuplicate"):
|
||||
print(f" + {title} (复用已有文件)")
|
||||
else:
|
||||
print(f" + {title} (上传成功)")
|
||||
upload_ok += 1
|
||||
except Exception as e:
|
||||
print(f" ! {title} (上传失败: {e})")
|
||||
upload_fail += 1
|
||||
# 上传失败也继续入库,只是 url 为空
|
||||
|
||||
bgm = BgmMusic(
|
||||
title=title,
|
||||
artist=None,
|
||||
category=category,
|
||||
file_path=relative_path,
|
||||
url=url,
|
||||
duration=duration,
|
||||
status="active",
|
||||
sort_order=idx,
|
||||
)
|
||||
session.add(bgm)
|
||||
total += 1
|
||||
|
||||
await session.commit()
|
||||
print(f"\n导入完成,共 {total} 首")
|
||||
print(f" 上传成功: {upload_ok} 首")
|
||||
print(f" 上传失败: {upload_fail} 首")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(import_bgm())
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BGM 种子数据导入脚本
|
||||
==================
|
||||
|
||||
用于部署环境初始化 BGM 数据。读取同目录下的 bgm_seed_data.json,
|
||||
将 129 首系统背景音乐的元数据(含七牛云 URL)写入数据库。
|
||||
|
||||
执行方式(在 API 容器内):
|
||||
python scripts/seed_bgm.py
|
||||
|
||||
环境变量依赖:
|
||||
DATABASE_URL — 同应用配置,容器启动时已注入
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.bgm_music import BgmMusic
|
||||
|
||||
|
||||
SEED_FILE = Path(__file__).parent / "bgm_seed_data.json"
|
||||
|
||||
|
||||
async def seed_bgm():
|
||||
if not SEED_FILE.exists():
|
||||
print(f"错误: 种子数据文件不存在: {SEED_FILE}")
|
||||
return
|
||||
|
||||
with open(SEED_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 检查是否已有数据
|
||||
count = await session.scalar(select(func.count(BgmMusic.id)))
|
||||
if count and count > 0:
|
||||
print(f"数据库中已有 {count} 条 BGM 记录,跳过导入")
|
||||
print("如需强制重新导入,请先清空 mjk_bgm_musics 表")
|
||||
return
|
||||
|
||||
for idx, item in enumerate(data):
|
||||
bgm = BgmMusic(
|
||||
title=item["title"],
|
||||
artist=item.get("artist"),
|
||||
category=item["category"],
|
||||
file_path=item["file_path"],
|
||||
url=item.get("url"),
|
||||
duration=item.get("duration"),
|
||||
status=item.get("status", "active"),
|
||||
sort_order=item.get("sort_order", idx),
|
||||
)
|
||||
session.add(bgm)
|
||||
|
||||
await session.commit()
|
||||
print(f"种子数据导入完成,共 {len(data)} 首")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_bgm())
|
||||
@@ -957,6 +957,7 @@ dependencies = [
|
||||
{ name = "openai" },
|
||||
{ name = "orjson" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
@@ -999,6 +1000,7 @@ requires-dist = [
|
||||
{ name = "openai", specifier = "~=1.58.0" },
|
||||
{ name = "orjson", specifier = ">=3.11.0" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = "~=1.7.4" },
|
||||
{ name = "pillow", specifier = ">=11.0.0" },
|
||||
{ name = "pip-audit", marker = "extra == 'dev'", specifier = "~=2.7.0" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "~=4.0.0" },
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.10" },
|
||||
@@ -1280,6 +1282,64 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.0.1"
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成圆角图标:原图(白底+内容)作为整体,缩放居中,圆角外透明"""
|
||||
"""生成圆角图标:原图(透明背景 logo)作为整体,缩放居中,圆角外透明"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ICONS_DIR = "/Users/0fun/work/meijiaka-zy/tauri-app/src-tauri/icons"
|
||||
SOURCE_PNG = "/tmp/original-source-icon.png"
|
||||
SOURCE_PNG = "/Users/0fun/work/meijiaka-zy/tauri-app/public/assets/logo.png"
|
||||
|
||||
# macOS Big Sur 圆角比例 ≈ 22.6%
|
||||
CORNER_RATIO = 0.226
|
||||
CORNER_RATIO_MACOS = 0.226
|
||||
|
||||
# Windows 11 风格圆角比例 ≈ 18%(Fluent Design 圆角矩形)
|
||||
CORNER_RATIO_WINDOWS = 0.18
|
||||
|
||||
# 内容占画布比例(参考腾讯视频 ≈ 80.5%)
|
||||
CONTENT_RATIO = 0.805
|
||||
@@ -43,14 +46,33 @@ def create_rounded_rect_mask(size: int, radius: int) -> Image.Image:
|
||||
return mask
|
||||
|
||||
|
||||
def prepare_source(source: Image.Image, canvas_size: int = 1024) -> Image.Image:
|
||||
"""将源图居中放置在正方形透明画布上,作为后续处理的统一源图"""
|
||||
src_w, src_h = source.size
|
||||
canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
# 等比缩放,长边充满画布的 CONTENT_RATIO
|
||||
target_size = int(canvas_size * CONTENT_RATIO)
|
||||
ratio = max(target_size / src_w, target_size / src_h)
|
||||
new_w = int(src_w * ratio)
|
||||
new_h = int(src_h * ratio)
|
||||
resized = source.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
# 居中放置
|
||||
left = (canvas_size - new_w) // 2
|
||||
top = (canvas_size - new_h) // 2
|
||||
canvas.paste(resized, (left, top), resized)
|
||||
return canvas
|
||||
|
||||
|
||||
def compose_icon(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
|
||||
"""原图作为整体,缩放至画布 CONTENT_RATIO,居中"""
|
||||
"""macOS / Linux 图标:内容占画布 80.5%,大圆角"""
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
|
||||
plate_size = int(size * CONTENT_RATIO)
|
||||
plate_offset = (size - plate_size) // 2
|
||||
|
||||
# 原图等比缩放,短边充满 plate_size
|
||||
# 源图等比缩放,短边充满 plate_size
|
||||
src_w, src_h = source.size
|
||||
ratio = max(plate_size / src_w, plate_size / src_h)
|
||||
new_w = int(src_w * ratio)
|
||||
@@ -64,18 +86,20 @@ def compose_icon(size: int, source: Image.Image, rounded: bool = True) -> Image.
|
||||
|
||||
if rounded:
|
||||
# 圆角蒙版裁剪(macOS / Linux)
|
||||
radius = int(plate_size * CORNER_RATIO)
|
||||
radius = int(plate_size * CORNER_RATIO_MACOS)
|
||||
mask = create_rounded_rect_mask(plate_size, radius)
|
||||
canvas.paste(img, (plate_offset, plate_offset), mask)
|
||||
else:
|
||||
# 正方形填满(Windows 专用)
|
||||
# 正方形填满
|
||||
canvas.paste(img, (plate_offset, plate_offset))
|
||||
return canvas
|
||||
|
||||
|
||||
def compose_icon_windows(size: int, source: Image.Image) -> Image.Image:
|
||||
"""Windows 专用:原图填满整个画布 100%,无圆角,无透明边距"""
|
||||
# 原图等比缩放,短边充满画布
|
||||
def compose_icon_windows(size: int, source: Image.Image, rounded: bool = True) -> Image.Image:
|
||||
"""Windows 图标:原图填满整个画布 100%,支持轻微圆角"""
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
|
||||
# 源图等比缩放,短边充满画布
|
||||
src_w, src_h = source.size
|
||||
ratio = max(size / src_w, size / src_h)
|
||||
new_w = int(src_w * ratio)
|
||||
@@ -85,7 +109,17 @@ def compose_icon_windows(size: int, source: Image.Image) -> Image.Image:
|
||||
# 居中裁剪到画布尺寸
|
||||
left = (new_w - size) // 2
|
||||
top = (new_h - size) // 2
|
||||
return resized.crop((left, top, left + size, top + size))
|
||||
img = resized.crop((left, top, left + size, top + size))
|
||||
|
||||
if rounded:
|
||||
# Windows 11 风格轻微圆角
|
||||
radius = max(1, int(size * CORNER_RATIO_WINDOWS))
|
||||
mask = create_rounded_rect_mask(size, radius)
|
||||
canvas.paste(img, (0, 0), mask)
|
||||
else:
|
||||
canvas.paste(img, (0, 0))
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def generate_icns(source: Image.Image, output_path: str):
|
||||
@@ -112,17 +146,18 @@ def generate_icns(source: Image.Image, output_path: str):
|
||||
|
||||
|
||||
def generate_ico(source: Image.Image, output_path: str):
|
||||
"""生成 Windows .ico 文件"""
|
||||
"""生成 Windows .ico 文件,包含更多尺寸以支持高 DPI"""
|
||||
import struct
|
||||
import io
|
||||
|
||||
sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||
# 添加 20x20 和 40x40 以支持 Windows 高 DPI (125%, 150%)
|
||||
sizes = [16, 20, 24, 32, 40, 48, 64, 128, 256]
|
||||
png_datas = []
|
||||
entries = []
|
||||
|
||||
for sz in sizes:
|
||||
# Windows .ico 填满画布,无圆角,无透明边距
|
||||
img = compose_icon_windows(sz, source)
|
||||
# Windows 图标使用轻微圆角
|
||||
img = compose_icon_windows(sz, source, rounded=True)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
@@ -149,18 +184,24 @@ def generate_ico(source: Image.Image, output_path: str):
|
||||
|
||||
|
||||
def main():
|
||||
source = Image.open(SOURCE_PNG).convert("RGBA")
|
||||
print(f"源图标尺寸: {source.size}")
|
||||
# 加载原始 logo(透明背景)
|
||||
raw_source = Image.open(SOURCE_PNG).convert("RGBA")
|
||||
print(f"原始 logo 尺寸: {raw_source.size}")
|
||||
|
||||
# 预处理:居中放置在 1024x1024 透明画布上
|
||||
source = prepare_source(raw_source, canvas_size=1024)
|
||||
print(f"预处理后源图尺寸: {source.size}")
|
||||
|
||||
# macOS / Linux PNG 图标(大圆角 + 透明边距)
|
||||
for filename, size in PNG_SIZES:
|
||||
path = os.path.join(ICONS_DIR, filename)
|
||||
compose_icon(size, source).save(path)
|
||||
compose_icon(size, source, rounded=True).save(path)
|
||||
print(f"已生成: {filename} ({size}x{size})")
|
||||
|
||||
# Windows Square Logo 填满画布,无圆角,无透明边距
|
||||
# Windows Square Logo(轻微圆角 + 填满画布)
|
||||
for filename, size in SQUARE_SIZES:
|
||||
path = os.path.join(ICONS_DIR, filename)
|
||||
compose_icon_windows(size, source).save(path)
|
||||
compose_icon_windows(size, source, rounded=True).save(path)
|
||||
print(f"已生成: {filename} ({size}x{size})")
|
||||
|
||||
generate_icns(source, os.path.join(ICONS_DIR, "icon.icns"))
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
### 2.2 密钥管理
|
||||
|
||||
**公钥**
|
||||
- 文件: `src-tauri/tauri.key.pub`
|
||||
- 文件: `src-tauri/.tauri-signing-key.pub`
|
||||
- 已嵌入 `tauri.conf.json` → `plugins.updater.pubkey`
|
||||
- **已提交到 Git 仓库**(公钥可以公开)
|
||||
|
||||
**私钥**
|
||||
- 文件: `tauri.key`(**已删除,未提交到 Git**)
|
||||
- 文件: `.tauri-signing-key`(**已加入 .gitignore,未提交到 Git**)
|
||||
- 必须保存在安全位置(如 GitLab CI/CD Variables、1Password、公司密码管理器)
|
||||
- **切勿泄露或提交到仓库**
|
||||
|
||||
@@ -48,11 +48,11 @@
|
||||
|
||||
```bash
|
||||
cd tauri-app
|
||||
npm run tauri signer generate -- --write-keys src-tauri/tauri.key
|
||||
npm run tauri signer generate -- --write-keys src-tauri/.tauri-signing-key
|
||||
```
|
||||
|
||||
然后:
|
||||
1. 将新生成的公钥(`tauri.key.pub` 内容)更新到 `tauri.conf.json`
|
||||
1. 将新生成的公钥(`.tauri-signing-key.pub` 内容)更新到 `tauri.conf.json`
|
||||
2. 将新生成的私钥保存到 GitLab CI/CD Variables
|
||||
3. **旧版本客户端将无法通过自动更新升级**(公钥不匹配),必须重新下载安装包
|
||||
|
||||
@@ -120,5 +120,5 @@ npm run tauri signer generate -- --write-keys src-tauri/tauri.key
|
||||
## 五、相关文件
|
||||
|
||||
- `src-tauri/tauri.conf.json` — updater 公钥配置
|
||||
- `src-tauri/tauri.key.pub` — minisign 公钥(已提交)
|
||||
- `src-tauri/.tauri-signing-key.pub` — minisign 公钥(已提交)
|
||||
- `.gitlab-ci.yml` — CI 构建流程与签名环境变量
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -1818,7 +1818,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
|
||||
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -1832,21 +1834,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.0",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.0",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.2",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
|
||||
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1861,9 +1865,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
|
||||
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
|
||||
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1878,9 +1882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
|
||||
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
|
||||
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1895,13 +1899,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
|
||||
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1912,13 +1919,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
|
||||
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
|
||||
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1929,13 +1939,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
|
||||
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1946,13 +1959,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
|
||||
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
|
||||
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1963,13 +1979,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
|
||||
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
|
||||
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1980,9 +1999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
|
||||
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1997,9 +2016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
|
||||
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2014,9 +2033,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
|
||||
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
|
||||
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "1.5.18",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -39,7 +39,7 @@
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwOEVEODY4MTgyRkJFRTMKUldUanZpOFlhTmlPSUJzS0FLL1NMUEgzLzRtNXpsT1FoTXZlS3JLOHJvak5KeThIeDJQRFpJZWgK
|
||||
@@ -4219,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-app"
|
||||
version = "1.5.18"
|
||||
version = "1.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "1.5.18"
|
||||
version = "1.6.0"
|
||||
description = "美家卡智影 - AI 视频创作桌面应用"
|
||||
authors = ["美家卡科技"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
},
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,123 @@
|
||||
//! 封面形象管理 IPC 命令
|
||||
|
||||
use crate::ApiResponse;
|
||||
use crate::storage::cover_avatar as cover_avatar_storage;
|
||||
|
||||
// --------------------- 封面形象库命令 ---------------------
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverAvatarArgs {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub image_url: String,
|
||||
pub local_path: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 加载封面形象库
|
||||
#[tauri::command]
|
||||
pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::CoverAvatar>> {
|
||||
match cover_avatar_storage::load_cover_avatars() {
|
||||
Ok(list) => ApiResponse {
|
||||
code: 200,
|
||||
message: "封面形象库加载成功".to_string(),
|
||||
data: Some(list.avatars),
|
||||
},
|
||||
Err(e) => ApiResponse {
|
||||
code: 500,
|
||||
message: format!("加载封面形象库失败: {}", e),
|
||||
data: Some(vec![]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存封面形象
|
||||
#[tauri::command]
|
||||
pub async fn save_cover_avatar(
|
||||
args: CoverAvatarArgs,
|
||||
) -> ApiResponse<bool> {
|
||||
let avatar = cover_avatar_storage::CoverAvatar {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
image_url: args.image_url,
|
||||
local_path: args.local_path,
|
||||
created_at: args.created_at,
|
||||
};
|
||||
match cover_avatar_storage::add_cover_avatar(avatar) {
|
||||
Ok(_) => ApiResponse {
|
||||
code: 200,
|
||||
message: "封面形象保存成功".to_string(),
|
||||
data: Some(true),
|
||||
},
|
||||
Err(e) => ApiResponse {
|
||||
code: 500,
|
||||
message: format!("保存封面形象失败: {}", e),
|
||||
data: Some(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除封面形象
|
||||
#[tauri::command]
|
||||
pub async fn delete_cover_avatar_cmd(
|
||||
id: String,
|
||||
) -> ApiResponse<bool> {
|
||||
match cover_avatar_storage::delete_cover_avatar(&id) {
|
||||
Ok(_) => ApiResponse {
|
||||
code: 200,
|
||||
message: "封面形象删除成功".to_string(),
|
||||
data: Some(true),
|
||||
},
|
||||
Err(e) => ApiResponse {
|
||||
code: 500,
|
||||
message: format!("删除封面形象失败: {}", e),
|
||||
data: Some(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------- 封面形象图片文件命令 ---------------------
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveCoverAvatarImageArgs {
|
||||
pub avatar_id: String,
|
||||
pub image_data: String, // base64 编码
|
||||
pub ext: String, // 文件扩展名,如 "png"
|
||||
}
|
||||
|
||||
/// 保存封面形象图片文件(前端传入 base64 编码)
|
||||
#[tauri::command]
|
||||
pub async fn save_cover_avatar_image(
|
||||
args: SaveCoverAvatarImageArgs,
|
||||
) -> ApiResponse<String> {
|
||||
let image_bytes = match base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&args.image_data,
|
||||
) {
|
||||
Ok(data) => data,
|
||||
Err(e) => return ApiResponse {
|
||||
code: 400,
|
||||
message: format!("Invalid base64 data: {}", e),
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
|
||||
match cover_avatar_storage::save_cover_avatar_image(
|
||||
&args.avatar_id,
|
||||
&image_bytes,
|
||||
&args.ext,
|
||||
) {
|
||||
Ok(path) => ApiResponse {
|
||||
code: 200,
|
||||
message: "封面形象图片保存成功".to_string(),
|
||||
data: Some(path),
|
||||
},
|
||||
Err(e) => ApiResponse {
|
||||
code: 500,
|
||||
message: format!("保存封面形象图片失败: {}", e),
|
||||
data: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod project;
|
||||
pub mod voice;
|
||||
pub mod video_compose;
|
||||
pub mod file;
|
||||
pub mod cover_avatar;
|
||||
|
||||
@@ -174,6 +174,77 @@ pub async fn generate_empty_shot_clip(
|
||||
}
|
||||
}
|
||||
|
||||
/// 混合 BGM 请求参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MixBgmToVideoArgs {
|
||||
pub video_path: String,
|
||||
pub bgm_path: String,
|
||||
pub output_path: String,
|
||||
pub video_volume: Option<f64>,
|
||||
pub bgm_volume: Option<f64>,
|
||||
}
|
||||
|
||||
/// 混合背景音乐到视频(保留原音频)
|
||||
#[tauri::command]
|
||||
pub async fn mix_bgm_to_video(
|
||||
app: AppHandle,
|
||||
args: MixBgmToVideoArgs,
|
||||
) -> ApiResponse<String> {
|
||||
let safe_output = match sanitize_output_path(&args.output_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return ApiResponse { code: 500, message: e, data: None },
|
||||
};
|
||||
|
||||
let video_vol = args.video_volume.unwrap_or(1.0);
|
||||
let bgm_vol = args.bgm_volume.unwrap_or(0.25);
|
||||
|
||||
// 如果输出路径和输入路径相同,使用临时文件避免 FFmpeg 报错
|
||||
let use_temp = args.video_path == safe_output;
|
||||
let temp_output = if use_temp {
|
||||
format!("{}.tmp_bgm.mp4", safe_output)
|
||||
} else {
|
||||
safe_output.clone()
|
||||
};
|
||||
|
||||
match crate::ffmpeg_cmd::mix_bgm_to_video(
|
||||
&app,
|
||||
&args.video_path,
|
||||
&args.bgm_path,
|
||||
&temp_output,
|
||||
video_vol,
|
||||
bgm_vol,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
if use_temp {
|
||||
// 用混合后的文件替换原文件
|
||||
let _ = std::fs::remove_file(&safe_output);
|
||||
if let Err(e) = std::fs::rename(&temp_output, &safe_output) {
|
||||
let _ = std::fs::remove_file(&temp_output);
|
||||
return ApiResponse {
|
||||
code: 500,
|
||||
message: format!("替换原文件失败: {}", e),
|
||||
data: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
ApiResponse {
|
||||
code: 200,
|
||||
message: "BGM 混合成功".to_string(),
|
||||
data: Some(safe_output),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_output);
|
||||
ApiResponse {
|
||||
code: 500,
|
||||
message: format!("BGM 混合失败: {}", e),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 截取视频片段请求参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -2,7 +2,16 @@ use tauri_plugin_shell::ShellExt;
|
||||
use crate::StringResultExt;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 视频元数据(由 ffprobe 解析)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VideoMetadata {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub duration: f64,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct PhaseInfo {
|
||||
@@ -330,7 +339,7 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
/**
|
||||
* 压制 ASS 字幕到视频(使用嵌入字体)
|
||||
*
|
||||
* 使用抖音美好体 (DouyinSansBold) 作为默认字体。
|
||||
* 使用抖音美好体 Bold (DouyinSans Bold) 作为默认字体。
|
||||
* 支持可选的 PNG overlay(用于大标题/小标题,效果与前端 Canvas 预览一致)。
|
||||
*/
|
||||
pub async fn burn_ass_subtitle(
|
||||
@@ -376,6 +385,7 @@ pub async fn burn_ass_subtitle(
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "veryfast".to_string(),
|
||||
"-crf".to_string(), "18".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
temp_output.clone(),
|
||||
@@ -394,6 +404,7 @@ pub async fn burn_ass_subtitle(
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output.clone(),
|
||||
@@ -415,6 +426,7 @@ pub async fn burn_ass_subtitle(
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output.clone(),
|
||||
@@ -436,6 +448,7 @@ pub async fn burn_ass_subtitle(
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output,
|
||||
@@ -476,6 +489,7 @@ pub async fn burn_ass_subtitle_with_fonts(
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output
|
||||
@@ -484,6 +498,53 @@ pub async fn burn_ass_subtitle_with_fonts(
|
||||
run_ffmpeg(app, args).await.map(|_| ())
|
||||
}
|
||||
|
||||
/**
|
||||
* 混合背景音乐到视频(保留原视频音频)
|
||||
*
|
||||
* 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。
|
||||
* 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。
|
||||
*
|
||||
* 注意:bgm_path 来自应用资源目录,不做 validate_safe_path 检查。
|
||||
*/
|
||||
pub async fn mix_bgm_to_video(
|
||||
app: &AppHandle,
|
||||
video_path: &str,
|
||||
bgm_path: &str,
|
||||
output_path: &str,
|
||||
video_volume: f64,
|
||||
bgm_volume: f64,
|
||||
) -> Result<(), String> {
|
||||
let safe_video = validate_safe_path(video_path)?;
|
||||
// BGM 来自应用资源目录,直接传递(路径由前端通过 Tauri path API 解析)
|
||||
let safe_bgm = bgm_path.to_string();
|
||||
let safe_output = sanitize_output_path(output_path)?;
|
||||
|
||||
// 构建 filter_complex:
|
||||
// [0:a]volume=1.0[a0]; — 原视频音频调整音量
|
||||
// [1:a]volume=0.25,aloop=loop=-1:size=2e+09[bgm]; — BGM 调整音量后无限循环
|
||||
// [a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout] — 混合,以第一个输入时长为准
|
||||
let filter_complex = format!(
|
||||
"[0:a]volume={:.2}[a0];[1:a]volume={:.2},aloop=loop=-1:size=2e+09[bgm];[a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout]",
|
||||
video_volume, bgm_volume
|
||||
);
|
||||
|
||||
let args = vec![
|
||||
"-i".to_string(), safe_video,
|
||||
"-i".to_string(), safe_bgm,
|
||||
"-filter_complex".to_string(), filter_complex,
|
||||
"-map".to_string(), "0:v:0".to_string(),
|
||||
"-map".to_string(), "[aout]".to_string(),
|
||||
"-c:v".to_string(), "copy".to_string(),
|
||||
"-c:a".to_string(), "aac".to_string(),
|
||||
"-b:a".to_string(), "192k".to_string(),
|
||||
"-ar".to_string(), "44100".to_string(),
|
||||
"-ac".to_string(), "2".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output,
|
||||
];
|
||||
run_ffmpeg(app, args).await.map(|_| ())
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频替换 — 用配音音频替换视频中的原音
|
||||
*
|
||||
@@ -519,6 +580,117 @@ pub async fn replace_audio_track(
|
||||
run_ffmpeg(app, args).await.map(|_| ())
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ffprobe 获取视频元数据(分辨率、时长、帧率)
|
||||
*
|
||||
* 跨平台可靠,不依赖浏览器视频解码器。
|
||||
*/
|
||||
pub async fn get_video_metadata(app: &AppHandle, input_path: &str) -> Result<VideoMetadata, String> {
|
||||
// URL 直接传递;本地文件只检查是否存在(用户通过系统文件选择器选取,已获授权)
|
||||
let safe_input = if input_path.starts_with("http://") || input_path.starts_with("https://") {
|
||||
input_path.to_string()
|
||||
} else if std::path::Path::new(input_path).exists() {
|
||||
input_path.to_string()
|
||||
} else {
|
||||
return Err(format!("输入文件不存在: {}", input_path));
|
||||
};
|
||||
|
||||
let args = vec![
|
||||
"-v".to_string(), "error".to_string(),
|
||||
"-select_streams".to_string(), "v:0".to_string(),
|
||||
"-show_entries".to_string(), "stream=width,height,duration,r_frame_rate:format=duration".to_string(),
|
||||
"-of".to_string(), "json".to_string(),
|
||||
safe_input,
|
||||
];
|
||||
|
||||
let (mut rx, child) = app.shell()
|
||||
.sidecar("ffprobe")
|
||||
.map_err(|e| format!("Failed to find ffprobe sidecar: {e}"))?
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ffprobe: {e}"))?;
|
||||
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
|
||||
let probe_future = async {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
stdout.push_str(&String::from_utf8_lossy(&line));
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
stderr.push_str(&String::from_utf8_lossy(&line));
|
||||
}
|
||||
CommandEvent::Terminated(status) => {
|
||||
match status.code {
|
||||
Some(0) => return Ok(()),
|
||||
Some(code) => {
|
||||
return Err(format!("ffprobe exited with status {}. stderr: {}", code, stderr.trim()));
|
||||
}
|
||||
None => return Err("ffprobe terminated by signal".to_string()),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(30), probe_future).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
return Err("ffprobe 执行超时(超过30秒),已强制终止".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 JSON 输出
|
||||
let parsed: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("ffprobe JSON 解析失败: {}. raw: {}", e, &stdout))?;
|
||||
|
||||
let stream = parsed.get("streams")
|
||||
.and_then(|s| s.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.ok_or_else(|| format!("ffprobe 未返回视频流信息: {}", &stdout))?;
|
||||
|
||||
let width = stream.get("width")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| "无法解析视频宽度".to_string())? as u32;
|
||||
|
||||
let height = stream.get("height")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| "无法解析视频高度".to_string())? as u32;
|
||||
|
||||
// duration 可能是字符串或数字;某些格式(如 MPEG-TS)stream duration 为 N/A,需从 format 回退
|
||||
let stream_duration = stream.get("duration")
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||
|
||||
let format_duration = parsed.get("format")
|
||||
.and_then(|f| f.get("duration"))
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||
|
||||
let duration = stream_duration.or(format_duration).unwrap_or(0.0);
|
||||
|
||||
// 帧率格式为 "25/1" 或 "30000/1001"
|
||||
let fps = stream.get("r_frame_rate")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| {
|
||||
let parts: Vec<&str> = s.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
let num: f64 = parts[0].parse().ok()?;
|
||||
let den: f64 = parts[1].parse().ok()?;
|
||||
if den != 0.0 { Some(num / den) } else { None }
|
||||
} else {
|
||||
s.parse().ok()
|
||||
}
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
|
||||
Ok(VideoMetadata { width, height, duration, fps })
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 裁剪视频片段(支持本地文件和 HTTP URL)
|
||||
@@ -603,3 +775,123 @@ pub async fn extract_audio_segment(
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 为预览转码视频(统一为浏览器兼容格式)
|
||||
*
|
||||
* 将任意格式的视频转码为 H.264 Baseline + YUV420p 540p,
|
||||
* 确保在所有平台的浏览器/WebView 中都能正常预览。
|
||||
* 转码结果按(文件路径 + 大小 + 修改时间)缓存,避免重复处理。
|
||||
*/
|
||||
pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<String, String> {
|
||||
let input = std::path::Path::new(input_path);
|
||||
if !input.exists() {
|
||||
return Err(format!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
let path_str = input.canonicalize()
|
||||
.unwrap_or(input.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// 快速检测:如果已经是 H.264 + YUV420p,直接返回原始路径(避免应用自己生成的成品重复转码)
|
||||
let probe_args = vec![
|
||||
"-v".to_string(), "error".to_string(),
|
||||
"-select_streams".to_string(), "v:0".to_string(),
|
||||
"-show_entries".to_string(), "stream=codec_name,pix_fmt,width,height".to_string(),
|
||||
"-of".to_string(), "json".to_string(),
|
||||
path_str.clone(),
|
||||
];
|
||||
|
||||
let probe_result = app.shell().sidecar("ffprobe")
|
||||
.and_then(|s| s.args(probe_args).spawn());
|
||||
if let Ok((mut rx, child)) = probe_result {
|
||||
let mut stdout = String::new();
|
||||
let probe_future = async {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => stdout.push_str(&String::from_utf8_lossy(&line)),
|
||||
CommandEvent::Terminated(status) if status.code == Some(0) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok::<(), ()>(())
|
||||
};
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(3), probe_future).await {
|
||||
Ok(Ok(())) => {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
if let Some(stream) = parsed.get("streams").and_then(|s| s.as_array()).and_then(|a| a.first()) {
|
||||
let codec = stream.get("codec_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let pix_fmt = stream.get("pix_fmt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// 应用生成的视频通常是 1080p 及以下;若用户上传了 4K H.264,仍转码为 540p 代理以保证预览流畅
|
||||
let width = stream.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let height = stream.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
if codec == "h264" && pix_fmt == "yuv420p" && width <= 1920 && height <= 1920 {
|
||||
return Ok(path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 超时或异常:强制结束 ffprobe,避免僵尸进程
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件元数据用于缓存 key
|
||||
let metadata = std::fs::metadata(input_path)
|
||||
.map_err(|e| format!("无法读取文件元数据: {}", e))?;
|
||||
// 某些文件系统(如 FAT32)不支持修改时间,失败时回退为 0
|
||||
let mtime = metadata.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let file_size = metadata.len();
|
||||
|
||||
// 计算缓存路径(基于文件路径 hash + 大小 + 修改时间)
|
||||
let path_hash = {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path_str.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
};
|
||||
|
||||
let app_data_dir = crate::storage::paths::get_app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {}", e))?;
|
||||
let cache_dir = app_data_dir.join("video_cache");
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("无法创建缓存目录: {}", e))?;
|
||||
let cache_path = cache_dir.join(format!("preview_{}_{}_{}.mp4", path_hash, file_size, mtime));
|
||||
|
||||
// 缓存命中且文件完整,直接返回
|
||||
if cache_path.exists() {
|
||||
let cache_meta = std::fs::metadata(&cache_path)
|
||||
.map_err(|e| format!("无法读取缓存文件: {}", e))?;
|
||||
if cache_meta.len() > 1024 {
|
||||
return Ok(cache_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// FFmpeg 转码:H.264 Baseline + YUV420p,540p,无音频,faststart
|
||||
let args = vec![
|
||||
"-i".to_string(), path_str,
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-profile:v".to_string(), "baseline".to_string(),
|
||||
"-level".to_string(), "3.0".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-preset".to_string(), "ultrafast".to_string(),
|
||||
"-crf".to_string(), "28".to_string(),
|
||||
"-vf".to_string(), "scale=540:-2:force_original_aspect_ratio=decrease".to_string(),
|
||||
"-an".to_string(),
|
||||
"-movflags".to_string(), "+faststart".to_string(),
|
||||
"-y".to_string(),
|
||||
cache_path.to_string_lossy().to_string(),
|
||||
];
|
||||
|
||||
run_ffmpeg(app, args).await?;
|
||||
|
||||
Ok(cache_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ fn restart_app(app: tauri::AppHandle) {
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_devtools(window: tauri::WebviewWindow) {
|
||||
let _ = window.open_devtools();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 公共类型导出
|
||||
// ============================================================
|
||||
@@ -41,25 +46,163 @@ pub struct ApiResponse<T> {
|
||||
pub data: Option<T>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 视频缓存清理
|
||||
// ============================================================
|
||||
|
||||
/// 清理 video_cache 目录
|
||||
///
|
||||
/// 策略:
|
||||
/// 1. 删除超过 30 天未修改的文件
|
||||
/// 2. 总容量超过 500MB 时,按修改时间删最旧文件直到 300MB
|
||||
fn clean_video_cache(app_data_dir: &std::path::Path) {
|
||||
let cache_dir = app_data_dir.join("video_cache");
|
||||
if !cache_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_age = std::time::Duration::from_secs(30 * 24 * 60 * 60);
|
||||
let max_total_size: u64 = 500 * 1024 * 1024;
|
||||
let target_size: u64 = 300 * 1024 * 1024;
|
||||
let now = std::time::SystemTime::now();
|
||||
|
||||
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
|
||||
let read_dir = match std::fs::read_dir(&cache_dir) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("[cache] 无法读取缓存目录: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if !metadata.is_file() { continue; }
|
||||
let mtime = metadata.modified().unwrap_or(now);
|
||||
let size = metadata.len();
|
||||
total_size += size;
|
||||
entries.push((entry.path(), mtime, size));
|
||||
}
|
||||
|
||||
// 1. 删除超过 30 天的文件
|
||||
let mut deleted_size: u64 = 0;
|
||||
for (path, mtime, size) in &entries {
|
||||
if now.duration_since(*mtime).unwrap_or_default() > max_age {
|
||||
if std::fs::remove_file(path).is_ok() {
|
||||
deleted_size += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 容量超限,删最旧的
|
||||
let remaining_size = total_size.saturating_sub(deleted_size);
|
||||
if remaining_size > max_total_size {
|
||||
let mut sorted = entries;
|
||||
sorted.sort_by_key(|(_, mtime, _)| *mtime);
|
||||
let mut to_free = remaining_size.saturating_sub(target_size);
|
||||
for (path, _, size) in sorted {
|
||||
if to_free == 0 { break; }
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
to_free = to_free.saturating_sub(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动清理 video_cache 目录,返回释放的字节数
|
||||
#[tauri::command]
|
||||
fn clear_video_cache_cmd() -> Result<u64, String> {
|
||||
let app_data_dir = crate::storage::paths::get_app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cache_dir = app_data_dir.join("video_cache");
|
||||
if !cache_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut freed: u64 = 0;
|
||||
let read_dir = match std::fs::read_dir(&cache_dir) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
|
||||
};
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if !metadata.is_file() { continue; }
|
||||
let size = metadata.len();
|
||||
if std::fs::remove_file(entry.path()).is_ok() {
|
||||
freed += size;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
/// 获取 video_cache 目录当前占用大小(字节)
|
||||
#[tauri::command]
|
||||
fn get_video_cache_size_cmd() -> Result<u64, String> {
|
||||
let app_data_dir = crate::storage::paths::get_app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cache_dir = app_data_dir.join("video_cache");
|
||||
if !cache_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut total: u64 = 0;
|
||||
let read_dir = match std::fs::read_dir(&cache_dir) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
|
||||
};
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if metadata.is_file() {
|
||||
total += metadata.len();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 应用入口
|
||||
// ============================================================
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Windows: 禁用 D3D11 硬件视频解码,解决本地视频预览黑屏问题
|
||||
// Chromium 在 Windows 上的 D3D11 视频解码器与部分显卡驱动/视频编码不兼容,
|
||||
// 回退到软件解码可确保视频画面正常渲染。对 5~20 秒短视频预览性能影响可忽略。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::set_var(
|
||||
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
|
||||
"--disable-features=MediaFoundationD3D11VideoDecode"
|
||||
);
|
||||
}
|
||||
|
||||
let _api_base_url = crate::storage::config::get_api_base_url_sync();
|
||||
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
// 初始化应用数据目录(所有业务数据的根目录)
|
||||
if let Ok(app_data_dir) = app.path().app_local_data_dir() {
|
||||
crate::storage::init_app_data_dir(app_data_dir);
|
||||
crate::storage::init_app_data_dir(app_data_dir.clone());
|
||||
// 后台清理过期视频缓存,不阻塞首屏
|
||||
std::thread::spawn(move || clean_video_cache(&app_data_dir));
|
||||
}
|
||||
|
||||
// Release 构建也打开 DevTools(临时:排查 Windows 网络问题)
|
||||
// 排查完成后可移除或改为快捷键触发
|
||||
// 窗口初始 visible=false,setup 阶段先显示窗口
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.open_devtools();
|
||||
if let Err(e) = window.show() {
|
||||
eprintln!("[setup] window.show() failed: {}", e);
|
||||
}
|
||||
if let Err(e) = window.set_focus() {
|
||||
eprintln!("[setup] window.set_focus() failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
eprintln!("[setup] main window not found");
|
||||
}
|
||||
|
||||
// macOS 自定义菜单栏(中文本地化)
|
||||
@@ -126,6 +269,7 @@ pub fn run() {
|
||||
storage::config::save_app_config,
|
||||
storage::config::get_environment_presets,
|
||||
restart_app,
|
||||
open_devtools,
|
||||
// 项目存储
|
||||
commands::project::save_project_meta_raw,
|
||||
commands::project::load_project_meta,
|
||||
@@ -167,12 +311,25 @@ pub fn run() {
|
||||
commands::voice::load_voice_materials,
|
||||
commands::voice::save_voice_material,
|
||||
commands::voice::delete_voice_material_cmd,
|
||||
// 封面形象库
|
||||
commands::cover_avatar::load_cover_avatars,
|
||||
commands::cover_avatar::save_cover_avatar,
|
||||
commands::cover_avatar::delete_cover_avatar_cmd,
|
||||
commands::cover_avatar::save_cover_avatar_image,
|
||||
// 压制成片(Phase 2)
|
||||
commands::video_compose::extract_video_segment,
|
||||
commands::video_compose::concat_video_clips,
|
||||
commands::video_compose::generate_empty_shot_clip,
|
||||
commands::video_compose::upload_video_file,
|
||||
commands::video_compose::download_file,
|
||||
commands::video_compose::mix_bgm_to_video,
|
||||
// 视频元数据读取(ffprobe)
|
||||
get_video_metadata_cmd,
|
||||
// 视频预览转码(统一浏览器兼容格式)
|
||||
transcode_for_preview_cmd,
|
||||
// 缓存清理
|
||||
get_video_cache_size_cmd,
|
||||
clear_video_cache_cmd,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@@ -274,6 +431,64 @@ async fn video_composite_synthesis(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 视频元数据读取(ffprobe)
|
||||
// ============================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GetVideoMetadataRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_video_metadata_cmd(
|
||||
app: tauri::AppHandle,
|
||||
request: GetVideoMetadataRequest,
|
||||
) -> ApiResponse<ffmpeg_cmd::VideoMetadata> {
|
||||
match ffmpeg_cmd::get_video_metadata(&app, &request.path).await {
|
||||
Ok(meta) => ApiResponse {
|
||||
code: 200,
|
||||
message: "读取成功".to_string(),
|
||||
data: Some(meta),
|
||||
},
|
||||
Err(e) => ApiResponse {
|
||||
code: 500,
|
||||
message: e,
|
||||
data: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 视频预览转码
|
||||
// ============================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct TranscodeForPreviewRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn transcode_for_preview_cmd(
|
||||
app: tauri::AppHandle,
|
||||
request: TranscodeForPreviewRequest,
|
||||
) -> ApiResponse<String> {
|
||||
match ffmpeg_cmd::transcode_for_preview(&app, &request.path).await {
|
||||
Ok(path) => ApiResponse {
|
||||
code: 200,
|
||||
message: "转码成功".to_string(),
|
||||
data: Some(path),
|
||||
},
|
||||
Err(e) => ApiResponse {
|
||||
code: 500,
|
||||
message: e,
|
||||
data: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 错误处理扩展
|
||||
// ============================================================
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
//!
|
||||
//! 存储路径: {app_local_data_dir}/config.json
|
||||
//!
|
||||
//! 所有环境预设和默认配置均在 Rust 层定义,前端不硬编码任何 API 地址。
|
||||
//! 运行模式(生产/调试)控制桌面端行为,API 地址固定为 dev 环境。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::storage::engine::{read_json, atomic_write_json, StorageError};
|
||||
|
||||
const API_BASE_URL: &str = "https://dev.tapi.meijiaka.cn/api/v1";
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub api_base_url: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
/// 环境预设(唯一数据源,前端通过 IPC 获取)
|
||||
/// 运行模式预设(唯一数据源,前端通过 IPC 获取)
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct EnvironmentPreset {
|
||||
pub label: String,
|
||||
@@ -25,27 +27,22 @@ pub struct EnvironmentPreset {
|
||||
fn presets() -> Vec<EnvironmentPreset> {
|
||||
vec![
|
||||
EnvironmentPreset {
|
||||
label: "生产环境".to_string(),
|
||||
url: "https://tapi.meijiaka.cn/api/v1".to_string(),
|
||||
label: "生产模式".to_string(),
|
||||
url: API_BASE_URL.to_string(),
|
||||
env: "production".to_string(),
|
||||
},
|
||||
EnvironmentPreset {
|
||||
label: "测试环境".to_string(),
|
||||
url: "https://dev.tapi.meijiaka.cn/api/v1".to_string(),
|
||||
env: "staging".to_string(),
|
||||
},
|
||||
EnvironmentPreset {
|
||||
label: "本地开发".to_string(),
|
||||
url: "http://127.0.0.1:8081/api/v1".to_string(),
|
||||
env: "development".to_string(),
|
||||
label: "调试模式".to_string(),
|
||||
url: API_BASE_URL.to_string(),
|
||||
env: "debug".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
// 默认指向测试环境(索引 1),避免首次安装时误连生产环境
|
||||
let preset = &presets()[1];
|
||||
// 默认生产模式
|
||||
let preset = &presets()[0];
|
||||
Self {
|
||||
api_base_url: preset.url.clone(),
|
||||
environment: preset.env.clone(),
|
||||
@@ -59,22 +56,24 @@ fn get_config_path() -> Result<PathBuf, StorageError> {
|
||||
|
||||
/// 同步读取 API Base URL(供 Rust 命令内部使用)
|
||||
pub fn get_api_base_url_sync() -> String {
|
||||
let path = match get_config_path() {
|
||||
Ok(p) => p,
|
||||
// 默认指向测试环境(索引 1)
|
||||
Err(_) => return presets()[1].url.clone(),
|
||||
};
|
||||
match read_json::<AppConfig>(&path) {
|
||||
Ok(Some(config)) => config.api_base_url,
|
||||
_ => AppConfig::default().api_base_url,
|
||||
}
|
||||
API_BASE_URL.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_app_config() -> Result<AppConfig, String> {
|
||||
let path = get_config_path().map_err(|e| e.to_string())?;
|
||||
match read_json::<AppConfig>(&path) {
|
||||
Ok(Some(config)) => Ok(config),
|
||||
Ok(Some(config)) => {
|
||||
// 兼容旧环境值:旧值(staging/development/custom)统一映射为 debug
|
||||
let environment = match config.environment.as_str() {
|
||||
"production" => "production".to_string(),
|
||||
_ => "debug".to_string(),
|
||||
};
|
||||
Ok(AppConfig {
|
||||
api_base_url: API_BASE_URL.to_string(),
|
||||
environment,
|
||||
})
|
||||
}
|
||||
Ok(None) => Ok(AppConfig::default()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
@@ -86,19 +85,9 @@ pub async fn get_environment_presets() -> Result<Vec<EnvironmentPreset>, String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_app_config(environment: String, custom_url: Option<String>) -> Result<(), String> {
|
||||
let url = if environment == "custom" {
|
||||
custom_url.ok_or("自定义地址不能为空")?
|
||||
} else {
|
||||
presets()
|
||||
.iter()
|
||||
.find(|p| p.env == environment)
|
||||
.map(|p| p.url.clone())
|
||||
.unwrap_or_else(|| "https://dev.tapi.meijiaka.cn/api/v1".to_string())
|
||||
};
|
||||
|
||||
pub async fn save_app_config(environment: String, _custom_url: Option<String>) -> Result<(), String> {
|
||||
let config = AppConfig {
|
||||
api_base_url: url,
|
||||
api_base_url: API_BASE_URL.to_string(),
|
||||
environment,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! 封面形象存储模块
|
||||
//!
|
||||
//! 管理用户上传的人物照片(抠图后)的本地存储。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::storage::engine::{atomic_write_bytes, atomic_write_json, ensure_dir, read_json, StorageError};
|
||||
use crate::storage::paths::get_cover_avatars_dir;
|
||||
|
||||
/// 封面形象记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverAvatar {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 抠图后的透明背景图片 URL(七牛云)
|
||||
pub image_url: String,
|
||||
/// 本地文件路径(相对于项目目录或绝对路径)
|
||||
pub local_path: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 封面形象列表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverAvatarsList {
|
||||
pub avatars: Vec<CoverAvatar>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 加载封面形象库
|
||||
pub fn load_cover_avatars() -> Result<CoverAvatarsList, StorageError> {
|
||||
let path = crate::storage::paths::get_cover_avatars_json_path()?;
|
||||
Ok(read_json(&path)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 保存封面形象库
|
||||
pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> {
|
||||
let path = crate::storage::paths::get_cover_avatars_json_path()?;
|
||||
atomic_write_json(&path, list)
|
||||
}
|
||||
|
||||
/// 添加封面形象
|
||||
pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> {
|
||||
let mut list = load_cover_avatars()?;
|
||||
// 去重:相同 id 替换
|
||||
if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) {
|
||||
list.avatars[pos] = avatar;
|
||||
} else {
|
||||
list.avatars.push(avatar);
|
||||
}
|
||||
list.updated_at = chrono_lite_now();
|
||||
save_cover_avatars(&list)
|
||||
}
|
||||
|
||||
/// 删除封面形象
|
||||
pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> {
|
||||
let mut list = load_cover_avatars()?;
|
||||
let pos = list.avatars.iter().position(|a| a.id == id)
|
||||
.ok_or_else(|| StorageError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("封面形象 {} 不存在", id),
|
||||
)))?;
|
||||
list.avatars.remove(pos);
|
||||
list.updated_at = chrono_lite_now();
|
||||
save_cover_avatars(&list)
|
||||
}
|
||||
|
||||
/// 保存封面形象图片文件到本地
|
||||
///
|
||||
/// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。
|
||||
pub fn save_cover_avatar_image(
|
||||
avatar_id: &str,
|
||||
data: &[u8],
|
||||
ext: &str,
|
||||
) -> Result<String, StorageError> {
|
||||
let avatars_dir = get_cover_avatars_dir()?;
|
||||
ensure_dir(&avatars_dir)?;
|
||||
|
||||
// 净化扩展名:只允许字母数字,防止路径遍历
|
||||
let safe_ext = ext
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect::<String>();
|
||||
if safe_ext.is_empty() {
|
||||
return Err(StorageError::InvalidId("无效的文件扩展名".into()));
|
||||
}
|
||||
|
||||
let file_name = format!("{}.{}", avatar_id, safe_ext);
|
||||
let file_path = avatars_dir.join(&file_name);
|
||||
|
||||
atomic_write_bytes(&file_path, data)?;
|
||||
|
||||
Ok(file_path.to_str().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
// ====================== 工具函数 ======================
|
||||
|
||||
fn chrono_lite_now() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod project;
|
||||
pub mod auth;
|
||||
pub mod voice;
|
||||
pub mod config;
|
||||
pub mod cover_avatar;
|
||||
|
||||
pub use engine::{
|
||||
atomic_write_json, atomic_write_bytes,
|
||||
@@ -22,5 +23,5 @@ pub use paths::{
|
||||
init_app_data_dir, get_app_data_dir, get_projects_root_dir,
|
||||
get_project_dir, get_project_dir_path, get_project_assets_dir,
|
||||
get_project_videos_dir, get_project_products_dir, get_voices_json_path,
|
||||
get_app_config_json_path,
|
||||
get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path,
|
||||
};
|
||||
|
||||
@@ -104,3 +104,19 @@ pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> {
|
||||
crate::storage::engine::ensure_dir(&path)?;
|
||||
Ok(path.join("auth.json"))
|
||||
}
|
||||
|
||||
/// 获取封面形象图片存储目录
|
||||
/// {app_local_data_dir}/cover_avatars/
|
||||
pub fn get_cover_avatars_dir() -> Result<PathBuf, StorageError> {
|
||||
let base = get_app_data_dir()?;
|
||||
let path = base.join("cover_avatars");
|
||||
crate::storage::engine::ensure_dir(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 获取封面形象库 JSON 路径
|
||||
/// {app_local_data_dir}/cover_avatars.json
|
||||
pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
|
||||
let base = get_app_data_dir()?;
|
||||
Ok(base.join("cover_avatars.json"))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "美家卡智影",
|
||||
"version": "1.5.18",
|
||||
"version": "1.6.0",
|
||||
"identifier": "cn.meijiaka.ai-zy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset:; media-src 'self' https: blob: asset:; connect-src 'self' https: ws://localhost:*;",
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; connect-src 'self' https: ws://localhost:*;",
|
||||
"capabilities": [
|
||||
"default"
|
||||
],
|
||||
@@ -41,7 +41,7 @@
|
||||
"plugins": {
|
||||
"opener": {},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwRTdFOUI5NkZERkNFRDMKUldUVHp0OXZ1ZW5ua0pVN2M0ZGoyakxneFc5am5pR21UNFdCaThDN0p5S0JubUxUVUhLNnlkMWkK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwOEVEODY4MTgyRkJFRTMKUldUanZpOFlhTmlPSUJzS0FLL1NMUEgzLzRtNXpsT1FoTXZlS3JLOHJvak5KeThIeDJQRFpJZWgK",
|
||||
"endpoints": [
|
||||
"https://dev.tapi.meijiaka.cn/api/v1/update/check?version={{current_version}}&target={{target}}&arch={{arch}}"
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZCMUQyRjU1MzY3ODZDREIKUldUYmJIZzJWUzhkK3l4VTl0RGE3OFRhN2JXdjBjZnR5UC9aMmwxTUY5K2ZoeDVNOStjZlFwQWYK
|
||||
@@ -13,7 +13,7 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e8e8e8;
|
||||
border-top-color: #1a9e8a;
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: app-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
.app-loading-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a9e8a;
|
||||
color: var(--primary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,8 @@ import Login from './pages/Login/Login';
|
||||
import VideoCreation from './pages/VideoCreation';
|
||||
import MyWorks from './pages/ContentManagement/MyWorks';
|
||||
import VoiceMaterialLibrary from './pages/ContentManagement/VoiceMaterialLibrary';
|
||||
import AboutUs from './pages/Settings/AboutUs';
|
||||
import SystemUpdate from './pages/Settings/SystemUpdate';
|
||||
|
||||
|
||||
import CoverAvatarLibrary from './pages/ContentManagement/CoverAvatarLibrary';
|
||||
import Settings from './pages/Settings/Settings';
|
||||
import Profile from './pages/Profile/Profile';
|
||||
import UsageDetail from './pages/Profile/UsageDetail';
|
||||
import ToastContainer from './components/Toast/ToastContainer';
|
||||
@@ -32,9 +30,9 @@ import './App.css';
|
||||
type PageType =
|
||||
| 'video-creation'
|
||||
| 'voice-material'
|
||||
| 'cover-avatar'
|
||||
| 'my-works'
|
||||
| 'about-us'
|
||||
| 'system-update'
|
||||
| 'settings'
|
||||
| 'profile'
|
||||
| 'usage-detail';
|
||||
|
||||
@@ -42,9 +40,9 @@ type PageType =
|
||||
const pages: Record<PageType, React.ComponentType> = {
|
||||
'video-creation': VideoCreation,
|
||||
'voice-material': VoiceMaterialLibrary,
|
||||
'cover-avatar': CoverAvatarLibrary,
|
||||
'my-works': MyWorks,
|
||||
'about-us': AboutUs,
|
||||
'system-update': SystemUpdate,
|
||||
settings: Settings,
|
||||
profile: Profile,
|
||||
'usage-detail': UsageDetail,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { client } from '../client';
|
||||
|
||||
export interface BgmMusicItem {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
category: string;
|
||||
filePath: string;
|
||||
url: string | null;
|
||||
duration: number | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface BgmMusicListResponse {
|
||||
items: BgmMusicItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* BGM 音乐 API
|
||||
*/
|
||||
export const bgmMusicApi = {
|
||||
/**
|
||||
* 获取背景音乐列表
|
||||
* @param category 场景分类筛选(可选)
|
||||
*/
|
||||
list: async (category?: string): Promise<BgmMusicItem[]> => {
|
||||
const path = category ? `/bgm-musics?category=${encodeURIComponent(category)}` : '/bgm-musics';
|
||||
const response = await client.get<{ items: BgmMusicItem[]; total: number }>(path);
|
||||
return response.items || [];
|
||||
},
|
||||
};
|
||||
@@ -40,8 +40,8 @@ export async function getEnvironmentPresets(): Promise<EnvironmentPreset[]> {
|
||||
|
||||
/**
|
||||
* 保存应用配置
|
||||
* @param environment 环境标识(production / staging / development / custom)
|
||||
* @param customUrl 自定义地址(仅 custom 时必填)
|
||||
* @param environment 运行模式(production / debug)
|
||||
* @param customUrl 已废弃,保留参数仅为兼容旧调用
|
||||
*/
|
||||
export async function saveAppConfig(environment: string, customUrl?: string): Promise<void> {
|
||||
await invoke('save_app_config', { environment, customUrl });
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Cover Avatar 封面形象 API 模块
|
||||
* ================================
|
||||
*
|
||||
* 提供图片上传、AI 抠图、本地封面形象库管理接口。
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { client } from '../client';
|
||||
|
||||
// ====================== 类型 ======================
|
||||
|
||||
export interface CoverAvatar {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
localPath?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ====================== HTTP API(后端直连) ======================
|
||||
|
||||
/**
|
||||
* 上传图片到七牛云
|
||||
*/
|
||||
export async function uploadImage(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const result = await client.postForm<{ url: string; key: string }>('/upload/image', formData);
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 抠图(火山引擎 MediaKit)
|
||||
*/
|
||||
export async function removeBackground(imageUrl: string, scene = 'human'): Promise<string> {
|
||||
const result = await client.post<{ url: string }>('/image/remove-background', {
|
||||
imageUrl,
|
||||
scene,
|
||||
});
|
||||
return result.url;
|
||||
}
|
||||
|
||||
// ====================== IPC 命令(本地存储) ======================
|
||||
|
||||
/**
|
||||
* 从本地加载封面形象库
|
||||
*/
|
||||
export async function loadCoverAvatars(): Promise<CoverAvatar[]> {
|
||||
const result = await invoke<{ code: number; data?: CoverAvatar[]; message: string }>('load_cover_avatars');
|
||||
if (result.code !== 200) {
|
||||
throw new Error(result.message || '加载封面形象库失败');
|
||||
}
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存封面形象到本地
|
||||
*/
|
||||
export async function saveCoverAvatar(avatar: CoverAvatar): Promise<void> {
|
||||
const result = await invoke<{ code: number; message: string }>('save_cover_avatar', {
|
||||
args: {
|
||||
id: avatar.id,
|
||||
name: avatar.name,
|
||||
imageUrl: avatar.imageUrl,
|
||||
localPath: avatar.localPath,
|
||||
createdAt: avatar.createdAt,
|
||||
},
|
||||
});
|
||||
if (result.code !== 200) {
|
||||
throw new Error(result.message || '保存封面形象失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除本地封面形象
|
||||
*/
|
||||
export async function deleteCoverAvatar(id: string): Promise<void> {
|
||||
const result = await invoke<{ code: number; message: string }>('delete_cover_avatar_cmd', { id });
|
||||
if (result.code !== 200) {
|
||||
throw new Error(result.message || '删除封面形象失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存封面形象图片文件到本地(base64 编码)
|
||||
*/
|
||||
export async function saveCoverAvatarImage(args: {
|
||||
avatarId: string;
|
||||
imageData: string;
|
||||
ext: string;
|
||||
}): Promise<string> {
|
||||
const result = await invoke<{ code: number; data?: string; message: string }>('save_cover_avatar_image', {
|
||||
args: {
|
||||
avatarId: args.avatarId,
|
||||
imageData: args.imageData,
|
||||
ext: args.ext,
|
||||
},
|
||||
});
|
||||
if (result.code !== 200 || !result.data) {
|
||||
throw new Error(result.message || '保存封面形象图片失败');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export const localProjectApi = {
|
||||
updatedAt: meta.updatedAt,
|
||||
coverPath: meta.coverPath,
|
||||
finalVideoPath: meta.finalVideoPath,
|
||||
finalVideoDuration: meta.finalVideoDuration,
|
||||
exportedAt: meta.exportedAt,
|
||||
selectedVoiceId: meta.selectedVoiceId,
|
||||
composedVideoUrl: meta.composedVideoUrl,
|
||||
@@ -111,6 +112,7 @@ export const localProjectApi = {
|
||||
lipSyncedVideoUrl: meta.lipSyncedVideoUrl,
|
||||
dubbingAudioUrl: meta.dubbingAudioUrl,
|
||||
dubbingAudioPath: meta.dubbingAudioPath,
|
||||
dubbingAudioDuration: meta.dubbingAudioDuration,
|
||||
voiceSpeed: meta.voiceSpeed,
|
||||
voiceVolume: meta.voiceVolume,
|
||||
voicePitch: meta.voicePitch,
|
||||
@@ -125,8 +127,13 @@ export const localProjectApi = {
|
||||
subTitlePreset: meta.subTitlePreset,
|
||||
captionPreset: meta.captionPreset,
|
||||
coverConfig: meta.coverConfig,
|
||||
bgmMusicId: meta.bgmMusicId,
|
||||
bgmMusicTitle: meta.bgmMusicTitle,
|
||||
bgmMusicPath: meta.bgmMusicPath,
|
||||
bgmVolume: meta.bgmVolume,
|
||||
version: meta.version,
|
||||
userUploadedMaterials: meta.userUploadedMaterials,
|
||||
stepDirtyFlags: meta.stepDirtyFlags,
|
||||
};
|
||||
const jsonContent = JSON.stringify(orderedMeta, null, 2);
|
||||
const res = await safeInvoke<ApiResponse<boolean>>('save_project_meta_raw', {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
interface AppHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
rightActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AppHeader({ title, showBack, onBack, rightActions }: AppHeaderProps) {
|
||||
return (
|
||||
<div className="app-header">
|
||||
<h2 className="app-header-title">{title}</h2>
|
||||
<div className="app-header-right">
|
||||
{showBack && onBack && (
|
||||
<button className="page-back-btn" onClick={onBack}>
|
||||
返回
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{rightActions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,15 +164,28 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-footer-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-balance-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
@@ -199,15 +212,84 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.user-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
padding: var(--spacing-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.user-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
font-family: var(--font-family);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.user-dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-dropdown-item.active {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.user-dropdown-danger {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-dropdown-danger:hover {
|
||||
color: #e74c3c;
|
||||
background: #fdf2f2;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createNewProject, useAuthStore } from '../../store';
|
||||
import { usePointStore } from '../../store';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import './Sidebar.css';
|
||||
|
||||
@@ -22,19 +23,10 @@ const navItems: NavItem[] = [
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
children: [
|
||||
{ id: 'voice-material', label: '声音复刻' },
|
||||
{ id: 'cover-avatar', label: '封面形象' },
|
||||
{ id: 'my-works', label: '我的作品' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '系统设置',
|
||||
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
children: [
|
||||
{ id: 'system-update', label: '系统更新' },
|
||||
{ id: 'about-us', label: '关于我们' },
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -42,12 +34,38 @@ interface SidebarProps {
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
const userMenuItems = [
|
||||
{ id: 'profile', label: '我的账户' },
|
||||
{ id: 'settings', label: '设置' },
|
||||
];
|
||||
|
||||
export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
|
||||
const { appEnvironment } = useNavigation();
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const balance = usePointStore((s) => s.balance);
|
||||
const fetchBalance = usePointStore((s) => s.fetchBalance);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||
new Set(['content-management', 'settings'])
|
||||
new Set(['content-management'])
|
||||
);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 组件挂载时拉取一次余额
|
||||
useEffect(() => {
|
||||
fetchBalance().catch(() => {});
|
||||
}, [fetchBalance]);
|
||||
|
||||
// 点击外部关闭用户菜单
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [showUserMenu]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedItems(prev => {
|
||||
@@ -64,10 +82,6 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
|
||||
const handleClick = (item: NavItem) => {
|
||||
if (item.children) {
|
||||
toggleExpand(item.id);
|
||||
// Navigate to first child
|
||||
if (!expandedItems.has(item.id)) {
|
||||
onNavigate(item.children[0].id);
|
||||
}
|
||||
} else {
|
||||
onNavigate(item.id);
|
||||
}
|
||||
@@ -196,18 +210,64 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
|
||||
{appEnvironment === 'staging' ? '测试环境' : appEnvironment === 'development' ? '开发环境' : appEnvironment}
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-user" onClick={() => onNavigate('profile')} title="个人中心">
|
||||
<img
|
||||
src={authUser?.avatar || '/default-avatar.svg'}
|
||||
alt="avatar"
|
||||
className="user-avatar"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{authUser?.nickname || '用户'}</span>
|
||||
<div className="sidebar-footer" ref={menuRef}>
|
||||
<div className="sidebar-footer-card">
|
||||
<div
|
||||
className="sidebar-user"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
title="菜单"
|
||||
>
|
||||
<img
|
||||
src={authUser?.avatar || '/default-avatar.svg'}
|
||||
alt="avatar"
|
||||
className="user-avatar"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{authUser?.nickname || '用户'}</span>
|
||||
<span className="sidebar-balance-text">{balance} 积分</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`user-chevron ${showUserMenu ? 'expanded' : ''}`}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{showUserMenu && (
|
||||
<div className="user-dropdown-menu">
|
||||
{userMenuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`user-dropdown-item ${currentPath === item.id ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onNavigate(item.id);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="user-dropdown-divider" />
|
||||
<button
|
||||
className="user-dropdown-item user-dropdown-danger"
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onNavigate('logout');
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 环境切换弹窗
|
||||
* 运行模式切换弹窗
|
||||
* =============
|
||||
*
|
||||
* 通过「关于我们」页面连击版本号 5 次触发。
|
||||
@@ -13,7 +13,7 @@ import { getEnvironmentPresets, type EnvironmentPreset } from '../../api/modules
|
||||
interface EnvironmentSwitchModalProps {
|
||||
open: boolean;
|
||||
currentEnv: string;
|
||||
onSave: (env: string, customUrl?: string) => void;
|
||||
onSave: (env: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ export default function EnvironmentSwitchModal({
|
||||
}: EnvironmentSwitchModalProps) {
|
||||
const [presets, setPresets] = useState<EnvironmentPreset[]>([]);
|
||||
const [selected, setSelected] = useState(currentEnv);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -36,12 +35,7 @@ export default function EnvironmentSwitchModal({
|
||||
if (!open) {return null;}
|
||||
|
||||
const handleSave = () => {
|
||||
if (selected === 'custom') {
|
||||
if (!customUrl.trim()) {return;}
|
||||
onSave('custom', customUrl.trim());
|
||||
} else {
|
||||
onSave(selected);
|
||||
}
|
||||
onSave(selected);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -55,7 +49,7 @@ export default function EnvironmentSwitchModal({
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="confirm-modal-title" style={{ marginBottom: '8px' }}>切换服务器环境</h3>
|
||||
<h3 className="confirm-modal-title" style={{ marginBottom: '8px' }}>切换运行模式</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
@@ -85,67 +79,11 @@ export default function EnvironmentSwitchModal({
|
||||
style={{ margin: '2px 0 0' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px', width: 'calc(100% - 32px)' }}>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px' }}>
|
||||
<div style={{ fontSize: '14px', lineHeight: '20px' }}>{preset.label}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-tertiary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={preset.url}
|
||||
>
|
||||
{preset.url}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: selected === 'custom' ? 'var(--bg-hover)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'top', width: '20px' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="env"
|
||||
value="custom"
|
||||
checked={selected === 'custom'}
|
||||
onChange={() => setSelected('custom')}
|
||||
style={{ margin: '2px 0 0' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px' }}>
|
||||
<div style={{ fontSize: '14px', lineHeight: '20px' }}>自定义</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{selected === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入 API 地址,如 http://192.168.1.100:8081/api/v1"
|
||||
value={customUrl}
|
||||
onChange={e => setCustomUrl(e.target.value)}
|
||||
style={{
|
||||
marginLeft: '32px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border-light)',
|
||||
fontSize: '14px',
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
width: 'calc(100% - 56px)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
interface PointsCardProps {
|
||||
balance: number;
|
||||
todayConsumed: number;
|
||||
onRecharge: () => void;
|
||||
onViewDetail: () => void;
|
||||
onViewPricing: () => void;
|
||||
}
|
||||
|
||||
export default function PointsCard({
|
||||
balance,
|
||||
todayConsumed,
|
||||
onRecharge,
|
||||
onViewDetail,
|
||||
onViewPricing,
|
||||
}: PointsCardProps) {
|
||||
return (
|
||||
<div className="profile-points-section">
|
||||
<div className="profile-points-grid">
|
||||
<div className="profile-points-card">
|
||||
<div className="profile-points-label">剩余积分</div>
|
||||
<div className="profile-points-value-row">
|
||||
<span className="profile-points-value primary">{balance}</span>
|
||||
<span className="profile-points-unit">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-points-card">
|
||||
<div className="profile-points-label">今日消耗</div>
|
||||
<div className="profile-points-value-row">
|
||||
<span className="profile-points-value danger">{todayConsumed}</span>
|
||||
<span className="profile-points-unit">分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-points-actions-row">
|
||||
<button className="btn btn-primary btn-sm" onClick={onRecharge}>
|
||||
积分充值
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onViewDetail}>
|
||||
充值明细
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onViewPricing}>
|
||||
产品定价
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pointsApi, type PointRule } from '../../api/modules/points';
|
||||
import Modal from '../Modal/Modal';
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
script: '脚本生成',
|
||||
polish: '文案润色',
|
||||
title: '标题生成',
|
||||
tts: '配音合成',
|
||||
voice_clone: '声音复刻',
|
||||
video: '视频生成',
|
||||
compose: '压制成片',
|
||||
subtitle_burn: '字幕烧录',
|
||||
cover_design: '封面设计',
|
||||
caption: '字幕生成',
|
||||
};
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface PricingModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PricingModal({ open, onClose }: PricingModalProps) {
|
||||
const [rules, setRules] = useState<PointRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || rules.length > 0) return;
|
||||
setLoading(true);
|
||||
pointsApi.getRules()
|
||||
.then(setRules)
|
||||
.catch((e) => console.error('[PricingModal] 获取积分规则失败:', e))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, rules.length]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} width="600px">
|
||||
{loading ? (
|
||||
<div className="profile-pricing-loading">
|
||||
加载中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="profile-pricing-body">
|
||||
{/* 表格 */}
|
||||
<div className="profile-pricing-table">
|
||||
<div className="profile-pricing-header">
|
||||
<span>功能名称</span>
|
||||
<span>计费方式</span>
|
||||
<span>积分消耗</span>
|
||||
</div>
|
||||
{rules
|
||||
.filter((rule) => rule.mode !== 'free')
|
||||
.map((rule) => (
|
||||
<div key={rule.sourceType} className="profile-pricing-row">
|
||||
<span className="profile-pricing-name">
|
||||
{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}
|
||||
</span>
|
||||
<span className="profile-pricing-mode">
|
||||
<span className={`profile-pricing-tag ${rule.mode}`}>
|
||||
{rule.mode === 'fixed' ? '固定' : '按时长'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="profile-pricing-points">
|
||||
{rule.mode === 'fixed'
|
||||
? `${rule.points} 积分/次`
|
||||
: `${rule.unit} ${rule.pointsPerUnit} 积分`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{rules.filter((rule) => rule.mode !== 'free').length === 0 && (
|
||||
<div className="profile-pricing-empty">暂无计费项目</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按时长细则 */}
|
||||
{rules.some((r) => r.mode === 'duration') && (
|
||||
<div className="profile-pricing-detail-section">
|
||||
<div className="profile-pricing-detail-title">
|
||||
<ClockIcon />
|
||||
按时长计费细则
|
||||
</div>
|
||||
<div className="profile-pricing-detail-list">
|
||||
{rules
|
||||
.filter((r) => r.mode === 'duration')
|
||||
.map((rule) => {
|
||||
const contentType =
|
||||
rule.sourceType === 'tts' ? '音频'
|
||||
: rule.sourceType === 'video' ? '视频'
|
||||
: '内容';
|
||||
const unitNum = (rule.unit?.match(/\d+/) || ['1'])[0];
|
||||
return (
|
||||
<div key={rule.sourceType} className="profile-pricing-detail-row">
|
||||
<strong>{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}</strong>
|
||||
<span>
|
||||
按生成{contentType}的实际时长计费,{rule.unit}消耗 {rule.pointsPerUnit} 积分,不足{unitNum}秒按{unitNum}秒计。
|
||||
{rule.sourceType === 'video' && (
|
||||
<>
|
||||
<br />
|
||||
使用系统素材每个空镜额外消耗 2 积分。
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,39 @@ export default function ProgressModal() {
|
||||
<div className="progress-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部标题 */}
|
||||
<div className="progress-modal-header">
|
||||
{(title.includes('脚本') || title.includes('文案')) && (
|
||||
{(title.includes('形象') || title.includes('克隆')) ? (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (title.includes('图片') || title.includes('封面')) ? (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (title.includes('视频') || title.includes('合成')) ? (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (title.includes('字幕') || title.includes('压制')) ? (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" ry="2" />
|
||||
<line x1="7" y1="9" x2="17" y2="9" />
|
||||
<line x1="7" y1="13" x2="12" y2="13" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (title.includes('脚本') || title.includes('文案')) ? (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
@@ -63,43 +95,7 @@ export default function ProgressModal() {
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{(title.includes('视频') || title.includes('合成')) && (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{(title.includes('字幕') || title.includes('压制')) && (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" ry="2" />
|
||||
<line x1="7" y1="9" x2="17" y2="9" />
|
||||
<line x1="7" y1="13" x2="12" y2="13" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{(title.includes('图片') || title.includes('封面')) && (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{(title.includes('形象') || title.includes('克隆')) && (
|
||||
<div className="progress-modal-icon-wrapper">
|
||||
<svg className="progress-modal-icon-animated" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<h3 className="progress-modal-title">{title}</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* 用于 ScriptCreation 等页面
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './ShotStats.css';
|
||||
|
||||
export interface ShotStatsData {
|
||||
|
||||
@@ -11,11 +11,6 @@
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.update-dialog {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: var(--radius-xl, 16px);
|
||||
@@ -23,10 +18,10 @@
|
||||
box-shadow: 0 20px 60px -10px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
animation: slideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: updateSlideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
@keyframes updateSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
@@ -162,10 +157,6 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.update-dialog-progress {
|
||||
display: flex;
|
||||
|
||||
@@ -28,6 +28,7 @@ interface CoverDesignConfig {
|
||||
backgroundImage: string | null;
|
||||
mainTitle: string;
|
||||
subtitle: string;
|
||||
avatarImage?: string | null;
|
||||
}
|
||||
|
||||
interface TemplateDef {
|
||||
@@ -98,16 +99,24 @@ function wrapTextByWidth(
|
||||
): string[] {
|
||||
if (!text.trim()) {return [];}
|
||||
|
||||
// 把中英文逗号、顿号替换为换行(语义断句)
|
||||
text = text
|
||||
.replace(/,/g, '\n')
|
||||
.replace(/,/g, '\n')
|
||||
.replace(/、/g, '\n');
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.font = `${fontSize}px ${FONT_FAMILY}`;
|
||||
|
||||
// 先按用户手动换行分割,再对每行按宽度自动换行
|
||||
// 按换行分割,再对每行按宽度自动换行
|
||||
const paragraphs = text.split('\n');
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
const chars = paragraph.split('');
|
||||
const trimmed = paragraph.trim();
|
||||
if (!trimmed) {continue;}
|
||||
const chars = trimmed.split('');
|
||||
let currentLine = '';
|
||||
|
||||
for (const char of chars) {
|
||||
@@ -218,6 +227,47 @@ export function useCoverFabric() {
|
||||
[]
|
||||
);
|
||||
|
||||
// 加载封面形象(叠加在背景之上,文字之下)
|
||||
const loadAvatarImage = useCallback(
|
||||
async (canvas: Canvas, imageUrl: string): Promise<number> => {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = (e) => reject(e);
|
||||
image.src = imageUrl;
|
||||
});
|
||||
|
||||
const fabricImg = new FabricImage(img);
|
||||
// 计算缩放:宽度/高度最大占画布的 68%
|
||||
const maxWidth = CANVAS_WIDTH * 0.68;
|
||||
const maxHeight = CANVAS_HEIGHT * 0.68;
|
||||
const scale = Math.min(
|
||||
maxWidth / (fabricImg.width || 1),
|
||||
maxHeight / (fabricImg.height || 1)
|
||||
);
|
||||
fabricImg.scale(scale);
|
||||
|
||||
const scaledHeight = (fabricImg.height || 1) * scale;
|
||||
|
||||
// 左下区域定位:左侧留边,底部与背景对齐
|
||||
const leftMargin = 40;
|
||||
fabricImg.set({
|
||||
left: leftMargin,
|
||||
top: CANVAS_HEIGHT - scaledHeight,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
canvas.add(fabricImg);
|
||||
canvas.renderAll();
|
||||
|
||||
return scaledHeight;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 渲染封面
|
||||
const renderCover = useCallback(
|
||||
async (config: CoverDesignConfig) => {
|
||||
@@ -240,54 +290,51 @@ export function useCoverFabric() {
|
||||
}
|
||||
}
|
||||
|
||||
const template = TEMPLATES[config.template];
|
||||
|
||||
// 2. 主标题(自动换行,最多2行,居中)
|
||||
if (config.mainTitle.trim()) {
|
||||
const maxWidth = CANVAS_WIDTH - 120; // 左右留边
|
||||
const lines = wrapTextByWidth(
|
||||
config.mainTitle.trim(),
|
||||
maxWidth,
|
||||
template.mainTitle.fontSize
|
||||
);
|
||||
const displayLines = lines.slice(0, 2); // 最多2行
|
||||
const lineHeight = template.mainTitle.fontSize * 1.2;
|
||||
|
||||
displayLines.forEach((line, i) => {
|
||||
const text = new FabricText(line, {
|
||||
left: CANVAS_WIDTH / 2,
|
||||
top: template.mainTitle.top + i * lineHeight,
|
||||
fontSize: template.mainTitle.fontSize,
|
||||
fill: template.mainTitle.fill,
|
||||
fontWeight: template.mainTitle.fontWeight,
|
||||
fontFamily: FONT_FAMILY,
|
||||
textAlign: 'center',
|
||||
originX: 'center',
|
||||
originY: 'top',
|
||||
selectable: false,
|
||||
evented: false,
|
||||
shadow: template.mainTitle.shadow,
|
||||
});
|
||||
canvas.add(text);
|
||||
});
|
||||
// 2. 封面形象(叠加在背景之上)
|
||||
let avatarTop = CANVAS_HEIGHT;
|
||||
if (config.avatarImage) {
|
||||
try {
|
||||
const scaledHeight = await loadAvatarImage(canvas, config.avatarImage);
|
||||
avatarTop = CANVAS_HEIGHT - scaledHeight;
|
||||
} catch {
|
||||
// no-op: 封面形象加载失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 副标题(底部居中,加粗)
|
||||
if (config.subtitle.trim()) {
|
||||
const maxWidth = CANVAS_WIDTH - 120;
|
||||
const lines = wrapTextByWidth(
|
||||
config.subtitle.trim(),
|
||||
maxWidth,
|
||||
template.subtitle.fontSize
|
||||
);
|
||||
const displayLines = lines.slice(0, 2);
|
||||
const lineHeight = template.subtitle.fontSize * 1.5;
|
||||
const totalHeight = displayLines.length * lineHeight;
|
||||
const template = TEMPLATES[config.template];
|
||||
|
||||
displayLines.forEach((line, i) => {
|
||||
// 预计算主副标题行数和高度,用于动态定位
|
||||
const mainTitleLines = config.mainTitle.trim()
|
||||
? wrapTextByWidth(config.mainTitle.trim(), CANVAS_WIDTH - 120, template.mainTitle.fontSize).slice(0, 2)
|
||||
: [];
|
||||
const subtitleLines = config.subtitle.trim()
|
||||
? wrapTextByWidth(config.subtitle.trim(), CANVAS_WIDTH - 120, template.subtitle.fontSize).slice(0, 2)
|
||||
: [];
|
||||
|
||||
const mainTitleLineHeight = template.mainTitle.fontSize * 1.2;
|
||||
const subtitleLineHeight = template.subtitle.fontSize * 1.5;
|
||||
const mainTitleHeight = mainTitleLines.length * mainTitleLineHeight;
|
||||
const subtitleHeight = subtitleLines.length * subtitleLineHeight;
|
||||
|
||||
// 间距配置
|
||||
const gapAvatarToMain = 50; // 人物与主标题间距
|
||||
const gapMainToSub = 24; // 主标题与副标题间距
|
||||
|
||||
// 从人物头顶往上计算文字位置
|
||||
// 主标题底部 = 人物顶部 - 间距
|
||||
const mainTitleBottom = avatarTop - gapAvatarToMain;
|
||||
const mainTitleTop = mainTitleBottom - mainTitleHeight;
|
||||
|
||||
// 副标题底部 = 主标题顶部 - 间距
|
||||
const subtitleBottom = mainTitleTop - gapMainToSub;
|
||||
const subtitleTop = subtitleBottom - subtitleHeight;
|
||||
|
||||
// 3. 副标题(放在人物上方,主标题上方)
|
||||
if (subtitleLines.length > 0) {
|
||||
subtitleLines.forEach((line, i) => {
|
||||
const text = new FabricText(line, {
|
||||
left: CANVAS_WIDTH / 2,
|
||||
top: template.subtitle.top - totalHeight / 2 + i * lineHeight,
|
||||
top: subtitleTop + i * subtitleLineHeight,
|
||||
fontSize: template.subtitle.fontSize,
|
||||
fill: template.subtitle.fill,
|
||||
fontWeight: 'bold',
|
||||
@@ -306,6 +353,27 @@ export function useCoverFabric() {
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 主标题(放在人物上方,副标题下方)
|
||||
if (mainTitleLines.length > 0) {
|
||||
mainTitleLines.forEach((line, i) => {
|
||||
const text = new FabricText(line, {
|
||||
left: CANVAS_WIDTH / 2,
|
||||
top: mainTitleTop + i * mainTitleLineHeight,
|
||||
fontSize: template.mainTitle.fontSize,
|
||||
fill: template.mainTitle.fill,
|
||||
fontWeight: template.mainTitle.fontWeight,
|
||||
fontFamily: FONT_FAMILY,
|
||||
textAlign: 'center',
|
||||
originX: 'center',
|
||||
originY: 'top',
|
||||
selectable: false,
|
||||
evented: false,
|
||||
shadow: template.mainTitle.shadow,
|
||||
});
|
||||
canvas.add(text);
|
||||
});
|
||||
}
|
||||
|
||||
canvas.renderAll();
|
||||
},
|
||||
[loadBackground]
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* 本地视频加载 Hook
|
||||
* =================
|
||||
*
|
||||
* 使用 Rust Command 路径校验 + convertFileSrc 安全访问本地视频文件。
|
||||
* 调用 getPreviewVideoUrl 获取浏览器兼容的预览视频 URL。
|
||||
* 首次加载时后台自动转码(H.264 Baseline + YUV420p),缓存后复用。
|
||||
* 替代原有的 readFile + Blob URL 模式,零内存拷贝,支持 video 标签流式播放与 seek。
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getLocalFileUrl } from '../utils/fileUrl';
|
||||
import { getPreviewVideoUrl } from '../utils/videoPreview';
|
||||
|
||||
interface UseLocalVideoResult {
|
||||
videoUrl: string | undefined;
|
||||
@@ -16,10 +17,10 @@ interface UseLocalVideoResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载本地视频文件,返回 asset:// URL
|
||||
* 加载本地视频文件,返回预览用的 asset:// URL
|
||||
*
|
||||
* @param filePath 本地文件绝对路径(如 /Users/.../scene_1.mp4)
|
||||
* @returns asset:// URL 或 undefined
|
||||
* @returns 转码后的 asset:// URL 或 undefined(远程 URL 直接返回)
|
||||
*/
|
||||
export function useLocalVideo(filePath: string | undefined): UseLocalVideoResult {
|
||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||
@@ -49,7 +50,7 @@ export function useLocalVideo(filePath: string | undefined): UseLocalVideoResult
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = await getLocalFileUrl(filePath!);
|
||||
const url = await getPreviewVideoUrl(filePath!);
|
||||
if (canceled) {return;}
|
||||
|
||||
setVideoUrl(url);
|
||||
|
||||
@@ -1,45 +1,82 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/variables.css';
|
||||
import './styles/global.css';
|
||||
import { loadAppConfig } from './api/modules/config';
|
||||
|
||||
// 全局禁用浏览器默认右键菜单,提升桌面应用质感
|
||||
// 输入框/文本区自动放行(保留复制/粘贴/全选)
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
const isInput = tag === 'input' || tag === 'textarea' || target.isContentEditable;
|
||||
if (!isInput) {
|
||||
e.preventDefault();
|
||||
async function bootstrap() {
|
||||
// 加载运行模式配置
|
||||
let appEnvironment = 'production';
|
||||
try {
|
||||
const config = await loadAppConfig();
|
||||
appEnvironment = config.environment;
|
||||
} catch {
|
||||
// 加载失败时默认为生产模式
|
||||
}
|
||||
});
|
||||
(window as unknown as Record<string, unknown>).__APP_ENV__ = appEnvironment;
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
// 右键菜单:生产模式受限,调试模式开放
|
||||
// 生产模式:非输入框/文本区且未按 Shift 时阻止默认右键(不支持刷新)
|
||||
// 调试模式:不拦截,WebView 默认右键菜单可用(含刷新/检查元素)
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const env = (window as unknown as Record<string, unknown>).__APP_ENV__ as string;
|
||||
if (env === 'production') {
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
const isInput = tag === 'input' || tag === 'textarea' || target.isContentEditable;
|
||||
if (!isInput && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
// 调试模式:不拦截,放行默认右键菜单
|
||||
});
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
// 全局快捷键:仅 F12 打开 DevTools
|
||||
// 生产模式禁用,调试模式开放(macOS 需 Fn+F12)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F12') {
|
||||
const env = (window as unknown as Record<string, unknown>).__APP_ENV__ as string;
|
||||
if (env === 'production') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
import('@tauri-apps/api/core')
|
||||
.then(({ invoke }) => invoke('open_devtools'))
|
||||
.catch(() => {
|
||||
// 非 Tauri 环境忽略
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 前端渲染完成后,通知 Tauri 显示主窗口
|
||||
// 使用 requestIdleCallback 确保首帧已绘制
|
||||
const showWindow = () => {
|
||||
import('@tauri-apps/api/webviewWindow')
|
||||
.then(({ getCurrentWebviewWindow }) => {
|
||||
const win = getCurrentWebviewWindow();
|
||||
win.show();
|
||||
win.setFocus();
|
||||
})
|
||||
.catch(() => {
|
||||
// 非 Tauri 环境(如浏览器开发)忽略
|
||||
});
|
||||
};
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(showWindow, { timeout: 500 });
|
||||
} else {
|
||||
setTimeout(showWindow, 100);
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// 前端渲染完成后,通知 Tauri 显示主窗口
|
||||
// 使用 requestIdleCallback 确保首帧已绘制
|
||||
const showWindow = () => {
|
||||
import('@tauri-apps/api/webviewWindow')
|
||||
.then(({ getCurrentWebviewWindow }) => {
|
||||
const win = getCurrentWebviewWindow();
|
||||
win.show();
|
||||
win.setFocus();
|
||||
})
|
||||
.catch(() => {
|
||||
// 非 Tauri 环境(如浏览器开发)忽略
|
||||
});
|
||||
};
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(showWindow, { timeout: 500 });
|
||||
} else {
|
||||
setTimeout(showWindow, 100);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -342,7 +342,8 @@
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
font-size: var(--font-xl);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
@@ -403,6 +404,432 @@
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Profile 页面重构样式(基于设计系统)── */
|
||||
|
||||
.profile-user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-xl) 28px;
|
||||
}
|
||||
|
||||
.profile-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-nickname-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-nickname {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-nickname-input {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
width: 160px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.profile-nickname-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.profile-nickname-input.error {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.profile-nickname-error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.profile-edit-icon {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.profile-mobile {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.profile-divider {
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
margin: 0 28px;
|
||||
}
|
||||
|
||||
.profile-points-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: 20px 28px;
|
||||
}
|
||||
|
||||
.profile-points-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-points-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.profile-points-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-points-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-points-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-points-value.primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.profile-points-value.danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.profile-points-unit {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.profile-points-actions-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profile-points-actions-row .btn {
|
||||
padding: 10px 20px;
|
||||
font-size: var(--font-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.profile-menu-list {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-base);
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.profile-menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.profile-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.profile-menu-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.profile-menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-menu-arrow {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.profile-logout-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.profile-logout-btn:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* 定价 Modal */
|
||||
.profile-pricing-body {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.profile-pricing-table {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-pricing-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px 140px;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.profile-pricing-header span:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-pricing-header span:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.profile-pricing-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px 140px;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-pricing-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.profile-pricing-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-pricing-mode {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-pricing-tag {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-pricing-tag.fixed {
|
||||
background: #e8f4fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.profile-pricing-tag.duration {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.profile-pricing-points {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.profile-pricing-empty {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-pricing-detail-section {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: 16px 20px;
|
||||
background: #fff8f0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ffe8d0;
|
||||
}
|
||||
|
||||
.profile-pricing-detail-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: #d46b08;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-pricing-detail-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.profile-pricing-detail-row strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-pricing-info-section {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: 16px 20px;
|
||||
background: #f8faf8;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8f0e8;
|
||||
}
|
||||
|
||||
.profile-pricing-info-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: #2e7d32;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-pricing-info-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.profile-card-flat {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 页面返回按钮 ── */
|
||||
.page-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.page-back-btn:hover {
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.page-back-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── AppHeader ── */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.app-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.app-header-title {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.profile-edit-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profile-section-spaced {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-pricing-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.profile-pricing-detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Avatar Clone Card - 这个就是我们要复用的样式 */
|
||||
.avatar-card {
|
||||
position: relative;
|
||||
@@ -506,9 +933,7 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
.avatar-card:hover .avatar-card-video {
|
||||
transform: scale(1.05);
|
||||
@@ -555,11 +980,13 @@
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar-card:hover .avatar-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.avatar-card-action-btn {
|
||||
@@ -664,6 +1091,18 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 封面形象网格 — 复用 avatar-card 风格 */
|
||||
.cover-avatar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
padding: var(--spacing-sm) var(--spacing-xs);
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Content Page Compact variant */
|
||||
.content-page-compact {
|
||||
gap: var(--spacing-md);
|
||||
@@ -1632,3 +2071,171 @@
|
||||
max-width: 280px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
关于我们 (About) - 品牌展示 + 版本更新
|
||||
============================================================ */
|
||||
|
||||
.about-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.about-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.about-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.about-name {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.about-version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-xs) 0;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.about-version-text {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.about-version-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.about-checking-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.about-check-result {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.about-check-latest {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.about-check-available {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.about-check-btn {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 10px;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.about-check-btn:hover:not(:disabled) {
|
||||
background: rgba(54, 178, 106, 0.08);
|
||||
}
|
||||
|
||||
.about-check-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.about-update-detail {
|
||||
width: 100%;
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-input);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.about-update-body-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.about-update-body pre {
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.about-update-progress {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.about-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--border-light);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.about-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.about-update-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.about-update-error {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: var(--font-sm);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 封面形象库页面
|
||||
* ============
|
||||
*
|
||||
* 管理用户上传的人物照片(抠图后)。
|
||||
* 上传图片 → 七牛云 → 火山引擎 MediaKit 抠图 → 保存到本地素材库。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useCoverAvatarStore } from '../../store';
|
||||
import { usePointStore } from '../../store';
|
||||
import { toast } from '../../store/uiStore';
|
||||
import { useProgressStore } from '../../store/progressStore';
|
||||
import Modal from '../../components/Modal/Modal';
|
||||
import ConfirmModal from '../../components/Modal/ConfirmModal';
|
||||
import RechargeModal from '../../components/RechargeModal/RechargeModal';
|
||||
import './ContentManagement.css';
|
||||
|
||||
export default function CoverAvatarLibrary() {
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadName, setUploadName] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
// 重命名状态
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// 删除确认状态
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
// 积分不足弹窗
|
||||
const [showPointsModal, setShowPointsModal] = useState(false);
|
||||
const showRechargeModal = usePointStore((state) => state.showRechargeModal);
|
||||
const setShowRechargeModal = usePointStore((state) => state.setShowRechargeModal);
|
||||
const fetchBalance = usePointStore((state) => state.fetchBalance);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
coverAvatars,
|
||||
isLoading,
|
||||
loadCoverAvatars,
|
||||
addCoverAvatar,
|
||||
renameCoverAvatar,
|
||||
deleteCoverAvatar,
|
||||
} = useCoverAvatarStore();
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
loadCoverAvatars();
|
||||
}, [loadCoverAvatars]);
|
||||
|
||||
// 图片文件验证
|
||||
const validateImageFile = (file: File): { valid: boolean; error?: string } => {
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (file.size > maxSize) {
|
||||
return { valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` };
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return { valid: false, error: '仅支持 JPG、PNG、GIF、WebP 格式' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {return;}
|
||||
|
||||
const validation = validateImageFile(file);
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error || '文件验证失败');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
// 默认使用文件名(不含扩展名)作为名称
|
||||
if (!uploadName.trim()) {
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
setUploadName(nameWithoutExt);
|
||||
}
|
||||
}, [uploadName]);
|
||||
|
||||
// 重置弹窗状态
|
||||
const resetUploadModal = useCallback(() => {
|
||||
setUploadModalOpen(false);
|
||||
setUploadName('');
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 上传处理
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!uploadName.trim() || !selectedFile) {return;}
|
||||
|
||||
// 前置积分检查
|
||||
await fetchBalance();
|
||||
const coverAvatarPoints = usePointStore.getState().getRule('cover_avatar')?.points || 10;
|
||||
const balance = usePointStore.getState().balance;
|
||||
if (balance < coverAvatarPoints) {
|
||||
setShowPointsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = useProgressStore.getState();
|
||||
setUploadModalOpen(false);
|
||||
|
||||
progress.show('上传封面形象');
|
||||
try {
|
||||
progress.update('正在上传并抠图...');
|
||||
const avatar = await addCoverAvatar(selectedFile, uploadName.trim());
|
||||
progress.success('封面形象保存成功', 200);
|
||||
toast.success(`「${avatar.name}」已保存`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '上传失败';
|
||||
progress.error(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
|
||||
setUploadName('');
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [uploadName, selectedFile, addCoverAvatar, fetchBalance]);
|
||||
|
||||
// 删除处理
|
||||
const openDeleteModal = (id: string, name: string) => {
|
||||
setDeleteTarget({ id, name });
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget) {return;}
|
||||
try {
|
||||
await deleteCoverAvatar(deleteTarget.id);
|
||||
toast.success('已删除');
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
} finally {
|
||||
setDeleteModalOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget, deleteCoverAvatar]);
|
||||
|
||||
// 重命名
|
||||
const startRename = (id: string, currentName: string) => {
|
||||
setEditingId(id);
|
||||
setEditingName(currentName);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const confirmRename = useCallback(async () => {
|
||||
if (!editingId || !editingName.trim()) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await renameCoverAvatar(editingId, editingName.trim());
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
} catch {
|
||||
toast.error('重命名失败');
|
||||
}
|
||||
}, [editingId, editingName, renameCoverAvatar]);
|
||||
|
||||
return (
|
||||
<div className="content-page">
|
||||
{/* 页面标题和上传区域 */}
|
||||
<div className="voice-clone-wrapper">
|
||||
<div className="voice-clone-title-group">
|
||||
<h2>封面形象</h2>
|
||||
<p className="voice-clone-desc">上传人物照片,AI 自动抠图,用于封面设计叠加</p>
|
||||
</div>
|
||||
|
||||
{/* 上传引导卡片 */}
|
||||
<div
|
||||
className="voice-upload-card"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
<div className="voice-upload-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="voice-upload-text">
|
||||
<span className="voice-upload-title">上传人物照片</span>
|
||||
<span className="voice-upload-hint">JPG / PNG / GIF / WebP,建议半身或全身照</span>
|
||||
</div>
|
||||
<div className="voice-upload-arrow">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传弹窗 */}
|
||||
<Modal
|
||||
open={uploadModalOpen}
|
||||
onClose={resetUploadModal}
|
||||
title=""
|
||||
width="480px"
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
|
||||
形象名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="例如:我的形象"
|
||||
value={uploadName}
|
||||
onChange={e => setUploadName(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
|
||||
选择图片
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed var(--border-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--spacing-xl)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; }}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--font-sm)' }}>{selectedFile.name}</div>
|
||||
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<div style={{ fontSize: 'var(--font-sm)' }}>点击选择图片</div>
|
||||
<div style={{ fontSize: 'var(--font-xs)', marginTop: 6, lineHeight: 1.6 }}>
|
||||
支持 JPG / PNG / GIF / WebP,不超过 20MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}>
|
||||
每次上传消耗 <strong style={{ color: 'var(--primary)' }}>10</strong> 积分
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={resetUploadModal}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!uploadName.trim() || !selectedFile}
|
||||
>
|
||||
上传并抠图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 封面形象列表 */}
|
||||
{isLoading ? (
|
||||
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
|
||||
) : coverAvatars.length === 0 ? (
|
||||
<div className="empty-state-page">
|
||||
<div className="empty-state-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="empty-state-title">暂无封面形象</p>
|
||||
<p className="empty-state-desc">上传一张人物照片,<br />AI 将自动抠图生成透明背景形象</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-avatar-grid">
|
||||
{coverAvatars.map(a => (
|
||||
<div key={a.id} className="avatar-card">
|
||||
{/* 图片预览 */}
|
||||
<div className="avatar-card-thumb-container">
|
||||
<img
|
||||
src={a.imageUrl}
|
||||
alt={a.name}
|
||||
className="avatar-card-video"
|
||||
style={{ objectFit: 'contain', background: 'var(--bg-input)' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* 悬停操作按钮 */}
|
||||
<div className="avatar-card-actions">
|
||||
<button
|
||||
className="avatar-card-action-btn"
|
||||
onClick={() => startRename(a.id, a.name)}
|
||||
title="重命名"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
className="avatar-card-action-btn delete"
|
||||
onClick={() => openDeleteModal(a.id, a.name)}
|
||||
title="删除"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="avatar-card-info">
|
||||
{editingId === a.id ? (
|
||||
<input
|
||||
type="text"
|
||||
className="avatar-card-name-input"
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {confirmRename();}
|
||||
if (e.key === 'Escape') {cancelRename();}
|
||||
}}
|
||||
onBlur={confirmRename}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-card-name">{a.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<ConfirmModal
|
||||
open={deleteModalOpen}
|
||||
type="danger"
|
||||
title={<>确认删除形象 <strong>「{deleteTarget?.name}」</strong> 吗?</>}
|
||||
description="此操作不可撤销,形象将被永久删除"
|
||||
confirmText="确认删除"
|
||||
cancelText="取消"
|
||||
confirmButtonType="danger"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => { setDeleteModalOpen(false); setDeleteTarget(null); }}
|
||||
/>
|
||||
|
||||
{/* 积分不足弹窗 */}
|
||||
<ConfirmModal
|
||||
open={showPointsModal}
|
||||
type="warning"
|
||||
title="积分不足"
|
||||
description={`每次上传封面形象消耗 10 积分,当前余额不足。请先充值后再尝试。`}
|
||||
confirmText="立即充值"
|
||||
cancelText="稍后再说"
|
||||
confirmButtonType="danger"
|
||||
onConfirm={() => { setShowPointsModal(false); setShowRechargeModal(true); }}
|
||||
onCancel={() => setShowPointsModal(false)}
|
||||
onClose={() => setShowPointsModal(false)}
|
||||
/>
|
||||
<RechargeModal
|
||||
open={showRechargeModal}
|
||||
onClose={() => setShowRechargeModal(false)}
|
||||
onRechargeSuccess={() => { fetchBalance(); setShowPointsModal(false); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -392,15 +392,20 @@ export default function VoiceMaterialLibrary() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setUploadModalOpen(false)}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!uploadName.trim() || !selectedFile}
|
||||
>
|
||||
开始复刻
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}>
|
||||
每次复刻消耗 <strong style={{ color: 'var(--primary)' }}>200</strong> 积分
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={() => setUploadModalOpen(false)}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!uploadName.trim() || !selectedFile}
|
||||
>
|
||||
开始复刻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useAuthStore } from '../../store';
|
||||
import { client } from '../../api/client';
|
||||
import { pointsApi, type PointBalance, type PointTransaction } from '../../api/modules/points';
|
||||
import RechargeModal from '../../components/RechargeModal/RechargeModal';
|
||||
import PricingModal from '../../components/PricingModal/PricingModal';
|
||||
import PointsCard from '../../components/PointsCard/PointsCard';
|
||||
import AppHeader from '../../components/Layout/AppHeader';
|
||||
import '../ContentManagement/ContentManagement.css';
|
||||
|
||||
interface UserProfile {
|
||||
@@ -13,40 +16,28 @@ interface UserProfile {
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
function formatTxTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
expire: '过期',
|
||||
refund: '退款',
|
||||
};
|
||||
|
||||
function maskMobile(mobile: string): string {
|
||||
if (!mobile || mobile.length !== 11) {return mobile;}
|
||||
if (!mobile || mobile.length !== 11) { return mobile; }
|
||||
return `${mobile.slice(0, 3)}****${mobile.slice(7)}`;
|
||||
}
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Profile() {
|
||||
const { navigate } = useNavigation();
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [balance, setBalance] = useState<PointBalance | null>(null);
|
||||
const [recentTx, setRecentTx] = useState<PointTransaction[]>([]);
|
||||
const [todayConsumed, setTodayConsumed] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showRechargeModal, setShowRechargeModal] = useState(false);
|
||||
const [showPricingModal, setShowPricingModal] = useState(false);
|
||||
const [recentTx, setRecentTx] = useState<PointTransaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 昵称编辑状态
|
||||
const [nickname, setNickname] = useState('');
|
||||
@@ -65,14 +56,14 @@ export default function Profile() {
|
||||
setUser(profileData);
|
||||
setNickname(profileData.nickname || '');
|
||||
}
|
||||
if (balanceData) {setBalance(balanceData);}
|
||||
if (balanceData) { setBalance(balanceData); }
|
||||
|
||||
const [txData, todayData] = await Promise.all([
|
||||
pointsApi.getTransactions({ page: 1, pageSize: 10 }).catch(() => null),
|
||||
pointsApi.getTransactions({ page: 1, pageSize: 5 }).catch(() => null),
|
||||
pointsApi.getTodayConsumed().catch(() => null),
|
||||
]);
|
||||
if (txData) {setRecentTx(txData.items);}
|
||||
if (todayData) {setTodayConsumed(todayData.total);}
|
||||
if (txData) { setRecentTx(txData.items); }
|
||||
if (todayData) { setTodayConsumed(todayData.total); }
|
||||
} catch (e) {
|
||||
console.error('[Profile] 加载数据失败:', e);
|
||||
} finally {
|
||||
@@ -88,6 +79,10 @@ export default function Profile() {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleOpenPricing = () => {
|
||||
setShowPricingModal(true);
|
||||
};
|
||||
|
||||
const handleSaveNickname = async () => {
|
||||
const trimmed = nickname.trim();
|
||||
if (!trimmed) { setNickError('昵称不能为空'); return; }
|
||||
@@ -109,30 +104,43 @@ export default function Profile() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!window.confirm('确定要退出登录吗?')) {return;}
|
||||
await logout();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const displayName = user?.nickname || authUser?.nickname || '用户';
|
||||
const displayAvatar = user?.avatar || authUser?.avatar || '';
|
||||
const displayMobile = user?.mobile ? maskMobile(user.mobile) : '';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
expire: '过期',
|
||||
refund: '退款',
|
||||
};
|
||||
|
||||
function formatTxTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
{/* 个人资料卡片 */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<AppHeader title="我的账户" />
|
||||
|
||||
{/* 个人信息 + 积分 */}
|
||||
<div className="card profile-card-flat">
|
||||
{/* 用户区 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', padding: '24px 28px' }}>
|
||||
<div className="profile-user-section">
|
||||
<img
|
||||
src={displayAvatar || '/default-avatar.svg'}
|
||||
alt="avatar"
|
||||
className="profile-topbar-avatar"
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="profile-user-info">
|
||||
{editing ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<div className="profile-edit-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={nickname}
|
||||
@@ -140,19 +148,9 @@ export default function Profile() {
|
||||
maxLength={20}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '6px',
|
||||
border: nickError ? '1px solid #e74c3c' : '1px solid var(--border-color)',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
outline: 'none',
|
||||
width: '160px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
className={`profile-nickname-input ${nickError ? 'error' : ''}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {handleSaveNickname();}
|
||||
if (e.key === 'Enter') { handleSaveNickname(); }
|
||||
if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setNickname(displayName);
|
||||
@@ -171,114 +169,42 @@ export default function Profile() {
|
||||
}}
|
||||
/>
|
||||
{nickError && (
|
||||
<span style={{ color: '#e74c3c', fontSize: '12px' }}>
|
||||
{nickError}
|
||||
</span>
|
||||
<span className="profile-nickname-error">{nickError}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontSize: '18px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{displayName}
|
||||
<div className="profile-nickname-wrap">
|
||||
<span className="profile-nickname">{displayName}</span>
|
||||
<span className="profile-edit-icon" onClick={() => setEditing(true)}>
|
||||
<EditIcon />
|
||||
</span>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#36b26a"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ cursor: 'pointer', flexShrink: 0 }}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<title>修改昵称</title>
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{displayMobile && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '2px' }}>
|
||||
{displayMobile}
|
||||
</div>
|
||||
<div className="profile-mobile">{displayMobile}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '0 28px' }} />
|
||||
<div className="profile-divider" />
|
||||
|
||||
{/* 积分统计卡片 */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '16px', padding: '24px 28px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', flex: 1 }}>
|
||||
{/* 剩余积分 */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
padding: '20px 24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)', marginBottom: '12px' }}>
|
||||
剩余积分
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
|
||||
<span style={{ fontSize: '36px', fontWeight: 700, color: '#36b26a', lineHeight: 1 }}>
|
||||
{balance?.balance ?? 0}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 今日消耗 */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
padding: '20px 24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)', marginBottom: '12px' }}>
|
||||
今日消耗
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
|
||||
<span style={{ fontSize: '36px', fontWeight: 700, color: '#ff6b6b', lineHeight: 1 }}>
|
||||
{todayConsumed}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ padding: '10px 24px', fontSize: 'var(--font-sm)', whiteSpace: 'nowrap' }}
|
||||
onClick={() => setShowRechargeModal(true)}
|
||||
>
|
||||
积分充值
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: '10px 24px', fontSize: 'var(--font-sm)', whiteSpace: 'nowrap' }}
|
||||
onClick={() => {
|
||||
localStorage.setItem('usage-detail-initial-tab', 'recharge');
|
||||
navigate('usage-detail');
|
||||
}}
|
||||
>
|
||||
充值明细
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 积分统计 */}
|
||||
<PointsCard
|
||||
balance={balance?.balance ?? 0}
|
||||
todayConsumed={todayConsumed}
|
||||
onRecharge={() => setShowRechargeModal(true)}
|
||||
onViewDetail={() => {
|
||||
localStorage.setItem('usage-detail-initial-tab', 'recharge');
|
||||
navigate('usage-detail');
|
||||
}}
|
||||
onViewPricing={handleOpenPricing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最近记录表格 */}
|
||||
<div style={{ marginTop: 'var(--spacing-xl)' }}>
|
||||
{/* 最近记录 */}
|
||||
<div className="profile-section-spaced">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
|
||||
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600 }}>最近记录</h3>
|
||||
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600, margin: 0 }}>最近记录</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => navigate('usage-detail')}>
|
||||
查看全部
|
||||
</button>
|
||||
@@ -289,8 +215,6 @@ export default function Profile() {
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>变动积分</th>
|
||||
<th>变动前余额</th>
|
||||
<th>变动后余额</th>
|
||||
<th>说明</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
@@ -298,13 +222,13 @@ export default function Profile() {
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
||||
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : recentTx.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
||||
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
||||
暂无记录
|
||||
</td>
|
||||
</tr>
|
||||
@@ -326,8 +250,6 @@ export default function Profile() {
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{tx.type === 'recharge' ? '+' : '-'}{tx.amount}
|
||||
</td>
|
||||
<td>{tx.balanceBefore}</td>
|
||||
<td>{tx.balanceAfter}</td>
|
||||
<td className="description-cell" title={tx.description || '-'}>
|
||||
{tx.description || '-'}
|
||||
</td>
|
||||
@@ -340,41 +262,13 @@ export default function Profile() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 退出登录 — 页面底部 */}
|
||||
<div style={{ marginTop: 'var(--spacing-xl)' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid var(--border-light)',
|
||||
background: 'var(--bg-card)',
|
||||
fontSize: 'var(--font-sm)',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLButtonElement).style.color = '#e74c3c';
|
||||
(e.target as HTMLButtonElement).style.borderColor = '#e74c3c';
|
||||
(e.target as HTMLButtonElement).style.background = '#fdf2f2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLButtonElement).style.color = 'var(--text-secondary)';
|
||||
(e.target as HTMLButtonElement).style.borderColor = 'var(--border-light)';
|
||||
(e.target as HTMLButtonElement).style.background = 'var(--bg-card)';
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RechargeModal
|
||||
open={showRechargeModal}
|
||||
onClose={() => setShowRechargeModal(false)}
|
||||
onRechargeSuccess={handleRechargeSuccess}
|
||||
/>
|
||||
|
||||
<PricingModal open={showPricingModal} onClose={() => setShowPricingModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import AppHeader from '../../components/Layout/AppHeader';
|
||||
import { pointsApi, type PointTransaction } from '../../api/modules/points';
|
||||
import DateRangePicker from '../../components/DatePicker/DateRangePicker';
|
||||
import '../ContentManagement/ContentManagement.css';
|
||||
@@ -360,10 +362,12 @@ export default function UsageDetail() {
|
||||
return `${sign}${Math.abs(amount)}`;
|
||||
};
|
||||
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<AppHeader title="积分明细" showBack onBack={() => navigate('profile')} />
|
||||
<div className="settings-section">
|
||||
<h2>积分明细</h2>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import '../ContentManagement/ContentManagement.css';
|
||||
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import { saveAppConfig } from '../../api/modules/config';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { toast } from '../../store/uiStore';
|
||||
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
|
||||
export default function AboutUs() {
|
||||
const { appEnvironment } = useNavigation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
// 版本号连击检测
|
||||
const clickCountRef = useRef(0);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleVersionClick = useCallback(() => {
|
||||
clickCountRef.current += 1;
|
||||
|
||||
if (clickCountRef.current === 5) {
|
||||
setShowModal(true);
|
||||
clickCountRef.current = 0;
|
||||
if (clickTimerRef.current) {clearTimeout(clickTimerRef.current);}
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickTimerRef.current) {clearTimeout(clickTimerRef.current);}
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickCountRef.current = 0;
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
const handleSave = async (env: string, customUrl?: string) => {
|
||||
try {
|
||||
await saveAppConfig(env, customUrl);
|
||||
|
||||
// 开发模式下 Vite dev server 重启后无法自动恢复,改用刷新页面
|
||||
if (import.meta.env.DEV) {
|
||||
toast.success('配置已保存,即将刷新');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('配置已保存,应用即将重启');
|
||||
setTimeout(() => {
|
||||
invoke('restart_app');
|
||||
}, 800);
|
||||
} catch {
|
||||
toast.error('保存配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-section">
|
||||
<h2>关于我们</h2>
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="settings-row">
|
||||
<span className="settings-row-label">应用名称</span>
|
||||
<span className="settings-row-value">美家卡 智影</span>
|
||||
</div>
|
||||
<div className="settings-row" style={{ borderTop: '1px solid var(--border-light)' }}>
|
||||
<span className="settings-row-label">版本号</span>
|
||||
<span
|
||||
className="settings-row-value"
|
||||
onClick={handleVersionClick}
|
||||
style={{ cursor: 'default', userSelect: 'none' }}
|
||||
title={appEnvironment !== 'production' ? `当前环境: ${appEnvironment}` : undefined}
|
||||
>
|
||||
v{CURRENT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2>授权信息</h2>
|
||||
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
|
||||
<p style={{ lineHeight: 1.8, margin: 0 }}>
|
||||
本软件由美家卡团队开发维护。授权用户可在授权范围内使用本软件进行视频创作。
|
||||
如需商业授权或有任何疑问,请联系我们的支持团队。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2>版权声明</h2>
|
||||
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
|
||||
<p style={{ lineHeight: 1.8, margin: 0 }}>
|
||||
Copyright 2025 美家卡 (meijiaka.cn). All rights reserved.
|
||||
</p>
|
||||
<p style={{ lineHeight: 1.8, marginTop: 'var(--spacing-md)', marginBottom: 0 }}>
|
||||
本软件及其相关文档的所有权利均归美家卡所有。
|
||||
未经授权,不得复制、修改、分发或以其他方式使用本软件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentSwitchModal
|
||||
key={appEnvironment}
|
||||
open={showModal}
|
||||
currentEnv={appEnvironment}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import AppHeader from '../../components/Layout/AppHeader';
|
||||
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
|
||||
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { saveAppConfig } from '../../api/modules/config';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { toast } from '../../store/uiStore';
|
||||
import '../ContentManagement/ContentManagement.css';
|
||||
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
|
||||
export default function Settings() {
|
||||
const { navigate, appEnvironment } = useNavigation();
|
||||
|
||||
// ── 系统更新状态 ──
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<'none' | 'latest' | 'available'>('none');
|
||||
const [updateInfo, setUpdateInfo] = useState<Update | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
||||
const [totalBytes, setTotalBytes] = useState(0);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
// ── 环境切换 ──
|
||||
const [showEnvModal, setShowEnvModal] = useState(false);
|
||||
|
||||
// ── 缓存清理 ──
|
||||
const [cacheSize, setCacheSize] = useState(0);
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const clickCountRef = useRef(0);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
setCheckResult('none');
|
||||
setUpdateError(null);
|
||||
setUpdateInfo(null);
|
||||
|
||||
try {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdateInfo(update);
|
||||
setCheckResult('available');
|
||||
} else {
|
||||
setCheckResult('latest');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Settings] 检查更新失败:', err);
|
||||
setUpdateError(err instanceof Error ? err.message : '检查更新失败');
|
||||
setCheckResult('none');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAndInstall = async () => {
|
||||
if (!updateInfo) return;
|
||||
|
||||
setDownloading(true);
|
||||
setProgress(0);
|
||||
setDownloadedBytes(0);
|
||||
setTotalBytes(0);
|
||||
setUpdateError(null);
|
||||
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
await updateInfo.downloadAndInstall((event: DownloadEvent) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
totalSize = event.data.contentLength ?? 0;
|
||||
setTotalBytes(totalSize);
|
||||
break;
|
||||
case 'Progress':
|
||||
setDownloadedBytes(prev => {
|
||||
const next = prev + event.data.chunkLength;
|
||||
if (totalSize > 0) {
|
||||
setProgress(Math.min(Math.round((next / totalSize) * 100), 100));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'Finished':
|
||||
setProgress(100);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setDownloading(false);
|
||||
setInstalling(true);
|
||||
} catch (err) {
|
||||
console.error('[Settings] 下载安装失败:', err);
|
||||
setUpdateError(err instanceof Error ? err.message : '下载安装失败');
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelaunch = async () => {
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (err) {
|
||||
setUpdateError(err instanceof Error ? err.message : '重启失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 版本号连击 ──
|
||||
const handleVersionClick = useCallback(() => {
|
||||
clickCountRef.current += 1;
|
||||
|
||||
if (clickCountRef.current === 5) {
|
||||
setShowEnvModal(true);
|
||||
clickCountRef.current = 0;
|
||||
if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); }
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickCountRef.current = 0;
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
// 获取缓存大小
|
||||
const fetchCacheSize = useCallback(async () => {
|
||||
try {
|
||||
const size = await invoke<number>('get_video_cache_size_cmd');
|
||||
setCacheSize(size);
|
||||
} catch {
|
||||
setCacheSize(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCacheSize();
|
||||
return () => {
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchCacheSize]);
|
||||
|
||||
// 清理缓存
|
||||
const handleClearCache = async () => {
|
||||
if (cacheSize === 0) {
|
||||
toast.info('暂无缓存需要清理');
|
||||
return;
|
||||
}
|
||||
setClearingCache(true);
|
||||
try {
|
||||
const freed = await invoke<number>('clear_video_cache_cmd');
|
||||
setCacheSize(0);
|
||||
toast.success(`已清理 ${formatBytes(freed)} 缓存`);
|
||||
} catch {
|
||||
toast.error('清理缓存失败');
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEnv = async (env: string) => {
|
||||
try {
|
||||
await saveAppConfig(env);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
toast.success('配置已保存,即将刷新');
|
||||
setTimeout(() => { window.location.reload(); }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('配置已保存,应用即将重启');
|
||||
setTimeout(() => { invoke('restart_app'); }, 800);
|
||||
} catch {
|
||||
toast.error('保存配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<AppHeader title="设置" showBack onBack={() => navigate('profile')} />
|
||||
|
||||
{/* 关于我们 */}
|
||||
<div className="settings-section">
|
||||
<div className="card about-card" style={{ padding: 'var(--spacing-lg)', overflow: 'hidden' }}>
|
||||
{/* 品牌 */}
|
||||
<div className="about-brand">
|
||||
<img className="about-logo" src="/assets/logo.png" alt="美家卡智影" />
|
||||
<span className="about-name">美家卡 智影</span>
|
||||
</div>
|
||||
|
||||
{/* 版本 + 检查更新 */}
|
||||
<div className="about-version-row">
|
||||
<span
|
||||
className="about-version-text"
|
||||
onClick={handleVersionClick}
|
||||
title={appEnvironment !== 'production' ? `当前环境: ${appEnvironment}` : undefined}
|
||||
>
|
||||
当前版本 v{CURRENT_VERSION}
|
||||
</span>
|
||||
<div className="about-version-actions">
|
||||
{checking ? (
|
||||
<span className="about-checking-status">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
检查中...
|
||||
</span>
|
||||
) : checkResult === 'latest' ? (
|
||||
<span className="about-check-result about-check-latest">当前已是最新版本</span>
|
||||
) : checkResult === 'available' ? (
|
||||
<span className="about-check-result about-check-available">发现新版本 {updateInfo?.version}</span>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm about-check-btn"
|
||||
onClick={handleCheck}
|
||||
disabled={checking || downloading}
|
||||
>
|
||||
{checking ? '检查中' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新详情(发现新版本时展开) */}
|
||||
{checkResult === 'available' && updateInfo && (
|
||||
<div className="about-update-detail">
|
||||
{updateInfo.body && (
|
||||
<div className="about-update-body">
|
||||
<div className="about-update-body-title">更新内容</div>
|
||||
<pre>{updateInfo.body}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloading && (
|
||||
<div className="about-update-progress">
|
||||
<div className="about-progress-bar">
|
||||
<div className="about-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="about-progress-meta">
|
||||
<span>{progress}%</span>
|
||||
{totalBytes > 0 && <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="about-update-actions">
|
||||
{installing ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleRelaunch}>立即重启</button>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleDownloadAndInstall} disabled={downloading}>
|
||||
{downloading ? '下载中...' : '立即更新'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateError && (
|
||||
<div className="about-update-error">
|
||||
{updateError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缓存清理 */}
|
||||
<div className="settings-section">
|
||||
<h2>缓存管理</h2>
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="settings-row">
|
||||
<span className="settings-row-label">本地文件缓存</span>
|
||||
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>
|
||||
{cacheSize > 0 ? formatBytes(cacheSize) : '暂无缓存'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm about-check-btn"
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache || cacheSize === 0}
|
||||
>
|
||||
{clearingCache ? '清理中...' : '立即清理'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 授权信息 */}
|
||||
<div className="settings-section">
|
||||
<h2>授权信息</h2>
|
||||
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
|
||||
<p style={{ lineHeight: 1.8, margin: 0 }}>
|
||||
本软件由美家卡团队开发维护。授权用户可在授权范围内使用本软件进行视频创作。
|
||||
如需商业授权或有任何疑问,请联系我们的支持团队(support@meijiaka.cn)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版权声明 */}
|
||||
<div className="settings-section">
|
||||
<h2>版权声明</h2>
|
||||
<div className="card" style={{ padding: 'var(--spacing-xl)' }}>
|
||||
<p style={{ lineHeight: 1.8, margin: 0 }}>
|
||||
Copyright 2025 美家卡 (meijiaka.cn). All rights reserved.
|
||||
</p>
|
||||
<p style={{ lineHeight: 1.8, marginTop: 'var(--spacing-md)', marginBottom: 0 }}>
|
||||
本软件及其相关文档的所有权利均归美家卡所有。
|
||||
未经授权,不得复制、修改、分发或以其他方式使用本软件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentSwitchModal
|
||||
key={appEnvironment}
|
||||
open={showEnvModal}
|
||||
currentEnv={appEnvironment}
|
||||
onSave={handleSaveEnv}
|
||||
onCancel={() => setShowEnvModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* 系统更新页面
|
||||
* ============
|
||||
*
|
||||
* 支持手动检查更新、下载并安装。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import '../ContentManagement/ContentManagement.css';
|
||||
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
|
||||
export default function SystemUpdate() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<'none' | 'latest' | 'available'>('none');
|
||||
const [updateInfo, setUpdateInfo] = useState<Update | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
||||
const [totalBytes, setTotalBytes] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
setCheckResult('none');
|
||||
setError(null);
|
||||
setUpdateInfo(null);
|
||||
|
||||
try {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdateInfo(update);
|
||||
setCheckResult('available');
|
||||
} else {
|
||||
setCheckResult('latest');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SystemUpdate] 检查更新失败:', err);
|
||||
setError(err instanceof Error ? err.message : '检查更新失败');
|
||||
setCheckResult('none');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAndInstall = async () => {
|
||||
if (!updateInfo) return;
|
||||
|
||||
setDownloading(true);
|
||||
setProgress(0);
|
||||
setDownloadedBytes(0);
|
||||
setTotalBytes(0);
|
||||
setError(null);
|
||||
|
||||
// 用局部变量保存总大小,避免 Progress 回调里的闭包问题
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
await updateInfo.downloadAndInstall((event: DownloadEvent) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
totalSize = event.data.contentLength ?? 0;
|
||||
setTotalBytes(totalSize);
|
||||
break;
|
||||
case 'Progress':
|
||||
setDownloadedBytes(prev => {
|
||||
const next = prev + event.data.chunkLength;
|
||||
if (totalSize > 0) {
|
||||
setProgress(Math.min(Math.round((next / totalSize) * 100), 100));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'Finished':
|
||||
setProgress(100);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setDownloading(false);
|
||||
setInstalling(true);
|
||||
} catch (err) {
|
||||
console.error('[SystemUpdate] 下载安装失败:', err);
|
||||
setError(err instanceof Error ? err.message : '下载安装失败');
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelaunch = async () => {
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重启失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-section">
|
||||
<h2>系统更新</h2>
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* 当前版本 */}
|
||||
<div className="settings-row">
|
||||
<span className="settings-row-label">当前版本</span>
|
||||
<span className="settings-row-value">v{CURRENT_VERSION}</span>
|
||||
</div>
|
||||
|
||||
{/* 检查更新 */}
|
||||
<div className="settings-row" style={{ borderTop: '1px solid var(--border-light)' }}>
|
||||
<span className="settings-row-label">版本更新</span>
|
||||
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
|
||||
{checking ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', color: 'var(--text-tertiary)' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
检查中...
|
||||
</span>
|
||||
) : checkResult === 'latest' ? (
|
||||
<span style={{ color: 'var(--success)', fontWeight: 500 }}>当前已是最新版本</span>
|
||||
) : checkResult === 'available' ? (
|
||||
<span style={{ color: 'var(--primary)', fontWeight: 500 }}>发现新版本 {updateInfo?.version}</span>
|
||||
) : null}
|
||||
<button className="btn btn-primary btn-sm" onClick={handleCheck} disabled={checking || downloading}>
|
||||
{checking ? '检查中' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新详情 & 操作 */}
|
||||
{checkResult === 'available' && updateInfo && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--border-light)', background: 'var(--bg-input)' }}>
|
||||
{updateInfo.body && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 'var(--font-sm)', fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>更新内容</div>
|
||||
<pre style={{ fontSize: 'var(--font-sm)', lineHeight: 1.7, whiteSpace: 'pre-wrap', margin: 0, color: 'var(--text-primary)' }}>{updateInfo.body}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下载进度 */}
|
||||
{downloading && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ height: 6, background: 'var(--border-light)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${progress}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||
<span>{progress}%</span>
|
||||
{totalBytes > 0 && <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮 */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{installing ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleRelaunch}>
|
||||
立即重启
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleDownloadAndInstall} disabled={downloading}>
|
||||
{downloading ? '下载中...' : '立即更新'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误 */}
|
||||
{error && (
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-light)', background: '#fef2f2', color: '#dc2626', fontSize: 'var(--font-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,13 @@
|
||||
* 完全遵循字幕压制页面结构和间距,保持一致
|
||||
*/
|
||||
|
||||
/* 布局 - 55:45 和字幕压制一致 */
|
||||
/* 布局 - 40:60 */
|
||||
.step-layout.subtitle-burning {
|
||||
grid-template-columns: 55fr 45fr;
|
||||
}
|
||||
|
||||
.step-layout.cover-design-variant {
|
||||
grid-template-columns: 55fr 45fr;
|
||||
grid-template-columns: 40fr 60fr;
|
||||
}
|
||||
|
||||
/* 左侧操作区 - 与视频生成页面统一 */
|
||||
@@ -187,19 +187,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 生成按钮 */
|
||||
.cover-generate-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
margin-top: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* 右侧预览区 - 与视频生成页面统一 */
|
||||
.video-gen-right {
|
||||
display: flex;
|
||||
@@ -378,7 +365,7 @@
|
||||
min-height: 64px;
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
padding: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 智能生成按钮 */
|
||||
@@ -390,6 +377,284 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 视觉素材横向卡片 */
|
||||
.visual-assets-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xl);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.visual-asset-card {
|
||||
flex: 0 0 auto;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.visual-asset-card:hover {
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.visual-asset-card:hover .visual-asset-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.visual-asset-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
margin: 8px;
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.visual-asset-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.visual-asset-card:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.visual-asset-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.4px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.visual-asset-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visual-asset-overlay-text {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.visual-asset-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-xs);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* .visual-asset-hint removed - using hover overlay instead */
|
||||
|
||||
/* 弹窗背景图网格 */
|
||||
.modal-bg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modal-bg-thumb {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
aspect-ratio: 9 / 16;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-bg-thumb:hover {
|
||||
border-color: var(--border-color);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.modal-bg-thumb.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
/* 选中态勾选标记 */
|
||||
.modal-bg-thumb.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: var(--spacing-sm);
|
||||
right: var(--spacing-sm);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.modal-bg-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 弹窗封面形象网格 */
|
||||
.modal-avatar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modal-avatar-thumb {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
aspect-ratio: 9 / 16;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-avatar-thumb:hover {
|
||||
border-color: var(--border-color);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.modal-avatar-thumb.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.modal-avatar-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 加载失败 */
|
||||
.modal-bg-error {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.modal-bg-error::after {
|
||||
content: '加载失败';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 本地上传卡片 */
|
||||
.modal-bg-upload {
|
||||
border-style: dashed;
|
||||
border-color: var(--border-color);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-bg-upload:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-placeholder svg {
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-bg-upload:hover .upload-placeholder {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.modal-bg-upload:hover .upload-placeholder svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.avatar-thumb-error {
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-thumb-error::after {
|
||||
content: '加载失败';
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 背景图片区域头部(标签 + 按钮) */
|
||||
.bg-section-header {
|
||||
display: flex;
|
||||
|
||||