Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
OpenDAS
LightX2V
Commits
a1ebc651
Commit
a1ebc651
authored
Dec 11, 2025
by
xuwx1
Browse files
updata lightx2v
parent
5a4db490
Pipeline
#3149
canceled with stages
Changes
428
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
6020 additions
and
0 deletions
+6020
-0
lightx2v/deploy/server/frontend/public/logo_black.svg
lightx2v/deploy/server/frontend/public/logo_black.svg
+1
-0
lightx2v/deploy/server/frontend/public/male.svg
lightx2v/deploy/server/frontend/public/male.svg
+3
-0
lightx2v/deploy/server/frontend/public/robots.txt
lightx2v/deploy/server/frontend/public/robots.txt
+3
-0
lightx2v/deploy/server/frontend/public/sitemap.xml
lightx2v/deploy/server/frontend/public/sitemap.xml
+14
-0
lightx2v/deploy/server/frontend/public/vite.svg
lightx2v/deploy/server/frontend/public/vite.svg
+1
-0
lightx2v/deploy/server/frontend/src/App.vue
lightx2v/deploy/server/frontend/src/App.vue
+125
-0
lightx2v/deploy/server/frontend/src/components/Alert.vue
lightx2v/deploy/server/frontend/src/components/Alert.vue
+311
-0
lightx2v/deploy/server/frontend/src/components/AudioPreviewTest.vue
...eploy/server/frontend/src/components/AudioPreviewTest.vue
+67
-0
lightx2v/deploy/server/frontend/src/components/Confirm.vue
lightx2v/deploy/server/frontend/src/components/Confirm.vue
+70
-0
lightx2v/deploy/server/frontend/src/components/DropdownMenu.vue
...2v/deploy/server/frontend/src/components/DropdownMenu.vue
+96
-0
lightx2v/deploy/server/frontend/src/components/FloatingParticles.vue
...ploy/server/frontend/src/components/FloatingParticles.vue
+73
-0
lightx2v/deploy/server/frontend/src/components/Generate.vue
lightx2v/deploy/server/frontend/src/components/Generate.vue
+3517
-0
lightx2v/deploy/server/frontend/src/components/Inspirations.vue
...2v/deploy/server/frontend/src/components/Inspirations.vue
+289
-0
lightx2v/deploy/server/frontend/src/components/LeftBar.vue
lightx2v/deploy/server/frontend/src/components/LeftBar.vue
+56
-0
lightx2v/deploy/server/frontend/src/components/Loading.vue
lightx2v/deploy/server/frontend/src/components/Loading.vue
+15
-0
lightx2v/deploy/server/frontend/src/components/LoginCard.vue
lightx2v/deploy/server/frontend/src/components/LoginCard.vue
+124
-0
lightx2v/deploy/server/frontend/src/components/MediaTemplate.vue
...v/deploy/server/frontend/src/components/MediaTemplate.vue
+466
-0
lightx2v/deploy/server/frontend/src/components/ModelDropdown.vue
...v/deploy/server/frontend/src/components/ModelDropdown.vue
+57
-0
lightx2v/deploy/server/frontend/src/components/Projects.vue
lightx2v/deploy/server/frontend/src/components/Projects.vue
+609
-0
lightx2v/deploy/server/frontend/src/components/PromptTemplate.vue
.../deploy/server/frontend/src/components/PromptTemplate.vue
+123
-0
No files found.
Too many changes to show.
To preserve performance only
428 of 428+
files are displayed.
Plain diff
Email patch
lightx2v/deploy/server/frontend/public/logo_black.svg
0 → 100644
View file @
a1ebc651
<svg
width=
"765"
height=
"669"
xmlns=
"http://www.w3.org/2000/svg"
xmlns:xlink=
"http://www.w3.org/1999/xlink"
xml:space=
"preserve"
overflow=
"hidden"
><g
transform=
"translate(-2530 -605)"
><g><path
d=
"M3022.72 606.11C3025.74 606.113 3025.74 606.113 3028.82 606.117 3031.12 606.114 3033.42 606.11 3035.79 606.107 3038.33 606.116 3040.88 606.124 3043.5 606.133 3047.5 606.133 3047.5 606.133 3051.57 606.132 3060.42 606.134 3069.27 606.153 3078.12 606.172 3084.24 606.176 3090.36 606.18 3096.48 606.182 3110.96 606.19 3125.45 606.21 3139.94 606.234 3156.43 606.261 3172.92 606.274 3189.41 606.286 3223.34 606.311 3257.26 606.354 3291.19 606.408 3287.04 615.065 3282.01 622.098 3276.09 629.687 3275.04 631.034 3274 632.382 3272.92 633.769 3270.6 636.746 3268.29 639.72 3265.97 642.692 3262 647.781 3258.05 652.885 3254.1 657.992 3239.44 676.934 3224.67 695.764 3209.67 714.436 3201.04 725.206 3192.58 736.099 3184.2 747.068 3176.85 756.678 3169.41 766.208 3161.93 775.711 3152.96 787.105 3144.04 798.535 3135.2 810.024 3133.25 812.553 3131.3 815.081 3129.3 817.687 3125.94 822.137 3122.73 826.7 3119.64 831.339 3174.66 831.339 3229.68 831.339 3286.36 831.339 3282.06 839.942 3279.32 843.707 3272.62 850.083 3264.57 857.934 3257.08 866.031 3249.75 874.552 3244.71 880.373 3239.57 886.09 3234.41 891.804 3227.91 899.013 3221.45 906.249 3215.08 913.571 3206.88 922.992 3198.53 932.262 3190.16 941.536 3185.54 946.692 3180.97 951.883 3176.42 957.106 3168.23 966.527 3159.87 975.797 3151.51 985.072 3146.88 990.227 3142.31 995.418 3137.76 1000.64 3131.39 1007.96 3124.93 1015.2 3118.43 1022.41 3106.79 1035.34 3095.29 1048.39 3084 1061.65 3070.53 1077.46 3056.92 1093.08 3042.88 1108.4 3038.2 1113.54 3033.59 1118.74 3029.04 1123.99 3022.3 1131.75 3015.42 1139.37 3008.5 1146.97 2996.53 1160.12 2984.76 1173.39 2973.29 1186.98 2958.33 1204.68 2942.93 1221.84 2926.96 1238.65 2917.56 1248.55 2908.57 1258.67 2899.77 1269.11 2897.35 1264.27 2897.35 1264.27 2899.39 1258.04 2901.03 1254.06 2901.03 1254.06 2902.71 1250.01 2903.32 1248.55 2903.92 1247.08 2904.54 1245.57 2906.56 1240.66 2908.6 1235.76 2910.64 1230.86 2912.07 1227.41 2913.49 1223.96 2914.91 1220.51 2917.15 1215.04 2919.41 1209.58 2921.66 1204.11 2929.65 1184.77 2937.48 1165.36 2945.31 1145.95 2978.1 1064.69 2978.1 1064.69 2990.83 1037.37 2996.42 1025.28 3000.81 1012.88 3005.1 1000.27 3009.83 987.329 3015.28 974.664 3020.58 961.943 2971.94 961.943 2923.3 961.943 2873.19 961.943 2878.03 947.4 2883.01 933.108 2888.59 918.862 2889.27 917.127 2889.94 915.392 2890.63 913.604 2896.65 898.172 2903.03 882.896 2909.43 867.618 2928.93 821.008 2948.27 774.341 2967.17 727.485 2976.34 704.764 2985.57 682.079 2995.17 659.533 3001.38 644.917 3007.24 630.174 3013.02 615.383 3016.71 606.502 3016.71 606.502 3022.72 606.11Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2706.2 612.344C2709.12 612.342 2712.05 612.34 2715.06 612.338 2723.07 612.346 2731.07 612.407 2739.07 612.492 2747.44 612.569 2755.81 612.576 2764.19 612.59 2780.03 612.628 2795.87 612.729 2811.71 612.852 2829.76 612.989 2847.8 613.056 2865.84 613.117 2902.94 613.245 2940.04 613.463 2977.14 613.733 2972.36 626.655 2967.57 639.578 2962.64 652.891 2925.96 653.118 2889.28 653.293 2852.6 653.399 2835.57 653.449 2818.54 653.518 2801.5 653.63 2785.07 653.738 2768.64 653.795 2752.21 653.82 2745.94 653.838 2739.66 653.874 2733.39 653.926 2724.61 653.998 2715.84 654.008 2707.06 654.003 2704.46 654.038 2701.86 654.073 2699.17 654.109 2681.63 654.011 2681.63 654.011 2672.13 648.453 2665.75 640.869 2664.86 635.872 2665.21 625.97 2675.56 609.983 2689.1 612.072 2706.2 612.344Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2567.49 765.244C2572.21 765.187 2572.21 765.187 2577.03 765.129 2580.45 765.151 2583.86 765.175 2587.28 765.203 2590.8 765.195 2594.33 765.183 2597.85 765.168 2605.23 765.15 2612.6 765.176 2619.97 765.232 2629.41 765.3 2638.84 765.261 2648.28 765.189 2655.55 765.146 2662.82 765.16 2670.1 765.191 2673.58 765.199 2677.05 765.189 2680.53 765.161 2685.4 765.131 2690.27 765.185 2695.13 765.244 2697.9 765.254 2700.66 765.263 2703.51 765.273 2713.25 767.275 2716.93 771.094 2722.99 778.796 2723.29 787.346 2723.29 787.346 2720.58 795.595 2711.61 804.572 2705.51 806.411 2692.94 806.513 2689.84 806.559 2686.74 806.605 2683.55 806.652 2680.18 806.651 2676.82 806.646 2673.46 806.639 2669.99 806.657 2666.52 806.678 2663.05 806.701 2655.79 806.737 2648.53 806.735 2641.27 806.71 2631.98 806.682 2622.69 806.764 2613.4 806.875 2606.24 806.946 2599.08 806.95 2591.92 806.935 2588.49 806.937 2585.07 806.962 2581.64 807.011 2555 807.341 2555 807.341 2546.9 802.088 2542.19 796.699 2539.82 791.552 2538.12 784.646 2542.01 769.555 2553.07 765.294 2567.49 765.244Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2712.13 920.161C2715.21 920.161 2718.3 920.162 2721.48 920.162 2724.84 920.199 2728.2 920.237 2731.56 920.275 2735 920.289 2738.45 920.299 2741.89 920.306 2750.95 920.333 2760 920.404 2769.05 920.484 2778.29 920.558 2787.53 920.59 2796.78 920.627 2814.9 920.704 2833.02 920.827 2851.14 920.978 2847.13 932.985 2843.11 944.991 2839.09 956.998 2820.72 957.22 2802.34 957.391 2783.96 957.495 2775.43 957.545 2766.89 957.614 2758.36 957.722 2748.55 957.847 2738.74 957.893 2728.92 957.936 2725.86 957.985 2722.8 958.035 2719.65 958.086 2715.38 958.087 2715.38 958.087 2711.02 958.088 2708.52 958.109 2706.01 958.131 2703.43 958.153 2695.18 956.7 2692.37 953.84 2687.23 947.392 2685.37 940.287 2686.27 935.705 2687.23 928.182 2694.83 920.197 2701.41 920.098 2712.13 920.161Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2805.67 765.653C2808.76 765.671 2811.86 765.688 2815.06 765.705 2823.27 765.751 2831.47 765.87 2839.68 766.004 2848.07 766.128 2856.46 766.183 2864.85 766.243 2881.29 766.376 2897.72 766.579 2914.16 766.833 2913.69 768.207 2913.22 769.58 2912.74 770.995 2908.88 782.425 2905.28 793.862 2902.06 805.488 2884.01 805.856 2865.96 806.109 2847.91 806.284 2841.77 806.357 2835.63 806.456 2829.5 806.581 2820.67 806.757 2811.85 806.84 2803.02 806.903 2800.27 806.978 2797.53 807.053 2794.7 807.13 2776.14 807.138 2776.14 807.138 2769.33 802.382 2764.56 796.899 2761.88 791.841 2760.16 784.801 2761.85 778.235 2764.24 774.087 2769.17 769.396 2779.97 763.388 2793.68 765.405 2805.67 765.653Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2692.53 997.251C2695.22 997.245 2697.92 997.239 2700.7 997.233 2706.38 997.235 2712.07 997.268 2717.76 997.328 2726.47 997.412 2735.18 997.378 2743.9 997.331 2749.43 997.348 2754.95 997.371 2760.48 997.402 2763.09 997.39 2765.7 997.378 2768.39 997.366 2772.03 997.424 2772.03 997.424 2775.74 997.482 2778.94 997.505 2778.94 997.505 2782.2 997.528 2789.81 999.175 2792.75 1002.52 2797.62 1008.47 2798.99 1013.31 2798.99 1013.31 2798.98 1018.16 2799.03 1019.76 2799.09 1021.36 2799.14 1023.01 2796.62 1031.06 2792.73 1033.19 2785.54 1037.54 2776.49 1039.03 2767.52 1038.96 2758.36 1038.92 2755.72 1038.93 2753.09 1038.95 2750.37 1038.96 2744.81 1038.98 2739.24 1038.97 2733.68 1038.94 2725.17 1038.91 2716.66 1038.99 2708.15 1039.08 2702.74 1039.09 2697.33 1039.08 2691.92 1039.07 2689.37 1039.1 2686.83 1039.13 2684.21 1039.17 2666.61 1038.94 2666.61 1038.94 2657.09 1031.85 2652.65 1025.43 2652.65 1025.43 2651.14 1018.16 2655.78 995.844 2673.84 997.02 2692.53 997.251Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2876.55 997.375C2879.23 997.392 2881.9 997.409 2884.66 997.427 2893.19 997.495 2901.72 997.648 2910.25 997.802 2916.05 997.863 2921.84 997.919 2927.63 997.969 2941.81 998.103 2955.99 998.307 2970.17 998.563 2965.93 1011.87 2961.29 1024.75 2955.61 1037.51 2940.62 1037.87 2925.64 1038.14 2910.65 1038.31 2905.55 1038.39 2900.46 1038.49 2895.36 1038.61 2888.03 1038.79 2880.7 1038.87 2873.37 1038.94 2871.09 1039.01 2868.82 1039.09 2866.48 1039.17 2851.03 1039.17 2851.03 1039.17 2844.66 1034.38 2839.85 1028.81 2836.91 1023.84 2835.17 1016.67 2840.62 995.348 2858.58 996.939 2876.55 997.375Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2791.15 842.436C2795.09 842.456 2795.09 842.456 2799.12 842.477 2807.5 842.531 2815.89 842.653 2824.27 842.776 2829.96 842.824 2835.65 842.868 2841.35 842.908 2855.28 843.014 2869.22 843.176 2883.16 843.38 2878.9 856.593 2874.25 869.379 2868.55 882.047 2853.47 882.286 2838.4 882.461 2823.32 882.578 2818.19 882.627 2813.06 882.693 2807.93 882.776 2800.56 882.893 2793.19 882.948 2785.81 882.991 2782.37 883.066 2782.37 883.066 2778.86 883.142 2762.94 883.148 2762.94 883.148 2755.12 877.457 2751.44 872.018 2750.31 869.21 2750.32 862.713 2750.27 861.118 2750.21 859.523 2750.16 857.88 2755.97 839.546 2775.55 842.13 2791.15 842.436Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2871.29 688.254C2873.4 688.271 2875.52 688.289 2877.7 688.306 2884.43 688.373 2891.15 688.524 2897.88 688.678 2902.46 688.738 2907.03 688.793 2911.6 688.842 2922.79 688.975 2933.98 689.183 2945.17 689.43 2943.52 698.146 2941.3 705.891 2937.96 714.117 2936.7 717.265 2936.7 717.265 2935.41 720.477 2933.16 725.557 2933.16 725.557 2930.76 727.965 2924.64 728.298 2918.59 728.479 2912.47 728.539 2910.63 728.559 2908.79 728.578 2906.9 728.598 2902.99 728.632 2899.09 728.658 2895.19 728.676 2889.23 728.718 2883.27 728.823 2877.3 728.93 2873.52 728.954 2869.73 728.975 2865.94 728.991 2863.27 729.054 2863.27 729.054 2860.54 729.119 2852.23 729.084 2847.55 728.597 2840.77 723.602 2835.31 715.783 2835.62 710.791 2837.09 701.472 2847 687.804 2855.68 687.779 2871.29 688.254Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2759.73 687.731C2762.81 687.745 2765.88 687.759 2769.04 687.774 2794.67 688.115 2794.67 688.115 2802.8 693.658 2808.38 702.148 2808.81 705.554 2807.6 715.543 2803.12 721.142 2799.54 724.487 2793.19 727.701 2783.34 728.382 2773.49 728.464 2763.61 728.552 2760.34 728.592 2757.06 728.659 2753.78 728.752 2749.04 728.885 2744.31 728.93 2739.57 728.974 2736.74 729.022 2733.9 729.07 2730.98 729.12 2723.57 727.701 2723.57 727.701 2717.86 721.998 2713.05 714.015 2712.27 710.146 2713.97 700.953 2724.55 683.74 2741.54 687.256 2759.73 687.731Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2575.39 613.481C2580.44 613.501 2585.48 613.432 2590.53 613.356 2595.33 613.359 2595.33 613.359 2600.23 613.361 2603.15 613.356 2606.07 613.352 2609.08 613.347 2618.5 614.784 2622.54 617.399 2629.1 624.16 2630.47 632.567 2630.47 632.567 2629.1 640.974 2623.61 648.484 2620.67 650.381 2611.34 651.863 2606.63 651.903 2606.63 651.903 2601.83 651.943 2600.14 651.958 2598.46 651.972 2596.72 651.988 2593.17 652.005 2589.61 651.997 2586.06 651.967 2580.63 651.933 2575.21 652.015 2569.78 652.107 2566.32 652.109 2562.86 652.105 2559.4 652.093 2556.26 652.094 2553.12 652.096 2549.89 652.098 2541.82 650.582 2541.82 650.582 2535.88 645.027 2532.12 638.572 2532.12 638.572 2532.12 629.865 2538.23 609.439 2557.26 613.369 2575.39 613.481Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/><path
d=
"M2896.04 1075.19C2899.73 1075.23 2899.73 1075.23 2903.49 1075.26 2907.31 1075.36 2907.31 1075.36 2911.21 1075.45 2915.09 1075.5 2915.09 1075.5 2919.05 1075.55 2925.42 1075.63 2931.8 1075.75 2938.17 1075.9 2934.18 1087.94 2930.18 1099.98 2926.19 1112.02 2918.8 1112.38 2911.41 1112.59 2904.02 1112.78 2901.93 1112.88 2899.84 1112.98 2897.68 1113.08 2882.19 1113.38 2882.19 1113.38 2874.4 1106.5 2869.84 1097.58 2870.66 1092.63 2873.46 1083.12 2880.75 1075.93 2886.01 1075.03 2896.04 1075.19Z"
fill=
"#000000"
fill-rule=
"evenodd"
fill-opacity=
"1"
/></g></g></svg>
lightx2v/deploy/server/frontend/public/male.svg
0 → 100644
View file @
a1ebc651
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer -->
<svg
id=
"svg"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
style=
"display: block;"
viewBox=
"0 0 512 512"
><path
d=
"M0 0 C7.6294558 3.09666147 12.63201091 10.6173878 15.9921875 17.80859375 C21.26713269 32.83857458 18.08561228 49.98608669 11.8046875 64.18359375 C9.81965511 67.93091967 7.64875194 69.93432306 4.33984375 72.53515625 C0.76896185 76.04289737 -0.55171048 78.54659249 -0.78697205 83.59750366 C-0.77699253 87.34474366 -0.60896185 91.0687976 -0.3828125 94.80859375 C-0.24078821 97.80796518 -0.12625106 100.80737557 -0.03125 103.80859375 C-0.0066571 104.5466626 0.01793579 105.28473145 0.04327393 106.04516602 C0.20035009 114.1076769 -0.20930757 124.51262885 -4.03916931 131.76036072 C-5.10816253 134.02078772 -5.29543842 135.569866 -5.37084961 138.05688477 C-5.40119293 138.89135132 -5.43153625 139.72581787 -5.46279907 140.58557129 C-5.48667694 141.48545776 -5.51055481 142.38534424 -5.53515625 143.3125 C-5.60369748 145.25725078 -5.67247291 147.20199332 -5.74145508 149.14672852 C-5.77429596 150.1635672 -5.80713684 151.18040588 -5.8409729 152.22805786 C-8.01678864 217.77045009 -8.01678864 217.77045009 -20.0078125 238.80859375 C-20.45898438 239.62585938 -20.91015625 240.443125 -21.375 241.28515625 C-26.31160323 249.47724917 -32.68126469 253.01933247 -41.84375 255.31640625 C-47.91282146 256.26048403 -53.87225249 255.97746237 -60.0078125 255.80859375 C-59.6778125 265.04859375 -59.3478125 274.28859375 -59.0078125 283.80859375 C-57.0278125 283.47859375 -55.0478125 283.14859375 -53.0078125 282.80859375 C-50.85613184 285.90874489 -48.71249257 289.01437187 -46.5703125 292.12109375 C-45.95865234 293.00216797 -45.34699219 293.88324219 -44.71679688 294.79101562 C-44.13349609 295.63857422 -43.55019531 296.48613281 -42.94921875 297.359375 C-42.40982666 298.13966064 -41.87043457 298.91994629 -41.31469727 299.72387695 C-40.0078125 301.80859375 -40.0078125 301.80859375 -39.0078125 304.80859375 C-37.06959129 305.54414516 -37.06959129 305.54414516 -34.7578125 305.87109375 C-32.18788131 306.35925964 -29.99666818 306.81316578 -27.5703125 307.80859375 C-25.16410341 308.74760217 -23.1207055 309.16273998 -20.5859375 309.58984375 C-14.48508465 310.78636973 -8.93219474 312.95299309 -3.1953125 315.30859375 C-2.10581299 315.75219238 -1.01631348 316.19579102 0.10620117 316.65283203 C21.50238225 325.46916106 43.49901313 335.33888358 60.9921875 350.80859375 C61.765625 351.44023437 62.5390625 352.071875 63.3359375 352.72265625 C72.30864585 360.22982224 77.58781487 369.06984251 81.9921875 379.80859375 C82.58580078 381.08283203 82.58580078 381.08283203 83.19140625 382.3828125 C84.09881494 385.13159686 84.11496717 386.17086734 82.9921875 388.80859375 C81.64453125 390.6328125 81.64453125 390.6328125 79.9296875 392.49609375 C79.29804688 393.18960937 78.66640625 393.883125 78.015625 394.59765625 C77.01402344 395.69207031 77.01402344 395.69207031 75.9921875 396.80859375 C75.02152344 397.93394531 75.02152344 397.93394531 74.03125 399.08203125 C70.29434291 403.40975369 66.45878282 407.33625 62.0859375 411.01953125 C60.6587794 412.2390059 59.26347271 413.49652227 57.8984375 414.78515625 C44.82652604 427.09603177 29.51334841 436.89054274 13.9921875 445.80859375 C13.25983887 446.23237305 12.52749023 446.65615234 11.77294922 447.09277344 C-11.65985086 460.49030053 -37.35401596 468.86368051 -63.7578125 474.05859375 C-64.90697144 474.28554932 -64.90697144 474.28554932 -66.0793457 474.51708984 C-81.00546475 477.26137474 -95.91803816 478.18954152 -111.0703125 478.12109375 C-111.91609863 478.11927094 -112.76188477 478.11744812 -113.63330078 478.11557007 C-127.9283677 478.06400274 -141.90288008 477.27862754 -156.0078125 474.80859375 C-157.38718994 474.5751123 -157.38718994 474.5751123 -158.79443359 474.33691406 C-198.77205778 467.32034734 -237.25464558 450.05420757 -269.0078125 424.80859375 C-269.9153125 424.09316406 -270.8228125 423.37773437 -271.7578125 422.640625 C-281.62655972 414.72039463 -290.98169118 406.15683226 -299.79296875 397.07421875 C-301.17192382 395.66364742 -302.59143438 394.29031094 -304.06640625 392.98046875 C-305.83137039 391.29805304 -307.01945864 390.03835826 -308.0078125 387.80859375 C-307.84618862 384.11710438 -306.52141111 381.14517434 -305.0078125 377.80859375 C-304.39292969 376.34486328 -304.39292969 376.34486328 -303.765625 374.8515625 C-297.20874732 360.42272716 -284.97030781 349.77258402 -271.6328125 341.62109375 C-270.73780029 341.07283936 -269.84278809 340.52458496 -268.9206543 339.9597168 C-253.28084814 330.59557797 -236.69392804 323.03070876 -219.6953125 316.49609375 C-218.62547119 316.08254639 -217.55562988 315.66899902 -216.45336914 315.24291992 C-211.33983648 313.30637184 -206.25725636 311.55458272 -200.98046875 310.1171875 C-187.03916496 306.12926893 -181.040389 300.51870463 -173.6875 288 C-173.26194824 287.21826416 -172.83639648 286.43652832 -172.39794922 285.63110352 C-171.0078125 283.80859375 -171.0078125 283.80859375 -167.0078125 282.80859375 C-167.0078125 251.12859375 -167.0078125 219.44859375 -167.0078125 186.80859375 C-168.9878125 186.80859375 -170.9678125 186.80859375 -173.0078125 186.80859375 C-185.2969042 179.65857676 -188.74289424 160.46960239 -192.23046875 147.6953125 C-206.82294413 92.06405484 -206.82294413 92.06405484 -195.37109375 69.59765625 C-193.85228289 67.00780708 -193.85228289 67.00780708 -194.0078125 64.68359375 C-196.5004182 60.00995805 -201.73912639 57.77367275 -206.3203125 55.55859375 C-207.38894531 55.0378125 -208.45757812 54.51703125 -209.55859375 53.98046875 C-210.36683594 53.59375 -211.17507812 53.20703125 -212.0078125 52.80859375 C-208.41295732 49.21373857 -204.29455785 49.62393848 -199.3828125 49.55859375 C-193.11057597 49.58483742 -187.84153425 50.55037888 -182.0078125 52.80859375 C-183.95650417 47.30640551 -186.56659995 43.73939538 -190.58203125 39.5078125 C-192.0078125 37.80859375 -192.0078125 37.80859375 -192.0078125 35.80859375 C-177.71781228 35.11434273 -177.71781228 35.11434273 -171.5078125 38.30859375 C-167.84822711 39.82585251 -166.58836638 40.03480217 -162.83984375 38.57421875 C-161.01429577 37.57492774 -159.24029709 36.509939 -157.4621582 35.42871094 C-134.32869732 21.59603787 -104.2210965 18.22963215 -77.859375 16.1328125 C-39.71718431 13.25459139 -39.71718431 13.25459139 -3.87670898 0.69970703 C-2.0078125 -0.19140625 -2.0078125 -0.19140625 0 0 Z "
transform=
"translate(368.0078125,34.19140625)"
style=
"fill: #FE937D;"
/><path
d=
"M0 0 C0.56041992 0.57347168 1.12083984 1.14694336 1.69824219 1.73779297 C4.45548105 4.44765459 7.50647436 6.27578477 10.828125 8.1953125 C11.82132751 8.77955238 11.82132751 8.77955238 12.83459473 9.37559509 C14.97001579 10.63067505 17.11001001 11.87772968 19.25 13.125 C22.1580445 14.83159109 25.06350882 16.54254828 27.96875 18.25390625 C28.67489441 18.6692009 29.38103882 19.08449554 30.10858154 19.51237488 C35.99919677 22.98281653 41.8204865 26.55343131 47.58300781 30.23291016 C50.32692655 31.98132674 53.05147931 33.6187755 56 35 C56.20625 34.22914063 56.4125 33.45828125 56.625 32.6640625 C58.53616998 27.57122885 61.25624862 23.0600849 64 18.375 C65.08420838 16.50832047 66.16757479 14.64115159 67.25 12.7734375 C67.72953125 11.95278809 68.2090625 11.13213867 68.703125 10.28662109 C69.87510448 8.220213 70.96069333 6.13560363 72 4 C72.66 4 73.32 4 74 4 C74.4439209 4.80219971 74.8878418 5.60439941 75.34521484 6.4309082 C76.99539098 9.41146057 78.64671868 12.39136875 80.29882812 15.37084961 C81.01315724 16.65965619 81.72702857 17.94871662 82.44042969 19.23803711 C83.46689056 21.0929577 84.49500304 22.94695433 85.5234375 24.80078125 C86.14138184 25.91622314 86.75932617 27.03166504 87.39599609 28.1809082 C88.52431536 30.16396796 89.73440432 32.10160648 91 34 C94.60061056 32.6544027 97.79285589 31.04189996 101.078125 29.05078125 C102.55551147 28.15975708 102.55551147 28.15975708 104.06274414 27.25073242 C105.11453857 26.61111572 106.16633301 25.97149902 107.25 25.3125 C109.48283976 23.96055234 111.71592021 22.60900216 113.94921875 21.2578125 C114.80917702 20.73658058 114.80917702 20.73658058 115.68650818 20.20481873 C119.84329638 17.68850019 124.02054978 15.20871956 128.20703125 12.7421875 C129.36764383 12.05580382 130.52812767 11.36920241 131.68847656 10.68237305 C133.91508856 9.36590079 136.14485383 8.05474543 138.37792969 6.74926758 C139.37856445 6.15525146 140.37919922 5.56123535 141.41015625 4.94921875 C142.29840088 4.42674561 143.18664551 3.90427246 144.10180664 3.3659668 C144.72821045 2.91519775 145.35461426 2.46442871 146 2 C146 1.34 146 0.68 146 0 C154.2365416 1.6867863 154.2365416 1.6867863 157.4375 3 C159.84370909 3.93900842 161.887107 4.35414623 164.421875 4.78125 C170.52272785 5.97777598 176.07561776 8.14439934 181.8125 10.5 C182.90199951 10.94359863 183.99149902 11.38719727 185.11401367 11.84423828 C206.51019475 20.66056731 228.50682563 30.53028983 246 46 C246.7734375 46.63164062 247.546875 47.26328125 248.34375 47.9140625 C257.31645835 55.42122849 262.59562737 64.26124876 267 75 C267.39574219 75.84949219 267.79148437 76.69898438 268.19921875 77.57421875 C269.10662744 80.32300311 269.12277967 81.36227359 268 84 C266.65234375 85.82421875 266.65234375 85.82421875 264.9375 87.6875 C264.30585938 88.38101563 263.67421875 89.07453125 263.0234375 89.7890625 C262.35570312 90.51867187 261.68796875 91.24828125 261 92 C260.02933594 93.12535156 260.02933594 93.12535156 259.0390625 94.2734375 C255.30215541 98.60115994 251.46659532 102.52765625 247.09375 106.2109375 C245.6665919 107.43041215 244.27128521 108.68792852 242.90625 109.9765625 C229.83433854 122.28743802 214.52116091 132.08194899 199 141 C198.26765137 141.4237793 197.53530273 141.84755859 196.78076172 142.28417969 C173.34796164 155.68170678 147.65379654 164.05508676 121.25 169.25 C120.10084106 169.47695557 120.10084106 169.47695557 118.9284668 169.70849609 C104.00234775 172.45278099 89.08977434 173.38094777 73.9375 173.3125 C73.09171387 173.31067719 72.24592773 173.30885437 71.37451172 173.30697632 C57.0794448 173.25540899 43.10493242 172.47003379 29 170 C28.08041504 169.8443457 27.16083008 169.68869141 26.21337891 169.52832031 C-13.76424528 162.51175359 -52.24683308 145.24561382 -84 120 C-84.9075 119.28457031 -85.815 118.56914062 -86.75 117.83203125 C-96.61874722 109.91180088 -105.97387868 101.34823851 -114.78515625 92.265625 C-116.16411132 90.85505367 -117.58362188 89.48171719 -119.05859375 88.171875 C-120.82355789 86.48945929 -122.01164614 85.22976451 -123 83 C-122.83837612 79.30851063 -121.51359861 76.33658059 -120 73 C-119.59007812 72.02417969 -119.18015625 71.04835938 -118.7578125 70.04296875 C-112.20093482 55.61413341 -99.96249531 44.96399027 -86.625 36.8125 C-85.28248169 35.99011841 -85.28248169 35.99011841 -83.9128418 35.15112305 C-64.35489312 23.44104527 -43.39269722 14.72694514 -22 7 C-20.99565918 6.63583984 -19.99131836 6.27167969 -18.95654297 5.89648438 C-2.61724463 0 -2.61724463 0 0 0 Z "
transform=
"translate(183,339)"
style=
"fill: #3C3277;"
/><path
d=
"M0 0 C7.6294558 3.09666147 12.63201091 10.6173878 15.9921875 17.80859375 C21.26713269 32.83857458 18.08561228 49.98608669 11.8046875 64.18359375 C9.81965511 67.93091967 7.64875194 69.93432306 4.33984375 72.53515625 C0.76896185 76.04289737 -0.55171048 78.54659249 -0.78697205 83.59750366 C-0.77699253 87.34474366 -0.60896185 91.0687976 -0.3828125 94.80859375 C-0.24078829 97.80796347 -0.12591492 100.8073652 -0.03125 103.80859375 C-0.0073822 104.5473877 0.0164856 105.28618164 0.04107666 106.04736328 C0.14339613 111.51750803 -0.36276036 116.73305672 -1.296875 122.12109375 C-1.41606293 122.82041016 -1.53525085 123.51972656 -1.65805054 124.24023438 C-2.44375839 128.28508538 -3.50569325 131.51262944 -6.0078125 134.80859375 C-6.9978125 135.13859375 -7.9878125 135.46859375 -9.0078125 135.80859375 C-9.72291425 129.33105955 -10.4346495 122.85316182 -11.14404297 116.375 C-11.38577982 114.17113868 -11.62828787 111.96736182 -11.87158203 109.76367188 C-12.22110419 106.59657311 -12.56785397 103.42918124 -12.9140625 100.26171875 C-13.07811584 98.78339569 -13.07811584 98.78339569 -13.2454834 97.27520752 C-13.34558716 96.35455505 -13.44569092 95.43390259 -13.54882812 94.48535156 C-13.63765259 93.67704895 -13.72647705 92.86874634 -13.81799316 92.03594971 C-13.99433832 89.96670091 -14.0078125 87.88534318 -14.0078125 85.80859375 C-14.80960938 86.17210938 -15.61140625 86.535625 -16.4375 86.91015625 C-41.17851078 97.85005897 -66.24906794 102.47408703 -93.0078125 105.80859375 C-93.89597656 105.92114502 -94.78414063 106.03369629 -95.69921875 106.1496582 C-101.18858776 106.75148099 -106.61529084 106.84647879 -112.1328125 106.765625 C-113.21254333 106.75048103 -113.21254333 106.75048103 -114.31408691 106.73503113 C-117.29508138 106.6893461 -120.27552982 106.64041876 -123.25585938 106.56176758 C-131.71745162 106.35022777 -139.0844477 106.47391109 -147.00390625 109.94018555 C-149.0078125 110.80859375 -149.0078125 110.80859375 -151.0078125 110.80859375 C-149.09753509 132.60979584 -149.09753509 132.60979584 -142.88671875 153.4765625 C-142.0078125 155.80859375 -142.0078125 155.80859375 -142.0078125 158.80859375 C-145.6378125 158.80859375 -149.2678125 158.80859375 -153.0078125 158.80859375 C-153.15476562 157.34099609 -153.15476562 157.34099609 -153.3046875 155.84375 C-154.17872586 148.18084476 -155.42102249 141.70268364 -159.0078125 134.80859375 C-159.3378125 133.81859375 -159.6678125 132.82859375 -160.0078125 131.80859375 C-165.11697767 129.14437243 -170.33587741 128.44655534 -176.0078125 128.80859375 C-180.82450777 136.42929377 -182.23343646 142.85752039 -181.0078125 151.80859375 C-178.73981295 161.49440835 -174.64020053 169.95172699 -169.77734375 178.56640625 C-168.29490754 181.28256973 -167.07415109 183.91034015 -166.0078125 186.80859375 C-170.12765096 187.31306377 -173.11743775 187.22260208 -176.5078125 184.80859375 C-194.24070316 167.62985592 -200.85095076 122.34965098 -201.68847656 98.32861328 C-201.71413796 87.93024757 -200.13749678 78.9484517 -195.37109375 69.59765625 C-193.85228289 67.00780708 -193.85228289 67.00780708 -194.0078125 64.68359375 C-196.5004182 60.00995805 -201.73912639 57.77367275 -206.3203125 55.55859375 C-207.38894531 55.0378125 -208.45757812 54.51703125 -209.55859375 53.98046875 C-210.36683594 53.59375 -211.17507812 53.20703125 -212.0078125 52.80859375 C-208.41295732 49.21373857 -204.29455785 49.62393848 -199.3828125 49.55859375 C-193.11057597 49.58483742 -187.84153425 50.55037888 -182.0078125 52.80859375 C-183.95650417 47.30640551 -186.56659995 43.73939538 -190.58203125 39.5078125 C-192.0078125 37.80859375 -192.0078125 37.80859375 -192.0078125 35.80859375 C-177.71781228 35.11434273 -177.71781228 35.11434273 -171.5078125 38.30859375 C-167.84822711 39.82585251 -166.58836638 40.03480217 -162.83984375 38.57421875 C-161.01429577 37.57492774 -159.24029709 36.509939 -157.4621582 35.42871094 C-134.32869732 21.59603787 -104.2210965 18.22963215 -77.859375 16.1328125 C-39.71718431 13.25459139 -39.71718431 13.25459139 -3.87670898 0.69970703 C-2.0078125 -0.19140625 -2.0078125 -0.19140625 0 0 Z "
transform=
"translate(368.0078125,34.19140625)"
style=
"fill: #3C3276;"
/><path
d=
"M0 0 C5.92863717 1.16651426 11.38798394 2.75519358 17 5 C15.6884381 7.80169326 14.18231361 10.10868981 12.3125 12.5625 C2.96635094 26.66092824 -0.09747763 44.50743544 -4.1484375 60.69140625 C-5.9008549 67.68057454 -7.84355625 74.587537 -9.9375 81.48046875 C-10.92944289 84.76627957 -11.84457316 88.06453313 -12.75 91.375 C-30.0629353 151.83065309 -30.0629353 151.83065309 -51 169 C-51.33 169 -51.66 169 -52 169 C-53.24270122 150.41542324 -54.27708077 131.83046383 -55.1234436 113.22366333 C-55.64376126 101.825002 -56.26675897 90.44531515 -57.0625 79.0625 C-58.22097775 62.39068817 -58.63127637 45.70497912 -59 29 C-57.68 30.65 -56.36 32.3 -55 34 C-51.39938944 32.6544027 -48.20714411 31.04189996 -44.921875 29.05078125 C-43.44448853 28.15975708 -43.44448853 28.15975708 -41.93725586 27.25073242 C-40.88546143 26.61111572 -39.83366699 25.97149902 -38.75 25.3125 C-36.51716024 23.96055234 -34.28407979 22.60900216 -32.05078125 21.2578125 C-31.47747574 20.91032455 -30.90417023 20.56283661 -30.31349182 20.20481873 C-26.15670362 17.68850019 -21.97945022 15.20871956 -17.79296875 12.7421875 C-16.63235617 12.05580382 -15.47187233 11.36920241 -14.31152344 10.68237305 C-12.08491144 9.36590079 -9.85514617 8.05474543 -7.62207031 6.74926758 C-6.62143555 6.15525146 -5.62080078 5.56123535 -4.58984375 4.94921875 C-3.70159912 4.42674561 -2.81335449 3.90427246 -1.89819336 3.3659668 C-1.27178955 2.91519775 -0.64538574 2.46442871 0 2 C0 1.34 0 0.68 0 0 Z "
transform=
"translate(329,339)"
style=
"fill: #6385EE;"
/><path
d=
"M0 0 C0.56041992 0.57347168 1.12083984 1.14694336 1.69824219 1.73779297 C4.45548105 4.44765459 7.50647436 6.27578477 10.828125 8.1953125 C11.82132751 8.77955238 11.82132751 8.77955238 12.83459473 9.37559509 C14.97001579 10.63067505 17.11001001 11.87772968 19.25 13.125 C22.1580445 14.83159109 25.06350882 16.54254828 27.96875 18.25390625 C29.02796661 18.87684822 29.02796661 18.87684822 30.10858154 19.51237488 C35.99919677 22.98281653 41.8204865 26.55343131 47.58300781 30.23291016 C50.32692655 31.98132674 53.05147931 33.6187755 56 35 C56.99 32.03 56.99 32.03 58 29 C58.33 29 58.66 29 59 29 C59.45598368 45.49985376 58.61075696 61.79764037 57.48672485 78.25149536 C56.60245841 91.20844955 55.80689138 104.1653971 55.16796875 117.13671875 C55.11761128 118.15815192 55.11761128 118.15815192 55.0662365 119.20022011 C54.7663603 125.30403125 54.4725969 131.40812566 54.18661499 137.51260376 C54.06609193 140.08270258 53.94442471 142.65274807 53.82148743 145.22273254 C53.70940021 147.56812379 53.6000814 149.91364965 53.49406433 152.25932312 C53.23937838 157.56798981 52.87917924 162.75669087 52 168 C35.72347099 152.81546976 29.08622222 116.25487446 23.48046875 95.390625 C21.96015244 89.75461617 20.13747682 84.27517565 18.23046875 78.7578125 C17.03686696 75.11259054 15.99202115 71.45468084 14.9753418 67.75756836 C6.84572156 35.78270784 6.84572156 35.78270784 -10 8 C-13.62473109 6.64164329 -13.62473109 6.64164329 -17 6 C-13.9666221 3.97774806 -11.38655511 3.00652476 -7.9375 1.875 C-6.87402344 1.52179688 -5.81054688 1.16859375 -4.71484375 0.8046875 C-2 0 -2 0 0 0 Z "
transform=
"translate(183,339)"
style=
"fill: #6385EE;"
/><path
d=
"M0 0 C6.6503775 2.15290358 13.19422364 4.53613703 19.73046875 7.01171875 C21.76999456 7.77719309 23.8097139 8.54215195 25.84960938 9.30664062 C29.03107712 10.50015292 32.21132039 11.69653507 35.38842773 12.90161133 C38.47829564 14.07235093 41.57261645 15.23057102 44.66796875 16.38671875 C45.6149614 16.74916183 46.56195404 17.11160492 47.53764343 17.48503113 C57.15906136 21.05393985 62.7147018 19.69324669 72.1640625 16.1328125 C73.23369232 15.73802704 74.30332214 15.34324158 75.40536499 14.93649292 C78.81534548 13.67504563 82.22004275 12.39993526 85.625 11.125 C87.92416846 10.27389976 90.22364449 9.42362992 92.5234375 8.57421875 C94.71642231 7.76116199 96.90913999 6.94738428 99.1015625 6.1328125 C100.13391022 5.74940704 101.16625793 5.36600159 102.22988892 4.97097778 C103.19123749 4.61075531 104.15258606 4.25053284 105.14306641 3.87939453 C105.98656647 3.56406769 106.83006653 3.24874084 107.6991272 2.92385864 C110.00469787 2.04436095 110.00469787 2.04436095 112.11605835 0.88253784 C114 0 114 0 117 0 C118.50219727 1.66479492 118.50219727 1.66479492 120.06640625 3.98046875 C120.63423828 4.81513672 121.20207031 5.64980469 121.78710938 6.50976562 C122.37298828 7.39341797 122.95886719 8.27707031 123.5625 9.1875 C124.44711914 10.48397461 124.44711914 10.48397461 125.34960938 11.80664062 C128.03440851 15.79260521 130.32710701 19.47343663 132 24 C131.2879541 24.43119141 130.5759082 24.86238281 129.84228516 25.30664062 C127.06624709 26.98833456 124.29081639 28.67102477 121.51559448 30.35406494 C119.62304825 31.501183 117.7296294 32.64685124 115.83618164 33.79248047 C109.34640535 37.72378578 102.87977748 41.6865544 96.45703125 45.7265625 C94.68607958 46.835569 92.91479061 47.94403534 91.14349365 49.05249023 C89.45451034 50.11430608 87.77237432 51.18698826 86.09033203 52.25976562 C85.0692334 52.89849609 84.04813477 53.53722656 82.99609375 54.1953125 C82.10075928 54.76266113 81.2054248 55.33000977 80.28295898 55.91455078 C78 57 78 57 75 56 C73.40600586 53.67089844 73.40600586 53.67089844 71.71484375 50.609375 C71.10189453 49.50722656 70.48894531 48.40507812 69.85742188 47.26953125 C69.54216553 46.69154785 69.22690918 46.11356445 68.90209961 45.51806641 C67.94619502 43.76593757 66.97608142 42.0215852 66.00585938 40.27734375 C63.41546981 35.58789247 60.89282716 31.02068231 59 26 C58.34 26 57.68 26 57 26 C56.73719238 26.86512207 56.47438477 27.73024414 56.20361328 28.62158203 C54.99425254 32.01613254 53.51790329 34.92297055 51.7421875 38.0546875 C50.80439453 39.71757812 50.80439453 39.71757812 49.84765625 41.4140625 C49.19667969 42.55617188 48.54570312 43.69828125 47.875 44.875 C47.20915523 46.05181177 46.54378604 47.22889276 45.87890625 48.40625 C44.25725735 51.27351326 42.63082801 54.13795084 41 57 C37.25149374 55.84297916 34.00817399 54.59433167 30.69921875 52.48828125 C29.89863037 51.98474121 29.09804199 51.48120117 28.27319336 50.96240234 C27.41991455 50.41793457 26.56663574 49.8734668 25.6875 49.3125 C23.82921578 48.14279655 21.96978809 46.97490825 20.109375 45.80859375 C19.12646484 45.19226074 18.14355469 44.57592773 17.13085938 43.94091797 C10.53359386 39.85106606 3.85056891 35.90158153 -2.82348633 31.9387207 C-3.8317749 31.33761475 -4.84006348 30.73650879 -5.87890625 30.1171875 C-6.7716626 29.58722168 -7.66441895 29.05725586 -8.58422852 28.51123047 C-10.82253579 27.11101602 -12.91620899 25.61894299 -15 24 C-14.36791079 20.588948 -13.06208716 18.32684002 -11.09375 15.48828125 C-10.22556641 14.22854492 -10.22556641 14.22854492 -9.33984375 12.94335938 C-8.73269531 12.07517578 -8.12554687 11.20699219 -7.5 10.3125 C-6.89285156 9.43271484 -6.28570312 8.55292969 -5.66015625 7.64648438 C-1.1896734 1.1896734 -1.1896734 1.1896734 0 0 Z "
transform=
"translate(198,317)"
style=
"fill: #F9E2DB;"
/><path
d=
"M0 0 C3.90520527 1.47257278 7.50274954 3.29536534 11.1875 5.25 C26.29002991 12.97073965 42.30836492 16.6154807 59 19 C59 25.93 59 32.86 59 40 C41.38871408 36.08638091 26.39254307 28.01838963 13 16 C12.33226562 15.40316406 11.66453125 14.80632812 10.9765625 14.19140625 C6.51596817 9.97429414 3.22267306 5.20585648 0 0 Z "
transform=
"translate(249,270)"
style=
"fill: #41203D;"
/><path
d=
"M0 0 C0.66 0 1.32 0 2 0 C4.1681511 3.65389428 6.30530193 7.32502825 8.4375 11 C9.04916016 12.02996094 9.66082031 13.05992187 10.29101562 14.12109375 C13.62610766 19.90683352 15.82502997 24.29281554 16 31 C16.03222656 31.9796875 16.06445312 32.959375 16.09765625 33.96875 C16.08605469 34.8040625 16.07445312 35.639375 16.0625 36.5 C16.05347656 37.3559375 16.04445313 38.211875 16.03515625 39.09375 C16.02355469 39.7228125 16.01195312 40.351875 16 41 C12.29102741 39.60582632 8.92161688 37.92181521 5.48046875 35.9609375 C4.45888672 35.38085938 3.43730469 34.80078125 2.38476562 34.203125 C1.32966797 33.59984375 0.27457031 32.9965625 -0.8125 32.375 C-1.88693359 31.76398438 -2.96136719 31.15296875 -4.06835938 30.5234375 C-6.71386076 29.01844115 -9.3576625 27.51054209 -12 26 C-11.46615777 21.81956382 -10.56650747 18.70393276 -8.49609375 15.0390625 C-8.00302734 14.15605469 -7.50996094 13.27304687 -7.00195312 12.36328125 C-6.48568359 11.45964844 -5.96941406 10.55601562 -5.4375 9.625 C-4.91736328 8.69816406 -4.39722656 7.77132813 -3.86132812 6.81640625 C-2.58212166 4.53965346 -1.29487762 2.26786477 0 0 Z "
transform=
"translate(255,343)"
style=
"fill: #241D4B;"
/></svg>
lightx2v/deploy/server/frontend/public/robots.txt
0 → 100644
View file @
a1ebc651
User-agent: *
Allow: /
Sitemap: https://x2v.light-ai.top/sitemap.xml
lightx2v/deploy/server/frontend/public/sitemap.xml
0 → 100644
View file @
a1ebc651
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns=
"http://www.sitemaps.org/schemas/sitemap/0.9"
>
<url>
<loc>
https://x2v.light-ai.top/
</loc>
<changefreq>
daily
</changefreq>
<priority>
1.0
</priority>
</url>
<url>
<loc>
https://x2v.light-ai.top/examples
</loc>
</url>
<url>
<loc>
https://x2v.light-ai.top/docs
</loc>
</url>
</urlset>
lightx2v/deploy/server/frontend/public/vite.svg
0 → 100644
View file @
a1ebc651
<svg
xmlns=
"http://www.w3.org/2000/svg"
xmlns:xlink=
"http://www.w3.org/1999/xlink"
aria-hidden=
"true"
role=
"img"
class=
"iconify iconify--logos"
width=
"31.88"
height=
"32"
preserveAspectRatio=
"xMidYMid meet"
viewBox=
"0 0 256 257"
><defs><linearGradient
id=
"IconifyId1813088fe1fbc01fb466"
x1=
"-.828%"
x2=
"57.636%"
y1=
"7.652%"
y2=
"78.411%"
><stop
offset=
"0%"
stop-color=
"#41D1FF"
></stop><stop
offset=
"100%"
stop-color=
"#BD34FE"
></stop></linearGradient><linearGradient
id=
"IconifyId1813088fe1fbc01fb467"
x1=
"43.376%"
x2=
"50.316%"
y1=
"2.242%"
y2=
"89.03%"
><stop
offset=
"0%"
stop-color=
"#FFEA83"
></stop><stop
offset=
"8.333%"
stop-color=
"#FFDD35"
></stop><stop
offset=
"100%"
stop-color=
"#FFA800"
></stop></linearGradient></defs><path
fill=
"url(#IconifyId1813088fe1fbc01fb466)"
d=
"M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"
></path><path
fill=
"url(#IconifyId1813088fe1fbc01fb467)"
d=
"M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"
></path></svg>
lightx2v/deploy/server/frontend/src/App.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
onMounted
,
onUnmounted
,
ref
}
from
'
vue
'
import
router
from
'
./router
'
import
{
init
,
handleLoginCallback
,
handleClickOutside
,
validateToken
}
from
'
./utils/other
'
import
{
initLanguage
}
from
'
./utils/i18n
'
import
{
startHintRotation
,
stopHintRotation
}
from
'
./utils/other
'
import
{
currentUser
,
isLoading
,
applyMobileStyles
,
isLoggedIn
,
loginLoading
,
initLoading
,
pollingInterval
,
pollingTasks
,
showAlert
,
logout
,
login
}
from
'
./utils/other
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Loading
from
'
./components/Loading.vue
'
const
{
t
,
locale
}
=
useI18n
()
let
source
=
null
// 页面加载时应用移动端样式
onMounted
(()
=>
{
applyMobileStyles
();
window
.
addEventListener
(
'
resize
'
,
applyMobileStyles
);
});
// 组件卸载时移除事件监听器
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'
resize
'
,
applyMobileStyles
);
});
// 生命周期:页面加载
onMounted
(
async
()
=>
{
// 1. 初始化语言
isLoading
.
value
=
true
await
initLanguage
()
initLoading
.
value
=
true
// 2. 启动提示滚动
startHintRotation
()
// 3. 添加全局点击事件监听器
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
try
{
// 检查是否有登录回调参数
const
urlParams
=
new
URLSearchParams
(
window
.
location
.
search
)
const
code
=
urlParams
.
get
(
'
code
'
)
if
(
code
)
{
// 处理登录回调
isLoading
.
value
=
true
source
=
localStorage
.
getItem
(
'
loginSource
'
)
await
handleLoginCallback
(
code
,
source
)
return
}
// 检查本地存储的登录状态
const
savedToken
=
localStorage
.
getItem
(
'
accessToken
'
)
const
savedUser
=
localStorage
.
getItem
(
'
currentUser
'
)
if
(
savedToken
&&
savedUser
)
{
// 验证token是否过期
const
isValidToken
=
await
validateToken
(
savedToken
)
if
(
isValidToken
)
{
currentUser
.
value
=
JSON
.
parse
(
savedUser
)
isLoggedIn
.
value
=
true
await
init
();
console
.
log
(
'
用户已登录,初始化完成
'
)
}
else
{
// Token已过期,清除本地存储
localStorage
.
removeItem
(
'
accessToken
'
)
localStorage
.
removeItem
(
'
currentUser
'
)
isLoggedIn
.
value
=
false
console
.
log
(
'
Token已过期
'
)
showAlert
(
t
(
'
pleaseRelogin
'
),
'
warning
'
,
{
label
:
t
(
'
login
'
),
onClick
:
login
})
}
}
else
{
isLoggedIn
.
value
=
false
console
.
log
(
'
用户未登录
'
)
}
}
catch
(
error
)
{
console
.
error
(
'
初始化失败
'
,
error
)
showAlert
(
t
(
'
initFailedPleaseRefresh
'
),
'
danger
'
)
isLoggedIn
.
value
=
false
}
finally
{
loginLoading
.
value
=
false
initLoading
.
value
=
false
isLoading
.
value
=
false
}
// 6. 移动端样式适配
applyMobileStyles
()
window
.
addEventListener
(
'
resize
'
,
applyMobileStyles
)
})
// 生命周期:页面卸载
onUnmounted
(()
=>
{
// 清理轮询
if
(
pollingInterval
.
value
)
clearInterval
(
pollingInterval
.
value
)
pollingTasks
.
value
.
clear
()
// 清理提示滚动
stopHintRotation
()
// 移除事件监听器
window
.
removeEventListener
(
'
resize
'
,
applyMobileStyles
)
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
})
</
script
>
<
template
>
<router-view></router-view>
<!-- 全局路由跳转Loading覆盖层 -->
<div
v-show=
"isLoading"
class=
"fixed inset-0 bg-gradient-main flex items-center justify-center"
>
<Loading
/>
</div>
</
template
>
lightx2v/deploy/server/frontend/src/components/Alert.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
alert
,
getAlertClass
,
getAlertIconBgClass
,
getAlertIcon
}
from
'
../utils/other
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
ref
,
onMounted
,
onUnmounted
}
from
'
vue
'
const
{
t
,
locale
}
=
useI18n
()
// 处理操作按钮点击
const
handleActionClick
=
()
=>
{
if
(
alert
.
value
.
action
&&
alert
.
value
.
action
.
onClick
)
{
// 先执行action的回调
alert
.
value
.
action
.
onClick
()
// 立即关闭alert
alert
.
value
.
show
=
false
}
}
// 处理transition离开完成后的回调
const
handleAfterLeave
=
()
=>
{
// 只有在alert确实关闭时才重置,避免覆盖正在显示的alert
if
(
alert
.
value
&&
!
alert
.
value
.
show
)
{
// 记录当前alert的时间戳,用于后续检查
const
currentTimestamp
=
alert
.
value
.
_timestamp
// 延迟一小段时间再重置,确保不会影响后续的alert显示
setTimeout
(()
=>
{
// 只有当alert仍然关闭,且时间戳没有变化(没有新alert创建)时才重置
if
(
alert
.
value
&&
!
alert
.
value
.
show
&&
alert
.
value
.
_timestamp
===
currentTimestamp
)
{
alert
.
value
=
{
show
:
false
,
message
:
''
,
type
:
'
info
'
,
action
:
null
}
}
},
50
)
}
}
// 响应式变量控制Alert位置
const
alertPosition
=
ref
({
top
:
'
1rem
'
})
// 防抖函数
let
scrollTimeout
=
null
let
scrollContainer
=
null
// 监听滚动事件,动态调整Alert位置
const
handleScroll
=
()
=>
{
// 清除之前的定时器
if
(
scrollTimeout
)
{
clearTimeout
(
scrollTimeout
)
}
// 设置新的定时器,防抖处理
scrollTimeout
=
setTimeout
(()
=>
{
// 获取实际的滚动容器
const
mainScrollable
=
scrollContainer
||
document
.
querySelector
(
'
.main-scrollbar
'
)
if
(
!
mainScrollable
)
{
alertPosition
.
value
=
{
top
:
'
1rem
'
}
return
}
const
scrollY
=
mainScrollable
.
scrollTop
const
viewportHeight
=
window
.
innerHeight
// 如果用户滚动了超过50px,将Alert显示在视口内
if
(
scrollY
>
50
)
{
// 计算Alert应该显示的位置,确保在视口内可见
// 距离顶部80px(TopBar高度 + 一些间距)
alertPosition
.
value
=
{
top
:
'
6rem
'
}
}
else
{
// 在页面顶部时,显示在固定位置
alertPosition
.
value
=
{
top
:
'
1.5rem
'
}
}
},
10
)
// 10ms防抖延迟
}
onMounted
(()
=>
{
// 查找实际的滚动容器
scrollContainer
=
document
.
querySelector
(
'
.main-scrollbar
'
)
if
(
scrollContainer
)
{
scrollContainer
.
addEventListener
(
'
scroll
'
,
handleScroll
,
{
passive
:
true
})
}
// 也监听 window 的滚动(作为后备)
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
{
passive
:
true
})
// 初始化时也调用一次,确保位置正确
handleScroll
()
})
onUnmounted
(()
=>
{
if
(
scrollContainer
)
{
scrollContainer
.
removeEventListener
(
'
scroll
'
,
handleScroll
)
}
window
.
removeEventListener
(
'
scroll
'
,
handleScroll
)
if
(
scrollTimeout
)
{
clearTimeout
(
scrollTimeout
)
}
})
</
script
>
<
template
>
<!-- Apple 风格极简提示消息 -->
<div
v-cloak
>
<transition
enter-active-class=
"alert-enter-active"
leave-active-class=
"alert-leave-active"
enter-from-class=
"alert-enter-from"
leave-to-class=
"alert-leave-to"
@
after-leave=
"handleAfterLeave"
>
<div
v-if=
"alert.show"
:key=
"alert._timestamp || alert.message"
class=
"fixed right-6 z-[9999] w-auto min-w-[260px] sm:min-w-[300px] max-w-[calc(100vw-2.5rem)] sm:max-w-md px-4 sm:px-5 transition-all duration-500 ease-out"
:style=
"
{ top: alertPosition.top }">
<div
class=
"alert-container text-[#1d1d1f] bg-white/95 dark:text-white dark:bg-[#0d0d12]/90 dark:shadow-[0_12px_32px_rgba(0,0,0,0.6),0_4px_12px_rgba(0,0,0,0.4),0_0_0_1px_rgba(255,255,255,0.06)]"
>
<div
class=
"alert-content"
>
<!-- 图标 -->
<div
class=
"alert-icon-wrapper"
>
<i
:class=
"getAlertIcon(alert.type)"
class=
"alert-icon"
></i>
</div>
<!-- 消息文本 -->
<div
class=
"alert-message"
>
<span>
{{
alert
.
message
}}
</span>
</div>
<!-- 操作按钮和关闭按钮(右侧,紧挨着) -->
<div
class=
"alert-actions"
>
<!-- 操作链接 - Apple 风格 -->
<button
v-if=
"alert.action"
@
click=
"handleActionClick"
class=
"alert-action-link"
>
{{
alert
.
action
.
label
}}
</button>
<!-- 关闭按钮 -->
<button
@
click=
"alert.show = false"
class=
"alert-close-btn"
aria-label=
"Close"
>
<i
class=
"fas fa-times"
></i>
</button>
</div>
</div>
</div>
</div>
</transition>
</div>
</
template
>
<
style
scoped
>
/* Apple 风格 Alert 容器 */
.alert-container
{
backdrop-filter
:
blur
(
20px
)
saturate
(
180%
);
-webkit-backdrop-filter
:
blur
(
20px
)
saturate
(
180%
);
border-radius
:
16px
;
box-shadow
:
0
4px
6px
-1px
rgba
(
0
,
0
,
0
,
0.1
),
0
2px
4px
-1px
rgba
(
0
,
0
,
0
,
0.06
),
0
0
0
1px
rgba
(
0
,
0
,
0
,
0.05
);
overflow
:
hidden
;
}
/* Alert 内容 */
.alert-content
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
padding
:
14px
18px
;
}
/* 图标包装器 */
.alert-icon-wrapper
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-shrink
:
0
;
}
/* 图标样式 */
.alert-icon
{
font-size
:
18px
;
color
:
var
(
--brand-primary
);
}
:global
(
.dark
)
.alert-icon
{
color
:
var
(
--brand-primary-light
);
}
/* 消息文本 */
.alert-message
{
flex
:
1
;
font-size
:
14px
;
font-weight
:
500
;
line-height
:
1.5
;
letter-spacing
:
-0.01em
;
min-width
:
0
;
/* 允许文本收缩 */
}
/* 操作按钮和关闭按钮容器 */
.alert-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
flex-shrink
:
0
;
}
/* 关闭按钮 */
.alert-close-btn
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
width
:
24px
;
height
:
24px
;
border-radius
:
50%
;
border
:
none
;
background
:
transparent
;
color
:
#86868b
;
cursor
:
pointer
;
flex-shrink
:
0
;
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
}
.alert-close-btn
:hover
{
background
:
rgba
(
0
,
0
,
0
,
0.05
);
color
:
#1d1d1f
;
transform
:
scale
(
1.05
);
}
.alert-close-btn
:active
{
transform
:
scale
(
0.95
);
}
:global
(
.dark
)
.alert-close-btn
:hover
{
background
:
rgba
(
255
,
255
,
255
,
0.08
);
color
:
#f5f5f7
;
}
.alert-close-btn
i
{
font-size
:
12px
;
}
/* 操作链接 - Apple 风格下划线文本 */
.alert-action-link
{
display
:
inline-flex
;
align-items
:
center
;
padding
:
0
;
border
:
none
;
background
:
transparent
;
color
:
var
(
--brand-primary
);
font-size
:
14px
;
font-weight
:
600
;
text-decoration
:
underline
;
text-underline-offset
:
2px
;
text-decoration-thickness
:
1px
;
cursor
:
pointer
;
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
white-space
:
nowrap
;
height
:
24px
;
/* 与关闭按钮高度一致 */
}
.alert-action-link
:hover
{
color
:
var
(
--brand-primary
);
opacity
:
0.8
;
text-decoration-thickness
:
2px
;
}
.alert-action-link
:active
{
opacity
:
0.6
;
}
:global
(
.dark
)
.alert-action-link
{
color
:
#ffffff
;
}
:global
(
.dark
)
.alert-action-link
:hover
{
color
:
#ffffff
;
}
/* 进入动画 */
.alert-enter-active
{
transition
:
all
0.4s
cubic-bezier
(
0.34
,
1.56
,
0.64
,
1
);
}
.alert-leave-active
{
transition
:
all
0.3s
cubic-bezier
(
0.4
,
0
,
1
,
1
);
}
.alert-enter-from
{
opacity
:
0
;
transform
:
translate
(
-50%
,
-20px
)
scale
(
0.95
);
}
.alert-leave-to
{
opacity
:
0
;
transform
:
translate
(
-50%
,
-10px
)
scale
(
0.98
);
}
/* 响应式设计 */
@media
(
max-width
:
640px
)
{
.alert-content
{
padding
:
12px
16px
;
gap
:
8px
;
}
.alert-message
{
font-size
:
13px
;
}
.alert-icon
{
font-size
:
16px
;
}
.alert-action-link
{
font-size
:
13px
;
height
:
22px
;
/* 移动端稍微小一点 */
}
.alert-actions
{
gap
:
6px
;
/* 移动端间距更小 */
}
}
</
style
>
lightx2v/deploy/server/frontend/src/components/AudioPreviewTest.vue
0 → 100644
View file @
a1ebc651
<
template
>
<div
class=
"p-6"
>
<h2
class=
"text-xl font-bold text-white mb-4"
>
音频预览测试
</h2>
<!-- 测试音频模板预览 -->
<div
class=
"mb-6"
>
<h3
class=
"text-lg text-white mb-2"
>
音频模板预览测试
</h3>
<div
class=
"space-y-2"
>
<div
v-for=
"template in audioTemplates"
:key=
"template.filename"
class=
"flex items-center gap-4 p-3 bg-dark-light rounded-lg"
>
<span
class=
"text-white"
>
{{
template
.
filename
}}
</span>
<button
@
click=
"previewAudioTemplate(template)"
class=
"px-3 py-1 bg-laser-purple text-white rounded hover:bg-laser-purple/80"
>
预览
</button>
</div>
<div
v-if=
"audioTemplates.length === 0"
class=
"text-gray-400"
>
暂无音频模板
</div>
</div>
</div>
<!-- 测试音频历史预览 -->
<div
class=
"mb-6"
>
<h3
class=
"text-lg text-white mb-2"
>
音频历史预览测试
</h3>
<div
class=
"space-y-2"
>
<div
v-for=
"history in audioHistory"
:key=
"history.filename"
class=
"flex items-center gap-4 p-3 bg-dark-light rounded-lg"
>
<span
class=
"text-white"
>
{{
history
.
filename
}}
</span>
<button
@
click=
"previewAudioHistory(history)"
class=
"px-3 py-1 bg-laser-purple text-white rounded hover:bg-laser-purple/80"
>
预览
</button>
</div>
<div
v-if=
"audioHistory.length === 0"
class=
"text-gray-400"
>
暂无音频历史
</div>
</div>
</div>
<!-- 调试信息 -->
<div
class=
"mt-6 p-4 bg-gray-800 rounded-lg"
>
<h3
class=
"text-lg text-white mb-2"
>
调试信息
</h3>
<div
class=
"text-sm text-gray-300"
>
<p>
音频模板数量:
{{
audioTemplates
.
length
}}
</p>
<p>
音频历史数量:
{{
audioHistory
.
length
}}
</p>
<div
v-if=
"audioTemplates.length > 0"
>
<p>
第一个音频模板:
</p>
<pre
class=
"text-xs bg-gray-900 p-2 rounded mt-1"
>
{{
JSON
.
stringify
(
audioTemplates
[
0
],
null
,
2
)
}}
</pre>
</div>
<div
v-if=
"audioHistory.length > 0"
>
<p>
第一个音频历史:
</p>
<pre
class=
"text-xs bg-gray-900 p-2 rounded mt-1"
>
{{
JSON
.
stringify
(
audioHistory
[
0
],
null
,
2
)
}}
</pre>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
>
import
{
audioTemplates
,
audioHistory
,
previewAudioTemplate
,
previewAudioHistory
}
from
'
../utils/other
'
</
script
>
lightx2v/deploy/server/frontend/src/components/Confirm.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
confirmDialog
,
showConfirmDialog
}
from
'
../utils/other
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
,
locale
}
=
useI18n
()
</
script
>
<
template
>
<!-- 自定义确认对话框 - Apple 极简风格 -->
<div
v-cloak
>
<div
v-if=
"confirmDialog.show"
class=
"fixed inset-0 z-[70] flex items-center justify-center p-4"
>
<!-- 背景遮罩 - Apple 风格 -->
<div
class=
"absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
@
click=
"confirmDialog.cancel()"
>
</div>
<!-- 对话框内容 - Apple 风格 -->
<div
class=
"relative bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] max-w-md w-full mx-4 overflow-hidden transform transition-all duration-300 ease-out"
:class=
"confirmDialog.show ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
>
<!-- 头部 - Apple 风格 -->
<div
class=
"flex items-center justify-between px-6 py-5 border-b border-black/8 dark:border-white/8"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"w-9 h-9 bg-red-500/10 dark:bg-red-400/10 rounded-full flex items-center justify-center"
>
<i
class=
"fas fa-exclamation-triangle text-red-500 dark:text-red-400 text-base"
></i>
</div>
<h3
class=
"text-lg font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
confirmDialog
.
title
}}
</h3>
</div>
<button
@
click=
"confirmDialog.cancel()"
class=
"w-8 h-8 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
>
<i
class=
"fas fa-times text-xs"
></i>
</button>
</div>
<!-- 内容 - Apple 风格 -->
<div
class=
"px-6 py-5"
>
<p
class=
"text-[15px] text-[#1d1d1f] dark:text-[#f5f5f7] leading-relaxed tracking-tight"
>
{{
confirmDialog
.
message
}}
</p>
<!-- 警告信息 - Apple 风格 -->
<div
v-if=
"confirmDialog.warning"
class=
"mt-4 bg-red-500/5 dark:bg-red-400/5 border border-red-500/20 dark:border-red-400/20 rounded-2xl p-4"
>
<div
class=
"flex items-start gap-3"
>
<i
class=
"fas fa-info-circle text-red-500 dark:text-red-400 text-sm mt-0.5"
></i>
<div
class=
"flex-1"
>
<p
class=
"text-sm font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-2 tracking-tight"
>
{{
confirmDialog
.
warning
.
title
}}
</p>
<ul
class=
"space-y-1.5 text-xs text-[#86868b] dark:text-[#98989d]"
>
<li
v-for=
"item in confirmDialog.warning.items"
:key=
"item"
class=
"flex items-start gap-2 tracking-tight"
>
<i
class=
"fas fa-circle text-[6px] text-red-500 dark:text-red-400 mt-1"
></i>
<span
class=
"flex-1"
>
{{
item
}}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 底部按钮 - Apple 风格 -->
<div
class=
"flex gap-3 px-6 pb-6"
>
<button
@
click=
"confirmDialog.cancel()"
class=
"flex-1 px-5 py-3 bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 text-[#1d1d1f] dark:text-[#f5f5f7] rounded-full transition-all duration-200 font-medium text-[15px] tracking-tight hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98]"
>
{{
t
(
'
cancel
'
)
}}
</button>
<button
@
click=
"confirmDialog.confirm()"
class=
"flex-1 px-5 py-3 bg-red-500 dark:bg-red-500 text-white rounded-full transition-all duration-200 font-semibold text-[15px] tracking-tight hover:bg-red-600 dark:hover:bg-red-600 hover:shadow-[0_8px_24px_rgba(239,68,68,0.35)] dark:hover:shadow-[0_8px_24px_rgba(239,68,68,0.4)] active:scale-[0.98] flex items-center justify-center gap-2"
>
<i
class=
"fas fa-check text-sm"
></i>
{{
confirmDialog
.
confirmText
}}
</button>
</div>
</div>
</div>
</div>
</
template
>
lightx2v/deploy/server/frontend/src/components/DropdownMenu.vue
0 → 100644
View file @
a1ebc651
<
template
>
<div
class=
"relative inline-block text-left"
>
<Menu
as=
"div"
>
<div>
<!-- Apple 风格菜单按钮 -->
<MenuButton
class=
"inline-flex w-full sm:min-w-[120px] md:min-w-[160px] lg:min-w-[200px] justify-between items-center px-4 py-2.5 sm:px-3.5 sm:py-2 bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] backdrop-saturate-[180%] border border-black/8 dark:border-white/8 rounded-xl text-sm sm:text-[13px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] cursor-pointer transition-all duration-200 ease-out shadow-[0_1px_3px_0_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.02)] dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.3),0_0_0_1px_rgba(255,255,255,0.04)] tracking-tight hover:bg-white dark:hover:bg-[#282828]/98 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_2px_6px_0_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.04)] dark:hover:shadow-[0_2px_6px_0_rgba(0,0,0,0.4),0_0_0_1px_rgba(255,255,255,0.06)] focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 focus:shadow-[0_2px_6px_0_rgba(var(--brand-primary-rgb),0.15),0_0_0_3px_rgba(var(--brand-primary-rgb),0.1)] dark:focus:shadow-[0_2px_6px_0_rgba(var(--brand-primary-light-rgb),0.3),0_0_0_3px_rgba(var(--brand-primary-light-rgb),0.2)]"
>
<span
class=
"flex-1 text-left"
>
{{
selectedLabel
||
placeholder
}}
</span>
<ChevronDownIcon
class=
"w-4 h-4 ml-2 flex-shrink-0 text-[#86868b] dark:text-[#98989d] transition-all duration-200"
aria-hidden=
"true"
/>
</MenuButton>
</div>
<transition
enter-active-class=
"transition-all duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
enter-from-class=
"opacity-0 scale-95 -translate-y-2"
enter-to-class=
"opacity-100 scale-100 translate-y-0"
leave-active-class=
"transition-all duration-150 ease-[cubic-bezier(0.4,0,1,1)]"
leave-from-class=
"opacity-100 scale-100 translate-y-0"
leave-to-class=
"opacity-0 scale-95 -translate-y-1"
>
<!-- Apple 风格下拉菜单 -->
<MenuItems
class=
"absolute right-0 mt-2 min-w-[200px] w-max max-w-[320px] bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] backdrop-saturate-[180%] border border-black/8 dark:border-white/8 rounded-xl shadow-[0_4px_6px_-1px_rgba(0,0,0,0.1),0_10px_15px_-3px_rgba(0,0,0,0.1),0_0_0_1px_rgba(0,0,0,0.05)] dark:shadow-[0_4px_6px_-1px_rgba(0,0,0,0.3),0_10px_15px_-3px_rgba(0,0,0,0.2),0_0_0_1px_rgba(255,255,255,0.06)] overflow-hidden z-50 outline-none"
>
<div
class=
"p-1.5"
>
<MenuItem
v-for=
"item in items"
:key=
"item.value"
v-slot=
"
{ active }">
<button
@
click=
"selectItem(item)"
:class=
"[
'flex w-full items-center px-3 py-2 sm:px-2.5 sm:py-1.5 border-0 bg-transparent rounded-lg text-sm sm:text-[13px] text-[#1d1d1f] dark:text-[#f5f5f7] text-left cursor-pointer transition-all duration-150 ease-out tracking-tight',
active ? 'bg-black/4 dark:bg-white/8' : '',
selectedValue === item.value ? 'font-medium bg-black/6 dark:bg-white/12' : 'font-normal'
]"
>
<i
v-if=
"item.icon"
:class=
"[item.icon, 'w-4 h-4 mr-2.5 sm:mr-2 flex-shrink-0 text-[#86868b] dark:text-[#98989d] transition-colors', active ? 'text-[#1d1d1f] dark:text-[#f5f5f7]' : '']"
aria-hidden=
"true"
></i>
<span
class=
"flex-1"
>
{{
item
.
label
}}
</span>
<i
v-if=
"selectedValue === item.value"
class=
"fas fa-check w-3.5 h-3.5 ml-3 flex-shrink-0 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
</button>
</MenuItem>
</div>
<div
v-if=
"items.length === 0"
class=
"p-1.5"
>
<div
class=
"px-3 py-3 text-center text-[13px] text-[#86868b] dark:text-[#98989d]"
>
{{
emptyMessage
}}
</div>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</
template
>
<
script
setup
>
import
{
computed
}
from
'
vue
'
import
{
Menu
,
MenuButton
,
MenuItems
,
MenuItem
}
from
'
@headlessui/vue
'
import
{
ChevronDownIcon
}
from
'
@heroicons/vue/20/solid
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
// Props
const
props
=
defineProps
({
items
:
{
type
:
Array
,
default
:
()
=>
[]
},
selectedValue
:
{
type
:
[
String
,
Number
],
default
:
''
},
placeholder
:
{
type
:
String
,
default
:
''
},
emptyMessage
:
{
type
:
String
,
default
:
''
}
})
// Emits
const
emit
=
defineEmits
([
'
select-item
'
])
// Computed
const
selectedLabel
=
computed
(()
=>
{
const
selectedItem
=
props
.
items
.
find
(
item
=>
item
.
value
===
props
.
selectedValue
)
return
selectedItem
?
selectedItem
.
label
:
''
})
// Methods
const
selectItem
=
(
item
)
=>
{
emit
(
'
select-item
'
,
item
)
}
</
script
>
<
style
scoped
>
/* 所有样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
/* Apple 风格的极简黑白设计 */
</
style
>
lightx2v/deploy/server/frontend/src/components/FloatingParticles.vue
0 → 100644
View file @
a1ebc651
<
template
>
<!-- 浮动粒子背景 -->
<div
class=
"floating-particles"
>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
<div
class=
"particle"
></div>
</div>
</
template
>
<
style
scoped
type=
"text/tailwindcss"
>
.floating-particles
{
position
:
absolute
;
top
:
0
;
left
:
0
;
width
:
100%
;
height
:
100%
;
overflow
:
hidden
;
pointer-events
:
none
;
}
.particle
{
position
:
absolute
;
width
:
4px
;
height
:
4px
;
background
:
rgba
(
193
,
169
,
255
,
0.6
);
border-radius
:
50%
;
animation
:
floatParticle
15s
linear
infinite
;
}
.particle
:nth-child
(
1
)
{
left
:
10%
;
animation-delay
:
0s
;
}
.particle
:nth-child
(
2
)
{
left
:
20%
;
animation-delay
:
12s
;
}
.particle
:nth-child
(
3
)
{
left
:
30%
;
animation-delay
:
10s
;
}
.particle
:nth-child
(
4
)
{
left
:
40%
;
animation-delay
:
6s
;
}
.particle
:nth-child
(
5
)
{
left
:
50%
;
animation-delay
:
8s
;
}
.particle
:nth-child
(
6
)
{
left
:
60%
;
animation-delay
:
14s
;
}
.particle
:nth-child
(
7
)
{
left
:
70%
;
animation-delay
:
16s
;
}
.particle
:nth-child
(
8
)
{
left
:
80%
;
animation-delay
:
2s
;
}
.particle
:nth-child
(
9
)
{
left
:
90%
;
animation-delay
:
4s
;
}
@keyframes
floatParticle
{
0
%
{
transform
:
translateY
(
100vh
)
scale
(
0
);
opacity
:
0
;
}
10
%
{
opacity
:
1
;
}
90
%
{
opacity
:
1
;
}
100
%
{
transform
:
translateY
(
-100px
)
scale
(
1
);
opacity
:
0
;
}
}
</
style
>
lightx2v/deploy/server/frontend/src/components/Generate.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
submitting
,
templateLoading
,
templateLoadingMessage
,
// 任务类型下拉菜单
showTaskTypeMenu
,
showModelMenu
,
isLoggedIn
,
loading
,
loginLoading
,
initLoading
,
downloadLoading
,
downloadLoadingMessage
,
// 录音相关
isRecording
,
recordingDuration
,
startRecording
,
stopRecording
,
formatRecordingDuration
,
taskSearchQuery
,
currentUser
,
models
,
tasks
,
alert
,
showErrorDetails
,
showFailureDetails
,
confirmDialog
,
showConfirmDialog
,
showTaskDetailModal
,
modalTask
,
currentTask
,
t2vForm
,
i2vForm
,
s2vForm
,
getCurrentForm
,
i2vImagePreview
,
s2vImagePreview
,
s2vAudioPreview
,
getCurrentImagePreview
,
getCurrentAudioPreview
,
getCurrentVideoPreview
,
setCurrentImagePreview
,
setCurrentAudioPreview
,
setCurrentVideoPreview
,
updateUploadedContentStatus
,
availableTaskTypes
,
availableModelClasses
,
currentTaskHints
,
currentHintIndex
,
startHintRotation
,
stopHintRotation
,
filteredTasks
,
selectedTaskId
,
selectedTask
,
selectedModel
,
loadingTaskFiles
,
statusFilter
,
pagination
,
paginationInfo
,
currentTaskPage
,
taskPageSize
,
taskPageInput
,
paginationKey
,
taskMenuVisible
,
toggleTaskMenu
,
closeAllTaskMenus
,
handleClickOutside
,
showAlert
,
setLoading
,
apiCall
,
logout
,
loadModels
,
sidebarCollapsed
,
sidebarWidth
,
showExpandHint
,
showGlow
,
isDefaultStateHidden
,
hideDefaultState
,
showDefaultState
,
isCreationAreaExpanded
,
hasUploadedContent
,
isContracting
,
expandCreationArea
,
contractCreationArea
,
taskFileCache
,
taskFileCacheLoaded
,
templateFileCache
,
templateFileCacheLoaded
,
loadTaskFiles
,
downloadFile
,
viewFile
,
handleImageUpload
,
detectFacesInImage
,
faceDetecting
,
audioSeparating
,
cropFaceImage
,
updateFaceRoleName
,
toggleFaceEditing
,
saveFaceRoleName
,
selectTask
,
selectModel
,
resetForm
,
triggerImageUpload
,
triggerAudioUpload
,
removeImage
,
removeAudio
,
removeVideo
,
handleAudioUpload
,
handleVideoUpload
,
separateAudioTracks
,
updateSeparatedAudioRole
,
updateSeparatedAudioName
,
toggleSeparatedAudioEditing
,
saveSeparatedAudioName
,
loadImageAudioTemplates
,
selectImageTemplate
,
selectAudioTemplate
,
previewAudioTemplate
,
getTemplateFile
,
imageTemplates
,
audioTemplates
,
showImageTemplates
,
showAudioTemplates
,
mediaModalTab
,
templatePagination
,
templatePaginationInfo
,
templateCurrentPage
,
templatePageSize
,
templatePageInput
,
templatePaginationKey
,
imageHistory
,
audioHistory
,
showTemplates
,
showHistory
,
showPromptModal
,
promptModalTab
,
submitTask
,
fileToBase64
,
formatTime
,
refreshTasks
,
goToPage
,
jumpToPage
,
getVisiblePages
,
goToTemplatePage
,
jumpToTemplatePage
,
getVisibleTemplatePages
,
goToInspirationPage
,
jumpToInspirationPage
,
getVisibleInspirationPages
,
preloadTaskFilesUrl
,
preloadTemplateFilesUrl
,
loadTaskFilesFromCache
,
saveTaskFilesToCache
,
getTaskFileFromCache
,
setTaskFileToCache
,
getTaskFileUrlFromApi
,
getTaskFileUrl
,
getTaskFileUrlSync
,
getTemplateFileUrlFromApi
,
getTemplateFileUrl
,
getTemplateFileUrlAsync
,
loadTemplateFilesFromCache
,
saveTemplateFilesToCache
,
loadFromCache
,
saveToCache
,
clearAllCache
,
getStatusBadgeClass
,
viewSingleResult
,
cancelTask
,
resumeTask
,
deleteTask
,
startPollingTask
,
stopPollingTask
,
reuseTask
,
showTaskCreator
,
toggleSidebar
,
clearPrompt
,
getTaskItemClass
,
getStatusIndicatorClass
,
getTaskTypeBtnClass
,
getModelBtnClass
,
getTaskTypeIcon
,
getTaskTypeName
,
getPromptPlaceholder
,
getStatusTextClass
,
getImagePreview
,
getTaskInputUrl
,
getTaskInputImage
,
getTaskInputAudio
,
getHistoryImageUrl
,
getUserAvatarUrl
,
getCurrentImagePreviewUrl
,
getCurrentAudioPreviewUrl
,
getCurrentVideoPreviewUrl
,
handleThumbnailError
,
handleImageError
,
handleImageLoad
,
handleAudioError
,
handleAudioLoad
,
getTaskStatusDisplay
,
getTaskStatusColor
,
getTaskStatusIcon
,
getTaskDuration
,
getRelativeTime
,
getTaskHistory
,
getActiveTasks
,
getOverallProgress
,
getProgressTitle
,
getProgressInfo
,
getSubtaskProgress
,
getSubtaskStatusText
,
formatEstimatedTime
,
formatDuration
,
searchTasks
,
filterTasksByStatus
,
filterTasksByType
,
getAlertClass
,
getAlertBorderClass
,
getAlertTextClass
,
getAlertIcon
,
getAlertIconBgClass
,
getPromptTemplates
,
selectPromptTemplate
,
promptHistory
,
getPromptHistory
,
addTaskToHistory
,
getLocalTaskHistory
,
selectPromptHistory
,
clearPromptHistory
,
getImageHistory
,
getAudioHistory
,
selectImageHistory
,
selectAudioHistory
,
previewAudioHistory
,
clearImageHistory
,
clearAudioHistory
,
getAudioMimeType
,
getAuthHeaders
,
startResize
,
sidebar
,
switchToCreateView
,
switchToProjectsView
,
switchToInspirationView
,
switchToLoginView
,
openTaskDetailModal
,
closeTaskDetailModal
,
// 灵感广场相关
inspirationSearchQuery
,
selectedInspirationCategory
,
inspirationItems
,
InspirationCategories
,
loadInspirationData
,
selectInspirationCategory
,
handleInspirationSearch
,
loadMoreInspiration
,
inspirationPagination
,
inspirationPaginationInfo
,
inspirationCurrentPage
,
inspirationPageSize
,
inspirationPageInput
,
inspirationPaginationKey
,
// 精选模版相关
featuredTemplates
,
featuredTemplatesLoading
,
loadFeaturedTemplates
,
getRandomFeaturedTemplates
,
// 工具函数
formatDate
,
// 模板详情弹窗相关
showTemplateDetailModal
,
selectedTemplate
,
previewTemplateDetail
,
closeTemplateDetailModal
,
useTemplate
,
// 图片放大弹窗相关
showImageZoomModal
,
zoomedImageUrl
,
showImageZoom
,
closeImageZoomModal
,
// 模板素材应用相关
applyTemplateImage
,
applyTemplateAudio
,
applyTemplatePrompt
,
copyPrompt
,
// 视频播放控制
playVideo
,
pauseVideo
,
toggleVideoPlay
,
pauseAllVideos
,
updateVideoIcon
,
onVideoLoaded
,
onVideoError
,
onVideoEnded
,
generateShareUrl
,
copyShareLink
,
shareToSocial
,
openTaskFromRoute
,
showVoiceTTSModal
}
from
'
../utils/other
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
watch
,
onMounted
,
computed
,
ref
,
nextTick
,
onUnmounted
}
from
'
vue
'
import
ModelDropdown
from
'
./ModelDropdown.vue
'
import
TaskCarousel
from
'
./TaskCarousel.vue
'
// Props
const
props
=
defineProps
({
query
:
{
type
:
Object
,
default
:
()
=>
({})
}
})
const
{
t
,
tm
,
locale
}
=
useI18n
()
const
route
=
useRoute
()
const
router
=
useRouter
()
// 当前显示的精选模版
const
currentFeaturedTemplates
=
ref
([])
// 屏幕尺寸响应式状态
const
screenSize
=
ref
(
'
large
'
)
// 'small' 或 'large'
// 精选模版瀑布流容器高度控制
const
featuredMasonryRef
=
ref
(
null
)
const
baseMasonryHeight
=
computed
(()
=>
(
screenSize
.
value
===
'
large
'
?
600
:
400
))
const
masonryHeight
=
ref
(
baseMasonryHeight
.
value
)
const
updateMasonryHeight
=
()
=>
{
const
container
=
featuredMasonryRef
.
value
if
(
!
container
)
{
masonryHeight
.
value
=
baseMasonryHeight
.
value
return
}
const
columnEls
=
container
.
querySelectorAll
(
'
[data-masonry-column]
'
)
if
(
!
columnEls
.
length
)
{
masonryHeight
.
value
=
baseMasonryHeight
.
value
return
}
let
maxBottom
=
0
columnEls
.
forEach
((
column
)
=>
{
const
bottom
=
column
.
offsetTop
+
column
.
offsetHeight
if
(
bottom
>
maxBottom
)
{
maxBottom
=
bottom
}
})
masonryHeight
.
value
=
Math
.
max
(
Math
.
ceil
(
maxBottom
)
+
32
,
baseMasonryHeight
.
value
)
}
const
scheduleMasonryUpdate
=
()
=>
{
requestAnimationFrame
(()
=>
updateMasonryHeight
())
}
// 拖拽状态
const
isDragOver
=
ref
(
false
)
// 音频预览播放器相关
const
audioPreviewElement
=
ref
(
null
)
const
audioPreviewIsPlaying
=
ref
(
false
)
const
audioPreviewDuration
=
ref
(
0
)
const
audioPreviewCurrentTime
=
ref
(
0
)
const
audioPreviewIsDragging
=
ref
(
false
)
// 分离后的音频播放器相关状态
const
separatedAudioElements
=
ref
([])
// Array of audio elements
const
separatedAudioPlaying
=
ref
({})
// { index: boolean }
const
separatedAudioDuration
=
ref
({})
// { index: number }
const
separatedAudioCurrentTime
=
ref
({})
// { index: number }
const
separatedAudioIsDragging
=
ref
({})
// { index: boolean }
// 拖拽排序相关状态
const
draggedRoleIndex
=
ref
(
-
1
)
const
draggedAudioIndex
=
ref
(
-
1
)
const
dragOverRoleIndex
=
ref
(
-
1
)
const
dragOverAudioIndex
=
ref
(
-
1
)
const
dragPreviewElement
=
ref
(
null
)
const
dragOffset
=
ref
({
x
:
0
,
y
:
0
})
// 计算属性:当前表单检测到的脸
const
currentDetectedFaces
=
computed
(()
=>
{
const
form
=
getCurrentForm
()
return
form
?.
detectedFaces
||
[]
})
// 计算属性:当前表单分离后的音频
const
currentSeparatedAudios
=
computed
(()
=>
{
const
form
=
getCurrentForm
()
const
audios
=
form
?.
separatedAudios
||
[]
// Debug log
if
(
audios
.
length
>
0
)
{
console
.
log
(
'
currentSeparatedAudios computed:
'
,
audios
.
length
,
'
audios
'
)
}
return
audios
})
// 计算属性:当前音频预览
const
currentAudioPreview
=
computed
(()
=>
{
return
getCurrentAudioPreview
()
})
// 记录上次分离的角色个数,避免重复分离
const
lastSeparatedFaceCount
=
ref
(
0
)
const
lastSeparatedAudioUrl
=
ref
(
''
)
// 角色模式:单角色/多角色
const
isMultiRoleMode
=
ref
(
false
)
// false = 单角色模式, true = 多角色模式(默认关闭,单人模式)
// 监听任务类型变化,切换任务时重置为单人模式
watch
(
selectedTaskId
,
(
newTaskId
,
oldTaskId
)
=>
{
if
(
newTaskId
!==
oldTaskId
)
{
// 切换任务类型时,重置为单人模式
isMultiRoleMode
.
value
=
false
// 重置分离记录
lastSeparatedFaceCount
.
value
=
0
lastSeparatedAudioUrl
.
value
=
''
}
})
// 统一监听 detectedFaces 和音频预览,当两者都存在且角色个数 > 1 时,自动分离音频
// 这样可以覆盖所有场景:上传音频、应用历史音频、使用音频模板、复用任务等
watch
([
currentDetectedFaces
,
currentAudioPreview
,
selectedTaskId
],
([
newFaces
,
audioUrl
,
taskType
],
[
oldFaces
,
oldAudioUrl
,
oldTaskType
])
=>
{
// 只在 s2v 任务下处理
if
(
taskType
!==
'
s2v
'
)
{
// 如果不是 s2v 任务,清空分离记录
if
(
oldTaskType
===
'
s2v
'
)
{
lastSeparatedFaceCount
.
value
=
0
lastSeparatedAudioUrl
.
value
=
''
}
return
}
const
faceCount
=
newFaces
?.
length
||
0
const
oldFaceCount
=
oldFaces
?.
length
||
0
// 如果角色个数
<=
1
,
清空分离的音频
if
(
faceCount
<=
1
)
{
if
(
oldFaceCount
>
1
)
{
const
form
=
getCurrentForm
()
if
(
form
)
{
form
.
separatedAudios
=
[]
}
lastSeparatedFaceCount
.
value
=
0
lastSeparatedAudioUrl
.
value
=
''
}
return
}
// 如果角色个数 > 1 且有音频预览
if
(
faceCount
>
1
&&
audioUrl
)
{
// 检查是否需要分离(避免重复分离)
const
needsSeparation
=
faceCount
!==
lastSeparatedFaceCount
.
value
||
audioUrl
!==
lastSeparatedAudioUrl
.
value
if
(
needsSeparation
)
{
console
.
log
(
`[自动音频分离] 检测到
${
faceCount
}
个角色且有音频,开始分离...`
,
{
faceCount
,
audioUrl
:
audioUrl
.
substring
(
0
,
50
)
+
'
...
'
,
lastSeparatedFaceCount
:
lastSeparatedFaceCount
.
value
,
lastSeparatedAudioUrl
:
lastSeparatedAudioUrl
.
value
?.
substring
(
0
,
50
)
+
'
...
'
})
separateAudioTracks
(
audioUrl
,
faceCount
)
.
then
(()
=>
{
// 分离成功,更新记录
lastSeparatedFaceCount
.
value
=
faceCount
lastSeparatedAudioUrl
.
value
=
audioUrl
console
.
log
(
`[自动音频分离] 分离成功,角色个数:
${
faceCount
}
`
)
})
.
catch
(
error
=>
{
console
.
error
(
'
[自动音频分离] 分离失败:
'
,
error
)
// 分离失败,不清空记录,允许重试
})
}
else
{
console
.
log
(
`[自动音频分离] 跳过重复分离,角色个数:
${
faceCount
}
,音频未变化`
)
}
}
else
if
(
faceCount
>
1
&&
!
audioUrl
)
{
// 有多个角色但没有音频,清空分离的音频
const
form
=
getCurrentForm
()
if
(
form
&&
form
.
separatedAudios
&&
form
.
separatedAudios
.
length
>
0
)
{
form
.
separatedAudios
=
[]
lastSeparatedFaceCount
.
value
=
0
lastSeparatedAudioUrl
.
value
=
''
}
}
},
{
immediate
:
true
})
// 手动切换模式
const
toggleRoleMode
=
async
()
=>
{
if
(
selectedTaskId
.
value
!==
'
s2v
'
)
return
const
form
=
getCurrentForm
()
if
(
!
form
)
return
const
newMode
=
!
isMultiRoleMode
.
value
if
(
newMode
)
{
// 切换到多角色模式
isMultiRoleMode
.
value
=
true
// 如果还没有检测到角色,调用角色识别功能
if
(
!
form
.
detectedFaces
||
form
.
detectedFaces
.
length
===
0
)
{
const
imageUrl
=
getCurrentImagePreviewUrl
()
if
(
imageUrl
)
{
try
{
faceDetecting
.
value
=
true
await
detectFacesInImage
(
imageUrl
)
}
catch
(
error
)
{
console
.
error
(
'
Face detection failed:
'
,
error
)
showAlert
(
t
(
'
faceDetectionFailed
'
)
+
'
:
'
+
(
error
.
message
||
t
(
'
unknownError
'
)),
'
error
'
)
}
finally
{
faceDetecting
.
value
=
false
}
}
else
{
showAlert
(
t
(
'
pleaseUploadImage
'
),
'
warning
'
)
isMultiRoleMode
.
value
=
false
return
}
}
// 如果检测后仍然只有0个或1个角色,提示用户
if
(
!
form
.
detectedFaces
||
form
.
detectedFaces
.
length
<=
1
)
{
showAlert
(
t
(
'
multiRoleModeRequires
'
),
'
info
'
)
return
}
// 如果有音频,自动分离
if
(
form
.
audioFile
&&
getCurrentAudioPreview
())
{
const
audioDataUrl
=
getCurrentAudioPreview
()
if
(
audioDataUrl
&&
form
.
detectedFaces
&&
form
.
detectedFaces
.
length
>
1
)
{
try
{
await
separateAudioTracks
(
audioDataUrl
,
form
.
detectedFaces
.
length
)
}
catch
(
error
)
{
console
.
error
(
'
Audio separation failed:
'
,
error
)
showAlert
(
t
(
'
audioSeparationFailed
'
)
+
'
:
'
+
error
.
message
,
'
error
'
)
}
}
}
}
else
{
// 切换到单角色模式
isMultiRoleMode
.
value
=
false
// 清空分离的音频(单角色模式不需要分离)
form
.
separatedAudios
=
[]
// 如果有多于1个角色的情况下切回单模式,提示用户
if
(
form
.
detectedFaces
&&
form
.
detectedFaces
.
length
>
1
)
{
showAlert
(
t
(
'
singleRoleModeInfo
'
),
'
info
'
)
}
}
}
// 处理删除图片,同时重置多角色模式
const
handleRemoveImage
=
()
=>
{
removeImage
()
// 删除图片后,自动切回单角色模式
if
(
selectedTaskId
.
value
===
'
s2v
'
&&
isMultiRoleMode
.
value
)
{
isMultiRoleMode
.
value
=
false
const
form
=
getCurrentForm
()
if
(
form
)
{
form
.
separatedAudios
=
[]
}
}
}
// 脸部放大图片编辑相关状态
const
showFaceEditModal
=
ref
(
false
)
const
editingFaceIndex
=
ref
(
-
1
)
const
editingFaceBbox
=
ref
([
0
,
0
,
0
,
0
])
// [x1, y1, x2, y2]
const
originalImageUrl
=
ref
(
''
)
const
imageContainerRef
=
ref
(
null
)
const
imageLoaded
=
ref
(
false
)
// 图片是否已加载完成
const
imageNaturalSize
=
ref
({
width
:
0
,
height
:
0
})
// 图片原始尺寸
const
isDraggingBbox
=
ref
(
false
)
const
dragType
=
ref
(
'
move
'
)
// 'move', 'resize-n', 'resize-s', 'resize-w', 'resize-e', 'resize-nw', 'resize-ne', 'resize-sw', 'resize-se'
const
dragStartPos
=
ref
({
x
:
0
,
y
:
0
})
const
dragStartBbox
=
ref
([
0
,
0
,
0
,
0
])
// 拖拽开始时的bbox坐标
const
bboxOffset
=
ref
({
x
:
0
,
y
:
0
})
const
isAddingNewFace
=
ref
(
false
)
// 是否在新增角色模式
const
faceSaving
=
ref
(
false
)
// 是否正在保存角色(用于显示加载状态)
const
showRoleModeInfo
=
ref
(
false
)
// 是否显示角色模式说明
// 打开脸部编辑模态框
const
openFaceEditModal
=
async
(
faceIndex
)
=>
{
const
form
=
getCurrentForm
()
if
(
!
form
)
return
originalImageUrl
.
value
=
getCurrentImagePreviewUrl
()
imageLoaded
.
value
=
false
// 重置图片加载状态
imageNaturalSize
.
value
=
{
width
:
0
,
height
:
0
}
// 重置图片尺寸
// 如果是新增模式(faceIndex 为 -1)
if
(
faceIndex
===
-
1
)
{
isAddingNewFace
.
value
=
true
editingFaceIndex
.
value
=
-
1
showFaceEditModal
.
value
=
true
// 等待DOM更新,确保图片元素已渲染
await
nextTick
()
await
nextTick
()
// 多等待一次,确保图片元素完全渲染
// 等待图片加载完成
const
img
=
imageContainerRef
.
value
?.
querySelector
(
'
img
'
)
if
(
img
)
{
// 如果图片已经加载完成(从缓存),立即设置状态
if
(
img
.
complete
&&
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
}
else
{
// 确保图片完全加载
await
new
Promise
((
resolve
)
=>
{
if
(
img
.
complete
&&
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
resolve
()
}
else
{
const
onLoad
=
()
=>
{
// 确保图片尺寸已正确设置
if
(
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
img
.
removeEventListener
(
'
load
'
,
onLoad
)
img
.
removeEventListener
(
'
error
'
,
onError
)
resolve
()
}
}
const
onError
=
()
=>
{
imageLoaded
.
value
=
true
img
.
removeEventListener
(
'
load
'
,
onLoad
)
img
.
removeEventListener
(
'
error
'
,
onError
)
resolve
()
// 即使加载失败也继续
}
img
.
addEventListener
(
'
load
'
,
onLoad
)
img
.
addEventListener
(
'
error
'
,
onError
)
}
})
}
// 再次等待,确保图片尺寸已正确设置
await
nextTick
()
// 计算图片的原始尺寸
const
imgNaturalWidth
=
img
.
naturalWidth
const
imgNaturalHeight
=
img
.
naturalHeight
if
(
imgNaturalWidth
>
0
&&
imgNaturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
imgNaturalWidth
,
height
:
imgNaturalHeight
}
// 默认居中,大小为图片的 30%
const
bboxSize
=
Math
.
min
(
imgNaturalWidth
,
imgNaturalHeight
)
*
0.3
const
centerX
=
imgNaturalWidth
/
2
const
centerY
=
imgNaturalHeight
/
2
editingFaceBbox
.
value
=
[
centerX
-
bboxSize
/
2
,
centerY
-
bboxSize
/
2
,
centerX
+
bboxSize
/
2
,
centerY
+
bboxSize
/
2
]
// 标记图片已加载
imageLoaded
.
value
=
true
// 再次等待DOM更新,确保边界框已渲染
await
nextTick
()
}
else
{
// 如果图片尺寸无效,使用默认值
editingFaceBbox
.
value
=
[
0
,
0
,
100
,
100
]
imageLoaded
.
value
=
true
await
nextTick
()
}
}
else
{
// 如果图片还没加载,使用默认值
editingFaceBbox
.
value
=
[
0
,
0
,
100
,
100
]
imageLoaded
.
value
=
true
await
nextTick
()
}
}
else
{
// 编辑现有角色
if
(
!
form
.
detectedFaces
||
!
form
.
detectedFaces
[
faceIndex
])
return
isAddingNewFace
.
value
=
false
const
face
=
form
.
detectedFaces
[
faceIndex
]
editingFaceIndex
.
value
=
faceIndex
editingFaceBbox
.
value
=
[...(
face
.
bbox
||
[
0
,
0
,
0
,
0
])]
showFaceEditModal
.
value
=
true
// 等待DOM更新,确保图片元素已渲染
await
nextTick
()
await
nextTick
()
// 多等待一次,确保图片元素完全渲染
// 等待图片加载完成,确保边界框能正确显示
const
img
=
imageContainerRef
.
value
?.
querySelector
(
'
img
'
)
if
(
img
)
{
// 如果图片已经加载完成(从缓存),立即设置状态
if
(
img
.
complete
&&
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
}
else
{
// 确保图片完全加载
await
new
Promise
((
resolve
)
=>
{
if
(
img
.
complete
&&
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
resolve
()
}
else
{
const
onLoad
=
()
=>
{
// 确保图片尺寸已正确设置
if
(
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
img
.
removeEventListener
(
'
load
'
,
onLoad
)
img
.
removeEventListener
(
'
error
'
,
onError
)
resolve
()
}
}
const
onError
=
()
=>
{
imageLoaded
.
value
=
true
img
.
removeEventListener
(
'
load
'
,
onLoad
)
img
.
removeEventListener
(
'
error
'
,
onError
)
resolve
()
// 即使加载失败也继续
}
img
.
addEventListener
(
'
load
'
,
onLoad
)
img
.
addEventListener
(
'
error
'
,
onError
)
}
})
}
// 再次等待,确保图片尺寸已正确设置
await
nextTick
()
}
else
{
imageLoaded
.
value
=
true
}
}
}
// 处理脸部编辑模态框中的图片加载
const
handleFaceEditImageLoad
=
()
=>
{
const
img
=
imageContainerRef
.
value
?.
querySelector
(
'
img
'
)
if
(
img
&&
img
.
naturalWidth
>
0
&&
img
.
naturalHeight
>
0
)
{
imageNaturalSize
.
value
=
{
width
:
img
.
naturalWidth
,
height
:
img
.
naturalHeight
}
imageLoaded
.
value
=
true
nextTick
()
}
}
// 处理脸部编辑模态框中的图片加载错误
const
handleFaceEditImageError
=
()
=>
{
imageLoaded
.
value
=
true
// 即使加载失败也显示,避免一直显示加载中
}
// 关闭脸部编辑模态框
const
closeFaceEditModal
=
()
=>
{
showFaceEditModal
.
value
=
false
editingFaceIndex
.
value
=
-
1
isDraggingBbox
.
value
=
false
isAddingNewFace
.
value
=
false
imageLoaded
.
value
=
false
imageNaturalSize
.
value
=
{
width
:
0
,
height
:
0
}
}
// 保存边界框更改
const
saveFaceBbox
=
async
()
=>
{
const
form
=
getCurrentForm
()
if
(
!
form
)
return
// 保存当前状态(在关闭模态框之前)
const
wasAddingNewFace
=
isAddingNewFace
.
value
const
currentEditingIndex
=
editingFaceIndex
.
value
// 保存编辑索引
const
currentBbox
=
[...
editingFaceBbox
.
value
]
// 保存边界框
const
currentImageUrl
=
originalImageUrl
.
value
// 保存图片URL
// 立即关闭模态框
closeFaceEditModal
()
// 如果是新增模式,显示加载状态
if
(
wasAddingNewFace
)
{
faceSaving
.
value
=
true
}
try
{
// 如果是新增模式
if
(
wasAddingNewFace
)
{
if
(
!
form
.
detectedFaces
)
{
form
.
detectedFaces
=
[]
}
// 创建新角色
const
newFaceIndex
=
form
.
detectedFaces
.
length
const
newFace
=
{
bbox
:
[...
currentBbox
],
roleName
:
`角色
${
newFaceIndex
+
1
}
`
,
roleIndex
:
newFaceIndex
,
isEditing
:
false
,
face_image
:
null
}
// 根据新的 bbox 坐标,从原始图片裁剪出新的 face_image
try
{
let
imageUrl
=
currentImageUrl
if
(
imageUrl
.
startsWith
(
'
data:image
'
))
{
// 保持 data URL 格式,可以直接使用
}
else
if
(
!
imageUrl
.
startsWith
(
'
http
'
)
&&
!
imageUrl
.
startsWith
(
'
/
'
))
{
imageUrl
=
currentImageUrl
}
// 裁剪出新的 face_image
const
croppedImage
=
await
cropFaceImage
(
imageUrl
,
newFace
.
bbox
)
// 移除 data URL 前缀,只保留 base64 部分(与后端返回的格式一致)
const
base64Data
=
croppedImage
.
split
(
'
,
'
)[
1
]
||
croppedImage
newFace
.
face_image
=
base64Data
}
catch
(
error
)
{
console
.
error
(
'
Failed to crop face image:
'
,
error
)
}
// 添加到角色列表
form
.
detectedFaces
.
push
(
newFace
)
// 触发响应式更新
form
.
detectedFaces
=
[...
form
.
detectedFaces
]
// 如果是在 s2v 模式下且有上传的音频,自动重新分割音频
if
(
selectedTaskId
.
value
===
'
s2v
'
&&
getCurrentAudioPreview
())
{
try
{
const
audioDataUrl
=
getCurrentAudioPreview
()
await
separateAudioTracks
(
audioDataUrl
,
form
.
detectedFaces
.
length
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to re-separate audio after adding face:
'
,
error
)
}
}
}
else
{
// 编辑现有角色
if
(
!
form
.
detectedFaces
||
currentEditingIndex
<
0
||
!
form
.
detectedFaces
[
currentEditingIndex
])
{
console
.
error
(
'
Invalid editing index or face not found:
'
,
currentEditingIndex
)
return
}
const
face
=
form
.
detectedFaces
[
currentEditingIndex
]
// editingFaceBbox.value 存储的是原始图片坐标 [x1, y1, x2, y2]
face
.
bbox
=
[...
currentBbox
]
// 根据新的 bbox 坐标,从原始图片裁剪出新的 face_image
try
{
let
imageUrl
=
currentImageUrl
if
(
imageUrl
.
startsWith
(
'
data:image
'
))
{
// 保持 data URL 格式,可以直接使用
}
else
if
(
!
imageUrl
.
startsWith
(
'
http
'
)
&&
!
imageUrl
.
startsWith
(
'
/
'
))
{
imageUrl
=
currentImageUrl
}
// 裁剪出新的 face_image
const
croppedImage
=
await
cropFaceImage
(
imageUrl
,
face
.
bbox
)
// 移除 data URL 前缀,只保留 base64 部分(与后端返回的格式一致)
const
base64Data
=
croppedImage
.
split
(
'
,
'
)[
1
]
||
croppedImage
face
.
face_image
=
base64Data
}
catch
(
error
)
{
console
.
error
(
'
Failed to crop face image:
'
,
error
)
}
// 触发响应式更新
form
.
detectedFaces
=
[...
form
.
detectedFaces
]
}
}
finally
{
// 隐藏加载状态
faceSaving
.
value
=
false
}
}
// 删除角色
const
removeFace
=
async
(
faceIndex
)
=>
{
const
form
=
getCurrentForm
()
if
(
!
form
||
!
form
.
detectedFaces
||
faceIndex
<
0
||
faceIndex
>=
form
.
detectedFaces
.
length
)
return
// 从角色列表中删除
form
.
detectedFaces
.
splice
(
faceIndex
,
1
)
// 触发响应式更新
form
.
detectedFaces
=
[...
form
.
detectedFaces
]
// 如果是在 s2v 模式下且有上传的音频,自动重新分割音频
if
(
selectedTaskId
.
value
===
'
s2v
'
&&
getCurrentAudioPreview
()
&&
form
.
detectedFaces
.
length
>
0
)
{
try
{
const
audioDataUrl
=
getCurrentAudioPreview
()
await
separateAudioTracks
(
audioDataUrl
,
form
.
detectedFaces
.
length
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to re-separate audio after removing face:
'
,
error
)
}
}
else
if
(
selectedTaskId
.
value
===
'
s2v
'
)
{
// 如果没有角色了,清空分离的音频
s2vForm
.
value
.
separatedAudios
=
[]
}
}
// 获取图片缩放比例
const
getImageScale
=
()
=>
{
const
container
=
imageContainerRef
.
value
if
(
!
container
)
return
{
scaleX
:
1
,
scaleY
:
1
,
imgWidth
:
0
,
imgHeight
:
0
}
const
img
=
container
.
querySelector
(
'
img
'
)
if
(
!
img
||
!
img
.
complete
)
return
{
scaleX
:
1
,
scaleY
:
1
,
imgWidth
:
0
,
imgHeight
:
0
}
const
imgRect
=
img
.
getBoundingClientRect
()
const
scaleX
=
img
.
naturalWidth
>
0
?
imgRect
.
width
/
img
.
naturalWidth
:
1
const
scaleY
=
img
.
naturalHeight
>
0
?
imgRect
.
height
/
img
.
naturalHeight
:
1
return
{
scaleX
,
scaleY
,
imgWidth
:
imgRect
.
width
,
imgHeight
:
imgRect
.
height
}
}
// 获取图片相对于容器的偏移
const
getImageOffset
=
()
=>
{
const
container
=
imageContainerRef
.
value
if
(
!
container
)
return
{
offsetX
:
0
,
offsetY
:
0
}
const
img
=
container
.
querySelector
(
'
img
'
)
if
(
!
img
)
return
{
offsetX
:
0
,
offsetY
:
0
}
const
containerRect
=
container
.
getBoundingClientRect
()
const
imgRect
=
img
.
getBoundingClientRect
()
return
{
offsetX
:
imgRect
.
left
-
containerRect
.
left
,
offsetY
:
imgRect
.
top
-
containerRect
.
top
}
}
// 开始拖拽边界框
const
startDragBbox
=
(
event
,
type
=
'
move
'
)
=>
{
event
.
preventDefault
()
event
.
stopPropagation
()
const
container
=
imageContainerRef
.
value
if
(
!
container
)
return
const
img
=
container
.
querySelector
(
'
img
'
)
if
(
!
img
)
return
// 获取图片的实际显示尺寸和原始尺寸
const
imgRect
=
img
.
getBoundingClientRect
()
const
containerRect
=
container
.
getBoundingClientRect
()
const
displayWidth
=
imgRect
.
width
const
displayHeight
=
imgRect
.
height
const
naturalWidth
=
img
.
naturalWidth
const
naturalHeight
=
img
.
naturalHeight
// 检查图片是否已加载(通过尺寸判断,而不是 complete 属性)
// 因为 complete 可能在图片尺寸设置之前就为 true
if
(
naturalWidth
===
0
||
naturalHeight
===
0
||
displayWidth
===
0
||
displayHeight
===
0
)
{
console
.
warn
(
'
Image not ready for dragging:
'
,
{
naturalWidth
,
naturalHeight
,
displayWidth
,
displayHeight
,
complete
:
img
.
complete
})
return
}
// 检查 #app 是否有 transform: scale
const
appElement
=
document
.
getElementById
(
'
app
'
)
let
appScale
=
1
if
(
appElement
)
{
const
appStyle
=
window
.
getComputedStyle
(
appElement
)
const
transform
=
appStyle
.
transform
if
(
transform
&&
transform
!==
'
none
'
)
{
const
matrix
=
transform
.
match
(
/matrix
\(([^
)
]
+
)\)
/
)
if
(
matrix
)
{
const
values
=
matrix
[
1
].
split
(
'
,
'
).
map
(
v
=>
parseFloat
(
v
.
trim
()))
appScale
=
values
[
0
]
||
1
}
else
{
const
scaleMatch
=
transform
.
match
(
/scale
\(([^
)
]
+
)\)
/
)
if
(
scaleMatch
)
{
appScale
=
parseFloat
(
scaleMatch
[
1
])
}
}
}
}
// 计算缩放比例(补偿 #app 的缩放)
const
scaleX
=
displayWidth
/
(
naturalWidth
*
appScale
)
const
scaleY
=
displayHeight
/
(
naturalHeight
*
appScale
)
// 图片在容器中的偏移
const
offsetX
=
imgRect
.
left
-
containerRect
.
left
const
offsetY
=
imgRect
.
top
-
containerRect
.
top
// 边界框坐标
const
[
x1
,
y1
,
x2
,
y2
]
=
editingFaceBbox
.
value
// 计算边界框在容器中的显示位置
const
bboxRect
=
{
left
:
offsetX
+
x1
*
scaleX
,
top
:
offsetY
+
y1
*
scaleY
,
right
:
offsetX
+
x2
*
scaleX
,
bottom
:
offsetY
+
y2
*
scaleY
}
// 点击位置相对于容器
const
clickX
=
event
.
clientX
-
containerRect
.
left
const
clickY
=
event
.
clientY
-
containerRect
.
top
// 如果是拖拽手柄(type 不是 'move'),直接开始拖拽
if
(
type
!==
'
move
'
)
{
isDraggingBbox
.
value
=
true
dragType
.
value
=
type
dragStartPos
.
value
=
{
x
:
clickX
,
y
:
clickY
}
dragStartBbox
.
value
=
[...
editingFaceBbox
.
value
]
return
}
// 检查点击是否在边界框内(移动模式)
if
(
clickX
<
bboxRect
.
left
||
clickX
>
bboxRect
.
right
||
clickY
<
bboxRect
.
top
||
clickY
>
bboxRect
.
bottom
)
{
return
}
isDraggingBbox
.
value
=
true
dragType
.
value
=
'
move
'
dragStartPos
.
value
=
{
x
:
clickX
,
y
:
clickY
}
dragStartBbox
.
value
=
[...
editingFaceBbox
.
value
]
}
// 拖拽边界框
const
dragBbox
=
(
event
)
=>
{
if
(
!
isDraggingBbox
.
value
)
return
const
container
=
imageContainerRef
.
value
if
(
!
container
)
return
const
img
=
container
.
querySelector
(
'
img
'
)
if
(
!
img
||
!
img
.
complete
)
return
// 获取图片的实际显示尺寸和原始尺寸
const
imgRect
=
img
.
getBoundingClientRect
()
const
containerRect
=
container
.
getBoundingClientRect
()
const
displayWidth
=
imgRect
.
width
const
displayHeight
=
imgRect
.
height
const
naturalWidth
=
img
.
naturalWidth
const
naturalHeight
=
img
.
naturalHeight
if
(
naturalWidth
===
0
||
naturalHeight
===
0
)
return
// 检查 #app 是否有 transform: scale
const
appElement
=
document
.
getElementById
(
'
app
'
)
let
appScale
=
1
if
(
appElement
)
{
const
appStyle
=
window
.
getComputedStyle
(
appElement
)
const
transform
=
appStyle
.
transform
if
(
transform
&&
transform
!==
'
none
'
)
{
const
matrix
=
transform
.
match
(
/matrix
\(([^
)
]
+
)\)
/
)
if
(
matrix
)
{
const
values
=
matrix
[
1
].
split
(
'
,
'
).
map
(
v
=>
parseFloat
(
v
.
trim
()))
appScale
=
values
[
0
]
||
1
}
else
{
const
scaleMatch
=
transform
.
match
(
/scale
\(([^
)
]
+
)\)
/
)
if
(
scaleMatch
)
{
appScale
=
parseFloat
(
scaleMatch
[
1
])
}
}
}
}
// 计算缩放比例(补偿 #app 的缩放)
// displayWidth 已经是经过 appScale 缩放后的尺寸,所以需要除以 appScale 来得到相对于原始图片的缩放比例
const
scaleX
=
displayWidth
/
(
naturalWidth
*
appScale
)
const
scaleY
=
displayHeight
/
(
naturalHeight
*
appScale
)
// 图片在容器中的偏移
const
offsetX
=
imgRect
.
left
-
containerRect
.
left
const
offsetY
=
imgRect
.
top
-
containerRect
.
top
// 鼠标当前位置相对于容器
const
containerRect2
=
container
.
getBoundingClientRect
()
const
currentX
=
event
.
clientX
-
containerRect2
.
left
const
currentY
=
event
.
clientY
-
containerRect2
.
top
// 将坐标转换为相对于图片的位置(考虑图片在容器中的偏移)
const
imgCurrentX
=
currentX
-
offsetX
const
imgCurrentY
=
currentY
-
offsetY
const
imgStartX
=
dragStartPos
.
value
.
x
-
offsetX
const
imgStartY
=
dragStartPos
.
value
.
y
-
offsetY
const
deltaX
=
(
imgCurrentX
-
imgStartX
)
/
scaleX
const
deltaY
=
(
imgCurrentY
-
imgStartY
)
/
scaleY
// 获取拖拽开始时的bbox
const
[
startX1
,
startY1
,
startX2
,
startY2
]
=
dragStartBbox
.
value
const
startWidth
=
startX2
-
startX1
const
startHeight
=
startY2
-
startY1
let
newX1
=
startX1
let
newY1
=
startY1
let
newX2
=
startX2
let
newY2
=
startY2
// 根据拖拽类型调整bbox
const
type
=
dragType
.
value
if
(
type
===
'
move
'
)
{
// 移动模式:整体移动
newX1
=
startX1
+
deltaX
newY1
=
startY1
+
deltaY
newX2
=
startX2
+
deltaX
newY2
=
startY2
+
deltaY
}
else
if
(
type
===
'
resize-n
'
)
{
// 调整顶部
newY1
=
Math
.
min
(
startY1
+
deltaY
,
startY2
-
10
)
// 最小高度10px
newX1
=
startX1
newX2
=
startX2
newY2
=
startY2
}
else
if
(
type
===
'
resize-s
'
)
{
// 调整底部
newY2
=
Math
.
max
(
startY2
+
deltaY
,
startY1
+
10
)
// 最小高度10px
newX1
=
startX1
newY1
=
startY1
newX2
=
startX2
}
else
if
(
type
===
'
resize-w
'
)
{
// 调整左侧
newX1
=
Math
.
min
(
startX1
+
deltaX
,
startX2
-
10
)
// 最小宽度10px
newY1
=
startY1
newX2
=
startX2
newY2
=
startY2
}
else
if
(
type
===
'
resize-e
'
)
{
// 调整右侧
newX2
=
Math
.
max
(
startX2
+
deltaX
,
startX1
+
10
)
// 最小宽度10px
newX1
=
startX1
newY1
=
startY1
newY2
=
startY2
}
else
if
(
type
===
'
resize-nw
'
)
{
// 调整左上角
newX1
=
Math
.
min
(
startX1
+
deltaX
,
startX2
-
10
)
newY1
=
Math
.
min
(
startY1
+
deltaY
,
startY2
-
10
)
newX2
=
startX2
newY2
=
startY2
}
else
if
(
type
===
'
resize-ne
'
)
{
// 调整右上角
newX2
=
Math
.
max
(
startX2
+
deltaX
,
startX1
+
10
)
newY1
=
Math
.
min
(
startY1
+
deltaY
,
startY2
-
10
)
newX1
=
startX1
newY2
=
startY2
}
else
if
(
type
===
'
resize-sw
'
)
{
// 调整左下角
newX1
=
Math
.
min
(
startX1
+
deltaX
,
startX2
-
10
)
newY2
=
Math
.
max
(
startY2
+
deltaY
,
startY1
+
10
)
newX2
=
startX2
newY1
=
startY1
}
else
if
(
type
===
'
resize-se
'
)
{
// 调整右下角
newX2
=
Math
.
max
(
startX2
+
deltaX
,
startX1
+
10
)
newY2
=
Math
.
max
(
startY2
+
deltaY
,
startY1
+
10
)
newX1
=
startX1
newY1
=
startY1
}
// 边界限制:确保bbox在图片范围内
const
minSize
=
10
// 最小尺寸
// X方向边界限制
if
(
newX1
<
0
)
{
newX1
=
0
if
(
type
.
includes
(
'
w
'
)
||
type
===
'
resize-nw
'
||
type
===
'
resize-sw
'
)
{
// 如果是调整左边或左角,需要保持宽度
newX2
=
Math
.
max
(
newX2
,
minSize
)
}
}
if
(
newX2
>
naturalWidth
)
{
newX2
=
naturalWidth
if
(
type
.
includes
(
'
e
'
)
||
type
===
'
resize-ne
'
||
type
===
'
resize-se
'
)
{
// 如果是调整右边或右角,需要保持宽度
newX1
=
Math
.
min
(
newX1
,
naturalWidth
-
minSize
)
}
}
// Y方向边界限制
if
(
newY1
<
0
)
{
newY1
=
0
if
(
type
.
includes
(
'
n
'
)
||
type
===
'
resize-nw
'
||
type
===
'
resize-ne
'
)
{
// 如果是调整上边或上角,需要保持高度
newY2
=
Math
.
max
(
newY2
,
minSize
)
}
}
if
(
newY2
>
naturalHeight
)
{
newY2
=
naturalHeight
if
(
type
.
includes
(
'
s
'
)
||
type
===
'
resize-sw
'
||
type
===
'
resize-se
'
)
{
// 如果是调整下边或下角,需要保持高度
newY1
=
Math
.
min
(
newY1
,
naturalHeight
-
minSize
)
}
}
// 确保最小尺寸
if
(
newX2
-
newX1
<
minSize
)
{
if
(
type
.
includes
(
'
w
'
)
||
type
===
'
resize-nw
'
||
type
===
'
resize-sw
'
)
{
newX1
=
newX2
-
minSize
}
else
{
newX2
=
newX1
+
minSize
}
}
if
(
newY2
-
newY1
<
minSize
)
{
if
(
type
.
includes
(
'
n
'
)
||
type
===
'
resize-nw
'
||
type
===
'
resize-ne
'
)
{
newY1
=
newY2
-
minSize
}
else
{
newY2
=
newY1
+
minSize
}
}
// 更新边界框坐标
editingFaceBbox
.
value
=
[
newX1
,
newY1
,
newX2
,
newY2
]
}
// 结束拖拽
const
endDragBbox
=
()
=>
{
isDraggingBbox
.
value
=
false
dragType
.
value
=
'
move
'
}
// 计算边界框的样式(用于在放大图片上显示)
const
getBboxStyle
=
computed
(()
=>
{
if
(
!
imageContainerRef
.
value
||
editingFaceBbox
.
value
.
length
!==
4
||
!
imageLoaded
.
value
)
{
return
{}
}
const
container
=
imageContainerRef
.
value
const
img
=
container
.
querySelector
(
'
img
'
)
if
(
!
img
||
!
img
.
complete
||
img
.
naturalWidth
===
0
||
img
.
naturalHeight
===
0
)
{
return
{}
}
// 获取图片的实际显示尺寸
// getBoundingClientRect() 返回的是相对于 viewport 的坐标
// 如果 #app 有 transform: scale(0.8),那么所有元素都会被缩放 0.8
const
imgRect
=
img
.
getBoundingClientRect
()
const
containerRect
=
container
.
getBoundingClientRect
()
// 图片的实际显示尺寸(已考虑所有CSS样式和可能的缩放,包括 #app 的 0.8 缩放)
const
displayWidth
=
imgRect
.
width
const
displayHeight
=
imgRect
.
height
// 图片的原始尺寸(naturalWidth/naturalHeight 是图片文件的真实尺寸)
const
naturalWidth
=
img
.
naturalWidth
const
naturalHeight
=
img
.
naturalHeight
if
(
naturalWidth
===
0
||
naturalHeight
===
0
)
{
return
{}
}
// 检查 #app 是否有 transform: scale
// 如果模态框在 #app 内部,会受到 #app 的 transform 影响
const
appElement
=
document
.
getElementById
(
'
app
'
)
let
appScale
=
1
if
(
appElement
)
{
const
appStyle
=
window
.
getComputedStyle
(
appElement
)
const
transform
=
appStyle
.
transform
if
(
transform
&&
transform
!==
'
none
'
)
{
// 解析 transform matrix 或 scale
const
matrix
=
transform
.
match
(
/matrix
\(([^
)
]
+
)\)
/
)
if
(
matrix
)
{
const
values
=
matrix
[
1
].
split
(
'
,
'
).
map
(
v
=>
parseFloat
(
v
.
trim
()))
// matrix(a, b, c, d, tx, ty) 中,a 和 d 是缩放值
appScale
=
values
[
0
]
||
1
}
else
{
const
scaleMatch
=
transform
.
match
(
/scale
\(([^
)
]
+
)\)
/
)
if
(
scaleMatch
)
{
appScale
=
parseFloat
(
scaleMatch
[
1
])
}
}
}
}
// 计算缩放比例
// displayWidth 已经是经过 appScale 缩放后的尺寸
// 所以相对于原始图片的实际缩放比例是 displayWidth / naturalWidth
// 但由于 #app 的缩放,边界框在模态框中的尺寸需要补偿这个缩放
// 如果 appScale = 0.8,那么边界框的尺寸应该是 displayWidth / appScale / naturalWidth = displayWidth / (naturalWidth * appScale)
const
scaleX
=
displayWidth
/
(
naturalWidth
*
appScale
)
const
scaleY
=
displayHeight
/
(
naturalHeight
*
appScale
)
// 图片在容器中的偏移(相对于容器)
// 如果容器是 inline-block,图片和容器可能在同一位置
// 我们需要检查容器是否包裹了图片,或者图片就是容器的唯一内容
let
offsetX
=
imgRect
.
left
-
containerRect
.
left
let
offsetY
=
imgRect
.
top
-
containerRect
.
top
// 如果计算出的偏移很小(可能是浮点数误差),或者容器和图片尺寸相同,说明图片填充了整个容器
// 在这种情况下,offset 应该为 0
if
(
Math
.
abs
(
offsetX
)
<
1
&&
Math
.
abs
(
offsetY
)
<
1
)
{
offsetX
=
0
offsetY
=
0
}
// 边界框坐标(原始图片坐标 [x1, y1, x2, y2])
// 这些坐标是基于原始图片尺寸的绝对像素坐标
const
[
x1
,
y1
,
x2
,
y2
]
=
editingFaceBbox
.
value
// 转换为显示坐标
// bbox坐标是基于原始图片尺寸的,需要乘以缩放比例得到显示尺寸
// 注意:这里计算的是边界框在容器中的位置和尺寸
const
left
=
offsetX
+
x1
*
scaleX
const
top
=
offsetY
+
y1
*
scaleY
const
width
=
(
x2
-
x1
)
*
scaleX
const
height
=
(
y2
-
y1
)
*
scaleY
const
indicatorSize
=
12
// 确保边界框的尺寸计算正确(考虑 border 的影响)
// border-2 = 2px,左右各2px,所以总宽度需要包含 border
// 但由于使用了 box-sizing: border-box,所以不需要额外调整
return
{
left
:
`
${
left
}
px`
,
top
:
`
${
top
}
px`
,
width
:
`
${
width
}
px`
,
height
:
`
${
height
}
px`
,
indicatorSize
:
indicatorSize
,
boxSizing
:
'
border-box
'
}
})
// 计算角色名字标签的样式(显示在边界框上方)
const
getRoleNameLabelStyle
=
computed
(()
=>
{
const
bboxStyle
=
getBboxStyle
.
value
if
(
!
bboxStyle
.
left
||
!
bboxStyle
.
top
)
{
return
{}
}
// 获取当前编辑的人脸信息
const
form
=
getCurrentForm
()
let
roleName
// 如果是新增模式
if
(
isAddingNewFace
.
value
)
{
// 计算新角色的序号:当前角色数量 + 1
const
newRoleIndex
=
(
form
?.
detectedFaces
?.
length
||
0
)
+
1
roleName
=
`角色
${
newRoleIndex
}
`
}
else
{
// 编辑现有角色
const
face
=
form
?.
detectedFaces
?.[
editingFaceIndex
.
value
]
roleName
=
face
?.
roleName
||
`角色
${
editingFaceIndex
.
value
+
1
}
`
}
// 计算标签位置:在边界框上方居中
const
left
=
parseFloat
(
bboxStyle
.
left
)
||
0
const
top
=
parseFloat
(
bboxStyle
.
top
)
||
0
const
width
=
parseFloat
(
bboxStyle
.
width
)
||
0
// 标签在边界框上方,水平居中
const
labelLeft
=
left
+
width
/
2
return
{
left
:
`
${
labelLeft
}
px`
,
top
:
`
${
top
-
28
}
px`
,
// 在边界框上方 28px
transform
:
'
translateX(-50%)
'
,
// 水平居中
roleName
:
roleName
}
})
// 处理提交任务并滚动到任务区域
const
handleSubmitTask
=
async
()
=>
{
try
{
// 提交任务,获取新任务ID
const
newTaskId
=
await
submitTask
()
if
(
!
newTaskId
)
{
console
.
error
(
'
任务提交失败,未获取到任务ID
'
)
return
}
// 刷新任务列表
await
refreshTasks
(
true
)
// 等待 Vue 更新 DOM
await
nextTick
()
// 查找新创建的任务并设置为当前任务
const
newTask
=
tasks
.
value
.
find
(
task
=>
task
.
task_id
===
newTaskId
)
if
(
newTask
)
{
currentTask
.
value
=
newTask
console
.
log
(
'
已将新任务设置为当前任务:
'
,
newTaskId
)
}
// 滚动到任务区域
scrollToTaskArea
()
}
catch
(
error
)
{
console
.
error
(
'
提交任务失败:
'
,
error
)
}
}
const
handleMasonryVideoLoaded
=
(
event
)
=>
{
onVideoLoaded
(
event
)
scheduleMasonryUpdate
()
}
const
handleMasonryVideoEnded
=
(
event
)
=>
{
onVideoEnded
(
event
)
scheduleMasonryUpdate
()
}
const
handleMasonryVideoError
=
(
event
)
=>
{
onVideoError
(
event
)
scheduleMasonryUpdate
()
}
const
handleMasonryImageLoaded
=
()
=>
{
scheduleMasonryUpdate
()
}
const
handleMasonryImageError
=
(
event
)
=>
{
handleThumbnailError
(
event
)
scheduleMasonryUpdate
()
}
// 滚动到任务区域
const
scrollToTaskArea
=
()
=>
{
const
taskArea
=
document
.
querySelector
(
'
.task-carousel
'
)
if
(
taskArea
)
{
taskArea
.
scrollIntoView
({
behavior
:
'
smooth
'
,
block
:
'
start
'
})
}
}
// 滚动到生成区域
const
scrollToCreationArea
=
()
=>
{
const
creationArea
=
document
.
querySelector
(
'
#task-creator
'
)
if
(
creationArea
)
{
creationArea
.
scrollIntoView
({
behavior
:
'
smooth
'
,
block
:
'
start
'
})
}
}
// 包装 useTemplate 函数,在应用模板后滚动到生成区域
const
handleUseTemplate
=
async
(
item
)
=>
{
await
useTemplate
(
item
)
// 等待 DOM 更新后再滚动
await
nextTick
()
// 延迟一下确保展开动画完成
setTimeout
(()
=>
{
scrollToCreationArea
()
},
100
)
}
// 处理语音合成完成后的回调
const
handleTTSComplete
=
(
audioBlob
)
=>
{
// 创建File对象
const
audioFile
=
new
File
([
audioBlob
],
'
tts_audio.mp3
'
,
{
type
:
'
audio/mpeg
'
})
// 模拟文件上传事件
const
dataTransfer
=
new
DataTransfer
()
dataTransfer
.
items
.
add
(
audioFile
)
const
fileList
=
dataTransfer
.
files
const
event
=
{
target
:
{
files
:
fileList
}
}
// 处理音频上传
handleAudioUpload
(
event
)
// 关闭模态框
showVoiceTTSModal
.
value
=
false
// 显示成功提示
showAlert
(
t
(
'
ttsCompleted
'
),
'
success
'
)
}
// 跳转到项目页面
const
goToProjects
=
()
=>
{
// 构建查询参数,包含当前的表单数据
const
query
=
{}
// 添加任务类型
if
(
selectedTaskId
.
value
)
{
query
.
taskType
=
selectedTaskId
.
value
}
// 添加模型
if
(
selectedModel
.
value
)
{
query
.
model
=
selectedModel
.
value
}
// 添加提示词
if
(
getCurrentForm
().
prompt
)
{
query
.
prompt
=
getCurrentForm
().
prompt
}
// 添加图片(如果有)
if
(
getCurrentImagePreview
())
{
query
.
hasImage
=
'
true
'
}
// 添加音频(如果有)
if
(
getCurrentAudioPreview
())
{
query
.
hasAudio
=
'
true
'
}
// 跳转到项目页面
router
.
push
({
path
:
'
/projects
'
,
query
:
query
})
}
// 获取随机精选模版
const
refreshRandomTemplates
=
async
()
=>
{
const
randomTemplates
=
await
getRandomFeaturedTemplates
(
10
)
// 获取10个模版
currentFeaturedTemplates
.
value
=
randomTemplates
}
// 随机列布局相关函数
const
generateRandomColumnLayout
=
(
templates
)
=>
{
if
(
!
templates
||
templates
.
length
===
0
)
return
{
columns
:
[],
templates
:
[]
}
// 响应式列数控制
const
getColumnCount
=
()
=>
{
if
(
screenSize
.
value
===
'
large
'
)
{
// 大屏幕:4-6列
return
Math
.
floor
(
Math
.
random
()
*
2
)
+
4
// 4, 5, 6列
}
else
{
// 小屏幕:2-3列
return
Math
.
floor
(
Math
.
random
()
*
2
)
+
2
// 2, 3列
}
}
const
numColumns
=
getColumnCount
()
// 生成随机列宽(总和为100%)
const
columnWidths
=
[]
let
remainingWidth
=
100
for
(
let
i
=
0
;
i
<
numColumns
;
i
++
)
{
if
(
i
===
numColumns
-
1
)
{
// 最后一列使用剩余宽度
columnWidths
.
push
(
remainingWidth
)
}
else
{
// 随机宽度:20% 到 50%
const
minWidth
=
20
const
maxWidth
=
Math
.
min
(
50
,
remainingWidth
-
(
numColumns
-
i
-
1
)
*
minWidth
)
const
width
=
Math
.
random
()
*
(
maxWidth
-
minWidth
)
+
minWidth
columnWidths
.
push
(
Math
.
round
(
width
))
remainingWidth
-=
Math
.
round
(
width
)
}
}
// 生成每列的起始位置(距离顶部的距离)
const
columnStartPositions
=
[]
for
(
let
i
=
0
;
i
<
numColumns
;
i
++
)
{
// 随机起始位置:0% 到 20%
const
startPosition
=
Math
.
random
()
*
20
columnStartPositions
.
push
(
Math
.
round
(
startPosition
))
}
// 计算每列的起始left位置
const
columnLeftPositions
=
[]
let
currentLeft
=
0
for
(
let
i
=
0
;
i
<
numColumns
;
i
++
)
{
columnLeftPositions
.
push
(
currentLeft
)
currentLeft
+=
columnWidths
[
i
]
}
// 将模版分配到各列
const
columnTemplates
=
Array
.
from
({
length
:
numColumns
},
()
=>
[])
templates
.
forEach
((
template
,
index
)
=>
{
const
columnIndex
=
index
%
numColumns
columnTemplates
[
columnIndex
].
push
(
template
)
})
// 生成列配置
const
columns
=
columnWidths
.
map
((
width
,
index
)
=>
({
width
:
`
${
width
}
%`
,
left
:
`
${
columnLeftPositions
[
index
]}
%`
,
top
:
`
${
columnStartPositions
[
index
]}
%`
,
templates
:
columnTemplates
[
index
]
}))
return
{
columns
,
templates
}
}
// 计算属性:带随机列布局的模版
const
templatesWithRandomColumns
=
computed
(()
=>
{
return
generateRandomColumnLayout
(
currentFeaturedTemplates
.
value
)
})
watch
(
currentFeaturedTemplates
,
async
()
=>
{
masonryHeight
.
value
=
baseMasonryHeight
.
value
await
nextTick
()
scheduleMasonryUpdate
()
},
{
deep
:
true
})
watch
(
templatesWithRandomColumns
,
async
()
=>
{
await
nextTick
()
scheduleMasonryUpdate
()
})
watch
(
baseMasonryHeight
,
(
value
)
=>
{
masonryHeight
.
value
=
value
scheduleMasonryUpdate
()
})
// 屏幕尺寸监听器
const
updateScreenSize
=
()
=>
{
screenSize
.
value
=
window
.
innerWidth
>=
1024
?
'
large
'
:
'
small
'
}
// 监听屏幕尺寸变化
let
resizeHandler
=
null
// 路由监听和URL同步
// 标记是否正在更新 URL,避免循环更新
let
isUpdatingUrl
=
false
// 标记是否正在从路由恢复状态
let
isRestoringFromRoute
=
false
// 存储待处理的路由参数(当 availableTaskTypes 还未加载完成时)
let
pendingRouteRestore
=
null
// 处理路由参数恢复的函数
const
restoreFromRoute
=
(
newQuery
,
oldQuery
)
=>
{
// 如果正在更新 URL,跳过处理,避免循环更新
if
(
isUpdatingUrl
)
{
return
}
// 如果 availableTaskTypes 还没有加载完成,保存参数等待处理
if
(
availableTaskTypes
.
value
.
length
===
0
)
{
pendingRouteRestore
=
{
newQuery
,
oldQuery
}
return
}
// 标记正在从路由恢复状态
isRestoringFromRoute
=
true
// 同步URL参数到组件状态
// 首次加载时(oldQuery 为 undefined),或者参数真正变化时才更新
const
isInitialLoad
=
!
oldQuery
// 处理任务类型
if
(
newQuery
.
taskType
)
{
const
taskType
=
newQuery
.
taskType
const
shouldUpdate
=
isInitialLoad
||
(
newQuery
.
taskType
!==
oldQuery
?.
taskType
)
// availableTaskTypes 是字符串数组,不是对象数组
if
(
shouldUpdate
&&
availableTaskTypes
.
value
.
includes
(
taskType
))
{
// 如果当前任务类型不匹配,执行 selectTask
// 在首次加载时,即使值已经匹配,也执行 selectTask 以确保所有相关状态正确设置
if
(
selectedTaskId
.
value
!==
taskType
||
isInitialLoad
)
{
selectTask
(
taskType
)
// 等待 selectTask 完成后再处理 model(因为 selectTask 可能会改变 availableModelClasses)
// 使用 nextTick 确保 selectTask 的副作用已完成
nextTick
(()
=>
{
// 再次等待,确保 availableModelClasses 已更新
setTimeout
(()
=>
{
// 处理模型(在任务类型设置后)
if
(
newQuery
.
model
)
{
const
model
=
newQuery
.
model
const
shouldUpdateModel
=
isInitialLoad
||
(
newQuery
.
model
!==
oldQuery
?.
model
)
// availableModelClasses 是字符串数组,不是对象数组
// 在首次加载时,即使值已经匹配,也执行 selectModel 以确保所有相关状态正确设置
if
(
shouldUpdateModel
&&
availableModelClasses
.
value
.
includes
(
model
)
&&
(
selectedModel
.
value
!==
model
||
isInitialLoad
))
{
selectModel
(
model
)
}
}
},
100
)
})
}
}
else
if
(
!
shouldUpdate
&&
newQuery
.
model
)
{
// 如果任务类型没有变化,但需要更新模型
const
model
=
newQuery
.
model
const
shouldUpdateModel
=
isInitialLoad
||
(
newQuery
.
model
!==
oldQuery
?.
model
)
// availableModelClasses 是字符串数组,不是对象数组
// 在首次加载时,即使值已经匹配,也执行 selectModel 以确保所有相关状态正确设置
if
(
shouldUpdateModel
&&
availableModelClasses
.
value
.
includes
(
model
)
&&
(
selectedModel
.
value
!==
model
||
isInitialLoad
))
{
selectModel
(
model
)
}
}
}
else
if
(
newQuery
.
model
&&
selectedTaskId
.
value
)
{
// 如果没有任务类型参数,但任务类型已经设置,直接处理模型
const
model
=
newQuery
.
model
const
shouldUpdate
=
isInitialLoad
||
(
newQuery
.
model
!==
oldQuery
?.
model
)
// availableModelClasses 是字符串数组,不是对象数组
// 在首次加载时,即使值已经匹配,也执行 selectModel 以确保所有相关状态正确设置
if
(
shouldUpdate
&&
availableModelClasses
.
value
.
includes
(
model
)
&&
(
selectedModel
.
value
!==
model
||
isInitialLoad
))
{
selectModel
(
model
)
}
}
// 处理 expanded 状态
const
shouldBeExpanded
=
newQuery
.
expanded
===
'
true
'
if
(
shouldBeExpanded
&&
!
isCreationAreaExpanded
.
value
)
{
// 展开创建区域
expandCreationArea
()
}
else
if
(
!
shouldBeExpanded
&&
isCreationAreaExpanded
.
value
&&
(
isInitialLoad
||
oldQuery
?.
expanded
===
'
true
'
))
{
// 如果 URL 中 expanded 从 'true' 变为其他值,收缩创建区域
contractCreationArea
()
}
// 恢复状态完成,使用 setTimeout 确保所有状态更新完成后再重置标志
setTimeout
(()
=>
{
isRestoringFromRoute
=
false
},
200
)
}
// 监听 availableTaskTypes,当它加载完成后处理待处理的路由恢复
watch
(
availableTaskTypes
,
(
newVal
)
=>
{
if
(
newVal
&&
newVal
.
length
>
0
&&
pendingRouteRestore
)
{
// availableTaskTypes 加载完成,处理待处理的路由恢复
const
{
newQuery
,
oldQuery
}
=
pendingRouteRestore
pendingRouteRestore
=
null
restoreFromRoute
(
newQuery
,
oldQuery
)
}
},
{
immediate
:
true
})
watch
(()
=>
route
.
query
,
(
newQuery
,
oldQuery
)
=>
{
restoreFromRoute
(
newQuery
,
oldQuery
)
// 注意:分享数据导入功能已移至 Share.vue 中的 createSimilar 函数
// 这里不再需要处理分享数据导入
},
{
immediate
:
true
})
// 监听组件状态变化,同步到URL
watch
([
selectedTaskId
,
isCreationAreaExpanded
,
selectedModel
],
(
newVals
,
oldVals
)
=>
{
// 如果正在更新 URL 或正在从路由恢复状态,跳过处理,避免循环更新
if
(
isUpdatingUrl
||
isRestoringFromRoute
)
{
return
}
// 检查任务类型是否变化
const
taskTypeChanged
=
oldVals
&&
oldVals
[
0
]
!==
newVals
[
0
]
// 如果任务类型变化,检查当前模型是否属于新任务类型
if
(
taskTypeChanged
&&
selectedTaskId
.
value
&&
selectedModel
.
value
)
{
const
isModelValid
=
availableModelClasses
.
value
.
includes
(
selectedModel
.
value
)
// 如果模型不属于新任务类型,延迟更新路由,等待模型更新完成
if
(
!
isModelValid
)
{
setTimeout
(()
=>
{
// 再次检查,确保模型已经更新
if
(
!
isUpdatingUrl
&&
!
isRestoringFromRoute
)
{
updateRouteFromState
()
}
},
150
)
return
}
}
updateRouteFromState
()
},
{
deep
:
true
})
// 更新路由的函数
const
updateRouteFromState
=
()
=>
{
// 如果正在更新 URL 或正在从路由恢复状态,跳过处理,避免循环更新
if
(
isUpdatingUrl
||
isRestoringFromRoute
)
{
return
}
// 获取当前查询参数,保留其他参数(如分享相关的参数)
const
currentQuery
=
{
...
route
.
query
}
const
query
=
{}
// 只更新我们关心的参数
if
(
selectedTaskId
.
value
)
{
query
.
taskType
=
selectedTaskId
.
value
}
else
{
// 如果任务类型被清除,也从 URL 中移除
delete
currentQuery
.
taskType
}
if
(
isCreationAreaExpanded
.
value
)
{
query
.
expanded
=
'
true
'
}
else
{
// 如果创作区域收缩,从 URL 中移除 expanded 参数
delete
currentQuery
.
expanded
}
if
(
selectedModel
.
value
)
{
query
.
model
=
selectedModel
.
value
}
else
{
// 如果模型被清除,也从 URL 中移除
delete
currentQuery
.
model
}
// 合并查询参数,保留其他参数
const
finalQuery
=
{
...
currentQuery
,
...
query
}
// 检查是否需要更新 URL(避免不必要的更新)
const
needsUpdate
=
finalQuery
.
taskType
!==
route
.
query
.
taskType
||
finalQuery
.
expanded
!==
route
.
query
.
expanded
||
finalQuery
.
model
!==
route
.
query
.
model
if
(
needsUpdate
)
{
isUpdatingUrl
=
true
// 更新URL但不触发路由监听(使用 replace 而不是 push,避免历史记录堆积)
router
.
replace
({
query
:
finalQuery
}).
finally
(()
=>
{
// 使用 nextTick 确保路由更新完成后再重置标志
nextTick
(()
=>
{
isUpdatingUrl
=
false
})
})
}
}
// 组件挂载时初始化
onMounted
(
async
()
=>
{
// 注意:watch route.query 已经使用 immediate: true 处理了 URL 参数的恢复
// 这里不需要再次处理,避免重复执行
// 如果需要额外的初始化逻辑,可以在这里添加
// 初始化屏幕尺寸
updateScreenSize
()
// 添加屏幕尺寸监听器
resizeHandler
=
()
=>
{
updateScreenSize
()
scheduleMasonryUpdate
()
}
window
.
addEventListener
(
'
resize
'
,
resizeHandler
)
// 添加全局鼠标事件监听用于拖拽边界框
document
.
addEventListener
(
'
mousemove
'
,
dragBbox
)
document
.
addEventListener
(
'
mouseup
'
,
endDragBbox
)
// 加载精选模版数据
await
loadFeaturedTemplates
(
true
)
// 获取随机精选模版
const
randomTemplates
=
await
getRandomFeaturedTemplates
(
10
)
// 获取10个模版
currentFeaturedTemplates
.
value
=
randomTemplates
await
nextTick
()
scheduleMasonryUpdate
()
})
// 拖拽处理函数
const
handleDragOver
=
(
e
)
=>
{
e
.
preventDefault
()
e
.
stopPropagation
()
}
const
handleDragEnter
=
(
e
)
=>
{
e
.
preventDefault
()
e
.
stopPropagation
()
isDragOver
.
value
=
true
}
const
handleDragLeave
=
(
e
)
=>
{
e
.
preventDefault
()
e
.
stopPropagation
()
// 只有当离开整个拖拽区域时才设置为false
if
(
!
e
.
currentTarget
.
contains
(
e
.
relatedTarget
))
{
isDragOver
.
value
=
false
}
}
const
handleImageDrop
=
(
e
)
=>
{
e
.
preventDefault
()
e
.
stopPropagation
()
isDragOver
.
value
=
false
const
files
=
Array
.
from
(
e
.
dataTransfer
.
files
)
const
imageFile
=
files
.
find
(
file
=>
file
.
type
.
startsWith
(
'
image/
'
))
if
(
imageFile
)
{
// 创建FileList对象来模拟input[type="file"]的change事件
const
dataTransfer
=
new
DataTransfer
()
dataTransfer
.
items
.
add
(
imageFile
)
const
fileList
=
dataTransfer
.
files
// 创建模拟的change事件
const
event
=
{
target
:
{
files
:
fileList
}
}
handleImageUpload
(
event
)
showAlert
(
t
(
'
imageDragSuccess
'
),
'
success
'
)
}
else
{
showAlert
(
t
(
'
pleaseDragImage
'
),
'
warning
'
)
}
}
const
handleAudioDrop
=
(
e
)
=>
{
e
.
preventDefault
()
e
.
stopPropagation
()
isDragOver
.
value
=
false
const
files
=
Array
.
from
(
e
.
dataTransfer
.
files
)
const
audioFile
=
files
.
find
(
file
=>
file
.
type
.
startsWith
(
'
audio/
'
)
||
file
.
type
.
startsWith
(
'
video/
'
))
if
(
audioFile
)
{
// 创建FileList对象来模拟input[type="file"]的change事件
const
dataTransfer
=
new
DataTransfer
()
dataTransfer
.
items
.
add
(
audioFile
)
const
fileList
=
dataTransfer
.
files
// 创建模拟的change事件
const
event
=
{
target
:
{
files
:
fileList
}
}
handleAudioUpload
(
event
)
showAlert
(
t
(
'
audioDragSuccess
'
),
'
success
'
)
}
else
{
showAlert
(
t
(
'
pleaseDragAudio
'
),
'
warning
'
)
}
}
// 触发视频上传
const
triggerVideoUpload
=
()
=>
{
// 使用 nextTick 确保 DOM 已更新
nextTick
(()
=>
{
const
videoInput
=
document
.
querySelector
(
'
input[type="file"][data-role="video-input"]
'
)
if
(
videoInput
)
{
videoInput
.
click
()
}
else
{
console
.
warn
(
'
视频输入框未找到,请确保已选择 animate 任务类型
'
)
}
})
}
// 处理视频拖拽上传
const
handleVideoDrop
=
(
e
)
=>
{
e
.
preventDefault
()
e
.
stopPropagation
()
isDragOver
.
value
=
false
const
files
=
Array
.
from
(
e
.
dataTransfer
.
files
)
const
videoFile
=
files
.
find
(
file
=>
file
.
type
.
startsWith
(
'
video/
'
))
if
(
videoFile
)
{
// 创建FileList对象来模拟input[type="file"]的change事件
const
dataTransfer
=
new
DataTransfer
()
dataTransfer
.
items
.
add
(
videoFile
)
const
fileList
=
dataTransfer
.
files
// 创建模拟的change事件
const
event
=
{
target
:
{
files
:
fileList
}
}
handleVideoUpload
(
event
)
showAlert
(
t
(
'
videoDragSuccess
'
),
'
success
'
)
}
else
{
showAlert
(
t
(
'
pleaseDragVideo
'
),
'
warning
'
)
}
}
// 格式化音频预览时间
const
formatAudioPreviewTime
=
(
seconds
)
=>
{
if
(
!
seconds
||
isNaN
(
seconds
))
return
'
0:00
'
const
mins
=
Math
.
floor
(
seconds
/
60
)
const
secs
=
Math
.
floor
(
seconds
%
60
)
return
`
${
mins
}
:
${
secs
.
toString
().
padStart
(
2
,
'
0
'
)}
`
}
// 切换音频预览播放/暂停
const
toggleAudioPreviewPlayback
=
()
=>
{
if
(
!
audioPreviewElement
.
value
)
return
if
(
audioPreviewElement
.
value
.
paused
)
{
audioPreviewElement
.
value
.
play
().
catch
(
error
=>
{
console
.
log
(
'
播放失败:
'
,
error
)
})
}
else
{
audioPreviewElement
.
value
.
pause
()
}
}
// 音频预览加载完成
const
onAudioPreviewLoaded
=
()
=>
{
if
(
audioPreviewElement
.
value
)
{
audioPreviewDuration
.
value
=
audioPreviewElement
.
value
.
duration
||
0
}
}
// 音频预览时间更新
const
onAudioPreviewTimeUpdate
=
()
=>
{
if
(
audioPreviewElement
.
value
&&
!
audioPreviewIsDragging
.
value
)
{
audioPreviewCurrentTime
.
value
=
audioPreviewElement
.
value
.
currentTime
||
0
}
}
// 音频预览进度条变化处理(点击或拖拽)
const
onAudioPreviewProgressChange
=
(
event
)
=>
{
if
(
audioPreviewDuration
.
value
>
0
&&
audioPreviewElement
.
value
&&
event
.
target
)
{
const
newTime
=
parseFloat
(
event
.
target
.
value
)
audioPreviewCurrentTime
.
value
=
newTime
// 立即更新音频位置
audioPreviewElement
.
value
.
currentTime
=
newTime
}
}
// 音频预览进度条拖拽结束处理
const
onAudioPreviewProgressEnd
=
(
event
)
=>
{
if
(
audioPreviewElement
.
value
&&
audioPreviewDuration
.
value
>
0
&&
event
.
target
)
{
const
newTime
=
parseFloat
(
event
.
target
.
value
)
audioPreviewElement
.
value
.
currentTime
=
newTime
audioPreviewCurrentTime
.
value
=
newTime
}
audioPreviewIsDragging
.
value
=
false
}
// 音频预览播放结束
const
onAudioPreviewEnded
=
()
=>
{
audioPreviewIsPlaying
.
value
=
false
audioPreviewCurrentTime
.
value
=
0
}
// 监听音频预览变化,重置状态
watch
(()
=>
getCurrentAudioPreview
(),
(
newPreview
)
=>
{
// 停止当前播放
if
(
audioPreviewElement
.
value
)
{
audioPreviewElement
.
value
.
pause
()
}
audioPreviewIsPlaying
.
value
=
false
audioPreviewCurrentTime
.
value
=
0
audioPreviewDuration
.
value
=
0
if
(
newPreview
)
{
// 等待 DOM 更新后加载新音频
nextTick
(()
=>
{
if
(
audioPreviewElement
.
value
)
{
audioPreviewElement
.
value
.
load
()
}
})
}
})
// 监听分离后的音频变化,重置播放器状态
watch
(()
=>
s2vForm
.
value
.
separatedAudios
,
(
newAudios
,
oldAudios
)
=>
{
// 如果音频列表发生变化(重新分割),清理旧的音频元素和状态
if
(
newAudios
&&
newAudios
.
length
>
0
&&
oldAudios
&&
oldAudios
.
length
>
0
)
{
// 检查是否是重新分割(音频数量或内容发生变化)
const
isReseparation
=
newAudios
.
length
!==
oldAudios
.
length
||
newAudios
.
some
((
audio
,
index
)
=>
{
const
oldAudio
=
oldAudios
[
index
]
return
!
oldAudio
||
audio
.
audioDataUrl
!==
oldAudio
.
audioDataUrl
})
if
(
isReseparation
)
{
// 停止所有正在播放的音频
separatedAudioElements
.
value
.
forEach
((
audioElement
,
index
)
=>
{
if
(
audioElement
)
{
audioElement
.
pause
()
separatedAudioElements
.
value
[
index
]
=
null
}
})
// 清理所有状态
separatedAudioElements
.
value
=
[]
separatedAudioPlaying
.
value
=
{}
separatedAudioDuration
.
value
=
{}
separatedAudioCurrentTime
.
value
=
{}
separatedAudioIsDragging
.
value
=
{}
// 等待 DOM 更新后重新加载音频
nextTick
(()
=>
{
// 音频元素会在模板中自动重新创建和加载
})
}
}
else
if
(
!
newAudios
||
newAudios
.
length
===
0
)
{
// 如果音频列表被清空,清理所有状态
separatedAudioElements
.
value
.
forEach
((
audioElement
,
index
)
=>
{
if
(
audioElement
)
{
audioElement
.
pause
()
separatedAudioElements
.
value
[
index
]
=
null
}
})
separatedAudioElements
.
value
=
[]
separatedAudioPlaying
.
value
=
{}
separatedAudioDuration
.
value
=
{}
separatedAudioCurrentTime
.
value
=
{}
separatedAudioIsDragging
.
value
=
{}
}
},
{
deep
:
true
})
// 分离后的音频播放器控制函数
const
toggleSeparatedAudioPlayback
=
(
index
)
=>
{
const
audioElement
=
separatedAudioElements
.
value
[
index
]
if
(
!
audioElement
)
return
if
(
audioElement
.
paused
)
{
audioElement
.
play
().
catch
(
error
=>
{
console
.
log
(
'
播放失败:
'
,
error
)
})
}
else
{
audioElement
.
pause
()
}
}
const
getSeparatedAudioPlaying
=
(
index
)
=>
{
return
separatedAudioPlaying
.
value
[
index
]
||
false
}
const
getSeparatedAudioDuration
=
(
index
)
=>
{
return
separatedAudioDuration
.
value
[
index
]
||
0
}
const
getSeparatedAudioCurrentTime
=
(
index
)
=>
{
return
separatedAudioCurrentTime
.
value
[
index
]
||
0
}
const
onSeparatedAudioLoaded
=
(
index
)
=>
{
const
audioElement
=
separatedAudioElements
.
value
[
index
]
if
(
audioElement
)
{
separatedAudioDuration
.
value
[
index
]
=
audioElement
.
duration
||
0
}
}
const
onSeparatedAudioTimeUpdate
=
(
index
)
=>
{
const
audioElement
=
separatedAudioElements
.
value
[
index
]
if
(
audioElement
&&
!
separatedAudioIsDragging
.
value
[
index
])
{
separatedAudioCurrentTime
.
value
[
index
]
=
audioElement
.
currentTime
||
0
}
}
const
onSeparatedAudioProgressChange
=
(
index
,
event
)
=>
{
if
(
separatedAudioDuration
.
value
[
index
]
>
0
&&
separatedAudioElements
.
value
[
index
]
&&
event
.
target
)
{
const
newTime
=
parseFloat
(
event
.
target
.
value
)
separatedAudioCurrentTime
.
value
[
index
]
=
newTime
separatedAudioElements
.
value
[
index
].
currentTime
=
newTime
}
}
const
onSeparatedAudioProgressEnd
=
(
index
,
event
)
=>
{
const
audioElement
=
separatedAudioElements
.
value
[
index
]
if
(
audioElement
&&
separatedAudioDuration
.
value
[
index
]
>
0
&&
event
.
target
)
{
const
newTime
=
parseFloat
(
event
.
target
.
value
)
audioElement
.
currentTime
=
newTime
separatedAudioCurrentTime
.
value
[
index
]
=
newTime
}
separatedAudioIsDragging
.
value
[
index
]
=
false
}
const
onSeparatedAudioEnded
=
(
index
)
=>
{
separatedAudioPlaying
.
value
[
index
]
=
false
separatedAudioCurrentTime
.
value
[
index
]
=
0
}
const
handleSeparatedAudioError
=
(
index
)
=>
{
console
.
error
(
`分离后的音频
${
index
}
加载失败`
)
separatedAudioPlaying
.
value
[
index
]
=
false
}
// 拖拽排序函数 - 角色
const
onRoleDragStart
=
(
event
,
index
)
=>
{
draggedRoleIndex
.
value
=
index
event
.
dataTransfer
.
effectAllowed
=
'
move
'
event
.
dataTransfer
.
setData
(
'
text/html
'
,
event
.
target
.
outerHTML
)
// 创建拖拽预览
const
target
=
event
.
currentTarget
const
rect
=
target
.
getBoundingClientRect
()
dragOffset
.
value
=
{
x
:
event
.
clientX
-
rect
.
left
,
y
:
event
.
clientY
-
rect
.
top
}
// 创建拖拽预览图片
const
dragImage
=
target
.
cloneNode
(
true
)
// 设置固定尺寸,确保预览正确显示
dragImage
.
style
.
width
=
`
${
rect
.
width
}
px`
dragImage
.
style
.
height
=
`
${
rect
.
height
}
px`
dragImage
.
style
.
position
=
'
fixed
'
dragImage
.
style
.
top
=
'
-9999px
'
dragImage
.
style
.
left
=
'
-9999px
'
dragImage
.
style
.
opacity
=
'
0.9
'
dragImage
.
style
.
transform
=
'
rotate(2deg)
'
dragImage
.
style
.
pointerEvents
=
'
none
'
dragImage
.
style
.
zIndex
=
'
10000
'
dragImage
.
style
.
boxShadow
=
'
0 8px 24px rgba(0,0,0,0.3)
'
dragImage
.
style
.
backgroundColor
=
'
transparent
'
// 立即添加到 DOM
document
.
body
.
appendChild
(
dragImage
)
// 强制重排,确保元素已渲染
void
dragImage
.
offsetHeight
// 同步设置拖拽图片(必须在 dragstart 事件中同步调用)
try
{
event
.
dataTransfer
.
setDragImage
(
dragImage
,
dragOffset
.
value
.
x
,
dragOffset
.
value
.
y
)
}
catch
(
e
)
{
console
.
warn
(
'
Failed to set drag image:
'
,
e
)
}
// 延迟移除预览元素
setTimeout
(()
=>
{
if
(
dragImage
.
parentNode
)
{
dragImage
.
parentNode
.
removeChild
(
dragImage
)
}
},
0
)
}
const
onRoleDragOver
=
(
event
,
index
)
=>
{
event
.
preventDefault
()
event
.
dataTransfer
.
dropEffect
=
'
move
'
if
(
draggedRoleIndex
.
value
!==
index
)
{
dragOverRoleIndex
.
value
=
index
}
}
const
onRoleDragLeave
=
()
=>
{
dragOverRoleIndex
.
value
=
-
1
}
const
onRoleDrop
=
(
event
,
targetIndex
)
=>
{
event
.
preventDefault
()
if
(
draggedRoleIndex
.
value
===
-
1
||
draggedRoleIndex
.
value
===
targetIndex
)
{
draggedRoleIndex
.
value
=
-
1
dragOverRoleIndex
.
value
=
-
1
return
}
const
form
=
getCurrentForm
()
if
(
!
form
||
!
form
.
detectedFaces
)
return
// 保存原始状态
const
originalFaces
=
[...
form
.
detectedFaces
]
// 重新排序角色(只改变角色顺序,不影响音频顺序)
const
faces
=
[...
form
.
detectedFaces
]
const
draggedFace
=
faces
[
draggedRoleIndex
.
value
]
faces
.
splice
(
draggedRoleIndex
.
value
,
1
)
faces
.
splice
(
targetIndex
,
0
,
draggedFace
)
form
.
detectedFaces
=
faces
// 更新音频的 roleIndex 和 roleName,以匹配新的角色位置
// 但不改变音频的显示顺序
if
(
s2vForm
.
value
.
separatedAudios
&&
s2vForm
.
value
.
separatedAudios
.
length
>
0
)
{
s2vForm
.
value
.
separatedAudios
.
forEach
((
audio
)
=>
{
// 找到这个音频原来对应的角色
const
originalRoleIndex
=
audio
.
roleIndex
!==
undefined
?
audio
.
roleIndex
:
-
1
if
(
originalRoleIndex
>=
0
&&
originalRoleIndex
<
originalFaces
.
length
)
{
const
originalFace
=
originalFaces
[
originalRoleIndex
]
// 找到这个角色在新列表中的位置
const
newRoleIndex
=
faces
.
findIndex
(
f
=>
f
===
originalFace
)
if
(
newRoleIndex
>=
0
)
{
audio
.
roleIndex
=
newRoleIndex
audio
.
roleName
=
faces
[
newRoleIndex
].
roleName
||
`角色
${
newRoleIndex
+
1
}
`
}
}
})
// 触发响应式更新
s2vForm
.
value
.
separatedAudios
=
[...
s2vForm
.
value
.
separatedAudios
]
}
draggedRoleIndex
.
value
=
-
1
dragOverRoleIndex
.
value
=
-
1
}
// 拖拽排序函数 - 音频
const
onAudioDragStart
=
(
event
,
index
)
=>
{
draggedAudioIndex
.
value
=
index
event
.
dataTransfer
.
effectAllowed
=
'
move
'
event
.
dataTransfer
.
setData
(
'
text/html
'
,
event
.
target
.
outerHTML
)
// 创建拖拽预览
const
target
=
event
.
currentTarget
const
rect
=
target
.
getBoundingClientRect
()
dragOffset
.
value
=
{
x
:
event
.
clientX
-
rect
.
left
,
y
:
event
.
clientY
-
rect
.
top
}
// 创建拖拽预览图片
const
dragImage
=
target
.
cloneNode
(
true
)
// 设置固定尺寸,确保预览正确显示
dragImage
.
style
.
width
=
`
${
rect
.
width
}
px`
dragImage
.
style
.
height
=
`
${
rect
.
height
}
px`
dragImage
.
style
.
position
=
'
fixed
'
dragImage
.
style
.
top
=
'
-9999px
'
dragImage
.
style
.
left
=
'
-9999px
'
dragImage
.
style
.
opacity
=
'
0.9
'
dragImage
.
style
.
transform
=
'
rotate(2deg)
'
dragImage
.
style
.
pointerEvents
=
'
none
'
dragImage
.
style
.
zIndex
=
'
10000
'
dragImage
.
style
.
boxShadow
=
'
0 8px 24px rgba(0,0,0,0.3)
'
dragImage
.
style
.
backgroundColor
=
'
transparent
'
// 立即添加到 DOM
document
.
body
.
appendChild
(
dragImage
)
// 强制重排,确保元素已渲染
void
dragImage
.
offsetHeight
// 同步设置拖拽图片(必须在 dragstart 事件中同步调用)
try
{
event
.
dataTransfer
.
setDragImage
(
dragImage
,
dragOffset
.
value
.
x
,
dragOffset
.
value
.
y
)
}
catch
(
e
)
{
console
.
warn
(
'
Failed to set drag image:
'
,
e
)
}
// 延迟移除预览元素
setTimeout
(()
=>
{
if
(
dragImage
.
parentNode
)
{
dragImage
.
parentNode
.
removeChild
(
dragImage
)
}
},
0
)
}
const
onAudioDragOver
=
(
event
,
index
)
=>
{
event
.
preventDefault
()
event
.
dataTransfer
.
dropEffect
=
'
move
'
if
(
draggedAudioIndex
.
value
!==
index
)
{
dragOverAudioIndex
.
value
=
index
}
}
const
onAudioDragLeave
=
()
=>
{
dragOverAudioIndex
.
value
=
-
1
}
const
onAudioDrop
=
(
event
,
targetIndex
)
=>
{
event
.
preventDefault
()
if
(
draggedAudioIndex
.
value
===
-
1
||
draggedAudioIndex
.
value
===
targetIndex
)
{
draggedAudioIndex
.
value
=
-
1
dragOverAudioIndex
.
value
=
-
1
return
}
if
(
!
s2vForm
.
value
.
separatedAudios
)
return
// 重新排序音频(只改变音频顺序,不影响角色顺序)
const
audios
=
[...
s2vForm
.
value
.
separatedAudios
]
const
draggedAudio
=
audios
[
draggedAudioIndex
.
value
]
audios
.
splice
(
draggedAudioIndex
.
value
,
1
)
audios
.
splice
(
targetIndex
,
0
,
draggedAudio
)
// 音频的 roleIndex 和 roleName 保持不变,因为它们仍然对应原来的角色
// 不需要更新 roleIndex,因为角色顺序没有改变
s2vForm
.
value
.
separatedAudios
=
audios
draggedAudioIndex
.
value
=
-
1
dragOverAudioIndex
.
value
=
-
1
}
// 组件卸载时清理
onUnmounted
(()
=>
{
if
(
resizeHandler
)
{
window
.
removeEventListener
(
'
resize
'
,
resizeHandler
)
}
// 停止音频预览播放
if
(
audioPreviewElement
.
value
)
{
audioPreviewElement
.
value
.
pause
()
audioPreviewElement
.
value
=
null
}
// 停止并清理所有分离后的音频播放器
separatedAudioElements
.
value
.
forEach
((
audioElement
,
index
)
=>
{
if
(
audioElement
)
{
audioElement
.
pause
()
separatedAudioElements
.
value
[
index
]
=
null
}
})
separatedAudioElements
.
value
=
[]
separatedAudioPlaying
.
value
=
{}
separatedAudioDuration
.
value
=
{}
separatedAudioCurrentTime
.
value
=
{}
separatedAudioIsDragging
.
value
=
{}
})
</
script
>
<
template
>
<div
v-if=
"templateLoading || downloadLoading"
class=
"fixed right-6 top-24 sm:top-20 z-[9999] w-auto min-w-[260px] sm:min-w-[300px] max-w-[calc(100vw-2.5rem)] sm:max-w-md px-4 sm:px-5 transition-all duration-300 ease-out"
>
<div
class=
"pointer-events-auto text-[#1d1d1f] bg-white/95 dark:text-white dark:bg-[#0d0d12]/90 backdrop-blur-[20px] backdrop-saturate-[180%] border border-black/8 dark:border-white/8 rounded-2xl shadow-[0_4px_6px_-1px_rgba(0,0,0,0.1),0_2px_4px_-1px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.05)] dark:shadow-[0_12px_32px_rgba(0,0,0,0.6),0_4px_12px_rgba(0,0,0,0.4),0_0_0_1px_rgba(255,255,255,0.06)]"
>
<div
class=
"flex items-center gap-3 px-5 py-3"
>
<div
class=
"flex items-center justify-center w-9 h-9 rounded-full bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
>
<i
class=
"fas fa-spinner fa-spin text-sm"
></i>
</div>
<div
class=
"flex-1 text-sm font-medium tracking-tight"
>
{{
templateLoading
?
(
templateLoadingMessage
||
t
(
'
prefillLoadingDefault
'
))
:
(
downloadLoadingMessage
||
t
(
'
downloadPreparing
'
))
}}
</div>
</div>
</div>
</div>
<!-- 主内容区域 - 响应式布局 -->
<div
class=
"flex-1 flex flex-col min-h-0 mobile-content"
>
<!-- 生成视频区域 -->
<div
class=
"flex-1 flex flex-col"
>
<!-- 内容区域 -->
<div
class=
"flex-1 p-6"
>
<!-- 任务创建面板 -->
<div
class=
"max-w-4xl mx-auto min-h-[100vh] flex flex-col"
id=
"task-creator"
>
<!-- 合并的创作区域 -->
<div
class=
"creation-area-container flex-1 flex flex-col justify-center"
>
<div
class=
"default-state-container"
>
<!-- 两个并列的下拉菜单 - Apple 风格 -->
<div
class=
"flex justify-center gap-6 mb-10"
>
<!-- 任务类型下拉菜单 -->
<ModelDropdown
:available-models=
"availableTaskTypes.map(taskType => (
{
value: taskType,
label: getTaskTypeName(taskType),
icon: getTaskTypeIcon(taskType)
}))"
:selected-model="selectedTaskId"
@select-model="selectTask"
/>
<!-- 模型选择下拉菜单 -->
<ModelDropdown
:available-models=
"availableModelClasses"
:selected-model=
"getCurrentForm().model_cls"
@
select-model=
"selectModel"
/>
</div>
<!-- 默认状态:中心文字 -->
<div
v-show=
"!isCreationAreaExpanded"
class=
"flex flex-col items-center justify-center"
>
<div
class=
"text-center"
>
<h2
class=
"text-3xl sm:text-3xl md:text-4xl lg:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-8 tracking-tight"
>
{{
t
(
'
whatDoYouWantToDo
'
)
}}
</h2>
<!-- 动态滚动提示 - Apple 风格 -->
<div
class=
"hint-container mb-12 pb-10"
>
<div
class=
"hint-text text-[#86868b] dark:text-[#98989d] text-base min-h-[60px] flex items-center justify-center tracking-tight"
>
<transition
name=
"hint-fade"
mode=
"out-in"
>
<p
:key=
"currentHintIndex"
class=
"text-center max-w-2xl"
>
{{
currentTaskHints
[
currentHintIndex
]
}}
</p>
</transition>
</div>
<!-- 提示指示器 - Apple 风格 -->
<div
class=
"flex justify-center mt-6 gap-2"
>
<div
v-for=
"(hint, index) in currentTaskHints"
:key=
"index"
class=
"w-2 h-2 rounded-full transition-all duration-300"
:class=
"index === currentHintIndex ? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] scale-110' : 'bg-[#86868b]/30 dark:bg-[#98989d]/30'"
>
</div>
</div>
</div>
</div>
<!-- 展开开关 - Apple 极简风格 -->
<div
class=
"relative group cursor-pointer"
@
click=
"expandCreationArea"
>
<button
class=
"cursor-pointer relative bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-full px-8 py-4 text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)] active:scale-100 transition-all duration-200 ease-out min-w-[250px] max-w-[400px] tracking-tight"
>
<i
class=
"fi fi-sr-cursor-finger-click text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] transition-all duration-200 pointer-events-none"
></i>
<span
class=
"pl-2 text-base font-semibold transition-all duration-200 pointer-events-none"
>
{{
t
(
'
startCreatingVideo
'
)
}}
</span>
</button>
</div>
</div>
<!-- 展开状态:素材区域 -->
<div
v-if=
"isCreationAreaExpanded"
class=
"mb-8 prompt-input-section"
>
<!-- 中心文字 - Apple 风格 -->
<div
class=
"text-center"
>
<h2
class=
"text-3xl sm:text-3xl md:text-3xl lg:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-6 animate-fade-in tracking-tight"
>
{{
t
(
'
whatMaterialsDoYouNeed
'
)
}}
</h2>
<p
class=
"text-[#86868b] dark:text-[#98989d] text-base mb-10 transition-all duration-300 tracking-tight"
>
<span
v-if=
"selectedTaskId === 't2v'"
class=
"inline-block animate-fade-in"
>
{{
t
(
'
pleaseEnterTheMostDetailedVideoScript
'
)
}}
</span>
<span
v-else-if=
"selectedTaskId === 'i2v'"
class=
"inline-block animate-fade-in"
>
{{
t
(
'
pleaseUploadAnImageAsTheFirstFrameOfTheVideoAndTheMostDetailedVideoScript
'
)
}}
</span>
<span
v-else-if=
"selectedTaskId === 's2v'"
class=
"inline-block animate-fade-in"
>
{{
t
(
'
pleaseUploadARoleImageAnAudioAndTheGeneralVideoRequirements
'
)
}}
</span>
<span
v-else
class=
"inline-block animate-fade-in"
>
选择任务类型开始创作您的视频
</span>
</p>
</div>
<!-- 收缩开关 - Apple 风格 -->
<div
class=
"creation-area transition-all duration-500 ease-out max-w-10xl mx-auto"
@
click.stop
>
<!-- 收起按钮 - Apple 风格 -->
<div
class=
"flex justify-center mb-6"
>
<button
@
click=
"contractCreationArea"
class=
"flex items-center gap-2 px-4 py-2 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/6 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/10 dark:hover:border-white/12 hover:shadow-[0_2px_8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_2px_8px_rgba(0,0,0,0.3)] rounded-full transition-all duration-200 ease-out group"
:class=
"
{ 'animate-pulse': isContracting }">
<i
class=
"fas fa-compress-alt text-sm transition-transform duration-200 group-hover:scale-110"
:class=
"
{ 'animate-spin': isContracting }">
</i>
<span
class=
"text-sm font-medium tracking-tight"
>
{{
t
(
'
collapseCreationArea
'
)
}}
</span>
<i
class=
"fas fa-chevron-up text-xs transition-transform duration-200 group-hover:translate-y-[-2px]"
:class=
"
{ 'animate-bounce': isContracting }">
</i>
</button>
</div>
<div
v-if=
"selectedTaskId === 'i2v' || selectedTaskId === 's2v' || selectedTaskId === 'animate'"
class=
"upload-section"
>
<!-- 上传图片 - Apple 风格 -->
<div
v-if=
"selectedTaskId === 'i2v' || selectedTaskId === 's2v' || selectedTaskId === 'animate'"
>
<!-- 图片标签 -->
<div
class=
"flex justify-between items-center mb-3"
>
<label
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
t
(
'
image
'
)
}}
</label>
</div>
<!-- 上传图片区域 - Apple 风格 -->
<div
class=
"relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl p-2 min-h-[220px] transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_16px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)]"
@
drop=
"handleImageDrop"
@
dragover=
"handleDragOver"
@
dragenter=
"handleDragEnter"
@
dragleave=
"handleDragLeave"
:class=
"
{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10': isDragOver,
'p-8': !getCurrentImagePreview()
}"
>
<!-- 默认上传界面 - Apple 风格 -->
<div
v-if=
"!getCurrentImagePreview()"
class=
"flex flex-col items-center justify-center h-full"
>
<p
class=
"text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-2 tracking-tight"
>
{{
t
(
'
uploadImage
'
)
}}
</p>
<p
class=
"text-xs text-[#86868b] dark:text-[#98989d] mb-6 tracking-tight"
>
{{
t
(
'
supportedImageFormats
'
)
}}
</p>
<div
class=
"flex items-center justify-center gap-4"
>
<div
class=
"flex flex-col items-center gap-2"
>
<button
class=
"w-12 h-12 flex items-center justify-center bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.3)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100"
@
click=
"triggerImageUpload"
:title=
"t('uploadImage')"
>
<i
class=
"fas fa-upload text-base"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
upload
'
)
}}
</span>
</div>
<div
class=
"flex flex-col items-center gap-2"
>
<button
@
click.stop=
"showImageTemplates = true; mediaModalTab = 'history'; getImageHistory()"
class=
"w-12 h-12 flex items-center justify-center bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 text-[#1d1d1f] dark:text-[#f5f5f7] rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-100"
:title=
"t('templates')"
>
<i
class=
"fas fa-history text-base"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
templates
'
)
}}
</span>
</div>
</div>
</div>
<!-- 图片预览区域 - 只显示主图 -->
<div
v-if=
"getCurrentImagePreview()"
class=
"flex items-center justify-center w-full min-h-[220px]"
>
<!-- 主图预览 - Apple 风格 -->
<div
class=
"relative w-auto max-w-full min-h-[220px] flex items-center justify-center group"
>
<img
:src=
"getCurrentImagePreviewUrl()"
alt=
"t('previewImage')"
class=
"max-w-full max-h-[220px] w-auto h-auto object-contain rounded-xl transition-all duration-200"
>
<!-- 删除按钮 - Apple 风格 -->
<div
class=
"absolute inset-x-0 bottom-4 flex items-center justify-center opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200"
>
<button
@
click.stop=
"handleRemoveImage"
class=
"w-11 h-11 flex items-center justify-center bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 text-red-500 dark:text-red-400 rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(239,68,68,0.2)] dark:hover:shadow-[0_4px_12px_rgba(248,113,113,0.3)] active:scale-100"
:title=
"t('deleteImage')"
>
<i
class=
"fas fa-trash text-base"
></i>
</button>
</div>
</div>
</div>
<input
type=
"file"
ref=
"imageInput"
@
change=
"handleImageUpload"
accept=
"image/*"
style=
"display: none;"
>
</div>
<!-- 角色检测加载提示 -->
<div
v-if=
"faceDetecting"
class=
"mt-3 flex items-center justify-center gap-2 text-sm text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{
t
(
'
detectingCharacters
'
)
}}
</span>
</div>
</div>
<!-- 上传音频 - Apple 风格 -->
<div
v-if=
"selectedTaskId === 's2v'"
>
<!-- 音频标签 -->
<div
class=
"flex justify-between items-center mb-3"
>
<label
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
t
(
'
audio
'
)
}}
</label>
</div>
<!-- 上传音频区域 - Apple 风格 -->
<div
class=
"relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl p-2 min-h-[220px] transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_16px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)]"
@
drop=
"handleAudioDrop"
@
dragover=
"handleDragOver"
@
dragenter=
"handleDragEnter"
@
dragleave=
"handleDragLeave"
:class=
"
{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10': isDragOver,
'p-8': !getCurrentAudioPreview()
}"
>
<!-- 默认上传界面 - Apple 风格 -->
<div
v-if=
"!getCurrentAudioPreview()"
class=
"flex flex-col items-center justify-center h-full"
>
<p
class=
"text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-2 tracking-tight"
>
{{
t
(
'
uploadAudio
'
)
}}
</p>
<p
class=
"text-xs text-[#86868b] dark:text-[#98989d] mb-6 tracking-tight"
>
{{
t
(
'
supportedAudioFormats
'
)
}}
</p>
<div
class=
"flex items-center justify-center gap-3"
>
<div
class=
"flex flex-col items-center gap-2"
>
<button
@
click.stop=
"showVoiceTTSModal = true"
class=
"w-12 h-12 flex items-center justify-center bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border border-black/8 dark:border-white/8 text-white rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-100"
:title=
"t('textToSpeech')"
>
<i
class=
"fi fi-bs-text text-lg"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
textToSpeech
'
)
}}
</span>
</div>
<div
class=
"flex flex-col items-center gap-2"
>
<button
@
click.stop=
"router.push('/podcast_generate')"
class=
"w-12 h-12 flex items-center justify-center bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border border-black/8 dark:border-white/8 text-white rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-100"
:title=
"t('podcast.dualPersonPodcast')"
>
<!-- 讲话的icon,用fa-microphone-alt如果有,否则fa-microphone -->
<i
class=
"fi fi-bs-signal-stream text-xl"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
podcast.dualPersonPodcast
'
)
}}
</span>
</div>
<div
class=
"flex flex-col items-center gap-2"
>
<button
class=
"w-12 h-12 flex items-center justify-center bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.3)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100"
@
click=
"triggerAudioUpload"
:title=
"t('uploadAudio')"
>
<i
class=
"fas fa-upload text-base"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
upload
'
)
}}
</span>
</div>
<div
class=
"flex flex-col items-center gap-2"
>
<button
@
click.stop=
"showAudioTemplates = true; mediaModalTab = 'history'; getAudioHistory()"
class=
"w-12 h-12 flex items-center justify-center bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 text-[#1d1d1f] dark:text-[#f5f5f7] rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-100"
:title=
"t('templates')"
>
<i
class=
"fas fa-history text-base"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
templates
'
)
}}
</span>
</div>
<div
class=
"flex flex-col items-center gap-2"
>
<button
@
click.stop=
"isRecording ? stopRecording() : startRecording()"
class=
"w-12 h-12 flex items-center justify-center rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
:class=
"isRecording ? 'bg-red-500 dark:bg-red-400 text-white shadow-[0_4px_12px_rgba(239,68,68,0.3)] dark:shadow-[0_4px_12px_rgba(248,113,113,0.4)]' : 'bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 text-[#1d1d1f] dark:text-[#f5f5f7] hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)]'"
:title=
"isRecording ? t('stopRecording') : t('recordAudio')"
>
<i
class=
"fas fa-microphone-alt text-base"
:class=
"
{ 'animate-pulse': isRecording }">
</i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
isRecording
?
formatRecordingDuration
(
recordingDuration
)
:
t
(
'
recordAudio
'
)
}}
</span>
</div>
</div>
</div>
<!-- 音频预览 - 原始音频播放器 -->
<div
v-if=
"getCurrentAudioPreview()"
class=
"relative w-full min-h-[220px] flex items-center justify-center"
>
<div
class=
"bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] w-full p-4"
>
<div
class=
"relative flex items-center mb-3"
>
<!-- 头像容器 -->
<div
class=
"relative mr-3 flex-shrink-0"
>
<!-- 透明白色头像 -->
<div
class=
"w-12 h-12 rounded-full bg-white/40 dark:bg-white/20 border border-white/30 dark:border-white/20 transition-all duration-200"
></div>
<!-- 播放/暂停按钮 -->
<button
@
click=
"toggleAudioPreviewPlayback"
class=
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 rounded-full flex items-center justify-center text-white cursor-pointer hover:scale-110 transition-all duration-200 z-20 shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)]"
>
<i
:class=
"audioPreviewIsPlaying ? 'fas fa-pause' : 'fas fa-play'"
class=
"text-xs ml-0.5"
></i>
</button>
</div>
<!-- 音频信息 -->
<div
class=
"flex-1 min-w-0"
>
<div
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate"
>
{{
t
(
'
audio
'
)
}}
</div>
</div>
<!-- 音频时长 -->
<div
class=
"text-xs font-medium text-[#86868b] dark:text-[#98989d] tracking-tight flex-shrink-0 mr-3"
>
{{
formatAudioPreviewTime
(
audioPreviewCurrentTime
)
}}
/
{{
formatAudioPreviewTime
(
audioPreviewDuration
)
}}
</div>
<!-- 删除按钮 -->
<button
@
click.stop=
"removeAudio"
class=
"w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-red-500 dark:text-red-400 rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(239,68,68,0.2)] dark:hover:shadow-[0_4px_12px_rgba(248,113,113,0.3)] active:scale-100 flex-shrink-0"
:title=
"t('deleteAudio')"
>
<i
class=
"fas fa-trash text-sm"
></i>
</button>
</div>
<!-- 进度条 -->
<div
class=
"flex items-center gap-2"
v-if=
"audioPreviewDuration > 0"
>
<input
type=
"range"
:min=
"0"
:max=
"audioPreviewDuration"
:value=
"audioPreviewCurrentTime"
@
input=
"onAudioPreviewProgressChange"
@
change=
"onAudioPreviewProgressChange"
@
mousedown=
"audioPreviewIsDragging = true"
@
mouseup=
"onAudioPreviewProgressEnd"
@
touchstart=
"audioPreviewIsDragging = true"
@
touchend=
"onAudioPreviewProgressEnd"
class=
"flex-1 h-1 bg-black/6 dark:bg-white/15 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-[color:var(--brand-primary)] dark:[&::-webkit-slider-thumb]:bg-[color:var(--brand-primary-light)] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
</div>
<!-- 隐藏的音频元素 -->
<audio
ref=
"audioPreviewElement"
:src=
"getCurrentAudioPreviewUrl()"
@
loadedmetadata=
"onAudioPreviewLoaded"
@
timeupdate=
"onAudioPreviewTimeUpdate"
@
ended=
"onAudioPreviewEnded"
@
play=
"audioPreviewIsPlaying = true"
@
pause=
"audioPreviewIsPlaying = false"
@
error=
"handleAudioError"
class=
"hidden"
></audio>
</div>
<input
type=
"file"
ref=
"audioInput"
@
change=
"handleAudioUpload"
accept=
"audio/*,audio/mp4,audio/x-m4a,video/*"
data-role=
"audio-input"
style=
"display: none;"
>
</div>
<!-- 音频分割加载提示 -->
<div
v-if=
"audioSeparating"
class=
"mt-3 flex items-center justify-center gap-2 text-sm text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{
t
(
'
splitingAudio
'
)
}}
</span>
</div>
</div>
<!-- 上传视频 - Apple 风格(用于 animate 任务类型) -->
<div
v-if=
"selectedTaskId === 'animate'"
>
<!-- 视频标签 -->
<div
class=
"flex justify-between items-center mb-3"
>
<label
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
t
(
'
video
'
)
||
'
视频
'
}}
</label>
</div>
<!-- 上传视频区域 - Apple 风格 -->
<div
class=
"relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl p-2 min-h-[220px] transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_16px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_16px_rgba(0,0,0,0.3)]"
@
drop=
"handleVideoDrop"
@
dragover=
"handleDragOver"
@
dragenter=
"handleDragEnter"
@
dragleave=
"handleDragLeave"
:class=
"
{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10': isDragOver,
'p-8': !getCurrentVideoPreview()
}"
>
<!-- 默认上传界面 - Apple 风格 -->
<div
v-if=
"!getCurrentVideoPreview()"
class=
"flex flex-col items-center justify-center h-full"
>
<p
class=
"text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-2 tracking-tight"
>
{{
t
(
'
uploadVideo
'
)
}}
</p>
<p
class=
"text-xs text-[#86868b] dark:text-[#98989d] mb-6 tracking-tight"
>
{{
t
(
'
supportedVideoFormats
'
)
}}
</p>
<div
class=
"flex items-center justify-center gap-4"
>
<div
class=
"flex flex-col items-center gap-2"
>
<button
class=
"w-12 h-12 flex items-center justify-center bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.3)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100"
@
click=
"triggerVideoUpload"
:title=
"t('uploadVideo') || '上传视频'"
>
<i
class=
"fas fa-upload text-base"
></i>
</button>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
upload
'
)
}}
</span>
</div>
</div>
</div>
<!-- 视频预览区域 -->
<div
v-if=
"getCurrentVideoPreview()"
class=
"relative w-full min-h-[220px] flex items-center justify-center"
>
<div
class=
"bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] w-full p-4"
>
<div
class=
"relative flex items-center mb-3"
>
<!-- 视频预览 -->
<div
class=
"flex-1 min-w-0"
>
<video
:src=
"getCurrentVideoPreviewUrl()"
class=
"w-full max-h-[180px] rounded-lg object-contain"
controls
preload=
"metadata"
></video>
</div>
<!-- 删除按钮 -->
<button
@
click.stop=
"removeVideo"
class=
"ml-3 w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-red-500 dark:text-red-400 rounded-full transition-all duration-200 hover:scale-110 hover:shadow-[0_4px_12px_rgba(239,68,68,0.2)] dark:hover:shadow-[0_4px_12px_rgba(248,113,113,0.3)] active:scale-100 flex-shrink-0"
:title=
"t('deleteVideo') || '删除视频'"
>
<i
class=
"fas fa-trash text-sm"
></i>
</button>
</div>
</div>
</div>
<input
type=
"file"
ref=
"videoInput"
@
change=
"handleVideoUpload"
accept=
"video/*"
data-role=
"video-input"
style=
"display: none;"
>
</div>
</div>
</div>
<!-- 角色和音频配对区域 -->
<div
v-if=
"selectedTaskId === 's2v'"
class=
"mt-8"
>
<!-- 模式切换开关 - 始终显示 -->
<div
class=
"flex justify-center items-center mb-4"
>
<div
class=
"flex items-center gap-3"
>
<!-- 开关按钮 -->
<button
@
click=
"toggleRoleMode"
class=
"relative w-14 h-7 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-[color:var(--brand-primary)]/20 dark:focus:ring-[color:var(--brand-primary-light)]/20"
:class=
"isMultiRoleMode ? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)]' : 'bg-[#86868b]/30 dark:bg-[#98989d]/30'"
:title=
"isMultiRoleMode ? '切换到单角色模式' : '切换到多角色模式'"
>
<!-- 滑动圆点 -->
<span
class=
"absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 flex items-center justify-center"
:class=
"
{ 'translate-x-7': isMultiRoleMode, 'translate-x-0': !isMultiRoleMode }"
>
<i
:class=
"isMultiRoleMode ? 'fas fa-users text-[8px] text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]' : 'fas fa-user text-[8px] text-[#86868b] dark:text-[#98989d]'"
></i>
</span>
</button>
<span
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
:class=
"
{ 'text-[#86868b] dark:text-[#98989d]': isMultiRoleMode }">
{{
isMultiRoleMode
?
'
多角色模式
'
:
'
单角色模式
'
}}
</span>
<!-- Info 图标按钮 -->
<button
@
click=
"showRoleModeInfo = true"
class=
"w-5 h-5 flex items-center justify-center text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] transition-colors duration-200 rounded-full hover:bg-[#86868b]/10 dark:hover:bg-[#98989d]/10"
:title=
"t('roleModeInfo.title')"
>
<i
class=
"fas fa-info-circle text-xs"
></i>
</button>
</div>
</div>
<!-- 角色模式说明弹窗 - Apple 风格 -->
<div
v-if=
"showRoleModeInfo"
class=
"fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-[70] flex items-center justify-center p-4"
@
click=
"showRoleModeInfo = false"
>
<div
class=
"w-full max-w-md bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] overflow-hidden"
@
click.stop
>
<!-- 弹窗头部 -->
<div
class=
"flex items-center justify-between px-6 py-4 border-b border-black/8 dark:border-white/8 bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[20px]"
>
<h3
class=
"text-lg font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
t
(
'
roleModeInfo.title
'
)
}}
</h3>
<button
@
click=
"showRoleModeInfo = false"
class=
"w-8 h-8 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-red-500 dark:hover:text-red-400 hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
:title=
"t('close')"
>
<i
class=
"fas fa-times text-sm"
></i>
</button>
</div>
<!-- 弹窗内容 -->
<div
class=
"p-6 space-y-6"
>
<!-- 单角色模式说明 -->
<div
class=
"space-y-3"
>
<h4
class=
"text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight flex items-center gap-2"
>
<i
class=
"fas fa-user text-sm text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
{{
t
(
'
roleModeInfo.singleMode.title
'
)
}}
</h4>
<ul
class=
"space-y-2 pl-6"
>
<li
v-for=
"(point, index) in tm('roleModeInfo.singleMode.points')"
:key=
"index"
class=
"text-sm text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight leading-relaxed flex items-start gap-2"
>
<span
class=
"text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] mt-1.5 flex-shrink-0"
>
•
</span>
<span>
{{
point
}}
</span>
</li>
</ul>
</div>
<!-- 多角色模式说明 -->
<div
class=
"space-y-3"
>
<h4
class=
"text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight flex items-center gap-2"
>
<i
class=
"fas fa-users text-sm text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
{{
t
(
'
roleModeInfo.multiMode.title
'
)
}}
</h4>
<ul
class=
"space-y-2 pl-6"
>
<li
v-for=
"(point, index) in tm('roleModeInfo.multiMode.points')"
:key=
"index"
class=
"text-sm text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight leading-relaxed flex items-center gap-2"
>
<span
class=
"text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] flex-shrink-0"
>
•
</span>
<span>
{{
point
}}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 保存角色加载提示 -->
<div
v-if=
"faceSaving"
class=
"flex items-center justify-center gap-2 text-sm text-[#86868b] dark:text-[#98989d] tracking-tight mb-4"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
正在保存角色并更新音频...
</span>
</div>
<!-- 角色和音频配对区域 - 每行一个配对(仅在多角色模式且有角色时显示) -->
<div
v-if=
"isMultiRoleMode && currentDetectedFaces && currentDetectedFaces.length > 0"
class=
"flex flex-col items-center space-y-3"
>
<div
v-for=
"(face, index) in currentDetectedFaces"
:key=
"index"
class=
"flex items-stretch gap-4"
:class=
"
{
'border-[color:var(--brand-primary)]/50 dark:border-[color:var(--brand-primary-light)]/50': dragOverRoleIndex === index || dragOverAudioIndex === index
}"
>
<!-- 左侧:角色卡片 -->
<div
class=
"w-85 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl p-3 transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)]"
:class=
"
{
'border-[color:var(--brand-primary)]/50 dark:border-[color:var(--brand-primary-light)]/50 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10': dragOverRoleIndex === index,
'opacity-40 scale-95 shadow-lg': draggedRoleIndex === index,
'transform translate-y-0': draggedRoleIndex !== index
}"
@dragover.prevent="onRoleDragOver($event, index)"
@dragleave="onRoleDragLeave"
@drop="onRoleDrop($event, index)"
>
<!-- 角色区域 - 可拖拽 -->
<div
class=
"flex items-center justify-between gap-2 h-full w-full transition-all duration-200"
:class=
"
{
'opacity-50 scale-95': draggedRoleIndex === index,
'opacity-100': draggedRoleIndex !== index
}"
:draggable="true"
@dragstart="onRoleDragStart($event, index)"
@dragend="draggedRoleIndex = -1; dragOverRoleIndex = -1"
>
<!-- 左侧:拖拽手柄和角色名 -->
<div
class=
"flex items-center gap-2 flex-1 min-w-0"
>
<!-- 拖拽手柄 -->
<div
class=
"cursor-move text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] transition-colors"
>
<i
class=
"fas fa-grip-vertical text-sm"
></i>
</div>
<!-- 角色名显示/编辑 -->
<div
class=
"flex items-center"
>
<!-- 编辑模式 -->
<input
v-if=
"face.isEditing"
type=
"text"
:value=
"face.roleName"
:data-face-index=
"index"
@
input=
"updateFaceRoleName(index, $event.target.value)"
@
blur=
"saveFaceRoleName(index, $event.target.value)"
@
keyup.enter=
"saveFaceRoleName(index, $event.target.value)"
@
keyup.esc=
"toggleFaceEditing(index)"
:ref=
"(el) =>
{ if (el
&&
face.isEditing) { nextTick(() => el.focus()); } }"
class="w-24 px-2 py-1.5 text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] bg-white/80 dark:bg-[#2c2c2e]/80 border border-[color:var(--brand-primary)]/50 dark:border-[color:var(--brand-primary-light)]/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-[color:var(--brand-primary)]/20 dark:focus:ring-[color:var(--brand-primary-light)]/20 transition-all duration-200"
:placeholder="`角色${index + 1}`"
@click.stop>
<!-- 显示模式 - 可点击编辑 -->
<span
v-else
@
click.stop=
"toggleFaceEditing(index)"
class=
"w-24 px-2 py-1.5 text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] truncate tracking-tight cursor-text hover:bg-[color:var(--brand-primary)]/10 dark:hover:bg-[color:var(--brand-primary-light)]/15 hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] rounded transition-colors duration-200"
>
{{
face
.
roleName
||
`角色${index + 1
}
`
}}
<
/span
>
<
/div
>
<
/div
>
<!--
右侧
:
头像
、
编辑按钮和删除按钮
-->
<
div
class
=
"
flex items-center gap-2 flex-shrink-0
"
>
<!--
角色头像容器
-
相对定位
,
用于放置编辑按钮
-->
<
div
class
=
"
relative flex-shrink-0
"
>
<!--
角色头像
-
可点击
-->
<
div
@
click
.
stop
=
"
openFaceEditModal(index)
"
class
=
"
flex-shrink-0 w-14 h-14 rounded-lg overflow-hidden border border-black/8 dark:border-white/8 bg-black/5 dark:bg-white/5 cursor-pointer hover:border-[color:var(--brand-primary)]/50 dark:hover:border-[color:var(--brand-primary-light)]/50 transition-all duration-200 hover:scale-105
"
>
<
img
v
-
if
=
"
face.face_image
"
:
src
=
"
'data:image/png;base64,' + face.face_image
"
alt
=
"
Face
"
class
=
"
w-full h-full object-cover
"
@
error
=
"
(e) => { console.error('Face image load error:', index, e); e.target.style.display = 'none';
}
"
>
<
div
v
-
else
class
=
"
w-full h-full flex items-center justify-center text-[#86868b] dark:text-[#98989d] text-xs
"
>
<
i
class
=
"
fas fa-image
"
><
/i
>
<
/div
>
<
/div
>
<!--
编辑按钮
-
放在头像右上角
-->
<
button
v
-
if
=
"
!face.isEditing
"
@
click
.
stop
=
"
openFaceEditModal(index)
"
class
=
"
absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[10px] border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] rounded-full transition-all duration-200 hover:scale-110 shadow-sm
"
:
title
=
"
t('edit') || '编辑'
"
>
<
i
class
=
"
fas fa-edit text-xs
"
><
/i
>
<
/button
>
<!--
保存按钮
-->
<
button
v
-
else
@
click
.
stop
=
"
() => {
const inputEl = document.querySelector(`input[data-face-index='${index
}
']`);
const newRoleName = inputEl?.value || face.roleName;
saveFaceRoleName(index, newRoleName);
}
"
class
=
"
absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 text-white rounded-full transition-all duration-200 hover:scale-110 shadow-sm
"
:
title
=
"
t('save') || '保存'
"
>
<
i
class
=
"
fas fa-check text-xs
"
><
/i
>
<
/button
>
<
/div
>
<!--
删除按钮
-->
<
button
@
click
.
stop
=
"
removeFace(index)
"
class
=
"
flex-shrink-0 w-6 h-6 flex items-center justify-center text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all duration-200
"
:
title
=
"
t('delete') || '删除'
"
>
<
i
class
=
"
fas fa-trash text-xs
"
><
/i
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<!--
中间
:
链接符号
-->
<
div
class
=
"
flex items-center justify-center flex-shrink-0
"
>
<
div
class
=
"
w-8 h-8 flex items-center justify-center text-[#86868b] dark:text-[#98989d]
"
>
<
i
class
=
"
fas fa-link text-lg
"
><
/i
>
<
/div
>
<
/div
>
<!--
右侧
:
音频卡片
-->
<
div
v
-
if
=
"
currentSeparatedAudios && currentSeparatedAudios.length > index
"
class
=
"
w-85 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl p-3 transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)]
"
:
class
=
"
{
'border-[color:var(--brand-primary)]/50 dark:border-[color:var(--brand-primary-light)]/50 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10': dragOverAudioIndex === index,
'opacity-40 scale-95 shadow-lg': draggedAudioIndex === index,
'transform translate-y-0': draggedAudioIndex !== index
}
"
@
dragover
.
prevent
=
"
onAudioDragOver($event, index)
"
@
dragleave
=
"
onAudioDragLeave
"
@
drop
=
"
onAudioDrop($event, index)
"
>
<!--
音频区域
-
可拖拽
-->
<
div
class
=
"
flex items-center gap-2 h-full transition-all duration-200
"
:
class
=
"
{
'opacity-50 scale-95': draggedAudioIndex === index,
'opacity-100': draggedAudioIndex !== index
}
"
:
draggable
=
"
true
"
@
dragstart
=
"
onAudioDragStart($event, index)
"
@
dragend
=
"
draggedAudioIndex = -1; dragOverAudioIndex = -1
"
>
<!--
拖拽手柄
-->
<
div
class
=
"
cursor-move text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] transition-colors
"
>
<
i
class
=
"
fas fa-grip-vertical text-sm
"
><
/i
>
<
/div
>
<!--
音色名显示
/
编辑
-->
<
div
class
=
"
flex items-center
"
>
<!--
编辑模式
-->
<
input
v
-
if
=
"
currentSeparatedAudios[index].isEditing
"
type
=
"
text
"
:
value
=
"
currentSeparatedAudios[index].audioName
"
@
input
=
"
updateSeparatedAudioName(index, $event.target.value)
"
@
blur
=
"
saveSeparatedAudioName(index, $event.target.value)
"
@
keyup
.
enter
=
"
saveSeparatedAudioName(index, $event.target.value)
"
@
keyup
.
esc
=
"
toggleSeparatedAudioEditing(index)
"
:
ref
=
"
(el) => { if (el && currentSeparatedAudios[index].isEditing) { nextTick(() => el.focus());
}
}
"
class
=
"
w-24 px-2 py-1 text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] bg-white/80 dark:bg-[#2c2c2e]/80 border border-[color:var(--brand-primary)]/50 dark:border-[color:var(--brand-primary-light)]/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-[color:var(--brand-primary)]/20 dark:focus:ring-[color:var(--brand-primary-light)]/20 transition-all duration-200
"
:
placeholder
=
"
`音色${index + 1
}
`
"
@
click
.
stop
>
<!--
显示模式
-
可点击编辑
-->
<
span
v
-
else
@
click
.
stop
=
"
toggleSeparatedAudioEditing(index)
"
class
=
"
w-24 px-2 py-1 text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate cursor-text hover:bg-[color:var(--brand-primary)]/10 dark:hover:bg-[color:var(--brand-primary-light)]/15 hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] rounded transition-colors duration-200
"
>
{{
currentSeparatedAudios
[
index
].
audioName
||
`音色${index + 1
}
`
}}
<
/span
>
<
/div
>
<!--
音频播放器
-->
<
div
class
=
"
flex items-center gap-2 justify-center flex-shrink-0
"
>
<!--
播放
/
暂停按钮
-->
<
button
@
click
=
"
toggleSeparatedAudioPlayback(index)
"
class
=
"
flex-shrink-0 w-10 h-10 bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 rounded-full flex items-center justify-center text-white cursor-pointer hover:scale-110 transition-all duration-200 shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)]
"
>
<
i
:
class
=
"
getSeparatedAudioPlaying(index) ? 'fas fa-pause' : 'fas fa-play'
"
class
=
"
text-xs ml-0.5
"
><
/i
>
<
/button
>
<!--
右侧
:
时长和进度条
-->
<
div
class
=
"
flex flex-col justify-center
"
style
=
"
gap: 2px;
"
>
<!--
音频时长
-
显示在进度条上方
-->
<
div
class
=
"
text-xs font-medium text-[#86868b] dark:text-[#98989d] tracking-tight text-center
"
style
=
"
width: 128px;
"
>
{{
formatAudioPreviewTime
(
getSeparatedAudioCurrentTime
(
index
))
}}
/
{{
formatAudioPreviewTime
(
getSeparatedAudioDuration
(
index
))
}}
<
/div
>
<!--
进度条
-->
<
div
class
=
"
w-32
"
v
-
if
=
"
getSeparatedAudioDuration(index) > 0
"
>
<
input
type
=
"
range
"
:
min
=
"
0
"
:
max
=
"
getSeparatedAudioDuration(index)
"
:
value
=
"
getSeparatedAudioCurrentTime(index)
"
@
input
=
"
(e) => onSeparatedAudioProgressChange(index, e)
"
@
change
=
"
(e) => onSeparatedAudioProgressChange(index, e)
"
@
mousedown
=
"
separatedAudioIsDragging[index] = true
"
@
mouseup
=
"
(e) => onSeparatedAudioProgressEnd(index, e)
"
class
=
"
w-full h-1 bg-black/6 dark:bg-white/15 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-[color:var(--brand-primary)] dark:[&::-webkit-slider-thumb]:bg-[color:var(--brand-primary-light)] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer
"
/>
<
/div
>
<
/div
>
<!--
隐藏的音频元素
-->
<
audio
:
ref
=
"
el => { if (el) separatedAudioElements[index] = el
}
"
:
src
=
"
currentSeparatedAudios[index].audioDataUrl
"
@
loadedmetadata
=
"
() => onSeparatedAudioLoaded(index)
"
@
timeupdate
=
"
() => onSeparatedAudioTimeUpdate(index)
"
@
ended
=
"
() => onSeparatedAudioEnded(index)
"
@
play
=
"
() => separatedAudioPlaying[index] = true
"
@
pause
=
"
() => separatedAudioPlaying[index] = false
"
@
error
=
"
() => handleSeparatedAudioError(index)
"
class
=
"
hidden
"
><
/audio
>
<
/div
>
<
/div
>
<
/div
>
<!--
音频占位符
(
如果没有对应的分离音频
)
-->
<
div
v
-
else
class
=
"
w-85 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl p-3 flex items-center justify-center text-sm text-[#86868b] dark:text-[#98989d] tracking-tight
"
>
<
span
>
{{
t
(
'
waitingForMultipleRolesAudio
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
新增角色按钮
-->
<
div
v
-
if
=
"
selectedTaskId === 's2v' && getCurrentImagePreview() && isMultiRoleMode
"
class
=
"
flex justify-center mt-4
"
>
<
button
@
click
=
"
openFaceEditModal(-1)
"
class
=
"
w-8 h-8 flex items-center justify-center bg-[#86868b]/20 dark:bg-[#98989d]/20 text-[#86868b] dark:text-[#98989d] rounded-full transition-all duration-200 hover:bg-[#86868b]/30 dark:hover:bg-[#98989d]/30 hover:scale-110 active:scale-100
"
:
title
=
"
t('addRole') || '新增角色'
"
>
<
i
class
=
"
fas fa-plus text-sm
"
><
/i
>
<
/button
>
<
/div
>
<
/div
>
<!--
提示词输入区域
-
Apple
风格
(
animate
任务类型不显示
)
-->
<
div
v
-
if
=
"
selectedTaskId !== 'animate'
"
>
<
div
class
=
"
mt-8 space-y-3 flex justify-between items-center mb-3
"
>
<
label
class
=
"
text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center tracking-tight
"
>
{{
t
(
'
prompt
'
)
}}
<
button
@
click
=
"
showPromptModal = true; promptModalTab = 'templates'
"
class
=
"
ml-2 text-xs text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] transition-colors
"
:
title
=
"
t('promptTemplates')
"
>
<
i
class
=
"
fas fa-lightbulb text-lg
"
><
/i
>
<
/button
>
<
/label
>
<
div
class
=
"
text-xs text-[#86868b] dark:text-[#98989d] tracking-tight
"
>
{{
getCurrentForm
().
prompt
?.
length
||
0
}}
/
1000
<
/div
>
<
/div
>
<
div
class
=
"
relative
"
>
<
textarea
v
-
model
=
"
getCurrentForm().prompt
"
class
=
"
relative w-full bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl px-5 py-4 text-[15px] text-[#1d1d1f] dark:text-[#f5f5f7] transition-all duration-200 resize-none main-scrollbar placeholder-[#86868b] dark:placeholder-[#98989d] tracking-tight hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 focus:shadow-[0_4px_16px_rgba(var(--brand-primary-rgb),0.12)] dark:focus:shadow-[0_4px_16px_rgba(var(--brand-primary-light-rgb),0.2)]
"
:
placeholder
=
"
getPromptPlaceholder()
"
rows
=
"
2
"
maxlength
=
"
1000
"
required
><
/textarea
>
<
/div
>
<
div
class
=
"
flex justify-between items-center mt-3
"
>
<
button
@
click
=
"
clearPrompt
"
class
=
"
flex items-center text-sm rounded-lg px-3 py-1.5 transition-all duration-200 text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] hover:bg-black/4 dark:hover:bg-white/6 group tracking-tight
"
>
<
i
class
=
"
fas fa-sync-alt text-sm mr-2 group-hover:rotate-180 transition-transform duration-300
"
><
/i
>
{{
t
(
'
clear
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<!--
提交按钮
-
Apple
极简风格
-->
<
div
class
=
"
flex justify-center mt-8
"
>
<
button
@
click
=
"
handleSubmitTask
"
:
disabled
=
"
submitting || templateLoading
"
class
=
"
gap-3 cursor-pointer relative bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-full px-10 py-4 text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)] active:scale-100 transition-all duration-200 ease-out min-w-[250px] max-w-[400px] tracking-tight disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:shadow-none
"
:
class
=
"
{ 'disabled': submitting || templateLoading
}
"
>
<
i
v
-
if
=
"
submitting
"
class
=
"
fas fa-spinner fa-spin text-lg mr-2 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]
"
><
/i
>
<
i
v
-
else
-
if
=
"
templateLoading
"
class
=
"
fas fa-spinner fa-spin text-lg mr-2 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]
"
><
/i
>
<
i
v
-
else
class
=
"
fi fi-sr-select text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] transition-all duration-200 pointer-events-none
"
><
/i
>
<
span
class
=
"
pl-2 text-base font-semibold transition-all duration-200 pointer-events-none
"
>
{{
submitting
?
t
(
'
submitting
'
)
:
templateLoading
?
'
模板加载中...
'
:
t
(
'
generateVideo
'
)
}}
<
/span
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
历史任务区域
-
Apple
风格
-->
<
div
v
-
if
=
"
tasks.length > 0
"
class
=
"
task-carousel mt-16
"
>
<
div
class
=
"
flex-1 p-6
"
>
<
div
class
=
"
relative flex items-center justify-center mb-8
"
>
<!--
标题
-
Apple
风格
-->
<
h2
class
=
"
text-3xl sm:text-3xl md:text-4xl lg:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight
"
>
{{
t
(
'
historyTask
'
)
}}
<
/h2
>
<!--
更多按钮
-
Apple
风格
-->
<
button
@
click
=
"
switchToProjectsView()
"
class
=
"
absolute right-0 flex items-center gap-2 px-4 py-2 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/6 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/10 dark:hover:border-white/12 rounded-full transition-all duration-200
"
:
title
=
"
t('viewMore')
"
>
<
span
class
=
"
text-sm font-medium tracking-tight
"
>
{{
t
(
'
more
'
)
}}
<
/span
>
<
i
class
=
"
fas fa-arrow-right text-xs
"
><
/i
>
<
/button
>
<
/div
>
<
div
class
=
"
mx-auto
"
>
<
TaskCarousel
:
tasks
=
"
tasks
"
/>
<
/div
>
<
/div
>
<
/div
>
<!--
精选模版区域
-
Apple
风格
-->
<
div
v
-
if
=
"
currentFeaturedTemplates.length > 0
"
class
=
"
flex-1 flex flex-col min-h-0 mt-20
"
>
<
div
class
=
"
flex-1 p-6
"
>
<!--
控制区域
-
Apple
风格
-->
<
div
class
=
"
relative flex items-center justify-center mb-8
"
>
<!--
标题和随机按钮
-->
<
div
class
=
"
flex items-center gap-4
"
>
<
h2
class
=
"
text-3xl sm:text-3xl md:text-4xl lg:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight
"
>
{{
t
(
'
discover
'
)
}}
<
/h2
>
<!--
随机图标按钮
-
Apple
风格
-->
<
button
@
click
=
"
refreshRandomTemplates
"
:
disabled
=
"
featuredTemplatesLoading
"
class
=
"
w-10 h-10 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-full transition-all duration-200 hover:scale-110 hover:bg-[color:var(--brand-primary)]/20 dark:hover:bg-[color:var(--brand-primary-light)]/25 active:scale-100 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
"
:
title
=
"
t('refreshRandomTemplates')
"
>
<
i
class
=
"
fas fa-random text-sm
"
:
class
=
"
{ 'animate-spin': featuredTemplatesLoading
}
"
><
/i
>
<
/button
>
<
/div
>
<!--
更多按钮
-
Apple
风格
-->
<
button
@
click
=
"
switchToInspirationView()
"
class
=
"
absolute right-0 flex items-center gap-2 px-4 py-2 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/6 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/10 dark:hover:border-white/12 rounded-full transition-all duration-200
"
:
title
=
"
t('viewMore')
"
>
<
span
class
=
"
text-sm font-medium tracking-tight
"
>
{{
t
(
'
more
'
)
}}
<
/span
>
<
i
class
=
"
fas fa-arrow-right text-xs
"
><
/i
>
<
/button
>
<
/div
>
<!--
精选模版随机列布局
-
Apple
风格
-->
<
div
ref
=
"
featuredMasonryRef
"
class
=
"
relative
"
:
style
=
"
{ height: masonryHeight + 'px'
}
"
>
<!--
随机列
-->
<
div
v
-
for
=
"
(column, columnIndex) in templatesWithRandomColumns.columns
"
:
key
=
"
columnIndex
"
data
-
masonry
-
column
class
=
"
absolute transition-all duration-500 animate-fade-in
"
:
style
=
"
{
width: column.width,
left: column.left,
top: column.top,
animationDelay: `${columnIndex * 0.2
}
s`
}
"
>
<!--
列内的模版卡片
-
Apple
风格
-->
<
div
v
-
for
=
"
item in column.templates
"
:
key
=
"
item.task_id
"
class
=
"
mb-3 group relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.2)]
"
>
<!--
视频缩略图区域
-->
<
div
class
=
"
cursor-pointer bg-black/2 dark:bg-white/2 relative flex flex-col
"
@
click
=
"
previewTemplateDetail(item, false)
"
:
title
=
"
t('viewTemplateDetail')
"
>
<!--
视频预览
-->
<
video
v
-
if
=
"
item?.outputs?.output_video
"
:
src
=
"
getTemplateFileUrl(item.outputs.output_video,'videos')
"
:
poster
=
"
item?.inputs?.input_image ? getTemplateFileUrl(item.inputs.input_image,'images') : undefined
"
class
=
"
w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200
"
preload
=
"
auto
"
playsinline
webkit
-
playsinline
@
mouseenter
=
"
playVideo($event)
"
@
mouseleave
=
"
pauseVideo($event)
"
@
loadeddata
=
"
handleMasonryVideoLoaded($event)
"
@
ended
=
"
handleMasonryVideoEnded($event)
"
@
error
=
"
handleMasonryVideoError($event)
"
><
/video
>
<!--
图片缩略图
-->
<
img
v
-
else
-
if
=
"
item?.inputs?.input_image
"
:
src
=
"
getTemplateFileUrl(item.inputs.input_image,'images')
"
:
alt
=
"
item.params?.prompt || '模板图片'
"
class
=
"
w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200
"
@
load
=
"
handleMasonryImageLoaded
"
@
error
=
"
handleMasonryImageError
"
/>
<!--
如果没有图片
,
显示占位符
-->
<
div
v
-
else
class
=
"
w-full h-[200px] flex items-center justify-center bg-[#f5f5f7] dark:bg-[#1c1c1e]
"
>
<
i
class
=
"
fas fa-image text-3xl text-[#86868b]/30 dark:text-[#98989d]/30
"
><
/i
>
<
/div
>
<!--
移动端播放按钮
-
Apple
风格
-->
<
button
v
-
if
=
"
item?.outputs?.output_video
"
@
click
.
stop
=
"
toggleVideoPlay($event)
"
class
=
"
md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 transition-all duration-200 z-20
"
>
<
i
class
=
"
fas fa-play text-sm
"
><
/i
>
<
/button
>
<!--
悬浮操作按钮
(
下方居中
,
仅桌面端
)
-
Apple
风格
-->
<
div
class
=
"
hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full
"
>
<
div
class
=
"
flex gap-2 pointer-events-auto
"
>
<
button
@
click
.
stop
=
"
applyTemplateImage(item)
"
class
=
"
w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200
"
:
title
=
"
t('applyImage')
"
>
<
i
class
=
"
fas fa-image text-sm
"
><
/i
>
<
/button
>
<
button
@
click
.
stop
=
"
applyTemplateAudio(item)
"
class
=
"
w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200
"
:
title
=
"
t('applyAudio')
"
>
<
i
class
=
"
fas fa-music text-sm
"
><
/i
>
<
/button
>
<
button
@
click
.
stop
=
"
handleUseTemplate(item)
"
class
=
"
w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200
"
:
title
=
"
t('useTemplate')
"
>
<
i
class
=
"
fas fa-clone text-sm
"
><
/i
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
脸部编辑模态框
-
显示放大图片和可拖拽的边界框
-->
<
div
v
-
if
=
"
showFaceEditModal
"
class
=
"
fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 dark:bg-black/80 backdrop-blur-sm
"
@
click
=
"
closeFaceEditModal
"
>
<
div
@
click
.
stop
class
=
"
relative bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] rounded-2xl p-6 max-w-4xl max-h-[90vh] overflow-auto shadow-[0_12px_32px_rgba(0,0,0,0.6)] dark:shadow-[0_12px_32px_rgba(0,0,0,0.8)]
"
>
<!--
关闭按钮
-->
<
button
@
click
=
"
closeFaceEditModal
"
class
=
"
absolute top-4 right-4 w-10 h-10 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 z-10
"
:
title
=
"
t('close') || '关闭'
"
>
<
i
class
=
"
fas fa-times text-sm
"
><
/i
>
<
/button
>
<!--
标题
-->
<
h3
class
=
"
text-xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-4 tracking-tight
"
>
{{
isAddingNewFace
?
(
t
(
'
addNewRole
'
)
||
'
新增角色
'
)
:
(
t
(
'
adjustFaceBox
'
)
||
'
调整人脸边界框
'
)
}}
<
/h3
>
<!--
图片容器
-->
<
div
ref
=
"
imageContainerRef
"
class
=
"
relative inline-block max-w-full
"
:
style
=
"
imageNaturalSize.width > 0 && imageNaturalSize.height > 0 && !imageLoaded ? {
width: `${Math.min(imageNaturalSize.width, 800)
}
px`,
height: `${Math.min(imageNaturalSize.height, 600)
}
px`,
aspectRatio: `${imageNaturalSize.width
}
/ ${imageNaturalSize.height
}
`
}
: {
}
"
>
<!--
占位符
-
图片加载前显示
-->
<
div
v
-
show
=
"
!imageLoaded
"
class
=
"
absolute inset-0 w-full h-full min-w-[400px] min-h-[300px] bg-[#f5f5f7] dark:bg-[#1e1e1e] rounded-xl flex items-center justify-center z-10
"
>
<
div
class
=
"
flex flex-col items-center gap-3
"
>
<
i
class
=
"
fas fa-spinner fa-spin text-2xl text-[#86868b] dark:text-[#98989d]
"
><
/i
>
<
span
class
=
"
text-sm text-[#86868b] dark:text-[#98989d]
"
>
{{
t
(
'
loading
'
)
||
'
加载中...
'
}}
<
/span
>
<
/div
>
<
/div
>
<!--
实际图片
-
始终渲染
,
但加载完成后才显示
-->
<
img
:
src
=
"
originalImageUrl
"
alt
=
"
Face Edit
"
class
=
"
max-w-full max-h-[70vh] h-auto object-contain rounded-xl
"
:
class
=
"
{ 'opacity-0': !imageLoaded
}
"
@
load
=
"
handleFaceEditImageLoad
"
@
error
=
"
handleFaceEditImageError
"
>
<!--
遮罩层
-
框外区域变暗
-->
<
svg
v
-
if
=
"
editingFaceBbox.length === 4 && getBboxStyle.left
"
class
=
"
absolute inset-0 pointer-events-none z-[5]
"
:
style
=
"
{
left: 0,
top: 0,
width: '100%',
height: '100%'
}
"
>
<
defs
>
<
mask
id
=
"
bbox-mask
"
>
<
rect
width
=
"
100%
"
height
=
"
100%
"
fill
=
"
white
"
/>
<
rect
:
x
=
"
getBboxStyle.left
"
:
y
=
"
getBboxStyle.top
"
:
width
=
"
getBboxStyle.width
"
:
height
=
"
getBboxStyle.height
"
fill
=
"
black
"
/>
<
/mask
>
<
/defs
>
<
rect
width
=
"
100%
"
height
=
"
100%
"
fill
=
"
rgba(0,0,0,0.5)
"
mask
=
"
url(#bbox-mask)
"
/>
<
/svg
>
<!--
角色名字标签
-
显示在边界框上方
-->
<
div
v
-
if
=
"
editingFaceBbox.length === 4 && getBboxStyle.left && getRoleNameLabelStyle.roleName
"
:
style
=
"
{
left: getRoleNameLabelStyle.left,
top: getRoleNameLabelStyle.top,
transform: getRoleNameLabelStyle.transform
}
"
class
=
"
absolute px-2 py-1 text-xs font-medium text-white bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 rounded-md shadow-lg whitespace-nowrap pointer-events-none z-10
"
>
{{
getRoleNameLabelStyle
.
roleName
}}
<
/div
>
<!--
边界框
-->
<
div
v
-
if
=
"
editingFaceBbox.length === 4 && getBboxStyle.left
"
:
style
=
"
getBboxStyle
"
@
mousedown
=
"
(e) => startDragBbox(e, 'move')
"
class
=
"
absolute border-2 border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] cursor-move bg-transparent hover:bg-[color:var(--brand-primary)]/5 dark:hover:bg-[color:var(--brand-primary-light)]/5 transition-colors duration-200
"
:
class
=
"
{ 'ring-2 ring-[color:var(--brand-primary)]/50 dark:ring-[color:var(--brand-primary-light)]/50 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/10': isDraggingBbox
}
"
style
=
"
box-sizing: border-box;
"
>
<!--
四个角的拖拽手柄
-->
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-nw')
"
:
style
=
"
{
width: `${getBboxStyle.indicatorSize || 16
}
px`,
height: `${getBboxStyle.indicatorSize || 16
}
px`,
left: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
top: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full border-2 border-white dark:border-[#2c2c2e] shadow-lg cursor-nw-resize hover:scale-110 transition-transform z-20
"
><
/div
>
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-ne')
"
:
style
=
"
{
width: `${getBboxStyle.indicatorSize || 16
}
px`,
height: `${getBboxStyle.indicatorSize || 16
}
px`,
right: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
top: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full border-2 border-white dark:border-[#2c2c2e] shadow-lg cursor-ne-resize hover:scale-110 transition-transform z-20
"
><
/div
>
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-sw')
"
:
style
=
"
{
width: `${getBboxStyle.indicatorSize || 16
}
px`,
height: `${getBboxStyle.indicatorSize || 16
}
px`,
left: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
bottom: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full border-2 border-white dark:border-[#2c2c2e] shadow-lg cursor-sw-resize hover:scale-110 transition-transform z-20
"
><
/div
>
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-se')
"
:
style
=
"
{
width: `${getBboxStyle.indicatorSize || 16
}
px`,
height: `${getBboxStyle.indicatorSize || 16
}
px`,
right: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
bottom: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full border-2 border-white dark:border-[#2c2c2e] shadow-lg cursor-se-resize hover:scale-110 transition-transform z-20
"
><
/div
>
<!--
四个边的拖拽手柄
-->
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-n')
"
:
style
=
"
{
width: 'calc(100% + 16px)',
height: `${getBboxStyle.indicatorSize || 16
}
px`,
left: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
top: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute cursor-n-resize hover:bg-[color:var(--brand-primary)]/20 dark:hover:bg-[color:var(--brand-primary-light)]/20 transition-colors rounded-t z-10
"
><
/div
>
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-s')
"
:
style
=
"
{
width: 'calc(100% + 16px)',
height: `${getBboxStyle.indicatorSize || 16
}
px`,
left: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
bottom: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute cursor-s-resize hover:bg-[color:var(--brand-primary)]/20 dark:hover:bg-[color:var(--brand-primary-light)]/20 transition-colors rounded-b z-10
"
><
/div
>
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-w')
"
:
style
=
"
{
width: `${getBboxStyle.indicatorSize || 16
}
px`,
height: 'calc(100% + 16px)',
left: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
top: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute cursor-w-resize hover:bg-[color:var(--brand-primary)]/20 dark:hover:bg-[color:var(--brand-primary-light)]/20 transition-colors rounded-l z-10
"
><
/div
>
<
div
@
mousedown
.
stop
=
"
(e) => startDragBbox(e, 'resize-e')
"
:
style
=
"
{
width: `${getBboxStyle.indicatorSize || 16
}
px`,
height: 'calc(100% + 16px)',
right: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`,
top: `${-(getBboxStyle.indicatorSize || 16) / 2
}
px`
}
"
class
=
"
absolute cursor-e-resize hover:bg-[color:var(--brand-primary)]/20 dark:hover:bg-[color:var(--brand-primary-light)]/20 transition-colors rounded-r z-10
"
><
/div
>
<
/div
>
<
/div
>
<!--
操作按钮
-->
<
div
class
=
"
flex items-center justify-end gap-3 mt-6
"
>
<
button
@
click
=
"
closeFaceEditModal
"
class
=
"
px-4 py-2 text-sm font-medium text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-black/4 dark:hover:bg-white/6 rounded-lg transition-all duration-200 tracking-tight
"
>
{{
t
(
'
cancel
'
)
||
'
取消
'
}}
<
/button
>
<
button
@
click
=
"
saveFaceBbox
"
class
=
"
px-4 py-2 text-sm font-medium text-white bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] hover:opacity-90 rounded-lg transition-all duration-200 tracking-tight
"
>
{{
t
(
'
save
'
)
||
'
保存
'
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
style
scoped
>
/* Apple 风格极简设计 - 所有样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
<
/style
>
lightx2v/deploy/server/frontend/src/components/Inspirations.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
watch
,
onMounted
}
from
'
vue
'
// Props
const
props
=
defineProps
({
query
:
{
type
:
Object
,
default
:
()
=>
({})
},
templateId
:
{
type
:
String
,
default
:
null
}
})
const
{
t
,
locale
}
=
useI18n
()
const
route
=
useRoute
()
const
router
=
useRouter
()
import
{
goToInspirationPage
,
getVisibleInspirationPages
,
getTemplateFileUrl
,
handleThumbnailError
,
inspirationSearchQuery
,
selectedInspirationCategory
,
inspirationItems
,
InspirationCategories
,
selectInspirationCategory
,
handleInspirationSearch
,
inspirationPaginationInfo
,
inspirationCurrentPage
,
previewTemplateDetail
,
useTemplate
,
applyTemplateImage
,
applyTemplateAudio
,
playVideo
,
pauseVideo
,
toggleVideoPlay
,
onVideoLoaded
,
onVideoError
,
onVideoEnded
,
openTemplateFromRoute
,
copyShareLink
,
isPageLoading
}
from
'
../utils/other
'
// 监听模板详情路由
watch
(()
=>
route
.
params
.
templateId
,
(
newTemplateId
)
=>
{
if
(
newTemplateId
&&
route
.
name
===
'
TemplateDetail
'
)
{
openTemplateFromRoute
(
newTemplateId
)
}
},
{
immediate
:
true
})
// 路由监听和URL同步
watch
(()
=>
route
.
query
,
(
newQuery
)
=>
{
// 同步URL参数到组件状态
if
(
newQuery
.
search
)
{
inspirationSearchQuery
.
value
=
newQuery
.
search
}
if
(
newQuery
.
category
)
{
selectedInspirationCategory
.
value
=
newQuery
.
category
}
if
(
newQuery
.
page
)
{
const
page
=
parseInt
(
newQuery
.
page
)
if
(
page
>
0
&&
page
!==
inspirationCurrentPage
.
value
)
{
goToInspirationPage
(
page
)
}
}
},
{
immediate
:
true
})
// 监听组件状态变化,同步到URL
watch
([
inspirationSearchQuery
,
selectedInspirationCategory
,
inspirationCurrentPage
],
()
=>
{
const
query
=
{}
if
(
inspirationSearchQuery
.
value
)
{
query
.
search
=
inspirationSearchQuery
.
value
}
if
(
selectedInspirationCategory
.
value
&&
selectedInspirationCategory
.
value
!==
'
all
'
)
{
query
.
category
=
selectedInspirationCategory
.
value
}
if
(
inspirationCurrentPage
.
value
>
1
)
{
query
.
page
=
inspirationCurrentPage
.
value
.
toString
()
}
// 更新URL但不触发路由监听
router
.
replace
({
query
})
})
// 组件挂载时初始化
onMounted
(()
=>
{
// 确保URL参数正确同步
const
query
=
route
.
query
if
(
query
.
search
)
{
inspirationSearchQuery
.
value
=
query
.
search
}
if
(
query
.
category
)
{
selectedInspirationCategory
.
value
=
query
.
category
}
if
(
query
.
page
)
{
const
page
=
parseInt
(
query
.
page
)
if
(
page
>
0
)
{
goToInspirationPage
(
page
)
}
}
})
</
script
>
<
template
>
<!-- 灵感广场区域 - Apple 极简风格 -->
<div
class=
"flex-1 flex flex-col min-h-0 mobile-content"
>
<!-- 内容区域 -->
<div
class=
"flex-1 overflow-y-auto p-6 content-area main-scrollbar"
>
<!-- 灵感广场功能区 -->
<div
class=
"max-w-7xl mx-auto"
id=
"inspiration-gallery"
>
<!-- 标题区域 - Apple 风格 -->
<div
class=
"text-center mb-10"
>
<h1
class=
"text-4xl sm:text-5xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 tracking-tight"
>
{{
t
(
'
inspirationGallery
'
)
}}
</h1>
<p
class=
"text-base text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
discoverCreativity
'
)
}}
</p>
</div>
<!-- 搜索和筛选区域 - Apple 风格 -->
<div
class=
"flex flex-col md:flex-row gap-4 mb-8"
>
<!-- 搜索框 - Apple 风格 -->
<div
class=
"relative flex-1"
>
<i
class=
"fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-[#86868b] dark:text-[#98989d] pointer-events-none z-10"
></i>
<input
v-model=
"inspirationSearchQuery"
@
keyup.enter=
"handleInspirationSearch"
@
input=
"handleInspirationSearch"
class=
"w-full bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl py-3 pl-11 pr-4 text-[15px] text-[#1d1d1f] dark:text-[#f5f5f7] placeholder-[#86868b] dark:placeholder-[#98989d] tracking-tight hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 focus:shadow-[0_4px_16px_rgba(var(--brand-primary-rgb),0.12)] dark:focus:shadow-[0_4px_16px_rgba(var(--brand-primary-light-rgb),0.2)] transition-all duration-200"
:placeholder=
"t('searchInspiration')"
type=
"text"
/>
</div>
<!-- 分类筛选 - Apple 风格 -->
<div
class=
"flex gap-2 flex-wrap"
>
<!-- "全部"按钮 -->
<button
@
click=
"selectInspirationCategory('')"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"selectedInspirationCategory === '' || !selectedInspirationCategory
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
{{
t
(
'
all
'
)
}}
</button>
<!-- 其他分类按钮 -->
<button
v-for=
"category in InspirationCategories"
:key=
"category"
@
click=
"selectInspirationCategory(category)"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"selectedInspirationCategory === category
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
{{
category
}}
</button>
</div>
</div>
<!-- 灵感广场分页组件 - Apple 风格 -->
<div
v-if=
"inspirationPaginationInfo"
class=
"mb-6"
>
<div
class=
"flex items-center justify-between text-xs mb-4"
>
<div
class=
"flex items-center space-x-1 text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<span>
{{
inspirationPaginationInfo
.
total
}}
{{
t
(
'
records
'
)
}}
</span>
</div>
</div>
<div
v-if=
"inspirationPaginationInfo.total_pages > 1"
class=
"flex justify-center"
>
<nav
class=
"isolate inline-flex gap-1"
aria-label=
"Pagination"
>
<!-- 上一页按钮 -->
<button
@
click=
"goToInspirationPage(inspirationCurrentPage - 1)"
:disabled=
"inspirationCurrentPage
<
=
1"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"
{ 'opacity-50 cursor-not-allowed': inspirationCurrentPage
<
=
1
}"
:title=
"t('previousPage')"
>
<span
class=
"sr-only"
>
{{
t
(
'
previousPage
'
)
}}
</span>
<i
class=
"fas fa-chevron-left text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
<!-- 页码按钮 -->
<template
v-for=
"page in getVisibleInspirationPages()"
:key=
"page"
>
<button
v-if=
"page !== '...'"
@
click=
"goToInspirationPage(page)"
:class=
"[
'relative inline-flex items-center justify-center min-w-[36px] h-9 px-3 text-sm font-medium rounded-lg transition-all duration-200',
page === inspirationCurrentPage
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'
]"
:aria-current=
"page === inspirationCurrentPage ? 'page' : undefined"
>
{{
page
}}
</button>
<span
v-else
class=
"relative inline-flex items-center px-2 text-sm font-semibold text-[#86868b] dark:text-[#98989d]"
>
...
</span>
</
template
>
<!-- 下一页按钮 -->
<button
@
click=
"goToInspirationPage(inspirationCurrentPage + 1)"
:disabled=
"inspirationCurrentPage >= inspirationPaginationInfo.total_pages"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"{ 'opacity-50 cursor-not-allowed': inspirationCurrentPage >= inspirationPaginationInfo.total_pages }"
:title=
"t('nextPage')"
>
<span
class=
"sr-only"
>
{{ t('nextPage') }}
</span>
<i
class=
"fas fa-chevron-right text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
</nav>
</div>
</div>
<!-- 灵感内容网格 - Apple 风格 -->
<div
class=
"space-y-4"
>
<div
v-if=
"isPageLoading"
class=
"flex items-center justify-center"
>
<div
class=
"inline-flex items-center gap-3 px-4 py-2 rounded-full bg-white/90 dark:bg-[#2c2c2e]/90 border border-black/8 dark:border-white/8 text-sm text-[#1d1d1f] dark:text-[#f5f5f7] shadow-[0_4px_16px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{ t('loading') }}
</span>
</div>
</div>
<div
class=
"columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4"
>
<!-- 灵感卡片 - Apple 风格 -->
<div
v-for=
"item in inspirationItems"
:key=
"item.task_id"
class=
"break-inside-avoid mb-4 group relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.2)]"
>
<!-- 视频缩略图区域 -->
<div
class=
"cursor-pointer bg-black/2 dark:bg-white/2 relative flex flex-col"
@
click=
"previewTemplateDetail(item)"
:title=
"t('viewTemplateDetail')"
>
<!-- 视频预览 -->
<video
v-if=
"item?.outputs?.output_video"
:src=
"getTemplateFileUrl(item.outputs.output_video,'videos')"
:poster=
"item?.inputs?.input_image ? getTemplateFileUrl(item.inputs.input_image,'images') : undefined"
class=
"w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
preload=
"auto"
playsinline
webkit-playsinline
@
mouseenter=
"playVideo($event)"
@
mouseleave=
"pauseVideo($event)"
@
loadeddata=
"onVideoLoaded($event)"
@
ended=
"onVideoEnded($event)"
@
error=
"onVideoError($event)"
></video>
<!-- 图片缩略图 -->
<img
v-else-if=
"item?.inputs?.input_image"
:src=
"getTemplateFileUrl(item.inputs.input_image,'images')"
:alt=
"item.params?.prompt || '模板图片'"
class=
"w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
@
error=
"handleThumbnailError"
/>
<!-- 移动端播放按钮 - Apple 风格 -->
<button
v-if=
"item?.outputs?.output_video"
@
click.stop=
"toggleVideoPlay($event)"
class=
"md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 transition-all duration-200 z-20"
>
<i
class=
"fas fa-play text-sm"
></i>
</button>
<!-- 悬浮操作按钮(下方居中,仅桌面端)- Apple 风格 -->
<div
class=
"hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full"
>
<div
class=
"flex gap-2 pointer-events-auto"
>
<button
@
click.stop=
"applyTemplateImage(item)"
class=
"w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('applyImage')"
>
<i
class=
"fas fa-image text-sm"
></i>
</button>
<button
@
click.stop=
"applyTemplateAudio(item)"
class=
"w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('applyAudio')"
>
<i
class=
"fas fa-music text-sm"
></i>
</button>
<button
@
click.stop=
"useTemplate(item)"
class=
"w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('useTemplate')"
>
<i
class=
"fas fa-clone text-sm"
></i>
</button>
<button
@
click.stop=
"copyShareLink(item.task_id, 'template')"
class=
"w-10 h-10 rounded-full bg-white dark:bg-[#3a3a3c] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('shareTemplate')"
>
<i
class=
"fas fa-share-alt text-sm"
></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- GitHub 仓库链接 - Apple 极简风格 -->
<div
class=
"fixed bottom-6 right-6 z-50"
>
<a
href=
"https://github.com/ModelTC/LightX2V"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"flex items-center gap-2.5 px-4 py-2.5 bg-white/85 dark:bg-[#1e1e1e]/85 backdrop-blur-[40px] border border-black/10 dark:border-white/10 rounded-full shadow-[0_4px_16px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.3)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.4)] hover:scale-105 active:scale-100 transition-all duration-200 group"
title=
"Star us on GitHub"
>
<i
class=
"fab fa-github text-lg text-[#1d1d1f] dark:text-[#f5f5f7] transition-transform duration-200 group-hover:rotate-12"
></i>
<span
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
LightX2V
</span>
<i
class=
"fas fa-external-link-alt text-xs text-[#86868b] dark:text-[#98989d] transition-all duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
></i>
</a>
</div>
</div>
</div>
</div>
</template>
lightx2v/deploy/server/frontend/src/components/LeftBar.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
switchToCreateView
,
switchToProjectsView
,
switchToInspirationView
}
from
'
../utils/other
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
,
locale
}
=
useI18n
()
</
script
>
<
template
>
<!-- 左侧功能区 - Apple 极简风格 - 响应式悬浮按钮 -->
<div
class=
"fixed top-20 sm:top-1/2 sm:-translate-y-1/2 right-3 sm:right-auto sm:left-5 z-[10] w-auto"
>
<!-- 功能导航 - Apple 风格统一容器 -->
<div
class=
"p-2 flex flex-col justify-center"
>
<!-- 统一的圆角矩形容器 - Apple 风格 -->
<nav
class=
"bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_4px_16px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.4)] overflow-hidden flex flex-col w-14 sm:w-16"
>
<!-- 生成视频功能 -->
<div
@
click=
"switchToCreateView"
class=
"flex items-center justify-center h-16 cursor-pointer transition-all duration-200 ease-out group"
:class=
"$route.path === '/generate'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white'
: 'text-[#86868b] dark:text-[#98989d] hover:bg-black/4 dark:hover:bg-white/6 hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)]'"
:title=
"t('generateVideo')"
>
<i
class=
"fas fa-plus text-xl transition-all duration-200 group-hover:scale-110"
></i>
</div>
<!-- 分割线 - Apple 风格 -->
<div
class=
"h-px bg-black/8 dark:bg-white/8 mx-3"
></div>
<!-- 我的项目功能 -->
<div
@
click=
"switchToProjectsView"
class=
"flex items-center justify-center h-16 cursor-pointer transition-all duration-200 ease-out group"
:class=
"$route.path === '/projects'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white'
: 'text-[#86868b] dark:text-[#98989d] hover:bg-black/4 dark:hover:bg-white/6 hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)]'"
:title=
"t('myProjects')"
>
<i
class=
"fas fa-folder-open text-lg transition-all duration-200 group-hover:scale-110"
></i>
</div>
<!-- 分割线 - Apple 风格 -->
<div
class=
"h-px bg-black/8 dark:bg-white/8 mx-3"
></div>
<!-- 灵感广场功能 -->
<div
@
click=
"switchToInspirationView"
class=
"flex items-center justify-center h-16 cursor-pointer transition-all duration-200 ease-out group"
:class=
"$route.path === '/inspirations'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white'
: 'text-[#86868b] dark:text-[#98989d] hover:bg-black/4 dark:hover:bg-white/6 hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)]'"
:title=
"t('inspiration')"
>
<i
class=
"fas fa-lightbulb text-lg transition-all duration-200 group-hover:scale-110"
></i>
</div>
</nav>
</div>
</div>
</
template
>
lightx2v/deploy/server/frontend/src/components/Loading.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
,
locale
}
=
useI18n
()
</
script
>
<
template
>
<!-- 加载状态 - Apple 极简风格 -->
<div
class=
"flex flex-col items-center justify-center"
>
<div
class=
"relative w-12 h-12 mb-6"
>
<!-- Apple 风格加载环 -->
<div
class=
"absolute inset-0 rounded-full border-2 border-black/8 dark:border-white/8"
></div>
<div
class=
"absolute inset-0 rounded-full border-2 border-transparent border-t-[color:var(--brand-primary)] dark:border-t-[color:var(--brand-primary-light)] animate-spin"
></div>
</div>
<p
class=
"text-sm font-medium text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
loading
'
)
}}
</p>
</div>
</
template
>
lightx2v/deploy/server/frontend/src/components/LoginCard.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
,
locale
}
=
useI18n
()
import
{
// 登录相关
loginWithGitHub
,
loginWithGoogle
,
loginWithSms
,
phoneNumber
,
verifyCode
,
smsCountdown
,
showSmsForm
,
sendSmsCode
,
handleLoginCallback
,
handlePhoneEnter
,
handleVerifyCodeEnter
,
toggleSmsLogin
,
isLoggedIn
,
loginLoading
,
isLoading
,
initLoading
,
downloadLoading
}
from
'
../utils/other
'
import
{
ref
}
from
'
vue
'
;
import
{
useRouter
}
from
'
vue-router
'
const
router
=
useRouter
();
</
script
>
<
template
>
<!-- Apple 极简风格登录卡片 -->
<div
class=
"w-full max-w-xl mx-auto"
>
<div
class=
"bg-white/85 dark:bg-[#1e1e1e]/85 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_20px_60px_rgba(0,0,0,0.15)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] px-14 py-16 sm:px-12 sm:py-14"
>
<!-- Logo和标题 - Apple 风格 -->
<div
class=
"text-center mb-12"
>
<div
class=
"flex items-center justify-center gap-3 mb-5"
>
<img
src=
"../../public/logo.svg"
alt=
"LightX2V"
class=
"w-14 h-12"
loading=
"lazy"
/>
<h1
class=
"text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
LightX2V
</h1>
</div>
<p
class=
"text-base text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
loginSubtitle
'
)
}}
</p>
</div>
<!-- 表单区域 -->
<div
class=
"space-y-6"
>
<!-- 手机号输入框 - Apple 风格 -->
<div>
<label
for=
"phoneNumber"
class=
"block text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 text-left tracking-tight"
>
{{
t
(
'
phoneNumber
'
)
}}
</label>
<input
v-model=
"phoneNumber"
type=
"tel"
name=
"phoneNumber"
required
maxlength=
"11"
@
keyup.enter=
"handlePhoneEnter"
class=
"block w-full rounded-xl bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 px-5 py-4 text-[15px] text-[#1d1d1f] dark:text-[#f5f5f7] placeholder-[#86868b] dark:placeholder-[#98989d] transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 focus:shadow-[0_4px_16px_rgba(var(--brand-primary-rgb),0.12)] dark:focus:shadow-[0_4px_16px_rgba(var(--brand-primary-light-rgb),0.2)] tracking-tight"
/>
</div>
<!-- 验证码输入框 - Apple 风格 -->
<div>
<div
class=
"flex items-center justify-between mb-3"
>
<label
for=
"verifyCode"
class=
"block text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
t
(
'
verifyCode
'
)
}}
</label>
<button
@
click=
"sendSmsCode"
class=
"text-sm font-medium text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] hover:text-[color:var(--brand-primary)]/80 dark:hover:text-[color:var(--brand-primary-light)]/80 transition-colors tracking-tight"
:disabled=
"!phoneNumber || smsCountdown > 0 || loginLoading"
>
{{
smsCountdown
>
0
?
`${smsCountdown
}
s`
:
t
(
'
sendSmsCode
'
)
}}
<
/button
>
<
/div
>
<
input
v
-
model
=
"
verifyCode
"
type
=
"
text
"
name
=
"
verifyCode
"
required
maxlength
=
"
6
"
@
keyup
.
enter
=
"
handleVerifyCodeEnter
"
class
=
"
block w-full rounded-xl bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 px-5 py-4 text-[15px] text-[#1d1d1f] dark:text-[#f5f5f7] placeholder-[#86868b] dark:placeholder-[#98989d] transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 focus:shadow-[0_4px_16px_rgba(var(--brand-primary-rgb),0.12)] dark:focus:shadow-[0_4px_16px_rgba(var(--brand-primary-light-rgb),0.2)] tracking-tight
"
/>
<
/div
>
<!--
登录按钮
-
Apple
风格
-->
<
div
class
=
"
pt-4
"
>
<
button
@
click
=
"
loginWithSms
"
:
disabled
=
"
!phoneNumber || !verifyCode || loginLoading
"
class
=
"
w-full rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border-0 px-6 py-4 text-base font-semibold text-white hover:scale-[1.02] hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.35)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100 transition-all duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:shadow-none tracking-tight
"
>
{{
loginLoading
?
t
(
'
loginLoading
'
)
:
t
(
'
login
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<!--
分隔线
-
Apple
风格
-->
<
div
class
=
"
relative my-10
"
>
<
div
class
=
"
absolute inset-0 flex items-center
"
>
<
div
class
=
"
w-full border-t border-black/8 dark:border-white/8
"
><
/div
>
<
/div
>
<
div
class
=
"
relative flex justify-center
"
>
<
span
class
=
"
bg-white/95 dark:bg-[#1e1e1e]/95 px-4 text-sm text-[#86868b] dark:text-[#98989d] tracking-tight
"
>
{{
t
(
'
orLoginWith
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<!--
第三方登录按钮
-
Apple
风格
-->
<
div
class
=
"
flex justify-center gap-5
"
>
<
button
@
click
=
"
loginWithGitHub
"
class
=
"
w-14 h-14 flex items-center justify-center bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 rounded-full text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-100 transition-all duration-200
"
:
disabled
=
"
loginLoading
"
:
title
=
"
t('loginWithGitHub')
"
>
<
i
class
=
"
fab fa-github text-2xl
"
><
/i
>
<
/button
>
<
button
@
click
=
"
loginWithGoogle
"
class
=
"
w-14 h-14 flex items-center justify-center bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 rounded-full text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-100 transition-all duration-200
"
:
disabled
=
"
loginLoading
"
:
title
=
"
t('loginWithGoogle')
"
>
<
i
class
=
"
fab fa-google text-2xl
"
><
/i
>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
lightx2v/deploy/server/frontend/src/components/MediaTemplate.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
// 音频播放状态管理
const
playingAudioId
=
ref
(
null
)
const
audioDurations
=
ref
({})
import
{
getTemplateFileUrl
,
getHistoryImageUrl
,
goToTemplatePage
,
jumpToTemplatePage
,
getVisibleTemplatePages
,
selectImageHistory
,
selectImageTemplate
,
selectAudioHistory
,
selectAudioTemplate
,
previewAudioHistory
,
previewAudioTemplate
,
stopAudioPlayback
,
setAudioStopCallback
,
clearImageHistory
,
clearAudioHistory
,
templatePaginationInfo
,
templateCurrentPage
,
templatePageInput
,
showImageTemplates
,
showAudioTemplates
,
imageHistory
,
audioHistory
,
imageTemplates
,
audioTemplates
,
mergedTemplates
,
mediaModalTab
,
getImageHistory
,
getAudioHistory
,
isPageLoading
}
from
'
../utils/other
'
// 格式化音频时长
const
formatDuration
=
(
seconds
)
=>
{
if
(
!
seconds
||
isNaN
(
seconds
))
return
'
--:--
'
const
mins
=
Math
.
floor
(
seconds
/
60
)
const
secs
=
Math
.
floor
(
seconds
%
60
)
return
`
${
mins
.
toString
().
padStart
(
2
,
'
0
'
)}
:
${
secs
.
toString
().
padStart
(
2
,
'
0
'
)}
`
}
// 获取音频时长
const
getAudioDuration
=
async
(
url
,
id
)
=>
{
if
(
audioDurations
.
value
[
id
])
return
audioDurations
.
value
[
id
]
return
new
Promise
((
resolve
)
=>
{
const
audio
=
new
Audio
()
audio
.
addEventListener
(
'
loadedmetadata
'
,
()
=>
{
audioDurations
.
value
[
id
]
=
audio
.
duration
resolve
(
audio
.
duration
)
})
audio
.
addEventListener
(
'
error
'
,
()
=>
{
resolve
(
0
)
})
audio
.
src
=
url
})
}
// 处理音频预览播放/停止
const
handleAudioPreview
=
async
(
item
,
isTemplate
=
false
)
=>
{
const
id
=
isTemplate
?
`template_
${
item
.
filename
}
`
:
`history_
${
item
.
filename
}
`
const
url
=
isTemplate
?
getTemplateFileUrl
(
item
.
filename
,
'
audios
'
)
:
item
.
url
// 如果当前正在播放这个音频,则停止
if
(
playingAudioId
.
value
===
id
)
{
playingAudioId
.
value
=
null
stopAudioPlayback
()
// 调用停止音频播放函数
return
}
// 停止其他正在播放的音频
playingAudioId
.
value
=
null
stopAudioPlayback
()
// 先停止当前播放的音频
// 播放新音频
try
{
// 设置停止回调,当音频停止时更新UI状态
setAudioStopCallback
(()
=>
{
playingAudioId
.
value
=
null
})
if
(
isTemplate
)
{
previewAudioTemplate
(
item
)
}
else
{
previewAudioHistory
({
url
})
}
playingAudioId
.
value
=
id
// 获取音频时长
await
getAudioDuration
(
url
,
id
)
}
catch
(
error
)
{
console
.
error
(
'
音频播放失败:
'
,
error
)
}
}
// 检查是否正在播放
const
isPlaying
=
(
item
,
isTemplate
=
false
)
=>
{
const
id
=
isTemplate
?
`template_
${
item
.
filename
}
`
:
`history_
${
item
.
filename
}
`
return
playingAudioId
.
value
===
id
}
// 获取音频时长显示
const
getDurationDisplay
=
(
item
,
isTemplate
=
false
)
=>
{
const
id
=
isTemplate
?
`template_
${
item
.
filename
}
`
:
`history_
${
item
.
filename
}
`
return
formatDuration
(
audioDurations
.
value
[
id
])
}
// 预加载音频时长
const
preloadAudioDurations
=
(
items
,
isTemplate
=
false
)
=>
{
items
.
forEach
(
item
=>
{
const
id
=
isTemplate
?
`template_
${
item
.
filename
}
`
:
`history_
${
item
.
filename
}
`
const
url
=
isTemplate
?
getTemplateFileUrl
(
item
.
filename
,
'
audios
'
)
:
item
.
url
// 如果已经有时长数据,跳过
if
(
audioDurations
.
value
[
id
]
||
!
url
)
return
// 异步加载时长
getAudioDuration
(
url
,
id
)
})
}
// 监听音频历史和模板列表变化,预加载时长
watch
(
audioHistory
,
(
newHistory
)
=>
{
if
(
newHistory
&&
newHistory
.
length
>
0
)
{
preloadAudioDurations
(
newHistory
,
false
)
}
},
{
immediate
:
true
,
deep
:
true
})
watch
(
audioTemplates
,
(
newTemplates
)
=>
{
if
(
newTemplates
&&
newTemplates
.
length
>
0
)
{
preloadAudioDurations
(
newTemplates
,
true
)
}
},
{
immediate
:
true
,
deep
:
true
})
</
script
>
<
template
>
<!-- 模板选择浮窗 - Apple 极简风格 -->
<div
v-cloak
>
<div
v-if=
"showImageTemplates || showAudioTemplates"
class=
"fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center"
@
click=
"showImageTemplates = false; showAudioTemplates = false"
>
<div
class=
"bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] backdrop-saturate-[180%] border border-black/8 dark:border-white/8 rounded-3xl px-8 py-8 max-w-4xl w-full mx-6 h-[90vh] overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)]"
@
click.stop
>
<!-- 浮窗头部 - Apple 风格 -->
<div
class=
"flex items-center justify-between mb-8"
>
<h3
class=
"text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight"
>
<i
v-if=
"showImageTemplates"
class=
"fas fa-image text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<i
v-if=
"showAudioTemplates"
class=
"fas fa-music text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
{{
showImageTemplates
?
t
(
'
imageTemplates
'
)
:
t
(
'
audioTemplates
'
)
}}
</h3>
<button
@
click=
"showImageTemplates = false; showAudioTemplates = false"
class=
"w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
>
<i
class=
"fas fa-times text-base"
></i>
</button>
</div>
<!-- 标签页切换 - Apple 风格 -->
<div
class=
"flex gap-2 mb-8"
>
<button
@
click=
"mediaModalTab = 'history'; showImageTemplates && getImageHistory(); showAudioTemplates && getAudioHistory()"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"mediaModalTab === 'history'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
<i
class=
"fas fa-history mr-2"
></i>
{{
t
(
'
history
'
)
}}
</button>
<button
@
click=
"mediaModalTab = 'templates'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"mediaModalTab === 'templates'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
<i
class=
"fas fa-layer-group mr-2"
></i>
{{
t
(
'
templates
'
)
}}
</button>
</div>
<!-- 图片历史记录 - Apple 风格 -->
<div
v-if=
"showImageTemplates && mediaModalTab === 'history'"
class=
"overflow-y-auto flex-1 max-h-[60vh] main-scrollbar pr-6 pl-1"
>
<div
v-if=
"imageHistory.length === 0"
class=
"flex flex-col items-center justify-center py-12 text-center"
>
<div
class=
"w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4"
>
<i
class=
"fas fa-history text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"
></i>
</div>
<p
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium mb-2 tracking-tight"
>
{{
t
(
'
noHistoryRecords
'
)
}}
</p>
<p
class=
"text-[#86868b] dark:text-[#98989d] text-sm tracking-tight"
>
{{
t
(
'
imageHistoryAutoSave
'
)
}}
</p>
</div>
<div
v-else
class=
"space-y-4 pt-2"
>
<div
class=
"flex items-center justify-between mb-6 px-1"
>
<span
class=
"text-sm text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
total
'
)
}}
{{
imageHistory
.
length
}}
{{
t
(
'
records
'
)
}}
</span>
<button
@
click=
"clearImageHistory"
class=
"text-xs text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 transition-colors flex items-center gap-1.5 tracking-tight"
:title=
"t('clearHistory')"
>
<i
class=
"fas fa-trash"
></i>
{{
t
(
'
clear
'
)
}}
</button>
</div>
<div
class=
"columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 px-1"
>
<div
v-for=
"(history, index) in imageHistory"
:key=
"index"
@
click=
"selectImageHistory(history)"
class=
"break-inside-avoid mb-4 relative group cursor-pointer rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/50 dark:hover:border-[color:var(--brand-primary-light)]/50 transition-all hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)]"
>
<img
:src=
"getHistoryImageUrl(history)"
:alt=
"history.filename"
class=
"w-full h-auto object-contain"
>
<div
class=
"absolute inset-0 bg-black/50 dark:bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<i
class=
"fas fa-check text-white text-xl"
></i>
</div>
</div>
</div>
</div>
</div>
<!-- 图片模板网格 - Apple 风格 -->
<div
v-if=
"showImageTemplates && mediaModalTab === 'templates'"
class=
"pr-6 pl-1"
>
<!-- 图片模板分页组件 - Apple 风格 -->
<div
v-if=
"templatePaginationInfo"
class=
"mt-6"
>
<div
class=
"flex items-center justify-between text-xs mb-4"
>
<div
class=
"flex items-center space-x-1 text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<span>
{{
templatePaginationInfo
.
total
}}
{{
t
(
'
records
'
)
}}
</span>
</div>
</div>
<div
v-if=
"templatePaginationInfo.total_pages > 1"
class=
"flex justify-center"
>
<nav
class=
"isolate inline-flex gap-1"
aria-label=
"Pagination"
>
<!-- 上一页按钮 -->
<button
@
click=
"goToTemplatePage(templateCurrentPage - 1)"
:disabled=
"templateCurrentPage
<
=
1"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"
{ 'opacity-50 cursor-not-allowed': templateCurrentPage
<
=
1
}"
:title=
"t('previousPage')"
>
<span
class=
"sr-only"
>
{{
t
(
'
previousPage
'
)
}}
</span>
<i
class=
"fas fa-chevron-left text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
<!-- 页码按钮 -->
<template
v-for=
"page in getVisibleTemplatePages()"
:key=
"page"
>
<button
v-if=
"page !== '...'"
@
click=
"goToTemplatePage(page)"
:class=
"[
'relative inline-flex items-center justify-center min-w-[36px] h-9 px-3 text-sm font-medium rounded-lg transition-all duration-200',
page === templateCurrentPage
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'
]"
:aria-current=
"page === templateCurrentPage ? 'page' : undefined"
>
{{
page
}}
</button>
<span
v-else
class=
"relative inline-flex items-center px-2 text-sm font-semibold text-[#86868b] dark:text-[#98989d]"
>
...
</span>
</
template
>
<!-- 下一页按钮 -->
<button
@
click=
"goToTemplatePage(templateCurrentPage + 1)"
:disabled=
"templateCurrentPage >= templatePaginationInfo.total_pages"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"{ 'opacity-50 cursor-not-allowed': templateCurrentPage >= templatePaginationInfo.total_pages }"
:title=
"t('nextPage')"
>
<span
class=
"sr-only"
>
{{ t('nextPage') }}
</span>
<i
class=
"fas fa-chevron-right text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
</nav>
</div>
</div>
<div
class=
"overflow-y-auto flex-1 max-h-[60vh] main-scrollbar pr-2 pt-2"
>
<div
class=
"space-y-4"
>
<div
v-if=
"isPageLoading"
class=
"flex items-center justify-center"
>
<div
class=
"inline-flex items-center gap-3 px-4 py-2 rounded-full bg-white/90 dark:bg-[#2c2c2e]/90 border border-black/8 dark:border-white/8 text-sm text-[#1d1d1f] dark:text-[#f5f5f7] shadow-[0_4px_16px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{ t('loading') }}
</span>
</div>
</div>
<div
v-if=
"mergedTemplates.filter(t => t.image).length > 0"
class=
"columns-2 sm:columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 px-1"
>
<div
v-for=
"template in mergedTemplates.filter(t => t.image)"
:key=
"template.id"
@
click=
"selectImageTemplate(template.image)"
class=
"break-inside-avoid mb-4 relative group cursor-pointer rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/50 dark:hover:border-[color:var(--brand-primary-light)]/50 transition-all hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)]"
>
<img
:src=
"template.image.url"
:alt=
"template.image.filename"
class=
"w-full h-auto object-contain"
preload=
"metadata"
>
<div
class=
"absolute inset-0 bg-black/50 dark:bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<i
class=
"fas fa-check text-white text-2xl"
></i>
</div>
</div>
</div>
<div
v-else
class=
"flex flex-col items-center justify-center py-12 text-center"
>
<div
class=
"w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4"
>
<i
class=
"fas fa-image text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"
></i>
</div>
<p
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium tracking-tight"
>
{{ t('noImageTemplates') }}
</p>
</div>
</div>
</div>
</div>
<!-- 音频历史记录 - Apple 风格 -->
<div
v-if=
"showAudioTemplates && mediaModalTab === 'history'"
class=
"overflow-y-auto flex-1 max-h-[60vh] main-scrollbar pr-6 pl-1"
>
<div
v-if=
"audioHistory.length === 0"
class=
"flex flex-col items-center justify-center py-12 text-center"
>
<div
class=
"w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4"
>
<i
class=
"fas fa-history text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"
></i>
</div>
<p
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium mb-2 tracking-tight"
>
{{ t('noHistoryRecords') }}
</p>
<p
class=
"text-[#86868b] dark:text-[#98989d] text-sm tracking-tight"
>
{{ t('audioHistoryAutoSave') }}
</p>
</div>
<div
v-else
class=
"space-y-3 pt-2"
>
<div
class=
"flex items-center justify-between mb-6 px-1"
>
<span
class=
"text-sm text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{ t('total') }} {{ audioHistory.length }}
{{ t('records') }}
</span>
<button
@
click=
"clearAudioHistory"
class=
"text-xs text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 transition-colors flex items-center gap-1.5 tracking-tight"
:title=
"t('clearHistory')"
>
<i
class=
"fas fa-trash"
></i>
{{ t('clear') }}
</button>
</div>
<div
class=
"space-y-3 px-1"
>
<div
v-for=
"(history, index) in audioHistory"
:key=
"index"
@
click=
"selectAudioHistory(history)"
class=
"flex items-center gap-4 p-4 rounded-2xl border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/50 dark:hover:border-[color:var(--brand-primary-light)]/50 transition-all cursor-pointer bg-white/80 dark:bg-[#2c2c2e]/80 hover:bg-white dark:hover:bg-[#3a3a3c] hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)] group"
>
<div
class=
"w-12 h-12 rounded-xl overflow-hidden flex-shrink-0 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 flex items-center justify-center"
>
<img
v-if=
"history.imageUrl"
:src=
"history.imageUrl"
:alt=
"history.filename"
class=
"w-full h-full object-cover"
@
error=
"history.imageUrl = null"
/>
<i
v-else
class=
"fas fa-music text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-xl"
></i>
</div>
<div
class=
"flex-1 min-w-0"
>
<div
class=
"text-[#86868b] dark:text-[#98989d] text-sm flex items-center gap-2 tracking-tight"
>
<span>
{{ t('historyAudio') }}
</span>
<span
class=
"text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
>
•
</span>
<span>
{{ getDurationDisplay(history, false) }}
</span>
</div>
</div>
<button
@
click.stop=
"handleAudioPreview(history, false)"
class=
"px-4 py-2 rounded-lg transition-all cursor-pointer relative z-10 flex items-center gap-2 flex-shrink-0 tracking-tight"
:class=
"isPlaying(history, false)
? 'text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300'
: 'text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] hover:text-[color:var(--brand-primary)]/80 dark:hover:text-[color:var(--brand-primary-light)]/80'"
style=
"pointer-events: auto;"
>
<i
:class=
"isPlaying(history, false) ? 'fas fa-stop' : 'fas fa-play'"
></i>
<span
class=
"text-sm font-medium"
>
{{ isPlaying(history, false) ? t('stop') : t('preview') }}
</span>
</button>
</div>
</div>
</div>
</div>
<!-- 音频模板列表 - Apple 风格 -->
<div
v-if=
"showAudioTemplates && mediaModalTab === 'templates'"
class=
"pr-6 pl-1"
>
<!-- 音频模板分页组件 - Apple 风格 -->
<div
v-if=
"templatePaginationInfo"
class=
"mt-6"
>
<div
class=
"flex items-center justify-between text-xs mb-4"
>
<div
class=
"flex items-center space-x-1 text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<span>
{{ templatePaginationInfo.total }} {{ t('records') }}
</span>
</div>
</div>
<div
v-if=
"templatePaginationInfo.total_pages > 1"
class=
"flex justify-center"
>
<nav
class=
"isolate inline-flex gap-1"
aria-label=
"Pagination"
>
<!-- 上一页按钮 -->
<button
@
click=
"goToTemplatePage(templateCurrentPage - 1)"
:disabled=
"templateCurrentPage <= 1"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"{ 'opacity-50 cursor-not-allowed': templateCurrentPage <= 1 }"
:title=
"t('previousPage')"
>
<span
class=
"sr-only"
>
{{ t('previousPage') }}
</span>
<i
class=
"fas fa-chevron-left text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
<!-- 页码按钮 -->
<
template
v-for=
"page in getVisibleTemplatePages()"
:key=
"page"
>
<button
v-if=
"page !== '...'"
@
click=
"goToTemplatePage(page)"
:class=
"[
'relative inline-flex items-center justify-center min-w-[36px] h-9 px-3 text-sm font-medium rounded-lg transition-all duration-200',
page === templateCurrentPage
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'
]"
:aria-current=
"page === templateCurrentPage ? 'page' : undefined"
>
{{
page
}}
</button>
<span
v-else
class=
"relative inline-flex items-center px-2 text-sm font-semibold text-[#86868b] dark:text-[#98989d]"
>
...
</span>
</
template
>
<!-- 下一页按钮 -->
<button
@
click=
"goToTemplatePage(templateCurrentPage + 1)"
:disabled=
"templateCurrentPage >= templatePaginationInfo.total_pages"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"{ 'opacity-50 cursor-not-allowed': templateCurrentPage >= templatePaginationInfo.total_pages }"
:title=
"t('nextPage')"
>
<span
class=
"sr-only"
>
{{ t('nextPage') }}
</span>
<i
class=
"fas fa-chevron-right text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
</nav>
</div>
</div>
<div
class=
"overflow-y-auto flex-1 max-h-[60vh] main-scrollbar pr-2 pt-2"
>
<div
class=
"space-y-4"
>
<div
v-if=
"isPageLoading"
class=
"flex items-center justify-center"
>
<div
class=
"inline-flex items-center gap-3 px-4 py-2 rounded-full bg-white/90 dark:bg-[#2c2c2e]/90 border border-black/8 dark:border-white/8 text-sm text-[#1d1d1f] dark:text-[#f5f5f7] shadow-[0_4px_16px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{ t('loading') }}
</span>
</div>
</div>
<div
v-if=
"mergedTemplates.length > 0"
class=
"space-y-3 px-1"
>
<div
v-for=
"template in mergedTemplates"
:key=
"template.id"
@
click=
"selectAudioTemplate(template.audio)"
class=
"flex items-center gap-4 p-4 rounded-2xl border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/50 dark:hover:border-[color:var(--brand-primary-light)]/50 transition-all cursor-pointer bg-white/80 dark:bg-[#2c2c2e]/80 hover:bg-white dark:hover:bg-[#3a3a3c] hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)] group"
>
<div
class=
"w-12 h-12 rounded-xl overflow-hidden flex-shrink-0 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 flex items-center justify-center"
>
<img
v-if=
"template.image?.url"
:src=
"template.image.url"
:alt=
"t('audioTemplates')"
class=
"w-full h-full object-cover"
@
error=
"template.image.url = null"
/>
<i
v-else
class=
"fas fa-music text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-xl"
></i>
</div>
<div
class=
"flex-1 min-w-0"
>
<div
class=
"text-[#86868b] dark:text-[#98989d] text-sm flex items-center gap-2 tracking-tight"
>
<span>
{{ t('audioTemplates') }}
</span>
<span
v-if=
"template.audio"
class=
"text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
>
•
</span>
<span
v-if=
"template.audio"
>
{{ getDurationDisplay(template.audio, true) }}
</span>
</div>
</div>
<div
class=
"flex items-center gap-2 flex-shrink-0"
>
<button
v-if=
"template.image"
@
click.stop=
"selectImageTemplate(template.image)"
class=
"px-4 py-2 rounded-lg transition-all cursor-pointer relative z-10 flex items-center gap-2 tracking-tight text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] hover:text-[color:var(--brand-primary)]/80 dark:hover:text-[color:var(--brand-primary-light)]/80"
style=
"pointer-events: auto;"
>
<i
class=
"fas fa-image"
></i>
<span
class=
"text-sm font-medium"
>
{{ t('useImage') }}
</span>
</button>
<button
v-if=
"template.audio"
@
click.stop=
"handleAudioPreview(template.audio, true)"
class=
"px-4 py-2 rounded-lg transition-all cursor-pointer relative z-10 flex items-center gap-2 tracking-tight"
:class=
"isPlaying(template.audio, true)
? 'text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300'
: 'text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] hover:text-[color:var(--brand-primary)]/80 dark:hover:text-[color:var(--brand-primary-light)]/80'"
style=
"pointer-events: auto;"
>
<i
:class=
"isPlaying(template.audio, true) ? 'fas fa-stop' : 'fas fa-play'"
></i>
<span
class=
"text-sm font-medium"
>
{{ isPlaying(template.audio, true) ? t('stop') : t('preview') }}
</span>
</button>
</div>
</div>
</div>
<div
v-else
class=
"flex flex-col items-center justify-center py-12 text-center"
>
<div
class=
"w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4"
>
<i
class=
"fas fa-music text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"
></i>
</div>
<p
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium tracking-tight"
>
目前暂无音频模板
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
lightx2v/deploy/server/frontend/src/components/ModelDropdown.vue
0 → 100644
View file @
a1ebc651
<
template
>
<DropdownMenu
:items=
"modelItems"
:selected-value=
"selectedModel"
:placeholder=
"t('selectModel')"
:empty-message=
"t('selectTaskTypeFirst')"
@
select-item=
"handleSelectModel"
/>
</
template
>
<
script
setup
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
DropdownMenu
from
'
./DropdownMenu.vue
'
const
{
t
}
=
useI18n
()
// Props
const
props
=
defineProps
({
availableModels
:
{
type
:
Array
,
default
:
()
=>
[]
},
selectedModel
:
{
type
:
String
,
default
:
''
}
})
// Emits
const
emit
=
defineEmits
([
'
select-model
'
])
// Computed
const
modelItems
=
computed
(()
=>
{
return
props
.
availableModels
.
map
(
model
=>
{
// 如果 model 已经是对象格式(包含 value 和 label),直接使用
if
(
typeof
model
===
'
object
'
&&
model
!==
null
&&
model
.
value
!==
undefined
)
{
return
{
value
:
model
.
value
,
label
:
model
.
label
,
icon
:
model
.
icon
||
'
fas fa-cog
'
}
}
// 否则,将字符串转换为对象格式
return
{
value
:
model
,
label
:
model
,
icon
:
'
fas fa-cog
'
}
})
})
// Methods
const
handleSelectModel
=
(
item
)
=>
{
emit
(
'
select-model
'
,
item
.
value
)
}
</
script
>
lightx2v/deploy/server/frontend/src/components/Projects.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
useI18n
}
from
'
vue-i18n
'
import
{
watch
}
from
'
vue
'
const
{
t
,
locale
}
=
useI18n
()
import
FloatingParticles
from
'
./FloatingParticles.vue
'
import
TopBar
from
'
./TopBar.vue
'
import
LeftBar
from
'
./LeftBar.vue
'
import
Loading
from
'
./Loading.vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
// Props
const
props
=
defineProps
({
query
:
{
type
:
Object
,
default
:
()
=>
({})
},
taskId
:
{
type
:
String
,
default
:
null
}
})
import
{
submitting
,
// 任务类型下拉菜单
showTaskTypeMenu
,
showModelMenu
,
isLoggedIn
,
loading
,
loginLoading
,
initLoading
,
downloadLoading
,
downloadLoadingMessage
,
// 录音相关
isRecording
,
recordingDuration
,
startRecording
,
stopRecording
,
formatRecordingDuration
,
taskSearchQuery
,
currentUser
,
models
,
tasks
,
alert
,
showErrorDetails
,
showFailureDetails
,
confirmDialog
,
showConfirmDialog
,
showTaskDetailModal
,
modalTask
,
t2vForm
,
i2vForm
,
s2vForm
,
getCurrentForm
,
i2vImagePreview
,
s2vImagePreview
,
s2vAudioPreview
,
getCurrentImagePreview
,
getCurrentAudioPreview
,
setCurrentImagePreview
,
setCurrentAudioPreview
,
updateUploadedContentStatus
,
availableTaskTypes
,
availableModelClasses
,
currentTaskHints
,
currentHintIndex
,
startHintRotation
,
stopHintRotation
,
filteredTasks
,
selectedTaskId
,
selectedTask
,
loadingTaskFiles
,
statusFilter
,
pagination
,
paginationInfo
,
currentTaskPage
,
taskPageSize
,
taskPageInput
,
paginationKey
,
taskMenuVisible
,
toggleTaskMenu
,
closeAllTaskMenus
,
handleClickOutside
,
showAlert
,
setLoading
,
apiCall
,
logout
,
loadModels
,
sidebarCollapsed
,
sidebarWidth
,
showExpandHint
,
showGlow
,
isDefaultStateHidden
,
hideDefaultState
,
showDefaultState
,
isCreationAreaExpanded
,
hasUploadedContent
,
isContracting
,
expandCreationArea
,
contractCreationArea
,
taskFileCache
,
taskFileCacheLoaded
,
templateFileCache
,
templateFileCacheLoaded
,
loadTaskFiles
,
handleDownloadFile
,
viewFile
,
handleImageUpload
,
selectTask
,
selectModel
,
resetForm
,
triggerImageUpload
,
triggerAudioUpload
,
removeImage
,
removeAudio
,
handleAudioUpload
,
loadImageAudioTemplates
,
selectImageTemplate
,
selectAudioTemplate
,
previewAudioTemplate
,
getTemplateFile
,
imageTemplates
,
audioTemplates
,
showImageTemplates
,
showAudioTemplates
,
mediaModalTab
,
templatePagination
,
templatePaginationInfo
,
templateCurrentPage
,
templatePageSize
,
templatePageInput
,
templatePaginationKey
,
imageHistory
,
audioHistory
,
showTemplates
,
showHistory
,
showPromptModal
,
promptModalTab
,
submitTask
,
fileToBase64
,
formatTime
,
refreshTasks
,
goToPage
,
jumpToPage
,
getVisiblePages
,
goToTemplatePage
,
jumpToTemplatePage
,
getVisibleTemplatePages
,
goToInspirationPage
,
jumpToInspirationPage
,
getVisibleInspirationPages
,
preloadTaskFilesUrl
,
preloadTemplateFilesUrl
,
loadTaskFilesFromCache
,
saveTaskFilesToCache
,
getTaskFileFromCache
,
setTaskFileToCache
,
getTaskFileUrlFromApi
,
getTaskFileUrl
,
getTaskFileUrlSync
,
getTemplateFileUrlFromApi
,
getTemplateFileUrl
,
getTemplateFileUrlAsync
,
loadTemplateFilesFromCache
,
saveTemplateFilesToCache
,
loadFromCache
,
saveToCache
,
clearAllCache
,
getStatusBadgeClass
,
viewSingleResult
,
cancelTask
,
resumeTask
,
deleteTask
,
startPollingTask
,
stopPollingTask
,
reuseTask
,
showTaskCreator
,
toggleSidebar
,
clearPrompt
,
getTaskItemClass
,
getStatusIndicatorClass
,
getTaskTypeBtnClass
,
getModelBtnClass
,
getTaskTypeIcon
,
getTaskTypeName
,
getPromptPlaceholder
,
getStatusTextClass
,
getImagePreview
,
getTaskInputUrl
,
getTaskInputImage
,
getTaskInputAudio
,
getHistoryImageUrl
,
getUserAvatarUrl
,
getCurrentImagePreviewUrl
,
getCurrentAudioPreviewUrl
,
handleThumbnailError
,
handleImageError
,
handleImageLoad
,
handleAudioError
,
handleAudioLoad
,
getTaskStatusDisplay
,
getTaskStatusColor
,
getTaskStatusIcon
,
getTaskDuration
,
getRelativeTime
,
getTaskHistory
,
getActiveTasks
,
getOverallProgress
,
getProgressTitle
,
getProgressInfo
,
getSubtaskProgress
,
getSubtaskStatusText
,
formatEstimatedTime
,
formatDuration
,
searchTasks
,
filterTasksByStatus
,
filterTasksByType
,
getAlertClass
,
getAlertBorderClass
,
getAlertTextClass
,
getAlertIcon
,
getAlertIconBgClass
,
getPromptTemplates
,
selectPromptTemplate
,
promptHistory
,
getPromptHistory
,
addTaskToHistory
,
getLocalTaskHistory
,
selectPromptHistory
,
clearPromptHistory
,
getImageHistory
,
getAudioHistory
,
selectImageHistory
,
selectAudioHistory
,
previewAudioHistory
,
clearImageHistory
,
clearAudioHistory
,
getAudioMimeType
,
getAuthHeaders
,
startResize
,
sidebar
,
switchToCreateView
,
switchToProjectsView
,
switchToInspirationView
,
switchToLoginView
,
openTaskDetailModal
,
closeTaskDetailModal
,
// 灵感广场相关
inspirationSearchQuery
,
selectedInspirationCategory
,
inspirationItems
,
InspirationCategories
,
loadInspirationData
,
selectInspirationCategory
,
handleInspirationSearch
,
loadMoreInspiration
,
inspirationPagination
,
inspirationPaginationInfo
,
inspirationCurrentPage
,
inspirationPageSize
,
inspirationPageInput
,
inspirationPaginationKey
,
// 工具函数
formatDate
,
// 模板详情弹窗相关
showTemplateDetailModal
,
selectedTemplate
,
previewTemplateDetail
,
closeTemplateDetailModal
,
useTemplate
,
// 图片放大弹窗相关
showImageZoomModal
,
zoomedImageUrl
,
showImageZoom
,
closeImageZoomModal
,
// 模板素材应用相关
applyTemplateImage
,
applyTemplateAudio
,
applyTemplatePrompt
,
copyPrompt
,
// 视频播放控制
playVideo
,
pauseVideo
,
toggleVideoPlay
,
pauseAllVideos
,
updateVideoIcon
,
onVideoLoaded
,
onVideoError
,
onVideoEnded
,
generateShareUrl
,
copyShareLink
,
shareToSocial
,
openTaskFromRoute
,
isPageLoading
}
from
'
../utils/other
'
// 路由监听
const
route
=
useRoute
()
const
router
=
useRouter
()
// 监听路由变化,处理任务详情路由
watch
(()
=>
route
.
params
.
taskId
,
(
newTaskId
)
=>
{
if
(
newTaskId
&&
route
.
name
===
'
TaskDetail
'
)
{
openTaskFromRoute
(
newTaskId
)
}
},
{
immediate
:
true
})
// 监听Projects页面的查询参数
watch
(()
=>
route
.
query
,
(
newQuery
)
=>
{
// 同步URL参数到组件状态
if
(
newQuery
.
search
)
{
taskSearchQuery
.
value
=
newQuery
.
search
}
if
(
newQuery
.
status
)
{
statusFilter
.
value
=
newQuery
.
status
}
if
(
newQuery
.
page
)
{
const
page
=
parseInt
(
newQuery
.
page
)
if
(
page
>
0
&&
page
!==
currentTaskPage
.
value
)
{
goToPage
(
page
)
}
}
},
{
immediate
:
true
})
// 监听组件状态变化,同步到URL
watch
([
taskSearchQuery
,
statusFilter
,
currentTaskPage
],
()
=>
{
const
query
=
{}
if
(
taskSearchQuery
.
value
)
{
query
.
search
=
taskSearchQuery
.
value
}
if
(
statusFilter
.
value
&&
statusFilter
.
value
!==
'
ALL
'
)
{
query
.
status
=
statusFilter
.
value
}
if
(
currentTaskPage
.
value
>
1
)
{
query
.
page
=
currentTaskPage
.
value
.
toString
()
}
// 更新URL但不触发路由监听
router
.
replace
({
query
})
})
</
script
>
<
template
>
<div
v-if=
"downloadLoading"
class=
"fixed inset-0 z-[120] flex justify-center items-center bg-black/10 dark:bg-black/30 pointer-events-none backdrop-blur-sm"
>
<div
class=
"pointer-events-auto px-5 py-3 rounded-2xl bg-white/85 dark:bg-[#2c2c2e]/85 border border-black/10 dark:border-white/10 backdrop-blur-[14px] shadow-[0_16px_40px_rgba(0,0,0,0.18)] dark:shadow-[0_16px_40px_rgba(0,0,0,0.5)] flex items-center gap-2 text-sm text-[#1d1d1f] dark:text-[#f5f5f7]"
>
<i
class=
"fas fa-spinner fa-spin text-base text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{
downloadLoadingMessage
||
t
(
'
downloadPreparing
'
)
}}
</span>
</div>
</div>
<!-- 历史任务区域 - Apple 极简风格 -->
<div
class=
"flex-1 flex flex-col min-h-0 mobile-content"
>
<!-- 内容区域 -->
<div
class=
"flex-1 overflow-y-auto p-6 content-area main-scrollbar"
>
<!-- 历史任务功能区 -->
<div
class=
"max-w-7xl mx-auto"
id=
"task-creator"
>
<!-- 标题区域 - Apple 风格 -->
<div
class=
"text-center mb-10"
>
<h1
class=
"text-4xl sm:text-5xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
{{
t
(
'
myProjects
'
)
}}
</h1>
</div>
<!-- 搜索和筛选区域 - Apple 风格 -->
<div
class=
"flex flex-col md:flex-row gap-4 mb-8"
>
<!-- 搜索框 - Apple 风格 -->
<div
class=
"relative flex-1"
>
<i
class=
"fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-[#86868b] dark:text-[#98989d] pointer-events-none z-10"
></i>
<input
v-model=
"taskSearchQuery"
class=
"w-full bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl py-3 pl-11 pr-4 text-[15px] text-[#1d1d1f] dark:text-[#f5f5f7] placeholder-[#86868b] dark:placeholder-[#98989d] tracking-tight hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 focus:shadow-[0_4px_16px_rgba(var(--brand-primary-rgb),0.12)] dark:focus:shadow-[0_4px_16px_rgba(var(--brand-primary-light-rgb),0.2)] transition-all duration-200"
:placeholder=
"t('searchTasks')"
type=
"text"
/>
</div>
<!-- 状态筛选按钮 - Apple 风格 -->
<div
class=
"flex gap-2 flex-wrap items-center"
>
<button
@
click=
"statusFilter = 'ALL'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"statusFilter === 'ALL'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
{{
t
(
'
all
'
)
}}
</button>
<button
@
click=
"statusFilter = 'SUCCEED'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"statusFilter === 'SUCCEED'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
{{
t
(
'
success
'
)
}}
</button>
<button
@
click=
"statusFilter = 'RUNNING'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"statusFilter === 'RUNNING'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
{{
t
(
'
running
'
)
}}
</button>
<button
@
click=
"statusFilter = 'FAILED'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"statusFilter === 'FAILED'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
{{
t
(
'
failed
'
)
}}
</button>
<!-- 刷新按钮 - Apple 风格 -->
<button
@
click=
"() => refreshTasks(true)"
class=
"w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100 flex-shrink-0"
:title=
"t('refreshTasks')"
>
<i
class=
"fas fa-sync-alt text-sm"
></i>
</button>
</div>
</div>
<!-- 分页组件 - Apple 风格 -->
<div
v-if=
"paginationInfo"
:key=
"paginationKey"
class=
"mb-6"
>
<div
class=
"flex items-center justify-between text-xs mb-4"
>
<div
class=
"flex items-center space-x-1 text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<span>
{{
t
(
'
total
'
)
}}
{{
paginationInfo
.
total
}}
{{
t
(
'
tasks
'
)
}}
</span>
</div>
</div>
<div
v-if=
"paginationInfo.total_pages > 1"
class=
"flex justify-center"
>
<nav
class=
"isolate inline-flex gap-1"
aria-label=
"Pagination"
>
<!-- 上一页按钮 -->
<button
@
click=
"goToPage(currentTaskPage - 1)"
:disabled=
"currentTaskPage
<
=
1"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"
{ 'opacity-50 cursor-not-allowed': currentTaskPage
<
=
1
}"
:title=
"t('previousPage')"
>
<span
class=
"sr-only"
>
{{
t
(
'
previousPage
'
)
}}
</span>
<i
class=
"fas fa-chevron-left text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
<!-- 页码按钮 -->
<template
v-for=
"page in getVisiblePages()"
:key=
"page"
>
<button
v-if=
"page !== '...'"
@
click=
"goToPage(page)"
:class=
"[
'relative inline-flex items-center justify-center min-w-[36px] h-9 px-3 text-sm font-medium rounded-lg transition-all duration-200',
page === currentTaskPage
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'
]"
:aria-current=
"page === currentTaskPage ? 'page' : undefined"
>
{{
page
}}
</button>
<span
v-else
class=
"relative inline-flex items-center px-2 text-sm font-semibold text-[#86868b] dark:text-[#98989d]"
>
...
</span>
</
template
>
<!-- 下一页按钮 -->
<button
@
click=
"goToPage(currentTaskPage + 1)"
:disabled=
"currentTaskPage >= paginationInfo.total_pages"
class=
"relative inline-flex items-center w-9 h-9 rounded-lg bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] transition-all duration-200"
:class=
"{ 'opacity-50 cursor-not-allowed': currentTaskPage >= paginationInfo.total_pages }"
:title=
"t('nextPage')"
>
<span
class=
"sr-only"
>
{{ t('nextPage') }}
</span>
<i
class=
"fas fa-chevron-right text-xs mx-auto"
aria-hidden=
"true"
></i>
</button>
</nav>
</div>
</div>
<!-- 任务内容网格 - Apple 风格 -->
<div
class=
"space-y-4"
>
<div
v-if=
"isPageLoading"
class=
"flex items-center justify-center"
>
<div
class=
"inline-flex items-center gap-3 px-4 py-2 rounded-full bg-white/90 dark:bg-[#2c2c2e]/90 border border-black/8 dark:border-white/8 text-sm text-[#1d1d1f] dark:text-[#f5f5f7] shadow-[0_4px_16px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]"
>
<i
class=
"fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
<span>
{{ t('loading') }}
</span>
</div>
</div>
<
template
v-if=
"filteredTasks.length === 0"
>
<div
class=
"flex flex-col items-center justify-center py-16 text-center"
>
<div
class=
"w-20 h-20 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-6"
>
<i
class=
"fas fa-video text-3xl text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
</div>
<p
class=
"text-lg font-medium text-[#1d1d1f] dark:text-[#f5f5f7] mb-2 tracking-tight"
>
{{
t
(
'
noHistoryTasks
'
)
}}
</p>
<p
class=
"text-sm text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
startToCreateYourFirstAIVideo
'
)
}}
</p>
</div>
</
template
>
<div
v-else
class=
"columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4"
>
<!-- 任务卡片 - Apple 风格 -->
<div
v-for=
"task in filteredTasks"
:key=
"task.task_id"
class=
"break-inside-avoid mb-4 group relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.2)]"
>
<!-- 缩略图区域 -->
<div
class=
"cursor-pointer bg-black/2 dark:bg-white/2 relative flex flex-col"
@
click=
"openTaskDetailModal(task)"
:title=
"t('viewTaskDetails')"
>
<!-- 成功任务:显示视频动图 -->
<video
v-if=
"task.status === 'SUCCEED' && task.outputs?.output_video"
:src=
"getTaskFileUrlSync(task.task_id, 'output_video')"
:poster=
"getTaskFileUrlSync(task.task_id, 'input_image')"
class=
"w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
preload=
"auto"
playsinline
webkit-playsinline
@
mouseenter=
"playVideo($event)"
@
mouseleave=
"pauseVideo($event)"
@
loadeddata=
"onVideoLoaded($event)"
@
ended=
"onVideoEnded($event)"
@
error=
"onVideoError($event)"
></video>
<!-- 其他状态:显示输入图片或占位符 -->
<img
v-else-if=
"task.inputs?.input_image"
:src=
"getTaskFileUrlSync(task.task_id, 'input_image')"
class=
"w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
@
error=
"handleThumbnailError"
/>
<!-- 默认占位符 - Apple 风格 -->
<div
v-else
class=
"w-full aspect-[9/16] bg-[#f5f5f7] dark:bg-[#1c1c1e] flex items-center justify-center"
>
<i
class=
"fas fa-video text-4xl text-[#86868b]/30 dark:text-[#98989d]/30"
></i>
</div>
<!-- 移动端播放按钮 - Apple 风格 -->
<button
v-if=
"task.status === 'SUCCEED' && task.outputs?.output_video"
@
click.stop=
"toggleVideoPlay($event)"
class=
"md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 transition-all duration-200 z-20"
>
<i
class=
"fas fa-play text-sm"
></i>
</button>
<!-- 状态指示器 - Apple 风格 -->
<div
class=
"absolute top-3 right-3"
>
<span
class=
"relative inline-flex items-center gap-2 px-3 py-2 rounded-full text-xs font-semibold tracking-tight shadow-[0_8px_24px_rgba(0,0,0,0.12)] backdrop-blur-[30px] bg-white/85 dark:bg-[#1f1f24]/85 text-[#1d1d1f] dark:text-[#f5f5f7] border border-white/60 dark:border-white/10"
>
<span
class=
"inline-flex h-1.5 w-1.5 rounded-full"
:class=
"[
task.status === 'SUCCEED' ? 'bg-[#2ecc71]' :
task.status === 'RUNNING' ? 'bg-[#5865f2]' :
task.status === 'FAILED' ? 'bg-[#ff5a65]' :
'bg-[#a0a4b8]'
]"
></span>
{{ getTaskStatusDisplay(task.status) }}
</span>
</div>
<!-- 悬停时显示的操作按钮(桌面端)- Apple 风格 -->
<div
class=
"hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full"
>
<div
class=
"flex gap-2 pointer-events-auto"
>
<!-- 复用按钮 - 所有状态可用 -->
<button
v-if=
"['CREATED', 'PENDING', 'RUNNING','SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@
click.stop=
"reuseTask(task)"
class=
"w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('reuseTask')"
>
<i
class=
"fas fa-copy text-sm"
></i>
</button>
<!-- 取消按钮 - 进行中状态 -->
<button
v-if=
"['CREATED', 'PENDING', 'RUNNING'].includes(task.status)"
@
click.stop=
"cancelTask(task.task_id)"
class=
"w-10 h-10 rounded-full bg-white dark:bg-[#3a3a3c] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-red-500 dark:text-red-400 hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('cancelTask')"
>
<i
class=
"fas fa-times text-sm"
></i>
</button>
<!-- 重试按钮 - 失败/取消状态 -->
<button
v-if=
"['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@
click.stop=
"resumeTask(task.task_id)"
class=
"w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('retryTask')"
>
<i
class=
"fas fa-redo text-sm"
></i>
</button>
<!-- 下载按钮 - 成功状态 -->
<button
v-if=
"task.status === 'SUCCEED'"
@
click.stop=
"handleDownloadFile(task.task_id, 'output_video', task.outputs.output_video)"
:disabled=
"downloadLoading"
class=
"w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white transition-all duration-200"
:class=
"downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title=
"t('downloadTask')"
>
<i
class=
"fas fa-download text-sm"
></i>
</button>
<!-- 删除按钮 - 完成/失败/取消状态 -->
<button
v-if=
"['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@
click.stop=
"deleteTask(task.task_id)"
class=
"w-10 h-10 rounded-full bg-white dark:bg-[#3a3a3c] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-red-500 dark:text-red-400 hover:scale-110 active:scale-100 transition-all duration-200"
:title=
"t('deleteTask')"
>
<i
class=
"fas fa-trash text-sm"
></i>
</button>
</div>
</div>
</div>
<!-- 任务信息 - Apple 风格 -->
<div
class=
"px-4 py-4"
>
<h3
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] font-medium text-sm mb-2 line-clamp-2 tracking-tight"
>
{{ task.params.prompt.length > 50 ? task.params.prompt.slice(0, 50) + '...' : task.params.prompt }}
</h3>
<div
class=
"flex items-center justify-between text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
<span
class=
"truncate max-w-[60%]"
>
{{ task.model_cls }}
</span>
<span
class=
"flex-shrink-0"
>
{{ getRelativeTime(task.create_t) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- GitHub 仓库链接 - Apple 极简风格 -->
<div
class=
"fixed bottom-6 right-6 z-50"
>
<a
href=
"https://github.com/ModelTC/LightX2V"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"flex items-center gap-2.5 px-4 py-2.5 bg-white/85 dark:bg-[#1e1e1e]/85 backdrop-blur-[40px] border border-black/10 dark:border-white/10 rounded-full shadow-[0_4px_16px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.3)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.4)] hover:scale-105 active:scale-100 transition-all duration-200 group"
title=
"Star us on GitHub"
>
<i
class=
"fab fa-github text-lg text-[#1d1d1f] dark:text-[#f5f5f7] transition-transform duration-200 group-hover:rotate-12"
></i>
<span
class=
"text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight"
>
LightX2V
</span>
<i
class=
"fas fa-external-link-alt text-xs text-[#86868b] dark:text-[#98989d] transition-all duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
></i>
</a>
</div>
</template>
lightx2v/deploy/server/frontend/src/components/PromptTemplate.vue
0 → 100644
View file @
a1ebc651
<
script
setup
>
import
{
showPromptModal
,
promptModalTab
,
getPromptTemplates
,
selectPromptTemplate
,
promptHistory
,
selectPromptHistory
,
clearPromptHistory
,
selectedTaskId
}
from
'
../utils/other
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
</
script
>
<
template
>
<!-- 提示词模板和历史记录弹窗 - Apple 极简风格 -->
<div
v-cloak
>
<div
v-if=
"showPromptModal"
class=
"fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center"
@
click=
"showPromptModal = false"
>
<div
class=
"bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] backdrop-saturate-[180%] border border-black/8 dark:border-white/8 rounded-3xl p-8 max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)]"
@
click.stop
>
<!-- 浮窗头部 - Apple 风格 -->
<div
class=
"flex items-center justify-between mb-6"
>
<h3
class=
"text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight"
>
<i
class=
"fas fa-lightbulb text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"
></i>
{{
t
(
'
promptTemplates
'
)
}}
</h3>
<button
@
click=
"showPromptModal = false"
class=
"w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
>
<i
class=
"fas fa-times text-base"
></i>
</button>
</div>
<!-- 标签页切换 - Apple 风格 -->
<div
class=
"flex gap-2 mb-6"
>
<button
@
click=
"promptModalTab = 'templates'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"promptModalTab === 'templates'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
<i
class=
"fas fa-layer-group mr-2"
></i>
{{
t
(
'
templates
'
)
}}
</button>
<button
@
click=
"promptModalTab = 'history'"
class=
"px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-200 tracking-tight"
:class=
"promptModalTab === 'history'
? 'bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.3)]'
: 'bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:bg-white dark:hover:bg-[#3a3a3c] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]'"
>
<i
class=
"fas fa-history mr-2"
></i>
{{
t
(
'
history
'
)
}}
</button>
</div>
<!-- 模板内容 - Apple 风格 -->
<div
v-if=
"promptModalTab === 'templates'"
class=
"overflow-y-auto max-h-[50vh] main-scrollbar"
>
<div
v-if=
"getPromptTemplates(selectedTaskId).length > 0"
class=
"grid grid-cols-1 md:grid-cols-2 gap-4"
>
<button
v-for=
"template in getPromptTemplates(selectedTaskId)"
:key=
"template.id"
@
click=
"selectPromptTemplate(template)"
class=
"p-5 text-left bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 transition-all duration-200 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)] group active:scale-[0.98]"
>
<div
class=
"font-semibold text-[15px] mb-3 text-[#1d1d1f] dark:text-[#f5f5f7] group-hover:text-[color:var(--brand-primary)] dark:group-hover:text-[color:var(--brand-primary-light)] transition-colors tracking-tight"
>
{{
template
.
title
}}
</div>
<div
class=
"text-[13px] text-[#86868b] dark:text-[#98989d] line-clamp-3 leading-relaxed tracking-tight"
>
{{
template
.
prompt
}}
</div>
<div
class=
"mt-4 flex items-center justify-between"
>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
clickApply
'
)
}}
</span>
<i
class=
"fas fa-arrow-right text-xs text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] group-hover:translate-x-1 transition-transform"
></i>
</div>
</button>
</div>
<div
v-else
class=
"flex flex-col items-center justify-center py-12 text-center"
>
<div
class=
"w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4"
>
<i
class=
"fas fa-layer-group text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"
></i>
</div>
<p
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium mb-2 tracking-tight"
>
{{
t
(
'
noAvailableTemplates
'
)
}}
</p>
<p
class=
"text-[#86868b] dark:text-[#98989d] text-sm tracking-tight"
>
{{
t
(
'
pleaseSelectTaskType
'
)
}}
</p>
</div>
</div>
<!-- 历史记录内容 - Apple 风格 -->
<div
v-if=
"promptModalTab === 'history'"
class=
"overflow-y-auto max-h-[50vh] main-scrollbar"
>
<div
v-if=
"promptHistory.length === 0"
class=
"flex flex-col items-center justify-center py-12 text-center"
>
<div
class=
"w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4"
>
<i
class=
"fas fa-history text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"
></i>
</div>
<p
class=
"text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium mb-2 tracking-tight"
>
{{
t
(
'
noHistoryRecords
'
)
}}
</p>
<p
class=
"text-[#86868b] dark:text-[#98989d] text-sm tracking-tight"
>
{{
t
(
'
promptHistoryAutoSave
'
)
}}
</p>
</div>
<div
v-else
class=
"space-y-3"
>
<div
class=
"flex items-center justify-between mb-4"
>
<span
class=
"text-sm text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
promptHistory
.
length
}}
{{
t
(
'
records
'
)
}}
</span>
<button
@
click=
"clearPromptHistory"
class=
"text-xs text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 transition-colors flex items-center gap-1.5 tracking-tight"
:title=
"t('clearHistory')"
>
<i
class=
"fas fa-trash"
></i>
{{
t
(
'
clear
'
)
}}
</button>
</div>
<button
v-for=
"(history, index) in promptHistory"
:key=
"index"
@
click=
"selectPromptHistory(history)"
class=
"w-full p-5 text-left bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 transition-all duration-200 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)] group active:scale-[0.98]"
>
<div
class=
"text-[13px] text-[#1d1d1f] dark:text-[#f5f5f7] line-clamp-3 leading-relaxed group-hover:text-[color:var(--brand-primary)] dark:group-hover:text-[color:var(--brand-primary-light)] transition-colors tracking-tight"
>
{{
history
}}
</div>
<div
class=
"mt-3 flex items-center justify-between"
>
<span
class=
"text-xs text-[#86868b] dark:text-[#98989d] tracking-tight"
>
{{
t
(
'
clickApply
'
)
}}
</span>
<i
class=
"fas fa-arrow-right text-xs text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] group-hover:translate-x-1 transition-transform"
></i>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</
template
>
<
style
scoped
>
/* 所有样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
/* Apple 风格极简黑白设计 */
</
style
>
Prev
1
…
17
18
19
20
21
22
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment