Unverified Commit d8558a0c authored by LiangLiu's avatar LiangLiu Committed by GitHub
Browse files

update froentend (#470)


Co-authored-by: default avatarqinxinyi <qxy118045534@163.com>
parent 2a31ba43
......@@ -2,24 +2,116 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LightX2V</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 主要图标库 -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>LightX2V — AI数字人视频生成平台</title>
<meta name="description" content="LightX2V:轻量、快速的AI数字人视频生成平台。在线体验、示例视频与使用说明。">
<meta name="keywords" content="AI数字人, 文生视频, 图生视频, LightX2V, Light AI, 视频生成, 文本生成视频, 数字人视频平台">
<meta name="robots" content="index,follow" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0f1329">
<link rel="canonical" href="https://x2v.light-ai.top/" />
<link rel="alternate" hreflang="zh-CN" href="https://x2v.light-ai.top/" />
<link rel="alternate" hreflang="en" href="https://x2v.light-ai.top/en/" />
<link rel="alternate" hreflang="x-default" href="https://x2v.light-ai.top/" />
<meta property="og:type" content="website">
<meta property="og:site_name" content="LightX2V">
<meta property="og:title" content="LightX2V — AI数字人视频生成平台">
<meta property="og:description" content="轻量、快速的AI数字人视频生成平台。在线体验、示例视频与使用说明。">
<meta property="og:url" content="https://x2v.light-ai.top/">
<meta property="og:image" content="https://x2v.light-ai.top/og-cover.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="LightX2V — AI数字人视频生成平台">
<meta name="twitter:description" content="轻量、快速的AI数字人视频生成平台。由 Light AI 工具链驱动,支持文本/图像到视频的高效生成。">
<meta name="twitter:image" content="https://x2v.light-ai.top/og-cover.jpg">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="preconnect" href="https://x2v.light-ai.top" crossorigin>
<link rel="preconnect" href="https://cdn.bootcdn.net" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="https://x2v.light-ai.top">
<link rel="dns-prefetch" href="https://cdn.bootcdn.net">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="preload" href="/src/style.css" as="style">
<link rel="preload" href="/src/main.js" as="script" type="module">
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- 备用图标库 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"
media="print" onload="this.media='all'">
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-solid-rounded/css/uicons-solid-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-bold-rounded/css/uicons-bold-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-solid-rounded/css/uicons-solid-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-regular-rounded/css/uicons-regular-rounded.css'>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet" media="print" onload="this.media='all'">
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-solid-rounded/css/uicons-solid-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-bold-rounded/css/uicons-bold-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-solid-rounded/css/uicons-solid-rounded.css'>
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/3.0.0/uicons-regular-rounded/css/uicons-regular-rounded.css'>
<link href="/src/style.css" rel="stylesheet">
<style>
.seo-shell {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
</style>
<script type="application/ld+json">
{
"@context":"https://schema.org",
"@type":"Organization",
"name":"Light AI",
"url":"https://www.light-ai.top/",
"logo":"https://x2v.light-ai.top/og-cover.jpg",
"sameAs":[
"https://github.com/ModelTC/LightX2V"
]
}
</script>
<script type="application/ld+json">
{
"@context":"https://schema.org",
"@type":"WebSite",
"name":"LightX2V",
"url":"https://x2v.light-ai.top/",
"publisher":{"@type":"Organization","name":"Light AI"},
"inLanguage":"zh-CN"
}
</script>
</head>
<body>
<div id="app">
<main id="app">
<!-- 这是爬虫可读的首屏静态内容;JS 启动后可增强或替换 -->
<div class="seo-shell">
<header>
<h1>LightX2V</h1>
<p>免费、轻量、快速的AI数字人视频生成平台,由 Light AI 工具链提供端到端加速支持。</p>
<p>了解更多关于工具链与最新动态,请访问 <a href="https://www.light-ai.top/" rel="noopener" target="_blank">Light AI 官网</a><a href="https://github.com/ModelTC/LightX2V" rel="noopener" target="_blank">LightX2V GitHub</a></p>
</header>
<section>
<h2>功能亮点</h2>
<ul>
<li>电影级数字人视频生成</li>
<li>20倍生成提速</li>
<li>超低成本生成</li>
<li>精准口型对齐</li>
<li>分钟级视频时长</li>
<li>多场景应用</li>
<li>最新tts语音合成技术,支持多种语言,支持100+种音色,支持语音指令控制合成语音细节</li>
</ul>
</section>
<section>
<h2>快速开始</h2>
<ol>
<li>上传图片及音频,输入视频生成提示词,点击开始生成</li>
<li>生成并下载视频</li>
<li>应用模版,一键生成同款数字人视频</li>
</ol>
</section>
</div>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
<svg width="766" height="664" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden"><g transform="translate(-1750 -715)"><g><path d="M2243.72 715.11C2246.74 715.113 2246.74 715.113 2249.82 715.117 2252.12 715.114 2254.42 715.11 2256.79 715.107 2259.33 715.116 2261.88 715.124 2264.5 715.133 2268.5 715.133 2268.5 715.133 2272.57 715.132 2281.42 715.134 2290.27 715.153 2299.12 715.172 2305.24 715.176 2311.36 715.18 2317.48 715.182 2331.96 715.19 2346.45 715.209 2360.94 715.234 2377.43 715.261 2393.92 715.274 2410.41 715.286 2444.34 715.311 2478.26 715.354 2512.19 715.408 2508.04 724.065 2503.01 731.098 2497.09 738.687 2496.04 740.034 2495 741.382 2493.92 742.769 2491.6 745.746 2489.29 748.72 2486.97 751.692 2483 756.781 2479.05 761.885 2475.1 766.992 2460.44 785.934 2445.67 804.763 2430.67 823.436 2422.04 834.206 2413.58 845.099 2405.2 856.068 2397.85 865.678 2390.41 875.208 2382.93 884.711 2373.96 896.105 2365.04 907.535 2356.2 919.024 2354.25 921.553 2352.3 924.081 2350.3 926.687 2346.94 931.137 2343.73 935.699 2340.64 940.339 2395.66 940.339 2450.68 940.339 2507.36 940.339 2503.06 948.942 2500.32 952.707 2493.62 959.083 2485.57 966.934 2478.08 975.031 2470.75 983.552 2465.71 989.373 2460.57 995.09 2455.41 1000.8 2448.91 1008.01 2442.45 1015.25 2436.08 1022.57 2427.88 1031.99 2419.53 1041.26 2411.16 1050.54 2406.54 1055.69 2401.97 1060.88 2397.42 1066.11 2389.23 1075.53 2380.87 1084.8 2372.51 1094.07 2367.88 1099.23 2363.31 1104.42 2358.76 1109.64 2352.39 1116.96 2345.93 1124.2 2339.43 1131.41 2327.79 1144.34 2316.29 1157.39 2305 1170.64 2291.53 1186.46 2277.92 1202.08 2263.88 1217.4 2259.2 1222.54 2254.59 1227.74 2250.04 1232.99 2243.3 1240.75 2236.42 1248.37 2229.5 1255.97 2217.53 1269.12 2205.76 1282.39 2194.29 1295.98 2179.33 1313.68 2163.93 1330.84 2147.96 1347.65 2138.56 1357.55 2129.57 1367.67 2120.77 1378.11 2118.35 1373.27 2118.35 1373.27 2120.39 1367.04 2122.03 1363.06 2122.03 1363.06 2123.71 1359.01 2124.32 1357.55 2124.92 1356.08 2125.54 1354.57 2127.56 1349.66 2129.6 1344.76 2131.64 1339.86 2133.07 1336.41 2134.49 1332.96 2135.91 1329.51 2138.15 1324.04 2140.41 1318.58 2142.66 1313.11 2150.65 1293.77 2158.48 1274.36 2166.31 1254.95 2199.1 1173.69 2199.1 1173.69 2211.83 1146.37 2217.42 1134.28 2221.81 1121.88 2226.1 1109.27 2230.83 1096.33 2236.28 1083.66 2241.58 1070.94 2192.94 1070.94 2144.3 1070.94 2094.19 1070.94 2099.03 1056.4 2104.01 1042.11 2109.59 1027.86 2110.27 1026.13 2110.94 1024.39 2111.63 1022.6 2117.65 1007.17 2124.03 991.896 2130.43 976.618 2149.93 930.008 2169.27 883.341 2188.17 836.485 2197.34 813.764 2206.57 791.079 2216.17 768.533 2222.38 753.917 2228.24 739.174 2234.02 724.383 2237.71 715.502 2237.71 715.502 2243.72 715.11Z" fill="#85EEEE" fill-rule="evenodd" fill-opacity="1"/><path d="M1927.2 721.344C1930.12 721.342 1933.05 721.34 1936.06 721.338 1944.07 721.346 1952.07 721.407 1960.07 721.492 1968.44 721.569 1976.81 721.576 1985.19 721.59 2001.03 721.628 2016.87 721.729 2032.71 721.852 2050.76 721.989 2068.8 722.056 2086.84 722.117 2123.94 722.245 2161.04 722.463 2198.14 722.733 2193.36 735.655 2188.57 748.578 2183.64 761.891 2146.96 762.118 2110.28 762.293 2073.6 762.399 2056.57 762.449 2039.54 762.518 2022.5 762.63 2006.07 762.738 1989.64 762.795 1973.21 762.82 1966.94 762.838 1960.66 762.874 1954.39 762.926 1945.61 762.998 1936.84 763.007 1928.06 763.003 1925.46 763.038 1922.86 763.073 1920.17 763.109 1902.63 763.011 1902.63 763.011 1893.13 757.453 1886.75 749.869 1885.86 744.872 1886.21 734.97 1896.56 718.983 1910.1 721.071 1927.2 721.344Z" fill="#8CF1EA" fill-rule="evenodd" fill-opacity="1"/><path d="M1788.49 873.25C1793.21 873.192 1793.21 873.192 1798.03 873.133 1801.45 873.155 1804.86 873.18 1808.28 873.208 1811.8 873.2 1815.33 873.188 1818.85 873.173 1826.23 873.154 1833.6 873.181 1840.97 873.238 1850.41 873.308 1859.84 873.268 1869.28 873.194 1876.55 873.15 1883.82 873.164 1891.1 873.196 1894.58 873.204 1898.05 873.194 1901.53 873.165 1906.4 873.134 1911.27 873.19 1916.13 873.25 1918.9 873.26 1921.66 873.269 1924.51 873.28 1934.25 875.329 1937.93 879.24 1943.99 887.125 1944.29 895.878 1944.29 895.878 1941.58 904.324 1932.61 913.514 1926.51 915.397 1913.94 915.502 1910.84 915.548 1907.74 915.595 1904.55 915.643 1901.18 915.642 1897.82 915.638 1894.46 915.63 1890.99 915.649 1887.52 915.67 1884.05 915.694 1876.79 915.73 1869.53 915.729 1862.27 915.703 1852.98 915.675 1843.69 915.758 1834.4 915.872 1827.24 915.944 1820.08 915.949 1812.92 915.933 1809.49 915.936 1806.07 915.962 1802.64 916.011 1776 916.349 1776 916.349 1767.9 910.971 1763.19 905.453 1760.82 900.185 1759.12 893.114 1763.01 877.664 1774.07 873.301 1788.49 873.25Z" fill="#75DCE5" fill-rule="evenodd" fill-opacity="1"/><path d="M1932.29 1029.16C1935.39 1029.16 1938.5 1029.16 1941.7 1029.16 1945.08 1029.2 1948.46 1029.24 1951.84 1029.27 1955.3 1029.29 1958.77 1029.3 1962.23 1029.31 1971.34 1029.33 1980.45 1029.4 1989.55 1029.48 1998.85 1029.56 2008.15 1029.59 2017.45 1029.63 2035.68 1029.7 2053.91 1029.83 2072.15 1029.98 2068.1 1041.98 2064.06 1053.99 2060.02 1066 2041.53 1066.22 2023.04 1066.39 2004.56 1066.5 1995.97 1066.55 1987.39 1066.61 1978.8 1066.72 1968.93 1066.85 1959.06 1066.89 1949.18 1066.94 1946.1 1066.99 1943.02 1067.03 1939.85 1067.09 1935.55 1067.09 1935.55 1067.09 1931.17 1067.09 1928.65 1067.11 1926.13 1067.13 1923.53 1067.15 1915.24 1065.7 1912.41 1062.84 1907.24 1056.39 1905.36 1049.29 1906.27 1044.7 1907.24 1037.18 1914.88 1029.2 1921.51 1029.1 1932.29 1029.16Z" fill="#69D7E9" fill-rule="evenodd" fill-opacity="1"/><path d="M2026.67 873.654C2029.76 873.671 2032.86 873.688 2036.06 873.706 2044.27 873.752 2052.47 873.87 2060.68 874.004 2069.07 874.128 2077.46 874.183 2085.85 874.244 2102.29 874.377 2118.72 874.579 2135.16 874.833 2134.69 876.207 2134.22 877.58 2133.74 878.995 2129.88 890.425 2126.28 901.862 2123.06 913.488 2105.01 913.855 2086.96 914.109 2068.91 914.284 2062.77 914.357 2056.63 914.456 2050.5 914.581 2041.67 914.757 2032.85 914.84 2024.02 914.903 2021.27 914.978 2018.53 915.053 2015.7 915.13 1997.14 915.138 1997.14 915.138 1990.33 910.382 1985.56 904.899 1982.88 899.841 1981.16 892.802 1982.85 886.236 1985.24 882.088 1990.17 877.396 2000.97 871.389 2014.68 873.406 2026.67 873.654Z" fill="#7FE8E8" fill-rule="evenodd" fill-opacity="1"/><path d="M1913.53 1105.25C1916.22 1105.25 1918.92 1105.24 1921.7 1105.23 1927.38 1105.24 1933.07 1105.27 1938.76 1105.33 1947.47 1105.41 1956.18 1105.38 1964.9 1105.33 1970.43 1105.35 1975.95 1105.37 1981.48 1105.4 1984.09 1105.39 1986.7 1105.38 1989.39 1105.37 1993.03 1105.42 1993.03 1105.42 1996.74 1105.48 1999.94 1105.5 1999.94 1105.5 2003.2 1105.53 2010.81 1107.18 2013.75 1110.52 2018.62 1116.47 2019.99 1121.31 2019.99 1121.31 2019.98 1126.16 2020.03 1127.76 2020.09 1129.36 2020.14 1131.01 2017.62 1139.06 2013.73 1141.19 2006.54 1145.54 1997.49 1147.03 1988.52 1146.96 1979.36 1146.92 1976.72 1146.93 1974.09 1146.95 1971.37 1146.96 1965.81 1146.98 1960.24 1146.97 1954.68 1146.94 1946.17 1146.91 1937.66 1146.99 1929.15 1147.08 1923.74 1147.09 1918.33 1147.08 1912.92 1147.07 1910.37 1147.1 1907.83 1147.13 1905.21 1147.17 1887.61 1146.94 1887.61 1146.94 1878.09 1139.85 1873.65 1133.43 1873.65 1133.43 1872.14 1126.16 1876.78 1103.84 1894.84 1105.02 1913.53 1105.25Z" fill="#58C5E9" fill-rule="evenodd" fill-opacity="1"/><path d="M2097.24 1106.37C2099.9 1106.38 2102.56 1106.4 2105.29 1106.42 2113.76 1106.48 2122.23 1106.63 2130.7 1106.78 2136.45 1106.84 2142.19 1106.9 2147.94 1106.95 2162.02 1107.08 2176.09 1107.28 2190.17 1107.53 2185.96 1120.52 2181.36 1133.09 2175.72 1145.55 2160.84 1145.9 2145.97 1146.16 2131.09 1146.33 2126.03 1146.4 2120.97 1146.5 2115.91 1146.62 2108.64 1146.8 2101.36 1146.88 2094.08 1146.94 2091.83 1147.01 2089.57 1147.09 2087.24 1147.16 2071.91 1147.17 2071.91 1147.17 2065.59 1142.49 2060.81 1137.05 2057.9 1132.2 2056.17 1125.2 2061.58 1104.39 2079.4 1105.94 2097.24 1106.37Z" fill="#70D8E4" fill-rule="evenodd" fill-opacity="1"/><path d="M2011.84 951.436C2015.75 951.457 2015.75 951.457 2019.75 951.478 2028.07 951.532 2036.39 951.653 2044.71 951.776 2050.36 951.824 2056.01 951.869 2061.66 951.908 2075.49 952.015 2089.32 952.176 2103.16 952.38 2098.93 965.593 2094.32 978.379 2088.66 991.047 2073.7 991.286 2058.73 991.461 2043.77 991.578 2038.68 991.627 2033.58 991.693 2028.49 991.776 2021.18 991.893 2013.86 991.948 2006.54 991.991 2003.13 992.066 2003.13 992.066 1999.65 992.142 1983.84 992.148 1983.84 992.148 1976.08 986.457 1972.43 981.018 1971.3 978.21 1971.32 971.713 1971.26 970.118 1971.21 968.523 1971.16 966.88 1976.92 948.547 1996.36 951.13 2011.84 951.436Z" fill="#76DEE4" fill-rule="evenodd" fill-opacity="1"/><path d="M2091.61 796.255C2093.74 796.272 2095.88 796.289 2098.08 796.307 2104.87 796.373 2111.66 796.525 2118.45 796.678 2123.07 796.738 2127.68 796.793 2132.3 796.843 2143.59 796.975 2154.88 797.183 2166.17 797.431 2164.51 806.146 2162.27 813.891 2158.9 822.117 2157.62 825.265 2157.62 825.265 2156.32 828.477 2154.05 833.557 2154.05 833.557 2151.63 835.965 2145.45 836.298 2139.35 836.479 2133.17 836.539 2131.32 836.559 2129.46 836.578 2127.55 836.598 2123.61 836.632 2119.67 836.658 2115.73 836.676 2109.71 836.718 2103.7 836.823 2097.68 836.93 2093.86 836.954 2090.04 836.975 2086.21 836.991 2083.52 837.054 2083.52 837.054 2080.76 837.119 2072.38 837.084 2067.66 836.597 2060.82 831.602 2055.3 823.783 2055.62 818.791 2057.1 809.473 2067.1 795.805 2075.86 795.779 2091.61 796.255Z" fill="#88EEE8" fill-rule="evenodd" fill-opacity="1"/><path d="M1980.23 796.731C1983.33 796.745 1986.43 796.759 1989.63 796.774 2015.53 797.115 2015.53 797.115 2023.74 802.658 2029.39 811.148 2029.81 814.554 2028.59 824.543 2024.06 830.142 2020.45 833.487 2014.04 836.701 2004.08 837.382 1994.12 837.463 1984.15 837.552 1980.83 837.592 1977.52 837.659 1974.21 837.752 1969.42 837.885 1964.64 837.93 1959.85 837.974 1956.99 838.022 1954.12 838.07 1951.17 838.12 1943.69 836.701 1943.69 836.701 1937.91 830.998 1933.05 823.014 1932.26 819.146 1933.98 809.953 1944.67 792.74 1961.84 796.256 1980.23 796.731Z" fill="#87ECE8" fill-rule="evenodd" fill-opacity="1"/><path d="M1796.39 721.481C1801.44 721.501 1806.48 721.432 1811.53 721.356 1816.33 721.358 1816.33 721.358 1821.23 721.361 1824.15 721.356 1827.07 721.352 1830.08 721.347 1839.5 722.784 1843.54 725.399 1850.1 732.16 1851.47 740.567 1851.47 740.567 1850.1 748.974 1844.61 756.484 1841.67 758.381 1832.34 759.863 1827.63 759.903 1827.63 759.903 1822.83 759.943 1821.14 759.958 1819.46 759.972 1817.72 759.988 1814.17 760.005 1810.61 759.997 1807.06 759.967 1801.63 759.933 1796.21 760.015 1790.78 760.107 1787.32 760.109 1783.86 760.105 1780.4 760.093 1777.26 760.094 1774.12 760.096 1770.89 760.098 1762.82 758.582 1762.82 758.582 1756.88 753.027 1753.12 746.572 1753.12 746.572 1753.12 737.865 1759.23 717.439 1778.26 721.369 1796.39 721.481Z" fill="#89F0EB" fill-rule="evenodd" fill-opacity="1"/><path d="M2116.42 1184.19C2120.16 1184.23 2120.16 1184.23 2123.97 1184.26 2127.85 1184.36 2127.85 1184.36 2131.81 1184.45 2135.75 1184.5 2135.75 1184.5 2139.76 1184.55 2146.23 1184.63 2152.7 1184.75 2159.17 1184.9 2155.12 1196.94 2151.07 1208.98 2147.01 1221.02 2139.51 1221.38 2132.02 1221.59 2124.51 1221.78 2122.39 1221.88 2120.27 1221.98 2118.08 1222.08 2102.36 1222.38 2102.36 1222.38 2094.45 1215.5 2089.82 1206.58 2090.66 1201.63 2093.5 1192.12 2100.9 1184.93 2106.24 1184.03 2116.42 1184.19Z" fill="#5FCCE5" fill-rule="evenodd" fill-opacity="1"/></g></g></svg>
User-agent: *
Allow: /
Sitemap: https://x2v.light-ai.top/sitemap.xml
<?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>
......@@ -62,10 +62,10 @@ const handleScroll = () => {
if (scrollY > 50) {
// 计算Alert应该显示的位置,确保在视口内可见
// 距离顶部80px(TopBar高度 + 一些间距)
alertPosition.value = { top: '80px' }
alertPosition.value = { top: '6rem' }
} else {
// 在页面顶部时,显示在固定位置
alertPosition.value = { top: '1rem' }
alertPosition.value = { top: '1.5rem' }
}
}, 10) // 10ms防抖延迟
}
......@@ -106,9 +106,9 @@ onUnmounted(() => {
@after-leave="handleAfterLeave">
<div v-if="alert.show"
:key="alert._timestamp || alert.message"
class="fixed left-1/2 transform -translate-x-1/2 z-[9999] w-auto min-w-[280px] sm:min-w-[320px] max-w-[calc(100vw-3rem)] sm:max-w-xl px-6 sm:px-6 transition-all duration-500 ease-out"
:style="alertPosition">
<div class="alert-container">
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">
......@@ -139,7 +139,6 @@ onUnmounted(() => {
<style scoped>
/* Apple 风格 Alert 容器 */
.alert-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-radius: 16px;
......@@ -150,15 +149,6 @@ onUnmounted(() => {
overflow: hidden;
}
/* 深色模式下的容器样式 */
:global(.dark) .alert-container {
background: rgba(30, 30, 30, 0.95);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -1px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.08);
}
/* Alert 内容 */
.alert-content {
display: flex;
......@@ -178,11 +168,11 @@ onUnmounted(() => {
/* 图标样式 */
.alert-icon {
font-size: 18px;
color: #1d1d1f;
color: var(--brand-primary);
}
:global(.dark) .alert-icon {
color: #f5f5f7;
color: var(--brand-primary-light);
}
/* 消息文本 */
......@@ -191,15 +181,10 @@ onUnmounted(() => {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
color: #1d1d1f;
letter-spacing: -0.01em;
min-width: 0; /* 允许文本收缩 */
}
:global(.dark) .alert-message {
color: #f5f5f7;
}
/* 操作按钮和关闭按钮容器 */
.alert-actions {
display: flex;
......@@ -273,11 +258,11 @@ onUnmounted(() => {
}
:global(.dark) .alert-action-link {
color: var(--brand-primary-light);
color: #ffffff;
}
:global(.dark) .alert-action-link:hover {
color: var(--brand-primary-light);
color: #ffffff;
}
/* 进入动画 */
......
......@@ -7,7 +7,7 @@ const { t, locale } = useI18n()
<template>
<!-- 自定义确认对话框 - Apple 极简风格 -->
<div v-cloak>
<div v-if="confirmDialog.show" class="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<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>
......
......@@ -42,7 +42,8 @@ import {
onVideoError,
onVideoEnded,
openTemplateFromRoute,
copyShareLink
copyShareLink,
isPageLoading
} from '../utils/other'
// 监听模板详情路由
......@@ -201,7 +202,14 @@ onMounted(() => {
</div>
<!-- 灵感内容网格 - Apple 风格 -->
<div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4">
<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)]">
......@@ -260,6 +268,7 @@ onMounted(() => {
</div>
</div>
</div>
</div>
</div>
<!-- GitHub 仓库链接 - Apple 极简风格 -->
......
......@@ -4,12 +4,12 @@ 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">
<!-- 统一的圆角矩形容器-->
<nav class="bg-white/95 dark:bg-[#1e1e1e]/95 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.4)] overflow-hidden flex flex-col w-12 sm:w-14">
<!-- 统一的圆角矩形容器 - 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
......
......@@ -32,6 +32,7 @@ import {
loginLoading,
initLoading,
downloadLoading,
downloadLoadingMessage,
// 录音相关
isRecording,
......@@ -105,7 +106,7 @@ import {
templateFileCache,
templateFileCacheLoaded,
loadTaskFiles,
downloadFile,
handleDownloadFile,
viewFile,
handleImageUpload,
selectTask,
......@@ -292,107 +293,14 @@ import {
generateShareUrl,
copyShareLink,
shareToSocial,
openTaskFromRoute
openTaskFromRoute,
isPageLoading
} from '../utils/other'
// 路由监听
const route = useRoute()
const router = useRouter()
// 处理任务下载
const handleDownloadTask = async (task) => {
try {
console.log('开始下载任务文件:', { taskId: task.task_id, outputs: task.outputs })
// 处理文件名,确保有正确的后缀名
let fileName = task.outputs?.output_video || 'video.mp4'
if (fileName && typeof fileName === 'string') {
// 检查是否已有后缀名
const hasExtension = /\.[a-zA-Z0-9]+$/.test(fileName)
if (!hasExtension) {
// 没有后缀名,添加mp4后缀
fileName = `${fileName}.mp4`
console.log('添加后缀名:', fileName)
}
}
// 先尝试从缓存获取
let fileData = getTaskFileFromCache(task.task_id, 'output_video')
console.log('缓存中的文件数据:', fileData)
if (fileData && fileData.blob) {
// 缓存中有blob数据,直接使用
console.log('使用缓存中的文件数据')
downloadFile({ ...fileData, name: fileName })
return
}
if (fileData && fileData.url) {
// 缓存中有URL,使用URL下载
console.log('使用缓存中的URL下载:', fileData.url)
try {
const response = await fetch(fileData.url)
console.log('文件响应状态:', response.status, response.ok)
if (response.ok) {
const blob = await response.blob()
console.log('文件blob大小:', blob.size)
const downloadData = {
blob: blob,
name: fileName
}
console.log('构造的文件数据:', downloadData)
downloadFile(downloadData)
return
} else {
console.error('文件响应失败:', response.status, response.statusText)
}
} catch (error) {
console.error('使用缓存URL下载失败:', error)
}
}
if (!fileData) {
console.log('缓存中没有文件,尝试异步获取...')
// 缓存中没有,尝试异步获取
const url = await getTaskFileUrl(task.task_id, 'output_video')
console.log('获取到的文件URL:', url)
if (url) {
const response = await fetch(url)
console.log('文件响应状态:', response.status, response.ok)
if (response.ok) {
const blob = await response.blob()
console.log('文件blob大小:', blob.size)
fileData = {
blob: blob,
name: fileName
}
console.log('构造的文件数据:', fileData)
} else {
console.error('文件响应失败:', response.status, response.statusText)
}
} else {
console.error('无法获取文件URL')
}
}
if (fileData && fileData.blob) {
console.log('开始下载文件:', fileData.name)
downloadFile(fileData)
} else {
console.error('文件数据无效:', fileData)
showAlert(t('fileUnavailableAlert'), 'danger')
}
} catch (error) {
console.error('下载失败:', error)
showAlert(t('downloadFailedAlert'), 'danger')
}
}
// 监听路由变化,处理任务详情路由
watch(() => route.params.taskId, (newTaskId) => {
if (newTaskId && route.name === 'TaskDetail') {
......@@ -436,6 +344,12 @@ watch([taskSearchQuery, statusFilter, currentTaskPage], () => {
</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">
<!-- 内容区域 -->
......@@ -545,15 +459,25 @@ watch([taskSearchQuery, statusFilter, currentTaskPage], () => {
</div>
<!-- 任务内容网格 - Apple 风格 -->
<div v-if="filteredTasks.length === 0" 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 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">
<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)]">
......@@ -633,8 +557,10 @@ watch([taskSearchQuery, statusFilter, currentTaskPage], () => {
<!-- 下载按钮 - 成功状态 -->
<button v-if="task.status === 'SUCCEED'"
@click.stop="handleDownloadTask(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"
@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>
......@@ -662,9 +588,10 @@ watch([taskSearchQuery, statusFilter, currentTaskPage], () => {
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- GitHub 仓库链接 - Apple 极简风格 -->
<div class="fixed bottom-6 right-6 z-50">
......
<script setup>
import { useI18n } from 'vue-i18n'
const currentYear = new Date().getFullYear()
const { t } = useI18n()
</script>
<template>
<div class="bg-transparent text-neutral-600 dark:text-neutral-300">
<div class="mx-auto w-full max-w-4xl pt-16 pb-16 text-center">
<div class="flex flex-col gap-10 items-center justify-center">
<div class="max-w-sm space-y-6">
<div class="inline-flex items-center justify-center gap-3 text-xl font-semibold tracking-tight text-neutral-900 dark:text-white">
<img src="../../public/logo.svg" alt="LightX2V" class="h-8 w-8" loading="lazy" />
<span>LightX2V</span>
</div>
<p class="leading-relaxed text-neutral-500 dark:text-neutral-400">
{{ t('footer.tagline') }}
</p>
<div class="flex flex-wrap items-center justify-center gap-5 text-sm text-neutral-500 dark:text-neutral-400">
<a href="https://www.light-ai.top/" target="_blank" rel="noopener" class="transition hover:text-neutral-900 dark:hover:text-white">{{ t('footer.links.home') }}</a>
<a href="https://github.com/ModelTC/LightX2V" target="_blank" rel="noopener"
class="inline-flex items-center gap-2 transition hover:text-neutral-900 dark:hover:text-white">
<img src="https://github.githubassets.com/favicons/favicon.svg" :alt="t('footer.alt.github')" class="h-4 w-4 transition dark:invert" loading="lazy" />
{{ t('footer.links.github') }}
</a>
<a href="https://xhslink.com/m/45NsEK8minq" class="inline-flex items-center gap-2 transition hover:text-neutral-900 dark:hover:text-white">
<img src="https://www.xiaohongshu.com/favicon.ico" :alt="t('footer.alt.xiaohongshu')" class="h-4 w-4" loading="lazy" />
{{ t('footer.links.xiaohongshu') }}
</a>
</div>
</div>
</div>
<div class="mt-12 border-t border-black/10 dark:border-white/10 pt-8 text-xs text-neutral-400 dark:text-neutral-500 flex items-center justify-center">
<p>{{ t('footer.copyright', { year: currentYear }) }}</p>
</div>
</div>
</div>
</template>
......@@ -17,14 +17,17 @@ import {
showAlert,
cancelTask,
resumeTask,
downloadFile,
downloadLoading,
handleDownloadFile,
getTaskFileFromCache,
apiRequest,
copyShareLink,
deleteTask,
currentTask,
startPollingTask,
openTaskDetailModal,
playVideo,
pauseVideo,
} from '../utils/other'
const { t } = useI18n()
......@@ -42,6 +45,7 @@ const props = defineProps({
const isVideoLoaded = ref(false)
const isVideoError = ref(false)
const videoElement = ref(null)
const isMuted = ref(true)
// 计算属性
const sortedTasks = computed(() => {
......@@ -147,6 +151,9 @@ const resetVideoState = () => {
const onVideoLoaded = () => {
isVideoLoaded.value = true
isVideoError.value = false
if (videoElement.value && isMuted.value) {
videoElement.value.muted = true
}
}
const onVideoError = () => {
......@@ -154,6 +161,24 @@ const onVideoError = () => {
isVideoLoaded.value = false
}
const toggleMute = (event) => {
event.stopPropagation()
isMuted.value = !isMuted.value
if (videoElement.value) {
videoElement.value.muted = isMuted.value
if (!isMuted.value) {
videoElement.value.play().catch(() => {})
}
}
}
const openDetail = (event) => {
event?.stopPropagation()
if (currentTask.value) {
openTaskDetailModal(currentTask.value)
}
}
// 处理取消任务
const handleCancel = async () => {
......@@ -257,8 +282,15 @@ onUnmounted(() => {
<!-- 视频容器 - Apple 圆角和阴影 -->
<div class="w-full max-w-[280px] sm:max-w-[300px] md:max-w-[400px] lg:max-w-[400px] aspect-[9/16] bg-black dark:bg-[#000000] rounded-[16px] overflow-hidden shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.5)] relative cursor-pointer transition-all duration-200 hover:shadow-[0_12px_32px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_12px_32px_rgba(0,0,0,0.6)]"
@click="openTaskDetailModal(currentTask)"
@click="openDetail"
:title="t('viewTaskDetails')">
<button
class="absolute top-3 left-3 z-20 w-10 h-10 flex items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm transition hover:bg-black/55 active:scale-95"
@click.stop="openDetail"
:title="t('viewTaskDetails')"
:aria-label="t('viewTaskDetails')">
<i class="fas fa-info"></i>
</button>
<!-- 已完成:显示视频播放器 -->
<video
v-if="isCompleted && videoUrl"
......@@ -266,12 +298,26 @@ onUnmounted(() => {
:poster="imageUrl"
class="w-full h-full object-contain"
controls
preload="metadata"
@loadeddata="onVideoLoaded"
@error="onVideoError"
preload="auto"
autoplay
muted
playsinline
webkit-playsinline
@mouseenter="playVideo($event)"
@mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"
ref="videoElement">
{{ t('browserNotSupported') }}
</video>
<button
v-if="isCompleted && videoUrl"
class="absolute top-3 right-3 z-20 w-10 h-10 flex items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm transition hover:bg-black/55 active:scale-95"
@click.stop="toggleMute"
:title="isMuted ? t('unmute') : t('mute')">
<i :class="isMuted ? 'fas fa-volume-mute' : 'fas fa-volume-up'"></i>
</button>
<!-- 进行中:Apple 风格加载状态 -->
<div v-else-if="isRunning" class="w-full h-full flex flex-col items-center justify-center relative bg-[#f5f5f7] dark:bg-[#1c1c1e]">
......@@ -370,11 +416,20 @@ onUnmounted(() => {
<!-- Apple 风格操作按钮 -->
<div class="flex justify-center gap-3">
<button
v-if="(isCompleted || isFailed || isCancelled) && currentTask?.task_id"
@click="deleteTask(currentTask.task_id, false)"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all.duration-200.ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-red-500 dark:text-red-400 hover:scale-105 active:scale-95"
:title="t('delete')">
<i class="fas fa-trash"></i>
</button>
<!-- 已完成:下载按钮 -->
<button
v-if="isCompleted && currentTask?.outputs?.output_video"
@click="handleDownloadFile(currentTask.task_id, 'output_video', currentTask.outputs.output_video)"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all duration-200 ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 active:scale-95"
:disabled="downloadLoading"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all duration-200 ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[#1d1d1f] dark:text-[#f5f5f7]"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-105 active:scale-95'"
:title="t('download')">
<i class="fas fa-download"></i>
</button>
......
......@@ -5,7 +5,6 @@ import { showTaskDetailModal,
closeTaskDetailModal,
cancelTask,
reuseTask,
downloadFile,
handleDownloadFile,
deleteTask,
getTaskTypeName,
......@@ -26,6 +25,7 @@ import { showTaskDetailModal,
getTaskFileUrlSync,
getTaskFileFromCache,
getTaskFileUrl,
downloadLoading,
showAlert,
apiRequest,
startPollingTask,
......@@ -78,6 +78,31 @@ const closeWithRoute = () => {
// 如果不是任务详情路由,不做任何路由跳转,保持在当前页面
}
// 滚动到生成区域(仅在 generate 页面)
const scrollToCreationArea = () => {
const mainScrollable = document.querySelector('.main-scrollbar')
if (mainScrollable) {
mainScrollable.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
// 包装 reuseTask 函数,复用任务后回到生成区域
const handleReuseTask = () => {
const task = modalTask.value
if (!task) {
return
}
void reuseTask(task)
if (route.path === '/generate' || route.name === 'Generate') {
setTimeout(() => {
scrollToCreationArea()
}, 300)
}
}
// 键盘事件处理
const handleKeydown = (event) => {
if (event.key === 'Escape' && showTaskDetailModal.value) {
......@@ -303,25 +328,24 @@ watch(() => getAudioMaterials(), (newMaterials) => {
<!-- 右侧信息区域 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px] aspect-[9/16] relative flex flex-col">
<!-- 右上角操作按钮 - Apple 极简风格 -->
<div class="absolute top-0 right-0 flex items-center gap-2 z-10">
<!-- 分享按钮 -->
<button @click="copyShareLink(modalTask.task_id, 'task')"
class="w-10 h-10 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/10 dark:border-white/10 rounded-full shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-110 hover:shadow-[0_4px_12px_rgba(0,0,0,0.15)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.5)] active:scale-100 transition-all duration-200"
:title="t('share')">
<i class="fas fa-share-alt text-xs"></i>
</button>
<!-- 删除按钮 -->
<button @click="deleteTask(modalTask.task_id, true)"
class="w-10 h-10 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/10 dark:border-white/10 rounded-full shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 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 transition-all duration-200"
:title="t('delete')">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
<!-- 居中的内容区域 -->
<div class="flex-1 flex items-center justify-center px-8 py-6">
<div class="w-full">
<div class="flex flex-col items-center gap-3 mb-6">
<div class="flex items-center gap-3">
<button @click="copyShareLink(modalTask.task_id, 'task')"
class="w-12 h-12 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-full shadow-[0_4px_16px_rgba(0,0,0,0.12)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.4)] hover:scale-110 active:scale-100 transition-all duration-200"
:title="t('share')">
<i class="fas fa-share-alt text-base"></i>
</button>
<button @click="deleteTask(modalTask.task_id, true)"
class="w-12 h-12 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 text-red-500 dark:text-red-400 rounded-full shadow-[0_4px_16px_rgba(0,0,0,0.12)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.4)] hover:scale-110 active:scale-100 transition-all duration-200"
:title="t('delete')">
<i class="fas fa-trash text-base"></i>
</button>
</div>
</div>
<!-- 标题 -->
<div class="text-center mb-6">
<h1 class="text-3xl sm:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 tracking-tight">
......@@ -352,11 +376,13 @@ watch(() => getAudioMaterials(), (newMaterials) => {
<div class="space-y-2.5">
<button v-if="modalTask?.outputs?.output_video"
@click="handleDownloadFile(modalTask.task_id, 'output_video', modalTask.outputs.output_video)"
class="w-full rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border-0 px-6 py-3 text-[15px] 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 tracking-tight flex items-center justify-center gap-2">
:disabled="downloadLoading"
class="w-full rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border-0 px-6 py-3 text-[15px] font-semibold text-white transition-all duration-200 ease-out tracking-tight flex items-center justify-center gap-2"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : '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'">
<i class="fas fa-download text-sm"></i>
<span>{{ t('downloadVideo') }}</span>
</button>
<button @click="reuseTask(modalTask)"
<button @click="handleReuseTask"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] 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] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-magic text-sm"></i>
<span>{{ t('reuseTask') }}</span>
......@@ -394,7 +420,9 @@ watch(() => getAudioMaterials(), (newMaterials) => {
</div>
<button v-if="getImageMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_image', modalTask.inputs.input_image)"
class="w-8 h-8 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-lg transition-all duration-200 hover:scale-110 active:scale-100"
:disabled="downloadLoading"
class="w-8 h-8 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-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
......@@ -423,7 +451,9 @@ watch(() => getAudioMaterials(), (newMaterials) => {
</div>
<button v-if="getAudioMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_audio', modalTask.inputs.input_audio)"
class="w-8 h-8 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-lg transition-all duration-200 hover:scale-110 active:scale-100"
:disabled="downloadLoading"
class="w-8 h-8 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-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
......@@ -725,7 +755,7 @@ watch(() => getAudioMaterials(), (newMaterials) => {
<!-- 通用按钮 -->
<button v-if="['SUCCEED', 'FAILED', 'CANCEL','CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
@click="reuseTask(modalTask)"
@click="handleReuseTask"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] 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] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-copy text-sm"></i>
<span>{{ t('reuseTask') }}</span>
......@@ -764,7 +794,9 @@ watch(() => getAudioMaterials(), (newMaterials) => {
</div>
<button v-if="getImageMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_image', modalTask.inputs.input_image)"
class="w-8 h-8 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-lg transition-all duration-200 hover:scale-110 active:scale-100"
:disabled="downloadLoading"
class="w-8 h-8 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-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
......@@ -793,7 +825,9 @@ watch(() => getAudioMaterials(), (newMaterials) => {
</div>
<button v-if="getAudioMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_audio', modalTask.inputs.input_audio)"
class="w-8 h-8 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-lg transition-all duration-200 hover:scale-110 active:scale-100"
:disabled="downloadLoading"
class="w-8 h-8 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-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
......
......@@ -16,7 +16,7 @@ import { showTemplateDetailModal,
} from '../utils/other'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
......@@ -24,14 +24,6 @@ const router = useRouter()
// 添加响应式变量
const showDetails = ref(false)
// 音频播放器相关
const audioElement = ref(null)
const isPlaying = ref(false)
const audioDuration = ref(0)
const currentTime = ref(0)
const isDragging = ref(false)
const currentAudioUrl = ref('')
// 获取图片素材
const getImageMaterials = () => {
if (!selectedTemplate.value?.inputs?.input_image) return []
......@@ -62,19 +54,22 @@ const closeWithRoute = () => {
// 滚动到生成区域(仅在 generate 页面)
const scrollToCreationArea = () => {
const mainScrollable = document.querySelector('.main-scrollbar');
if (mainScrollable) {
mainScrollable.scrollTo({
top: 0,
behavior: 'smooth'
});
const creationArea = document.querySelector('#task-creator')
if (creationArea) {
creationArea.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}
}
// 包装 useTemplate 函数,在 generate 页面时滚动到生成区域
const handleUseTemplate = async () => {
await useTemplate(selectedTemplate.value)
const handleUseTemplate = () => {
const template = selectedTemplate.value
if (!template) {
return
}
void useTemplate(template)
// 如果当前在 generate 页面,滚动到生成区域
if (route.path === '/generate' || route.name === 'Generate') {
// 等待 DOM 更新和展开动画完成
......@@ -98,100 +93,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
// 清理音频资源
const audio = getCurrentAudioElement()
if (audio) {
audio.pause()
}
})
// 格式化音频时间
const formatAudioTime = (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 getCurrentAudioElement = () => {
return Array.isArray(audioElement.value) ? audioElement.value[0] : audioElement.value
}
// 切换播放/暂停
const toggleAudioPlayback = () => {
const audio = getCurrentAudioElement()
if (!audio) return
if (audio.paused) {
audio.play().catch(error => {
console.log('播放失败:', error)
})
} else {
audio.pause()
}
}
// 音频加载完成
const onAudioLoaded = () => {
const audio = getCurrentAudioElement()
if (audio) {
audioDuration.value = audio.duration || 0
}
}
// 时间更新
const onTimeUpdate = () => {
const audio = getCurrentAudioElement()
if (audio && !isDragging.value) {
currentTime.value = audio.currentTime || 0
}
}
// 进度条变化处理
const onProgressChange = (event) => {
const audio = getCurrentAudioElement()
if (audioDuration.value > 0 && audio && event.target) {
const newTime = parseFloat(event.target.value)
currentTime.value = newTime
audio.currentTime = newTime
}
}
// 进度条拖拽结束处理
const onProgressEnd = (event) => {
const audio = getCurrentAudioElement()
if (audio && audioDuration.value > 0 && event.target) {
const newTime = parseFloat(event.target.value)
audio.currentTime = newTime
currentTime.value = newTime
}
isDragging.value = false
}
// 播放结束
const onAudioEnded = () => {
isPlaying.value = false
currentTime.value = 0
}
// 监听音频URL变化
watch(() => getAudioMaterials(), (newMaterials) => {
if (newMaterials && newMaterials.length > 0) {
currentAudioUrl.value = newMaterials[0][1]
nextTick(() => {
const audio = getCurrentAudioElement()
if (audio) {
audio.load()
}
})
} else {
currentAudioUrl.value = ''
isPlaying.value = false
currentTime.value = 0
audioDuration.value = 0
}
}, { immediate: true })
</script>
<template>
<!-- 模板详情弹窗 - Apple 极简风格 -->
......@@ -370,64 +272,7 @@ watch(() => getAudioMaterials(), (newMaterials) => {
<div class="p-6 min-h-[200px]">
<div v-if="getAudioMaterials().length > 0" class="space-y-4">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName">
<!-- 音频播放器卡片 - Apple 风格 -->
<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="toggleAudioPlayback"
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="isPlaying ? '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">
{{ formatAudioTime(currentTime) }} / {{ formatAudioTime(audioDuration) }}
</div>
</div>
<!-- 进度条 -->
<div class="flex items-center gap-2" v-if="audioDuration > 0">
<input
type="range"
:min="0"
:max="audioDuration"
:value="currentTime"
@input="onProgressChange"
@change="onProgressChange"
@mousedown="isDragging = true"
@mouseup="onProgressEnd"
@touchstart="isDragging = true"
@touchend="onProgressEnd"
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="audioElement"
:src="url"
@loadedmetadata="onAudioLoaded"
@timeupdate="onTimeUpdate"
@ended="onAudioEnded"
@play="isPlaying = true"
@pause="isPlaying = false"
class="hidden"
></audio>
<audio :src="url" controls class="w-full rounded-xl"></audio>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
......
......@@ -40,8 +40,11 @@ onMounted(() => {
<button @click="goToHome"
class="flex items-center gap-2.5 px-3 py-2 bg-transparent border-0 rounded-[10px] cursor-pointer transition-all duration-200 hover:bg-black/4 dark:hover:bg-white/6 hover:-translate-y-px active:scale-[0.97]"
:title="t('goToHome')">
<i class="fas fa-film text-xl text-[#1d1d1f] dark:text-[#f5f5f7] transition-colors"></i>
<span class="text-[17px] font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-[-0.02em]">LightX2V</span>
<img src="../../public/logo.svg" alt="LightX2V" class="w-6 h-6 sm:w-6 sm:h-6 md:w-8 md:h-8 lg:w-8 lg:h-8" loading="lazy" />
<span class="inline-flex items-baseline text-[20px] font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-[-0.025em]">
<span>Light</span>
<span class="text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]">X2V</span>
</span>
</button>
</div>
......
<script setup>
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
history: {
type: Array,
default: () => []
},
mode: {
type: String,
default: 'combined'
},
getVoiceName: {
type: Function,
default: () => ''
}
})
const emit = defineEmits(['close', 'apply', 'delete'])
const { t } = useI18n()
const normalizedMode = computed(() => {
const modes = ['combined', 'text', 'instruction', 'voice']
return modes.includes(props.mode) ? props.mode : 'combined'
})
const makeTextEntries = () => {
const seen = new Set()
const list = []
for (const entry of props.history || []) {
const value = (entry?.text || '').trim()
if (!value || seen.has(value)) continue
seen.add(value)
list.push({ id: value, value, label: value })
}
return list
}
const makeInstructionEntries = () => {
const seen = new Set()
const list = []
for (const entry of props.history || []) {
const value = (entry?.instruction || '').trim()
if (!value || seen.has(value)) continue
seen.add(value)
list.push({ id: value, value, label: value })
}
return list
}
const makeVoiceEntries = () => {
const seen = new Set()
const list = []
for (const entry of props.history || []) {
const value = (entry?.voiceType || '').trim()
const label = props.getVoiceName(entry) || entry?.voiceName || value
if (!value || seen.has(value)) continue
seen.add(value)
list.push({ id: value, value, label })
}
return list
}
const filteredHistory = computed(() => {
switch (normalizedMode.value) {
case 'text':
return makeTextEntries()
case 'instruction':
return makeInstructionEntries()
case 'voice':
return makeVoiceEntries()
case 'combined':
default:
return props.history || []
}
})
const totalCount = computed(() => filteredHistory.value.length)
const selectedKey = ref(null)
const panelTitle = computed(() => {
const map = {
combined: t('ttsHistoryTitleCombined'),
text: t('ttsHistoryTitleText'),
instruction: t('ttsHistoryTitleInstruction'),
voice: t('ttsHistoryTitleVoice')
}
return map[normalizedMode.value] || t('ttsHistoryTitle')
})
const isFemaleVoice = (entry) => {
const value = (entry?.voiceType || entry?.voiceName || entry?.label || '').toLowerCase()
return value.includes('female') || value.includes('')
}
const getEntryKey = (entry) => {
if (normalizedMode.value === 'combined') {
return entry?.id ?? null
}
return entry?.value ?? null
}
const ensureSelection = () => {
if (!props.visible) {
selectedKey.value = null
return
}
const list = filteredHistory.value
if (!list.length) {
selectedKey.value = null
return
}
const currentKey = selectedKey.value
if (list.some((entry) => getEntryKey(entry) === currentKey)) {
return
}
selectedKey.value = getEntryKey(list[0])
}
watch(() => props.visible, ensureSelection)
watch(filteredHistory, ensureSelection)
watch(normalizedMode, ensureSelection)
const isCombinedMode = computed(() => normalizedMode.value === 'combined')
const isApplyDisabled = computed(() => !props.visible || !selectedKey.value)
const handleOverlayClick = () => {
emit('close')
}
const handlePanelClick = (event) => {
event.stopPropagation()
}
const handleEntryClick = (entry) => {
selectedKey.value = getEntryKey(entry)
}
const handleApplyClick = () => {
if (isApplyDisabled.value) return
if (normalizedMode.value === 'combined') {
const entry = filteredHistory.value.find((item) => getEntryKey(item) === selectedKey.value)
if (entry) {
emit('apply', entry)
}
} else {
emit('apply', selectedKey.value)
}
}
const handleDeleteClick = (event, entry) => {
if (!isCombinedMode.value) return
event.stopPropagation()
emit('delete', entry)
}
const getEntryVoiceLabel = (entry) => {
return props.getVoiceName(entry) || entry.voiceType || t('ttsHistoryVoiceEmpty')
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
@click="handleOverlayClick"
>
<div
class="bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] flex flex-col"
@click.stop="handlePanelClick"
>
<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]">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight flex items-center gap-2">
<i class="fas fa-history text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ panelTitle }}</span>
</h3>
<span
v-if="totalCount > 0"
class="px-2 py-0.5 rounded-full text-xs font-medium bg-black/5 dark:bg-white/10 text-[#86868b] dark:text-[#98989d]"
>
{{ totalCount }}
</span>
</div>
<div class="flex items-center gap-2">
<button
@click.stop="handleApplyClick"
:disabled="isApplyDisabled"
class="w-9 h-9 flex items-center justify-center rounded-full transition-all duration-200 bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white disabled:opacity-50 disabled:cursor-not-allowed hover:scale-105 active:scale-100"
:title="t('ttsHistoryApplySelected')"
>
<i class="fas fa-check text-sm"></i>
</button>
<button
@click.stop="emit('close')"
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-sm"></i>
</button>
</div>
</div>
<div class="flex-1 min-h-[50vh] overflow-y-auto p-6 main-scrollbar">
<div v-if="!filteredHistory.length" 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-book 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('ttsHistoryEmpty') }}</p>
<p class="text-[#86868b] dark:text-[#98989d] text-sm tracking-tight">{{ t('ttsHistoryEmptyHint') }}</p>
</div>
<template v-else>
<div v-if="normalizedMode === 'voice'" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="entry in filteredHistory"
:key="getEntryKey(entry)"
@click="handleEntryClick(entry)"
class="p-4 border border-black/8 dark:border-white/8 rounded-2xl bg-white/80 dark:bg-[#2c2c2e]/80 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 cursor-pointer flex items-center gap-3"
:class="{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] shadow-[0_0_0_2px_rgba(var(--brand-primary-rgb),0.2)] dark:shadow-[0_0_0_2px_rgba(var(--brand-primary-light-rgb),0.25)] ring-2 ring-[color:var(--brand-primary)]/20 dark:ring-[color:var(--brand-primary-light)]/25': getEntryKey(entry) === selectedKey
}"
>
<div class="relative flex-shrink-0">
<img
v-if="isFemaleVoice(entry)"
src="../../public/female.svg"
alt="Female Avatar"
class="w-12 h-12 rounded-full object-cover bg-white transition-all duration-200"
/>
<!-- Male Avatar -->
<img
v-else
src="../../public/male.svg"
alt="Male Avatar"
class="w-12 h-12 rounded-full object-cover bg-white transition-all duration-200"
/>
</div>
<div class="flex-1 min-w-0 space-y-1">
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate">
{{ entry.label }}
</div>
<div class="text-xs text-[#86868b] dark:text-[#98989d] tracking-tight truncate">
{{ entry.voiceType }}
</div>
</div>
</div>
</div>
<div v-else class="space-y-3">
<div
v-for="entry in filteredHistory"
:key="getEntryKey(entry)"
@click="handleEntryClick(entry)"
class="p-4 border border-black/8 dark:border-white/8 rounded-2xl bg-white/80 dark:bg-[#2c2c2e]/80 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 cursor-pointer"
:class="{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] shadow-[0_0_0_2px_rgba(var(--brand-primary-rgb),0.2)] dark:shadow-[0_0_0_2px_rgba(var(--brand-primary-light-rgb),0.25)]': getEntryKey(entry) === selectedKey
}"
>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0 space-y-2">
<template v-if="normalizedMode === 'combined'">
<div class="text-sm font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words whitespace-pre-line">
<span class="text-xs uppercase text-[#86868b] dark:text-[#98989d] mr-2">{{ t('ttsHistoryTextLabel') }}:</span>
<span>{{ entry.text || t('ttsHistoryTextEmpty') }}</span>
</div>
<div class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words whitespace-pre-line">
<span class="text-xs uppercase text-[#86868b] dark:text-[#98989d] mr-2">{{ t('ttsHistoryInstructionLabel') }}:</span>
<span>{{ entry.instruction || t('ttsHistoryInstructionEmpty') }}</span>
</div>
<div class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words">
<span class="text-xs uppercase text-[#86868b] dark:text-[#98989d] mr-2">{{ t('ttsHistoryVoiceLabel') }}:</span>
<span>{{ getEntryVoiceLabel(entry) }}</span>
</div>
</template>
<template v-else>
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words whitespace-pre-line">
<span>{{ entry.label }}</span>
</div>
</template>
</div>
<button
v-if="isCombinedMode"
@click="handleDeleteClick($event, entry)"
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 hover:text-red-600 dark:hover:text-red-300 hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200"
:title="t('ttsHistoryDeleteEntry')"
>
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
......@@ -4,10 +4,19 @@
<div class="relative w-full h-full max-w-6xl max-h-[100vh] 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 flex flex-col">
<!-- 模态框头部 - Apple 风格 -->
<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] flex-shrink-0">
<h3 class="text-xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight">
<i class="fas fa-volume-up text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ t('voiceSynthesis') }}</span>
</h3>
<div class="flex items-center gap-3">
<h3 class="text-xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight">
<i class="fas fa-volume-up text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ t('voiceSynthesis') }}</span>
</h3>
<button
@click="openHistoryPanel"
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"
:title="t('ttsHistoryTitle')"
>
<i class="fas fa-history text-sm"></i>
</button>
</div>
<div class="flex items-center gap-2">
<!-- 应用按钮 - Apple 风格 -->
<button
......@@ -180,10 +189,20 @@
<div class="max-w-5xl mx-auto space-y-6">
<!-- 文本输入区域 - Apple 风格 -->
<div>
<label class="block text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 tracking-tight">
<i class="fas fa-keyboard mr-2 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
{{ t('enterTextToConvert') }}
</label>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i class="fas fa-keyboard text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('enterTextToConvert') }}</span>
<button
@click="openTextHistoryPanel"
class="w-8 h-8 flex items-center justify-center rounded-full 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] transition-all duration-200"
:title="t('ttsHistoryTabText')"
>
<i class="fas fa-history text-xs"></i>
</button>
</div>
</div>
<textarea
v-model="inputText"
:placeholder="t('ttsPlaceholder')"
......@@ -194,11 +213,21 @@
<!-- 语音指令区域 - Apple 风格 -->
<div>
<label class="block text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 tracking-tight">
<i class="fas fa-magic mr-2 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
{{ t('voiceInstruction') }}
<span class="text-xs text-[#86868b] dark:text-[#98989d] ml-2">{{ t('voiceInstructionHint') }}</span>
</label>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i class="fas fa-magic text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('voiceInstruction') }}</span>
<span class="text-xs text-[#86868b] dark:text-[#98989d]">{{ t('voiceInstructionHint') }}</span>
<button
@click="openInstructionHistoryPanel"
class="w-8 h-8 flex items-center justify-center rounded-full 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] transition-all duration-200"
:title="t('ttsHistoryTabInstruction')"
>
<i class="fas fa-history text-xs"></i>
</button>
</div>
</div>
<textarea
v-model="contextText"
:placeholder="t('voiceInstructionPlaceholder')"
......@@ -210,29 +239,39 @@
<!-- 音色选择区域 - Apple 风格 -->
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">
<i class="fas fa-microphone-alt mr-2 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
{{ t('selectVoice') }}
</label>
<div class="flex items-center gap-3">
<!-- 搜索框 - Apple 风格 -->
<div class="relative w-52">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-[#86868b] dark:text-[#98989d] text-xs pointer-events-none z-10"></i>
<input
v-model="searchQuery"
:placeholder="t('searchVoice')"
class="w-full bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-lg py-2 pl-9 pr-3 text-sm 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 transition-all duration-200"
type="text"
/>
</div>
<div class="flex items-center gap-2">
<i class="fas fa-microphone-alt text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('selectVoice') }}</span>
<button
@click="openVoiceHistoryPanel"
class="w-8 h-8 flex items-center justify-center rounded-full 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] transition-all duration-200"
:title="t('ttsHistoryTabVoice')"
>
<i class="fas fa-history text-xs"></i>
</button>
</div>
<!-- 筛选按钮 - Apple 风格 -->
<button @click="toggleFilterPanel"
class="flex items-center gap-2 px-4 py-2 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-lg transition-all duration-200 text-sm font-medium tracking-tight">
<i class="fas fa-filter text-xs"></i>
<span>{{ t('filter') }}</span>
</button>
<div class="flex items-center gap-3">
<!-- 搜索框 - Apple 风格 -->
<div class="relative w-52">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-[#86868b] dark:text-[#98989d] text-xs pointer-events-none z-10"></i>
<input
v-model="searchQuery"
:placeholder="t('searchVoice')"
class="w-full bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-lg py-2 pl-9 pr-3 text-sm 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 transition-all duration-200"
type="text"
/>
</div>
<!-- 筛选按钮 - Apple 风格 -->
<button @click="toggleFilterPanel"
class="flex items-center gap-2 px-4 py-2 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-lg transition-all duration-200 text-sm font-medium tracking-tight">
<i class="fas fa-filter text-xs"></i>
<span>{{ t('filter') }}</span>
</button>
</div>
</div>
</div>
<!-- 音色列表容器 - Apple 风格 -->
<div class="bg-white/50 dark:bg-[#2c2c2e]/50 backdrop-blur-[10px] border border-black/6 dark:border-white/6 rounded-2xl p-5 max-h-[500px] overflow-y-auto main-scrollbar pr-3" ref="voiceListContainer">
......@@ -309,12 +348,47 @@
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<VoiceTtsHistoryPanel
:visible="showHistoryPanel"
:history="ttsHistory"
mode="combined"
:get-voice-name="getHistoryVoiceName"
@close="closeHistoryPanel"
@apply="applyCombinedHistoryEntry"
@delete="handleDeleteHistoryEntry"
/>
<VoiceTtsHistoryPanel
:visible="showTextHistoryPanel"
:history="ttsHistory"
mode="text"
@close="closeTextHistoryPanel"
@apply="applyTextHistoryEntry"
/>
<VoiceTtsHistoryPanel
:visible="showInstructionHistoryPanel"
:history="ttsHistory"
mode="instruction"
@close="closeInstructionHistoryPanel"
@apply="applyInstructionHistoryEntry"
/>
<VoiceTtsHistoryPanel
:visible="showVoiceHistoryPanel"
:history="ttsHistory"
mode="voice"
:get-voice-name="getHistoryVoiceName"
@close="closeVoiceHistoryPanel"
@apply="applyVoiceHistoryEntry"
/>
<!-- 筛选面板遮罩 - Apple 风格 -->
<div v-if="showFilterPanel" class="fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click="closeFilterPanel">
<div class="bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] flex flex-col" @click.stop>
......@@ -426,11 +500,14 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from './DropdownMenu.vue'
import VoiceTtsHistoryPanel from './VoiceTtsHistoryPanel.vue'
import { ttsHistory, loadTtsHistory, addTtsHistoryEntry, removeTtsHistoryEntry } from '../utils/other'
export default {
name: 'VoiceTTS',
components: {
DropdownMenu
DropdownMenu,
VoiceTtsHistoryPanel
},
emits: ['tts-complete', 'close-modal'],
setup(props, { emit }) {
......@@ -459,6 +536,10 @@ export default {
const voiceListContainer = ref(null)
const showControls = ref(false)
const showFilterPanel = ref(false)
const showHistoryPanel = ref(false)
const showTextHistoryPanel = ref(false)
const showInstructionHistoryPanel = ref(false)
const showVoiceHistoryPanel = ref(false)
// Category filtering - 存储原始中文值
const selectedCategory = ref('全部场景')
......@@ -508,9 +589,52 @@ export default {
return map[gender] || gender
}
const openHistoryPanel = () => {
loadTtsHistory()
showHistoryPanel.value = true
}
const closeHistoryPanel = () => {
showHistoryPanel.value = false
}
const openTextHistoryPanel = () => {
loadTtsHistory()
showTextHistoryPanel.value = true
}
const openInstructionHistoryPanel = () => {
loadTtsHistory()
showInstructionHistoryPanel.value = true
}
const openVoiceHistoryPanel = () => {
loadTtsHistory()
showVoiceHistoryPanel.value = true
}
const closeTextHistoryPanel = () => {
showTextHistoryPanel.value = false
}
const closeInstructionHistoryPanel = () => {
showInstructionHistoryPanel.value = false
}
const closeVoiceHistoryPanel = () => {
showVoiceHistoryPanel.value = false
}
const handleDeleteHistoryEntry = (entry) => {
if (!entry?.id) return
removeTtsHistoryEntry(entry.id)
loadTtsHistory()
}
// Load voices data
onMounted(async () => {
loadTtsHistory()
try {
const response = await fetch('/api/v1/voices/list')
const data = await response.json()
......@@ -784,6 +908,14 @@ export default {
audioUrl.value = URL.createObjectURL(blob)
// 标记需要自动播放
shouldAutoPlay.value = true
addTtsHistoryEntry(
inputText.value,
contextText.value,
{
voiceType: selectedVoice.value,
voiceName: selectedVoiceData.value?.name || ''
}
)
} else {
throw new Error('TTS generation failed')
}
......@@ -795,6 +927,65 @@ export default {
}
}
const applyCombinedHistoryEntry = async (entry) => {
if (!entry) return
inputText.value = entry.text || ''
contextText.value = entry.instruction || ''
if (entry.voiceType) {
const voice = voices.value.find(v => v.voice_type === entry.voiceType)
if (voice) {
await onVoiceSelect(voice)
return
}
selectedVoice.value = entry.voiceType
selectedVoiceResourceId.value = ''
}
nextTick(() => {
generateTTS()
})
showHistoryPanel.value = false
}
const applyTextHistoryEntry = (value) => {
if (!value) return
inputText.value = value
showTextHistoryPanel.value = false
}
const applyInstructionHistoryEntry = (value) => {
if (!value) return
contextText.value = value
showInstructionHistoryPanel.value = false
}
const applyVoiceHistoryEntry = async (voiceType) => {
if (!voiceType) return
const voice = voices.value.find(v => v.voice_type === voiceType)
if (voice) {
await onVoiceSelect(voice)
} else {
selectedVoice.value = voiceType
selectedVoiceResourceId.value = ''
nextTick(() => {
generateTTS()
})
}
showVoiceHistoryPanel.value = false
}
const getHistoryVoiceName = (entry) => {
if (!entry) return ''
if (entry.voiceName) return entry.voiceName
if (entry.voiceType) {
const voice = voices.value.find(v => v.voice_type === entry.voiceType)
return voice?.name || ''
}
return ''
}
// 格式化音频时间
const formatAudioTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00'
......@@ -1051,7 +1242,26 @@ export default {
translateCategory,
translateVersion,
translateLanguage,
translateGender
translateGender,
ttsHistory,
showHistoryPanel,
openHistoryPanel,
closeHistoryPanel,
applyCombinedHistoryEntry,
applyTextHistoryEntry,
applyInstructionHistoryEntry,
applyVoiceHistoryEntry,
getHistoryVoiceName,
handleDeleteHistoryEntry,
showTextHistoryPanel,
showInstructionHistoryPanel,
showVoiceHistoryPanel,
openTextHistoryPanel,
openInstructionHistoryPanel,
openVoiceHistoryPanel,
closeTextHistoryPanel,
closeInstructionHistoryPanel,
closeVoiceHistoryPanel
}
}
}
......
......@@ -132,10 +132,22 @@
"deleteTask": "Delete",
"cancelTask": "Cancel",
"download": "Download",
"downloadPreparing": "Preparing download...",
"downloadFetching": "Fetching file...",
"downloadSaving": "Saving file...",
"mobileSaveToAlbumTip": "Long press the video in the new tab to save it to your gallery.",
"mobileSavePreviewTitle": "Preview & Save",
"mobileSaveInstruction": "Tap the full-screen button or long press the video to save it to your photo library.",
"mute": "Mute",
"unmute": "Unmute",
"unsupportedAudioOrVideo": "Please select an audio or video file.",
"unsupportedVideoFormat": "Only MP4/M4V/MPEG video files are supported for audio extraction.",
"downloadInProgressNotice": "A download is already in progress. Please wait.",
"downloadCancelledAlert": "Download cancelled",
"createVideo": "Create Video",
"selectTemplate": "Select Template",
"uploadImage": "Upload Image",
"uploadAudio": "Upload Audio",
"uploadAudio": "Upload Audio or Video",
"recordAudio": "Record Audio",
"recording": "Recording...",
"takePhoto": "Take Photo",
......@@ -175,7 +187,7 @@
"textToVideo": "Text to Video",
"imageToVideo": "Image to Video",
"speechToVideo": "Speech to Video",
"prompt": "Prompt",
"prompt": "Prompt (Optional)",
"negativePrompt": "Negative Prompt",
"promptTemplates": "Prompt Templates",
"promptHistory": "Prompt History",
......@@ -192,11 +204,14 @@
"s2vHint3": "Let your role become alive.",
"s2vHint4": "Create your own digital person.",
"uploadImageFile": "Upload Image File",
"uploadAudioFile": "Upload Audio File",
"uploadAudioFile": "Upload Audio or Video File",
"dragDropHere": "Drag and drop files here or click to upload",
"supportedImageFormats": "Supported jpg, png, webp image formats (< 10MB)",
"supportedAudioFormats": "Supported mp3, m4a, wav audio formats (< 120s)",
"supportedAudioFormatsShort": "Supported mp3, m4a, wav formats",
"supportedAudioFormats": "Supports audio or video formats (< 120s).",
"supportedAudioFormatsShort": "Supports audio or video formats (< 120s).",
"prefillLoadingDefault": "Preparing materials...",
"prefillLoadingTemplate": "Loading template assets...",
"prefillLoadingTask": "Loading task materials...",
"clearCharacterImageTip": "Upload a clear character image",
"maxFileSize": "Max file size",
"taskDetail": "Task Details",
......@@ -244,7 +259,7 @@
"taskMaterialReuseSuccessAlert": "Task material reuse successfully",
"loadTaskDataFailedAlert": "Load task data failed",
"fileUnavailableAlert": "File unavailable",
"downloadFailedAlert": "Download failed",
"downloadFailedAlert": "Download failed. Please try again.",
"taskSubmitSuccessAlert": "Task submit successfully",
"taskSubmitFailedAlert": "Task submit failed",
"submitTaskFailedAlert": "Submit task failed",
......@@ -309,7 +324,7 @@
"oneClickReplication": "One-click replication",
"customizableContent": "Customizable content",
"poweredByLightX2V": "Speed-generated video - LightX2V",
"latestAIModel": "Latest AI model, rapid video generation",
"latestAIModel": "Latest AI digital human model, rapid video generation",
"customizableCharacter": "Freely customizable characters and audio",
"userGeneratedVideo": " generated video",
"noImage": "No Images",
......@@ -360,9 +375,81 @@
"multilingual": "Multilingual",
"multiEmotion": "Multi-emotion",
"videoDubbing": "Video Dubbing",
"ttsHistoryTitle": "History",
"ttsHistoryHint": "We automatically keep the last 20 voice texts and instructions you used.",
"ttsHistoryEmpty": "No saved entries yet",
"ttsHistoryEmptyHint": "Generate voice once to create your first history entry.",
"ttsHistoryTextLabel": "Voice Text",
"ttsHistoryInstructionLabel": "Voice Instruction",
"ttsHistoryTextEmpty": "Empty text",
"ttsHistoryInstructionEmpty": "Empty instruction",
"ttsHistoryVoiceLabel": "Voice",
"ttsHistoryVoiceEmpty": "Not set",
"ttsHistoryApply": "Use This",
"ttsHistoryApplySelected": "Apply",
"ttsHistoryDeleteEntry": "Remove",
"ttsHistoryTabCombined": "All",
"ttsHistoryTabText": "Text",
"ttsHistoryTabInstruction": "Instruction",
"ttsHistoryTabVoice": "Voice",
"ttsHistoryTitleCombined": "All History",
"ttsHistoryTitleText": "Text History",
"ttsHistoryTitleInstruction": "Instruction History",
"ttsHistoryTitleVoice": "Voice History",
"ttsHistoryClear": "Clear History",
"allVersions": "All Versions",
"allLanguages": "All Languages",
"allGenders": "All Genders",
"female": "Female",
"male": "Male",
"taskCountdown": "Task countdown",
"footer": {
"tagline": "AI digital human video generation powered by the Light AI toolchain",
"links": {
"home": "Light AI Homepage",
"github": "GitHub",
"xiaohongshu": "Xiaohongshu"
},
"alt": {
"github": "GitHub logo",
"xiaohongshu": "Xiaohongshu logo"
},
"copyright": "© {year} Light AI. All rights reserved."
},
"tts": {
"title": "AI Voice Synthesis",
"subtitle": "Synthesize your voice with AI",
"inputText": "Enter text to synthesize",
"voiceSelection": "Select voice",
"voiceSettings": "Voice settings",
"speechRate": "Speech rate",
"volume": "Volume",
"pitch": "Pitch",
"emotionIntensity": "Emotion intensity",
"emotionType": "Emotion type",
"neutral": "Neutral",
"scene": "Scene",
"version": "Version",
"language": "Language",
"gender": "Gender",
"reset": "Reset",
"done": "Done",
"ttsGenerationFailed": "TTS generation failed, please retry",
"applyAudioFailed": "Apply audio failed, please retry",
"allScenes": "All Scenes",
"generalScene": "General",
"customerServiceScene": "Customer Service",
"educationScene": "Education",
"funAccent": "Fun Accent",
"rolePlaying": "Role Playing",
"audiobook": "Audiobook",
"multilingual": "Multilingual",
"multiEmotion": "Multi-emotion",
"videoDubbing": "Video Dubbing",
"allVersions": "All Versions",
"allLanguages": "All Languages",
"allGenders": "All Genders",
"female": "Female",
"male": "Male"
}
}
......@@ -55,9 +55,9 @@
"audio": "音频",
"optional": "(选填)",
"pageTitle": "LightX2V服务",
"pleaseEnterThePromptForVideoGeneration": "请输入视频生成提示词",
"pleaseEnterThePromptForVideoGeneration": "视频生成提示词",
"describeTheContentStyleSceneOfTheVideo": "描述视频内容、风格、场景等...",
"describeTheDigitalHumanImageBackgroundStyleActionRequirements": "描述数字人表情、语气、动作等...",
"describeTheDigitalHumanImageBackgroundStyleActionRequirements": "描述数字人表情、动作等,例如:角色应根据音频做出夸张的动作",
"describeTheContentActionRequirementsBasedOnTheImage": "描述基于图片的视频内容、动作要求等...",
"loginSubtitle": "一个强大的视频生成平台",
"loginWithGitHub": "使用GitHub登录",
......@@ -159,6 +159,18 @@
"retryTask": "重试",
"downloadTask": "下载视频",
"downloadVideo": "下载视频",
"downloadPreparing": "正在准备下载…",
"downloadFetching": "正在获取文件…",
"downloadSaving": "正在保存文件...",
"mobileSaveToAlbumTip": "新窗口打开后长按视频即可保存到相册。",
"mobileSavePreviewTitle": "预览并保存",
"mobileSaveInstruction": "可点击全屏或长按视频,将其保存到手机相册。",
"mute": "静音",
"unmute": "取消静音",
"unsupportedAudioOrVideo": "请选择音频或视频文件。",
"unsupportedVideoFormat": "仅支持 MP4/M4V/MPEG 视频文件转换音频。",
"downloadInProgressNotice": "已有下载任务正在进行,请稍候。",
"downloadCancelledAlert": "已取消下载",
"deleteTask": "删除",
"createVideo": "创建视频",
"selectTemplate": "选择模板",
......@@ -182,16 +194,19 @@
"textToVideo": "文生视频",
"imageToVideo": "图生视频",
"speechToVideo": "数字人",
"prompt": "提示词",
"prompt": "提示词(选填)",
"negativePrompt": "负面提示词",
"promptTemplates": "提示词模板",
"promptHistory": "提示词历史",
"uploadImageFile": "上传图片文件",
"uploadAudioFile": "上传音频文件",
"uploadImageFile": "上传图片",
"uploadAudioFile": "上传音频",
"dragDropHere": "拖拽文件到此处或点击上传",
"supportedImageFormats": "支持jpg、png、webp图片格式(10MB以内)",
"supportedAudioFormats": "支持mp3、m4a、wav音频格式(120s以内)",
"supportedAudioFormatsShort": "支持mp3、m4a、wav等格式",
"supportedImageFormats": "支持10MB以内的图片",
"supportedAudioFormats": "支持120s以内的音频/视频",
"supportedAudioFormatsShort": "支持120s以内的音频/视频",
"prefillLoadingDefault": "正在准备素材...",
"prefillLoadingTemplate": "正在加载模板素材...",
"prefillLoadingTask": "正在加载任务素材...",
"maxFileSize": "最大文件大小",
"taskId": "任务ID",
"taskStatus": "任务状态",
......@@ -246,7 +261,7 @@
"taskMaterialReuseSuccessAlert": "任务素材复用成功",
"loadTaskDataFailedAlert": "加载任务数据失败",
"fileUnavailableAlert": "文件不可用",
"downloadFailedAlert": "下载失败",
"downloadFailedAlert": "下载失败,请重试。",
"taskSubmitSuccessAlert": "任务提交成功",
"taskSubmitFailedAlert": "任务提交失败",
"submitTaskFailedAlert": "任务提交失败",
......@@ -323,7 +338,7 @@
"oneClickReplication": "一键复刻同款",
"customizableContent": "可自定义内容",
"poweredByLightX2V": "速生视频 - LightX2V",
"latestAIModel": "最新AI模型,飞速生成视频",
"latestAIModel": "最新AI数字人模型,飞速生成视频",
"customizableCharacter": "可自由更换角色与音频",
"userGeneratedVideo": "生成的视频",
"noImage": "暂无图片",
......@@ -374,9 +389,89 @@
"multilingual": "多语种",
"multiEmotion": "多情感",
"videoDubbing": "视频配音",
"ttsHistoryTitle": "历史记录",
"ttsHistoryHint": "系统会自动保留最近 20 条使用过的文本与语音指令。",
"ttsHistoryEmpty": "暂未保存任何记录",
"ttsHistoryEmptyHint": "生成一次语音即可创建首条历史记录。",
"ttsHistoryTextLabel": "语音文本",
"ttsHistoryInstructionLabel": "语音指令",
"ttsHistoryTextEmpty": "(文本为空)",
"ttsHistoryInstructionEmpty": "(指令为空)",
"ttsHistoryVoiceLabel": "使用音色",
"ttsHistoryVoiceEmpty": "未设置音色",
"ttsHistoryApply": "使用该记录",
"ttsHistoryApplySelected": "应用",
"ttsHistoryDeleteEntry": "删除此记录",
"ttsHistoryTabCombined": "全部",
"ttsHistoryTabText": "输入文本",
"ttsHistoryTabInstruction": "语音指令",
"ttsHistoryTabVoice": "音色",
"ttsHistoryTitleCombined": "全部历史记录",
"ttsHistoryTitleText": "输入文本历史",
"ttsHistoryTitleInstruction": "语音指令历史",
"ttsHistoryTitleVoice": "音色历史",
"ttsHistoryClear": "清空历史",
"allVersions": "全部版本",
"allLanguages": "全部语言",
"allGenders": "全部性别",
"female": "女性",
"male": "男性"
"male": "男性",
"taskCountdown": "任务倒计时",
"footer": {
"tagline": "由 Light AI 工具链驱动的 AI 数字人视频生成平台",
"links": {
"home": "Light AI 官网",
"github": "GitHub",
"xiaohongshu": "小红书"
},
"alt": {
"github": "GitHub 标志",
"xiaohongshu": "小红书标志"
},
"copyright": "© {year} Light AI 版权所有"
},
"tts": {
"title": "AI 语音合成",
"subtitle": "让您的文字变成动听的声音",
"inputText": "请输入要合成的文字",
"voice": "选择音色",
"speed": "语速",
"volume": "音量",
"pitch": "音调",
"emotion": "情感",
"generate": "生成语音",
"cancel": "取消",
"generating": "生成中...",
"generated": "已生成",
"error": "生成失败",
"errorMessage": "请检查输入文本或选择音色",
"voiceOptions": "音色选项",
"fast": "快速",
"normal": "正常",
"slow": "慢速",
"angry": "愤怒",
"happy": "开心",
"sad": "悲伤",
"neutral": "中性",
"excited": "兴奋",
"calm": "平静",
"gentle": "温柔",
"serious": "严肃",
"friendly": "友好",
"professional": "专业",
"child": "儿童",
"robot": "机器人",
"male": "男声",
"female": "女声",
"other": "其他",
"search": "搜索音色",
"noResults": "没有找到相关音色",
"placeholder": "请输入要合成的文字",
"placeholderVoice": "请选择音色",
"placeholderSpeed": "请选择语速",
"placeholderVolume": "请选择音量",
"placeholderPitch": "请选择音调",
"placeholderEmotion": "请选择情感",
"placeholderGenerate": "请点击生成按钮"
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment