diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\345\233\275\345\256\266\345\234\260\345\214\272\346\225\260\346\215\256.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\345\233\275\345\256\266\345\234\260\345\214\272\346\225\260\346\215\256.js" new file mode 100644 index 0000000000000000000000000000000000000000..a009475eda993f6fc705f3c5733214e0ca36713b --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\345\233\275\345\256\266\345\234\260\345\214\272\346\225\260\346\215\256.js" @@ -0,0 +1,321 @@ +// 前端页面/auth/国家地区数据.js +// ========================================== +// 🌍 国家地区数据 +// ========================================== +// 作用:提供注册表单中的国家和地区选项 +// 关联文件:注册表单组件.js、个人设置表单组件.js +// ========================================== +// 🏗️ 优化:扩充国家数量,按地区分组 +// ========================================== + +// ========================================== +// 🌏 亚洲 (Asia) +// ========================================== +const ASIA = { + "中国": [ + "北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江", + "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", + "湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州", + "云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", + "台湾", "香港", "澳门", "其他地区" + ], + "日本": [ + "東京", "大阪", "京都", "北海道", "横浜", "名古屋", "神戸", + "福岡", "沖縄", "広島", "仙台", "札幌", "その他の地域" + ], + "대한민국": [ + "서울", "부산", "인천", "대구", "대전", "광주", "울산", + "세종", "경기도", "강원도", "제주", "기타 지역" + ], + "भारत": [ + "नई दिल्ली", "मुंबई", "बेंगलुरु", "हैदराबाद", "चेन्नई", + "कोलकाता", "पुणे", "अहमदाबाद", "जयपुर", "अन्य क्षेत्र" + ], + "Indonesia": [ + "Jakarta", "Bali", "Surabaya", "Bandung", "Medan", + "Yogyakarta", "Semarang", "Makassar", "Daerah lainnya" + ], + "ประเทศไทย": [ + "กรุงเทพมหานคร", "เชียงใหม่", "ภูเก็ต", "พัทยา", "เกาะสมุย", + "อยุธยา", "ขอนแก่น", "หาดใหญ่", "พื้นที่อื่นๆ" + ], + "Việt Nam": [ + "Hà Nội", "TP. Hồ Chí Minh", "Đà Nẵng", "Hải Phòng", + "Nha Trang", "Đà Lạt", "Huế", "Cần Thơ", "Các khu vực khác" + ], + "Malaysia": [ + "Kuala Lumpur", "Pulau Pinang", "Johor Bahru", "Melaka", + "Kota Kinabalu", "Kuching", "Ipoh", "Kawasan lain" + ], + "Singapore": ["Singapore"], + "Pilipinas": [ + "Maynila", "Cebu", "Davao", "Boracay", "Quezon City", + "Makati", "Ibang mga rehiyon" + ], + "پاکستان": [ + "کراچی", "لاہور", "اسلام آباد", "راولپنڈی", "فیصل آباد", "دیگر علاقے" + ], + "বাংলাদেশ": [ + "ঢাকা", "চট্টগ্রাম", "খুলনা", "রাজশাহী", "সিলেট", "অন্যান্য অঞ্চল" + ], + "ישראל": [ + "תל אביב", "ירושלים", "חיפה", "באר שבע", "אזורים אחרים" + ], + "المملكة العربية السعودية": [ + "الرياض", "جدة", "مكة المكرمة", "المدينة المنورة", "الدمام", "مناطق أخرى" + ], + "الإمارات العربية المتحدة": [ + "دبي", "أبوظبي", "الشارقة", "العين", "مناطق أخرى" + ], + "ایران": [ + "تهران", "اصفهان", "شیراز", "مشهد", "تبریز", "سایر مناطق" + ], + "Türkiye": [ + "İstanbul", "Ankara", "İzmir", "Antalya", "Bursa", "Diğer bölgeler" + ], + "မြန်မာ": [ + "ရန်ကုန်", "မန္တလေး", "နေပြည်တော်", "ပုဂံ", "အခြားဒေသများ" + ], + "ປະເທດລາວ": [ + "ວຽງຈັນ", "ຫຼວງພະບາງ", "ປາກເຊ", "ເຂດອື່ນໆ" + ], + "កម្ពុជា": [ + "ភ្នំពេញ", "សៀមរាប", "ព្រះសីហនុ", "បាត់ដំបង", "តំបន់ផ្សេងទៀត" + ], + "नेपाल": [ + "काठमाडौं", "पोखरा", "ललितपुर", "अन्य क्षेत्र" + ], + "ශ්‍රී ලංකාව": [ + "කොළඹ", "කැන්ඩි", "ගාල්ල", "වෙනත් ප්‍රදේශ" + ], + "Монгол улс": [ + "Улаанбаатар", "Дархан", "Эрдэнэт", "Бусад бүс нутаг" + ], + "Қазақстан": [ + "Алматы", "Нұр-Сұлтан", "Шымкент", "Қарағанды", "Басқа аймақтар" + ] +}; + +// ========================================== +// 🌍 欧洲 (Europe) +// ========================================== +const EUROPE = { + "United Kingdom": [ + "London", "Manchester", "Birmingham", "Edinburgh", "Glasgow", + "Liverpool", "Leeds", "Bristol", "Cardiff", "Belfast", "Other regions" + ], + "France": [ + "Paris", "Lyon", "Marseille", "Nice", "Toulouse", + "Bordeaux", "Strasbourg", "Lille", "Nantes", "Autres régions" + ], + "Deutschland": [ + "Berlin", "München", "Hamburg", "Frankfurt", "Köln", + "Stuttgart", "Düsseldorf", "Dresden", "Leipzig", "Andere Regionen" + ], + "Italia": [ + "Roma", "Milano", "Napoli", "Torino", "Firenze", + "Venezia", "Bologna", "Altre regioni" + ], + "España": [ + "Madrid", "Barcelona", "Valencia", "Sevilla", "Málaga", + "Bilbao", "Granada", "Otras regiones" + ], + "Россия": [ + "Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", + "Казань", "Нижний Новгород", "Владивосток", "Сочи", "Другие регионы" + ], + "Nederland": [ + "Amsterdam", "Rotterdam", "Den Haag", "Utrecht", "Andere regio's" + ], + "Polska": [ + "Warszawa", "Kraków", "Gdańsk", "Wrocław", "Poznań", "Inne regiony" + ], + "Україна": [ + "Київ", "Харків", "Одеса", "Львів", "Дніпро", "Інші регіони" + ], + "België / Belgique": [ + "Brussel", "Antwerpen", "Gent", "Brugge", "Andere regio's" + ], + "Schweiz / Suisse": [ + "Zürich", "Genève", "Basel", "Bern", "Autres régions" + ], + "Österreich": [ + "Wien", "Salzburg", "Innsbruck", "Graz", "Andere Regionen" + ], + "Sverige": [ + "Stockholm", "Göteborg", "Malmö", "Uppsala", "Andra regioner" + ], + "Norge": [ + "Oslo", "Bergen", "Trondheim", "Stavanger", "Andre regioner" + ], + "Danmark": [ + "København", "Aarhus", "Odense", "Aalborg", "Andre regioner" + ], + "Suomi": [ + "Helsinki", "Espoo", "Tampere", "Turku", "Muut alueet" + ], + "Portugal": [ + "Lisboa", "Porto", "Faro", "Coimbra", "Outras regiões" + ], + "Ελλάδα": [ + "Αθήνα", "Θεσσαλονίκη", "Κρήτη", "Σαντορίνη", "Άλλες περιοχές" + ], + "Česká republika": [ + "Praha", "Brno", "Ostrava", "Plzeň", "Jiné regiony" + ], + "România": [ + "București", "Cluj-Napoca", "Timișoara", "Brașov", "Alte regiuni" + ], + "Magyarország": [ + "Budapest", "Debrecen", "Szeged", "Pécs", "Más régiók" + ], + "Ireland": [ + "Dublin", "Cork", "Galway", "Limerick", "Other regions" + ], + "Ísland": [ + "Reykjavík", "Akureyri", "Önnur svæði" + ] +}; + +// ========================================== +// 🌎 北美洲 (North America) +// ========================================== +const NORTH_AMERICA = { + "United States": [ + "California", "New York", "Texas", "Florida", "Washington", + "Illinois", "Pennsylvania", "Ohio", "Georgia", "Michigan", + "New Jersey", "Massachusetts", "Arizona", "Colorado", + "Hawaii", "Alaska", "Nevada", "Oregon", "Other states" + ], + "Canada": [ + "Toronto", "Vancouver", "Montréal", "Calgary", "Ottawa", + "Edmonton", "Winnipeg", "Québec City", "Halifax", "Other regions" + ], + "México": [ + "Ciudad de México", "Guadalajara", "Monterrey", "Cancún", + "Tijuana", "Puebla", "Otras regiones" + ] +}; + +// ========================================== +// 🌎 南美洲 (South America) +// ========================================== +const SOUTH_AMERICA = { + "Brasil": [ + "São Paulo", "Rio de Janeiro", "Brasília", "Salvador", + "Fortaleza", "Belo Horizonte", "Curitiba", "Outras regiões" + ], + "Argentina": [ + "Buenos Aires", "Córdoba", "Rosario", "Mendoza", + "Mar del Plata", "Otras regiones" + ], + "Chile": [ + "Santiago", "Valparaíso", "Concepción", "La Serena", "Otras regiones" + ], + "Colombia": [ + "Bogotá", "Medellín", "Cali", "Barranquilla", "Cartagena", "Otras regiones" + ], + "Perú": [ + "Lima", "Cusco", "Arequipa", "Trujillo", "Otras regiones" + ], + "Venezuela": [ + "Caracas", "Maracaibo", "Valencia", "Barquisimeto", "Otras regiones" + ], + "Ecuador": [ + "Quito", "Guayaquil", "Cuenca", "Otras regiones" + ], + "Uruguay": [ + "Montevideo", "Punta del Este", "Otras regiones" + ] +}; + +// ========================================== +// 🌏 大洋洲 (Oceania) +// ========================================== +const OCEANIA = { + "Australia": [ + "Sydney", "Melbourne", "Brisbane", "Perth", "Adelaide", + "Gold Coast", "Canberra", "Hobart", "Darwin", "Other regions" + ], + "New Zealand": [ + "Auckland", "Wellington", "Christchurch", "Queenstown", "Other regions" + ], + "Fiji": [ + "Suva", "Nadi", "Other regions" + ], + "Papua New Guinea": [ + "Port Moresby", "Other regions" + ] +}; + +// ========================================== +// 🌍 非洲 (Africa) +// ========================================== +const AFRICA = { + "مصر": [ + "القاهرة", "الإسكندرية", "الجيزة", "شرم الشيخ", "مناطق أخرى" + ], + "South Africa": [ + "Johannesburg", "Cape Town", "Durban", "Pretoria", "Other regions" + ], + "Nigeria": [ + "Lagos", "Abuja", "Kano", "Ibadan", "Other regions" + ], + "Kenya": [ + "Nairobi", "Mombasa", "Kisumu", "Other regions" + ], + "المغرب": [ + "الدار البيضاء", "مراكش", "الرباط", "فاس", "مناطق أخرى" + ], + "Ghana": [ + "Accra", "Kumasi", "Tamale", "Other regions" + ], + "Ethiopia": [ + "Addis Ababa", "Dire Dawa", "Other regions" + ], + "Tanzania": [ + "Dar es Salaam", "Zanzibar", "Arusha", "Other regions" + ] +}; + +// ========================================== +// 🌐 其他 (Others) +// ========================================== +const OTHERS = { + "Other Countries": ["Other regions"] +}; + +// ========================================== +// 📦 合并导出 +// ========================================== +export const regionData = { + // 亚洲(常用国家优先) + ...ASIA, + // 欧洲 + ...EUROPE, + // 北美洲 + ...NORTH_AMERICA, + // 南美洲 + ...SOUTH_AMERICA, + // 大洋洲 + ...OCEANIA, + // 非洲 + ...AFRICA, + // 其他 + ...OTHERS +}; + +// 国家列表(按优先级排序:中国、日本、韩国、美国等常用国家优先) +export const PRIORITY_COUNTRIES = [ + "中国", "日本", "대한민국", "United States", "United Kingdom", + "Deutschland", "France", "Canada", "Australia", "Singapore", + "Malaysia", "ประเทศไทย", "Việt Nam", "Indonesia", "भारत" +]; + +// 获取排序后的国家列表 +export function getSortedCountries() { + const allCountries = Object.keys(regionData); + const prioritized = PRIORITY_COUNTRIES.filter(c => allCountries.includes(c)); + const others = allCountries.filter(c => !PRIORITY_COUNTRIES.includes(c)); + return [...prioritized, ...others]; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\346\211\276\345\233\236\350\264\246\345\217\267\350\241\250\345\215\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\346\211\276\345\233\236\350\264\246\345\217\267\350\241\250\345\215\225\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..e1316dfe7c13be2dc16044c08aff7905d57fd5e3 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\346\211\276\345\233\236\350\264\246\345\217\267\350\241\250\345\215\225\347\273\204\344\273\266.js" @@ -0,0 +1,131 @@ +// 前端页面/auth/找回账号表单组件.js +// ========================================== +// 🔎 找回账号表单组件 +// ========================================== +// 作用:用户同时忘记账号和密码时,凭绑定邮箱+验证码找回账号 +// 关联文件: +// - 用户注册登录组件.js (switchView 渲染本组件) +// - 重置密码表单组件.js (找回成功后携带 prefill 跳转重置密码) +// - 网络请求_业务API.js (api.sendVerifyCode / api.recoverAccount) +// ========================================== + +import { api } from "../core/网络请求API.js"; +import { showToast } from "../components/UI交互提示组件.js"; +import { t } from "../components/用户体验增强.js"; +// 🛡️ 安全转义:防止网络返回数据注入 HTML(🧹 P2归一:使用统一版) +import { escapeHtml } from "../components/互动工具函数.js"; + +export function renderRecoverForm(container, switchView) { + container.innerHTML = ` +
${t('auth.recover_desc')}
+ +
+ +
+ + +
+ +
+ + +
${t('auth.back_to_login')}
+ `; + + container.querySelector("#recover-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); }; + + function startCountdown(btn, duration, resetColor) { + let timeLeft = duration; + btn.innerText = `${timeLeft}s ${t('auth.resend')}`; + const timer = setInterval(() => { + timeLeft--; + if (timeLeft <= 0) { + clearInterval(timer); + btn.disabled = false; + btn.style.background = resetColor; + btn.innerText = t('auth.get_code'); + } else { + btn.innerText = `${timeLeft}s ${t('auth.resend')}`; + } + }, 1000); + return timer; + } + + // 发送验证码逻辑(action_type=recover,无需账号) + const btnSendCode = container.querySelector("#btn-recover-send-code"); + let countdownTimer = null; + + // 🔧 注册清理函数:视图切换时清除倒计时定时器,防止内存泄漏 + container._onCleanup = () => { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } }; + + btnSendCode.onclick = async (e) => { + e.preventDefault(); + + const emailInput = container.querySelector("#recover-email").value.trim(); + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput); + if (!isEmail) return showToast(t('auth.invalid_email'), "error"); + + btnSendCode.disabled = true; + btnSendCode.style.background = "#555"; + btnSendCode.innerText = t('auth.sending'); + + try { + await api.sendVerifyCode(emailInput, "email", "recover"); + + showToast(t('auth.code_sent'), "success"); + + countdownTimer = startCountdown(btnSendCode, 60, "#2196F3"); + + } catch (err) { + showToast(err.message || t('feedback.error'), "error"); + btnSendCode.disabled = false; + btnSendCode.style.background = "#2196F3"; + btnSendCode.innerText = t('auth.get_code'); + } + }; + + // 提交找回:验证码核验成功后展示账号 + const btnSubmit = container.querySelector("#btn-submit-recover"); + btnSubmit.onclick = async () => { + const email = container.querySelector("#recover-email").value.trim(); + const code = container.querySelector("#recover-code").value.trim(); + + if (!email) return showToast(t('auth.invalid_email'), "warning"); + if (code.length !== 6) return showToast(t('auth.invalid_code'), "warning"); + + btnSubmit.disabled = true; + btnSubmit.innerText = t('auth.sending'); + + try { + const res = await api.recoverAccount(email, code); + + showToast(t('auth.recover_success'), "success"); + + // 🔧 展示结果前清除倒计时,避免定时器继续作用于已替换的 DOM + if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } + + // 展示找回结果:账号 + 昵称,提供跳转重置密码入口 + const nickname = res.name ? `(${escapeHtml(res.name)})` : ""; + container.innerHTML = ` +
+
✅ ${t('auth.recover_success')}
+
${t('auth.your_account')}
+
${escapeHtml(res.account)}${nickname}
+
+ + + `; + + // 跳转重置密码表单,自动回填账号与邮箱 + container.querySelector("#btn-go-reset").onclick = () => { + switchView("reset", { prefill: { account: res.account, email: email } }); + }; + container.querySelector("#btn-back-login").onclick = () => switchView("login"); + + } catch (err) { + showToast(err.message || t('feedback.error'), "error"); + btnSubmit.disabled = false; + btnSubmit.innerText = t('auth.recover_account'); + } + }; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\346\263\250\345\206\214\350\241\250\345\215\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\346\263\250\345\206\214\350\241\250\345\215\225\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..21df7eb0f19bcbf01a27d241c220fe011a6b987a --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\346\263\250\345\206\214\350\241\250\345\215\225\347\273\204\344\273\266.js" @@ -0,0 +1,220 @@ +import { api } from "../core/网络请求API.js"; +import { regionData, getSortedCountries } from "./国家地区数据.js"; +import { showToast } from "../components/UI交互提示组件.js"; +import { t } from "../components/用户体验增强.js"; +import { openImageCropper } from "../components/图片裁剪组件.js"; +import { PLACEHOLDERS } from "../core/全局配置.js"; + +const INPUT_STYLE = 'width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;'; + +const DEFAULT_AVATAR = PLACEHOLDERS.AVATAR; + +// 计算年龄工具函数 +function calculateAge(birthDate) { + if (!birthDate) return null; + const today = new Date(); + const birth = new Date(birthDate); + let age = today.getFullYear() - birth.getFullYear(); + const monthDiff = today.getMonth() - birth.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { + age--; + } + return age >= 0 && age <= 150 ? age : null; +} + +export function renderRegisterForm(container, switchView, onSuccessCallback) { + // 使用排序后的国家列表(常用国家置顶) + const sortedCountries = getSortedCountries(); + const countryOptions = sortedCountries.map(c => ``).join(""); + + container.innerHTML = ` +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+ + +
+
+
+
+
+
+ +
${t('auth.has_account')} ${t('auth.back_to_login')}
+ `; + + const genderSelect = container.querySelector("#reg-gender"); + const avatarPreview = container.querySelector("#avatar-preview"); + const avatarInput = container.querySelector("#reg-avatar"); + const avatarError = container.querySelector("#avatar-error"); + const countrySelect = container.querySelector("#reg-country"); + const regionSelect = container.querySelector("#reg-region"); + const birthdayInput = container.querySelector("#reg-birthday"); + const ageDisplay = container.querySelector("#reg-age-display"); + + // 出生日期变化时自动计算年龄 + birthdayInput.onchange = () => { + const age = calculateAge(birthdayInput.value); + if (age !== null) { + ageDisplay.textContent = `${age} ${t('auth.age_years')}`; + ageDisplay.style.color = "#4CAF50"; + } else { + ageDisplay.textContent = ""; + } + }; + + const updateRegionOptions = () => { + const regions = regionData[countrySelect.value] || []; + regionSelect.innerHTML = regions.map(r => ``).join(""); + }; + countrySelect.onchange = updateRegionOptions; + updateRegionOptions(); + + let selectedAvatarFile = null; + genderSelect.onchange = (e) => { if (!selectedAvatarFile) avatarPreview.src = DEFAULT_AVATAR; }; + + avatarInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + // 📱 移动端适配:手机拍照原图普遍超过 3MB,不再用原始大小拦截, + // 统一先走 1:1 裁剪压缩管线(输出 ≤512×512 JPG),与个人设置头像链路一致; + // 用户取消裁剪时静默还原默认头像预览。 + avatarInput.value = ""; + let croppedFile; + try { + croppedFile = await openImageCropper(file, 1, t('auth.avatar'), 1); + } catch (err) { + showToast(t('auth.file_too_large'), "warning"); + selectedAvatarFile = null; + avatarPreview.src = DEFAULT_AVATAR; + return; + } + if (!croppedFile) { + selectedAvatarFile = null; + avatarPreview.src = DEFAULT_AVATAR; + return; + } + avatarError.style.display = "none"; + selectedAvatarFile = croppedFile; + const reader = new FileReader(); + reader.onload = (event) => avatarPreview.src = event.target.result; + reader.onerror = () => showToast(t('auth.file_too_large'), "warning"); + reader.readAsDataURL(croppedFile); + }; + + container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); }; + + function startCountdown(btn, duration, resetColor) { + let timeLeft = duration; + btn.innerText = `${timeLeft}s ${t('auth.resend')}`; + const timer = setInterval(() => { + timeLeft--; + if (timeLeft <= 0) { + clearInterval(timer); + btn.disabled = false; + btn.style.background = resetColor; + btn.innerText = t('auth.get_code'); + } else { + btn.innerText = `${timeLeft}s ${t('auth.resend')}`; + } + }, 1000); + return timer; + } + + // 发送注册验证码逻辑 + const btnRegSendCode = container.querySelector("#btn-reg-send-code"); + let regCountdownTimer = null; + + btnRegSendCode.onclick = async (e) => { + e.preventDefault(); + const emailInput = container.querySelector("#reg-email").value.trim(); + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput); + + if (!isEmail) return showToast(t('auth.invalid_email'), "error"); + + btnRegSendCode.disabled = true; + btnRegSendCode.style.background = "#555"; + btnRegSendCode.innerText = t('auth.sending'); + + try { + await api.sendVerifyCode(emailInput, "email", "register"); + showToast(t('auth.code_sent'), "success"); + + regCountdownTimer = startCountdown(btnRegSendCode, 60, "#4CAF50"); + + } catch (err) { + showToast(err.message || t('feedback.error'), "error"); + btnRegSendCode.disabled = false; + btnRegSendCode.style.background = "#4CAF50"; + btnRegSendCode.innerText = t('auth.get_code'); + } + }; + + container.querySelector("#btn-submit-register").onclick = () => { + const account = container.querySelector("#reg-account").value.trim(); + const name = container.querySelector("#reg-name").value.trim(); + const password = container.querySelector("#reg-password").value; + const pwdConfirm = container.querySelector("#reg-password-confirm").value; + const email = container.querySelector("#reg-email").value.trim(); + + const code = container.querySelector("#reg-code").value.trim(); + + if (!account || !name || !password || !email || !code) return showToast(t('auth.required_fields'), "warning"); + if (code.length !== 6) return showToast(t('auth.invalid_code'), "warning"); + + if (!/^[a-zA-Z0-9_]{6,20}$/.test(account)) return showToast(t('auth.account_format_error'), "error"); + if (password !== pwdConfirm) return showToast(t('auth.password_mismatch'), "error"); + if (!/^[A-Za-z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{6,}$/.test(password)) return showToast(t('auth.password_format_error'), "error"); + + const formData = { + type: "register", account, password, email, name, gender: genderSelect.value, code, + birthday: birthdayInput.value, // 保存出生日期 + age: calculateAge(birthdayInput.value), // 自动计算年龄 + country: countrySelect.value, region: regionSelect.value, + intro: container.querySelector("#reg-intro").value.trim(), avatarFile: selectedAvatarFile, avatarDataUrl: selectedAvatarFile ? avatarPreview.src : null + }; + + // 🚀 核心自愈:提交前清洗数据,把非必填的空字符串删掉,防止引发后端 422 报错 + Object.keys(formData).forEach(key => { + if (formData[key] === "" || formData[key] === undefined || formData[key] === null) { + delete formData[key]; + } + }); + + if (onSuccessCallback) onSuccessCallback(formData); + }; +} \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..9c523e9f2262b609fe6fc2ecfc984bd2041a0b8e --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" @@ -0,0 +1,51 @@ +import { renderLoginForm } from "./登录表单组件.js"; +import { renderRegisterForm } from "./注册表单组件.js"; +import { renderResetForm } from "./重置密码表单组件.js"; +import { renderRecoverForm } from "./找回账号表单组件.js"; +import { t } from "../components/用户体验增强.js"; + +export function createAuthView(onSuccessCallback) { + const container = document.createElement("div"); + // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致) + Object.assign(container.style, { + display: "flex", flexDirection: "column", gap: "15px", color: "#ccc", + fontSize: "14px", padding: "15px", flex: "1", minHeight: "0", boxSizing: "border-box", overflowY: "auto", + background: "var(--comfy-menu-bg)" + }); + + container.innerHTML = ` + +
🔐 ${t('auth.title')}
+
+ `; + + container.querySelector("#btn-back-auth").onclick = () => { + window.dispatchEvent(new CustomEvent("comfy-route-back")); + }; + + const formContainer = container.querySelector("#auth-form-container"); + + const switchView = (viewState, options = {}) => { + // 🔧 定时器清理:切换视图前执行上一个表单注册的清理函数(如验证码倒计时),防止内存泄漏 + if (typeof formContainer._onCleanup === "function") { + formContainer._onCleanup(); + formContainer._onCleanup = null; + } + formContainer.innerHTML = ""; + if (viewState === "login") { + renderLoginForm(formContainer, switchView, onSuccessCallback); + } else if (viewState === "register") { + renderRegisterForm(formContainer, switchView, onSuccessCallback); + } else if (viewState === "reset") { + renderResetForm(formContainer, switchView, onSuccessCallback, options.prefill); + } else if (viewState === "recover") { + renderRecoverForm(formContainer, switchView); + } + }; + + switchView("login"); + + return container; +} \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\231\273\345\275\225\350\241\250\345\215\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\231\273\345\275\225\350\241\250\345\215\225\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..39d92866d5a3c1046475394971cd34862b6bc90c --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\231\273\345\275\225\350\241\250\345\215\225\347\273\204\344\273\266.js" @@ -0,0 +1,37 @@ +import { showToast } from "../components/UI交互提示组件.js"; +import { t } from "../components/用户体验增强.js"; + +const INPUT_STYLE = 'width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;'; + +export function renderLoginForm(container, switchView, onSuccessCallback) { + container.innerHTML = ` +
+
+
+
+ + +
+
+ 🔎 ${t('auth.forgot_account')} + 🔑 ${t('auth.forgot_password')} +
+
+ +
${t('auth.no_account')} ${t('auth.register_now')}
+ `; + + container.querySelector("#toggle-to-register").onclick = (e) => { e.preventDefault(); switchView("register"); }; + container.querySelector("#toggle-to-reset").onclick = (e) => { e.preventDefault(); switchView("reset"); }; + container.querySelector("#toggle-to-recover").onclick = (e) => { e.preventDefault(); switchView("recover"); }; + container.querySelector("#btn-submit-login").onclick = () => { + const account = container.querySelector("#login-account").value.trim(); + const password = container.querySelector("#login-password").value; + const remember = container.querySelector("#login-remember").checked; + + if (!account || !password) return showToast(t('auth.account_password_required'), "warning"); + + const formData = { type: "login", account, password, remember }; + if (onSuccessCallback) onSuccessCallback(formData); + }; +} \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\351\207\215\347\275\256\345\257\206\347\240\201\350\241\250\345\215\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\351\207\215\347\275\256\345\257\206\347\240\201\350\241\250\345\215\225\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..16cd0e83fd4ce7e894f8193af7e858b39094f352 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\351\207\215\347\275\256\345\257\206\347\240\201\350\241\250\345\215\225\347\273\204\344\273\266.js" @@ -0,0 +1,119 @@ +import { api } from "../core/网络请求API.js"; +import { showToast } from "../components/UI交互提示组件.js"; +import { t } from "../components/用户体验增强.js"; + +const INPUT_STYLE = 'width: 100%; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;'; + +export function renderResetForm(container, switchView, onSuccessCallback, prefill = null) { + container.innerHTML = ` +
+ +
+ +
+ + +
+ +
+ +
+
+
+ + +
${t('auth.back_to_login')}
+ `; + + container.querySelector("#toggle-to-login").onclick = (e) => { e.preventDefault(); switchView("login"); }; + + // 🔎 找回账号跳转过来时,自动回填账号与邮箱 + if (prefill) { + if (prefill.account) container.querySelector("#reset-account").value = prefill.account; + if (prefill.email) container.querySelector("#reset-verify").value = prefill.email; + } + + function startCountdown(btn, duration, resetColor) { + let timeLeft = duration; + btn.innerText = `${timeLeft}s ${t('auth.resend')}`; + const timer = setInterval(() => { + timeLeft--; + if (timeLeft <= 0) { + clearInterval(timer); + btn.disabled = false; + btn.style.background = resetColor; + btn.innerText = t('auth.get_code'); + } else { + btn.innerText = `${timeLeft}s ${t('auth.resend')}`; + } + }, 1000); + return timer; + } + + // 发送验证码逻辑 + const btnSendCode = container.querySelector("#btn-send-code"); + let countdownTimer = null; + + // 🔧 注册清理函数:视图切换时清除倒计时定时器,防止内存泄漏 + container._onCleanup = () => { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } }; + + btnSendCode.onclick = async (e) => { + e.preventDefault(); + + // 先抓取上方输入的账号,没填就不让发验证码 + const accountInput = container.querySelector("#reset-account").value.trim(); + if (!accountInput) return showToast(t('auth.enter_account_first'), "warning"); + + const verifyInput = container.querySelector("#reset-verify").value.trim(); + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(verifyInput); + + if (!isEmail) return showToast(t('auth.invalid_email'), "error"); + + const contactType = "email"; + + btnSendCode.disabled = true; + btnSendCode.style.background = "#555"; + btnSendCode.innerText = t('auth.sending'); + + try { + await api.sendVerifyCode(verifyInput, contactType, "reset", accountInput); + + showToast(t('auth.code_sent'), "success"); + + countdownTimer = startCountdown(btnSendCode, 60, "#2196F3"); + + } catch (err) { + showToast(err.message || t('feedback.error'), "error"); + btnSendCode.disabled = false; + btnSendCode.style.background = "#2196F3"; + btnSendCode.innerText = t('auth.get_code'); + } + }; + + container.querySelector("#btn-submit-reset").onclick = () => { + const acc = container.querySelector("#reset-account").value.trim(); + const verify = container.querySelector("#reset-verify").value.trim(); + const code = container.querySelector("#reset-code").value.trim(); + const op = container.querySelector("#reset-old-password").value; + const np = container.querySelector("#reset-new-password").value; + const cp = container.querySelector("#reset-confirm-password").value; + + if (!acc || !verify || !np || !code) return showToast(t('auth.reset_required_fields'), "warning"); + if (code.length !== 6) return showToast(t('auth.invalid_code'), "warning"); + + + if (np !== cp) return showToast(t('auth.password_mismatch'), "error"); + if (np.length < 6) return showToast(t('auth.password_format_error'), "error"); + + const formData = { + type: "reset", + account: acc, + verifyContact: verify, + verifyType: "email", + code: code, + old_password: op || null, + new_password: np + }; + if (onSuccessCallback) onSuccessCallback(formData); + }; +} \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..592109f3eb3463215ea620df5cca8ca788639953 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" @@ -0,0 +1,703 @@ +// 前端页面/components/UI交互提示组件.js +// ========================================== +// 🎨 UI 交互提示组件 +// ========================================== +// 作用:提供统一的用户交互反馈组件 +// 关联文件: +// - 所有业务组件(调用提示函数) +// - 网络请求API.js(错误处理) +// ========================================== +// 🎯 P1体验优化: +// - 统一错误处理与重试机制 +// - 网络状态检测与离线提示 +// - 按钮加载状态 +// - 全局加载遮罩 +// =========================================== + +import { t } from "./用户体验增强.js"; + +// ========================================== +// 📢 Toast 消息提示 +// ========================================== +// 轻量级全局消息提示框,自动消失 + +// Toast 队列管理(避免重叠) +let toastQueue = []; +let toastOffset = 50; + +/** + * 显示 Toast 消息提示 + * @param {string} message - 提示内容 + * @param {string} type - 类型: 'info', 'success', 'error', 'warning' + * @param {number} duration - 显示时长(毫秒),默认3000 + */ +export function showToast(message, type = "info", duration = 3000) { + const toast = document.createElement("div"); + const colors = { + success: "#4CAF50", + error: "#F44336", + info: "#2196F3", + warning: "#FF9800" + }; + const icons = { + success: "✅", + error: "❌", + info: "ℹ️", + warning: "⚠️" + }; + + // 计算位置(避免重叠) + const topPosition = toastOffset + toastQueue.length * 60; + + Object.assign(toast.style, { + position: "fixed", + top: `${topPosition - 30}px`, + left: "50%", + transform: "translateX(-50%)", + background: colors[type] || colors.info, + color: "#fff", + padding: "12px 24px", + borderRadius: "8px", + fontSize: "14px", + fontWeight: "500", + zIndex: "10000", + boxShadow: "0 4px 20px rgba(0,0,0,0.3)", + opacity: "0", + transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", + pointerEvents: "auto", + display: "flex", + alignItems: "center", + gap: "10px", + maxWidth: "400px", + wordBreak: "break-word" + }); + + toast.innerHTML = ` + ${icons[type] || icons.info} + ${message} + × + `; + + document.body.appendChild(toast); + toastQueue.push(toast); + + // 点击关闭 + toast.querySelector(".toast-close").onclick = () => closeToast(toast); + + // 入场动画 + requestAnimationFrame(() => { + toast.style.opacity = "1"; + toast.style.top = `${topPosition}px`; + }); + + // 自动关闭 + const autoCloseTimer = setTimeout(() => closeToast(toast), duration); + toast._autoCloseTimer = autoCloseTimer; +} + +function closeToast(toast) { + if (toast._autoCloseTimer) clearTimeout(toast._autoCloseTimer); + + toast.style.opacity = "0"; + toast.style.transform = "translateX(-50%) translateY(-20px)"; + + setTimeout(() => { + toast.remove(); + const idx = toastQueue.indexOf(toast); + if (idx > -1) toastQueue.splice(idx, 1); + }, 300); +} + + +// ========================================== +// ⚠️ 对话框辅助函数(内部复用) +// ========================================== + +/** + * 创建对话框遮罩层 + * @param {number} bgOpacity - 背景透明度 + * @param {number} blurPx - 模糊值(px) + * @returns {HTMLElement} + */ +function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) { + const overlay = document.createElement("div"); + Object.assign(overlay.style, { + position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", + background: `rgba(0,0,0,${bgOpacity})`, zIndex: "10001", display: "flex", + alignItems: "center", justifyContent: "center", backdropFilter: `blur(${blurPx}px)`, + opacity: "0", transition: "opacity 0.2s" + }); + return overlay; +} + +/** + * 创建对话框主体容器 + * @param {string} width - 宽度,如 "360px" + * @param {string} [background] - 背景色 + * @param {string} [borderRadius] - 圆角 + * @param {string} [padding] - 内边距 + * @param {string} [boxShadow] - 阴影 + * @param {string} [gap] - flex gap + * @param {string} [transformOrigin] - 变换基点(影响初始 transform) + * @returns {HTMLElement} + */ +function _createDialogBox({ width = "360px", background = "#222", borderRadius = "12px", padding = "24px", boxShadow = "0 10px 40px rgba(0,0,0,0.5)", gap = "16px", hasYTranslate = true } = {}) { + const box = document.createElement("div"); + const initTransform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)"; + Object.assign(box.style, { + background, border: "1px solid #444", borderRadius, + padding, width, maxWidth: "90vw", color: "#fff", + boxShadow, + display: "flex", flexDirection: "column", gap, + transform: initTransform, transition: "transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)" + }); + return box; +} + +/** + * 播放对话框入场动画 + * @param {HTMLElement} overlay + * @param {HTMLElement} box + * @param {boolean} hasYTranslate - 是否包含 Y 轴位移 + */ +function _showDialogAnimation(overlay, box, hasYTranslate = true) { + requestAnimationFrame(() => { + overlay.style.opacity = "1"; + box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)"; + }); +} + +/** + * 关闭对话框并移除 DOM + * @param {HTMLElement} overlay + * @param {HTMLElement} box + * @param {boolean} hasYTranslate + * @param {Function} [callback] - 关闭完成后的回调 + */ +function _closeDialog(overlay, box, hasYTranslate = true, callback) { + overlay.style.opacity = "0"; + box.style.transform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)"; + setTimeout(() => { overlay.remove(); if (callback) callback(); }, 200); +} + +// ========================================== +// ⚠️ 确认对话框 +// ========================================== + +/** + * 显示确认对话框 + * @param {string} message - 确认信息内容 + * @param {Object} options - 配置选项 + * @param {string} options.title - 标题,默认"操作确认" + * @param {string} options.confirmText - 确认按钮文字 + * @param {string} options.cancelText - 取消按钮文字 + * @param {string} options.type - 类型: 'warning', 'danger', 'info' + * @returns {Promise} + */ +export function showConfirm(message, options = {}) { + const { + title = "操作确认", + confirmText = "确认执行", + cancelText = "取消", + type = "warning" + } = options; + + const typeConfig = { + warning: { icon: "⚠️", color: "#FF9800", btnColor: "#FF9800" }, + danger: { icon: "🚨", color: "#F44336", btnColor: "#F44336" }, + info: { icon: "ℹ️", color: "#2196F3", btnColor: "#2196F3" } + }; + const config = typeConfig[type] || typeConfig.warning; + + return new Promise((resolve) => { + const overlay = _createDialogOverlay(0.6, 3); + const box = _createDialogBox({ width: "360px", hasYTranslate: true }); + + box.innerHTML = ` +
+ ${config.icon} + ${title} +
+
${message}
+
+ + +
+ `; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + // 入场动画 + _showDialogAnimation(overlay, box, true); + + const close = (result) => { + _closeDialog(overlay, box, true, () => resolve(result)); + }; + + // ESC 键关闭 + const handleKeydown = (e) => { + if (e.key === "Escape") { + document.removeEventListener("keydown", handleKeydown); + close(false); + } + }; + document.addEventListener("keydown", handleKeydown); + + box.querySelector("#ui-btn-cancel").onclick = () => { + document.removeEventListener("keydown", handleKeydown); + close(false); + }; + box.querySelector("#ui-btn-confirm").onclick = () => { + document.removeEventListener("keydown", handleKeydown); + close(true); + }; + }); +} + + +// ========================================== +// 🔄 错误重试对话框 +// ========================================== + +/** + * 显示错误重试对话框 + * @param {string} message - 错误信息 + * @param {Function} retryFn - 重试函数 + * @param {Object} options - 配置选项 + * @returns {Promise} - 用户选择重试返回true + */ +export function showRetryDialog(message, retryFn, options = {}) { + const { + title = "操作失败", + retryText = "🔄 重试", + cancelText = "关闭" + } = options; + + return new Promise((resolve) => { + const overlay = _createDialogOverlay(0.7, 4); + const box = _createDialogBox({ + width: "400px", + background: "linear-gradient(145deg, #2a2a2a, #1f1f1f)", + borderRadius: "16px", + padding: "28px", + boxShadow: "0 15px 50px rgba(0,0,0,0.5)", + gap: "20px", + hasYTranslate: false + }); + // RetryDialog 的过渡时间略长 + box.style.transition = "transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)"; + + box.innerHTML = ` +
+
😕
+
${title}
+
+
+ ${message} +
+
+ + +
+ `; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + // 入场动画 + _showDialogAnimation(overlay, box, false); + + const close = () => { + _closeDialog(overlay, box, false); + }; + + box.querySelector("#ui-btn-cancel").onclick = () => { + close(); + resolve(false); + }; + + box.querySelector("#ui-btn-retry").onclick = async () => { + const retryBtn = box.querySelector("#ui-btn-retry"); + retryBtn.innerHTML = ` ${t('common.retrying')}`; + retryBtn.disabled = true; + + try { + await retryFn(); + close(); + resolve(true); + } catch (error) { + // 重试失败,更新错误信息 + box.querySelector("div[style*='background: #1a1a1a']").innerHTML = error.message || t('common.retry_failed'); + retryBtn.innerHTML = retryText; + retryBtn.disabled = false; + } + }; + }); +} + + +// ========================================== +// 🌐 网络状态检测 +// ========================================== + +let isOffline = !navigator.onLine; +let offlineBanner = null; + +/** + * 初始化网络状态监听 + */ +export function initNetworkStatusListener() { + window.addEventListener("online", () => { + isOffline = false; + hideOfflineBanner(); + showToast("网络已恢复", "success"); + }); + + window.addEventListener("offline", () => { + isOffline = true; + showOfflineBanner(); + }); + + // 初始状态检测 + if (!navigator.onLine) { + showOfflineBanner(); + } +} + +function showOfflineBanner() { + if (offlineBanner) return; + + offlineBanner = document.createElement("div"); + Object.assign(offlineBanner.style, { + position: "fixed", + bottom: "0", + left: "0", + right: "0", + background: "linear-gradient(135deg, #FF5722, #E64A19)", + color: "#fff", + padding: "12px 20px", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "12px", + fontSize: "14px", + fontWeight: "500", + zIndex: "9999", + boxShadow: "0 -4px 20px rgba(0,0,0,0.3)", + transform: "translateY(100%)", + transition: "transform 0.3s ease" + }); + + offlineBanner.innerHTML = ` + 📡 + 网络连接已断开,部分功能可能不可用 + + `; + + document.body.appendChild(offlineBanner); + + requestAnimationFrame(() => { + offlineBanner.style.transform = "translateY(0)"; + }); +} + +function hideOfflineBanner() { + if (!offlineBanner) return; + + offlineBanner.style.transform = "translateY(100%)"; + setTimeout(() => { + offlineBanner?.remove(); + offlineBanner = null; + }, 300); +} + +/** + * 检查网络状态 + * @returns {boolean} - 是否在线 + */ +export function checkOnline() { + return navigator.onLine; +} + + +// ========================================== +// ⏳ 全局加载遮罩 +// ========================================== + +let loadingOverlay = null; +let loadingCount = 0; + +/** + * 显示全局加载遮罩 + * @param {string} message - 加载提示文字 + */ +export function showLoading(message = "加载中...") { + loadingCount++; + + if (loadingOverlay) { + // 更新文字 + const textEl = loadingOverlay.querySelector(".loading-text"); + if (textEl) textEl.textContent = message; + return; + } + + loadingOverlay = document.createElement("div"); + Object.assign(loadingOverlay.style, { + position: "fixed", + top: "0", + left: "0", + width: "100vw", + height: "100vh", + background: "rgba(0,0,0,0.5)", + backdropFilter: "blur(2px)", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "16px", + zIndex: "10002", + opacity: "0", + transition: "opacity 0.2s" + }); + + loadingOverlay.innerHTML = ` +
+
${message}
+ `; + + // 添加旋转动画样式 + if (!document.getElementById("loading-spinner-style")) { + const style = document.createElement("style"); + style.id = "loading-spinner-style"; + style.textContent = ` + @keyframes spin { to { transform: rotate(360deg); } } + .btn-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 6px; + vertical-align: middle; + } + `; + document.head.appendChild(style); + } + + document.body.appendChild(loadingOverlay); + + requestAnimationFrame(() => { + loadingOverlay.style.opacity = "1"; + }); +} + +/** + * 隐藏全局加载遮罩 + */ +export function hideLoading() { + loadingCount = Math.max(0, loadingCount - 1); + + if (loadingCount > 0 || !loadingOverlay) return; + + loadingOverlay.style.opacity = "0"; + setTimeout(() => { + loadingOverlay?.remove(); + loadingOverlay = null; + }, 200); +} + + +// ========================================== +// 🔘 按钮加载状态 +// ========================================== + +/** + * 设置按钮为加载状态 + * @param {HTMLButtonElement} button - 按钮元素 + * @param {boolean} loading - 是否加载中 + * @param {string} loadingText - 加载时显示的文字 + */ +export function setButtonLoading(button, loading, loadingText = "处理中...") { + if (!button) return; + + if (loading) { + // 保存原始状态 + button._originalHTML = button.innerHTML; + button._originalDisabled = button.disabled; + + button.disabled = true; + button.style.opacity = "0.7"; + button.style.cursor = "not-allowed"; + button.innerHTML = `${loadingText}`; + } else { + // 恢复原始状态 + button.disabled = button._originalDisabled || false; + button.style.opacity = ""; + button.style.cursor = ""; + if (button._originalHTML) { + button.innerHTML = button._originalHTML; + } + } +} + + +// ========================================== +// 📋 复制到剪贴板 +// ========================================== + +/** + * 复制文本到剪贴板 + * @param {string} text - 要复制的文本 + * @param {string} successMsg - 成功提示 + */ +export async function copyToClipboard(text, successMsg = "已复制到剪贴板") { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + // 降级方案 + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.cssText = "position: fixed; opacity: 0;"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + showToast(successMsg, "success"); + } catch (error) { + showToast("复制失败", "error"); + } +} + + +// ========================================== +// 🎯 快捷操作包装器 +// ========================================== + +/** + * 包装异步操作,自动处理加载状态和错误 + * @param {Function} asyncFn - 异步函数 + * @param {Object} options - 配置选项 + * @returns {Promise} + */ +export async function withFeedback(asyncFn, options = {}) { + const { + loadingMsg = "处理中...", + successMsg = "操作成功", + errorMsg = "操作失败", + showLoadingOverlay = false, + button = null + } = options; + + // 显示加载状态 + if (showLoadingOverlay) { + showLoading(loadingMsg); + } + if (button) { + setButtonLoading(button, true, loadingMsg); + } + + try { + const result = await asyncFn(); + + if (successMsg) { + showToast(successMsg, "success"); + } + + return result; + } catch (error) { + const msg = error.message || errorMsg; + showToast(msg, "error"); + throw error; + } finally { + if (showLoadingOverlay) { + hideLoading(); + } + if (button) { + setButtonLoading(button, false); + } + } +} + + +// ========================================== +// 🚀 自动初始化 +// ========================================== + +// 页面加载时初始化网络状态监听 +if (typeof window !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initNetworkStatusListener); + } else { + initNetworkStatusListener(); + } +} + + +// ========================================== +// 📦 安装进度条 +// ========================================== + +/** + * 创建安装进度条 + * @param {HTMLElement} container - 进度条容器元素 + * @returns {{update: Function, complete: Function, error: Function, destroy: Function}} + */ +export function createInstallProgress(container) { + // 创建进度条DOM + const wrapper = document.createElement("div"); + wrapper.className = "install-progress-wrapper"; + wrapper.innerHTML = ` +
+
+ 准备中... + 0% +
+
+
+
+
+ `; + container.innerHTML = ""; + container.appendChild(wrapper); + + const fill = wrapper.querySelector(".progress-fill"); + const stage = wrapper.querySelector(".progress-stage"); + const percent = wrapper.querySelector(".progress-percent"); + + return { + update(progress, message) { + const p = Math.max(0, Math.min(100, progress)); + fill.style.width = `${p}%`; + percent.textContent = `${p}%`; + if (message) stage.textContent = message; + }, + complete(message) { + fill.style.width = "100%"; + fill.style.background = "linear-gradient(90deg, #4CAF50, #8BC34A)"; + percent.textContent = "100%"; + percent.style.color = "#4CAF50"; + stage.textContent = message || "✅ 安装完成!"; + stage.style.color = "#4CAF50"; + }, + error(message) { + fill.style.background = "#F44336"; + percent.style.color = "#F44336"; + stage.textContent = message || "❌ 安装失败"; + stage.style.color = "#F44336"; + }, + destroy() { + wrapper.remove(); + } + }; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" new file mode 100644 index 0000000000000000000000000000000000000000..be6a8cd5aa3320b2cc0349592380726711a9ec67 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" @@ -0,0 +1,325 @@ +// 前端页面/components/互动工具函数.js +// ========================================== +// 👍 公共互动工具函数 +// ========================================== +// 作用:提取任务、帖子、资源详情页中重复的互动逻辑 +// 包括:访问量记录防抖、点赞/收藏按钮状态渲染、互动事件处理 +// ========================================== + +import { showToast } from "./UI交互提示组件.js"; +import { t } from "./用户体验增强.js"; + +// 开发模式检测,仅在本地环境输出调试日志 +const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; + +// ========================================== +// 👀 访问量记录防抖(通用版) +// ========================================== + +const _viewRecordCache = {}; +const VIEW_DEBOUNCE_MS = 300000; // 5分钟成功冷却 +const VIEW_ERROR_COOLDOWN_MS = 30000; // 30秒错误冷却 + +// 防重复提交状态跟踪 +const _pendingActions = new Set(); + +/** + * 记录内容浏览量(带5分钟防抖) + * @param {Function} apiMethod - API方法,如 api.recordTaskView + * @param {string} contentId - 内容ID + * @param {string} cachePrefix - 缓存前缀,如 'task', 'post', 'item' + * @param {Function} onSuccess - 成功回调,接收返回数据 + * @returns {Promise} + */ +export async function recordView(apiMethod, contentId, cachePrefix, onSuccess = null) { + // 🚀 登录检查:未登录时跳过浏览量记录,避免 401 错误 + const token = localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token"); + // 🔒 P0同步:旧版 mock_token 已被后端拒绝,仅标准 JWT 格式才记录浏览量 + if (!token || token.split(".").length !== 3) { + return null; + } + + DEBUG && console.warn(`[浏览量调试] recordView 被调用 - prefix: ${cachePrefix}, id: ${contentId}`); + const now = Date.now(); + const cacheKey = `${cachePrefix}_${contentId}`; + const cached = _viewRecordCache[cacheKey]; + + // 检查是否在防抖/冷却时间内(成功5分钟,失败30秒) + if (cached && now - cached.time < (cached.success ? VIEW_DEBOUNCE_MS : VIEW_ERROR_COOLDOWN_MS)) { + DEBUG && console.warn(`[浏览量调试] 防抖拦截 - ${cacheKey}, 距上次: ${now - cached.time}ms, 上次状态: ${cached.success ? 'success' : 'error'}`); + return null; // 防抖中,跳过 + } + + try { + const res = await apiMethod(contentId); + DEBUG && console.warn(`[浏览量调试] API响应:`, res?.status, res); + if (res.status === "success") { + // 成功后记录时间戳和成功状态 + _viewRecordCache[cacheKey] = { time: now, success: true }; + if (onSuccess) onSuccess(res); + return res; + } + } catch (err) { + // 失败时记录较短冷却时间,网络恢复后可快速重试 + _viewRecordCache[cacheKey] = { time: now, success: false }; + // 浏览记录失败不影响页面显示,但输出详细错误便于调试 + DEBUG && console.warn(`[浏览量调试] 记录${cachePrefix}浏览量失败:`, err.message || err); + DEBUG && console.warn(`[浏览量调试] 请求参数 - contentId: ${contentId}, cachePrefix: ${cachePrefix}`); + DEBUG && console.warn(`[浏览量调试] 错误详情:`, err); + } + return null; +} + +// ========================================== +// 👍 点赞/收藏按钮 HTML 生成 +// ========================================== + +/** + * 生成点赞/收藏按钮的 HTML + * @param {Object} data - 内容数据,包含 likes, favorites, liked_by, favorited_by + * @param {string} currentAccount - 当前用户账号 + * @param {Object} options - 配置选项 + * @param {boolean} options.showTip - 是否显示打赏按钮 + * @param {string} options.tipText - 打赏按钮文字 + * @returns {string} HTML字符串 + */ +export function renderInteractionButtonsHTML(data, currentAccount, options = {}) { + const { showTip = true, tipText = t('post.tip_author') } = options; + + const isLiked = data.liked_by?.includes(currentAccount) || false; + const isFavorited = data.favorited_by?.includes(currentAccount) || false; + + const likeBtn = ` + + `; + + const favoriteBtn = ` + + `; + + const tipBtn = showTip ? ` + + ` : ''; + + return `${likeBtn}${favoriteBtn}${tipBtn}`; +} + +// ========================================== +// 🔗 点赞/收藏 toggle 事件处理 +// ========================================== + +/** + * 通用 toggle 事件处理核心逻辑 + * @param {Function} apiMethod - API方法 + * @param {string} contentId - 内容ID + * @param {HTMLElement} buttonEl - 按钮元素 + * @param {HTMLElement} countEl - 计数元素 + * @param {Object} currentUser - 当前用户 + * @param {Object} options - 配置 + * @param {string} options.actionPrefix - 防重复key前缀,如 'like' / 'favorite' + * @param {string} options.countField - 响应中的计数字段名,如 'likes' / 'favorites' + * @param {string} options.activeAction - 激活时的 res.action 值,如 'liked' / 'favorited' + * @param {string} options.activeColor - 激活时的按钮背景色 + * @param {string} options.activeToastKey - 激活时 toast 的 i18n key + * @param {string} [options.activeTextColor] - 激活时的按钮文字色(可选) + * @param {string} [options.inactiveTextColor] - 取消激活时的按钮文字色(可选) + * @param {string} [options.warnLabel] - 日志警告用标签 + * @returns {Promise} + */ +async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, options) { + const { + actionPrefix, + countField, + activeAction, + activeColor, + activeToastKey, + activeTextColor = null, + inactiveTextColor = null, + warnLabel = 'toggle' + } = options; + + if (!currentUser) { + showToast(t('auth.login_required'), "warning"); + return null; + } + + const actionKey = `${actionPrefix}_${contentId}`; + if (_pendingActions.has(actionKey)) return null; + _pendingActions.add(actionKey); + + if (buttonEl) buttonEl.disabled = true; + + try { + const res = await apiMethod(contentId); + + if (!res || res.status !== "success") { + console.warn(`${warnLabel}响应异常:`, res); + return null; + } + + const count = typeof res[countField] === 'number' ? res[countField] : 0; + if (countEl) countEl.textContent = count; + + if (res.action === activeAction) { + if (buttonEl) buttonEl.style.background = activeColor; + if (buttonEl) buttonEl.style.borderColor = activeColor; + if (buttonEl && activeTextColor) buttonEl.style.color = activeTextColor; + showToast(t(activeToastKey), "success"); + } else { + if (buttonEl) buttonEl.style.background = "#333"; + if (buttonEl) buttonEl.style.borderColor = "#555"; + if (buttonEl && inactiveTextColor) buttonEl.style.color = inactiveTextColor; + } + return res; + } catch (err) { + console.error(`${warnLabel}操作失败:`, err); + showToast(t('task.operation_failed'), "error"); + return null; + } finally { + _pendingActions.delete(actionKey); + if (buttonEl) buttonEl.disabled = false; + } +} + +/** + * 处理点赞按钮点击 + * @param {Function} apiMethod - API方法,如 api.toggleTaskLike + * @param {string} contentId - 内容ID + * @param {HTMLElement} buttonEl - 按钮元素 + * @param {HTMLElement} countEl - 计数元素 + * @param {Object} currentUser - 当前用户 + * @returns {Promise} + */ +export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser) { + return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, { + actionPrefix: 'like', + countField: 'likes', + activeAction: 'liked', + activeColor: '#FF5722', + activeToastKey: 'post.liked', + warnLabel: '点赞' + }); +} + +/** + * 处理收藏按钮点击 + * @param {Function} apiMethod - API方法,如 api.toggleTaskFavorite + * @param {string} contentId - 内容ID + * @param {HTMLElement} buttonEl - 按钮元素 + * @param {HTMLElement} countEl - 计数元素 + * @param {Object} currentUser - 当前用户 + * @returns {Promise} + */ +export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser) { + return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, { + actionPrefix: 'favorite', + countField: 'favorites', + activeAction: 'favorited', + activeColor: '#FFC107', + activeToastKey: 'post.favorited', + activeTextColor: '#000', + inactiveTextColor: '#fff', + warnLabel: '收藏' + }); +} + +// ========================================== +// 🎁 打赏相关工具函数 +// ========================================== + +/** + * 生成打赏金额选择对话框的 HTML + * @param {Array} amounts - 金额选项,默认 [10, 50, 100, 500] + * @returns {string} HTML字符串 + */ +export function renderTipDialogHTML(amounts = [10, 50, 100, 500]) { + const amountButtons = amounts.map(amount => ` + + `).join(''); + + return ` +
+ ${amountButtons} +
+
+ +
+ `; +} + +/** + * 检查是否可以打赏(不能打赏给自己) + * @param {Object} currentUser - 当前用户 + * @param {string} targetAccount - 目标用户账号 + * @returns {boolean} + */ +export function canTip(currentUser, targetAccount) { + if (!currentUser) { + showToast(t('auth.login_required'), "warning"); + return false; + } + if (currentUser.account === targetAccount) { + showToast(t('post.tip_self'), "warning"); + return false; + } + return true; +} + +// ========================================== +// 🎁 渲染打赏榜单(重导出完整版,向后兼容) +// ========================================== +export { renderTipBoardHTML } from './打赏等级工具.js'; + +// ========================================== +// 🔒 HTML转义(通用工具) +// ========================================== + +/** + * HTML转义(统一版:各组件局部副本已归一到此) + * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参) + * @returns {string} 转义后的字符串 + */ +export function escapeHtml(str) { + if (str === null || str === undefined || str === "") return ""; + return String(str).replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * 读取用户设置中的列表缓存 TTL(统一版:讨论区/任务榜/提示词组件局部副本已归一到此) + * @returns {number} 毫秒数,默认30分钟 + */ +export function getCacheTTL() { + try { + const s = localStorage.getItem('ComfyCommunity_Settings'); + if (s) { const v = parseInt(JSON.parse(s).cacheExpireSeconds); if (v >= 60 && v <= 86400) return v * 1000; } + } catch(e) {} + return 1000 * 60 * 30; // 默认30分钟 +} + +/** + * 🕐 相对时间格式化(统一版:"刚刚/n分钟前/...",仅适用于相对时间语义, + * 绝对日期/本地化时间场景请勿复用) + * @param {number} timestamp - Unix 秒级时间戳 + * @returns {string} + */ +export function formatTime(timestamp) { + if (!timestamp) return ""; + const now = Date.now() / 1000; + const diff = now - timestamp; + if (diff < 60) return t('time.just_now'); + if (diff < 3600) return t('time.minutes_ago', { n: Math.floor(diff / 60) }); + if (diff < 86400) return t('time.hours_ago', { n: Math.floor(diff / 3600) }); + if (diff < 604800) return t('time.days_ago', { n: Math.floor(diff / 86400) }); + const date = new Date(timestamp * 1000); + return `${date.getMonth() + 1}/${date.getDate()}`; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" new file mode 100644 index 0000000000000000000000000000000000000000..fbb3720d7215367ed58365e9031b543930543c91 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" @@ -0,0 +1,138 @@ +// 前端页面/components/全局弹窗管理器.js + +export class ModalManager { + constructor() { + this.activeModals = []; // 用于存储当前打开的所有弹窗,实现层级管理 + this.overlay = null; + this.initOverlay(); + } + + // 初始化全局半透明遮罩层 + initOverlay() { + if (document.getElementById("community-hub-overlay")) return; + + this.overlay = document.createElement("div"); + this.overlay.id = "community-hub-overlay"; + Object.assign(this.overlay.style, { + position: "fixed", + top: "0", + left: "0", + width: "100vw", + height: "100vh", + backgroundColor: "rgba(0, 0, 0, 0.7)", + zIndex: "9998", // 位于 ComfyUI 画布之上,但低于侧边栏和弹窗 + display: "none", + backdropFilter: "blur(3px)" // 添加背景模糊效果 + }); + + // 点击遮罩层时,关闭最顶层的一个弹窗 + this.overlay.onclick = () => { + try { this.closeTopModal(); } catch(e) { console.warn('[ModalManager] closeTopModal error:', e); } + }; + + document.body.appendChild(this.overlay); + } + + /** + * 打开一个新的弹窗 + * @param {string} title 弹窗标题 + * @param {HTMLElement} contentElement 包含实际内容的 DOM 元素 + * @param {object} options 配置项 (如宽度) + */ + openModal(title, contentElement, options = { width: "500px" }) { + const modalContainer = document.createElement("div"); + Object.assign(modalContainer.style, { + position: "fixed", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: options.width, + backgroundColor: "var(--comfy-menu-bg)", + border: "1px solid #555", + borderRadius: "12px", + boxShadow: "0 10px 30px rgba(0,0,0,0.8)", + zIndex: (9999 + this.activeModals.length).toString(), // 动态计算 z-index,实现层级覆盖 + display: "flex", + flexDirection: "column", + overflow: "hidden" + }); + + // 弹窗头部 (包含标题和关闭按钮) + const header = document.createElement("div"); + Object.assign(header.style, { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "15px 20px", + borderBottom: "1px solid #444", + backgroundColor: "var(--comfy-input-bg)", + }); + + const titleSpan = document.createElement("span"); + titleSpan.innerText = title; + Object.assign(titleSpan.style, { + fontWeight: "bold", + fontSize: "16px", + color: "#fff" + }); + + const closeBtn = document.createElement("button"); + closeBtn.innerHTML = "✖"; + Object.assign(closeBtn.style, { + background: "transparent", + border: "none", + color: "#aaa", + cursor: "pointer", + fontSize: "14px" + }); + // 绑定关闭当前弹窗事件,防止事件冒泡触发底层点击 + closeBtn.onclick = (e) => { + e.stopPropagation(); + this.closeModal(modalContainer); + }; + + header.appendChild(titleSpan); + header.appendChild(closeBtn); + + // 弹窗内容区 + const contentWrapper = document.createElement("div"); + Object.assign(contentWrapper.style, { + padding: "20px", + maxHeight: "70vh", // 防止弹窗过高,限制最大高度 + overflowY: "auto", + color: "#eee" + }); + contentWrapper.appendChild(contentElement); + + modalContainer.appendChild(header); + modalContainer.appendChild(contentWrapper); + document.body.appendChild(modalContainer); + + // 记录状态并显示遮罩 + this.activeModals.push(modalContainer); + this.overlay.style.display = "block"; + } + + // 关闭指定的弹窗 + closeModal(modalElement) { + if (!modalElement) return; + modalElement.remove(); + this.activeModals = this.activeModals.filter(m => m !== modalElement); + + // 如果没有打开的弹窗了,隐藏遮罩层 + if (this.activeModals.length === 0) { + this.overlay.style.display = "none"; + } + } + + // 关闭位于最顶层的弹窗 (通常用于点击遮罩层或按 ESC 键时触发) + closeTopModal() { + if (this.activeModals.length > 0) { + const topModal = this.activeModals[this.activeModals.length - 1]; + this.closeModal(topModal); + } + } +} + +// 导出一个全局单例,确保整个应用只实例化一次 +export const globalModal = new ModalManager(); \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\350\256\276\347\275\256\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\350\256\276\347\275\256\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..2f914988d73b5fcd5d8ea9ab7e8e3c4db48a2ffa --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\350\256\276\347\275\256\347\273\204\344\273\266.js" @@ -0,0 +1,616 @@ +// 前端页面/components/全局设置组件.js +// ========================================== +// ⚙️ 全局设置组件(界面偏好设置) +// ========================================== +// 作用:提供全局界面设置入口,控制显示偏好 +// 关联文件: +// - 顶部导航组件.js (设置按钮入口) +// - 创作者卡片组件.js (背景图显示控制) +// ========================================== + +import { showToast } from "./UI交互提示组件.js"; +import { setLanguage, getLanguage, t } from "./用户体验增强.js"; +import { clearAllCache } from "./性能优化工具.js"; +import { IS_WEB_MODE } from "../core/全局配置.js"; + +// 🔧 设置项 localStorage Key +const SETTINGS_KEY = "ComfyCommunity_Settings"; + +// 🔧 默认设置值 +const DEFAULT_SETTINGS = { + showCreatorBanner: true, // 创作者卡片显示背景图 + enableAnimations: true, // ✨ 榜单切换动画 + enableSoundEffects: true, // 🔊 动画音效 + language: 'zh-CN', // 🌐 界面语言 + cacheExpireSeconds: 7200, // ⏱️ 缓存刷新间隔(秒),默认2小时 + // 🚀 后续可扩展更多设置项 + // compactMode: false, // 紧凑模式 + // darkMode: true, // 深色模式 +}; + +// 🌐 语言选项(双语显示) +const LANGUAGE_OPTIONS = [ + { value: 'zh-CN', label: '简体中文 / Simplified Chinese' }, + { value: 'en-US', label: 'English / 英语' } +]; + +/** + * 📦 获取当前设置 + */ +export function getSettings() { + try { + const saved = localStorage.getItem(SETTINGS_KEY); + if (saved) { + return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }; + } + } catch (e) {} + return { ...DEFAULT_SETTINGS }; +} + +/** + * 💾 保存设置 + */ +export function saveSettings(settings) { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + // 🔔 广播设置变更事件,让其他组件响应 + window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings })); + } catch (e) {} +} + +/** + * 📊 格式化文件大小 + */ +function formatSize(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log2(bytes) / 10); + const unitIndex = Math.min(i, units.length - 1); + const size = bytes / Math.pow(1024, unitIndex); + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +/** + * 📊 获取浏览器缓存统计 + */ +function getBrowserCacheStats() { + let totalSize = 0; + let count = 0; + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('ComfyRanking_')) { + const value = localStorage.getItem(key); + if (value) { + totalSize += new Blob([value]).size; + count++; + } + } + } + } catch (e) {} + return { count, totalSize }; +} + +/** + * ⚙️ 创建设置页面视图 + */ +export function createSettingsView() { + const settings = getSettings(); + + const container = document.createElement("div"); + Object.assign(container.style, { + display: "flex", + flexDirection: "column", + gap: "0", + color: "#ccc", + fontSize: "14px", + padding: "0", + overflowY: "auto", + flex: "1", + boxSizing: "border-box" + }); + + // 添加开关样式 + const styleSheet = document.createElement("style"); + styleSheet.textContent = ` + .setting-switch { + position: relative; + width: 44px; + height: 24px; + background: #444; + border-radius: 12px; + cursor: pointer; + transition: 0.3s; + flex-shrink: 0; + } + .setting-switch.active { + background: #4CAF50; + } + .setting-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #fff; + border-radius: 50%; + transition: 0.3s; + } + .setting-switch.active::after { + left: 22px; + } + .setting-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--border-color, #333); + transition: background 0.2s; + } + .setting-item:hover { + background: rgba(255,255,255,0.03); + } + .setting-select { + background: var(--comfy-input-bg); + border: 1px solid #444; + color: #fff; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + min-width: 200px; + outline: none; + transition: 0.2s; + } + .setting-select:hover { + border-color: #4CAF50; + } + .setting-select:focus { + border-color: #4CAF50; + box-shadow: 0 0 0 2px rgba(76,175,80,0.2); + } + .setting-select option { + background: var(--comfy-input-bg); + color: #fff; + padding: 8px; + } + .setting-number-input { + background: var(--comfy-input-bg); + border: 1px solid #444; + color: #fff; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + width: 80px; + outline: none; + transition: 0.2s; + text-align: center; + } + .setting-number-input:hover { + border-color: #4CAF50; + } + .setting-number-input:focus { + border-color: #4CAF50; + box-shadow: 0 0 0 2px rgba(76,175,80,0.2); + } + .setting-number-input::-webkit-inner-spin-button, + .setting-number-input::-webkit-outer-spin-button { + opacity: 0; + width: 0; + } + .setting-range-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, #4CAF50 0%, #333 0%); + outline: none; + cursor: pointer; + } + .setting-range-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #4CAF50; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0,0,0,0.4); + transition: transform 0.15s; + } + .setting-range-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); + } + .setting-range-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #4CAF50; + cursor: pointer; + border: none; + } + .cache-clear-btn { + flex: 1; + min-width: 120px; + background: rgba(156, 39, 176, 0.15); + border: 1px solid #9C27B0; + color: #ccc; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: 0.2s; + } + .cache-clear-btn:hover:not(:disabled) { + background: rgba(156, 39, 176, 0.3); + color: #fff; + } + .cache-clear-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `; + document.head.appendChild(styleSheet); + + container.innerHTML = ` + +
+ + + ⚙️ ${t('settings.title')} +
+ + +
+ + +
+ 🌐 ${t('settings.general')} +
+ + +
+
+
${t('settings.language')} / Language
+
${t('settings.language_desc')} / Select display language
+
+ +
+ + +
+ 📐 ${t('settings.display')} +
+ + +
+
+
${t('settings.creator_banner')}
+
${t('settings.creator_banner_desc')}
+
+
+
+ + +
+ ✨ ${t('settings.animation')} +
+ + +
+
+
${t('settings.animations')}
+
${t('settings.animations_desc')}
+
+
+
+ + +
+
+
${t('settings.sound')}
+
${t('settings.sound_desc')}
+
+
+
+ + +
+ ${t('settings.data_cache')} +
+ + +
+
+
${t('settings.cache_expire')}
+
${t('settings.cache_expire_desc')}
+
+
+
+ + + +
+
+ 💡 ${t('settings.cache_trigger_desc')} +
+
+
+ + +
+
+
${t('settings.cache_stats')}
+
${t('settings.cache_stats_hint')}
+
+
+
+ ${t('settings.no_cache')} +
+
+
+ + ${IS_WEB_MODE ? '' : ` + `} +
+
+ + +
+
🎨 ${t('settings.animation_preview')}
+
+ • ${t('settings.anim_cascade')}
+ • ${t('settings.anim_fan')}
+ • ${t('settings.anim_abyss')}
+ • ${t('settings.anim_dataflow')} +
+
+
+ + +
+
${t('settings.auto_save')}
+
+ `; + + // 🔌 绑定事件 + + // 返回按钮 + container.querySelector("#btn-back-settings").onclick = () => { + window.dispatchEvent(new CustomEvent("comfy-route-back")); + }; + + /** + * 通用开关绑定辅助函数 + * @param {string} selector - querySelector 选择器 + * @param {string} settingKey - settings 对象的字段名 + * @param {string} labelKey - i18n label key + * @param {string} [iconOn] - 开启时的图标前缀,默认 '✅' + */ + function _bindSwitchHandler(selector, settingKey, labelKey, iconOn = '✅') { + const switchEl = container.querySelector(selector); + switchEl.onclick = () => { + const newValue = !switchEl.classList.contains("active"); + switchEl.classList.toggle("active", newValue); + const currentSettings = getSettings(); + currentSettings[settingKey] = newValue; + saveSettings(currentSettings); + showToast( + newValue + ? `${iconOn} ${t(labelKey)} ${t('settings.enabled')}` + : `${t(labelKey)} ${t('settings.disabled')}`, + "success" + ); + }; + } + + // 🌐 下拉控制:界面语言 + const selectLanguage = container.querySelector("#select-language"); + selectLanguage.onchange = () => { + const newLang = selectLanguage.value; + + const currentSettings = getSettings(); + currentSettings.language = newLang; + saveSettings(currentSettings); + + // 同步到 i18n 模块 + setLanguage(newLang); + + const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang; + showToast(`🌐 ${t('settings.language_changed') || '语言已切换为'} ${langName.split(' / ')[0]}`, "success"); + + // 🔄 通过路由系统刷新,保持 activeInlineView 同步 + setTimeout(() => { + // 先返回主页 + window.dispatchEvent(new CustomEvent("comfy-route-back")); + // 再重新打开设置页 + setTimeout(() => { + const newSettingsView = createSettingsView(); + window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view: newSettingsView } })); + }, 50); + }, 100); + }; + + // 🎛️ 开关控制:创作者背景图 / 榜单切换动画 / 动画音效 + _bindSwitchHandler("#switch-creator-banner", "showCreatorBanner", "settings.creator_banner", "✅"); + _bindSwitchHandler("#switch-animations", "enableAnimations", "settings.animations", "✨"); + _bindSwitchHandler("#switch-sound-effects", "enableSoundEffects", "settings.sound", "🔊"); + + // ⏱️ 滑条+输入框联动:缓存刷新间隔 + const inputCacheExpire = container.querySelector("#input-cache-expire"); + const sliderCacheExpire = container.querySelector("#slider-cache-expire"); + const cacheExpireLabel = container.querySelector("#cache-expire-label"); + + // 格式化时间显示 + function formatCacheTime(seconds) { + if (seconds >= 3600) { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + } + return `${Math.floor(seconds / 60)}min`; + } + + // 更新滑条渐变背景 + function updateSliderTrack() { + const val = sliderCacheExpire.value; + const pct = ((val - 60) / (86400 - 60)) * 100; + sliderCacheExpire.style.background = `linear-gradient(to right, #4CAF50 ${pct}%, #333 ${pct}%)`; + } + + // 保存设置 + function saveCacheExpire(newValue) { + const currentSettings = getSettings(); + currentSettings.cacheExpireSeconds = newValue; + saveSettings(currentSettings); + cacheExpireLabel.textContent = formatCacheTime(newValue); + updateSliderTrack(); + } + + // 初始化显示 + cacheExpireLabel.textContent = formatCacheTime(parseInt(inputCacheExpire.value)); + updateSliderTrack(); + + // 滑条拖动 → 同步输入框 + 保存 + sliderCacheExpire.oninput = () => { + const val = parseInt(sliderCacheExpire.value); + inputCacheExpire.value = val; + cacheExpireLabel.textContent = formatCacheTime(val); + updateSliderTrack(); + }; + sliderCacheExpire.onchange = () => { + saveCacheExpire(parseInt(sliderCacheExpire.value)); + showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(parseInt(sliderCacheExpire.value))}`, "success"); + }; + + // 输入框修改 → 同步滑条 + 保存 + inputCacheExpire.onchange = () => { + let newValue = parseInt(inputCacheExpire.value); + if (isNaN(newValue) || newValue < 60) { newValue = 60; } + else if (newValue > 86400) { newValue = 86400; } + inputCacheExpire.value = newValue; + sliderCacheExpire.value = newValue; + saveCacheExpire(newValue); + showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(newValue)}`, "success"); + }; + + // 📊 缓存统计面板 + const cacheStatsPanel = container.querySelector("#cache-stats-panel"); + + async function loadCacheStats() { + // 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存 + if (IS_WEB_MODE) { + const browserStats = getBrowserCacheStats(); + cacheStatsPanel.innerHTML = ` +
+ ${t('settings.cache_browser')}:${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)} +
+ `; + return; + } + try { + const [diskRes, browserStats] = await Promise.all([ + fetch("/community_hub/cache/stats").then(r => r.ok ? r.json() : null).catch(() => null), + Promise.resolve(getBrowserCacheStats()) + ]); + + const imageCount = diskRes?.image_count || 0; + const imageSize = diskRes?.image_size || 0; + const videoCount = diskRes?.video_count || 0; + const videoSize = diskRes?.video_size || 0; + const browserCount = browserStats.count; + const browserSize = browserStats.totalSize; + + const hasAnyCache = imageCount > 0 || videoCount > 0 || browserCount > 0; + + if (!hasAnyCache) { + cacheStatsPanel.innerHTML = ` +
+ ${t('settings.no_cache')} +
+ `; + return; + } + + cacheStatsPanel.innerHTML = ` +
+ ${t('settings.cache_disk_images')}:${imageCount} ${t('settings.files')} · ${formatSize(imageSize)} +
+
+ ${t('settings.cache_disk_videos')}:${videoCount} ${t('settings.files')} · ${formatSize(videoSize)} +
+
+ ${t('settings.cache_browser')}:${browserCount} ${t('settings.files')} · ${formatSize(browserSize)} +
+ `; + } catch (e) { + cacheStatsPanel.innerHTML = ` +
+ ${t('settings.no_cache')} +
+ `; + } + } + + // 🧹 缓存清理按钮 + const btnClearBrowser = container.querySelector("#btn-clear-browser-cache"); + const btnClearDisk = container.querySelector("#btn-clear-disk-cache"); + const btnClearAll = container.querySelector("#btn-clear-all-cache"); + + async function handleClearCache(btn, action) { + const originalText = btn.textContent; + btn.textContent = t('settings.cache_clearing'); + btn.disabled = true; + + try { + if (action === 'browser' || action === 'all') { + clearAllCache(); + } + if (action === 'disk' || action === 'all') { + await fetch("/community_hub/cache/clear", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: "all" }) + }); + } + await loadCacheStats(); + showToast(t('settings.cache_cleared'), "success"); + } catch (e) { + showToast(t('settings.cache_cleared'), "success"); + await loadCacheStats(); + } finally { + btn.textContent = originalText; + btn.disabled = false; + } + } + + btnClearBrowser.onclick = () => handleClearCache(btnClearBrowser, 'browser'); + if (btnClearDisk) btnClearDisk.onclick = () => handleClearCache(btnClearDisk, 'disk'); + if (btnClearAll) btnClearAll.onclick = () => handleClearCache(btnClearAll, 'all'); + + // 进入设置页时自动加载缓存统计 + loadCacheStats(); + + return container; +} + +/** + * ⚙️ 打开设置页面(通过路由) + */ +export function openSettingsPage() { + const view = createSettingsView(); + window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } })); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..c64296efd79c7e021e736cfee2ed06f094255126 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" @@ -0,0 +1,296 @@ +// 前端页面/components/关于插件组件.js +import { api, request } from "../core/网络请求API.js"; +import { t } from "./用户体验增强.js"; + +// 🏷️ 版本配置缓存 +let cachedVersionConfig = null; + +// 清除版本配置缓存(管理员修改版本后调用) +export function clearVersionCache() { + cachedVersionConfig = null; +} + +// 🎨 卡片基础样式常量(authorSection 与 createCardSection 共享) +const CARD_BASE_STYLE = { + background: "rgba(255,255,255,0.03)", + border: "1px solid rgba(255,255,255,0.06)", + borderRadius: "10px", + padding: "16px" +}; + +// 获取版本配置 +async function getVersionConfig() { + if (cachedVersionConfig) return cachedVersionConfig; + + try { + const res = await request('/api/public/project-version'); + if (res?.data) { + cachedVersionConfig = res.data; + return cachedVersionConfig; + } + } catch (e) { + console.warn('获取版本配置失败:', e); + } + + // 默认值 + return { stage: 'beta', major: 2, minor: 0, patch: 0 }; +} + +// 生成版本字符串 +export function formatVersionString(config) { + const stageLabels = { + 'alpha': t('about.stage_alpha'), + 'beta': t('about.stage_beta'), + 'rc': t('about.stage_rc'), + 'stable': t('about.stage_stable') + }; + const version = `V${config.major}.${config.minor}.${config.patch}`; + const stageLabel = stageLabels[config.stage] || config.stage; + return `${version} ${stageLabel}`; +} + +// 获取阶段本地化名称 +export function getStageLabel(stage) { + const stageLabels = { + 'alpha': t('about.stage_alpha'), + 'beta': t('about.stage_beta'), + 'rc': t('about.stage_rc'), + 'stable': t('about.stage_stable') + }; + return stageLabels[stage] || stage; +} + +// ========================================== +// 🎨 创建关于页面独立视图 +// ========================================== +export function createAboutView(versionString, stageLabel) { + const container = document.createElement("div"); + Object.assign(container.style, { + display: "flex", + flexDirection: "column", + flex: "1", + overflowY: "auto", + color: "#ccc", + fontSize: "14px", + padding: "16px", + boxSizing: "border-box", + gap: "16px", + background: "transparent" + }); + + // ===== 1. Hero 品牌区 ===== + const heroSection = document.createElement("div"); + Object.assign(heroSection.style, { + background: "linear-gradient(180deg, rgba(0, 212, 170, 0.15) 0%, transparent 100%)", + borderRadius: "10px", + padding: "28px 20px", + textAlign: "center", + border: "1px solid rgba(255,255,255,0.06)" + }); + heroSection.innerHTML = ` +
${t('about.title')}
+
Community Hub
+
${versionString}
+
${t('about.subtitle')}
+ `; + + // ===== 2. 核心优势区 ===== + const advantageSection = createCardSection("✨ " + t('about.advantages_title'), ` +
+
+
🔗
+
${t('about.adv_native_title')}
+
${t('about.adv_native_desc')}
+
+
+
⚡
+
${t('about.adv_speed_title')}
+
${t('about.adv_speed_desc')}
+
+
+
💬
+
${t('about.adv_im_title')}
+
${t('about.adv_im_desc')}
+
+
+
🛡️
+
${t('about.adv_sandbox_title')}
+
${t('about.adv_sandbox_desc')}
+
+
+ `); + + // ===== 3. 主要功能区 ===== + const featureSection = createCardSection("🎯 " + t('about.features_title'), ` +
+
+
1.${t('about.feat_publish_title')}
+
${t('about.feat_publish_desc')}
+
+
+
2.${t('about.feat_social_title')}
+
${t('about.feat_social_desc')}
+
+
+
3.${t('about.feat_install_title')}
+
${t('about.feat_install_desc')}
+
+
+
4.${t('about.feat_download_title')}
+
${t('about.feat_download_desc')}
+
+
+
5.${t('about.feat_chat_title')}
+
${t('about.feat_chat_desc')}
+
+
+
6.${t('about.feat_account_title')}
+
${t('about.feat_account_desc')}
+
+
+
7.${t('about.feat_payment_title')}
+
${t('about.feat_payment_desc')}
+
+
+
8.${t('about.feat_protection_title')}
+
${t('about.feat_protection_desc')}
+
+
+ `); + + // ===== 4. 用户价值区 ===== + const userValueSection = createCardSection("👤 " + t('about.benefits_title'), ` +
+
+ 1. + ${t('about.benefit_1')} +
+
+ 2. + ${t('about.benefit_2')} +
+
+ 3. + ${t('about.benefit_3')} +
+
+ 4. + ${t('about.benefit_4')} +
+
+ 5. + ${t('about.benefit_5')} +
+
+ `); + + // ===== 5. 注意事项区 ===== + const warningSection = document.createElement("div"); + Object.assign(warningSection.style, { + background: "rgba(240, 160, 48, 0.05)", + borderRadius: "10px", + padding: "16px", + borderTop: "1px solid rgba(240, 160, 48, 0.15)", + borderRight: "1px solid rgba(240, 160, 48, 0.15)", + borderBottom: "1px solid rgba(240, 160, 48, 0.15)", + borderLeft: "3px solid #f0a030" + }); + warningSection.innerHTML = ` +
+ ⚠️ ${t('about.warning_title')} +
+
+
+ ${t('about.warning_login_title')}${t('about.warning_login_desc')} +
+
+ ${t('about.warning_account_title')}${t('about.warning_account_desc')} +
+
+ `; + + // ===== 6. 关于作者区 ===== + const authorSection = document.createElement("div"); + Object.assign(authorSection.style, { ...CARD_BASE_STYLE, textAlign: "center" }); + authorSection.innerHTML = ` +
${t('about.author_title')}
+ + Bilibili + +
+ ${t('about.author_thanks')} +
+ `; + + // ===== 返回按钮 ===== + const backBtn = document.createElement("button"); + Object.assign(backBtn.style, { + background: "rgba(51,51,51,0.8)", + border: "1px solid rgba(85,85,85,0.8)", + color: "#fff", + padding: "6px 14px", + borderRadius: "6px", + cursor: "pointer", + fontSize: "13px", + fontWeight: "bold", + display: "flex", + alignItems: "center", + gap: "6px", + boxShadow: "0 2px 4px rgba(0,0,0,0.3)", + width: "fit-content", + transition: "0.2s", + marginLeft: "14px", + marginTop: "14px", + marginBottom: "4px" + }); + backBtn.innerHTML = "⬅ " + t('common.back'); + backBtn.onmouseover = () => { + backBtn.style.background = "#4CAF50"; + backBtn.style.borderColor = "#4CAF50"; + }; + backBtn.onmouseout = () => { + backBtn.style.background = "rgba(51,51,51,0.8)"; + backBtn.style.borderColor = "rgba(85,85,85,0.8)"; + }; + backBtn.onclick = () => { + window.dispatchEvent(new CustomEvent("comfy-route-back")); + }; + + // 组装 + container.appendChild(backBtn); + container.appendChild(heroSection); + container.appendChild(advantageSection); + container.appendChild(featureSection); + container.appendChild(userValueSection); + container.appendChild(warningSection); + container.appendChild(authorSection); + + return container; +} + +// 辅助函数:创建带标题的卡片分区(titleHtml 支持纯文本或 HTML) +function createCardSection(titleHtml, contentHtml) { + const section = document.createElement("div"); + Object.assign(section.style, CARD_BASE_STYLE); + section.innerHTML = ` +
${titleHtml}
+ ${contentHtml} + `; + return section; +} + +// ========================================== +// 🚀 显示关于信息(通过路由系统) +// ========================================== +export async function showAboutInfo(currentUser) { + // 动态获取版本配置 + const versionConfig = await getVersionConfig(); + const versionString = formatVersionString(versionConfig); + const stageLabel = getStageLabel(versionConfig.stage); + + const view = createAboutView(versionString, stageLabel); + window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } })); +} + +// 导出版本获取函数供其他组件使用 +export { getVersionConfig }; diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" new file mode 100644 index 0000000000000000000000000000000000000000..2efe3deddded0812d86d983a8c63bbf1d8cfabd5 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" @@ -0,0 +1,524 @@ +// 前端页面/components/动画音效引擎.js +// ========================================== +// ✨ 榜单切换动画与音效引擎 +// ========================================== +// 功能:为各榜单提供科技感的切换动画和音效 +// 动画类型: +// - cascade: 工具/应用/推荐榜 - 瀑布式从上到下依次从左往右载入 +// - fan: 创作者榜 - 从下到上扇形载入 +// - abyss: 讨论区 - 伪3D效果,从深渊中汇聚 +// - dataflow: 任务榜 - 数据流效果,左右交替滑入 +// - tetris: 提示词 - 俄罗斯方块式逐个下坠堆积 +// ========================================== + +import { getSettings } from "./全局设置组件.js"; + +// ========================================== +// 🔊 音效管理 +// ========================================== + +// 音效类型定义(使用 Web Audio API 合成科技感音效) +let audioContext = null; + +// 音效参数配置表 +const SOUND_CONFIGS = { + whoosh: { type: 'sine', freqStart: 800, freqEnd: 200, duration: 0.15, gain: 0.12 }, + blip: { type: 'sine', freqStart: 800, freqEnd: 400, duration: 0.08, gain: 0.15 }, + charge: { type: 'sine', freqStart: 150, freqEnd: 450, duration: 0.45, gain: 0.03, + gainMid: 0.06, gainMidTime: 0.25, stopTime: 0.45 }, + pop: { type: 'sine', freqStart: 600, freqEnd: 400, duration: 0.08, gain: 0.1 }, +}; + +// 通用音效合成播放函数 +function _playSynthSound(ctx, config) { + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + const now = ctx.currentTime; + const dur = config.stopTime || config.duration; + oscillator.type = config.type; + oscillator.frequency.setValueAtTime(config.freqStart, now); + oscillator.frequency.exponentialRampToValueAtTime(config.freqEnd, now + config.duration); + gainNode.gain.setValueAtTime(config.gain, now); + if (config.gainMid !== undefined) { + gainNode.gain.linearRampToValueAtTime(config.gainMid, now + config.gainMidTime); + } + gainNode.gain.exponentialRampToValueAtTime(0.001, now + dur); + oscillator.start(now); + oscillator.stop(now + dur); +} + +// 🔧 懒加载 AudioContext(确保在用户交互后创建) +function getAudioContext() { + if (!audioContext && typeof AudioContext !== 'undefined') { + try { + audioContext = new AudioContext(); + } catch (e) { + console.warn('AudioContext 创建失败:', e); + return null; + } + } + return audioContext; +} + +// 🔒 恢复被挂起的 AudioContext(浏览器安全策略需要用户交互) +async function resumeAudioContext() { + const ctx = getAudioContext(); + if (ctx && ctx.state === 'suspended') { + try { + await ctx.resume(); + } catch (e) { + console.warn('AudioContext resume 失败:', e); + } + } + return ctx; +} + +/** + * 播放科技感音效 + * @param {string} type - 音效类型:'whoosh' | 'blip' | 'charge' | 'pop' + */ +export async function playSound(type = 'whoosh') { + const settings = getSettings(); + if (!settings.enableSoundEffects) return; + + // 🔒 确保 AudioContext 已恢复(浏览器安全策略) + const ctx = await resumeAudioContext(); + if (!ctx) return; + + try { + switch (type) { + case 'whoosh': + case 'blip': + case 'charge': + case 'pop': + if (SOUND_CONFIGS[type]) _playSynthSound(ctx, SOUND_CONFIGS[type]); + break; + } + } catch (e) { + console.warn('音效播放失败:', e); + } +} + +// ========================================== +// 🎬 动画样式注入 +// ========================================== + +let stylesInjected = false; + +function injectAnimationStyles() { + if (stylesInjected) return; + stylesInjected = true; + + const style = document.createElement('style'); + style.id = 'comfy-animation-engine-styles'; + + // 深渊方向动画配置(name: 方向名, tx: x偏移, ty: y偏移) + const ABYSS_DIRS = [ + { name: 'tl', tx: -100, ty: -100 }, + { name: 'tr', tx: 100, ty: -100 }, + { name: 'bl', tx: -100, ty: 100 }, + { name: 'br', tx: 100, ty: 100 }, + ]; + const abyssKeyframes = ABYSS_DIRS.map(d => + `@keyframes abyss-in-${d.name} { 0% { transform: perspective(800px) translateZ(-300px) translate(${d.tx}px, ${d.ty}px) scale(0.3); opacity: 0; filter: blur(8px); } 100% { transform: perspective(800px) translateZ(0) translate(0, 0) scale(1); opacity: 1; filter: blur(0); } }` + ).join('\n '); + const abyssClasses = ABYSS_DIRS.map(d => + `.anim-abyss-${d.name} { animation: abyss-in-${d.name} 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards; opacity: 0; }` + ).join('\n '); + + style.textContent = ` + /* ========================================== + * 🌊 瀑布式动画 (工具/应用/推荐榜) + * 从上到下依次从左往右载入 + * ========================================== */ + @keyframes cascade-in { + 0% { + opacity: 0; + transform: translateX(-30px) translateY(-20px) scale(0.9); + filter: blur(4px); + } + 60% { + opacity: 0.8; + transform: translateX(5px) translateY(0) scale(1.02); + filter: blur(0); + } + 100% { + opacity: 1; + transform: translateX(0) translateY(0) scale(1); + filter: blur(0); + } + } + + .anim-cascade { + animation: cascade-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + opacity: 0; + } + + /* ========================================== + * 🌀 扇形动画 (创作者榜) + * 从下到上扇形展开载入 + * ========================================== */ + @keyframes fan-in { + 0% { + opacity: 0; + transform: translateY(60px) rotate(-15deg) scale(0.8); + transform-origin: center bottom; + } + 50% { + opacity: 0.7; + transform: translateY(10px) rotate(5deg) scale(1.05); + } + 100% { + opacity: 1; + transform: translateY(0) rotate(0) scale(1); + } + } + + .anim-fan { + animation: fan-in 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; + opacity: 0; + } + + /* ========================================== + * 🕳️ 深渊汇聚动画 (讨论区) + * 伪3D效果,从四面八方汇聚 + * ========================================== */ + @keyframes abyss-in { + 0% { + opacity: 0; + transform: perspective(800px) translateZ(-300px) scale(0.3); + filter: blur(8px) brightness(2); + } + 40% { + opacity: 0.6; + filter: blur(2px) brightness(1.2); + } + 100% { + opacity: 1; + transform: perspective(800px) translateZ(0) scale(1); + filter: blur(0) brightness(1); + } + } + + /* 不同方向的起点 */ + @keyframes abyss-in-center { 0% { transform: perspective(800px) translateZ(-400px) scale(0.1); opacity: 0; filter: blur(10px) brightness(3); } 100% { transform: perspective(800px) translateZ(0) scale(1); opacity: 1; filter: blur(0) brightness(1); } } + ${abyssKeyframes} + + .anim-abyss { animation: abyss-in 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards; opacity: 0; } + ${abyssClasses} + .anim-abyss-center { animation: abyss-in-center 0.7s cubic-bezier(0.23, 1, 0.32, 1) forwards; opacity: 0; } + + /* ========================================== + * ⚡ 数据流动画 (任务榜) + * 电路板效果,左右交替滑入带电流 + * ========================================== */ + @keyframes dataflow-left { + 0% { + opacity: 0; + transform: translateX(-100%) skewX(-5deg); + box-shadow: -20px 0 30px rgba(33, 150, 243, 0.5); + } + 50% { + box-shadow: 0 0 20px rgba(33, 150, 243, 0.8), 0 0 40px rgba(33, 150, 243, 0.3); + } + 100% { + opacity: 1; + transform: translateX(0) skewX(0); + box-shadow: none; + } + } + + @keyframes dataflow-right { + 0% { + opacity: 0; + transform: translateX(100%) skewX(5deg); + box-shadow: 20px 0 30px rgba(255, 152, 0, 0.5); + } + 50% { + box-shadow: 0 0 20px rgba(255, 152, 0, 0.8), 0 0 40px rgba(255, 152, 0, 0.3); + } + 100% { + opacity: 1; + transform: translateX(0) skewX(0); + box-shadow: none; + } + } + + /* 扫描线效果 */ + @keyframes scanline { + 0% { background-position: 0 -100%; } + 100% { background-position: 0 200%; } + } + + .anim-dataflow-left { + animation: dataflow-left 0.45s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; + opacity: 0; + } + + .anim-dataflow-right { + animation: dataflow-right 0.45s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; + opacity: 0; + } + + /* 任务榜扫描线叠加效果 */ + .anim-dataflow-left::before, + .anim-dataflow-right::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 180deg, + transparent 0%, + rgba(33, 150, 243, 0.1) 50%, + transparent 100% + ); + background-size: 100% 200%; + animation: scanline 0.8s linear forwards; + pointer-events: none; + border-radius: inherit; + } + + /* ========================================== + * 🧱 俄罗斯方块堆积下坠动画 (提示词界面) + * 重力加速坠落 → 落地挤压回弹 → lock 闪光 + * ========================================== */ + @keyframes tetris-drop { + 0% { + opacity: 0; + transform: translateY(-130px) scaleY(1.06) scaleX(0.96); + box-shadow: 0 0 0 0 rgba(0, 188, 212, 0); + animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); /* 重力加速 */ + } + 12% { opacity: 1; } + 55% { + opacity: 1; + transform: translateY(0) scaleY(1) scaleX(1); + animation-timing-function: ease-out; + } + 68% { + /* 落地挤压 squash */ + transform: translateY(3px) scaleY(0.93) scaleX(1.05); + box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.55), 0 10px 22px rgba(0, 0, 0, 0.45); + } + 82% { + /* 轻微回弹 */ + transform: translateY(-4px) scaleY(1.02) scaleX(0.99); + box-shadow: 0 0 0 1px rgba(0, 188, 212, 0.3), 0 8px 18px rgba(0, 0, 0, 0.4); + } + 100% { + opacity: 1; + transform: none; + box-shadow: none; + } + } + + /* 落定瞬间顶部高光(lock flash) */ + @keyframes tetris-flash { + 0%, 55% { opacity: 0; } + 66% { opacity: 1; } + 100% { opacity: 0; } + } + + .anim-tetris { + animation: tetris-drop 0.62s forwards; + opacity: 0; + } + + .anim-tetris::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 3px; + background: linear-gradient(90deg, transparent, rgba(77, 208, 225, 0.9), transparent); + opacity: 0; + animation: tetris-flash 0.62s forwards; + animation-delay: inherit; /* 与主体动画的逐卡阶梯延迟同步 */ + pointer-events: none; + } + `; + document.head.appendChild(style); +} + +// ========================================== +// 🎮 动画应用函数 +// ========================================== + +/** + * 为卡片应用动画 + * @param {HTMLElement} card - 卡片元素 + * @param {string} animationType - 动画类型:'cascade' | 'fan' | 'abyss' | 'dataflow' | 'tetris' + * @param {number} index - 卡片索引(用于计算延迟) + * @param {number} totalVisible - 可见卡片总数 + * @param {boolean} silent - 是否静默(不播放音效),滚动补播场景使用 + */ +export function applyCardAnimation(card, animationType, index, totalVisible = 10, silent = false) { + const settings = getSettings(); + if (!settings.enableAnimations) return; + + // 确保样式已注入 + injectAnimationStyles(); + + // 只对可见范围内的卡片应用动画 + if (index >= totalVisible) { + card.style.opacity = '1'; + return; + } + + // 计算延迟时间(错开动画) + const baseDelay = 50; // 基础延迟 50ms + const delay = index * baseDelay; + + // 设置延迟 + card.style.animationDelay = `${delay}ms`; + + switch (animationType) { + case 'cascade': + // 瀑布式动画 + card.classList.add('anim-cascade'); + if (!silent && index === 0) playSound('whoosh'); + break; + + case 'fan': + // 扇形动画(从下往上,索引越小延迟越长,形成从下到上效果) + const fanDelay = (totalVisible - 1 - index) * baseDelay; + card.style.animationDelay = `${fanDelay}ms`; + card.classList.add('anim-fan'); + if (!silent && index === totalVisible - 1) playSound('whoosh'); + break; + + case 'abyss': + // 深渊汇聚动画(分配不同方向) + const directions = ['anim-abyss-tl', 'anim-abyss-tr', 'anim-abyss-bl', 'anim-abyss-br', 'anim-abyss-center']; + const direction = directions[index % directions.length]; + card.classList.add(direction); + if (!silent && index === 0) playSound('charge'); + break; + + case 'dataflow': + // 数据流动画(左右交替) + const isLeft = index % 2 === 0; + card.style.position = 'relative'; // 为扫描线效果 + card.classList.add(isLeft ? 'anim-dataflow-left' : 'anim-dataflow-right'); + if (!silent && index < 3) setTimeout(() => playSound('blip'), delay); + break; + + case 'tetris': + // 俄罗斯方块堆积下坠动画(逐个坠落 + 落地挤压回弹 + lock 闪光) + card.style.position = 'relative'; // 为 lock 闪光伪元素 + card.style.animationDelay = `${index * 90}ms`; // 90ms 逐卡间隔,堆积节奏感 + card.classList.add('anim-tetris'); + if (!silent && index < 3) setTimeout(() => playSound('blip'), index * 90 + 340); // 对齐落地瞬间 + break; + } + + // 动画结束后清理类名 + card.addEventListener('animationend', () => { + card.classList.remove( + 'anim-cascade', 'anim-fan', + 'anim-abyss', 'anim-abyss-tl', 'anim-abyss-tr', 'anim-abyss-bl', 'anim-abyss-br', 'anim-abyss-center', + 'anim-dataflow-left', 'anim-dataflow-right', 'anim-tetris' + ); + card.style.animationDelay = ''; + }, { once: true }); +} + +// ========================================== +// 👁️ 视口感知动画(滚动进入视口时补播) +// ========================================== + +// 容器 -> 观察器映射(同一视图的首次渲染与分页追加复用同一观察器) +const _viewportObservers = new WeakMap(); + +/** + * ✨ 视口感知卡片动画: + * - 已在视口内的卡片立即依次错开播放载入动画 + * - 屏幕外的卡片在滚动进入视口时自动补播动画(每张只播一次) + * - 基于原生 IntersectionObserver,无滚动监听开销,同一时刻仅少量卡片在动画,保证流畅度 + * @param {HTMLElement} container - 卡片容器(首次渲染与分页追加须为同一容器) + * @param {HTMLElement[]} cards - 待观察的卡片列表 + * @param {string} animationType - 动画类型 + * @param {boolean} isFirstBatch - 是否首次渲染批次(为 true 时清理旧视图残留的观察器) + */ +export function applyViewportAnimations(container, cards, animationType, isFirstBatch = false) { + const settings = getSettings(); + if (!settings.enableAnimations) return; + + // 确保样式已注入 + injectAnimationStyles(); + + // 视图重建:停止上一批残留卡片的补播观察 + if (isFirstBatch) { + const oldObserver = _viewportObservers.get(container); + if (oldObserver) { + oldObserver.disconnect(); + _viewportObservers.delete(container); + } + } + + let observer = _viewportObservers.get(container); + if (!observer) { + observer = new IntersectionObserver((entries) => { + // 本批进入视口的卡片,按文档顺序(从上到下)排列后错开播放 + const entering = entries + .filter(e => e.isIntersecting) + .sort((a, b) => + (a.target.compareDocumentPosition(b.target) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1 + ); + if (entering.length === 0) return; + + entering.forEach((entry, i) => { + observer.unobserve(entry.target); + // 仅入视图后首批播放音效,避免滚动时音效刷屏 + applyCardAnimation(entry.target, animationType, i, entering.length, observer._introPlayed); + }); + observer._introPlayed = true; + }, { threshold: 0.1 }); + _viewportObservers.set(container, observer); + } + + cards.forEach(card => observer.observe(card)); +} + +/** + * 获取榜单类型对应的动画类型 + * @param {string} tabId - 标签页ID + * @returns {string} 动画类型 + */ +export function getAnimationTypeForTab(tabId) { + const animationMap = { + 'tools': 'cascade', // 工具榜 + 'apps': 'cascade', // 应用榜 + 'recommends': 'cascade', // 推荐榜 + 'creators': 'fan', // 创作者榜 + 'posts': 'abyss', // 讨论区 + 'tasks': 'dataflow', // 任务榜 + 'prompts': 'tetris' // 提示词界面 + }; + return animationMap[tabId] || 'cascade'; +} + +/** + * 初始化动画引擎(在应用启动时调用) + */ +export function initAnimationEngine() { + injectAnimationStyles(); + + // 🔒 在用户首次交互时预热 AudioContext + const warmupAudio = () => { + resumeAudioContext(); + document.removeEventListener('click', warmupAudio); + document.removeEventListener('keydown', warmupAudio); + }; + document.addEventListener('click', warmupAudio, { once: true }); + document.addEventListener('keydown', warmupAudio, { once: true }); +} + +// 自动初始化 +initAnimationEngine(); diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" new file mode 100644 index 0000000000000000000000000000000000000000..aa3b14d91270b595d7968f0ecfd8f9c83399aba7 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" @@ -0,0 +1,1596 @@ +// 前端页面/components/图片展示测试.js +// ========================================== +// 🖼️ 全屏图像播放器组件 +// ========================================== +// 功能:沉浸式全屏图片浏览,支持方块聚合载入 + 粒子消散载出动画 +// 接口:openImageViewer(imageUrls, startIndex, options) +// ========================================== + +// ========================================== +// 🎨 CSS样式定义(CSS-in-JS模式) +// ========================================== +const VIEWER_STYLES = ` +/* 全屏遮罩层 */ +.cyber-image-viewer { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #0a0a0a; + z-index: 99999; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: 'Segoe UI', 'PingFang SC', sans-serif; +} + +/* 图片主容器 */ +.cyber-image-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + padding: 80px 80px 120px 80px; + /* 移除 overflow: hidden 以允许动画超出容器 */ + overflow: visible; +} + +/* 图片包装器 - 用于方块动画 */ +.cyber-image-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + /* 移除 overflow: hidden 以允许动画超出容器 */ + overflow: visible; +} + +/* 当前显示的图片 - 初始隐藏,动画完成后显示 */ +.cyber-main-image { + max-width: 85vw; + max-height: 70vh; + object-fit: contain; + opacity: 0; + visibility: hidden; + /* 移除默认transition,由JS精确控制 */ + transition: none; + will-change: opacity, visibility; + user-select: none; + -webkit-user-drag: none; + /* 确保图片在方块容器之下,让方块动画可见 */ + position: relative; + z-index: 1; +} + +.cyber-main-image.visible { + opacity: 1; + visibility: visible; +} + +/* 方块动画容器 */ +.cyber-blocks-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: grid; + pointer-events: none; + /* z-index必须高于图片,确保方块动画可见 */ + z-index: 20; + /* 确保方块容器在wrapper中正确居中定位 */ + box-sizing: border-box; + /* overflow: visible 允许方块从容器外飞入 */ + overflow: visible; +} + +/* 单个方块 */ +.cyber-block { + will-change: transform, opacity; + backface-visibility: hidden; +} + +/* 粒子消散容器 */ +.cyber-particles-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + /* z-index必须高于图片,确保粒子动画可见 */ + z-index: 20; +} + +/* 单个粒子 */ +.cyber-particle { + position: absolute; + will-change: transform, opacity; + backface-visibility: hidden; +} + +/* 简约加载动画 */ +.cyber-loader { + position: absolute; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.cyber-loader::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + border: 3px solid rgba(255, 255, 255, 0.2); + border-radius: 50%; + border-top-color: #fff; + animation: loaderSpin 0.8s linear infinite; +} + +@keyframes loaderSpin { + to { transform: rotate(360deg); } +} + +/* 关闭按钮 */ +.cyber-close-btn { + position: absolute; + top: 20px; + right: 20px; + width: 44px; + height: 44px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 8px; + color: #fff; + font-size: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + z-index: 20; +} + +.cyber-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} + +/* 左右切换区域 */ +.cyber-nav-zone { + position: absolute; + top: 0; + width: 15%; + height: 100%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 15; +} + +.cyber-nav-zone:hover { + opacity: 1; +} + +.cyber-nav-zone.left { + left: 0; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%); +} + +.cyber-nav-zone.right { + right: 0; + background: linear-gradient(-90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%); +} + +/* 导航箭头 */ +.cyber-nav-arrow { + width: 50px; + height: 50px; + border: 2px solid rgba(255, 255, 255, 0.4); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 20px; + transition: all 0.3s ease; + background: rgba(0, 0, 0, 0.3); +} + +.cyber-nav-zone:hover .cyber-nav-arrow { + transform: scale(1.1); + background: rgba(255, 255, 255, 0.1); +} + +/* 底部信息栏 */ +.cyber-info-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 20px 40px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 100%); + display: flex; + align-items: center; + justify-content: space-between; + z-index: 20; +} + +/* 图片计数器 */ +.cyber-counter { + font-size: 14px; + color: #fff; + font-family: 'Consolas', monospace; + letter-spacing: 2px; +} + +.cyber-counter .current { + font-size: 24px; + font-weight: bold; +} + +.cyber-counter .total { + color: rgba(255, 255, 255, 0.6); +} + +/* 缩略图导航条 */ +.cyber-thumbnails { + display: flex; + gap: 8px; + align-items: center; +} + +.cyber-thumb { + width: 50px; + height: 35px; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.3s ease; + opacity: 0.5; +} + +.cyber-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cyber-thumb:hover { + opacity: 0.8; + transform: scale(1.05); +} + +.cyber-thumb.active { + border-color: #fff; + opacity: 1; +} + +/* 键盘提示 */ +.cyber-keyboard-hint { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + display: flex; + gap: 15px; +} + +.cyber-keyboard-hint span { + display: flex; + align-items: center; + gap: 4px; +} + +.cyber-key { + background: rgba(255, 255, 255, 0.1); + padding: 2px 8px; + border-radius: 4px; + font-family: monospace; +} + +/* 自动播放按钮 */ +.cyber-autoplay-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 8px; + width: 40px; + height: 40px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + color: #fff; + margin-right: 15px; +} + +.cyber-autoplay-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} + +.cyber-autoplay-btn.active { + background: rgba(0, 255, 150, 0.2); + color: #00ff96; +} + +.cyber-autoplay-btn svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +/* 图片加载错误状态 */ +.cyber-error-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + color: #ff6b6b; +} + +.cyber-error-state svg { + width: 60px; + height: 60px; + opacity: 0.5; +} + +/* 预加载占位 */ +.cyber-preload { + position: absolute; + opacity: 0; + pointer-events: none; + z-index: -1; +} +`; + +// ========================================== +// 🚀 核心功能实现 +// ========================================== + +/** + * 计算动画网格布局(方块和粒子动画共用) + * @param {number} displayWidth - 显示区域宽度 + * @param {number} displayHeight - 显示区域高度 + * @param {number} maxGridSize - 较长边最多分格数 + * @returns {{ rows: number, cols: number, cellSize: number }} + */ +function _calcGridLayout(displayWidth, displayHeight, maxGridSize) { + const initialCellSize = Math.max(displayWidth, displayHeight) / maxGridSize; + const cols = Math.min(Math.ceil(displayWidth / initialCellSize), maxGridSize); + const rows = Math.min(Math.ceil(displayHeight / initialCellSize), maxGridSize); + // 反算实际单元尺寸:取两个方向中较大的值,确保 1:1 并完全覆盖 + const cellSize = Math.max(displayWidth / cols, displayHeight / rows); + return { rows, cols, cellSize }; +} + +/** + * 图片查看器状态管理 + */ +class CyberImageViewer { + constructor(imageUrls, startIndex = 0) { + // 处理URL,确保使用本地缓存代理 + this.imageUrls = imageUrls.map(url => this._proxyImageUrl(url)); + this.currentIndex = Math.max(0, Math.min(startIndex, this.imageUrls.length - 1)); + this.preloadCache = new Map(); + this.isAnimating = false; + this.container = null; + this.elements = {}; + // 动画ID计数器,用于快速点击中断动画 + this._animationId = 0; + // 自动播放状态管理 + this._autoPlayEnabled = false; + this._autoPlayTimerId = null; + this._autoPlayInterval = 1000; // 默认1秒展示时间 + this._autoPlayStateChangeCallbacks = []; // 状态变化回调 + } + + /** + * 将URL转换为本地缓存代理格式 + * 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL + */ + _proxyImageUrl(url) { + if (!url) return ''; + // data: URL 和 blob: URL 直接使用不转换 + if (url.startsWith('data:') || url.startsWith('blob:')) { + return url; + } + // 如果已经是代理格式,直接返回 + if (url.startsWith('/community_hub/image?url=')) { + return url; + } + // 如果是HTTP/HTTPS链接,添加代理前缀 + if (url.startsWith('http')) { + return `/community_hub/image?url=${encodeURIComponent(url)}`; + } + return url; + } + + /** + * 创建查看器DOM结构 + * @param {Object} options - 配置选项 + * @param {string} options.background - 背景色,默认 '#0a0a0a' + * @param {boolean} options.opaque - 是否不透明背景,默认true + */ + create(options = {}) { + const { background = '#0a0a0a', opaque = true } = options; + + // 注入样式 + if (!document.getElementById('cyber-viewer-styles')) { + const styleEl = document.createElement('style'); + styleEl.id = 'cyber-viewer-styles'; + styleEl.textContent = VIEWER_STYLES; + document.head.appendChild(styleEl); + } + + // 创建主容器 + this.container = document.createElement('div'); + this.container.className = 'cyber-image-viewer'; + this.container.style.background = background; + + this.container.innerHTML = ` + + + + +
+
+ +
+
+
+ + +
+
❮
+
+
+
❯
+
+ + +
+
+ 1 + / + ${this.imageUrls.length} +
+
+
+ +
+ ← 上一张 + → 下一张 + ESC 关闭 +
+
+
+ + +
+ `; + + // 缓存DOM元素引用 + this.elements = { + mainImage: this.container.querySelector('.cyber-main-image'), + wrapper: this.container.querySelector('.cyber-image-wrapper'), + loader: this.container.querySelector('.cyber-loader'), + counter: this.container.querySelector('.cyber-counter'), + counterCurrent: this.container.querySelector('.cyber-counter .current'), + thumbnails: this.container.querySelector('.cyber-thumbnails'), + preloadContainer: this.container.querySelector('.cyber-preload'), + navLeft: this.container.querySelector('.cyber-nav-zone.left'), + navRight: this.container.querySelector('.cyber-nav-zone.right'), + closeBtn: this.container.querySelector('.cyber-close-btn'), + autoPlayBtn: this.container.querySelector('.cyber-autoplay-btn'), + autoPlayIconPlay: this.container.querySelector('.autoplay-icon-play'), + autoPlayIconPause: this.container.querySelector('.autoplay-icon-pause') + }; + + // 绑定事件 + this._bindEvents(); + + // 渲染缩略图 + this._renderThumbnails(); + + // 加载初始图片 + this._loadImage(this.currentIndex); + + return this.container; + } + + /** + * 设置自动播放间隔时间(毫秒) + * @param {number} interval - 间隔时间(毫秒) + */ + setAutoPlayInterval(interval) { + this._autoPlayInterval = Math.max(2000, Math.min(interval, 30000)); // 限制2-30秒 + } + + /** + * 获取自动播放状态 + * @returns {boolean} 是否正在自动播放 + */ + getAutoPlayEnabled() { + return this._autoPlayEnabled; + } + + /** + * 切换自动播放状态 + */ + toggleAutoPlay() { + if (this._autoPlayEnabled) { + this._stopAutoPlay(); + } else { + this._startAutoPlay(); + } + } + + /** + * 开始自动播放 + */ + _startAutoPlay() { + if (this._autoPlayEnabled) return; + + console.log('▶️ 自动播放开始'); + this._autoPlayEnabled = true; + this._updateAutoPlayUI(); + + // 如果当前没有动画,立即开始第一张图片的计时 + if (!this.isAnimating) { + this._scheduleNextAutoPlay(); + } + } + + /** + * 停止自动播放 + */ + _stopAutoPlay() { + if (!this._autoPlayEnabled) return; + + console.log('⏸️ 自动播放停止'); + this._autoPlayEnabled = false; + + // 清除定时器 + if (this._autoPlayTimerId) { + clearTimeout(this._autoPlayTimerId); + this._autoPlayTimerId = null; + } + + this._updateAutoPlayUI(); + } + + /** + * 更新自动播放按钮UI状态 + */ + _updateAutoPlayUI() { + const btn = this.elements.autoPlayBtn; + const playIcon = this.elements.autoPlayIconPlay; + const pauseIcon = this.elements.autoPlayIconPause; + + if (this._autoPlayEnabled) { + btn.classList.add('active'); + btn.title = '暂停自动播放 (空格)'; + playIcon.style.display = 'none'; + pauseIcon.style.display = 'block'; + } else { + btn.classList.remove('active'); + btn.title = '自动播放 (空格)'; + playIcon.style.display = 'block'; + pauseIcon.style.display = 'none'; + } + + // 触发状态变化回调 + this._autoPlayStateChangeCallbacks.forEach(cb => cb(this._autoPlayEnabled)); + } + + /** + * 注册自动播放状态变化回调 + * @param {Function} callback - 回调函数,参数为新的自动播放状态 + */ + onAutoPlayStateChange(callback) { + this._autoPlayStateChangeCallbacks.push(callback); + } + + /** + * 调度下一次自动播放 + * 自动播放流程:等待展示时间 → 触发载出动画 → 等待1秒 → 切换到下一张 → 触发载入动画 → 重复 + */ + _scheduleNextAutoPlay() { + // 清除之前的定时器 + if (this._autoPlayTimerId) { + clearTimeout(this._autoPlayTimerId); + this._autoPlayTimerId = null; + } + + if (!this._autoPlayEnabled) return; + + const currentAnimId = this._animationId; + + console.log(`⏱️ 自动播放:等待 ${this._autoPlayInterval}ms 后切换`); + + this._autoPlayTimerId = setTimeout(() => { + // 检查动画ID守卫 + if (currentAnimId !== this._animationId) { + console.log('⛔ 自动播放定时器:动画已被中断,跳过本次切换'); + return; + } + + // 检查是否还在自动播放状态 + if (!this._autoPlayEnabled) { + console.log('⛔ 自动播放定时器:自动播放已停止,跳过本次切换'); + return; + } + + // 检查是否正在动画中 + if (this.isAnimating) { + console.log('⏳ 自动播放:当前正在动画中,等待下次调度'); + this._scheduleNextAutoPlay(); + return; + } + + console.log('🔄 自动播放:执行切换'); + + // 执行切换(使用屏幕中心作为波浪起点) + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + + // 使用 _autoPlayNext 方法进行切换,它会处理循环和动画完成后的调度 + this._autoPlayNext(centerX, centerY); + + }, this._autoPlayInterval); + } + + /** + * 自动播放下一张(内部方法) + * 流程:粒子消散(载出)→ 等待1秒 → 新图片方块合并(载入)→ 调度下一次 + */ + async _autoPlayNext(clickX, clickY) { + if (!this._autoPlayEnabled) return; + + // 递增动画ID,用于中断检测 + this._animationId++; + const currentAnimId = this._animationId; + + // 计算下一张索引(循环播放) + const nextIndex = this.currentIndex < this.imageUrls.length - 1 ? this.currentIndex + 1 : 0; + + console.log('🔄 自动播放切换到图片索引:', nextIndex); + this.isAnimating = true; + + // 1. 执行粒子消散动画(载出) + await this._playParticleDisperseAnimation(clickX, clickY); + + // 检查是否被中断 + if (currentAnimId !== this._animationId) return; + + // 2. 载出动画完全结束后,立即切换下一张(不等待) + // await new Promise(resolve => setTimeout(resolve, 0)); + + // 再次检查 + if (currentAnimId !== this._animationId) return; + if (!this._autoPlayEnabled) return; + + // 3. 更新索引并加载新图片(这会触发方块合并动画) + this.currentIndex = nextIndex; + await this._loadImage(this.currentIndex); + + // 4. 动画完成后,调度下一次自动播放 + // 使用方块动画完成后的回调来确保时序正确 + this._waitForBlockAnimationAndSchedule(currentAnimId); + } + + /** + * 等待方块动画完成后调度下一次自动播放 + * @param {number} expectedAnimId - 期望的动画ID + */ + _waitForBlockAnimationAndSchedule(expectedAnimId) { + const checkAndSchedule = () => { + // 检查动画ID守卫 + if (expectedAnimId !== this._animationId) { + console.log('⛔ 自动播放等待:动画已被中断'); + return; + } + + // 检查是否还在自动播放状态 + if (!this._autoPlayEnabled) { + console.log('⛔ 自动播放等待:自动播放已停止'); + return; + } + + // 如果动画还在进行,继续等待 + if (this.isAnimating) { + setTimeout(checkAndSchedule, 100); + return; + } + + // 动画完成,调度下一次 + console.log('✅ 自动播放:图片展示完成,调度下一次'); + this._scheduleNextAutoPlay(); + }; + + // 开始检查 + checkAndSchedule(); + } + + /** + * 绑定事件处理 + */ + _bindEvents() { + // 键盘导航 + this._keydownHandler = (e) => { + if (e.key === 'Escape') { + this.close(); + } else if (e.key === 'ArrowLeft') { + // 键盘导航使用屏幕中心作为波浪起点 + this._stopAutoPlay(); // 手动切换时停止自动播放 + this.prev(window.innerWidth / 2, window.innerHeight / 2); + } else if (e.key === 'ArrowRight') { + // 键盘导航使用屏幕中心作为波浪起点 + this._stopAutoPlay(); // 手动切换时停止自动播放 + this.next(window.innerWidth / 2, window.innerHeight / 2); + } else if (e.key === ' ') { + // 空格键切换自动播放状态 + e.preventDefault(); // 阻止页面滚动 + this.toggleAutoPlay(); + } + }; + document.addEventListener('keydown', this._keydownHandler); + + // 点击导航区域 - 传递点击位置用于波浪效果 + this.elements.navLeft.addEventListener('click', (e) => { + this._stopAutoPlay(); // 手动切换时停止自动播放 + this.prev(e.clientX, e.clientY); + }); + this.elements.navRight.addEventListener('click', (e) => { + this._stopAutoPlay(); // 手动切换时停止自动播放 + this.next(e.clientX, e.clientY); + }); + + // 关闭按钮 + this.elements.closeBtn.addEventListener('click', () => this.close()); + + // 自动播放按钮 + this.elements.autoPlayBtn.addEventListener('click', () => this.toggleAutoPlay()); + + // 阻止右键菜单 + this.container.addEventListener('contextmenu', (e) => e.preventDefault()); + + // 阻止拖拽 + this.elements.mainImage.addEventListener('dragstart', (e) => e.preventDefault()); + } + + /** + * 渲染缩略图导航 + */ + _renderThumbnails() { + // 最多显示7个缩略图 + const maxThumbs = Math.min(this.imageUrls.length, 7); + + for (let i = 0; i < maxThumbs; i++) { + const thumb = document.createElement('div'); + thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : ''); + thumb.innerHTML = ``; + thumb.addEventListener('click', (e) => { + this._stopAutoPlay(); // 手动切换时停止自动播放 + this.goTo(i, e.clientX, e.clientY); + }); + this.elements.thumbnails.appendChild(thumb); + } + + // 如果图片太多,显示省略号 + if (this.imageUrls.length > maxThumbs) { + const more = document.createElement('div'); + more.style.cssText = 'color: rgba(255,255,255,0.4); font-size: 12px; padding: 0 5px;'; + more.textContent = `+${this.imageUrls.length - maxThumbs}`; + this.elements.thumbnails.appendChild(more); + } + } + + /** + * 更新缩略图状态 + */ + _updateThumbnails() { + const thumbs = this.elements.thumbnails.querySelectorAll('.cyber-thumb'); + thumbs.forEach((thumb, index) => { + thumb.classList.toggle('active', index === this.currentIndex); + }); + } + + /** + * 加载指定索引的图片 + */ + async _loadImage(index) { + if (index < 0 || index >= this.imageUrls.length) return; + + const url = this.imageUrls[index]; + const img = this.elements.mainImage; + const loader = this.elements.loader; + + // 隐藏当前图片,准备加载新图片(同步立即隐藏,无过渡) + img.style.transition = 'none'; + img.style.opacity = '0'; + img.style.visibility = 'hidden'; + img.classList.remove('visible'); + loader.style.display = 'flex'; + + // 检查缓存 + if (this.preloadCache.has(url)) { + const cachedImg = this.preloadCache.get(url); + img.src = cachedImg.src; + this._onImageLoaded(); + } else { + // 加载图片 + try { + await this._loadImageAsync(url); + img.src = url; + this._onImageLoaded(); + } catch (err) { + console.error('图片加载失败:', err); + this._onImageError(); + } + } + + // 更新计数器 + this.elements.counterCurrent.textContent = index + 1; + this._updateThumbnails(); + + // 预加载相邻图片 + this._preloadAdjacent(index); + } + + /** + * 异步加载图片 + */ + _loadImageAsync(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + console.log('📥 开始加载图片:', url?.substring(0, 100) + '...'); + img.onload = () => { + console.log('✅ 图片加载成功:', url?.substring(0, 100) + '...'); + resolve(img); + }; + img.onerror = (err) => { + console.error('❌ 图片加载失败:', url?.substring(0, 100) + '...', err); + reject(new Error('图片加载失败')); + }; + img.src = url; + }); + } + + /** + * 图片加载完成回调 - 触发方块合并动画 + */ + _onImageLoaded() { + requestAnimationFrame(() => { + this.elements.loader.style.display = 'none'; + // 确保图片初始隐藏,不要在这里显示图片(同步立即隐藏,无过渡) + const img = this.elements.mainImage; + img.style.transition = 'none'; + img.style.opacity = '0'; + img.style.visibility = 'hidden'; + img.classList.remove('visible'); + // 强制同步布局,确保隐藏立即生效 + img.getBoundingClientRect(); + // 执行方块合并载入动画 + this._playBlockAssemblyAnimation(); + + // 如果处于自动播放状态,在方块动画完成后调度下一次 + if (this._autoPlayEnabled) { + this._waitForBlockAnimationAndSchedule(this._animationId); + } + }); + } + + /** + * 方块合并载入动画 - 将图片分割成方块从随机位置飞入拼合(载出动画的倒放) + * 使用与粒子消散相同的网格参数和背景图裁切方式 + */ + _playBlockAssemblyAnimation() { + const currentAnimId = this._animationId; // 记录当前动画ID + const img = this.elements.mainImage; + const wrapper = this.elements.wrapper; + const imgSrc = img.src; + + // 调试日志 + console.log('🎬 方块聚合动画开始 - 图片URL:', imgSrc?.substring(0, 100) + '...'); + + // 获取wrapper的实际尺寸 + const rect = wrapper.getBoundingClientRect(); + console.log('📐 Wrapper尺寸:', rect.width, 'x', rect.height); + + // 获取图片实际显示尺寸 + const imgWidth = img.naturalWidth || rect.width || 800; + const imgHeight = img.naturalHeight || rect.height || 600; + console.log('🖼️ 图片原始尺寸:', imgWidth, 'x', imgHeight); + + // 安全检查:如果图片尺寸无效,显示错误 + if (!imgWidth || !imgHeight || imgWidth <= 0 || imgHeight <= 0) { + console.error('❌ 图片尺寸无效,无法播放动画'); + // 直接显示原图,不播放动画 + img.style.visibility = 'visible'; + img.style.opacity = '1'; + img.classList.add('visible'); + this.isAnimating = false; + return; + } + + // 计算适应容器的显示尺寸 + const containerWidth = rect.width || imgWidth; + const containerHeight = rect.height || imgHeight; + const scale = Math.min(containerWidth / imgWidth, containerHeight / imgHeight, 1); + const displayWidth = imgWidth * scale; + const displayHeight = imgHeight * scale; + console.log('📺 显示尺寸:', displayWidth, 'x', displayHeight, '缩放比例:', scale); + + // 网格配置 - 最多 9x9 = 81 个正方形方块 + const maxGridSize = 9; + const { rows, cols, cellSize: blockSide } = _calcGridLayout(displayWidth, displayHeight, maxGridSize); + const totalBlocks = cols * rows; + const blockWidth = blockSide; + const blockHeight = blockSide; // 1:1 正方形 + + // 创建方块容器 - 使用绝对定位而不是grid,确保精确控制每个方块位置 + const blocksContainer = document.createElement('div'); + blocksContainer.className = 'cyber-blocks-container'; + blocksContainer.style.width = `${displayWidth}px`; + blocksContainer.style.height = `${displayHeight}px`; + blocksContainer.style.left = `${(containerWidth - displayWidth) / 2}px`; + blocksContainer.style.top = `${(containerHeight - displayHeight) / 2}px`; + + console.log('🔲 方块容器位置:', blocksContainer.style.left, 'x', blocksContainer.style.top); + console.log('🔲 方块尺寸:', blockWidth, 'x', blockHeight, '数量:', totalBlocks); + + const blocks = []; + + // 创建方块 - 使用与粒子消散相同的背景图裁切方式 + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const block = document.createElement('div'); + block.className = 'cyber-block'; + block.style.position = 'absolute'; + block.style.width = `${blockWidth}px`; + block.style.height = `${blockHeight}px`; + block.style.left = `${c * blockSide}px`; + block.style.top = `${r * blockSide}px`; + + // 使用与粒子消散相同的背景图设置方式 + // 注意:URL已经由_proxyImageUrl处理过,直接使用,不要再次编码 + block.style.backgroundImage = `url("${imgSrc}")`; + block.style.backgroundSize = `${displayWidth}px ${displayHeight}px`; + // 背景定位:根据正方形方块的实际位置计算 + const bgPosX = c * blockSide; + const bgPosY = r * blockSide; // 使用 blockSide(正方形边长)计算Y轴位置 + block.style.backgroundPosition = `-${bgPosX}px -${bgPosY}px`; + block.style.backgroundRepeat = 'no-repeat'; + + // 初始状态:从完全不可见的点开始(scale=0) + // 使用整个视口作为动画舞台,方块可以从屏幕边缘甚至屏幕外飞入 + const randomX = (Math.random() - 0.5) * window.innerWidth * 1.5; + const randomY = (Math.random() - 0.5) * window.innerHeight * 1.5; + const randomRotate = (Math.random() - 0.5) * 360; + const randomDelay = Math.random() * 300; // 0-300ms随机延迟 + + // 设置初始transform(从完全不可见的点开始) + block.style.transform = `translate(${randomX}px, ${randomY}px) rotate(${randomRotate}deg) scale(0)`; + block.style.opacity = '0'; + block.style.transition = `all ${0.5 + Math.random() * 0.4}s cubic-bezier(0.15, 0.46, 0.45, 0.94) ${randomDelay}ms`; + + // 确保没有任何边框效果 + block.style.border = 'none'; + block.style.outline = 'none'; + block.style.boxShadow = 'none'; + block.style.borderRadius = '0'; + + blocksContainer.appendChild(block); + blocks.push({ element: block, delay: randomDelay, row: r, col: c }); + } + } + + // 确保原始img隐藏,让方块动画可见(同步操作,在方块创建前完成) + img.style.transition = 'none'; + img.style.opacity = '0'; + img.style.visibility = 'hidden'; + img.classList.remove('visible'); + // 强制同步布局,确保隐藏立即生效 + img.getBoundingClientRect(); + + wrapper.appendChild(blocksContainer); + console.log('✅ 方块容器已添加到wrapper'); + + // 强制同步布局,确保初始状态被应用 + blocksContainer.getBoundingClientRect(); + + // 触发方块聚合动画 - 使用双重requestAnimationFrame确保浏览器已渲染初始状态 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // 检查动画是否已被中断 + if (currentAnimId !== this._animationId) { + console.log('⛔ 方块聚合动画被中断,跳过动画触发'); + return; + } + console.log('🎭 开始方块聚合动画'); + blocks.forEach(({ element }) => { + // 目标状态:聚合到正确位置 + element.style.transform = 'translate(0, 0) rotate(0deg) scale(1)'; + element.style.opacity = '1'; + }); + }); + }); + + // 计算动画完成时间 + const maxDelay = Math.max(...blocks.map(b => b.delay)); + const animationDuration = 900 + maxDelay; + console.log('⏱️ 动画持续时间:', animationDuration, 'ms'); + + // 动画完成后显示完整图片并清理方块 + setTimeout(() => { + // 检查动画是否已被中断 + if (currentAnimId !== this._animationId) { + // 动画已被中断,只做清理 + console.log('⛔ 方块动画被中断,执行清理'); + if (blocksContainer.parentNode) { + blocksContainer.parentNode.removeChild(blocksContainer); + } + return; + } + + console.log('✨ 方块动画完成,显示原图'); + + // 先确保图片src正确设置 + if (img.src !== imgSrc) { + img.src = imgSrc; + } + + // 关键修复:先禁用transition避免闪烁,然后立即显示原图 + img.style.transition = 'none'; + img.style.visibility = 'visible'; + img.style.opacity = '1'; + + // 强制同步布局,确保原图立即渲染 + img.getBoundingClientRect(); + + // 然后淡出方块容器 + blocksContainer.style.opacity = '0'; + blocksContainer.style.transition = 'opacity 0.15s ease'; + + // 等待方块淡出完成后移除容器,然后恢复transition + setTimeout(() => { + // 内层timeout也要检查 + if (currentAnimId !== this._animationId) { + if (blocksContainer.parentNode) { + blocksContainer.parentNode.removeChild(blocksContainer); + } + return; + } + + if (blocksContainer.parentNode) { + blocksContainer.parentNode.removeChild(blocksContainer); + } + // 恢复transition(如果需要后续使用) + img.style.transition = ''; + img.classList.add('visible'); + // 方块动画完全结束后,重置 isAnimating + this.isAnimating = false; + console.log('🔓 isAnimating 已重置为 false'); + }, 150); + }, animationDuration); + } + + /** + * 粒子消散动画 - 当前图片碎裂成粒子飘散(灰飞烟灭效果) + * 使用更多更小的粒子,波浪扩散效果 + * 支持多种路径类型:弧线(90%)、S形(5%)、直线(5%) + */ + async _playParticleDisperseAnimation(clickX, clickY) { + const currentAnimId = this._animationId; // 记录当前动画ID + const img = this.elements.mainImage; + const wrapper = this.elements.wrapper; + const imgSrc = img.src; + + console.log('💨 粒子消散动画开始'); + + // 载出动画开始前,立即隐藏原图(同步操作,确保在粒子创建前完成) + img.style.transition = 'none'; + img.style.opacity = '0'; + img.style.visibility = 'hidden'; + img.classList.remove('visible'); + // 强制同步布局,确保隐藏立即生效 + img.getBoundingClientRect(); + + // 获取图片显示尺寸 + const rect = wrapper.getBoundingClientRect(); + const imgWidth = img.naturalWidth || rect.width || 800; + const imgHeight = img.naturalHeight || rect.height || 600; + + // 安全检查:如果图片尺寸无效,跳过动画 + if (!imgWidth || !imgHeight || imgWidth <= 0 || imgHeight <= 0) { + console.error('❌ 图片尺寸无效,跳过粒子消散动画'); + return Promise.resolve(); + } + + const containerWidth = rect.width || imgWidth; + const containerHeight = rect.height || imgHeight; + const scale = Math.min(containerWidth / imgWidth, containerHeight / imgHeight, 1); + const displayWidth = imgWidth * scale; + const displayHeight = imgHeight * scale; + + console.log('💨 粒子容器尺寸:', displayWidth, 'x', displayHeight); + + // 粒子网格配置 - 使用与载入动画相同的正方形逻辑 + const maxParticleGrid = 30; // 较长边最多分30份 + const { rows, cols, cellSize: particleSide } = _calcGridLayout(displayWidth, displayHeight, maxParticleGrid); + const particleWidth = particleSide; + const particleHeight = particleSide; // 强制1:1 + const COLS = cols; + const ROWS = rows; + + // 创建粒子容器 + const particlesContainer = document.createElement('div'); + particlesContainer.className = 'cyber-particles-container'; + particlesContainer.style.width = `${displayWidth}px`; + particlesContainer.style.height = `${displayHeight}px`; + particlesContainer.style.left = `${(containerWidth - displayWidth) / 2}px`; + particlesContainer.style.top = `${(containerHeight - displayHeight) / 2}px`; + + const particles = []; + + // 计算波浪扩散的中心点(如果没有提供点击位置,使用中心) + const centerX = clickX !== undefined ? (clickX - rect.left - (containerWidth - displayWidth) / 2) : displayWidth / 2; + const centerY = clickY !== undefined ? (clickY - rect.top - (containerHeight - displayHeight) / 2) : displayHeight / 2; + + // 创建粒子 + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + // 随机决定路径类型:弧线(90%)、S形(5%)、直线(5%) + const pathTypeRandom = Math.random(); + const pathType = pathTypeRandom < 0.9 ? 'arc' : (pathTypeRandom < 0.95 ? 's-curve' : 'straight'); + + // 计算粒子到中心点的距离(用于波浪效果) + const particleCenterX = c * particleWidth + particleWidth / 2; + const particleCenterY = r * particleHeight + particleHeight / 2; + const distanceToCenter = Math.sqrt( + Math.pow(particleCenterX - centerX, 2) + + Math.pow(particleCenterY - centerY, 2) + ); + const maxDistance = Math.sqrt(displayWidth * displayWidth + displayHeight * displayHeight); + const distanceRatio = distanceToCenter / maxDistance; + + // 波浪延迟:距离中心越远,延迟越大 + const waveDelay = distanceRatio * 200; // 0-200ms波浪延迟 + const randomDelay = Math.random() * 100; // 额外随机延迟 + const totalDelay = waveDelay + randomDelay; + + // 消散方向:从中心向外,加上随机偏移 + const angle = Math.atan2(particleCenterY - centerY, particleCenterX - centerX); + const randomAngleOffset = (Math.random() - 0.5) * 2 * Math.PI * 0.6; // ±60%角度随机偏移 + const finalAngle = angle + randomAngleOffset; + + // 消散距离:使用整个视口作为动画舞台,粒子向屏幕四面八方飞散 + const baseDistance = Math.min(window.innerWidth, window.innerHeight) * 0.5 + Math.random() * Math.min(window.innerWidth, window.innerHeight) * 0.5; + const distanceMultiplier = 0.5 + distanceRatio * 1.5; + const finalDistance = baseDistance * distanceMultiplier * (1 + (Math.random() - 0.5) * 0.6); // ±30%距离随机偏移 + + const disperseX = Math.cos(finalAngle) * finalDistance; + const disperseY = Math.sin(finalAngle) * finalDistance; + + // 旋转 - 更大角度的旋转 + const randomRotate = (Math.random() - 0.5) * 720; + + // 计算动画时长和延迟 + const totalDuration = 0.75 + Math.random() * 0.375; // 0.75-1.125秒 + const delay = Math.random() * 100; // 0-100ms随机延迟 + const shapeDuration = totalDuration * 0.3; // 形变占前30% + + let particle, particleInner; + + if (pathType === 'arc' || pathType === 's-curve') { + // 弧线和S形路径:使用两层div嵌套实现 + // 外层控制X轴位移,内层控制Y轴位移 + 形变效果 + particle = document.createElement('div'); + particle.className = 'cyber-particle'; + particle.style.position = 'absolute'; + particle.style.width = `${particleWidth}px`; + particle.style.height = `${particleHeight}px`; + particle.style.left = `${c * particleSide}px`; + particle.style.top = `${r * particleSide}px`; + particle.style.overflow = 'visible'; + particle.style.border = 'none'; + particle.style.outline = 'none'; + particle.style.boxShadow = 'none'; + + // 内层div:包含背景图,控制Y轴和形变 + particleInner = document.createElement('div'); + particleInner.style.width = '100%'; + particleInner.style.height = '100%'; + particleInner.style.backgroundImage = `url("${imgSrc}")`; + particleInner.style.backgroundSize = `${displayWidth}px ${displayHeight}px`; + particleInner.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`; + particleInner.style.backgroundRepeat = 'no-repeat'; + particleInner.style.border = 'none'; + particleInner.style.outline = 'none'; + particleInner.style.boxShadow = 'none'; + + particle.appendChild(particleInner); + + // 设置不同的easing组合来产生曲线效果 + if (pathType === 'arc') { + // 弧线路径:X轴和Y轴使用不同的easing + // 随机选择弧线方向(向左弧或向右弧) + const arcDirection = Math.random() < 0.5 ? 1 : -1; + const xEasing = arcDirection > 0 ? 'ease-in' : 'ease-out'; + const yEasing = arcDirection > 0 ? 'ease-out' : 'ease-in'; + + // 外层:控制X轴位移 + particle.style.transform = 'translateX(0)'; + particle.style.transition = `transform ${totalDuration}s ${xEasing} ${delay}ms`; + + // 内层:控制Y轴位移 + 旋转 + 缩放 + 透明度 + 圆角 + particleInner.style.transform = 'translateY(0) rotate(0deg) scale(1)'; + particleInner.style.opacity = '1'; + particleInner.style.borderRadius = '0'; + particleInner.style.transition = ` + transform ${totalDuration}s ${yEasing} ${delay}ms, + opacity ${totalDuration}s ease-in ${delay}ms, + border-radius ${shapeDuration}s ease-out ${delay}ms + `; + + // 保存动画参数 + particle.dataset.pathType = 'arc'; + particle.dataset.disperseX = disperseX; + particle.dataset.disperseY = disperseY; + particle.dataset.rotate = randomRotate; + + } else { + // S形路径:使用cubic-bezier产生过冲效果 + // S形:X轴使用标准easing,Y轴使用带过冲的cubic-bezier + const sCurveEasing = 'cubic-bezier(0.68, -0.6, 0.32, 1.6)'; + + // 外层:控制X轴位移 + particle.style.transform = 'translateX(0)'; + particle.style.transition = `transform ${totalDuration}s ease-in-out ${delay}ms`; + + // 内层:控制Y轴位移(使用S形曲线easing)+ 其他效果 + particleInner.style.transform = 'translateY(0) rotate(0deg) scale(1)'; + particleInner.style.opacity = '1'; + particleInner.style.borderRadius = '0'; + particleInner.style.transition = ` + transform ${totalDuration}s ${sCurveEasing} ${delay}ms, + opacity ${totalDuration}s ease-in ${delay}ms, + border-radius ${shapeDuration}s ease-out ${delay}ms + `; + + // 保存动画参数 + particle.dataset.pathType = 's-curve'; + particle.dataset.disperseX = disperseX; + particle.dataset.disperseY = disperseY; + particle.dataset.rotate = randomRotate; + } + + particlesContainer.appendChild(particle); + particles.push({ + element: particle, + innerElement: particleInner, + delay: delay, + duration: totalDuration * 1000, // 转换为毫秒 + disperseX, + disperseY, + rotate: randomRotate, + pathType + }); + + } else { + // 直线路径:保持原有逻辑(单层div) + particle = document.createElement('div'); + particle.className = 'cyber-particle'; + particle.style.width = `${particleWidth}px`; + particle.style.height = `${particleHeight}px`; + particle.style.left = `${c * particleSide}px`; + particle.style.top = `${r * particleSide}px`; + + particle.style.backgroundImage = `url("${imgSrc}")`; + particle.style.backgroundSize = `${displayWidth}px ${displayHeight}px`; + particle.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`; + particle.style.backgroundRepeat = 'no-repeat'; + + // 保存动画参数供后续使用 + particle.dataset.disperseX = disperseX; + particle.dataset.disperseY = disperseY; + particle.dataset.rotate = randomRotate; + + // 初始状态(正常显示)- 方块形状 + particle.style.transform = 'translate(0, 0) rotate(0deg) scale(1)'; + particle.style.opacity = '1'; + particle.style.borderRadius = '0'; // 初始为方块形状 + particle.style.transition = ` + transform ${totalDuration}s ease-in ${delay}ms, + opacity ${totalDuration}s ease-in ${delay}ms, + border-radius ${shapeDuration}s ease-out ${delay}ms + `; + + // 移除边框效果 + particle.style.border = 'none'; + particle.style.outline = 'none'; + particle.style.boxShadow = 'none'; + + particlesContainer.appendChild(particle); + particles.push({ + element: particle, + delay: delay, + duration: totalDuration * 1000, // 转换为毫秒 + disperseX, + disperseY, + rotate: randomRotate, + pathType + }); + } + } + } + + wrapper.appendChild(particlesContainer); + console.log('✅ 粒子容器已添加,粒子数量:', particles.length); + + // 强制同步布局 + particlesContainer.getBoundingClientRect(); + + // 触发消散动画 - 使用双重requestAnimationFrame + requestAnimationFrame(() => { + requestAnimationFrame(() => { + console.log('🎭 开始粒子消散动画'); + particles.forEach(({ element, innerElement, disperseX, disperseY, rotate, pathType }) => { + if (pathType === 'arc' || pathType === 's-curve') { + // 两层div结构:外层控制X,内层控制Y + 形变 + // 外层X轴位移 + element.style.transform = `translateX(${disperseX}px)`; + // 内层Y轴位移 + 旋转 + 缩放 + 形变 + innerElement.style.borderRadius = '50%'; + innerElement.style.transform = `translateY(${disperseY}px) rotate(${rotate}deg) scale(0)`; + innerElement.style.opacity = '0'; + } else { + // 直线路径:单层div,同时设置所有属性 + element.style.borderRadius = '50%'; + element.style.transform = `translate(${disperseX}px, ${disperseY}px) rotate(${rotate}deg) scale(0)`; + element.style.opacity = '0'; + } + }); + }); + }); + + // 计算动画完成时间:取所有粒子中 (transition时长 + delay) 的最大值,然后加20ms缓冲 + const maxEndTime = Math.max(...particles.map(p => p.duration + p.delay)); + const animationDuration = maxEndTime + 20; + + // 等待动画完成 + return new Promise(resolve => { + setTimeout(() => { + console.log('✨ 粒子消散动画完成'); + // 无论是否被中断,都要清理粒子容器 + if (particlesContainer.parentNode) { + particlesContainer.parentNode.removeChild(particlesContainer); + } + // 只在未被中断时才操作img状态 + if (currentAnimId === this._animationId) { + // 动画完成后,确保原图保持隐藏状态 + img.style.opacity = '0'; + img.style.visibility = 'hidden'; + img.classList.remove('visible'); + } + resolve(); + }, animationDuration); + }); + } + + /** + * 图片加载失败回调 + */ + _onImageError() { + this.elements.loader.style.display = 'none'; + this.elements.mainImage.src = this._createErrorPlaceholder(); + // 错误图片直接显示,不使用方块动画 + this.elements.mainImage.classList.add('visible'); + } + + /** + * 创建错误占位图 + */ + _createErrorPlaceholder() { + return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300' viewBox='0 0 400 300'%3E%3Crect fill='%231a1a1a' width='400' height='300'/%3E%3Ctext x='200' y='140' text-anchor='middle' fill='%23ff6b6b' font-size='48'%3E⚠️%3C/text%3E%3Ctext x='200' y='180' text-anchor='middle' fill='%23888' font-size='14'%3E图片加载失败%3C/text%3E%3C/svg%3E`; + } + + /** + * 预加载相邻图片 + */ + _preloadAdjacent(currentIndex) { + const indices = [ + currentIndex - 1, + currentIndex + 1 + ].filter(i => i >= 0 && i < this.imageUrls.length); + + indices.forEach(index => { + const url = this.imageUrls[index]; + if (!this.preloadCache.has(url)) { + const img = new Image(); + img.onload = () => { + this.preloadCache.set(url, img); + }; + img.src = url; + } + }); + } + + /** + * 切换到指定索引(带动画) + * 流程:粒子消散(载出)→ 等待0.5秒 → 新图片方块合并(载入) + * @param {number} index - 目标图片索引 + * @param {number} clickX - 点击位置的X坐标(用于波浪效果) + * @param {number} clickY - 点击位置的Y坐标(用于波浪效果) + */ + async goTo(index, clickX, clickY) { + if (index === this.currentIndex) return; + if (index < 0 || index >= this.imageUrls.length) return; + + // 递增动画ID,用于中断检测 + this._animationId++; + const currentAnimId = this._animationId; + + // 如果正在动画中,立即清理并跳过载出动画,直接切换到新图片 + if (this.isAnimating) { + console.log('⚡ 快速切换:跳过载出动画,直接切换到图片索引:', index); + + // 清理所有动画容器 + const blocks = this.container.querySelector('.cyber-blocks-container'); + if (blocks) blocks.remove(); + const particles = this.container.querySelector('.cyber-particles-container'); + if (particles) particles.remove(); + + // 立即隐藏当前图片(同步操作,防止旧图闪现) + const img = this.elements.mainImage; + img.style.transition = 'none'; + img.style.opacity = '0'; + img.style.visibility = 'hidden'; + img.classList.remove('visible'); + img.getBoundingClientRect(); + + // 立即更新索引 + this.currentIndex = index; + + // 直接加载并显示新图片(会触发方块载入动画) + await this._loadImage(this.currentIndex); + + console.log('✅ 快速切换完成'); + return; + } + + console.log('🔄 切换到图片索引:', index); + this.isAnimating = true; + + // 1. 执行粒子消散动画(载出)- 粒子动画内部会处理原图隐藏 + await this._playParticleDisperseAnimation(clickX, clickY); + + // 检查是否被新的切换中断 + if (currentAnimId !== this._animationId) return; + + // 2. 载出动画完全结束后,等待0.5秒间隔 + await new Promise(resolve => setTimeout(resolve, 250)); + + // 检查是否被新的切换中断 + if (currentAnimId !== this._animationId) return; + + // 更新索引 + this.currentIndex = index; + + // 3. 加载新图片(这会触发方块合并动画) + // 注意:_loadImage 会设置 isAnimating = false,但方块动画是异步的 + // 所以需要在方块动画完成后再重置 isAnimating + await this._loadImage(this.currentIndex); + + // isAnimating 会在 _playBlockAssemblyAnimation 完成后自动重置 + console.log('✅ 切换完成'); + } + + /** + * 上一张 + * @param {number} clickX - 点击位置的X坐标 + * @param {number} clickY - 点击位置的Y坐标 + */ + prev(clickX, clickY) { + const newIndex = this.currentIndex > 0 ? this.currentIndex - 1 : this.imageUrls.length - 1; + this.goTo(newIndex, clickX, clickY); + } + + /** + * 下一张 + * @param {number} clickX - 点击位置的X坐标 + * @param {number} clickY - 点击位置的Y坐标 + */ + next(clickX, clickY) { + const newIndex = this.currentIndex < this.imageUrls.length - 1 ? this.currentIndex + 1 : 0; + this.goTo(newIndex, clickX, clickY); + } + + /** + * 关闭查看器 + */ + close() { + // 停止自动播放并清除定时器 + this._stopAutoPlay(); + + // 清理事件监听 + document.removeEventListener('keydown', this._keydownHandler); + + // 添加关闭动画 + this.container.style.opacity = '1'; + this.container.style.transition = 'opacity 0.3s ease'; + + requestAnimationFrame(() => { + this.container.style.opacity = '0'; + setTimeout(() => { + if (this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + }, 300); + }); + } +} + +// ========================================== +// 📦 导出接口 +// ========================================== + +/** + * 打开全屏图像播放器 + * @param {string[]} imageUrls - 图片URL数组(支持代理URL和原始URL) + * @param {number} startIndex - 起始图片索引,默认0 + * @param {Object} options - 可选配置 + * @param {string} options.background - 背景色,默认 '#0a0a0a' + * @param {boolean} options.opaque - 是否不透明背景,默认true + * @returns {Function} 关闭播放器的函数 + */ +export function openImageViewer(imageUrls, startIndex = 0, options = {}) { + if (!imageUrls || imageUrls.length === 0) { + console.warn('图片数组为空,无法打开查看器'); + return () => {}; + } + + console.log('🎬 openImageViewer 被调用:', 'URL数量:', imageUrls.length, '起始索引:', startIndex); + console.log('🖼️ 图片URLs:', imageUrls.map(u => u?.substring(0, 80) + '...')); + + const viewer = new CyberImageViewer(imageUrls, startIndex); + const container = viewer.create(options); + + // 添加到body + document.body.appendChild(container); + + // 添加入场动画 + requestAnimationFrame(() => { + container.style.opacity = '0'; + container.style.transition = 'opacity 0.3s ease'; + requestAnimationFrame(() => { + container.style.opacity = '1'; + }); + }); + + // 返回关闭函数 + return () => viewer.close(); +} + +// 导出默认对象 +export default { + openImageViewer, + CyberImageViewer +}; diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..19f0dcee42aaa200946ef2b1aaa2b6a22b86138c --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" @@ -0,0 +1,350 @@ +// 前端页面/components/图片沙箱组件.js +// ========================================== +// 🖼️ 图片沙箱组件(轮播、缩放、拖动、不可复制) +// ========================================== +// 作用:提供可交互的图片查看器,支持多图轮播、缩放和拖动 +// 关联文件: +// - 列表卡片组件.js (资源封面显示) +// - 资源详情页面组件.js (资源图片展示) +// ========================================== +// ⚡ 功能:多图轮播、左右切换、图片保护(禁止复制/下载) +// 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏 +// ========================================== + +import { t } from "./用户体验增强.js"; +import { openImageViewer } from "./图片展示测试.js"; + +// 🔧 通用SVG占位图生成函数 +function _createSVG(icon, text) { + const iconPart = icon + ? `%3Ctext x='200' y='125' text-anchor='middle' fill='%23666' font-size='24'%3E${encodeURIComponent(icon)}%3C/text%3E%3C` + : `%3Crect x='180' y='110' width='40' height='40' rx='4' fill='%23333'%3E%3Canimate attributeName='opacity' values='0.3;1;0.3' dur='1.5s' repeatCount='indefinite'/%3E%3C/rect%3E%3C`; + return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='260' viewBox='0 0 400 260'%3E%3Crect fill='%231a1a1a' width='400' height='260'/%3E${iconPart}text x='200' y='${icon ? 150 : 180}' text-anchor='middle' fill='%23666' font-size='12'%3E${encodeURIComponent(text)}%3C/text%3E%3C/svg%3E`; +} + +// 🔧 动态生成SVG占位图(支持多语言) +function createPlaceholderSVG(text) { return _createSVG(null, text); } +function createErrorSVG(text) { return _createSVG('⚠️', text); } +function createTimeoutSVG(text) { return _createSVG('⏱️', text); } + +// 获取占位图 +function getPlaceholderSVG() { return createPlaceholderSVG(t('image.loading')); } +function getErrorSVG() { return createErrorSVG(t('image.load_failed')); } +function getTimeoutSVG() { return createTimeoutSVG(t('image.load_timeout')); } + +// 🔧 P3优化:带超时机制的图片加载器 +const IMAGE_LOAD_TIMEOUT = 30000; // 30秒超时 + +function loadImageWithTimeout(url, timeout = IMAGE_LOAD_TIMEOUT) { + return new Promise((resolve, reject) => { + const img = new Image(); + const timer = setTimeout(() => { + img.onload = img.onerror = null; + reject(new Error('图片加载超时')); + }, timeout); + + img.onload = () => { + clearTimeout(timer); + resolve(img); + }; + img.onerror = () => { + clearTimeout(timer); + reject(new Error('图片加载失败')); + }; + img.src = url; + }); +} + +/** + * 🖼️ 生成效果展示图 HTML(支持多图轮播) + * @param {string|string[]} imageSource - 单个图片URL或多个图片URL数组 + * @param {boolean} lazyLoad - 是否懒加载 + */ +export function getCoverSandboxHTML(imageSource, lazyLoad = true) { + // 兼容:接受单个URL或数组 + let imageUrls = []; + if (Array.isArray(imageSource)) { + imageUrls = imageSource.filter(url => url && url.trim()); + } else if (imageSource && imageSource.trim()) { + imageUrls = [imageSource]; + } + + if (imageUrls.length === 0) return ''; + + const firstUrl = imageUrls[0]; + + // 🚀 本地代理URL已缓存,无需懒加载(从磁盘读取几乎瞬时) + const isLocalProxy = firstUrl && firstUrl.startsWith('/community_hub/image?url='); + const shouldLazy = lazyLoad && !isLocalProxy; + + const imgSrc = shouldLazy ? getPlaceholderSVG() : firstUrl; + const dataAttr = shouldLazy ? `data-src="${firstUrl}"` : ''; + const lazyClass = shouldLazy ? 'lazy-image lazy-loading' : ''; + + // 🖼️ 多图模式:显示左右切换按钮和计数器 + const hasMultiple = imageUrls.length > 1; + const navButtons = hasMultiple ? ` + + +
1 / ${imageUrls.length}
+ ` : ''; + + // 🔒 图片保护:禁用右键菜单、拖拽 + const protectionStyles = ` + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + -webkit-user-drag: none; + `; + + return ` +
🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}
+
+ + ${navButtons} +
+ + + + +
+
+ + +
+
+ `; +} + +/** + * 🖼️ 设置图片沙盒事件(轮播、缩放、拖动) + */ +export function setupImageSandboxEvents(detailView) { + const viewport = detailView.querySelector('.img-viewport'); + if (!viewport) return; + + const targetImg = detailView.querySelector('.target-img'); + const btnIn = detailView.querySelector('.btn-zoom-in'); + const btnOut = detailView.querySelector('.btn-zoom-out'); + const btnReset = detailView.querySelector('.btn-zoom-reset'); + const sliderX = detailView.querySelector('.slider-x'); + const sliderY = detailView.querySelector('.slider-y'); + const btnPrev = detailView.querySelector('.btn-prev'); + const btnNext = detailView.querySelector('.btn-next'); + const imgCounter = detailView.querySelector('.img-counter'); + + // 🔒 图片保护:禁用右键菜单 + viewport.addEventListener('contextmenu', (e) => e.preventDefault()); + targetImg.addEventListener('dragstart', (e) => e.preventDefault()); + + // 📦 解析图片数组 + let imageUrls = []; + let currentIndex = 0; + try { + imageUrls = JSON.parse(viewport.dataset.images || '[]'); + } catch (e) { + imageUrls = []; + } + + // 🖼️ 加载指定索引的图片(🔧 P3优化:带超时机制) + const loadImage = async (index) => { + if (index < 0 || index >= imageUrls.length) return; + currentIndex = index; + viewport.dataset.current = index; + + const url = imageUrls[index]; + targetImg.classList.add('lazy-loading'); + targetImg.src = getPlaceholderSVG(); + + try { + await loadImageWithTimeout(url); + targetImg.src = url; + targetImg.classList.remove('lazy-loading'); + targetImg.classList.add('lazy-loaded'); + targetImg.style.opacity = '1'; + } catch (err) { + // 区分超时和加载失败 + targetImg.src = err.message.includes('超时') ? getTimeoutSVG() : getErrorSVG(); + targetImg.classList.remove('lazy-loading'); + targetImg.classList.add('lazy-error'); + } + + // 更新计数器 + if (imgCounter) { + imgCounter.textContent = `${index + 1} / ${imageUrls.length}`; + } + + // 重置缩放和位置 + scale = 1; + sliderX.value = 0; + sliderY.value = 0; + syncTransform(); + }; + + // ⚡ 初始加载第一张图片(🔧 P3优化:带超时机制) + if (targetImg.classList.contains('lazy-loading') && targetImg.dataset.src) { + const realSrc = targetImg.dataset.src; + + loadImageWithTimeout(realSrc).then(() => { + targetImg.src = realSrc; + targetImg.classList.remove('lazy-loading'); + targetImg.classList.add('lazy-loaded'); + targetImg.style.opacity = '1'; + delete targetImg.dataset.src; + }).catch(err => { + targetImg.src = err.message.includes('超时') ? getTimeoutSVG() : getErrorSVG(); + targetImg.classList.remove('lazy-loading'); + targetImg.classList.add('lazy-error'); + }); + } + + // 🖼️ 轮播切换按钮事件 + if (btnPrev && btnNext) { + btnPrev.onclick = (e) => { + e.stopPropagation(); + loadImage(currentIndex > 0 ? currentIndex - 1 : imageUrls.length - 1); + }; + btnNext.onclick = (e) => { + e.stopPropagation(); + loadImage(currentIndex < imageUrls.length - 1 ? currentIndex + 1 : 0); + }; + } + + // 🔍 缩放功能 + let scale = 1.0; + const syncTransform = () => { + targetImg.style.transform = `scale(${scale}) translate(${sliderX.value}px, ${sliderY.value}px)`; + }; + + const zoom = (delta) => { + scale = Math.max(0.5, Math.min(5.0, scale + delta)); + syncTransform(); + }; + + btnIn.onclick = () => zoom(0.2); + btnOut.onclick = () => zoom(-0.2); + btnReset.onclick = () => { scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform(); }; + + // ⛶ 全屏播放按钮事件 + const btnFullscreen = viewport.querySelector('.btn-fullscreen'); + if (btnFullscreen) { + btnFullscreen.onclick = (e) => { + e.stopPropagation(); + + // 收集当前viewport的所有图片URL + const dataImages = viewport.getAttribute('data-images'); + let imageUrls = []; + + if (dataImages) { + try { + imageUrls = JSON.parse(dataImages); + } catch (e) { + // 单图模式 + const img = viewport.querySelector('.target-img'); + if (img && img.src) { + imageUrls = [img.src]; + } + } + } else { + // 无data-images属性,取当前img的src + const img = viewport.querySelector('.target-img'); + if (img && img.src) { + imageUrls = [img.src]; + } + } + + // 获取当前图片索引 + const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10); + + // 🔍 调试日志:输出收集到的图片URL + console.log('🖼️ 全屏播放图片:', imageUrls, '当前索引:', currentIndex); + + if (imageUrls.length > 0) { + // 打开全屏图像播放器(使用静态import,避免动态import路径问题) + try { + openImageViewer(imageUrls, currentIndex, { opaque: true }); + } catch (err) { + console.error('无法打开图像播放器:', err); + // 显示用户可见的错误提示 + import('./UI交互提示组件.js').then(module => { + module.showToast('图像播放器加载失败,请刷新页面重试', 'error'); + }).catch(() => { + // 动态import也失败时,创建简单的内联提示 + const fallback = document.createElement('div'); + fallback.textContent = '图像播放器加载失败,请刷新页面重试'; + Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' }); + document.body.appendChild(fallback); + setTimeout(() => fallback.remove(), 3000); + }); + } + } else { + console.warn('⚠️ 没有可播放的图片URL'); + } + }; + } + + viewport.addEventListener('wheel', (e) => { e.preventDefault(); zoom(e.deltaY > 0 ? -0.1 : 0.1); }); + sliderX.addEventListener('input', syncTransform); + sliderY.addEventListener('input', syncTransform); + + // ✨ 拖动功能(统一管理事件监听器) + let isDragging = false; let startX, startY, initValX, initValY; + + // 🔧 命名事件处理函数,便于清理 + const handleMouseMove = (e) => { + if (!isDragging) return; + const dx = (e.clientX - startX) / scale; const dy = (e.clientY - startY) / scale; + sliderX.value = initValX + dx; sliderY.value = initValY + dy; syncTransform(); + }; + + const handleMouseUp = () => { isDragging = false; viewport.style.cursor = 'grab'; }; + + viewport.addEventListener('mousedown', (e) => { + if(e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'button') return; + e.preventDefault(); // 阻止浏览器默认的文本/图片选中行为 + isDragging = true; viewport.style.cursor = 'grabbing'; + startX = e.clientX; startY = e.clientY; initValX = parseInt(sliderX.value); initValY = parseInt(sliderY.value); + }); + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + viewport.addEventListener('mouseleave', () => { isDragging = false; viewport.style.cursor = 'grab'; }); + + // ⌨️ 键盘左右切换 + const handleKeydown = (e) => { + if (e.key === 'ArrowLeft' && imageUrls.length > 1) { + loadImage(currentIndex > 0 ? currentIndex - 1 : imageUrls.length - 1); + } else if (e.key === 'ArrowRight' && imageUrls.length > 1) { + loadImage(currentIndex < imageUrls.length - 1 ? currentIndex + 1 : 0); + } + }; + + // 当鼠标在视口内时启用键盘切换 + viewport.addEventListener('mouseenter', () => { + document.addEventListener('keydown', handleKeydown); + }); + viewport.addEventListener('mouseleave', () => { + document.removeEventListener('keydown', handleKeydown); + }); + + // 🔧 P3优化:注册清理函数,供组件销毁时调用 + const cleanup = () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('keydown', handleKeydown); + }; + viewport._sandboxCleanup = cleanup; +} + +/** + * 🔧 清理图片沙盒事件监听器(防止内存泄漏) + * @param {HTMLElement} detailView - 详情视图容器 + */ +export function cleanupImageSandbox(detailView) { + const viewport = detailView?.querySelector('.img-viewport'); + if (viewport && viewport._sandboxCleanup) { + viewport._sandboxCleanup(); + viewport._sandboxCleanup = null; + } +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..d2526320c61dbf6f12d8af48e840ee539b91d52f --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" @@ -0,0 +1,454 @@ +// 前端页面/components/图片裁剪组件.js +// ========================================== +// ✂️ 图片裁剪组件 +// ========================================== +// 作用:提供可视化图片裁剪功能,支持比例约束、缩放、拖动 +// 关联文件: +// - 个人设置表单组件.js (调用此组件进行背景裁剪) +// ========================================== +// 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏 +// ========================================== + +// 辅助函数:将 value 限制在 [min, max] 范围内 +function _clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +// 📱 触控设备检测(用于提示文案区分;优先 matchMedia 能力媒体查询,兼容实时仿真切换与无 ontouchstart 的浏览器) +function _isTouchDevice() { + try { + if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) return true; + if (window.matchMedia && window.matchMedia('(hover: none)').matches) return true; + } catch (e) { /* 降级到下方检测 */ } + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +} + +/** + * 创建图片裁剪弹窗 + * @param {File} file - 要裁剪的图片文件 + * @param {number} aspectRatio - 宽高比(如 16/9 或 9/16) + * @param {string} title - 弹窗标题 + * @param {number} maxSizeMB - 最大输出大小(MB) + * @returns {Promise} - 裁剪后的文件或 null(取消) + */ +export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片", maxSizeMB = 3) { + return new Promise((resolve) => { + // 创建遮罩层 + const overlay = document.createElement("div"); + Object.assign(overlay.style, { + position: "fixed", top: "0", left: "0", width: "100%", height: "100%", + background: "rgba(0,0,0,0.85)", zIndex: "99999", + display: "flex", justifyContent: "center", alignItems: "center" + }); + + // 创建弹窗容器 + const modal = document.createElement("div"); + Object.assign(modal.style, { + background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px", + maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column", + boxShadow: "0 8px 32px rgba(0,0,0,0.5)" + }); + + // 标题栏 + const header = document.createElement("div"); + Object.assign(header.style, { + display: "flex", justifyContent: "space-between", alignItems: "center", + marginBottom: "15px", paddingBottom: "10px", borderBottom: "1px solid #444" + }); + // 比例显示文字 + let ratioText = '16:9'; + if (aspectRatio === 1) ratioText = '1:1'; + else if (aspectRatio < 1) ratioText = '9:16'; + // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念) + const gestureHint = _isTouchDevice() ? '单指拖动调整位置 | 双指捏合或滑块缩放' : '拖动调整位置 | 滚轮缩放'; + header.innerHTML = ` + ✂️ ${title} + 比例 ${ratioText} | ${gestureHint} + `; + + // 裁剪区域容器 + const cropContainer = document.createElement("div"); + let containerWidth, containerHeight; + if (aspectRatio === 1) { + // 1:1 正方形 + containerWidth = containerHeight = Math.min(400, window.innerWidth * 0.7); + } else if (aspectRatio > 1) { + // 16:9 横向 + containerWidth = Math.min(600, window.innerWidth * 0.8); + containerHeight = containerWidth / aspectRatio; + } else { + // 9:16 纵向 + containerWidth = Math.min(300, window.innerWidth * 0.5); + containerHeight = Math.min(450, window.innerHeight * 0.5); + } + + Object.assign(cropContainer.style, { + width: `${containerWidth}px`, height: `${containerHeight}px`, + position: "relative", overflow: "hidden", borderRadius: "8px", + background: "#1a1a1a", border: "2px solid #4CAF50", cursor: "move", + // 📱 禁止浏览器接管触摸手势(页面滚动/双指缩放页面),交由裁剪组件自行处理 + touchAction: "none" + }); + + // 图片元素 + const img = document.createElement("img"); + Object.assign(img.style, { + position: "absolute", maxWidth: "none", maxHeight: "none", + userSelect: "none", pointerEvents: "none" + }); + + // 裁剪状态 + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let imgNaturalWidth = 0; + let imgNaturalHeight = 0; + let isDragging = false; + let dragStartX = 0; + let dragStartY = 0; + + // 加载图片 + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target.result; + img.onload = () => { + imgNaturalWidth = img.naturalWidth; + imgNaturalHeight = img.naturalHeight; + + // 计算初始缩放(确保图片填满裁剪区域) + const scaleX = containerWidth / imgNaturalWidth; + const scaleY = containerHeight / imgNaturalHeight; + scale = Math.max(scaleX, scaleY); + + // 居中显示 + updateImagePosition(); + }; + }; + reader.readAsDataURL(file); + + // 更新图片位置 + function updateImagePosition() { + const scaledWidth = imgNaturalWidth * scale; + const scaledHeight = imgNaturalHeight * scale; + + // 限制偏移范围(确保图片始终覆盖裁剪区域) + const maxOffsetX = Math.max(0, (scaledWidth - containerWidth) / 2); + const maxOffsetY = Math.max(0, (scaledHeight - containerHeight) / 2); + + offsetX = _clamp(offsetX, -maxOffsetX, maxOffsetX); + offsetY = _clamp(offsetY, -maxOffsetY, maxOffsetY); + + // 居中 + 偏移 + const left = (containerWidth - scaledWidth) / 2 + offsetX; + const top = (containerHeight - scaledHeight) / 2 + offsetY; + + img.style.width = `${scaledWidth}px`; + img.style.height = `${scaledHeight}px`; + img.style.left = `${left}px`; + img.style.top = `${top}px`; + } + + // 鼠标拖动 + cropContainer.onmousedown = (e) => { + isDragging = true; + dragStartX = e.clientX - offsetX; + dragStartY = e.clientY - offsetY; + cropContainer.style.cursor = "grabbing"; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + // 🔧 P3优化:ESC 键关闭 + function handleKeyDown(e) { + if (e.key === 'Escape') { + cleanup(); + resolve(null); + } + } + document.addEventListener("keydown", handleKeyDown); + + function handleMouseMove(e) { + if (!isDragging) return; + offsetX = e.clientX - dragStartX; + offsetY = e.clientY - dragStartY; + updateImagePosition(); + } + + function handleMouseUp() { + isDragging = false; + cropContainer.style.cursor = "move"; + } + + // 滚轮缩放 + cropContainer.onwheel = (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.95 : 1.05; + const newScale = scale * delta; + + // 限制缩放范围(最小要覆盖裁剪区域) + const minScaleX = containerWidth / imgNaturalWidth; + const minScaleY = containerHeight / imgNaturalHeight; + const minScale = Math.max(minScaleX, minScaleY); + const maxScale = minScale * 5; + + scale = _clamp(newScale, minScale, maxScale); + updateImagePosition(); + }; + + // 📱 触控支持:单指拖动 + 双指捏合缩放(移动端无鼠标事件与滚轮) + let pinchStartDist = 0; + let pinchStartScale = 1; + let pinchCenterX = 0; + let pinchCenterY = 0; + let pinchStartOffsetX = 0; + let pinchStartOffsetY = 0; + + // 获取缩放边界(与滚轮缩放共用同一规则) + function _getScaleLimits() { + const minScaleX = containerWidth / imgNaturalWidth; + const minScaleY = containerHeight / imgNaturalHeight; + const minScale = Math.max(minScaleX, minScaleY); + return { minScale, maxScale: minScale * 5 }; + } + + function _getTouchMidPoint(touches) { + return { + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2 + }; + } + + function _getTouchDistance(touches) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function handleTouchStart(e) { + if (e.touches.length === 1) { + isDragging = true; + dragStartX = e.touches[0].clientX - offsetX; + dragStartY = e.touches[0].clientY - offsetY; + } else if (e.touches.length === 2) { + isDragging = false; + pinchStartDist = _getTouchDistance(e.touches); + pinchStartScale = scale; + const mid = _getTouchMidPoint(e.touches); + pinchCenterX = mid.x; + pinchCenterY = mid.y; + pinchStartOffsetX = offsetX; + pinchStartOffsetY = offsetY; + } + e.preventDefault(); + } + + function handleTouchMove(e) { + if (e.touches.length === 1 && isDragging) { + // 单指拖动 + offsetX = e.touches[0].clientX - dragStartX; + offsetY = e.touches[0].clientY - dragStartY; + updateImagePosition(); + } else if (e.touches.length === 2 && pinchStartDist > 0) { + // 双指捏合缩放(以两指中点为锚点,图片跟随手指中心移动) + const { minScale, maxScale } = _getScaleLimits(); + const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale); + const ratio = newScale / pinchStartScale; + const mid = _getTouchMidPoint(e.touches); + const centerX = containerWidth / 2; + const centerY = containerHeight / 2; + offsetX = (mid.x - pinchCenterX) + (pinchStartOffsetX - (centerX - pinchCenterX)) * ratio + (centerX - pinchCenterX); + offsetY = (mid.y - pinchCenterY) + (pinchStartOffsetY - (centerY - pinchCenterY)) * ratio + (centerY - pinchCenterY); + scale = newScale; + updateImagePosition(); + } + e.preventDefault(); + } + + function handleTouchEnd(e) { + if (e.touches.length === 0) { + isDragging = false; + pinchStartDist = 0; + } else if (e.touches.length === 1) { + // 双指抬起剩单指:切换为拖动模式,避免图片跳变 + pinchStartDist = 0; + isDragging = true; + dragStartX = e.touches[0].clientX - offsetX; + dragStartY = e.touches[0].clientY - offsetY; + } + } + + cropContainer.addEventListener("touchstart", handleTouchStart, { passive: false }); + cropContainer.addEventListener("touchmove", handleTouchMove, { passive: false }); + cropContainer.addEventListener("touchend", handleTouchEnd); + cropContainer.addEventListener("touchcancel", handleTouchEnd); + + // 缩放滑块 + const sliderContainer = document.createElement("div"); + Object.assign(sliderContainer.style, { + display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px" + }); + sliderContainer.innerHTML = ` + 🔍 缩放 + + 100% + `; + + // 按钮区域 + const buttonContainer = document.createElement("div"); + Object.assign(buttonContainer.style, { + display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end" + }); + buttonContainer.innerHTML = ` + + + `; + + // 组装DOM + cropContainer.appendChild(img); + modal.appendChild(header); + modal.appendChild(cropContainer); + modal.appendChild(sliderContainer); + modal.appendChild(buttonContainer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // 绑定缩放滑块事件 + setTimeout(() => { + const slider = modal.querySelector("#crop-scale-slider"); + const scaleValue = modal.querySelector("#crop-scale-value"); + + slider.oninput = () => { + const minScaleX = containerWidth / imgNaturalWidth; + const minScaleY = containerHeight / imgNaturalHeight; + const minScale = Math.max(minScaleX, minScaleY); + + scale = minScale * (parseInt(slider.value) / 100); + scaleValue.textContent = `${slider.value}%`; + updateImagePosition(); + }; + + // 取消按钮 + modal.querySelector("#btn-crop-cancel").onclick = () => { + cleanup(); + resolve(null); + }; + + // 确认裁剪按钮 + modal.querySelector("#btn-crop-confirm").onclick = async () => { + const btn = modal.querySelector("#btn-crop-confirm"); + btn.textContent = "⏳ 处理中..."; + btn.disabled = true; + + try { + const croppedFile = await cropAndCompress(); + cleanup(); + resolve(croppedFile); + } catch (err) { + btn.textContent = "❌ 处理失败,重试"; + btn.disabled = false; + console.error("裁剪失败:", err); + } + }; + }, 0); + + // 🔧 P3优化:统一清理函数,确保所有事件监听器被移除 + let isCleanedUp = false; + function cleanup() { + if (isCleanedUp) return; // 防止重复清理 + isCleanedUp = true; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("keydown", handleKeyDown); + cropContainer.removeEventListener("touchstart", handleTouchStart); + cropContainer.removeEventListener("touchmove", handleTouchMove); + cropContainer.removeEventListener("touchend", handleTouchEnd); + cropContainer.removeEventListener("touchcancel", handleTouchEnd); + overlay.remove(); + } + + // 点击遮罩关闭 + overlay.onclick = (e) => { + if (e.target === overlay) { + cleanup(); + resolve(null); + } + }; + + // 裁剪并压缩 + async function cropAndCompress() { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + // 输出尺寸(根据比例) + let outputWidth, outputHeight; + if (aspectRatio === 1) { + // 1:1 正方形(头像,512x512) + outputWidth = outputHeight = 512; + } else if (aspectRatio > 1) { + // 16:9 横向 + outputWidth = Math.min(1920, containerWidth * 2); + outputHeight = outputWidth / aspectRatio; + } else { + // 9:16 纵向 + outputHeight = Math.min(1920, containerHeight * 2); + outputWidth = outputHeight * aspectRatio; + } + + canvas.width = outputWidth; + canvas.height = outputHeight; + + // 计算裁剪区域在原图上的位置 + const scaledWidth = imgNaturalWidth * scale; + const scaledHeight = imgNaturalHeight * scale; + const imgLeft = (containerWidth - scaledWidth) / 2 + offsetX; + const imgTop = (containerHeight - scaledHeight) / 2 + offsetY; + + // 裁剪区域相对于缩放后图片的位置 + const cropX = -imgLeft / scale; + const cropY = -imgTop / scale; + const cropW = containerWidth / scale; + const cropH = containerHeight / scale; + + // 绘制到 canvas + ctx.drawImage( + img, + cropX, cropY, cropW, cropH, // 源图裁剪区域 + 0, 0, outputWidth, outputHeight // 目标canvas + ); + + // 压缩并转换为 Blob + let quality = 0.92; + let blob = await canvasToBlob(canvas, quality); + + // 如果超过限制大小,逐步降低质量 + while (blob.size > maxSizeMB * 1024 * 1024 && quality > 0.3) { + quality -= 0.1; + blob = await canvasToBlob(canvas, quality); + } + + // 如果仍然超过限制,缩小尺寸 + if (blob.size > maxSizeMB * 1024 * 1024) { + const scaleFactor = Math.sqrt((maxSizeMB * 1024 * 1024) / blob.size) * 0.9; + canvas.width = outputWidth * scaleFactor; + canvas.height = outputHeight * scaleFactor; + ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, canvas.width, canvas.height); + blob = await canvasToBlob(canvas, 0.85); + } + + // 生成文件名 + const baseName = file.name.replace(/\.[^/.]+$/, ""); + const fileName = `${baseName}_cropped.jpg`; + + return new File([blob], fileName, { type: "image/jpeg" }); + } + + // Canvas 转 Blob 辅助函数 + function canvasToBlob(canvas, quality) { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), "image/jpeg", quality); + }); + } + }); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\350\241\250\346\270\262\346\237\223\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\350\241\250\346\270\262\346\237\223\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..87915f5911b59f869b805987bc424cac7c31125a --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\350\241\250\346\270\262\346\237\223\347\273\204\344\273\266.js" @@ -0,0 +1,101 @@ +// 前端页面/components/图表渲染组件.js + +import { t } from "./用户体验增强.js"; + +function loadECharts() { + return new Promise((resolve, reject) => { + if (window.echarts) { resolve(window.echarts); return; } + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"; + script.onload = () => resolve(window.echarts); + script.onerror = () => reject(new Error("ECharts 加载失败")); + document.head.appendChild(script); + }); +} + +function getLast6Months() { + const res = []; + const now = new Date(); + let year = now.getFullYear(); + let month = now.getMonth() + 1; + for (let i = 5; i >= 0; i--) { + let m = month - i; + let y = year; + while (m <= 0) { m += 12; y -= 1; } + res.push(`${y}-${String(m).padStart(2, '0')}`); + } + return res; +} + +function _initChart(domElement, itemData) { + loadECharts().then(echarts => { + domElement.innerHTML = ""; + const chartInstance = echarts.init(domElement, 'dark', { backgroundColor: 'transparent' }); + + const months = getLast6Months(); + // 提取底层的真实时间序列 + const history = itemData.use_history || {}; + const data = months.map(m => history[m] || 0); + + chartInstance.setOption({ + tooltip: { trigger: 'axis', textStyle: { fontSize: 11 } }, + grid: { top: 10, bottom: 20, left: 35, right: 10, containLabel: true }, + xAxis: { type: 'category', data: months, axisLabel: { color: '#888', fontSize: 10 }, axisLine: { lineStyle: { color: '#444' } } }, + yAxis: { type: 'value', splitLine: { lineStyle: { color: '#333', type: 'dashed' } }, axisLabel: { color: '#888', fontSize: 10 }, minInterval: 1 }, + series: [{ + name: '按月获取量', type: 'line', data: data, smooth: true, + itemStyle: { color: '#2196F3' }, lineStyle: { width: 2 }, + areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{offset: 0, color: 'rgba(33,150,243,0.5)'}, {offset: 1, color: 'rgba(33,150,243,0)'}]) } + }] + }); + + // 🔧 P1修复:resize 监听可自清理——容器被移出 DOM(详情/列表关闭)后 + // 下次触发时自动解绑并 dispose 图表实例,避免监听累积与实例泄漏 + const onResize = () => { + if (!document.contains(domElement) || chartInstance.isDisposed()) { + window.removeEventListener('resize', onResize); + if (!chartInstance.isDisposed()) chartInstance.dispose(); + return; + } + chartInstance.resize(); + }; + window.addEventListener('resize', onResize); + + // 初始化完成后延迟调用resize确保正确渲染 + setTimeout(() => { + chartInstance.resize(); + }, 300); + }).catch(err => { + domElement.innerHTML = `
${t('chart.load_failed')}
`; + }); +} + +// 【核心修改】:接收入参从单纯的数字改为完整的 itemData 以读取历史记录 +export function renderItemTrendChart(domElement, itemData) { + domElement.innerHTML = `
${t('chart.loading')}
`; + + // 使用 ResizeObserver 等待容器获得有效宽度后再渲染 + if (domElement.clientWidth === 0) { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentRect.width > 0) { + observer.disconnect(); + _initChart(domElement, itemData); + return; + } + } + }); + observer.observe(domElement); + + // 安全超时:5秒后如果仍未获得宽度,放弃并清理 + setTimeout(() => { + observer.disconnect(); + if (domElement.clientWidth === 0) { + domElement.innerHTML = ''; + } + }, 5000); + return; + } + + _initChart(domElement, itemData); +} \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" new file mode 100644 index 0000000000000000000000000000000000000000..5a58c4e175967cd0c97259e33b8ba5fa775eff2b --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" @@ -0,0 +1,810 @@ +// 前端页面/components/彩蛋动画引擎.js +// ========================================== +// 🎮 彩蛋动画模块 - 3D星空千字文隧道 + 数字小猪 +// ========================================== +// 触发方式: 点击底部"猪的飞行梦"链接 +// 功能: 在新标签页打开全屏3D动画 + +/** + * 在新标签页打开彩蛋动画页面 + */ +export function openEasterEggPage() { + const html = generateFullHTML(); + const newWindow = window.open('', '_blank'); + if (newWindow) { + newWindow.document.write(html); + newWindow.document.close(); + } +} + +/** + * 生成完整的彩蛋动画HTML页面 + */ +function generateFullHTML() { + return ` + + + + + 🐷 猪的飞行梦 + + + + +
鼠标移动改变视角 | PgUp加速 PgDn减速 | ESC关闭
+
速度: 1.0x
+ + +`; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\346\200\247\350\203\275\344\274\230\345\214\226\345\267\245\345\205\267.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\346\200\247\350\203\275\344\274\230\345\214\226\345\267\245\345\205\267.js" new file mode 100644 index 0000000000000000000000000000000000000000..31388a8a84ffe18fee728ad58727563230f4f8c9 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\346\200\247\350\203\275\344\274\230\345\214\226\345\267\245\345\205\267.js" @@ -0,0 +1,1268 @@ +// 性能优化工具.js +// ========================================== +// ⚡ 性能优化工具模块 +// ========================================== +// 作用:提供分页加载、图片懒加载、缓存管理等性能优化工具 +// 关联文件: +// - 全局配置.js (配置中心) +// - 列表卡片组件.js (使用分页加载) +// - 创作者卡片组件.js (使用分页加载) +// - 所有图片渲染场景 (使用懒加载) +// ========================================== +// 🏗️ P2架构优化:使用配置中心常量 +// 🔒 P0安全优化:敏感数据脱敏 +// ========================================== + +import { PAGINATION } from "../core/全局配置.js"; + +// ========================================== +// 🔒 P0安全优化:敏感数据脱敏工具 +// ========================================== +// 特点: +// - 金额数据加密传输 +// - 敏感数据不存 localStorage +// - 内存缓存自动过期 + +// 🔐 加密密钥(每次会话随机生成) +const SESSION_KEY = crypto.getRandomValues(new Uint8Array(16)).reduce((a, b) => a + b.toString(16).padStart(2, '0'), ''); + +// 🔐 敏感数据内存缓存(不存 localStorage) +const sensitiveCache = new Map(); + +// 🔧 P3优化:缓存大小限制和统一清理定时器 +const MAX_SENSITIVE_ITEMS = 100; +let sensitiveCacheCleanupTimer = null; + +/** + * 🔒 简单加密(用于前端显示脱敏,非安全加密) + * @param {number} amount - 金额 + * @returns {string} 加密后的字符串 + */ +export function encryptAmount(amount) { + const str = String(amount); + let result = ''; + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i) ^ SESSION_KEY.charCodeAt(i % SESSION_KEY.length); + result += String.fromCharCode(charCode); + } + return btoa(result); +} + +/** + * 🔒 解密金额 + * @param {string} encrypted - 加密字符串 + * @returns {number} 原始金额 + */ +export function decryptAmount(encrypted) { + try { + const decoded = atob(encrypted); + let result = ''; + for (let i = 0; i < decoded.length; i++) { + const charCode = decoded.charCodeAt(i) ^ SESSION_KEY.charCodeAt(i % SESSION_KEY.length); + result += String.fromCharCode(charCode); + } + return parseInt(result, 10) || 0; + } catch { + return 0; + } +} + +/** + * 🔒 设置敏感数据缓存(仅内存,不存 localStorage) + * @param {string} key - 缓存键 + * @param {any} value - 缓存值 + * @param {number} ttl - 过期时间(毫秒),默认5分钟 + */ +export function setSensitiveCache(key, value, ttl = 5 * 60 * 1000) { + // 🔧 P3优化:限制缓存大小,防止内存持续增长 + if (sensitiveCache.size >= MAX_SENSITIVE_ITEMS) { + // 删除最早的项 + const firstKey = sensitiveCache.keys().next().value; + sensitiveCache.delete(firstKey); + } + + const expireAt = Date.now() + ttl; + sensitiveCache.set(key, { value, expireAt }); + + // 🔧 P3优化:统一清理定时器(仅一个,避免创建大量 setTimeout) + if (!sensitiveCacheCleanupTimer) { + sensitiveCacheCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [k, v] of sensitiveCache) { + if (now >= v.expireAt) { + sensitiveCache.delete(k); + } + } + // 缓存清空时停止定时器 + if (sensitiveCache.size === 0) { + clearInterval(sensitiveCacheCleanupTimer); + sensitiveCacheCleanupTimer = null; + } + }, 60000); // 每分钟清理一次 + } +} + +/** + * 🔒 获取敏感数据缓存 + * @param {string} key - 缓存键 + * @returns {any|null} + */ +export function getSensitiveCache(key) { + if (!sensitiveCache.has(key)) return null; + + const cached = sensitiveCache.get(key); + if (Date.now() >= cached.expireAt) { + sensitiveCache.delete(key); + return null; + } + return cached.value; +} + +/** + * 🔒 清除敏感数据缓存 + * @param {string} key - 缓存键,不传则清除全部 + */ +export function clearSensitiveCache(key) { + if (key) { + sensitiveCache.delete(key); + } else { + sensitiveCache.clear(); + } +} + +/** + * 🔒 敏感字段列表(这些字段不应该存到 localStorage) + */ +export const SENSITIVE_FIELDS = [ + 'balance', // 余额 + 'earn_balance', // 收益余额 + 'tip_balance', // 打赏余额 + 'frozen_balance', // 冻结余额 + 'password', // 密码 + 'password_hash', // 密码哈希 + 'token', // Token + 'netdisk_password', // 网盘密码 + 'github_token', // GitHub Token + 'transactions', // 交易记录 + 'tx_hash', // 交易哈希 +]; + +/** + * 🔒 过滤敏感字段(用于存储前脱敏) + * @param {Object} data - 原始数据 + * @returns {Object} 脱敏后的数据 + */ +export function filterSensitiveData(data) { + if (!data || typeof data !== 'object') return data; + + const filtered = Array.isArray(data) ? [...data] : { ...data }; + + if (Array.isArray(filtered)) { + return filtered.map(item => filterSensitiveData(item)); + } + + for (const field of SENSITIVE_FIELDS) { + if (field in filtered) { + delete filtered[field]; + } + } + + // 递归处理嵌套对象 + for (const key in filtered) { + if (filtered[key] && typeof filtered[key] === 'object') { + filtered[key] = filterSensitiveData(filtered[key]); + } + } + + return filtered; +} + +/** + * 🔒 金额显示脱敏(部分隐藏) + * @param {number} amount - 金额 + * @param {boolean} hide - 是否隐藏 + * @returns {string} + */ +export function maskAmount(amount, hide = false) { + if (hide) { + return '****'; + } + return String(amount); +} + +// ========================================== +// 📦 缓存管理器(已迁移到 网络请求_缓存管理.js) +// ========================================== +// 缓存实现已统一迁移到 ../core/网络请求_缓存管理.js +// 此处保持向后兼容重导出,调用方无需修改 import 路径 +export { setCache, getCache, getCacheWithMeta, removeCache, clearAllCache, CACHE_TTL, getRecommendedTTL, setSmartCache } from '../core/网络请求_缓存管理.js'; + + +// ========================================== +// 📜 分页加载管理器 +// ========================================== +// 特点: +// - 滚动到底部自动加载 +// - 防抖处理避免频繁触发 +// - 加载状态管理 + +/** + * 创建分页加载器 + * @param {Object} options - 配置选项 + * @param {HTMLElement} options.container - 滚动容器 + * @param {Function} options.loadMore - 加载更多数据的函数,返回 Promise + * @param {number} options.pageSize - 每页数量,默认 20 + * @param {number} options.threshold - 触发阈值(距底部像素),默认 200 + * @returns {Object} 分页加载器实例 + */ +export function createPaginationLoader(options) { + const { + container, + loadMore, + pageSize = 20, + threshold = 200 + } = options; + + let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载 + let isLoading = false; + let hasMore = true; + let debounceTimer = null; + let consecutiveErrors = 0; + let retryTimer = null; + let generation = 0; // 🔄 轻量级取消令牌,reset 时递增以丢弃飞行中请求 + + // 加载指示器 + const loadingIndicator = document.createElement("div"); + loadingIndicator.className = "pagination-loading"; + loadingIndicator.innerHTML = ` +
+ + 加载中... +
+ `; + loadingIndicator.style.display = "none"; + + // "没有更多"提示 + const endIndicator = document.createElement("div"); + endIndicator.className = "pagination-end"; + endIndicator.innerHTML = ` +
+ — 已经到底了 — +
+ `; + endIndicator.style.display = "none"; + + // 滚动事件处理(带防抖) + const handleScroll = () => { + if (debounceTimer) clearTimeout(debounceTimer); + + debounceTimer = setTimeout(() => { + if (isLoading || !hasMore) return; + + const { scrollTop, scrollHeight, clientHeight } = container; + const distanceToBottom = scrollHeight - scrollTop - clientHeight; + + if (distanceToBottom < threshold) { + _loadNextPage(); + } + }, 100); + }; + + // 加载下一页 + const _loadNextPage = async () => { + if (isLoading || !hasMore) return; + + isLoading = true; + loadingIndicator.style.display = "block"; + + try { + currentPage++; // 先递增,再加载(首屏已渲染第1页,从第2页开始) + const savedGen = generation; + const result = await loadMore(currentPage, pageSize); + if (savedGen !== generation) return; // reset 被调用了,丢弃结果 + + // 加载失败,允许重试 + if (result === null) { + consecutiveErrors++; + if (consecutiveErrors >= 3) { + hasMore = false; + loadingIndicator.style.display = "none"; + if (retryTimer) clearTimeout(retryTimer); + retryTimer = setTimeout(() => { + consecutiveErrors = 0; + hasMore = true; + }, 5000); + } + return; + } + + consecutiveErrors = 0; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + + // 判断是否还有更多 - 返回空数组则停止 + if (!result || result.length === 0) { + hasMore = false; + loadingIndicator.style.display = "none"; + } + } catch (error) { + console.error("分页加载失败:", error); + } finally { + isLoading = false; + if (hasMore) { + loadingIndicator.style.display = "none"; + } + } + }; + + // 启动监听 + const start = () => { + container.addEventListener("scroll", handleScroll); + // 添加指示器到容器末尾 + const scrollContent = container.querySelector(".scroll-content") || container; + scrollContent.appendChild(loadingIndicator); + scrollContent.appendChild(endIndicator); + }; + + // 停止监听 + const stop = () => { + container.removeEventListener("scroll", handleScroll); + if (debounceTimer) clearTimeout(debounceTimer); + if (retryTimer) clearTimeout(retryTimer); + }; + + // 重置状态 + const reset = () => { + generation++; // 🔄 递增使飞行中请求结果被忽略 + currentPage = 1; // 首屏已渲染第1页,分页器从第2页开始 + hasMore = true; + isLoading = false; + consecutiveErrors = 0; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + loadingIndicator.style.display = "none"; + endIndicator.style.display = "none"; + }; + + // 手动触发加载 + const load = () => _loadNextPage(); + + return { + start, + stop, + reset, + load, + get isLoading() { return isLoading; }, + get hasMore() { return hasMore; }, + get currentPage() { return currentPage; } + }; +} + + +// ========================================== +// 🖼️ 图片懒加载 +// ========================================== +// 特点: +// - 使用 IntersectionObserver API +// - 支持占位图和加载动画 +// - 自动重试失败的图片 + +// 默认占位图(Base64 灰色方块) +const PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect fill='%23f0f0f0' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%23999' font-size='12'%3E加载中%3C/text%3E%3C/svg%3E"; + +// 错误占位图 +const ERROR_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect fill='%23ffeaea' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%23c44' font-size='12'%3E加载失败%3C/text%3E%3C/svg%3E"; + +// 全局 Observer 实例 +let lazyLoadObserver = null; + +/** + * 初始化图片懒加载(全局单例) + */ +function _initLazyLoadObserver() { + if (lazyLoadObserver) return; + + // 检查浏览器支持 + if (!("IntersectionObserver" in window)) { + console.warn("⚠️ 浏览器不支持 IntersectionObserver,懒加载降级为直接加载"); + return; + } + + lazyLoadObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target; + _loadImage(img); + lazyLoadObserver.unobserve(img); + } + }); + }, { + rootMargin: "200px 0px", // 提前 200px 开始加载 + threshold: 0.01 + }); +} + +/** + * 实际加载图片 + */ +function _loadImage(img) { + const src = img.dataset.src; + if (!src) return; + + // 创建临时图片测试加载 + const tempImg = new Image(); + + tempImg.onload = () => { + img.src = src; + img.classList.remove("lazy-loading"); + img.classList.add("lazy-loaded"); + delete img.dataset.src; + }; + + tempImg.onerror = () => { + // 加载失败,显示错误占位图 + img.src = ERROR_IMAGE; + img.classList.remove("lazy-loading"); + img.classList.add("lazy-error"); + + // 记录失败,后续可重试 + img.dataset.retries = (parseInt(img.dataset.retries) || 0) + 1; + }; + + tempImg.src = src; +} + +/** + * 创建懒加载图片元素 + * @param {string} src - 图片真实地址 + * @param {string} alt - 图片描述 + * @param {Object} style - 样式对象 + * @returns {HTMLImageElement} + */ +export function createLazyImage(src, alt = "", style = {}) { + _initLazyLoadObserver(); + + const img = document.createElement("img"); + img.alt = alt; + img.src = PLACEHOLDER_IMAGE; + img.dataset.src = src; + img.className = "lazy-image lazy-loading"; + + // 应用样式 + Object.assign(img.style, { + transition: "opacity 0.3s ease", + ...style + }); + + // 添加到观察队列 + if (lazyLoadObserver) { + lazyLoadObserver.observe(img); + } else { + // 降级:直接加载 + img.src = src; + } + + return img; +} + +/** + * 将现有图片转换为懒加载 + * @param {HTMLImageElement} img - 已存在的图片元素 + */ +export function makeLazy(img) { + if (!img.src || img.dataset.src) return; + + _initLazyLoadObserver(); + + img.dataset.src = img.src; + img.src = PLACEHOLDER_IMAGE; + img.classList.add("lazy-image", "lazy-loading"); + + if (lazyLoadObserver) { + lazyLoadObserver.observe(img); + } +} + +/** + * 批量设置容器内所有图片为懒加载 + * @param {HTMLElement} container - 容器元素 + * @param {string} selector - 图片选择器,默认 "img" + */ +export function lazyLoadImages(container, selector = "img") { + const images = container.querySelectorAll(selector); + images.forEach(img => { + // 跳过已处理的和 data URL + if (img.dataset.src || img.src.startsWith("data:")) return; + makeLazy(img); + }); +} + + +// ========================================== +// 🔄 防抖与节流 +// ========================================== + +/** + * 防抖函数 + * @param {Function} fn - 要防抖的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {Function} + */ +export function debounce(fn, delay = 300) { + let timer = null; + return function(...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +} + +/** + * 节流函数 + * @param {Function} fn - 要节流的函数 + * @param {number} limit - 时间间隔(毫秒) + * @returns {Function} + */ +export function throttle(fn, limit = 100) { + let inThrottle = false; + return function(...args) { + if (!inThrottle) { + fn.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + + +// ========================================== +// 📊 性能监控 +// ========================================== + +/** + * 简单的性能计时器 + * @param {string} label - 标签名 + */ +export function startTimer(label) { + if (typeof performance !== "undefined") { + performance.mark(`${label}-start`); + } +} + +export function endTimer(label) { + if (typeof performance !== "undefined") { + performance.mark(`${label}-end`); + try { + performance.measure(label, `${label}-start`, `${label}-end`); + const measure = performance.getEntriesByName(label)[0]; + console.log(`⏱️ ${label}: ${measure.duration.toFixed(2)}ms`); + performance.clearMarks(`${label}-start`); + performance.clearMarks(`${label}-end`); + performance.clearMeasures(label); + } catch {} + } +} + + +// ========================================== +// 🎨 骨架屏生成器 +// ========================================== +// 特点: +// - 适配深色主题 +// - 多种卡片类型 +// - 流畅的动画效果 + +/** + * 创建骨架屏占位 + * @param {string} type - 类型:itemCard/creatorCard/list/avatar/text/detail + * @param {number} count - 数量 + * @returns {HTMLElement} + */ +export function createSkeleton(type = "card", count = 1) { + const container = document.createElement("div"); + container.className = "skeleton-container"; + + // 🎨 深色主题骨架屏样式 + const skeletonStyles = ` + .skeleton-container { + padding: 8px; + } + .skeleton-item { + background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 6px; + } + .skeleton-card { + background: var(--comfy-menu-bg); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + } + @keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + @keyframes skeleton-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.8; } + } + `; + + // 注入样式(仅一次) + if (!document.getElementById("skeleton-styles-v2")) { + const styleEl = document.createElement("style"); + styleEl.id = "skeleton-styles-v2"; + styleEl.textContent = skeletonStyles; + document.head.appendChild(styleEl); + } + + for (let i = 0; i < count; i++) { + const item = document.createElement("div"); + + switch (type) { + // 工具/应用卡片骨架 + case "card": + case "itemCard": + item.className = "skeleton-card"; + item.innerHTML = ` +
+
+
+
+
+
+
+
+
+ `; + break; + + // 创作者卡片骨架 + case "creatorCard": + item.className = "skeleton-card"; + item.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+ `; + break; + + // 列表项骨架 + case "list": + item.style.marginBottom = "12px"; + item.innerHTML = ` +
+
+
+
+
+
+
+
+ `; + break; + + // 头像骨架 + case "avatar": + item.style.display = "inline-block"; + item.innerHTML = `
`; + break; + + // 文本骨架 + case "text": + item.style.marginBottom = "16px"; + item.innerHTML = ` +
+
+
+ `; + break; + + // 详情页骨架 + case "detail": + item.className = "skeleton-card"; + item.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ `; + break; + + // 兑容旧版 card 类型 + default: + item.className = "skeleton-card"; + item.innerHTML = ` +
+
+
+ `; + } + + container.appendChild(item); + } + + return container; +} + +/** + * 快速显示骨架屏到容器 + * @param {HTMLElement} container - 容器元素 + * @param {string} type - 骨架屏类型 + * @param {number} count - 数量 + */ +export function showSkeletonIn(container, type = "itemCard", count = 3) { + container.innerHTML = ""; + container.appendChild(createSkeleton(type, count)); +} + +/** + * 移除容器中的骨架屏 + * @param {HTMLElement} container - 容器元素 + */ +export function hideSkeletonIn(container) { + const skeleton = container.querySelector(".skeleton-container"); + if (skeleton) { + skeleton.style.opacity = "0"; + skeleton.style.transition = "opacity 0.2s"; + setTimeout(() => skeleton.remove(), 200); + } +} + + +// ========================================== +// 🔗 批量请求合并 +// ========================================== + +const batchQueue = new Map(); // 请求队列 +const BATCH_DELAY = 50; // 合并等待时间 + +/** + * 批量请求合并器 + * 将多个相同类型的请求合并为一次批量请求 + * + * @param {string} batchKey - 批次标识(如 "getUserInfo") + * @param {string} itemId - 单个请求的ID + * @param {Function} batchFetcher - 批量获取函数,接收 ID 数组,返回 Map + * @returns {Promise} + */ +export function batchRequest(batchKey, itemId, batchFetcher) { + return new Promise((resolve, reject) => { + if (!batchQueue.has(batchKey)) { + batchQueue.set(batchKey, { + ids: [], + resolvers: new Map(), + timer: null + }); + } + + const batch = batchQueue.get(batchKey); + batch.ids.push(itemId); + batch.resolvers.set(itemId, { resolve, reject }); + + // 重置定时器 + if (batch.timer) clearTimeout(batch.timer); + + batch.timer = setTimeout(async () => { + const { ids, resolvers } = batch; + batchQueue.delete(batchKey); + + try { + // 去重 + const uniqueIds = [...new Set(ids)]; + const results = await batchFetcher(uniqueIds); + + // 分发结果 + resolvers.forEach((resolver, id) => { + if (results.has(id)) { + resolver.resolve(results.get(id)); + } else { + resolver.resolve(null); + } + }); + } catch (error) { + resolvers.forEach(resolver => resolver.reject(error)); + } + }, BATCH_DELAY); + }); +} + + +// ========================================== +// 🚀 P1性能优化:虚拟滚动组件 +// ========================================== +// 特点: +// - 只渲染可视区域 + 缓冲区 DOM,大幅减少 DOM 节点 +// - 支持动态高度卡片 +// - 平滑滚动体验 + +/** + * 创建虚拟滚动列表 + * @param {Object} options - 配置选项 + * @param {HTMLElement} options.container - 滚动容器 + * @param {Array} options.items - 数据数组 + * @param {Function} options.renderItem - 渲染单个卡片的函数 (item, index) => HTMLElement + * @param {number} options.itemHeight - 预估卡片高度(px),默认 160 + * @param {number} options.buffer - 缓冲区卡片数,默认 5 + * @returns {Object} 虚拟滚动实例 + */ +export function createVirtualScroller(options) { + const { + container, + items = [], + renderItem, + itemHeight = 160, + buffer = 5 + } = options; + + let _items = [...items]; + let _itemHeights = new Map(); // 记录实际高度 + let _scrollTop = 0; + let _containerHeight = container.clientHeight || 600; + let _isDestroyed = false; + + // 创建内部结构 + const wrapper = document.createElement("div"); + wrapper.className = "virtual-scroll-wrapper"; + wrapper.style.cssText = "position: relative; width: 100%;"; + + const content = document.createElement("div"); + content.className = "virtual-scroll-content"; + content.style.cssText = "position: absolute; top: 0; left: 0; right: 0;"; + + wrapper.appendChild(content); + container.innerHTML = ""; + container.appendChild(wrapper); + + // 计算总高度 + const getTotalHeight = () => { + let total = 0; + for (let i = 0; i < _items.length; i++) { + total += _itemHeights.get(i) || itemHeight; + } + return total; + }; + + // 获取可见范围 + const getVisibleRange = () => { + let startIdx = 0; + let endIdx = 0; + let accHeight = 0; + + // 找开始索引 + for (let i = 0; i < _items.length; i++) { + const h = _itemHeights.get(i) || itemHeight; + if (accHeight + h >= _scrollTop) { + startIdx = Math.max(0, i - buffer); + break; + } + accHeight += h; + } + + // 找结束索引 + accHeight = 0; + for (let i = 0; i < _items.length; i++) { + accHeight += _itemHeights.get(i) || itemHeight; + if (accHeight >= _scrollTop + _containerHeight) { + endIdx = Math.min(_items.length - 1, i + buffer); + break; + } + } + if (endIdx === 0) endIdx = Math.min(_items.length - 1, startIdx + Math.ceil(_containerHeight / itemHeight) + buffer * 2); + + return { startIdx, endIdx }; + }; + + // 计算偶移量 + const getOffsetTop = (idx) => { + let offset = 0; + for (let i = 0; i < idx; i++) { + offset += _itemHeights.get(i) || itemHeight; + } + return offset; + }; + + // 渲染可见卡片 + const render = () => { + if (_isDestroyed) return; + + const { startIdx, endIdx } = getVisibleRange(); + const fragment = document.createDocumentFragment(); + + for (let i = startIdx; i <= endIdx; i++) { + const item = _items[i]; + if (!item) continue; + + const el = renderItem(item, i); + el.style.position = "absolute"; + el.style.top = getOffsetTop(i) + "px"; + el.style.left = "0"; + el.style.right = "0"; + el.dataset.virtualIdx = i; + + fragment.appendChild(el); + } + + content.innerHTML = ""; + content.appendChild(fragment); + + // 更新容器高度 + wrapper.style.height = getTotalHeight() + "px"; + + // 测量实际高度(延迟执行) + requestAnimationFrame(() => { + content.querySelectorAll("[data-virtual-idx]").forEach(el => { + const idx = parseInt(el.dataset.virtualIdx); + const actualHeight = el.offsetHeight; + if (actualHeight && actualHeight !== (_itemHeights.get(idx) || itemHeight)) { + _itemHeights.set(idx, actualHeight); + } + }); + }); + }; + + // 滚动事件(节流) + let rafId = null; + const handleScroll = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + _scrollTop = container.scrollTop; + render(); + rafId = null; + }); + }; + + // 窗口尺寸变化 + const handleResize = debounce(() => { + _containerHeight = container.clientHeight; + render(); + }, 200); + + // 初始化 + const init = () => { + container.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", handleResize); + render(); + }; + + // 销毁 + const destroy = () => { + _isDestroyed = true; + container.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleResize); + if (rafId) cancelAnimationFrame(rafId); + }; + + // 更新数据 + const setItems = (newItems) => { + _items = [...newItems]; + _itemHeights.clear(); + render(); + }; + + // 滚动到指定位置 + const scrollToIndex = (idx) => { + const offset = getOffsetTop(idx); + container.scrollTop = offset; + }; + + init(); + + return { + render, + destroy, + setItems, + scrollToIndex, + get items() { return _items; }, + get visibleRange() { return getVisibleRange(); } + }; +} + +// ========================================== +// 🚀 P1性能优化:WebP 图片转换 +// ========================================== + +// 检测浏览器是否支持 WebP +let _webpSupported = null; +export async function isWebPSupported() { + if (_webpSupported !== null) return _webpSupported; + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + _webpSupported = img.width > 0 && img.height > 0; + resolve(_webpSupported); + }; + img.onerror = () => { + _webpSupported = false; + resolve(false); + }; + img.src = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='; + }); +} + +/** + * 转换图片 URL 为 WebP 格式(如果服务器支持) + * @param {string} url - 原始图片 URL + * @returns {string} 转换后的 URL + */ +export function toWebP(url) { + if (!url || typeof url !== 'string') return url; + + // 已经是 WebP 或 data URL,跳过 + if (url.endsWith('.webp') || url.startsWith('data:')) return url; + + // 检查是否是我们的图片代理 URL + if (url.includes('/api/image_proxy')) { + // 添加 webp 参数 + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}format=webp`; + } + + return url; +} + +/** + * 批量转换图片数组为 WebP + * @param {string[]} urls - 图片 URL 数组 + * @returns {string[]} 转换后的数组 + */ +export async function toWebPBatch(urls) { + const supported = await isWebPSupported(); + if (!supported) return urls; + + return urls.map(url => toWebP(url)); +} + +// ========================================== +// 🧹 P1优化:全局事件管理器 +// ========================================== +// 特点: +// - 统一管理所有 addEventListener +// - 统一管理所有 setInterval/setTimeout +// - 支持一键清理,防止内存泄漏 +// - 支持按组件分类管理 + +const eventManager = { + // 存储所有注册的监听器 {componentId: [{target, event, handler, options}]} + _listeners: new Map(), + // 存储所有定时器 {componentId: Set} + _timers: new Map(), + // 全局定时器(无组件关联) + _globalTimers: new Set(), + + /** + * 注册事件监听器 + * @param {string} componentId - 组件ID,用于分类管理 + * @param {EventTarget} target - 监听目标 + * @param {string} event - 事件类型 + * @param {Function} handler - 处理函数 + * @param {Object} options - 监听选项 + */ + addEventListener(componentId, target, event, handler, options = {}) { + if (!target || !event || !handler) return; + + target.addEventListener(event, handler, options); + + if (!this._listeners.has(componentId)) { + this._listeners.set(componentId, []); + } + this._listeners.get(componentId).push({ target, event, handler, options }); + }, + + /** + * 移除指定组件的所有事件监听器 + * @param {string} componentId - 组件ID + */ + removeEventListeners(componentId) { + const listeners = this._listeners.get(componentId); + if (!listeners) return; + + listeners.forEach(({ target, event, handler, options }) => { + try { + target.removeEventListener(event, handler, options); + } catch (e) { + // 目标已销毁,忽略 + } + }); + + this._listeners.delete(componentId); + console.log(`🧹 已清理 ${componentId} 的事件监听器`); + }, + + /** + * 注册 setInterval + * @param {string} componentId - 组件ID + * @param {Function} fn - 回调函数 + * @param {number} delay - 间隔时间 + * @returns {number} 定时器ID + */ + setInterval(componentId, fn, delay) { + const id = setInterval(fn, delay); + + if (componentId) { + if (!this._timers.has(componentId)) { + this._timers.set(componentId, new Set()); + } + this._timers.get(componentId).add(id); + } else { + this._globalTimers.add(id); + } + + return id; + }, + + /** + * 注册 setTimeout + * @param {string} componentId - 组件ID + * @param {Function} fn - 回调函数 + * @param {number} delay - 延迟时间 + * @returns {number} 定时器ID + */ + setTimeout(componentId, fn, delay) { + const id = setTimeout(() => { + fn(); + // 执行后自动清除 + if (componentId) { + const timers = this._timers.get(componentId); + if (timers) timers.delete(id); + } else { + this._globalTimers.delete(id); + } + }, delay); + + if (componentId) { + if (!this._timers.has(componentId)) { + this._timers.set(componentId, new Set()); + } + this._timers.get(componentId).add(id); + } else { + this._globalTimers.add(id); + } + + return id; + }, + + /** + * 清除指定组件的所有定时器 + * @param {string} componentId - 组件ID + */ + clearTimers(componentId) { + const timers = this._timers.get(componentId); + if (!timers) return; + + timers.forEach(id => { + clearInterval(id); + clearTimeout(id); + }); + + this._timers.delete(componentId); + console.log(`⏱️ 已清理 ${componentId} 的定时器`); + }, + + /** + * 清理指定组件的所有资源(事件 + 定时器) + * @param {string} componentId - 组件ID + */ + cleanup(componentId) { + this.removeEventListeners(componentId); + this.clearTimers(componentId); + }, + + /** + * 清理所有资源 + */ + cleanupAll() { + // 清理所有组件的事件监听器 + this._listeners.forEach((_, componentId) => { + this.removeEventListeners(componentId); + }); + + // 清理所有组件的定时器 + this._timers.forEach((_, componentId) => { + this.clearTimers(componentId); + }); + + // 清理全局定时器 + this._globalTimers.forEach(id => { + clearInterval(id); + clearTimeout(id); + }); + this._globalTimers.clear(); + + console.log('🧹 已清理所有事件和定时器'); + }, + + /** + * 获取统计信息 + * @returns {Object} 统计信息 + */ + getStats() { + let totalListeners = 0; + let totalTimers = 0; + + this._listeners.forEach(arr => totalListeners += arr.length); + this._timers.forEach(set => totalTimers += set.size); + totalTimers += this._globalTimers.size; + + return { + components: this._listeners.size, + listeners: totalListeners, + timers: totalTimers + }; + } +}; + +// 导出事件管理器 +export { eventManager }; + +// ========================================== +// 🔍 列表缓存查找工具(详情页离线回退用) +// ========================================== + +/** + * 🔍 从列表缓存中查找指定项(详情页离线回退用) + * @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_") + * @param {string} id - 要查找的项目ID + * @returns {Object|null} 找到的项目数据,或null + */ +export function findInListCache(prefix, id) { + try { + const fullPrefix = "ComfyRanking_" + prefix; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(fullPrefix)) { + const raw = JSON.parse(localStorage.getItem(key)); + const items = raw?.value; + if (Array.isArray(items)) { + const found = items.find(item => item.id === id); + if (found) return found; + } + } + } + } catch (e) { + console.warn("列表缓存查找失败:", e); + } + return null; +} + + diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\346\211\223\350\265\217\347\255\211\347\272\247\345\267\245\345\205\267.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\346\211\223\350\265\217\347\255\211\347\272\247\345\267\245\345\205\267.js" new file mode 100644 index 0000000000000000000000000000000000000000..d2cd3a2fa285ce681b649acf0bd73b0325a9d7d1 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\346\211\223\350\265\217\347\255\211\347\272\247\345\267\245\345\205\267.js" @@ -0,0 +1,260 @@ +// 前端页面/components/打赏等级工具.js +// ========================================== +// 🏅 打赏等级计算与显示工具 +// ========================================== +// 作用:根据打赏金额计算并渲染星星/月亮/太阳等级 +// 关联文件: +// - 列表卡片组件.js (工具/应用/推荐详情打赏榜) +// - 个人中心_UI模板.js (创作者总榜) +// - 创作者卡片组件.js (创作者列表) +// - 资源详情页面组件.js (资源详情打赏榜) +// =========================================== + +import { t } from "./用户体验增强.js"; +import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS } from "../core/全局配置.js"; +import { api } from "../core/网络请求_业务API.js"; + +/** + * 等级规则(参考QQ等级系统): + * - 1-100 积分 = 1 颗星星 ⭐,101-200 积分 = 2 颗星星,以此类推 + * - 每 5 颗星星 = 1 个月亮 🌙 (即 500 积分 = 1月亮) + * - 每 5 个月亮 = 1 个太阳 ☀️ (即 2500 积分 = 1太阳) + * - 上限:9 个太阳 = 22500 积分 (超过后不再接受打赏) + * - 注:1积分 = 1元,积分数即为元数 + */ + +// 等级图标定义 +const ICON_STAR = "⭐"; +const ICON_MOON = "🌙"; +const ICON_SUN = "☀️"; + +// 等级阈值常量 +const POINTS_PER_STAR = 100; // 每颗星星需要100积分 +const STARS_PER_MOON = 5; // 每5颗星星升级为1个月亮 +const MOONS_PER_SUN = 5; // 每5个月亮升级为1个太阳 +const MAX_SUNS = 9; // 最高等级:9个太阳 + +// 计算各等级数量的阈值 +const POINTS_PER_MOON = POINTS_PER_STAR * STARS_PER_MOON; // 500积分 = 1月亮 +const POINTS_PER_SUN = POINTS_PER_MOON * MOONS_PER_SUN; // 2500积分 = 1太阳 +const MAX_POINTS = POINTS_PER_SUN * MAX_SUNS; // 22500积分 = 满级 + +/** + * 计算打赏等级 + * @param {number} amount - 打赏金额(积分) + * @returns {object} - 包含 suns, moons, stars 数量 + */ +export function calculateTipLevel(amount) { + // 限制最大值 + const cappedAmount = Math.min(amount, MAX_POINTS); + + // 计算总星级(使用 ceil:1-100积分=1星,101-200积分=2星,以此类推) + const totalStars = cappedAmount > 0 ? Math.ceil(cappedAmount / POINTS_PER_STAR) : 0; + + // 转换为太阳、月亮、星星 + const suns = Math.floor(totalStars / (STARS_PER_MOON * MOONS_PER_SUN)); + const moons = Math.floor((totalStars % (STARS_PER_MOON * MOONS_PER_SUN)) / STARS_PER_MOON); + const stars = totalStars % STARS_PER_MOON; + + return { suns, moons, stars, isMaxLevel: cappedAmount >= MAX_POINTS }; +} + +/** + * 渲染等级图标HTML + * @param {number} amount - 打赏金额(积分) + * @param {boolean} compact - 是否紧凑模式(只显示图标不显示数量) + * @returns {string} - 等级图标的HTML字符串 + */ +export function renderTipLevelHTML(amount, compact = false) { + if (!amount || amount <= 0) { + return `${t('tip.new_fan')}`; + } + + const level = calculateTipLevel(amount); + let html = ''; + + // ========== 紧凑模式:只显示图标 ========== + if (compact) { + // 太阳(最高级别优先显示) + if (level.suns > 0) { + html += `${ICON_SUN.repeat(Math.min(level.suns, 3))}${level.suns > 3 ? '×' + level.suns : ''}`; + } + // 月亮 + if (level.moons > 0) { + html += `${ICON_MOON.repeat(level.moons)}`; + } + // 星星 + if (level.stars > 0) { + html += `${ICON_STAR.repeat(level.stars)}`; + } + + // 满级特效 + if (level.isMaxLevel) { + html = `${html} 👑`; + } + + return html || `⭐`; + } + + // ========== 完整模式:显示图标+数量 ========== + const parts = []; + + if (level.suns > 0) { + parts.push(`${ICON_SUN}×${level.suns}`); + } + if (level.moons > 0) { + parts.push(`${ICON_MOON}×${level.moons}`); + } + if (level.stars > 0) { + parts.push(`${ICON_STAR}×${level.stars}`); + } + + // 满级特效 + if (level.isMaxLevel) { + return `👑 ${t('tip.supreme_sponsor')}`; + } + + return parts.length > 0 + ? `${parts.join(' ')}` + : `⭐`; +} + +/** + * 检查是否达到打赏上限 + * @param {number} amount - 当前累计打赏金额 + * @returns {boolean} - 是否已达上限 + */ +export function isMaxTipLevel(amount) { + return amount >= MAX_POINTS; +} + +/** + * 获取等级描述文字 + * @param {number} amount - 打赏金额 + * @returns {string} - 等级描述 + */ +export function getTipLevelDescription(amount) { + if (!amount || amount <= 0) return "新粉丝"; + + const level = calculateTipLevel(amount); + + if (level.isMaxLevel) return "至尊赞助者 (已满级)"; + if (level.suns >= 5) return "超级赞助者"; + if (level.suns >= 1) return "黄金赞助者"; + if (level.moons >= 3) return "白银赞助者"; + if (level.moons >= 1) return "青铜赞助者"; + if (level.stars >= 3) return "热心粉丝"; + return "支持者"; +} + +/** + * 生成打赏榜单项HTML(统一格式) + * @param {object} tipData - 打赏数据 { account, amount, is_anon } + * @param {number} rank - 排名(从0开始) + * @param {string} size - 尺寸 'small' | 'normal' | 'large' + * @returns {string} - 榜单项HTML + */ +export function renderTipBoardItemHTML(tipData, rank, size = 'normal') { + const { account, amount, is_anon } = tipData; + + // 排名徽章颜色 + const badgeColors = ['#FFD700', '#C0C0C0', '#CD7F32']; // 金银铜 + const badgeBg = rank < 3 ? badgeColors[rank] : '#444'; + const badgeColor = rank < 3 ? '#000' : '#aaa'; + + // 尺寸配置 + const sizeConfig = { + small: { badge: 16, font: 11, padding: 3, avatar: 20 }, + normal: { badge: 20, font: 12, padding: 5, avatar: 24 }, + large: { badge: 24, font: 14, padding: 8, avatar: 28 } + }; + const cfg = sizeConfig[size] || sizeConfig.normal; + + // 等级图标 + const levelHtml = renderTipLevelHTML(amount, true); + + // 排名弽章 HTML(两个分支共用) + const badgeHtml = `${rank + 1}`; + + // 匿名用户处理 + if (is_anon) { + return ` +
+ + ${badgeHtml} + 🔒 匿名 + + + ${levelHtml} + ${amount} + +
+ `; + } + + // 🚀 SWR 模式:非匿名用户显示头像+用户名 + const containerId = `tip-board-item-${account}-${rank}-${Date.now()}`; + + // 从缓存获取初始数据(0延迟渲染) + const cached = getCachedProfile(account); + const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL; + const userName = cached?.name || account; + + // 后台静默校对并更新 DOM + setTimeout(() => { + getProfileWithSWR(account, api.getUserProfile, (profile) => { + const container = document.getElementById(containerId); + if (!container) return; + + const avatarImg = container.querySelector('.tip-board-avatar'); + const nameSpan = container.querySelector('.tip-board-name'); + + if (avatarImg && profile.avatar) { + avatarImg.src = profile.avatar; + } + if (nameSpan && profile.name) { + nameSpan.textContent = profile.name; + } + }); + }, 0); + + return ` +
+ + ${badgeHtml} + + + ${userName} + + + + ${levelHtml} + ${amount} + +
+ `; +} + +/** + * 生成完整的打赏榜单HTML + * @param {Array} boardData - 打赏榜数据数组 + * @param {number} maxShow - 最多显示数量 + * @param {string} emptyText - 无数据时的提示文字 + * @param {string} size - 尺寸 + * @returns {string} - 完整榜单HTML + */ +export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = "暂无打赏,快来成为首个赞赏人吧!", size = 'normal') { + if (!boardData || boardData.length === 0) { + return `
${emptyText}
`; + } + + return boardData.slice(0, maxShow).map((t, i) => renderTipBoardItemHTML(t, i, size)).join(''); +} + +/** + * 获取等级规则说明(简短格式,用于榜单标题下方) + * @returns {string} - 等级规则说明 + */ +export function getTipLevelRuleShort() { + return `1-100${t('tip.points')} = 1⭐ | 500${t('tip.points')} = 1🌙 | 2500${t('tip.points')} = 1☀️`; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\347\224\250\346\210\267\344\275\223\351\252\214_\345\233\275\351\231\205\345\214\226.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\347\224\250\346\210\267\344\275\223\351\252\214_\345\233\275\351\231\205\345\214\226.js" new file mode 100644 index 0000000000000000000000000000000000000000..4960d1e431cde50c71e4a7867d2078b619c8e634 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\347\224\250\346\210\267\344\275\223\351\252\214_\345\233\275\351\231\205\345\214\226.js" @@ -0,0 +1,2748 @@ +// 前端页面/components/用户体验_国际化.js +// ========================================== +// 🌐 P2-6: 多语言支持 (i18n) +// ========================================== +// 来源:从 用户体验增强.js 拆分,原文件第 500~3182 行 +// 特点: +// - 文案集中管理 +// - 动态切换语言 +// - 默认中文 + +let currentLang = 'zh-CN'; + +const translations = { + 'zh-CN': { + // 通用 + 'common.confirm': '确认', + 'common.cancel': '取消', + 'common.save': '保存', + 'common.saving': '保存中...', + 'common.delete': '删除', + 'common.deleting': '删除中...', + 'common.edit': '编辑', + 'common.close': '关闭', + 'common.reset': '重置', + 'common.loading': '加载中...', + 'common.chars': '字符', + 'common.digits': '位数', + 'common.success': '操作成功', + 'common.error': '操作失败', + 'common.retry': '重试', + 'common.sending': '发送中...', + 'common.retrying': '重试中...', + 'common.retry_failed': '重试失败,请稍后再试', + 'common.more': '更多', + 'common.back': '返回', + 'common.search': '搜索', + 'common.refresh': '刷新', + 'common.copy': '复制', + 'common.copied': '已复制', + 'common.share': '分享', + 'common.download': '下载', + 'common.upload': '上传', + 'common.select': '选择', + 'common.all': '全部', + 'common.none': '无', + 'common.yes': '是', + 'common.no': '否', + 'common.ok': '好的', + 'common.send': '发送', + 'common.reset': '重置', + 'common.install': '安装', + 'common.installed': '已安装', + 'common.update': '更新', + 'common.free': '免费', + 'common.paid': '付费', + + // 导航 + 'nav.tools': '插件榜', + 'nav.apps': '工作流', + 'nav.creators': '创作者', + 'nav.recommends': '推荐榜', + 'nav.posts': '讨论区', + 'nav.tasks': '任务榜', + 'nav.profile': '个人中心', + 'nav.settings': '设置', + 'nav.login': '登录 / 注册', + 'nav.logout': '退出登录', + 'nav.notifications': '消息', + 'nav.community': '社区精选', + + // 设置页面 + 'settings.title': '界面设置', + 'settings.general': '通用设置', + 'settings.display': '显示偏好', + 'settings.animation': '动画与音效', + 'settings.language': '界面语言', + 'settings.language_desc': '选择界面显示语言', + 'settings.creator_banner': '创作者卡片背景图', + 'settings.creator_banner_desc': '开启后创作者列表卡片显示个人背景图,关闭后显示简洁纯色风格', + 'settings.animations': '榜单切换动画', + 'settings.animations_desc': '开启后切换榜单时卡片将播放科技感载入动画', + 'settings.sound': '动画音效', + 'settings.sound_desc': '开启后动画播放时会发出科技感音效(需开启动画)', + 'settings.auto_save': '设置会自动保存到本地', + 'settings.language_changed': '语言已切换为', + 'settings.animation_preview': '动画效果预览', + 'settings.anim_cascade': '插件榜/工作流/推荐榜:瀑布式从左往右载入', + 'settings.anim_fan': '创作者榜:从下往上扇形展开', + 'settings.anim_abyss': '讨论区:伪3D深渊汇聚效果', + 'settings.anim_dataflow': '任务榜:数据流左右交替滑入', + 'settings.enabled': '已开启', + 'settings.disabled': '已关闭', + 'settings.data_cache': '💾 数据与缓存', + 'settings.cache_expire': '数据刷新间隔(秒)', + 'settings.cache_expire_desc': '设置本地缓存数据的刷新间隔,过期后切换页面时会自动后台更新数据。最小60秒,最大86400秒(1天)。', + 'settings.cache_trigger_desc': '当缓存超过设定时间后,切换页面标签时会自动在后台拉取最新数据并静默更新,不影响使用流畅度。手动刷新不受此设置影响。', + 'settings.cache_stats': '缓存统计', + 'settings.cache_stats_hint': '非卸载,不推荐清除磁盘缓存', + 'settings.cache_disk_images': '图片缓存', + 'settings.cache_disk_videos': '视频缓存', + 'settings.cache_browser': '浏览器缓存', + 'settings.clear_browser_cache': '清除浏览器缓存', + 'settings.clear_disk_cache': '清除磁盘缓存', + 'settings.clear_all_cache': '清除全部缓存', + 'settings.cache_clearing': '清理中...', + 'settings.cache_cleared': '缓存已清除', + 'settings.files': '个文件', + 'settings.no_cache': '无缓存', + + // 认证 + 'auth.login': '登录', + 'auth.title': '社区账号验证', + 'auth.register': '注册', + 'auth.logout': '退出', + 'auth.token_expired': '登录凭证已失效,请重新登录', + 'auth.email': '邮箱', + 'auth.password': '密码', + 'auth.confirm_password': '确认密码', + 'auth.username': '用户名', + 'auth.nickname': '昵称', + 'auth.forgot_password': '忘记密码?', + 'auth.forgot_account': '忘记账号?', + 'auth.recover_account': '找回账号', + 'auth.recover_desc': '忘记账号没关系,输入您注册时绑定的邮箱,通过验证码即可找回账号。', + 'auth.recover_success': '账号找回成功', + 'auth.your_account': '您的账号', + 'auth.go_reset_password': '去重置密码', + 'auth.reset_password': '重置密码', + 'auth.verification_code': '验证码', + 'auth.send_code': '发送验证码', + 'auth.resend_code': '重新发送', + 'auth.login_success': '登录成功', + 'auth.register_success': '注册成功', + 'auth.logout_success': '已退出登录', + 'auth.password_mismatch': '两次密码不一致', + 'auth.weak_password': '密码强度不足', + 'auth.account_exists': '账号已存在', + 'auth.invalid_credentials': '账号或密码错误', + 'auth.login_required': '请先登录您的社区账号!', + 'auth.resetting_password': '修改密码中...', + 'auth.password_reset_success': '密码修改成功!请使用新密码重新登录。', + 'auth.uploading_avatar': '上传头像中...', + 'auth.registering': '注册账号中...', + 'auth.auto_login': '!正在为您自动登录...', + 'auth.avatar_set_failed': '⚠️ 头像设置失败,您可稍后在个人设置中重新上传', + 'common.unknown_user': '未知用户', + 'auth.agree_terms': '我已阅读并同意', + 'auth.terms': '用户协议', + 'auth.and': '和', + 'auth.privacy': '隐私政策', + 'auth.account': '登录账号', + 'auth.remember_me': '保持登录 30 天', + 'auth.no_account': '没有账号?', + 'auth.register_now': '立即注册', + 'auth.account_password_required': '账号和密码不能为空!', + 'auth.display_name': '显示名称', + 'auth.set_password': '设置密码', + 'auth.security_email': '安全邮箱', + 'auth.security_email_desc': '用于接收验证码', + 'auth.get_code': '获取验证码', + 'auth.resend': '重发', + 'auth.avatar': '头像', + 'auth.gender': '性别', + 'auth.male': '男', + 'auth.female': '女', + 'auth.birthday': '出生日期', + 'auth.age_years': '岁', + 'auth.country': '国家', + 'auth.region': '地区', + 'auth.intro': '个人介绍', + 'auth.intro_limit': '限100字', + 'auth.register_submit': '注 册 账 号', + 'auth.has_account': '已有账号?', + 'auth.back_to_login': '返回登录', + 'auth.file_too_large': '头像处理失败,请更换一张图片再试', + 'auth.code_sent': '验证码已发送至邮箱,请查收', + 'auth.sending': '发送中...', + 'auth.required_fields': '带 * 号的均为必填项!', + 'auth.invalid_code': '请输入 6 位有效验证码!', + 'auth.account_format_error': '账号必须大于5个字符,且仅支持大小写英文字母、数字和下划线!', + 'auth.password_format_error': '密码必须大于等于6个字符!', + 'auth.new_password': '新密码', + 'auth.reset_submit': '重 置 密 码', + 'auth.reset_desc': '输入注册时的安全邮箱,通过验证码重置密码', + 'auth.verification_code': '验证码', + 'auth.phone': '手机号码', + 'auth.invalid_email': '请输入有效的安全邮箱!', + 'auth.confirm_password': '确认密码', + 'auth.password_mismatch': '两次输入的密码不一致!', + 'auth.current_account': '当前账号', + 'auth.enter_account': '输入要修改的账号', + 'auth.security_verify': '安全验证', + 'auth.bound_email': '绑定的邮箱', + 'auth.enter_bound_email': '请输入绑定的安全邮箱', + 'auth.enter_6_digit_code': '输入 6 位验证码', + 'auth.old_password': '原密码', + 'auth.old_password_tip': '如果是找回密码可留空', + 'auth.old_password_placeholder': '如忘记原密码请留空', + 'auth.confirm_new_password': '确认新密码', + 'auth.change_password': '修 改 密 码', + 'auth.enter_account_first': '请先输入要修改密码的当前账号!', + 'auth.reset_required_fields': '账号、安全验证、验证码和新密码均为必填项!', + + // 个人中心 + 'profile.my_profile': '我的资料', + 'profile.my_published': '我的发布', + 'profile.their_published': 'TA的发布', + 'profile.my_purchased': '我购买的', + 'profile.my_tasks': '我的任务', + 'profile.transactions': '交易明细', + 'profile.my_posts': '我的帖子', + 'profile.my_collected': '我的收藏', + 'profile.recent_likes': '近期点赞', + 'profile.following': '关注的人', + 'profile.max_privilege': '最高特权', + 'profile.beta_max_privilege': '内测最高特权', + 'profile.edit_profile': '编辑资料', + 'profile.my_works': '我的作品', + 'profile.my_purchases': '我获取的', + 'profile.my_favorites': '我收藏的', + 'profile.my_following': '我关注的', + 'profile.my_followers': '关注我的', + 'profile.my_wallet': '我的钱包', + 'profile.transactions': '交易记录', + 'profile.statistics': '数据统计', + 'profile.followers': '粉丝', + 'profile.following': '关注', + 'profile.works': '作品', + 'profile.likes': '获赞', + 'profile.bio': '个人简介', + 'profile.location': '所在地', + 'profile.website': '个人网站', + 'profile.joined': '加入时间', + 'profile.follow': '关注', + 'profile.unfollow': '取消关注', + 'profile.message': '私信', + 'profile.block': '拉黑', + 'profile.report': '举报', + + // 表单 + 'form.title': '标题', + 'form.description': '描述', + 'form.price': '价格', + 'form.submit': '提交', + 'form.draft_found': '发现未保存的草稿,是否恢复?', + 'form.draft_restored': '草稿已恢复', + 'form.draft_cleared': '草稿已清除', + 'form.required': '必填', + 'form.optional': '选填', + 'form.placeholder': '请输入...', + + // 钱包 + 'wallet.balance': '余额', + 'wallet.recharge': '充值', + 'wallet.withdraw': '提现', + 'wallet.tip': '打赏', + 'wallet.purchase': '购买', + 'wallet.income': '收入', + 'wallet.expense': '支出', + 'wallet.points': '积分', + 'wallet.sales': '销售收入', + 'wallet.tips_received': '打赏收入', + 'wallet.current_balance': '当前余额', + 'wallet.credits': '积分', + 'wallet.insufficient_balance': '余额不足,请前往充值', + + // 交易记录类型 + 'tx.withdraw': '💸 提现申请', + 'tx.tip_in': '🎁 收到打赏', + 'tx.tip_out': '🎁 打赏支出', + 'tx.purchase': '🛒 购买资源', + 'tx.task_deposit': '📋 任务订金', + 'tx.task_payment': '📋 任务尾款', + 'tx.task_income': '📋 任务收入', + 'tx.task_freeze': '🔒 任务冻结', + 'tx.task_refund': '↩️ 任务退款', + 'tx.task_cancel_refund': '🔄 任务取消退款', + 'tx.sale': '销售收入', + 'tx.recharge': '💰 充值', + 'tx.refund': '↩️ 退款', + 'tx.withdraw_fee': '💸 手续费', + 'tx.withdraw_pending': '待打款', + 'tx.withdraw_completed': '已完成', + 'tx.recharge_desc': '支付宝充值', + 'tx.withdraw_fee_desc': '提现手续费', + 'tx.net_amount': '实到', + + // 充值相关 + 'wallet.recharge.title': '💰 积分充值中心', + 'wallet.recharge.current_balance': '当前账号余额', + 'wallet.recharge.select_amount': '选择充值金额', + 'wallet.recharge.exchange_rate': '(1 积分 = 1 元)', + 'wallet.recharge.points_suffix': '积分', + 'wallet.recharge.price_prefix': '¥', + 'wallet.recharge.custom': '自定义', + 'wallet.recharge.any_amount': '任意金额', + 'wallet.recharge.payment_method': '支付方式', + 'wallet.recharge.alipay': '📘 支付宝', + 'wallet.recharge.wechat': '📗 微信支付(暂未开通)', + 'wallet.recharge.get_qr': '获取支付二维码', + 'wallet.recharge.connecting': '⏳ 正在连接支付网关...', + 'wallet.recharge.scan_to_pay': '请使用手机扫码支付', + 'wallet.recharge.yuan': '元', + 'wallet.recharge.auto_redirect': '支付成功后此处将自动跳转,请勿关闭弹窗', + 'wallet.recharge.invalid_amount': '请输入 6 位以内有效的整数充值金额!', + 'wallet.recharge.order_failed': '❌ 创建订单失败: ', + 'wallet.recharge.success': '✅ 充值成功!已实时到账 {points} 积分。', + 'wallet.recharge.custom_placeholder': '请输入自定义金额 (1~10000)', + 'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元', + 'wallet.recharge.processing': '处理中...', + + // 提现相关 + 'wallet.withdraw.title': '💳 收益提现中心', + 'wallet.withdraw.total_withdrawable': '总可提现积分', + 'wallet.withdraw.rmb_equivalent': '折合人民币 (扣除手续费前)', + 'wallet.withdraw.amount_label': '提现金额 (积分)', + 'wallet.withdraw.amount_placeholder': '1积分 = 1元', + 'wallet.withdraw.alipay_account': '收款支付宝账号', + 'wallet.withdraw.alipay_placeholder': '手机号或邮箱', + 'wallet.withdraw.real_name': '真实姓名 (需与支付宝实名一致)', + 'wallet.withdraw.real_name_placeholder': '收款人真实姓名', + 'wallet.withdraw.security_code': '安全验证码 (将发送至您绑定的邮箱)', + 'wallet.withdraw.code_placeholder': '6位验证码', + 'wallet.withdraw.get_code': '获取验证码', + 'wallet.withdraw.sending': '发送中...', + 'wallet.withdraw.resend_in': '{seconds}s 后重发', + 'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)', + 'wallet.withdraw.submitting': '⏳ 提交中...', + 'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!', + 'wallet.withdraw.min_amount': '最低提现额度为 1 积分!', + 'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!', + 'wallet.withdraw.invalid_code': '请输入 6 位有效验证码!', + 'wallet.withdraw.code_sent': '验证码已发送,请查收邮箱 (注意检查垃圾箱)', + 'wallet.withdraw.code_failed': '发送失败: ', + 'wallet.withdraw.success': '🎉 提现申请提交成功!预计 1-3 个工作日内打款至您的支付宝。', + 'wallet.withdraw.submit_failed': '提交失败: ', + 'wallet.withdraw.fee_free_quota': '🎉 您目前享有平台 100元 内免手续费优惠福利!', + 'wallet.withdraw.fee_full_amount': '预计全额到账:', + 'wallet.withdraw.fee_quota_exhausted': '⚠️ 平台累计免手续费额度 (100积分) 已耗尽。', + 'wallet.withdraw.fee_deducted': '本次提现需扣除 10% 平台维护手续费:', + 'wallet.withdraw.fee_actual': '扣除后预计实际到账:', + 'wallet.withdraw.yuan': '元', + 'wallet.withdraw.max_amount_exceeded': '单次提现金额不得超过5000元', + + // 市场 + 'market.latest': '最新', + 'market.popular': '热门', + 'market.downloads': '下载量', + 'market.favorites': '收藏量', + 'market.publish': '发布', + 'market.detail': '详情', + 'market.comments': '评论', + 'market.reviews': '评价', + 'market.like': '点赞', + 'market.liked': '已赞', + 'market.favorite': '收藏', + 'market.favorited': '已收藏', + 'market.views': '🔥 浏览总量', + 'market.daily_views': '🔥 今日热门', + 'market.rating': '★ 评分最高', + 'market.install_now': '立即安装', + 'market.load_workflow': '加载工作流', + 'market.price_free': '免费', + 'market.price_unit': '积分', + 'market.tips_ranking': '近期打赏榜', + + // 社交 + 'social.chat': '聊天', + 'social.messages': '消息', + 'social.notifications': '通知', + 'social.comment': '评论', + 'social.reply': '回复', + 'social.share': '分享', + 'social.mention': '@提及', + 'social.new_message': '新消息', + 'social.no_messages': '暂无消息', + 'social.type_message': '输入消息...', + + // 任务 + 'task.create': '发布任务', + 'task.apply': '申请接单', + 'task.deposit': '订金', + 'task.final_payment': '尾款', + 'task.deadline': '截止时间', + 'task.budget': '预算', + 'task.status': '状态', + 'task.pending': '待接单', + 'task.in_progress': '进行中', + 'task.completed': '已完成', + 'task.cancelled': '已取消', + + // 反馈 + 'feedback.loading': '处理中...', + 'feedback.success': '操作成功', + 'feedback.error': '操作失败,请重试', + 'feedback.network_error': '网络连接失败', + 'feedback.offline': '当前处于离线状态', + 'feedback.timeout': '请求超时', + 'feedback.saved': '已保存', + 'feedback.logged_out': '已退出登录', + 'feedback.login_required': '请先登录您的账号!', + 'feedback.login_required_tip': '请先登录您的账号后再进行赞赏!', + 'feedback.operation_failed': '操作失败:', + 'feedback.user_fetch_failed': '获取作者信息失败,可能该账号已注销。', + + // 确认弹窗 + 'confirm.logout': '确定要退出当前账号吗?', + 'confirm.delete': '确定要删除吗?', + 'confirm.yes': '确定', + 'confirm.no': '取消', + + // 社交 + 'social.follow': '关注作者', + 'social.followed': '已关注', + 'social.unfollow': '取消关注', + 'social.send_message': '发消息', + 'social.tip_author': '赞赏作者', + 'social.say_something': '说点什么...', + 'social.reply': '回复', + 'social.reply_to': '回复', + 'social.comment_deleted': '此条评论已由用户删除', + 'social.delete_comment_confirm': '确定要删除这条评论吗?删除后将无法恢复。', + 'social.no_comments': '暂无留言,来抢沙发吧~', + + // 通用补充 + 'common.send': '发送', + 'common.delete': '删除', + + // 反馈补充 + 'feedback.send_failed': '发送失败', + + // 评分 + 'rating.stars': '评分', + 'rating.count': '{count}人评分', + 'rating.your_score': '你的评分', + 'rating.login_required': '登录后评分', + 'rating.self_forbidden': '不能给自己的作品评分', + 'rating.sixth_star_tip': '超过90%的用户给出了五星好评!', + 'rating.submit_success': '评分成功', + 'rating.submit_failed': '评分失败,请重试', + + // 通知中心 + 'notif.login_required': '请先登录您的社区账号查看消息!', + 'notif.title': '消息与通知', + 'notif.clear': '清空记录', + 'notif.no_messages': '暂无任何消息通知', + 'notif.private_msg': '给您发送了私信', + 'notif.followed_you': '关注了您', + 'notif.liked': '点赞了您的作品', + 'notif.favorited': '收藏了您的作品', + 'notif.commented_on': '在您的作品中回复了您', + 'notif.replied_on': '回复了您的评论', + 'notif.purchased': '购买了您的作品', + 'notif.income_received': ',收益已入账!', + 'notif.refunded': '退回了作品', + 'notif.tipped_you': '打赏了您!', + 'notif.task_apply': '申请了您的任务', + 'notif.task_assigned': '您已被指派接单', + 'notif.task_submitted': '已提交任务成果', + 'notif.task_completed': '任务已完成', + 'notif.task_rejected': '任务验收未通过', + 'notif.task_disputed': '对任务发起了申诉', + 'notif.dispute_responded': '已回应您的申诉', + 'notif.dispute_resolved': '申诉已裁决', + 'notif.system_announcement': '系统公告', + 'notif.clear_confirm': '确定要清空本地的所有通知记录吗?云端7天前的记录不会再同步下来。', + 'notif.cleared': '通知已清空', + 'notif.plugin_update_title': '插件更新', + 'notif.plugin_update': '插件', + 'notif.click_to_view': '点击查看', + 'notif.item_not_found': '资源不存在或已被删除', + 'notif.load_item_failed': '加载资源失败,请稍后重试', + + // 私信聊天 + 'chat.title': '私信聊天', + 'chat.clear_messages': '清空消息', + 'chat.input_placeholder': '输入消息...', + 'chat.no_conversations': '暂无对话', + 'chat.me': '我', + 'chat.view_profile': '点击查看个人资料', + 'chat.time_just_now': '刚刚', + 'chat.time_min_ago': '分钟前', + 'chat.time_hour_ago': '小时前', + 'chat.time_day_ago': '天前', + 'chat.date_month': '月', + 'chat.date_day': '日', + 'chat.load_more': '加载更早的消息', + 'chat.msg_too_long': '消息不能超1000字', + 'chat.load_list_failed': '加载对话列表失败', + 'chat.load_history_failed': '加载消息记录失败', + 'chat.load_failed_hint': '消息加载失败', + 'chat.retry': '重试', + 'chat.confirm_clear': '确定要清空与该用户的本地聊天记录吗?', + 'chat.cleared': '消息已清空', + 'chat.chatting_with': '与', + 'chat.chatting_suffix': '聊天', + + // 创作者 + 'creator.no_tips': '暂无赞赏记录', + 'creator.anonymous': '匿名用户', + 'creator.loading_details': '加载详情中...', + 'creator.load_failed': '加载失败', + 'creator.retry': '重试', + 'creator.usage_count': '被使用 {count} 次', + 'creator.stats.likes': '获赞', + 'creator.stats.favorites': '收藏', + 'creator.stats.followers': '粉丝', + 'creator.output.tools': '产出插件', + 'creator.output.apps': '产出工作流', + 'creator.output.unit': '个', + 'creator.chart.download_trend': '📈 下载量增长趋势', + 'creator.chart.loading': '正在加载图表...', + 'creator.chart.load_failed': '图表库加载失败', + 'creator.chart.series.tools': '插件下载', + 'creator.chart.series.apps': '工作流下载', + 'creator.chart.series.recommends': '推荐榜获取', + 'creator.tip_board.title': '🎁 赞赏贡献总榜', + 'creator.tip_board.count': '共{count}人', + 'creator.tip_this_creator': '赞赏此创作者', + 'creator.tip_level_rule': '等级规则', + 'creator.cannot_tip_self': '不能给自己打赏', + 'creator.tip_login_required': '请先登录', + 'creator.message_board': '💬 创作者留言板', + 'creator.visit_profile': '访问 TA 的主页', + + // 通用补充 + 'common.back': '返回', + + // 市场 + 'market.uses': '使用', + 'market.points': '积分', + 'market.free': '免费', + 'market.author': '发布者', + 'market.update_available': '可更新', + 'market.installed': '已安装', + 'market.get_now': '立即获取', + 'market.liked': '已赞', + 'market.like': '点赞', + 'market.favorited': '已收藏', + 'market.favorite': '收藏', + 'market.usage_trend': '使用量增长曲线', + 'market.full_description': '详细说明', + 'market.no_tips_yet': '该资源暂无专属打赏,快来成为首个赞赏人吧!', + 'market.tip_board': '赞赏贡献榜', + 'market.tip': '打赏', + 'market.creator_manage': '创作者管理', + 'market.edit_content': '修改内容', + 'market.delete_permanently': '永久删除', + 'market.delete_confirm': '🚨 警告:确定要彻底删除该发布内容吗?

该操作不可逆,且相关的所有评论数据也将被同步永久清空。', + 'item.refundable': '支持退款', + 'item.confirm_refund_title': '确认退款', + 'item.confirm_refund': '确认退款', + 'item.refund_amount': '退款金额:', + 'item.confirm_delete_title': '确认删除', + 'item.confirm_delete': '确认删除', + 'item.deleting': '删除中...', + 'item.regret_mode': '后悔模式:还剩', + 'item.hours_left': '{hours} 小时可申请退款', + 'item.apply_refund': '申请退款', + 'item.refund_warning': '退款后权限将被收回,已下载的文件将被删除,且 30天内禁止再次购买 此商品', + 'item.refund_expired': '已购买此资源,退款窗口已过期', + 'item.product': '商品:', + 'item.untitled': '未命名资源', + 'item.invalid_id_tip': '资源ID无效,无法打赏', + 'item.refund_consequence': '退款后果', + 'item.refund_consequence_1': '您将失去此资源的访问权限', + 'item.refund_consequence_2': '已下载的文件将被自动删除', + 'item.refund_consequence_3': '30天内禁止再次购买此商品', + 'item.refund_success': '退款成功', + 'item.credits_refunded': '积分已退还到您的账户', + 'item.ban_days_notice': '{days}天内禁止再次购买此商品', + 'item.refund_failed_retry': '退款失败,请重试', + 'item.refund_request_failed': '退款请求失败,请检查网络', + 'item.content_label': '内容:', + 'item.type_label': '类型:', + 'item.type_unknown': '未知', + 'item.delete_consequence': '删除后果(不可恢复)', + 'item.delete_consequence_1': '该内容将从市场永久移除', + 'item.delete_consequence_2': '所有相关评论将被删除', + 'item.delete_consequence_3': '已购买用户将无法再下载', + 'item.delete_success': '内容已删除', + 'item.delete_failed_retry': '删除失败,请重试', + 'item.delete_request_failed': '删除请求失败,请检查网络', + 'item.no_tips_yet': '该资源暂无专属打赏,快来成为首个赞赏人吧!', + 'item.author': '作者', + 'item.original_notice': '原创内容,请勿商用', + 'item.tip_encourage': '赞赏鼓励该作品', + 'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)', + 'item.owned_reinstall': '已拥有 (点击重新覆盖安装)', + 'item.owned_purchased': '已购买 (请在电脑端安装)', + 'item.update_available': '发现新版本 (点击静默热更新)', + 'item.get_now': '立即获取使用', + 'item.free': '完全免费', + 'item.detail_info': '内容详情与介绍', + 'item.other_info': '其它信息', + + // 时间 + 'time.hours_later': '小时后生效', + + // 反馈补充 + 'feedback.deleted': '已成功删除', + 'feedback.delete_failed': '删除失败', + 'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据', + + // 通用 + 'common.processing': '处理中', + 'common.got_it': '知道了', + + // 确认框 + 'confirm.delete_title': '确认删除', + 'confirm.delete_message': '此操作不可撤销,确定要删除吗?', + 'confirm.logout_title': '确认退出', + 'confirm.logout_message': '确定要退出登录吗?', + + // 时间 + 'time.just_now': '刚刚', + 'time.minutes_ago': '{n}分钟前', + 'time.hours_ago': '{n}小时前', + 'time.days_ago': '{n}天前', + 'time.months_ago': '{n}个月前', + 'time.deadline_today': '今天截止', + 'time.deadline_tomorrow': '明天截止', + 'time.deadline_days': '{n}天后', + 'time.deadline_ended': '已截止', + 'time.unlimited': '无限期', + 'time.expired': '已过期', + + // 任务榜 + 'task.title': '任务榜', + 'task.publish': '📝 发布任务', + 'task.arbitrate': '⚖️ 仲裁', + 'task.filter_all': '全部', + 'task.filter_open': '🟢 开放接单', + 'task.filter_in_progress': '🔵 进行中', + 'task.filter_submitted': '🟡 待验收', + 'task.filter_completed': '✅ 已完成', + 'task.filter_disputed': '⚠️ 争议中', + 'task.sort_latest': '最新发布', + 'task.sort_price': '价格最高', + 'task.sort_deadline': '截止日期', + 'task.sort_views': '🔥 浏览总量', + 'task.sort_daily_views': '🔥 今日热门', + 'task.sort_likes': '👍 最多点赞', + 'task.sort_favorites': '🔖 最多收藏', + 'task.status_open': '开放接单', + 'task.status_in_progress': '进行中', + 'task.status_submitted': '待验收', + 'task.status_completed': '已完成', + 'task.status_disputed': '争议中', + 'task.status_cancelled': '已取消', + 'task.status_expired': '已过期', + 'task.status_unknown': '未知', + 'task.overdue': '逾期', + 'task.loading': '加载中...', + 'task.load_more': '加载更多', + 'task.no_tasks': '暂无任务', + 'task.be_first': '成为第一个发布任务的人吧!', + 'task.load_failed': '加载失败,请稍后重试', + 'task.network_cache': '网络异常,已加载本地缓存', + 'task.points': '积分', + 'task.applicants': '人申请', + 'task.login_first': '请先登录后再发布任务', + + // 任务详情 + 'task.detail_title': '任务详情', + 'task.deposit_ratio': '订金', + 'task.published_at': '发布于', + 'task.deadline_label': '截止日期', + 'task.description_label': '📄 任务描述', + 'task.ref_images': '🖼️ 参考图片', + 'task.ref_link': '🔗 参考链接', + 'task.assignee_label': '👷 接单者', + 'task.deliverables': '📦 交付成果', + 'task.deliverables_note': '备注', + 'task.applicants_title': '👥 申请者', + 'task.select_applicant': '选择TA', + 'task.assign_confirm': '确定选择 {name} 作为接单者吗?\n\n将从您的账户扣除订金 {amount} 积分', + 'task.assign_success': '指派成功!', + 'task.assign_failed': '指派失败', + 'task.login_to_operate': '请登录后操作', + 'task.cancel_task': '❌ 取消任务', + 'task.edit_task': '编辑任务', + 'task.title_label': '任务标题', + 'task.description_label': '任务描述', + 'task.title_required': '请输入任务标题', + 'task.edit_success': '任务修改成功', + 'task.edit_failed': '任务修改失败', + 'task.edit_limited_notice': '任务已有人接单,仅可修改标题、描述和参考链接', + 'task.delete_confirm_title': '确认删除任务', + 'task.delete_confirm_desc': '删除后无法恢复,确定要删除这个任务吗?', + 'task.delete_success': '任务已删除', + 'task.delete_failed': '删除失败', + 'task.cancel_apply': '撤回申请', + 'task.apply_task': '🙋 申请接单', + 'task.submit_result': '📤 提交成果', + 'task.accept': '✅ 验收通过', + 'task.reject': '❌ 验收不通过', + 'task.dispute': '⚖️ 发起申诉', + 'task.view_dispute': '⚖️ 查看申诉详情', + 'task.apply_message': '请输入申请留言(可选):', + 'task.apply_success': '申请成功!', + 'task.apply_failed': '申请失败', + 'task.cancel_apply_confirm': '确定撤回申请吗?', + 'task.cancel_apply_success': '已撤回申请', + 'task.cancel_apply_failed': '撤回失败', + 'task.cancel_confirm': '确定取消任务吗?此操作不可撤销。', + 'task.cancel_success': '任务已取消', + 'task.cancel_failed': '取消失败', + 'task.accept_confirm': '确定验收通过吗?\n\n将支付尾款 {amount} 积分给接单者', + 'task.accept_success': '验收完成!款项已支付', + 'task.reject_reason': '请输入不通过的原因:', + 'task.reject_success': '已退回修改', + 'task.operation_failed': '操作失败', + 'task.private_message': '💬 私信', + 'task.post_not_exist': '帖子不存在或已被删除', + + // 提交成果弹窗 + 'task.submit_title': '📤 提交成果', + 'task.submit_images': '交付图片 *', + 'task.submit_images_multi': '支持多张图片', + 'task.submit_note': '备注说明(可选)', + 'task.submit_note_placeholder': '对交付内容的说明...', + 'task.submit_confirm': '确认提交', + 'task.submit_uploading': '⏳ 上传中...', + 'task.submit_progress': '⏳ 上传 ({current}/{total})...', + 'task.submit_failed': '提交失败', + 'task.submit_upload_failed': '图片上传失败,请重试', + 'task.submit_submitting': '⏳ 提交中...', + 'task.submit_success': '成果已提交,等待发布者验收', + 'task.upload_images_required': '请上传交付图片', + + // 发布任务 + 'task.publish_title': '📝 发布任务', + 'task.title_label': '📌 任务标题', + 'task.title_placeholder': '简洁描述您的需求,50字以内', + 'task.desc_label': '📄 任务描述', + 'task.desc_placeholder': '详细描述任务要求、交付标准、参考说明等...', + 'task.images_label': '🖼️ 参考图片(可选,最多6张)', + 'task.add_images': '+ 添加参考图片', + 'task.link_label': '🔗 参考链接(可选)', + 'task.total_price': '💰 总价格', + 'task.deposit_ratio_label': '📊 订金比例', + 'task.deduct_on_assign': '接单时扣除', + 'task.price_min': '最低10积分', + 'task.price_summary': '💡 价格说明', + 'task.price_total': '总价格', + 'task.price_deposit': '订金(指派接单时扣除)', + 'task.price_remaining': '尾款(验收通过时扣除)', + 'task.deadline_label_form': '⏰ 截止日期', + 'task.publish_submit': '🚀 发布任务', + 'task.publish_notice': '发布后可在「任务榜」中管理您的任务', + 'task.title_required': '请输入任务标题', + 'task.desc_required': '请输入任务描述', + 'task.price_min_error': '任务价格不能低于10积分', + 'task.deadline_required': '请选择截止日期', + 'task.publish_uploading': '⏳ 上传图片中...', + 'task.publish_progress': '⏳ 上传图片 ({current}/{total})...', + 'task.publish_submitting': '⏳ 发布中...', + 'task.publish_success': '任务发布成功!', + 'task.publish_failed': '发布失败,请稀后重试', + 'task.max_images': '最多上传6张图片', + + // 申诉相关 + 'dispute.title': '申诉详情', + 'dispute.status_pending': '等待回应', + 'dispute.status_responded': '等待仲裁', + 'dispute.status_resolved': '已裁决', + 'dispute.task_label': '📋 关联任务', + 'dispute.parties': '👥 争议双方', + 'dispute.publisher': '发布者', + 'dispute.assignee': '接单者', + 'dispute.initiator': '(申诉方)', + 'dispute.respondent': '(被申诉方)', + 'dispute.initiator_statement': '📝 申诉方陈述', + 'dispute.respondent_response': '💬 被申诉方回应', + 'dispute.no_response': '暂无回应', + 'dispute.respond_placeholder': '请输入您的回应说明...', + 'dispute.upload_evidence': '📷 点击上传证据图片(可选)', + 'dispute.submit_response': '提交回应', + 'dispute.submitting': '提交中...', + 'dispute.response_success': '回应已提交', + 'dispute.response_failed': '提交失败', + 'dispute.resolution_title': '⚖️ 仲裁结果', + 'dispute.favor_initiator': '✅ 支持申诉方', + 'dispute.favor_respondent': '❌ 支持被申诉方', + 'dispute.split': '⚖️ 双方协商分成', + 'dispute.split_detail': '申诉方 {ratio}% : 被申诉方 {other}%', + 'dispute.resolved': '已裁决', + 'dispute.resolved_at': '裁决于', + 'dispute.submitted_at': '提交于', + 'dispute.responded_at': '回应于', + 'dispute.reason_required': '请输入申诉理由', + 'dispute.submit_dispute': '提交申诉', + 'dispute.uploading': '⏳ 上传图片 ({current}/{total})...', + 'dispute.submitting_dispute': '⏳ 提交申诉...', + 'dispute.dispute_success': '申诉已提交,等待对方回应', + 'dispute.dispute_failed': '申诉失败', + 'dispute.dialog_title': '⚖️ 发起申诉', + 'dispute.reason_label': '申诉理由', + 'dispute.evidence_label': '证据图片(可选,最多6张)', + 'dispute.notice_title': '⚠️ 注意事项:', + 'dispute.notice_1': '申诉后任务将进入争议状态,等待管理员仲裁', + 'dispute.notice_2': '对方将有机会回应您的申诉', + 'dispute.notice_3': '请提供真实有效的证据', + 'dispute.max_images': '最多上传6张图片', + + // 讨论区/帖子 + 'post.title': '讨论区', + 'post.publish': '发布作品', + 'post.loading': '加载中...', + 'post.load_more': '加载更多', + 'post.no_posts': '还没有人发布作品', + 'post.be_first': '成为第一个分享创作的人吧!', + 'post.load_failed': '加载失败,请稍后重试', + 'post.network_cache': '网络异常,已加载本地缓存', + 'post.login_first': '请先登录后再发布作品', + 'post.video_tag': '视频', + + // 帖子详情 + 'post.sort_latest': '最新', + 'post.sort_likes': '👍 最多点赞', + 'post.sort_favorites': '🔖 最多收藏', + 'post.sort_tips': '💰 打赏榜', + 'post.sort_views': '🔥 浏览总量', + 'post.sort_daily_views': '🔥 今日热门', + 'post.sort_rating': '★ 评分最高', + 'post.detail_title': '📄 作品详情', + 'post.not_exist': '帖子不存在或已被删除', + 'post.load_detail_failed': '加载失败', + 'post.liked': '已点赞', + 'post.favorited': '已收藏', + 'post.tip_author': '🎁 打赏', + 'post.tip_dialog_title': '🎁 打赏作者', + 'post.tip_self': '不能打赏自己的作品', + 'post.tip_success': '成功打赏 {amount} 积分', + 'post.tip_failed': '打赏失败', + 'post.comments_title': '💬 评论', + 'post.comment_placeholder': '写下你的评论...', + 'post.no_comments': '暂无评论,来说两句吧~', + 'post.comment_loading': '加载评论中...', + 'post.comment_load_failed': '加载评论失败', + 'post.comment_required': '请输入评论内容', + 'post.comment_sending': '发送中...', + 'post.comment_success': '评论成功', + 'post.comment_failed': '评论失败', + 'post.tip_board_title': '🎁 打赏榜', + 'post.no_tips': '🎁 暂无打赏记录', + + // ✨ 提示词市场 + 'nav.prompts': '提示词', + 'prompt.title': '提示词市场', + 'prompt.tab_image': '图像提示词', + 'prompt.tab_video': '视频提示词', + 'prompt.tab_music': '音乐提示词', + 'prompt.original_badge': '原创内容', + 'prompt.category_all': '全部', + 'prompt.category_load_failed': '分类加载失败', + // 🏷️ 内置分类双语映射(用户自定义分类原样显示) + 'promptcat.人物': '人物', + 'promptcat.动物': '动物', + 'promptcat.植物': '植物', + 'promptcat.道具': '道具', + 'promptcat.建筑': '建筑', + 'promptcat.动作': '动作', + 'promptcat.表情变化': '表情变化', + 'promptcat.天气变化': '天气变化', + 'promptcat.光效变化': '光效变化', + 'promptcat.运镜': '运镜', + 'promptcat.视觉风格': '视觉风格', + 'promptcat.中国风': '中国风', + 'promptcat.流行': '流行', + 'promptcat.说唱': '说唱', + 'promptcat.摇滚': '摇滚', + 'promptcat.电音': '电音', + 'promptcat.纯音乐': '纯音乐', + // 🏷️ 标签 + 'prompt.tag_label': '标签', + 'prompt.tag_placeholder': '输入标签后回车添加', + 'prompt.tag_recommend': '推荐标签(点击快速添加)', + 'prompt.tag_max': '最多添加10个标签', + 'prompt.tag_no_recommend': '暂无推荐标签', + 'prompt.no_prompts': '还没有人发布提示词', + 'prompt.be_first': '成为第一个分享优质提示词的人吧!', + 'prompt.loading': '加载中...', + 'prompt.load_failed': '加载失败,请稍后重试', + 'prompt.network_cache': '网络异常,已加载本地缓存', + 'prompt.free': '免费', + 'prompt.sort_latest': '最新', + 'prompt.sort_likes': '👍 最多点赞', + 'prompt.sort_favorites': '🔖 最多收藏', + 'prompt.sort_views': '🔥 浏览总量', + 'prompt.sort_daily_views': '🔥 今日热门', + 'prompt.detail_title': '✨ 提示词详情', + 'prompt.not_exist': '提示词不存在或已被删除', + 'prompt.load_detail_failed': '加载失败', + 'prompt.liked': '已点赞', + 'prompt.favorited': '已收藏', + 'prompt.tip_author': '🎁 打赏', + 'prompt.tip_dialog_title': '🎁 打赏作者', + 'prompt.tip_self': '不能打赏自己的提示词', + 'prompt.tip_success': '成功打赏 {amount} 积分', + 'prompt.tip_failed': '打赏失败', + 'prompt.comments_title': '💬 评论', + 'prompt.comment_placeholder': '写下你的评论...', + 'prompt.no_comments': '暂无评论,来说两句吧~', + 'prompt.comment_required': '请输入评论内容', + 'prompt.comment_success': '评论成功', + 'prompt.comment_failed': '评论失败', + 'prompt.tip_board_title': '🎁 打赏榜', + 'prompt.no_tips': '🎁 暂无打赏记录', + 'prompt.locked_title': '付费内容', + 'prompt.locked_desc': '购买后才能查看完整提示词', + 'prompt.unlock': '{price} 积分解锁', + 'prompt.owned_mark': '✅ 已购买', + 'prompt.copy': '一键复制', + 'prompt.copied': '提示词已复制到剪贴板', + 'prompt.copy_failed': '复制失败,请手动复制', + 'prompt.purchase_confirm_title': '确认购买', + 'prompt.purchase_confirm_desc': '花费 {price} 积分购买《{title}》?购买后可查看完整提示词。', + 'prompt.purchase_balance': '当前余额', + 'prompt.purchase_success': '购买成功,提示词已解锁', + 'prompt.purchase_failed': '购买失败', + 'prompt.balance_not_enough': '余额不足,请先充值', + 'prompt.login_first': '请先登录', + 'prompt.publish_title': '✨ 发布提示词', + 'prompt.edit_title': '✏️ 编辑提示词', + 'prompt.publish_submit': '发布', + 'prompt.save_submit': '保存', + 'prompt.type_label': '提示词类型', + 'prompt.category_label': '所属分类', + 'prompt.category_select': '请选择分类', + 'prompt.title_label': '标题', + 'prompt.title_placeholder': '给提示词起个标题...', + 'prompt.content_label': '公开介绍', + 'prompt.content_placeholder': '介绍提示词的用途、效果与使用心得...', + 'prompt.text_label': '提示词正文(付费内容)', + 'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...', + 'prompt.price_label': '定价', + 'prompt.video_label': '示例视频', + 'prompt.title_required': '请输入标题', + 'prompt.content_required': '请输入介绍文案', + 'prompt.text_required': '请输入提示词正文', + 'prompt.category_required': '请选择分类', + 'prompt.publish_success': '发布成功!', + 'prompt.update_success': '保存成功!', + 'prompt.publish_failed': '发布失败', + 'prompt.submitting': '发布中', + 'prompt.purchasing': '购买中', + 'prompt.notice_title': '发布须知', + 'prompt.notice_1': '提示词正文发布后仅购买者可查看', + 'prompt.notice_2': '介绍文案与封面将公开展示', + 'prompt.notice_3': '发布后可在个人中心管理', + 'prompt.delete_confirm': '确定删除这条提示词吗?', + 'prompt.deleted': '删除成功', + 'prompt.edit': '✏️ 编辑', + 'prompt.delete': '🗑️ 删除', + 'prompt.views': '浏览', + 'prompt.daily_views': '今日', + 'prompt.my_prompts': '我的提示词', + + // 发布帖子 + 'post.publish_title': '✏️ 发布作品', + 'post.publish_submit': '🚀 发布', + 'post.images_label': '🖼️ 上传图片', + 'post.images_required': '(最多9张,第一张为封面)', + 'post.images_placeholder': '点击或拖拽上传图片', + 'post.title_label': '📝 标题', + 'post.title_placeholder': '给你的作品起个标题...', + 'post.content_label': '✍️ 正文', + 'post.content_placeholder': '分享你的创作心得、使用技巧...', + 'post.notice_title': '💡 发布须知:', + 'post.notice_1': '请上传原创作品或标明出处', + 'post.notice_2': '图片将自动压缩优化', + 'post.notice_3': '发布后可在个人中心管理', + 'post.cover': '封面', + 'post.images_required_error': '请至少上传一张图片', + 'post.title_required': '请输入标题', + 'post.uploading': '⏳ 上传图片中...', + 'post.upload_progress': '⏳ 上传图片 ({current}/{total})...', + 'post.submitting': '⏳ 发布中...', + 'post.publish_success': '发布成功!', + 'post.publish_failed': '发布失败', + 'post.mode_label': '发布模式', + 'post.mode_image': '图文', + 'post.mode_video': '视频', + 'post.upload_images': '上传图片', + 'post.max_9_images': '最多9张', + 'post.click_upload': '点击上传', + 'post.upload_video': '上传视频', + 'post.click_upload_video': '点击选择视频', + 'post.video_cover': '视频封面', + 'post.capture_frame': '截取当前帧', + 'post.upload_cover': '手动上传封面', + 'post.publish_notice': '发布须知', + 'post.notice_original': '请上传原创作品或标明出处', + 'post.notice_compress': '图片将自动压缩优化', + 'post.notice_manage': '发布后可在个人中心管理', + 'post.error_video_format': '仅支持 mp4、webm、mov 格式的视频', + 'post.error_video_size': '视频大小不能超过 100MB', + 'post.error_video_duration': '视频时长不能超过 3 分钟', + 'post.error_video_load': '无法读取视频信息', + 'post.existing_video': '已有视频', + 'post.error_video_not_ready': '视频尚未加载完成', + 'post.cover_captured': '封面已更新', + 'post.cover_capture_failed': '封面截取失败', + 'post.error_cover_format': '请选择图片文件作为封面', + 'post.error_no_title': '请输入标题', + 'post.error_no_content': '请输入正文内容', + 'post.error_no_video': '请选择视频文件', + 'post.error_no_cover': '请等待封面生成或手动上传封面', + 'post.uploading_cover': '上传封面中...', + 'post.uploading_video': '上传视频中...', + // 🎵 音乐提示词音频(图片+音频方案,视频可选) + 'prompt.audio_label': '音频文件', + 'prompt.optional_mark': '(可选)', + 'prompt.music_media_hint': '示例视频与「图片 + 音频」两种展示方式二选一,选定一种后自动清空另一种。', + 'post.click_upload_audio': '点击选择音频', + 'post.error_audio_format': '仅支持 mp3、wav、m4a、ogg 格式的音频', + 'post.error_audio_size': '音频大小不能超过 30MB', + 'post.existing_audio': '已有音频', + 'post.error_no_audio': '请选择音频文件', + 'post.uploading_audio': '上传音频中...', + 'post.media_exclusive': '视频与「图片 + 音频」二选一,已自动清空另一组', + 'post.publishing': '发布中...', + 'post.edit_success': '保存成功', + 'post.edit_failed': '保存失败', + 'post.error_no_image': '请至少上传一张图片', + 'post.compressing_images': '压缩图片中...', + 'post.compressing_progress': '压缩图片 ({current}/{total})', + 'post.uploading_progress': '上传图片 ({current}/{total})', + + // 通用补充 - 组件使用 + 'common.message': '私信', + 'common.credits': '积分', + 'common.unknown': '未知', + 'common.load_failed': '加载失败', + + // 🎰 打赏等级 + 'tip.new_fan': '新粉丝', + 'tip.supreme_sponsor': '至尊赞助者', + 'tip.points': '积分', + + // 📊 图表 + 'chart.loading': '正在加载图表...', + 'chart.load_failed': '图表库加载失败', + + // 🖼️ 图片 + 'image.loading': '加载中...', + 'image.load_failed': '图片加载失败', + 'image.load_timeout': '加载超时', + 'image.gallery': '效果展示图', + 'common.operation_failed': '操作失败', + 'common.login_required': '请登录后操作', + 'common.publish': '发布', + 'common.confirm_submit': '确认提交', + 'common.uploading': '上传中', + 'common.upload_progress': '上传 ({current}/{total})', + 'common.submitting': '提交中', + 'common.upload_image_progress': '上传图片 ({current}/{total})', + 'common.unknown_task': '未知任务', + + // 任务详情补充 + 'task.description': '任务描述', + 'task.reference_images': '参考图片', + 'task.reference_link': '参考链接', + 'task.assignee': '接单者', + 'task.note': '备注', + 'task.applicants_count': '人', + 'task.choose_assignee': '选择TA', + 'task.confirm_assign': '确定选择 {assignee} 作为接单者吗?', + 'task.confirm_deposit': '将从您的账户扣除订金 {amount} 积分', + 'task.apply_message_prompt': '请输入申请留言(可选):', + 'task.confirm_cancel_apply': '确定撤回申请吗?', + 'task.apply_cancelled': '已撤回申请', + 'task.cancel_apply_failed': '撤回失败', + 'task.confirm_cancel_task': '确定取消任务吗?此操作不可撤销。', + 'task.task_cancelled': '任务已取消', + 'task.cancel_task_failed': '取消失败', + 'task.confirm_accept': '确定验收通过吗?', + 'task.confirm_pay_remaining': '将支付尾款 {amount} 积分给接单者', + 'task.reject_reason_prompt': '请输入不通过的原因:', + 'task.work_returned': '已退回修改', + 'task.submit_work': '提交成果', + 'task.deliverable_images': '交付图片', + 'task.support_multiple_images': '支持多张图片', + 'task.note_optional': '备注说明(可选)', + 'task.note_placeholder': '对交付内容的说明...', + 'task.please_upload_deliverables': '请上传交付图片', + 'task.upload_failed_retry': '图片上传失败,请重试', + 'task.submit_success_waiting': '成果已提交,等待发布者验收', + 'task.start_dispute': '发起申诉', + 'task.view_dispute': '查看申诉详情', + 'task.accept_work': '验收通过', + 'task.reject_work': '验收不通过', + + // 发布任务补充 + 'task.task_title': '任务标题', + 'task.title_placeholder': '简洁描述您的需求,50字以内', + 'task.description_placeholder': '详细描述任务要求、交付标准、参考说明等...', + 'task.reference_images_optional': '参考图片(可选,最多6张)', + 'task.add_reference_image': '添加参考图片', + 'task.reference_link_optional': '参考链接(可选)', + 'task.deposit_ratio': '订金比例', + 'task.min_10_credits': '最低10积分', + 'task.deposit_deducted_on_assign': '接单时扣除', + 'task.price_description': '价格说明', + 'task.deposit_on_assign': '订金(指派接单时扣除)', + 'task.remaining_on_accept': '尾款(验收通过时扣除)', + 'task.publish_task': '发布任务', + 'task.manage_in_task_list': '发布后可在「任务榜」中管理您的任务', + 'task.please_enter_title': '请输入任务标题', + 'task.please_enter_description': '请输入任务描述', + 'task.price_min_10': '任务价格不能低于10积分', + 'task.please_select_deadline': '请选择截止日期', + 'task.uploading_images': '上传图片中', + 'task.upload_progress': '上传图片 ({current}/{total})', + 'task.publishing': '发布中', + 'task.max_6_images': '最多上传6张图片', + 'task.view_total': '🔥 浏览总量', + 'task.view_today': '📅 今日浏览', + 'task.save_changes': '保存修改', + + // 申诉详情补充 + 'dispute.start': '发起申诉', + 'dispute.reason': '申诉理由', + 'dispute.reason_placeholder': '请详细说明您的申诉理由...', + 'dispute.evidence_optional': '证据图片(可选,最多6张)', + 'dispute.click_upload_evidence': '点击上传证据图片', + 'dispute.submit': '提交申诉', + 'dispute.submit_success': '申诉已提交,等待对方回应', + 'dispute.submit_failed': '申诉失败', + 'dispute.max_6_images': '最多上传6张图片', + 'dispute.please_enter_reason': '请输入申诉理由', + 'dispute.get_detail_failed': '获取申诉详情失败', + 'dispute.related_task': '关联任务', + 'dispute.task_id': '任务ID', + 'dispute.click_upload_evidence_optional': '点击上传证据图片(可选)', + 'dispute.resolution_result': '仲裁结果', + 'dispute.upload_failed': '上传失败', + 'dispute.please_enter_response': '请输入回应内容', + 'dispute.response_submitted': '回应已提交', + 'dispute.split_result': '双方协商分成', + + // 发布帖子补充 + 'post.upload_images': '上传图片', + 'post.max_9_images': '最多9张,第一张为封面', + 'post.click_upload': '点击或拖拽上传图片', + 'post.title_label': '标题', + 'post.content_label': '正文', + 'post.publish_notice': '发布须知', + 'post.notice_original': '请上传原创作品或标明出处', + 'post.notice_compress': '图片将自动压缩优化', + 'post.notice_manage': '发布后可在个人中心管理', + 'post.cover': '封面', + 'post.error_no_image': '请至少上传一张图片', + 'post.error_no_title': '请输入标题', + 'post.uploading_images': '上传图片中', + 'post.uploading_progress': '上传图片 ({current}/{total})', + 'post.compressing_images': '压缩图片中', + 'post.compressing_progress': '压缩图片 ({current}/{total})', + 'post.publishing': '发布中', + 'post.edit_title': '编辑帖子', + 'post.title_label': '标题', + 'post.content_label': '内容', + 'post.edit_success': '修改成功', + 'post.edit_failed': '修改失败', + 'post.delete_confirm_title': '确认删除', + 'post.delete_confirm_desc': '删除后无法恢复,确定要删除这篇帖子吗?', + 'post.delete_success': '删除成功', + 'post.delete_failed': '删除失败', + + // 发布内容 + 'publish.new_content': '发布新内容', + 'publish.edit_content': '修改发布内容', + 'publish.save_changes': '保 存 修 改', + 'publish.confirm_publish': '确 认 发 布', + 'publish.main_category': '主类别', + 'publish.type_tool': '原创插件', + 'publish.type_app': '原创工作流', + 'publish.type_recommend': '推荐他人作品', + 'publish.recommend_form': '推荐形式 (三选一)', + 'publish.recommend_as_tool': '作为插件 (支持Git安装)', + 'publish.recommend_as_app': '作为工作流 (加载JSON)', + 'publish.recommend_as_link': '纯链接 (新窗口打开)', + 'publish.name': '名称', + 'publish.name_placeholder': '例如:强烈推荐飞行翻译双语增强版!', + 'publish.short_desc': '简短描述 (一句话介绍)', + 'publish.short_desc_placeholder': '最多 50 字,突出核心亮点...', + 'publish.resource_type': '资源接入', + 'publish.resource_link': '外部链接 / Git', + 'publish.resource_json': '上传 JSON', + 'publish.resource_netdisk': '网盘链接', + 'publish.source_url': '源地址或外部 URL', + 'publish.source_url_placeholder': '输入外部地址或Git库链接...', + 'publish.select_json': '选择 JSON 文件', + 'publish.has_cloud_file': '已包含云端文件,重新选择将覆盖', + 'publish.netdisk_link': '网盘链接', + 'publish.netdisk_placeholder': '粘贴百度网盘/阿里云盘等分享链接...', + 'publish.netdisk_password': '网盘提取码 (可选)', + 'publish.netdisk_password_placeholder': '填写网盘密码,如:1234', + 'publish.security_guarantee': '安全保障', + 'publish.password_encrypted_hint': '密码加密存储于云端,仅在用户成功购买后所解密显示。未购买用户无法通过任何接口获取。', + 'publish.private_repo_hint': '这是一个私有 GitHub 仓库(需配置访问密钥)', + 'publish.pat_label': '私有库专属访问密钥 (PAT)', + 'publish.pat_hint': '系统使用此 Token 替购买者拉取源码。该密钥仅存储于云端后台,绝对不会在前端接口中公开暴露。', + 'publish.token_placeholder': 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + 'publish.token_keep_hint': '已配置,留空保留原密钥', + 'publish.effect_images': '效果展示图 (最多6张,可选)', + 'publish.reselect_override': '重新选择将覆盖原图', + 'publish.drag_select_hint': '支持拖拽排序,第一张将作为封面图', + 'publish.mark_original': '原创内容', + 'publish.mark_as_original': '标记为原创作品', + 'publish.original_no_commercial': '原创内容,请勿商用', + 'publish.original_default_hint': '原创内容将获得特殊标识展示,请勿标记非原创内容', + 'publish.original_required_hint': '插件/工作流类型必须为原创内容,非原创请发布到「推荐榜」分类', + 'publish.original_required_toast': '插件/工作流类型必须为原创内容', + 'publish.allow_refund': '支持退款', + 'publish.refund_hint': '勾选后,购买者可在 24 小时内申请退款。未勾选则不支持退款', + 'publish.original_confirm_title': '原创内容声明', + 'publish.original_confirm_desc': '您正在声明该内容为个人原创作品。', + 'publish.original_confirm_subtitle': '请确认:', + 'publish.original_confirm_item1': '该内容由您本人独立创作', + 'publish.original_confirm_item2': '您对该内容拥有完整的知识产权', + 'publish.original_confirm_item3': '您愿意为此声明承担相应法律责任', + 'publish.original_confirm_warning': '如果您分享的是他人作品或非原创内容,请发布到「推荐」分类。', + 'publish.leave_confirm_title': '确认离开?', + 'publish.leave_confirm_desc': '您有未保存的内容,离开后编辑内容将被清空。', + 'publish.unsaved_changes_title': '放弃修改?', + 'publish.unsaved_changes_desc': '您有未保存的修改,离开后修改将被舍弃。', + 'common.leave': '离开', + 'common.stay': '留下继续编辑', + 'publish.price': '标价 (积分)', + 'publish.price_hint': '填写 0 代表免费开源。', + 'publish.price_delay_hint': '修改价格将24小时后生效', + 'publish.full_desc': '详细说明', + 'publish.full_desc_placeholder': '介绍具体功能、使用方法、前置环境要求等...', + 'publish.current_price': '当前价格', + 'publish.will_change_in': '将在', + 'publish.hours_later': '小时后变更为', + // 发布提交引擎 + 'publish.processing_avatar': '正在本地处理和压缩头像...', + 'publish.uploading_file': '正在上传文件 {size}KB 至云端...', + 'publish.upload_success': '文件上传成功!', + 'publish.upload_failed': '文件上传失败', + 'publish.upload_error': '上传过程出现异常', + 'publish.pat_required': '勾选了私有仓库,请务必填写 PAT 访问密钥!', + 'publish.name_desc_required': '请填写名称和简短描述!', + 'publish.link_required': '第三方链接必须提供源地址!', + 'publish.git_required': '必须提供 Git 安装地址!', + 'publish.json_required': '必须上传工作流 JSON 文件!', + 'publish.netdisk_required': '必须填写网盘链接!', + 'publish.connecting': '正在连接云端...', + 'publish.uploading_secure': '正在安全上传文件...', + 'publish.uploading_images': '正在上传图片 ({current}/{total})...', + 'publish.syncing': '正在同步全网数据库...', + 'publish.save_success': '修改已保存并同步全网!', + 'publish.publish_success': '发布成功!您的作品已全网同步。', + + // 🌐 个人中心页面 + 'profile.back': '返回', + 'profile.recharge': '充值积分', + 'profile.withdraw': '收益提现', + 'profile.settings': '设置', + 'profile.logout': '登出', + 'profile.balance': '余额', + 'profile.sales': '销售', + 'profile.tips': '打赏', + 'profile.task_earnings': '任务收益', + + // 个人列表 + 'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开', + 'profile.no_following': '暂无关注的人', + 'profile.no_collected': '还没有收藏任何内容', + 'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!', + 'profile.collected_items': '收藏的资源', + 'profile.collected_posts': '收藏的帖子', + 'profile.collected_prompts': '收藏的提示词', + 'profile.load_collected_failed': '收藏加载失败', + 'profile.loading_users': '正在加载用户数据...', + 'profile.load_failed': '数据加载失败', + 'profile.homepage': '主页', + 'profile.update_available': '可更新', + 'profile.installed': '已安装', + 'profile.tip_stats': '打赏统计', + 'profile.tip_received': '收到打赏', + 'profile.tip_sent': '打赏支出', + 'profile.net_tips': '净打赏', + 'profile.sales_stats': '销售统计', + 'profile.sales_income': '销售收入', + 'profile.purchase_expense': '购买支出', + 'profile.net_sales': '净销售', + 'profile.transactions_unit': '笔', + 'profile.received_likes': '获赞', + 'profile.received_favorites': '收藏', + 'profile.followers_count': '粉丝', + 'profile.following_count': '关注', + 'profile.tip_support': '打赏支持', + 'profile.send_message': '私信', + 'profile.follow_author': '关注作者', + 'profile.followed': '已关注', + 'profile.creator_tip_board': '创作者赞赏总榜 (TOP 10)', + 'profile.age_years': '岁', + 'profile.location_secret': '保密', + 'profile.no_intro': '这个人很懒,什么都没写...', + 'profile.unknown_age': '未知', + + // 🌐 编辑资料与隐私设置 + 'settings_form.title': '编辑资料与隐私设置', + 'settings_form.change_avatar': '更换头像', + 'settings_form.avatar_hint': '支持 JPG/PNG,将在本地自动裁剪为 1:1 并压缩。', + 'settings_form.banner_title': '个人资料卡背景图', + 'settings_form.banner_upload': '上传并裁剪', + 'settings_form.banner_clear': '清除', + 'settings_form.banner_hint': '比例 16:9,可缩放拖动裁剪,压缩后上传云端。', + 'settings_form.ui_bg_title': '界面背景(仅本地)', + 'settings_form.ui_bg_hint': '比例 9:16,仅保存在本地,不占用云端空间。', + 'settings_form.display_name': '显示名称', + 'settings_form.gender': '性别', + 'settings_form.gender_male': '男 (Male)', + 'settings_form.gender_female': '女 (Female)', + 'settings_form.gender_secret': '保密 (Secret)', + 'settings_form.birthday': '出生日期 (选填)', + 'settings_form.country': '国家/地区', + 'settings_form.select_country': '请选择国家', + 'settings_form.select_country_first': '请先选择国家', + 'settings_form.intro': '个人简介', + 'settings_form.intro_placeholder': '介绍一下你自己...', + 'settings_form.privacy_title': '隐私设置', + 'settings_form.privacy_hint': '您可以选择向其他用户隐藏您在社区的足迹(不影响您自己查看)。', + 'settings_form.hide_follows': '不公开我关注的人', + 'settings_form.hide_likes': '不公开我点赞过的内容', + 'settings_form.hide_favorites': '不公开我收藏的内容', + 'settings_form.hide_downloads': '不公开我获取的记录', + 'settings_form.admin_title': '管理员设置', + 'settings_form.admin_hint': '以下设置影响整个平台,请谨慎操作。', + 'settings_form.image_moderation': '图像内容审核', + 'settings_form.image_moderation_hint': '启用后上传的图片将自动进行内容安全审核', + 'settings_form.version_title': '插件版本管理', + 'settings_form.version_hint': '统一修改项目阶段与版本号,将同步更新所有相关位置', + 'settings_form.project_stage': '项目阶段', + 'settings_form.version_number': '版本号', + 'settings_form.apply_version': '应用版本更改', + 'settings_form.cancel': '取消', + 'settings_form.save_all': '保存所有设置', + 'settings_form.save_failed': '设置失败', + 'settings_form.avatar_upload_success': '头像上传成功!请点击底部保存设置生效。', + 'settings_form.banner_cleared': '已清除背景图,请点击底部保存设置生效。', + // 视频播放器 + 'video.play': '播放', + 'video.pause': '暂停', + 'video.mute': '静音', + 'video.unmute': '取消静音', + 'video.fullscreen': '全屏', + 'video.exit_fullscreen': '退出全屏', + 'video.speed': '播放速度', + 'video.close': '关闭', + 'video.forward': '快进5秒', + 'video.backward': '快退5秒', + 'video.volume': '音量', + + // 关于插件 + 'about.title': 'ComfyUI 社区精选', + 'about.subtitle': '专为 ComfyUI 打造的现代化 Web3.0 级别生态引擎', + 'about.stage_alpha': 'Alpha 内测', + 'about.stage_beta': 'Beta 公测', + 'about.stage_rc': 'RC 候选版', + 'about.stage_stable': '正式版', + 'about.advantages_title': '核心优势', + 'about.adv_native_title': '原生融合', + 'about.adv_native_desc': '采用 ComfyUI V3 标准机制,零侵入保护工作流运行', + 'about.adv_speed_title': '零延时体验', + 'about.adv_speed_desc': '引入现代前端缓存策略,告别白屏等待', + 'about.adv_im_title': '全局即时通讯', + 'about.adv_im_desc': '内置私信与系统通知,打破信息孤岛', + 'about.adv_sandbox_title': '沙箱化交互', + 'about.adv_sandbox_desc': '严格的事件防穿透机制,全方位保护底层画布安全', + 'about.features_title': '主要功能', + 'about.feat_publish_title': '作品发布', + 'about.feat_publish_desc': '支持发布插件、工作流 及优质资源推荐', + 'about.feat_social_title': '社区互动', + 'about.feat_social_desc': '内置点赞、评论、收藏、关注', + 'about.feat_install_title': '极简安装', + 'about.feat_install_desc': '一键静默安装插件,云端内存解压,无需手动,并支持 GitHub 私有库密钥防盗', + 'about.feat_download_title': '一键加载', + 'about.feat_download_desc': '一键下载插件与工作流,云端校验所有权防重复扣费,下载过的文件直接使用', + 'about.feat_chat_title': '即时通讯', + 'about.feat_chat_desc': '内置私信聊天与全局消息提醒', + 'about.feat_account_title': '账号系统', + 'about.feat_account_desc': '完善的注册、资料修改与基于邮箱的安全验证风控', + 'about.feat_payment_title': '消费系统', + 'about.feat_payment_desc': '支付宝扫码秒充值,支持安全提现', + 'about.feat_protection_title': '内容保护', + 'about.feat_protection_desc': '付费内容云端永久记录,一次购买终身有效', + 'about.benefits_title': '用户受益', + 'about.benefit_1': '根据榜单和图表趋势,一键下载优秀的插件提升生产力', + 'about.benefit_2': '通过真实评价和文件检测机制,避坑无效资源', + 'about.benefit_3': '发布自己原创的插件或工作流', + 'about.benefit_4': '自由设置下载付费积分(1积分=1元),或设为免费开源', + 'about.benefit_5': '积累粉丝与口碑,获得好心人的积分打赏', + 'about.warning_title': '注意', + 'about.warning_login_title': '账号登录问题:', + 'about.warning_login_desc': '之前注册的账号无法登录的,请修改密码后再登录。', + 'about.warning_account_title': '登录账号错误:', + 'about.warning_account_desc': '可能是你的浏览器自动帮你填入别的网站账号密码,账号是字符(字母、数字、符号)没有中文。', + 'about.author_title': '关于作者', + 'about.author_thanks': '感谢使用 ComfyUI 社区精选,愿 ComfyUI 生态繁荣昌盛 🌱' + }, + + 'en-US': { + // Common + 'common.confirm': 'Confirm', + 'common.cancel': 'Cancel', + 'common.save': 'Save', + 'common.saving': 'Saving...', + 'common.delete': 'Delete', + 'common.deleting': 'Deleting...', + 'common.edit': 'Edit', + 'common.close': 'Close', + 'common.reset': 'Reset', + 'common.loading': 'Loading...', + 'common.chars': 'chars', + 'common.digits': 'digits', + 'common.success': 'Success', + 'common.error': 'Error', + 'common.retry': 'Retry', + 'common.sending': 'Sending...', + 'common.retrying': 'Retrying...', + 'common.retry_failed': 'Retry failed, please try again later', + 'common.more': 'More', + 'common.back': 'Back', + 'common.search': 'Search', + 'common.refresh': 'Refresh', + 'common.copy': 'Copy', + 'common.copied': 'Copied', + 'common.share': 'Share', + 'common.download': 'Download', + 'common.upload': 'Upload', + 'common.select': 'Select', + 'common.all': 'All', + 'common.none': 'None', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.ok': 'OK', + 'common.send': 'Send', + 'common.reset': 'Reset', + 'common.install': 'Install', + 'common.installed': 'Installed', + 'common.update': 'Update', + 'common.free': 'Free', + 'common.paid': 'Paid', + + // Navigation + 'nav.tools': 'Plugins', + 'nav.apps': 'Workflows', + 'nav.creators': 'Creators', + 'nav.recommends': 'Top Picks', + 'nav.posts': 'Forum', + 'nav.tasks': 'Tasks', + 'nav.profile': 'Profile', + 'nav.settings': 'Settings', + 'nav.login': 'Login / Register', + 'nav.logout': 'Logout', + 'nav.notifications': 'Notifications', + 'nav.community': 'Ranking', + + // Settings + 'settings.title': 'Settings', + 'settings.general': 'General', + 'settings.display': 'Display', + 'settings.animation': 'Animation & Sound', + 'settings.language': 'Language', + 'settings.language_desc': 'Select display language', + 'settings.creator_banner': 'Creator Card Banner', + 'settings.creator_banner_desc': 'Show personal banner on creator cards, disable for minimal style', + 'settings.animations': 'List Animations', + 'settings.animations_desc': 'Enable sci-fi loading animations when switching tabs', + 'settings.sound': 'Sound Effects', + 'settings.sound_desc': 'Enable sound effects with animations (requires animations)', + 'settings.auto_save': 'Settings are saved automatically', + 'settings.language_changed': 'Language changed to', + 'settings.animation_preview': 'Animation Preview', + 'settings.anim_cascade': 'Plugins/Workflows/Top Picks: Cascade from left to right', + 'settings.anim_fan': 'Creators: Fan out from bottom', + 'settings.anim_abyss': 'Forum: 3D abyss convergence', + 'settings.anim_dataflow': 'Tasks: Data flow alternating slides', + 'settings.enabled': 'Enabled', + 'settings.disabled': 'Disabled', + 'settings.data_cache': '💾 Data & Cache', + 'settings.cache_expire': 'Data Refresh Interval (seconds)', + 'settings.cache_expire_desc': 'Set the local cache refresh interval. Data will be silently updated in the background when switching tabs after expiry. Minimum 60 seconds, maximum 86400 seconds (1 day).', + 'settings.cache_trigger_desc': 'When the cache exceeds the set time, switching tabs will automatically fetch the latest data in the background and update silently without affecting usability. Manual refresh is not affected by this setting.', + 'settings.cache_stats': 'Cache Statistics', + 'settings.cache_stats_hint': 'Not uninstall, clearing disk cache is not recommended', + 'settings.cache_disk_images': 'Image Cache', + 'settings.cache_disk_videos': 'Video Cache', + 'settings.cache_browser': 'Browser Cache', + 'settings.clear_browser_cache': 'Clear Browser Cache', + 'settings.clear_disk_cache': 'Clear Disk Cache', + 'settings.clear_all_cache': 'Clear All Cache', + 'settings.cache_clearing': 'Clearing...', + 'settings.cache_cleared': 'Cache Cleared', + 'settings.files': 'files', + 'settings.no_cache': 'No Cache', + + // Auth + 'auth.login': 'Login', + 'auth.title': 'Community Account', + 'auth.register': 'Register', + 'auth.logout': 'Logout', + 'auth.token_expired': 'Your session has expired. Please log in again.', + 'auth.email': 'Email', + 'auth.password': 'Password', + 'auth.confirm_password': 'Confirm Password', + 'auth.username': 'Username', + 'auth.nickname': 'Nickname', + 'auth.forgot_password': 'Forgot Password?', + 'auth.forgot_account': 'Forgot Account?', + 'auth.recover_account': 'Recover Account', + 'auth.recover_desc': "Don't worry about forgetting your account. Enter your bound email and verify with the code to recover it.", + 'auth.recover_success': 'Account recovered successfully', + 'auth.your_account': 'Your Account', + 'auth.go_reset_password': 'Reset Password Now', + 'auth.reset_password': 'Reset Password', + 'auth.verification_code': 'Verification Code', + 'auth.send_code': 'Send Code', + 'auth.resend_code': 'Resend', + 'auth.login_success': 'Login successful', + 'auth.register_success': 'Registration successful', + 'auth.logout_success': 'Logged out', + 'auth.password_mismatch': 'Passwords do not match', + 'auth.invalid_email': 'Invalid email format', + 'auth.weak_password': 'Password too weak', + 'auth.account_exists': 'Account already exists', + 'auth.invalid_credentials': 'Invalid credentials', + 'auth.login_required': 'Please login first!', + 'auth.resetting_password': 'Resetting password...', + 'auth.password_reset_success': 'Password changed! Please login with new password.', + 'auth.uploading_avatar': 'Uploading avatar...', + 'auth.registering': 'Registering...', + 'auth.auto_login': '! Auto logging in...', + 'auth.avatar_set_failed': '⚠️ Failed to set avatar. You can re-upload it later in Profile Settings.', + 'common.unknown_user': 'Unknown User', + 'auth.agree_terms': 'I have read and agree to the', + 'auth.terms': 'Terms of Service', + 'auth.and': 'and', + 'auth.privacy': 'Privacy Policy', + 'auth.account': 'Account', + 'auth.remember_me': 'Stay logged in for 30 days', + 'auth.no_account': 'No account?', + 'auth.register_now': 'Register now', + 'auth.account_password_required': 'Account and password are required!', + 'auth.display_name': 'Display Name', + 'auth.set_password': 'Set Password', + 'auth.security_email': 'Security Email', + 'auth.security_email_desc': 'For receiving verification code', + 'auth.get_code': 'Get Code', + 'auth.resend': 'Resend', + 'auth.avatar': 'Avatar', + 'auth.gender': 'Gender', + 'auth.male': 'Male', + 'auth.female': 'Female', + 'auth.birthday': 'Birthday', + 'auth.age_years': 'years old', + 'auth.country': 'Country', + 'auth.region': 'Region', + 'auth.intro': 'Bio', + 'auth.intro_limit': 'Max 100 chars', + 'auth.register_submit': 'Register', + 'auth.has_account': 'Have an account?', + 'auth.back_to_login': 'Back to login', + 'auth.file_too_large': 'Failed to process the avatar image. Please try a different image.', + 'auth.code_sent': 'Verification code sent to your email', + 'auth.sending': 'Sending...', + 'auth.required_fields': 'Fields with * are required!', + 'auth.invalid_code': 'Please enter a valid 6-digit code!', + 'auth.account_format_error': 'Account must be >5 chars, only letters, numbers and underscore!', + 'auth.password_format_error': 'Password must be at least 6 characters!', + 'auth.new_password': 'New Password', + 'auth.reset_submit': 'Reset Password', + 'auth.reset_desc': 'Enter your security email to reset password', + 'auth.phone': 'Phone Number', + 'auth.current_account': 'Current Account', + 'auth.enter_account': 'Enter account to modify', + 'auth.security_verify': 'Security Verification', + 'auth.bound_email': 'Bound Email', + 'auth.enter_bound_email': 'Enter your bound security email', + 'auth.enter_6_digit_code': 'Enter 6-digit code', + 'auth.old_password': 'Old Password', + 'auth.old_password_tip': 'Leave empty if recovering', + 'auth.old_password_placeholder': 'Leave empty if forgot', + 'auth.confirm_new_password': 'Confirm New Password', + 'auth.change_password': 'Change Password', + 'auth.enter_account_first': 'Please enter the account first!', + 'auth.reset_required_fields': 'Account, verification and new password are required!', + + // Profile + 'profile.my_profile': 'My Profile', + 'profile.my_published': 'My Published', + 'profile.their_published': 'Their Published', + 'profile.my_purchased': 'My Purchases', + 'profile.my_tasks': 'My Tasks', + 'profile.transactions': 'Transactions', + 'profile.my_posts': 'My Posts', + 'profile.my_collected': 'My Favorites', + 'profile.recent_likes': 'Recent Likes', + 'profile.following': 'Following', + 'profile.max_privilege': ' Max Privilege', + 'profile.beta_max_privilege': 'Beta Max Privilege', + 'profile.edit_profile': 'Edit Profile', + 'profile.my_works': 'My Works', + 'profile.my_purchases': 'Purchased', + 'profile.my_favorites': 'Favorites', + 'profile.my_following': 'Following', + 'profile.my_followers': 'Followers', + 'profile.my_wallet': 'Wallet', + 'profile.statistics': 'Statistics', + 'profile.followers': 'Followers', + 'profile.works': 'Works', + 'profile.likes': 'Likes', + 'profile.bio': 'Bio', + 'profile.location': 'Location', + 'profile.website': 'Website', + 'profile.joined': 'Joined', + 'profile.follow': 'Follow', + 'profile.unfollow': 'Unfollow', + 'profile.message': 'Message', + 'profile.block': 'Block', + 'profile.report': 'Report', + + // Form + 'form.title': 'Title', + 'form.description': 'Description', + 'form.price': 'Price', + 'form.submit': 'Submit', + 'form.draft_found': 'Draft found. Restore?', + 'form.draft_restored': 'Draft restored', + 'form.draft_cleared': 'Draft cleared', + 'form.required': 'Required', + 'form.optional': 'Optional', + 'form.placeholder': 'Enter...', + + // Wallet + 'wallet.balance': 'Balance', + 'wallet.recharge': 'Recharge', + 'wallet.withdraw': 'Withdraw', + 'wallet.tip': 'Tip', + 'wallet.purchase': 'Purchase', + 'wallet.income': 'Income', + 'wallet.expense': 'Expense', + 'wallet.points': 'Points', + 'wallet.sales': 'Sales', + 'wallet.tips_received': 'Tips Received', + 'wallet.current_balance': 'Current Balance', + 'wallet.credits': 'Credits', + 'wallet.insufficient_balance': 'Insufficient balance, please recharge', + + // Transaction Types + 'tx.withdraw': '💸 Withdrawal Request', + 'tx.tip_in': '🎁 Tip Received', + 'tx.tip_out': '🎁 Tip Sent', + 'tx.purchase': '🛒 Purchase', + 'tx.task_deposit': '📋 Task Deposit', + 'tx.task_payment': '📋 Task Payment', + 'tx.task_income': '📋 Task Income', + 'tx.task_freeze': '🔒 Task Frozen', + 'tx.task_refund': '↩️ Task Refund', + 'tx.task_cancel_refund': '🔄 Task Cancel Refund', + 'tx.sale': 'Sales Income', + 'tx.recharge': '💰 Recharge', + 'tx.refund': '↩️ Refund', + 'tx.withdraw_fee': '💸 Withdrawal Fee', + 'tx.withdraw_pending': 'Pending', + 'tx.withdraw_completed': 'Completed', + 'tx.recharge_desc': 'Alipay Recharge', + 'tx.withdraw_fee_desc': 'Withdrawal Fee', + 'tx.net_amount': 'Net Amount', + + // Recharge + 'wallet.recharge.title': '💰 Points Recharge Center', + 'wallet.recharge.current_balance': 'Current Account Balance', + 'wallet.recharge.select_amount': 'Select Recharge Amount', + 'wallet.recharge.exchange_rate': '(1 Point = ¥1)', + 'wallet.recharge.points_suffix': 'Points', + 'wallet.recharge.price_prefix': '¥', + 'wallet.recharge.custom': 'Custom', + 'wallet.recharge.any_amount': 'Any Amount', + 'wallet.recharge.payment_method': 'Payment Method', + 'wallet.recharge.alipay': '📘 Alipay', + 'wallet.recharge.wechat': '📗 WeChat Pay (Coming Soon)', + 'wallet.recharge.get_qr': 'Get Payment QR Code', + 'wallet.recharge.connecting': '⏳ Connecting to payment gateway...', + 'wallet.recharge.scan_to_pay': 'Please scan with mobile to pay', + 'wallet.recharge.yuan': 'CNY', + 'wallet.recharge.auto_redirect': 'Will auto-redirect after payment, please keep this window open', + 'wallet.recharge.invalid_amount': 'Please enter a valid amount (1-10000)', + 'wallet.recharge.order_failed': '❌ Failed to create order: ', + 'wallet.recharge.success': '✅ Recharge successful! {points} points credited.', + 'wallet.recharge.custom_placeholder': 'Enter custom amount (1~10000)', + 'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000', + 'wallet.recharge.processing': 'Processing...', + + // Withdraw + 'wallet.withdraw.title': '💳 Earnings Withdrawal Center', + 'wallet.withdraw.total_withdrawable': 'Total Withdrawable Points', + 'wallet.withdraw.rmb_equivalent': 'Equivalent in RMB (Before Fees)', + 'wallet.withdraw.amount_label': 'Withdrawal Amount (Points)', + 'wallet.withdraw.amount_placeholder': '1 Point = 1 CNY', + 'wallet.withdraw.alipay_account': 'Alipay Account for Payment', + 'wallet.withdraw.alipay_placeholder': 'Phone or Email', + 'wallet.withdraw.real_name': 'Real Name (Must Match Alipay ID)', + 'wallet.withdraw.real_name_placeholder': "Payee's Real Name", + 'wallet.withdraw.security_code': 'Security Code (Will Be Sent to Your Email)', + 'wallet.withdraw.code_placeholder': '6-digit Code', + 'wallet.withdraw.get_code': 'Get Code', + 'wallet.withdraw.sending': 'Sending...', + 'wallet.withdraw.resend_in': 'Resend in {seconds}s', + 'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)', + 'wallet.withdraw.submitting': '⏳ Submitting...', + 'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!', + 'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!', + 'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!', + 'wallet.withdraw.invalid_code': 'Please enter a valid 6-digit code!', + 'wallet.withdraw.code_sent': 'Code sent! Please check your email (including spam folder)', + 'wallet.withdraw.code_failed': 'Send failed: ', + 'wallet.withdraw.success': '🎉 Withdrawal submitted! Funds will be transferred within 1-3 business days.', + 'wallet.withdraw.submit_failed': 'Submit failed: ', + 'wallet.withdraw.fee_free_quota': '🎉 You enjoy fee-free withdrawal up to 100 CNY!', + 'wallet.withdraw.fee_full_amount': 'Expected full amount: ', + 'wallet.withdraw.fee_quota_exhausted': '⚠️ Free quota (100 points) has been used.', + 'wallet.withdraw.fee_deducted': '10% platform fee will be deducted: ', + 'wallet.withdraw.fee_actual': 'Expected amount after fee: ', + 'wallet.withdraw.yuan': 'CNY', + 'wallet.withdraw.max_amount_exceeded': 'Single withdrawal cannot exceed 5000', + + // Market + 'market.latest': 'Latest', + 'market.popular': 'Popular', + 'market.downloads': 'Downloads', + 'market.favorites': 'Favorites', + 'market.publish': 'Publish', + 'market.detail': 'Details', + 'market.comments': 'Comments', + 'market.reviews': 'Reviews', + 'market.like': 'Like', + 'market.liked': 'Liked', + 'market.favorite': 'Favorite', + 'market.favorited': 'Favorited', + 'market.views': '🔥 Most Viewed', + 'market.daily_views': '🔥 Trending Today', + 'market.rating': '★ Highest Rated', + 'market.install_now': 'Install Now', + 'market.load_workflow': 'Load Workflow', + 'market.price_free': 'Free', + 'market.price_unit': 'pts', + 'market.tips_ranking': 'Top Tipped', + + // Social + 'social.chat': 'Chat', + 'social.messages': 'Messages', + 'social.notifications': 'Notifications', + 'social.comment': 'Comment', + 'social.reply': 'Reply', + 'social.share': 'Share', + 'social.mention': 'Mention', + 'social.new_message': 'New Message', + 'social.no_messages': 'No messages', + 'social.type_message': 'Type a message...', + + // Task + 'task.create': 'Create Task', + 'task.apply': 'Apply', + 'task.deposit': 'Deposit', + 'task.final_payment': 'Final Payment', + 'task.deadline': 'Deadline', + 'task.budget': 'Budget', + 'task.status': 'Status', + 'task.pending': 'Pending', + 'task.in_progress': 'In Progress', + 'task.completed': 'Completed', + 'task.cancelled': 'Cancelled', + + // Feedback + 'feedback.loading': 'Processing...', + 'feedback.success': 'Success', + 'feedback.error': 'Failed, please retry', + 'feedback.network_error': 'Network error', + 'feedback.offline': 'Currently offline', + 'feedback.timeout': 'Request timeout', + 'feedback.saved': 'Saved', + 'feedback.logged_out': 'Logged out', + 'feedback.login_required': 'Please log in first!', + 'feedback.login_required_tip': 'Please log in to tip!', + 'feedback.operation_failed': 'Operation failed: ', + 'feedback.user_fetch_failed': 'Failed to fetch user info. Account may be deleted.', + + // Confirm dialogs + 'confirm.logout': 'Are you sure you want to log out?', + 'confirm.delete': 'Are you sure you want to delete?', + 'confirm.yes': 'Yes', + 'confirm.no': 'Cancel', + + // Social + 'social.follow': 'Follow Author', + 'social.followed': 'Following', + 'social.unfollow': 'Unfollow', + 'social.send_message': 'Send Message', + 'social.tip_author': 'Tip Author', + 'social.say_something': 'Say something...', + 'social.reply_to': 'Reply to', + 'social.comment_deleted': 'This comment has been deleted', + 'social.delete_comment_confirm': 'Are you sure you want to delete this comment? This cannot be undone.', + 'social.no_comments': 'No comments yet. Be the first!', + + // Common additions + 'common.send': 'Send', + 'common.delete': 'Delete', + 'feedback.send_failed': 'Send failed', + + // Rating + 'rating.stars': 'Rating', + 'rating.count': '{count} ratings', + 'rating.your_score': 'Your rating', + 'rating.login_required': 'Login to rate', + 'rating.self_forbidden': 'Cannot rate your own work', + 'rating.sixth_star_tip': 'Over 90% of users gave 5 stars!', + 'rating.submit_success': 'Rating submitted', + 'rating.submit_failed': 'Rating failed, please retry', + + // Notifications + 'notif.login_required': 'Please log in to view messages!', + 'notif.title': 'Messages & Notifications', + 'notif.clear': 'Clear All', + 'notif.no_messages': 'No messages yet', + 'notif.private_msg': 'sent you a private message', + 'notif.followed_you': 'followed you', + 'notif.liked': 'liked your work', + 'notif.favorited': 'favorited your work', + 'notif.commented_on': 'replied to your work', + 'notif.replied_on': 'replied to your comment', + 'notif.purchased': 'purchased your work', + 'notif.income_received': ', income received!', + 'notif.refunded': 'refunded your work', + 'notif.tipped_you': 'tipped you!', + 'notif.task_apply': 'applied for your task', + 'notif.task_assigned': 'You have been assigned', + 'notif.task_submitted': 'submitted task result', + 'notif.task_completed': 'Task completed', + 'notif.task_rejected': 'Task rejected', + 'notif.task_disputed': 'filed a dispute', + 'notif.dispute_responded': 'responded to your dispute', + 'notif.dispute_resolved': 'Dispute resolved', + 'notif.system_announcement': 'System Announcement', + 'notif.clear_confirm': 'Clear all local notifications? Records older than 7 days will not be synced again.', + 'notif.cleared': 'Notifications cleared', + 'notif.plugin_update_title': 'Plugin Update', + 'notif.plugin_update': 'Plugin', + 'notif.click_to_view': 'click to view', + 'notif.item_not_found': 'Item not found or deleted', + 'notif.load_item_failed': 'Failed to load item, please try again later', + + // Chat + 'chat.title': 'Private Messages', + 'chat.clear_messages': 'Clear Messages', + 'chat.input_placeholder': 'Type a message...', + 'chat.no_conversations': 'No conversations', + 'chat.me': 'Me', + 'chat.view_profile': 'View profile', + 'chat.time_just_now': 'Just now', + 'chat.time_min_ago': 'min ago', + 'chat.time_hour_ago': 'hr ago', + 'chat.time_day_ago': 'days ago', + 'chat.date_month': '/', + 'chat.date_day': '', + 'chat.load_more': 'Load earlier messages', + 'chat.msg_too_long': 'Message cannot exceed 1000 characters', + 'chat.load_list_failed': 'Failed to load conversations', + 'chat.load_history_failed': 'Failed to load messages', + 'chat.load_failed_hint': 'Message loading failed', + 'chat.retry': 'Retry', + 'chat.confirm_clear': 'Clear local chat history with this user?', + 'chat.cleared': 'Messages cleared', + 'chat.chatting_with': 'Chatting with', + 'chat.chatting_suffix': '', + + // Creator + 'creator.no_tips': 'No tips yet', + 'creator.anonymous': 'Anonymous', + 'creator.loading_details': 'Loading details...', + 'creator.load_failed': 'Failed to load', + 'creator.retry': 'Retry', + 'creator.usage_count': 'Used {count} times', + 'creator.stats.likes': 'Likes', + 'creator.stats.favorites': 'Favorites', + 'creator.stats.followers': 'Followers', + 'creator.output.tools': 'Plugins', + 'creator.output.apps': 'Workflows', + 'creator.output.unit': '', + 'creator.chart.download_trend': '📈 Download Growth Trend', + 'creator.chart.loading': 'Loading chart...', + 'creator.chart.load_failed': 'Chart failed to load', + 'creator.chart.series.tools': 'Plugin Downloads', + 'creator.chart.series.apps': 'Workflow Downloads', + 'creator.chart.series.recommends': 'Top Picks', + 'creator.tip_board.title': '🎁 Top Contributors', + 'creator.tip_board.count': '{count} contributors', + 'creator.tip_this_creator': 'Tip this Creator', + 'creator.tip_level_rule': 'Level Rules', + 'creator.cannot_tip_self': 'Cannot tip yourself', + 'creator.tip_login_required': 'Please login first', + 'creator.message_board': '💬 Creator Message Board', + 'creator.visit_profile': 'Visit profile', + 'common.back': 'Back', + + // Market + 'market.uses': 'Uses', + 'market.points': 'Points', + 'market.free': 'Free', + 'market.author': 'Author', + 'market.update_available': 'Update', + 'market.installed': 'Installed', + 'market.get_now': 'Get Now', + 'market.liked': 'Liked', + 'market.like': 'Like', + 'market.favorited': 'Favorited', + 'market.favorite': 'Favorite', + 'market.usage_trend': 'Usage Trend', + 'market.full_description': 'Full Description', + 'market.no_tips_yet': 'No tips yet. Be the first to support!', + 'market.tip_board': 'Top Supporters', + 'market.tip': 'Tip', + 'market.creator_manage': 'Creator Tools', + 'market.edit_content': 'Edit', + 'market.delete_permanently': 'Delete', + 'market.delete_confirm': '🚨 Warning: Are you sure you want to permanently delete this content?

This action is irreversible and all related comments will be deleted.', + 'item.refundable': 'Refundable', + 'item.confirm_refund_title': 'Confirm Refund', + 'item.confirm_refund': 'Confirm Refund', + 'item.refund_amount': 'Refund Amount: ', + 'item.confirm_delete_title': 'Confirm Delete', + 'item.confirm_delete': 'Confirm Delete', + 'item.deleting': 'Deleting...', + 'item.regret_mode': 'Regret Mode: ', + 'item.hours_left': '{hours} hours left to refund', + 'item.apply_refund': 'Request Refund', + 'item.refund_warning': 'Access will be revoked after refund, downloaded files will be deleted, and re-purchase is prohibited for 30 days', + 'item.refund_expired': 'Purchased, refund window expired', + 'item.product': 'Product: ', + 'item.untitled': 'Untitled Resource', + 'item.invalid_id_tip': 'Invalid resource ID, cannot tip', + 'item.refund_consequence': 'Refund Consequences', + 'item.refund_consequence_1': 'You will lose access to this resource', + 'item.refund_consequence_2': 'Downloaded files will be automatically deleted', + 'item.refund_consequence_3': 'Re-purchase is prohibited for 30 days', + 'item.refund_success': 'Refund Successful', + 'item.credits_refunded': ' credits have been refunded to your account', + 'item.ban_days_notice': 'Re-purchase prohibited for {days} days', + 'item.refund_failed_retry': 'Refund failed, please retry', + 'item.refund_request_failed': 'Refund request failed, please check network', + 'item.content_label': 'Content: ', + 'item.type_label': 'Type: ', + 'item.type_unknown': 'Unknown', + 'item.delete_consequence': 'Delete Consequences (Irreversible)', + 'item.delete_consequence_1': 'This content will be permanently removed from the market', + 'item.delete_consequence_2': 'All related comments will be deleted', + 'item.delete_consequence_3': 'Purchased users will no longer be able to download', + 'item.delete_success': 'Content deleted', + 'item.delete_failed_retry': 'Delete failed, please retry', + 'item.delete_request_failed': 'Delete request failed, please check network', + 'item.no_tips_yet': 'No tips yet for this resource. Be the first to support!', + 'item.author': 'Author', + 'item.original_notice': 'Original content, do not use commercially', + 'item.tip_encourage': 'Tip to encourage this work', + 'item.tip_board_title': 'Top Supporters (TOP 10)', + 'item.owned_reinstall': 'Owned (Click to reinstall)', + 'item.owned_purchased': 'Purchased (Install on PC)', + 'item.update_available': 'Update available (Click for silent hot update)', + 'item.get_now': 'Get Now', + 'item.free': 'Free', + 'item.detail_info': 'Details & Introduction', + 'item.other_info': 'Other Info', + 'time.hours_later': ' hours effective', + 'feedback.deleted': 'Successfully deleted', + 'feedback.delete_failed': 'Delete failed', + 'feedback.cache_fallback': '⚠️ Network failed, showing cached data', + 'common.processing': 'Processing', + 'common.got_it': 'Got it', + 'confirm.delete_title': 'Confirm Delete', + 'confirm.delete_message': 'This action cannot be undone. Are you sure?', + 'confirm.logout_title': 'Confirm Logout', + 'confirm.logout_message': 'Are you sure you want to logout?', + + // Time + 'time.just_now': 'Just now', + 'time.minutes_ago': '{n}m ago', + 'time.hours_ago': '{n}h ago', + 'time.days_ago': '{n}d ago', + 'time.months_ago': '{n}mo ago', + 'time.deadline_today': 'Due today', + 'time.deadline_tomorrow': 'Due tomorrow', + 'time.deadline_days': 'In {n} days', + 'time.deadline_ended': 'Expired', + 'time.unlimited': 'No deadline', + 'time.expired': 'Expired', + + // Tasks + 'task.title': 'Tasks', + 'task.publish': '📝 Create Task', + 'task.arbitrate': '⚖️ Arbitrate', + 'task.filter_all': 'All', + 'task.filter_open': '🟢 Open', + 'task.filter_in_progress': '🔵 In Progress', + 'task.filter_submitted': '🟡 Submitted', + 'task.filter_completed': '✅ Completed', + 'task.filter_disputed': '⚠️ Disputed', + 'task.sort_latest': 'Latest', + 'task.sort_price': 'Highest Price', + 'task.sort_deadline': 'Deadline', + 'task.sort_views': '🔥 Most Viewed', + 'task.sort_daily_views': '🔥 Trending Today', + 'task.sort_likes': '👍 Most Liked', + 'task.sort_favorites': '🔖 Most Favorited', + 'task.status_open': 'Open', + 'task.status_in_progress': 'In Progress', + 'task.status_submitted': 'Submitted', + 'task.status_completed': 'Completed', + 'task.status_disputed': 'Disputed', + 'task.status_cancelled': 'Cancelled', + 'task.status_expired': 'Expired', + 'task.status_unknown': 'Unknown', + 'task.overdue': 'Overdue', + 'task.loading': 'Loading...', + 'task.load_more': 'Load More', + 'task.no_tasks': 'No tasks', + 'task.be_first': 'Be the first to create a task!', + 'task.load_failed': 'Failed to load, please retry', + 'task.network_cache': 'Network error, loaded from cache', + 'task.points': 'pts', + 'task.applicants': 'applicants', + 'task.login_first': 'Please login to create tasks', + 'task.detail_title': 'Task Details', + 'task.deposit_ratio': 'Deposit', + 'task.published_at': 'Published', + 'task.deadline_label': 'Deadline', + 'task.description_label': '📄 Description', + 'task.ref_images': '🖼️ Reference Images', + 'task.ref_link': '🔗 Reference Link', + 'task.assignee_label': '👷 Assignee', + 'task.deliverables': '📦 Deliverables', + 'task.deliverables_note': 'Note', + 'task.applicants_title': '👥 Applicants', + 'task.select_applicant': 'Select', + 'task.assign_confirm': 'Assign {name} as the assignee?\n\nDeposit of {amount} pts will be deducted', + 'task.assign_success': 'Assigned successfully!', + 'task.assign_failed': 'Assignment failed', + 'task.login_to_operate': 'Please login to operate', + 'task.cancel_task': '❌ Cancel Task', + 'task.edit_task': 'Edit Task', + 'task.title_label': 'Task Title', + 'task.title_required': 'Please enter task title', + 'task.edit_success': 'Task updated successfully', + 'task.edit_failed': 'Failed to update task', + 'task.edit_limited_notice': 'Task has been assigned. Only title, description and reference link can be modified.', + 'task.delete_confirm_title': 'Confirm Delete Task', + 'task.delete_confirm_desc': 'This action cannot be undone. Are you sure you want to delete this task?', + 'task.delete_success': 'Task deleted', + 'task.delete_failed': 'Failed to delete', + 'task.cancel_apply': 'Withdraw', + 'task.apply_task': '🙋 Apply', + 'task.submit_result': '📤 Submit Result', + 'task.accept': '✅ Accept', + 'task.reject': '❌ Reject', + 'task.dispute': '⚖️ Dispute', + 'task.view_dispute': '⚖️ View Dispute', + 'task.apply_message': 'Enter application message (optional):', + 'task.apply_success': 'Applied successfully!', + 'task.apply_failed': 'Application failed', + 'task.cancel_apply_confirm': 'Withdraw application?', + 'task.cancel_apply_success': 'Application withdrawn', + 'task.cancel_apply_failed': 'Withdrawal failed', + 'task.cancel_confirm': 'Cancel this task? This cannot be undone.', + 'task.cancel_success': 'Task cancelled', + 'task.cancel_failed': 'Cancellation failed', + 'task.accept_confirm': 'Accept deliverables?\n\nFinal payment of {amount} pts will be paid', + 'task.accept_success': 'Accepted! Payment sent', + 'task.reject_reason': 'Enter rejection reason:', + 'task.reject_success': 'Sent back for revision', + 'task.operation_failed': 'Operation failed', + 'task.private_message': '💬 Message', + 'task.post_not_exist': 'Post not found or deleted', + 'task.submit_title': '📤 Submit Deliverables', + 'task.submit_images': 'Deliverable Images *', + 'task.submit_images_multi': 'Multiple images supported', + 'task.submit_note': 'Note (optional)', + 'task.submit_note_placeholder': 'Describe your deliverables...', + 'task.submit_confirm': 'Submit', + 'task.submit_uploading': '⏳ Uploading...', + 'task.submit_progress': '⏳ Uploading ({current}/{total})...', + 'task.submit_failed': 'Submission failed', + 'task.submit_upload_failed': 'Upload failed, please retry', + 'task.submit_submitting': '⏳ Submitting...', + 'task.submit_success': 'Submitted, awaiting review', + 'task.upload_images_required': 'Please upload deliverable images', + 'task.publish_title': '📝 Create Task', + 'task.title_placeholder': 'Describe your requirements briefly, max 50 chars', + 'task.desc_label': '📄 Description', + 'task.desc_placeholder': 'Detailed requirements, deliverable standards, references...', + 'task.images_label': '🖼️ Reference Images (optional, max 6)', + 'task.add_images': '+ Add Images', + 'task.link_label': '🔗 Reference Link (optional)', + 'task.total_price': '💰 Total Price', + 'task.deposit_ratio_label': '📊 Deposit Ratio', + 'task.deduct_on_assign': 'Deducted on assignment', + 'task.price_min': 'Min 10 pts', + 'task.price_summary': '💡 Price Summary', + 'task.price_total': 'Total', + 'task.price_deposit': 'Deposit (on assignment)', + 'task.price_remaining': 'Final (on acceptance)', + 'task.deadline_label_form': '⏰ Deadline', + 'task.publish_submit': '🚀 Create Task', + 'task.publish_notice': 'Manage your tasks in Task Board after publishing', + 'task.desc_required': 'Please enter task description', + 'task.price_min_error': 'Price must be at least 10 pts', + 'task.deadline_required': 'Please select deadline', + 'task.publish_uploading': '⏳ Uploading images...', + 'task.publish_progress': '⏳ Uploading ({current}/{total})...', + 'task.publish_submitting': '⏳ Publishing...', + 'task.publish_success': 'Task published!', + 'task.publish_failed': 'Publishing failed, please retry', + 'task.max_images': 'Max 6 images', + 'task.view_total': '🔥 Total Views', + 'task.view_today': '📅 Today Views', + 'task.save_changes': 'Save Changes', + + // Dispute + 'dispute.title': 'Dispute Details', + 'dispute.status_pending': 'Pending Response', + 'dispute.status_responded': 'Pending Arbitration', + 'dispute.status_resolved': 'Resolved', + 'dispute.task_label': '📋 Related Task', + 'dispute.parties': '👥 Parties', + 'dispute.publisher': 'Publisher', + 'dispute.assignee': 'Assignee', + 'dispute.initiator': '(Initiator)', + 'dispute.respondent': '(Respondent)', + 'dispute.initiator_statement': '📝 Initiator Statement', + 'dispute.respondent_response': '💬 Respondent Response', + 'dispute.no_response': 'No response yet', + 'dispute.respond_placeholder': 'Enter your response...', + 'dispute.upload_evidence': '📷 Upload evidence (optional)', + 'dispute.submit_response': 'Submit Response', + 'dispute.submitting': 'Submitting...', + 'dispute.response_success': 'Response submitted', + 'dispute.response_failed': 'Submission failed', + 'dispute.resolution_title': '⚖️ Resolution', + 'dispute.favor_initiator': '✅ In favor of initiator', + 'dispute.favor_respondent': '❌ In favor of respondent', + 'dispute.split': '⚖️ Split settlement', + 'dispute.split_detail': 'Initiator {ratio}% : Respondent {other}%', + 'dispute.resolved': 'Resolved', + 'dispute.resolved_at': 'Resolved at', + 'dispute.submitted_at': 'Submitted at', + 'dispute.responded_at': 'Responded at', + 'dispute.reason_required': 'Please enter dispute reason', + 'dispute.submit_dispute': 'Submit Dispute', + 'dispute.uploading': '⏳ Uploading ({current}/{total})...', + 'dispute.submitting_dispute': '⏳ Submitting dispute...', + 'dispute.dispute_success': 'Dispute submitted, awaiting response', + 'dispute.dispute_failed': 'Dispute failed', + 'dispute.dialog_title': '⚖️ File Dispute', + 'dispute.reason_label': 'Reason', + 'dispute.evidence_label': 'Evidence (optional, max 6)', + 'dispute.notice_title': '⚠️ Notice:', + 'dispute.notice_1': 'Task will enter dispute status pending arbitration', + 'dispute.notice_2': 'The other party will respond to your dispute', + 'dispute.notice_3': 'Please provide valid evidence', + 'dispute.max_images': 'Max 6 images', + + // Posts / Forum + 'post.title': 'Forum', + 'post.publish': 'Create Post', + 'post.loading': 'Loading...', + 'post.load_more': 'Load More', + 'post.no_posts': 'No posts yet', + 'post.be_first': 'Be the first to share!', + 'post.load_failed': 'Failed to load, please retry', + 'post.network_cache': 'Network error, loaded from cache', + 'post.login_first': 'Please login to create posts', + 'post.video_tag': 'Video', + 'post.sort_latest': 'Latest', + 'post.sort_likes': '👍 Most Liked', + 'post.sort_favorites': '🔖 Most Favorited', + 'post.sort_tips': '💰 Top Tipped', + 'post.sort_views': '🔥 Most Viewed', + 'post.sort_daily_views': '🔥 Trending Today', + 'post.sort_rating': '★ Highest Rated', + 'post.detail_title': '📄 Post Details', + 'post.not_exist': 'Post not found or deleted', + 'post.load_detail_failed': 'Failed to load', + 'post.liked': 'Liked', + 'post.favorited': 'Favorited', + 'post.tip_author': '🎁 Tip', + 'post.tip_dialog_title': '🎁 Tip Author', + 'post.tip_self': 'Cannot tip your own post', + 'post.tip_success': 'Tipped {amount} pts successfully', + 'post.tip_failed': 'Tip failed', + 'post.comments_title': '💬 Comments', + 'post.comment_placeholder': 'Write a comment...', + 'post.no_comments': 'No comments yet. Say something!', + 'post.comment_loading': 'Loading comments...', + 'post.comment_load_failed': 'Failed to load comments', + 'post.comment_required': 'Please enter your comment', + 'post.comment_sending': 'Sending...', + 'post.comment_success': 'Comment posted', + 'post.comment_failed': 'Comment failed', + 'post.tip_board_title': '🎁 Top Tippers', + 'post.no_tips': '🎁 No tips yet', + + // ✨ Prompt Market + 'nav.prompts': 'Prompts', + 'prompt.title': 'Prompt Market', + 'prompt.tab_image': 'Image Prompts', + 'prompt.tab_video': 'Video Prompts', + 'prompt.tab_music': 'Music Prompts', + 'prompt.original_badge': 'Original Content', + 'prompt.category_all': 'All', + 'prompt.category_load_failed': 'Failed to load categories', + // 🏷️ Built-in category translations (custom categories shown as-is) + 'promptcat.人物': 'Characters', + 'promptcat.动物': 'Animals', + 'promptcat.植物': 'Plants', + 'promptcat.道具': 'Props', + 'promptcat.建筑': 'Architecture', + 'promptcat.动作': 'Actions', + 'promptcat.表情变化': 'Expressions', + 'promptcat.天气变化': 'Weather', + 'promptcat.光效变化': 'Lighting', + 'promptcat.运镜': 'Camera Moves', + 'promptcat.视觉风格': 'Visual Styles', + 'promptcat.中国风': 'Chinese Style', + 'promptcat.流行': 'Pop', + 'promptcat.说唱': 'Rap', + 'promptcat.摇滚': 'Rock', + 'promptcat.电音': 'Electronic', + 'promptcat.纯音乐': 'Instrumental', + // 🏷️ Tags + 'prompt.tag_label': 'Tags', + 'prompt.tag_placeholder': 'Type a tag and press Enter', + 'prompt.tag_recommend': 'Recommended tags (click to add)', + 'prompt.tag_max': 'Up to 10 tags', + 'prompt.tag_no_recommend': 'No recommended tags', + 'prompt.no_prompts': 'No prompts yet', + 'prompt.be_first': 'Be the first to share a great prompt!', + 'prompt.loading': 'Loading...', + 'prompt.load_failed': 'Load failed, please try again later', + 'prompt.network_cache': 'Network error, loaded local cache', + 'prompt.free': 'Free', + 'prompt.sort_latest': 'Latest', + 'prompt.sort_likes': '👍 Most Liked', + 'prompt.sort_favorites': '🔖 Most Favorited', + 'prompt.sort_views': '🔥 Total Views', + 'prompt.sort_daily_views': '🔥 Trending Today', + 'prompt.detail_title': '✨ Prompt Detail', + 'prompt.not_exist': 'Prompt does not exist or has been deleted', + 'prompt.load_detail_failed': 'Load failed', + 'prompt.liked': 'Liked', + 'prompt.favorited': 'Favorited', + 'prompt.tip_author': '🎁 Tip', + 'prompt.tip_dialog_title': '🎁 Tip the Author', + 'prompt.tip_self': 'You cannot tip your own prompt', + 'prompt.tip_success': 'Tipped {amount} points', + 'prompt.tip_failed': 'Tip failed', + 'prompt.comments_title': '💬 Comments', + 'prompt.comment_placeholder': 'Write your comment...', + 'prompt.no_comments': 'No comments yet, say something~', + 'prompt.comment_required': 'Please enter a comment', + 'prompt.comment_success': 'Comment posted', + 'prompt.comment_failed': 'Comment failed', + 'prompt.tip_board_title': '🎁 Tip Board', + 'prompt.no_tips': '🎁 No tips yet', + 'prompt.locked_title': 'Paid Content', + 'prompt.locked_desc': 'Purchase to view the full prompt', + 'prompt.unlock': 'Unlock for {price} pts', + 'prompt.owned_mark': '✅ Purchased', + 'prompt.copy': 'Copy', + 'prompt.copied': 'Prompt copied to clipboard', + 'prompt.copy_failed': 'Copy failed, please copy manually', + 'prompt.purchase_confirm_title': 'Confirm Purchase', + 'prompt.purchase_confirm_desc': 'Spend {price} points to buy "{title}"? The full prompt will be unlocked.', + 'prompt.purchase_balance': 'Current balance', + 'prompt.purchase_success': 'Purchase successful, prompt unlocked', + 'prompt.purchase_failed': 'Purchase failed', + 'prompt.balance_not_enough': 'Insufficient balance, please recharge first', + 'prompt.login_first': 'Please log in first', + 'prompt.publish_title': '✨ Publish Prompt', + 'prompt.edit_title': '✏️ Edit Prompt', + 'prompt.publish_submit': 'Publish', + 'prompt.save_submit': 'Save', + 'prompt.type_label': 'Prompt Type', + 'prompt.category_label': 'Category', + 'prompt.category_select': 'Select a category', + 'prompt.title_label': 'Title', + 'prompt.title_placeholder': 'Give the prompt a title...', + 'prompt.content_label': 'Public Intro', + 'prompt.content_placeholder': 'Describe the usage and effects of the prompt...', + 'prompt.text_label': 'Prompt Text (Paid Content)', + 'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...', + 'prompt.price_label': 'Price', + 'prompt.video_label': 'Sample Video', + 'prompt.title_required': 'Please enter a title', + 'prompt.content_required': 'Please enter the intro', + 'prompt.text_required': 'Please enter the prompt text', + 'prompt.category_required': 'Please select a category', + 'prompt.publish_success': 'Published successfully!', + 'prompt.update_success': 'Saved successfully!', + 'prompt.publish_failed': 'Publish failed', + 'prompt.submitting': 'Publishing', + 'prompt.purchasing': 'Purchasing', + 'prompt.notice_title': 'Publishing Notes', + 'prompt.notice_1': 'The prompt text is only visible to buyers after publishing', + 'prompt.notice_2': 'The intro and cover will be shown publicly', + 'prompt.notice_3': 'You can manage it in your profile after publishing', + 'prompt.delete_confirm': 'Delete this prompt?', + 'prompt.deleted': 'Deleted', + 'prompt.edit': '✏️ Edit', + 'prompt.delete': '🗑️ Delete', + 'prompt.views': 'Views', + 'prompt.daily_views': 'Today', + 'prompt.my_prompts': 'My Prompts', + 'post.publish_title': '✏️ Create Post', + 'post.publish_submit': '🚀 Publish', + 'post.images_label': '🖼️ Upload Images', + 'post.images_required': '(Max 9, first is cover)', + 'post.images_placeholder': 'Click or drag to upload', + 'post.title_label': '📝 Title', + 'post.title_placeholder': 'Give your post a title...', + 'post.content_label': '✍️ Content', + 'post.content_placeholder': 'Share your creation...', + 'post.notice_title': '💡 Notice:', + 'post.notice_1': 'Please upload original work or cite sources', + 'post.notice_2': 'Images will be compressed automatically', + 'post.notice_3': 'Manage your posts in profile after publishing', + 'post.cover': 'Cover', + 'post.images_required_error': 'Please upload at least one image', + 'post.title_required': 'Please enter title', + 'post.uploading': '⏳ Uploading images...', + 'post.upload_progress': '⏳ Uploading ({current}/{total})...', + 'post.submitting': '⏳ Publishing...', + 'post.publish_success': 'Published!', + 'post.publish_failed': 'Publishing failed', + 'post.mode_label': 'Mode', + 'post.mode_image': 'Image', + 'post.mode_video': 'Video', + 'post.upload_images': 'Upload Images', + 'post.max_9_images': 'Max 9', + 'post.click_upload': 'Click to upload', + 'post.upload_video': 'Upload Video', + 'post.click_upload_video': 'Click to select video', + 'post.video_cover': 'Video Cover', + 'post.capture_frame': 'Capture Frame', + 'post.upload_cover': 'Upload Cover', + 'post.publish_notice': 'Notice', + 'post.notice_original': 'Upload original or cite sources', + 'post.notice_compress': 'Images compressed automatically', + 'post.notice_manage': 'Manage in profile after publishing', + 'post.error_video_format': 'Only mp4, webm, mov supported', + 'post.error_video_size': 'Video cannot exceed 100MB', + 'post.error_video_duration': 'Video cannot exceed 3 minutes', + 'post.error_video_load': 'Cannot read video info', + 'post.existing_video': 'Existing video', + 'post.error_video_not_ready': 'Video not ready yet', + 'post.cover_captured': 'Cover updated', + 'post.cover_capture_failed': 'Cover capture failed', + 'post.error_cover_format': 'Please select an image for cover', + 'post.error_no_title': 'Please enter title', + 'post.error_no_content': 'Please enter content', + 'post.error_no_video': 'Please select a video', + 'post.error_no_cover': 'Please wait for cover or upload manually', + 'post.uploading_cover': 'Uploading cover...', + 'post.uploading_video': 'Uploading video...', + // 🎵 Music prompt audio (image + audio option; video optional) + 'prompt.audio_label': 'Audio File', + 'prompt.optional_mark': '(optional)', + 'prompt.music_media_hint': 'Choose one of two display options: a demo video, or image(s) + audio. Picking one automatically clears the other.', + 'post.click_upload_audio': 'Click to select audio', + 'post.error_audio_format': 'Only mp3, wav, m4a, ogg audio supported', + 'post.error_audio_size': 'Audio cannot exceed 30MB', + 'post.existing_audio': 'Existing audio', + 'post.error_no_audio': 'Please select an audio file', + 'post.uploading_audio': 'Uploading audio...', + 'post.media_exclusive': 'Video and "images + audio" are mutually exclusive; the other set has been cleared', + 'post.publishing': 'Publishing...', + 'post.edit_success': 'Saved', + 'post.edit_failed': 'Save failed', + 'post.error_no_image': 'Please upload at least one image', + 'post.compressing_images': 'Compressing images...', + 'post.compressing_progress': 'Compressing ({current}/{total})', + 'post.uploading_progress': 'Uploading ({current}/{total})', + + // Common component additions + 'common.message': 'Message', + 'common.credits': 'Points', + 'common.unknown': 'Unknown', + 'common.load_failed': 'Load failed', + 'tip.new_fan': 'New Fan', + 'tip.supreme_sponsor': 'Supreme Sponsor', + 'tip.points': 'pts', + 'chart.loading': 'Loading chart...', + 'chart.load_failed': 'Chart failed to load', + 'image.loading': 'Loading...', + 'image.load_failed': 'Image load failed', + 'image.load_timeout': 'Load timeout', + 'image.gallery': 'Gallery', + 'common.operation_failed': 'Operation failed', + 'common.login_required': 'Please login to continue', + 'common.publish': 'Publish', + 'common.confirm_submit': 'Confirm Submit', + 'common.uploading': 'Uploading', + 'common.upload_progress': 'Upload ({current}/{total})', + 'common.submitting': 'Submitting', + 'common.upload_image_progress': 'Uploading ({current}/{total})', + 'common.unknown_task': 'Unknown Task', + + // Publish + 'publish.new_content': 'Publish New Content', + 'publish.edit_content': 'Edit Content', + 'publish.save_changes': 'Save Changes', + 'publish.confirm_publish': 'Confirm Publish', + 'publish.main_category': 'Category', + 'publish.type_tool': 'Original Plugin', + 'publish.type_app': 'Original Workflow', + 'publish.type_recommend': 'Recommend Others\' Work', + 'publish.recommend_form': 'Recommendation Type', + 'publish.recommend_as_tool': 'As Plugin (Git Install)', + 'publish.recommend_as_app': 'As Workflow (Load JSON)', + 'publish.recommend_as_link': 'Link Only (Opens in New Tab)', + 'publish.name': 'Name', + 'publish.name_placeholder': 'e.g. Highly recommend this amazing translation tool!', + 'publish.short_desc': 'Short Description', + 'publish.short_desc_placeholder': 'Max 50 chars, highlight key features...', + 'publish.resource_type': 'Resource Type', + 'publish.resource_link': 'External Link / Git', + 'publish.resource_json': 'Upload JSON', + 'publish.resource_netdisk': 'Cloud Drive', + 'publish.source_url': 'Source URL or External URL', + 'publish.source_url_placeholder': 'Enter external URL or Git repo link...', + 'publish.select_json': 'Select JSON File', + 'publish.has_cloud_file': 'Cloud file included, re-select to override', + 'publish.netdisk_link': 'Cloud Drive Link', + 'publish.netdisk_placeholder': 'Paste Baidu/Aliyun share link...', + 'publish.netdisk_password': 'Password (optional)', + 'publish.netdisk_password_placeholder': 'Enter drive password, e.g. 1234', + 'publish.security_guarantee': 'Security', + 'publish.password_encrypted_hint': 'Password encrypted in cloud, shown only after successful purchase.', + 'publish.private_repo_hint': 'This is a private GitHub repo (requires access key)', + 'publish.pat_label': 'Private Repo PAT', + 'publish.pat_hint': 'System uses this token to pull source for buyers. Only stored on backend, never exposed in frontend APIs.', + 'publish.token_placeholder': 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + 'publish.token_keep_hint': 'Already configured, leave empty to keep', + 'publish.effect_images': 'Preview Images (max 6, optional)', + 'publish.reselect_override': 'Re-select will override existing', + 'publish.drag_select_hint': 'Supports drag to reorder, first image is cover', + 'publish.mark_original': 'Original', + 'publish.mark_as_original': 'Mark as Original', + 'publish.original_no_commercial': 'Original content, not for commercial use', + 'publish.original_default_hint': 'Original content gets special badge, do not mark non-original', + 'publish.original_required_hint': 'Plugin/Workflow must be original. Non-original should be in Top Picks', + 'publish.original_required_toast': 'Plugin/Workflow must be original content', + 'publish.allow_refund': 'Allow Refund', + 'publish.refund_hint': 'Buyers can request refund within 24 hours if checked', + 'publish.original_confirm_title': 'Original Content Declaration', + 'publish.original_confirm_desc': 'You are declaring this as your original work.', + 'publish.original_confirm_subtitle': 'Please confirm:', + 'publish.original_confirm_item1': 'This content was independently created by you', + 'publish.original_confirm_item2': 'You own the full intellectual property rights', + 'publish.original_confirm_item3': 'You accept legal responsibility for this declaration', + 'publish.original_confirm_warning': 'If sharing others\' work or non-original content, use Recommend category.', + 'publish.leave_confirm_title': 'Leave?', + 'publish.leave_confirm_desc': 'You have unsaved content. Leaving will clear your edits.', + 'publish.unsaved_changes_title': 'Discard Changes?', + 'publish.unsaved_changes_desc': 'You have unsaved changes. Leaving will discard them.', + 'common.leave': 'Leave', + 'common.stay': 'Stay and continue editing', + 'publish.price': 'Price (Points)', + 'publish.price_hint': '0 means free and open source.', + 'publish.price_delay_hint': 'Price changes take effect after 24 hours', + 'publish.full_desc': 'Full Description', + 'publish.full_desc_placeholder': 'Describe features, usage, requirements...', + 'publish.current_price': 'Current Price', + 'publish.will_change_in': 'Will change in', + 'publish.hours_later': ' hours to', + 'publish.processing_avatar': 'Processing and compressing avatar locally...', + 'publish.uploading_file': 'Uploading file {size}KB to cloud...', + 'publish.upload_success': 'File uploaded!', + 'publish.upload_failed': 'File upload failed', + 'publish.upload_error': 'Upload error occurred', + 'publish.pat_required': 'Private repo checked. Please fill in the PAT access key!', + 'publish.name_desc_required': 'Please fill in name and short description!', + 'publish.link_required': 'Third-party link requires source URL!', + 'publish.git_required': 'Git install URL is required!', + 'publish.json_required': 'Workflow JSON file is required!', + 'publish.netdisk_required': 'Cloud drive link is required!', + 'publish.connecting': 'Connecting to cloud...', + 'publish.uploading_secure': 'Securely uploading file...', + 'publish.uploading_images': 'Uploading images ({current}/{total})...', + 'publish.syncing': 'Syncing to global database...', + 'publish.save_success': 'Changes saved and synced!', + 'publish.publish_success': 'Published! Your work has been synced globally.', + + // Profile page + 'profile.back': 'Back', + 'profile.recharge': 'Recharge', + 'profile.withdraw': 'Withdraw', + 'profile.settings': 'Settings', + 'profile.logout': 'Logout', + 'profile.balance': 'Balance', + 'profile.sales': 'Sales', + 'profile.tips': 'Tips', + 'profile.task_earnings': 'Task Earnings', + 'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings', + 'profile.no_following': 'Not following anyone', + 'profile.no_collected': 'No favorites yet', + 'profile.go_discover': 'Go discover content in rankings or forum!', + 'profile.collected_items': 'Favorited Resources', + 'profile.collected_posts': 'Favorited Posts', + 'profile.collected_prompts': 'Favorited Prompts', + 'profile.load_collected_failed': 'Failed to load favorites', + 'profile.loading_users': 'Loading user data...', + 'profile.load_failed': 'Load failed', + 'profile.homepage': 'Homepage', + 'profile.update_available': 'Update', + 'profile.installed': 'Installed', + 'profile.tip_stats': 'Tip Statistics', + 'profile.tip_received': 'Tips Received', + 'profile.tip_sent': 'Tips Sent', + 'profile.net_tips': 'Net Tips', + 'profile.sales_stats': 'Sales Statistics', + 'profile.sales_income': 'Sales Income', + 'profile.purchase_expense': 'Purchase Expense', + 'profile.net_sales': 'Net Sales', + 'profile.transactions_unit': '', + 'profile.received_likes': 'Likes', + 'profile.received_favorites': 'Favorites', + 'profile.followers_count': 'Followers', + 'profile.following_count': 'Following', + 'profile.tip_support': 'Tip', + 'profile.send_message': 'Message', + 'profile.follow_author': 'Follow', + 'profile.followed': 'Following', + 'profile.creator_tip_board': 'Top Contributors (TOP 10)', + 'profile.age_years': 'years old', + 'profile.location_secret': 'Secret', + 'profile.no_intro': 'This person is lazy and left nothing...', + 'profile.unknown_age': 'Unknown', + + // Edit profile & privacy + 'settings_form.title': 'Edit Profile & Privacy', + 'settings_form.change_avatar': 'Change Avatar', + 'settings_form.avatar_hint': 'JPG/PNG supported, auto-cropped 1:1 and compressed locally.', + 'settings_form.banner_title': 'Profile Card Banner', + 'settings_form.banner_upload': 'Upload & Crop', + 'settings_form.banner_clear': 'Clear', + 'settings_form.banner_hint': '16:9 ratio, drag to crop, compressed then uploaded.', + 'settings_form.ui_bg_title': 'UI Background (Local Only)', + 'settings_form.ui_bg_hint': '9:16 ratio, saved locally only, no cloud storage used.', + 'settings_form.display_name': 'Display Name', + 'settings_form.gender': 'Gender', + 'settings_form.gender_male': 'Male', + 'settings_form.gender_female': 'Female', + 'settings_form.gender_secret': 'Secret', + 'settings_form.birthday': 'Birthday (Optional)', + 'settings_form.country': 'Country / Region', + 'settings_form.select_country': 'Select country', + 'settings_form.select_country_first': 'Please select country first', + 'settings_form.intro': 'Bio', + 'settings_form.intro_placeholder': 'Tell us about yourself...', + 'settings_form.privacy_title': 'Privacy Settings', + 'settings_form.privacy_hint': 'Hide your community activity from others (does not affect your own view).', + 'settings_form.hide_follows': 'Hide who I follow', + 'settings_form.hide_likes': 'Hide my liked content', + 'settings_form.hide_favorites': 'Hide my favorited content', + 'settings_form.hide_downloads': 'Hide my download history', + 'settings_form.admin_title': 'Admin Settings', + 'settings_form.admin_hint': 'These settings affect the entire platform. Be careful.', + 'settings_form.image_moderation': 'Image Content Moderation', + 'settings_form.image_moderation_hint': 'Automatically moderate uploaded images for safety', + 'settings_form.version_title': 'Version Management', + 'settings_form.version_hint': 'Change project stage and version number across all locations', + 'settings_form.project_stage': 'Project Stage', + 'settings_form.version_number': 'Version Number', + 'settings_form.apply_version': 'Apply Version Change', + 'settings_form.cancel': 'Cancel', + 'settings_form.save_all': 'Save All Settings', + 'settings_form.save_failed': 'Settings failed', + 'settings_form.avatar_upload_success': 'Avatar uploaded! Click Save below to apply.', + 'settings_form.banner_cleared': 'Banner cleared. Click Save below to apply.', + + // Video player + 'video.play': 'Play', + 'video.pause': 'Pause', + 'video.mute': 'Mute', + 'video.unmute': 'Unmute', + 'video.fullscreen': 'Fullscreen', + 'video.exit_fullscreen': 'Exit Fullscreen', + 'video.speed': 'Speed', + 'video.close': 'Close', + 'video.forward': 'Forward 5s', + 'video.backward': 'Backward 5s', + 'video.volume': 'Volume', + + // About plugin + 'about.title': 'ComfyUI Community Hub', + 'about.subtitle': 'A modern Web3.0-level ecosystem engine built for ComfyUI', + 'about.stage_alpha': 'Alpha', + 'about.stage_beta': 'Beta', + 'about.stage_rc': 'RC', + 'about.stage_stable': 'Stable', + 'about.advantages_title': 'Core Advantages', + 'about.adv_native_title': 'Native Integration', + 'about.adv_native_desc': 'Built on ComfyUI V3 standard, zero-intrusion workflow protection', + 'about.adv_speed_title': 'Zero Latency', + 'about.adv_speed_desc': 'Modern frontend caching, no more white screen loading', + 'about.adv_im_title': 'Global Messaging', + 'about.adv_im_desc': 'Built-in DM and system notifications, breaking information silos', + 'about.adv_sandbox_title': 'Sandboxed Interaction', + 'about.adv_sandbox_desc': 'Strict event isolation protects the canvas from interference', + 'about.features_title': 'Features', + 'about.feat_publish_title': 'Publish Works', + 'about.feat_publish_desc': 'Publish plugins, workflows and quality recommendations', + 'about.feat_social_title': 'Community', + 'about.feat_social_desc': 'Built-in likes, comments, favorites, and follows', + 'about.feat_install_title': 'Easy Install', + 'about.feat_install_desc': 'One-click silent install, cloud decompression, with private repo key protection', + 'about.feat_download_title': 'One-Click Load', + 'about.feat_download_desc': 'Download plugins and workflows with cloud ownership verification, reuse downloaded files', + 'about.feat_chat_title': 'Messaging', + 'about.feat_chat_desc': 'Built-in DM and global message alerts', + 'about.feat_account_title': 'Account System', + 'about.feat_account_desc': 'Full registration, profile editing, and email-based security', + 'about.feat_payment_title': 'Payment System', + 'about.feat_payment_desc': 'Alipay QR recharge in seconds, secure withdrawal', + 'about.feat_protection_title': 'Content Protection', + 'about.feat_protection_desc': 'Paid content permanently recorded in cloud, buy once use forever', + 'about.benefits_title': 'User Benefits', + 'about.benefit_1': 'Download top plugins with one click based on rankings and charts', + 'about.benefit_2': 'Avoid poor resources through real reviews and file verification', + 'about.benefit_3': 'Publish your own original plugins or workflows', + 'about.benefit_4': 'Set download price in points (1pt=1 CNY), or make it free', + 'about.benefit_5': 'Build a following and reputation, and receive tips from appreciative users', + 'about.warning_title': 'Notice', + 'about.warning_login_title': 'Login Issues:', + 'about.warning_login_desc': 'If your previously registered account cannot log in, please reset your password and try again.', + 'about.warning_account_title': 'Wrong Account Auto-filled:', + 'about.warning_account_desc': 'Your browser may have auto-filled credentials from another site. Account names only contain letters, numbers, and symbols — no Chinese characters.', + 'about.author_title': 'About the Author', + 'about.author_thanks': 'Thank you for using ComfyUI Community Hub. May the ComfyUI ecosystem thrive and flourish 🌱' + } +}; + +/** + * 获取翻译文案 + * @param {string} key - 文案键 + * @param {Object} params - 插值参数 + * @returns {string} + */ +export function t(key, params = {}) { + const lang = translations[currentLang] || translations['zh-CN']; + let text = lang[key] || translations['zh-CN'][key] || key; + + // 处理插值 {name} + Object.entries(params).forEach(([k, v]) => { + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + + return text; +} + +/** + * 🔍 仅在词条存在时翻译,否则返回 fallback + * (t() 缺失键时返回键名本身,不适合分类/标签等用户自定义内容的双语显示) + */ +export function tIfExists(key, fallback = '') { + const lang = translations[currentLang] || translations['zh-CN']; + return lang[key] || translations['zh-CN'][key] || fallback; +} + +/** + * 设置当前语言 + * @param {string} lang - 语言代码 ('zh-CN' | 'en-US') + */ +export function setLanguage(lang) { + if (translations[lang]) { + currentLang = lang; + localStorage.setItem('comfy_ranking_lang', lang); + + // 触发语言变更事件 + document.dispatchEvent(new CustomEvent('comfy-language-change', { + detail: { lang } + })); + } +} + +/** + * 获取当前语言 + * @returns {string} + */ +export function getLanguage() { + return currentLang; +} + +/** + * 初始化 i18n + */ +export function initI18n() { + // 🔧 修复:优先从全局设置读取语言 + try { + const globalSettings = JSON.parse(localStorage.getItem('ComfyCommunity_Settings') || '{}'); + if (globalSettings.language && translations[globalSettings.language]) { + currentLang = globalSettings.language; + return; + } + } catch (e) {} + + // 回退:从旧的独立存储读取 + const savedLang = localStorage.getItem('comfy_ranking_lang'); + if (savedLang && translations[savedLang]) { + currentLang = savedLang; + } else { + // 检测浏览器语言 + const browserLang = navigator.language; + if (browserLang.startsWith('en')) { + currentLang = 'en-US'; + } else { + currentLang = 'zh-CN'; + } + } +} + +/** + * 添加自定义翻译 + * @param {string} lang - 语言代码 + * @param {Object} texts - 翻译文案对象 + */ +export function addTranslations(lang, texts) { + if (!translations[lang]) { + translations[lang] = {}; + } + Object.assign(translations[lang], texts); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\347\224\250\346\210\267\344\275\223\351\252\214\345\242\236\345\274\272.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\347\224\250\346\210\267\344\275\223\351\252\214\345\242\236\345\274\272.js" new file mode 100644 index 0000000000000000000000000000000000000000..3422222898afc4718151d32eb7b4f358bac0d190 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\347\224\250\346\210\267\344\275\223\351\252\214\345\242\236\345\274\272.js" @@ -0,0 +1,595 @@ +// 前端页面/components/用户体验增强.js +// ========================================== +// 🎯 P2 用户体验增强模块 +// ========================================== +// 作用:提供表单自动保存、快捷键、主题、响应式、i18n等增强功能 +// 关联文件: +// - UI交互提示组件.js (toast/loading) +// - 全局配置.js (配置中心) +// - 侧边栏主程序.js (初始化入口) +// - 用户体验_国际化.js (i18n 翻译字典) +// ========================================== + +import { t, initI18n } from './用户体验_国际化.js'; + + +// ========================================== +// 📝 P2-2: 表单自动保存(草稿功能) +// ========================================== +// 特点: +// - 输入防抖自动保存 +// - 离开页面前保存 +// - 恢复草稿提示 + +const DRAFT_PREFIX = 'comfy_ranking_draft_'; +const DRAFT_DEBOUNCE = 2000; // 2秒防抖 + +/** + * 创建表单自动保存管理器 + * @param {string} formId - 表单唯一标识 + * @param {HTMLFormElement|HTMLElement} formElement - 表单元素 + * @param {Object} options - 配置选项 + */ +export function createAutoSaver(formId, formElement, options = {}) { + const { + fields = [], // 需要保存的字段选择器 + onRestore = null, // 恢复草稿回调 + excludeFields = [] // 排除的字段 + } = options; + + let debounceTimer = null; + const storageKey = DRAFT_PREFIX + formId; + + // 获取表单数据 + const getFormData = () => { + const data = {}; + const inputs = formElement.querySelectorAll('input, textarea, select'); + + inputs.forEach(input => { + const name = input.name || input.id; + if (!name || excludeFields.includes(name)) return; + + if (input.type === 'checkbox') { + data[name] = input.checked; + } else if (input.type === 'radio') { + if (input.checked) data[name] = input.value; + } else { + data[name] = input.value; + } + }); + + return data; + }; + + // 保存草稿 + const saveDraft = () => { + try { + const data = getFormData(); + data._savedAt = Date.now(); + localStorage.setItem(storageKey, JSON.stringify(data)); + console.log(`📝 草稿已自动保存: ${formId}`); + } catch (e) { + console.warn('草稿保存失败:', e); + } + }; + + // 加载草稿 + const loadDraft = () => { + try { + const saved = localStorage.getItem(storageKey); + if (!saved) return null; + return JSON.parse(saved); + } catch (e) { + return null; + } + }; + + // 恢复草稿到表单 + const restoreDraft = (data) => { + if (!data) return; + + const inputs = formElement.querySelectorAll('input, textarea, select'); + inputs.forEach(input => { + const name = input.name || input.id; + if (!name || !(name in data)) return; + + if (input.type === 'checkbox') { + input.checked = !!data[name]; + } else if (input.type === 'radio') { + input.checked = input.value === data[name]; + } else { + input.value = data[name] || ''; + } + }); + + if (onRestore) onRestore(data); + }; + + // 清除草稿 + const clearDraft = () => { + localStorage.removeItem(storageKey); + }; + + // 检查是否有草稿 + const hasDraft = () => { + const draft = loadDraft(); + return draft && draft._savedAt; + }; + + // 输入事件监听(防抖保存) + const handleInput = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(saveDraft, DRAFT_DEBOUNCE); + }; + + // 页面离开前保存 + const handleBeforeUnload = () => { + saveDraft(); + }; + + // 初始化 + const init = () => { + // 监听输入事件 + formElement.addEventListener('input', handleInput); + formElement.addEventListener('change', handleInput); + + // 页面离开前保存 + window.addEventListener('beforeunload', handleBeforeUnload); + + // 检查是否有草稿需要恢复 + const draft = loadDraft(); + if (draft && draft._savedAt) { + const savedTime = new Date(draft._savedAt).toLocaleString(); + return { hasDraft: true, savedAt: savedTime, draft }; + } + + return { hasDraft: false }; + }; + + // 销毁:在表单所在弹窗关闭或组件卸载时必须调用, + // 以移除 input/change/beforeunload 事件监听并清理防抖定时器, + // 防止内存泄漏和已卸载 DOM 上的事件监听器残留。 + // 调用时机:弹窗关闭回调、组件 unmount 钩子、或 beforeunload 兜底。 + const destroy = () => { + formElement.removeEventListener('input', handleInput); + formElement.removeEventListener('change', handleInput); + window.removeEventListener('beforeunload', handleBeforeUnload); + if (debounceTimer) clearTimeout(debounceTimer); + }; + + return { + init, + destroy, + saveDraft, + loadDraft, + restoreDraft, + clearDraft, + hasDraft + }; +} + + +// ========================================== +// ⌨️ P2-3: 快捷键支持 +// ========================================== +// 特点: +// - Esc 关闭弹窗 +// - Enter 确认提交 +// - Ctrl+S 保存 +// - 可配置扩展 + +const keyboardShortcuts = new Map(); +let shortcutsInitialized = false; + +/** + * 注册全局快捷键 + * @param {string} key - 按键组合 (如 'Escape', 'Enter', 'Ctrl+S') + * @param {Function} handler - 处理函数 + * @param {Object} options - 配置选项 + */ +export function registerShortcut(key, handler, options = {}) { + const { + scope = 'global', // 作用域 + preventDefault = true, // 是否阻止默认行为 + description = '' // 描述 + } = options; + + if (!keyboardShortcuts.has(key)) { + keyboardShortcuts.set(key, []); + } + + keyboardShortcuts.get(key).push({ + handler, + scope, + preventDefault, + description + }); +} + +/** + * 移除快捷键 + * @param {string} key - 按键组合 + * @param {Function} handler - 处理函数(可选,不传则移除所有) + */ +export function unregisterShortcut(key, handler = null) { + if (handler && keyboardShortcuts.has(key)) { + const handlers = keyboardShortcuts.get(key); + const filtered = handlers.filter(h => h.handler !== handler); + if (filtered.length > 0) { + keyboardShortcuts.set(key, filtered); + } else { + keyboardShortcuts.delete(key); + } + } else { + keyboardShortcuts.delete(key); + } +} + +/** + * 初始化快捷键系统 + */ +export function initKeyboardShortcuts() { + if (shortcutsInitialized) return; + shortcutsInitialized = true; + + document.addEventListener('keydown', (e) => { + // 构建按键标识 + let keyCombo = ''; + if (e.ctrlKey || e.metaKey) keyCombo += 'Ctrl+'; + if (e.altKey) keyCombo += 'Alt+'; + if (e.shiftKey) keyCombo += 'Shift+'; + keyCombo += e.key; + + // 也检查单独的按键 + const keysToCheck = [keyCombo]; + if (!e.ctrlKey && !e.altKey && !e.shiftKey) { + keysToCheck.push(e.key); + } + + for (const key of keysToCheck) { + if (keyboardShortcuts.has(key)) { + const handlers = keyboardShortcuts.get(key); + for (const { handler, preventDefault } of handlers) { + // 跳过在输入框中的快捷键(除了 Escape) + const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName); + if (isInput && key !== 'Escape') continue; + + if (preventDefault) { + e.preventDefault(); + } + handler(e); + return; + } + } + } + }); + + // 注册默认快捷键 + _registerDefaultShortcuts(); +} + +function _registerDefaultShortcuts() { + // Esc 关闭最上层弹窗 + registerShortcut('Escape', () => { + const modals = document.querySelectorAll('[data-modal="true"]'); + if (modals.length > 0) { + const topModal = modals[modals.length - 1]; + const closeBtn = topModal.querySelector('[data-close]'); + if (closeBtn) closeBtn.click(); + } + }, { description: '关闭弹窗' }); + + // Ctrl+S 保存(触发自定义事件) + registerShortcut('Ctrl+s', () => { + document.dispatchEvent(new CustomEvent('comfy-save-shortcut')); + }, { description: '保存' }); +} + + +// ========================================== +// 🎨 P2-4: 暗色主题适配 +// ========================================== +// 特点: +// - CSS 变量统一管理 +// - ComfyUI 原生变量兼容 +// - 动态主题切换 + +export const THEME_COLORS = { + // 背景色 + bgPrimary: 'var(--comfy-input-bg, #2b2b2b)', + bgSecondary: 'var(--comfy-menu-bg)', + bgTertiary: '#161616', + bgOverlay: 'rgba(0, 0, 0, 0.6)', + + // 边框 + border: '#444', + borderLight: '#555', + borderDark: '#333', + + // 文字 + textPrimary: '#fff', + textSecondary: '#ccc', + textMuted: '#888', + textDisabled: '#666', + + // 强调色 + accent: '#4CAF50', + accentHover: '#66BB6A', + accentDark: '#388E3C', + + // 状态色 + success: '#4CAF50', + error: '#F44336', + warning: '#FF9800', + info: '#2196F3', + + // 交互 + hoverBg: 'rgba(255, 255, 255, 0.05)', + activeBg: 'rgba(255, 255, 255, 0.1)', + + // 阴影 + shadowSm: '0 2px 8px rgba(0, 0, 0, 0.3)', + shadowMd: '0 4px 16px rgba(0, 0, 0, 0.4)', + shadowLg: '0 8px 32px rgba(0, 0, 0, 0.5)' +}; + +/** + * 注入 style 标签(内部含已存在检查,幂等安全) + * @param {string} id - style 标签的 id + * @param {string} cssContent - CSS 内容 + */ +function _injectStyleTag(id, cssContent) { + if (document.getElementById(id)) return; + const style = document.createElement('style'); + style.id = id; + style.textContent = cssContent; + document.head.appendChild(style); +} + +/** + * 注入主题 CSS 变量 + */ +export function injectThemeVariables() { + const cssVars = Object.entries(THEME_COLORS) + .map(([key, value]) => `--cr-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value};`) + .join('\n '); + + _injectStyleTag('comfy-ranking-theme-vars', ` + :root { + ${cssVars} + } + + /* 暗色主题基础样式 */ + .comfy-ranking-root { + color-scheme: dark; + background: var(--cr-bg-secondary); + color: var(--cr-text-primary); + } + + /* 滚动条样式 */ + .comfy-ranking-root ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .comfy-ranking-root ::-webkit-scrollbar-track { + background: var(--cr-bg-tertiary); + } + .comfy-ranking-root ::-webkit-scrollbar-thumb { + background: var(--cr-border); + border-radius: 4px; + } + .comfy-ranking-root ::-webkit-scrollbar-thumb:hover { + background: var(--cr-border-light); + } + + /* 输入框焦点状态 */ + .comfy-ranking-root input:focus, + .comfy-ranking-root textarea:focus, + .comfy-ranking-root select:focus { + outline: none; + border-color: var(--cr-accent) !important; + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); + } + + /* 按钮悬停效果 */ + .comfy-ranking-root button:not(:disabled):hover { + filter: brightness(1.1); + } + `); +} + + +// ========================================== +// 📱 P2-5: 移动端适配 +// ========================================== +// 特点: +// - 响应式断点 +// - 触摸友好的交互区域 +// - 侧边栏自适应 + +export const BREAKPOINTS = { + mobile: 480, + tablet: 768, + desktop: 1024, + wide: 1440 +}; + +/** + * 检测当前设备类型 + * @returns {'mobile'|'tablet'|'desktop'|'wide'} + */ +export function getDeviceType() { + const width = window.innerWidth; + if (width < BREAKPOINTS.mobile) return 'mobile'; + if (width < BREAKPOINTS.tablet) return 'tablet'; + if (width < BREAKPOINTS.desktop) return 'desktop'; + return 'wide'; +} + +/** + * 检测是否为触摸设备 + */ +export function isTouchDevice() { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +} + +/** + * 注入响应式样式 + */ +export function injectResponsiveStyles() { + _injectStyleTag('comfy-ranking-responsive', ` + /* 移动端适配 */ + @media (max-width: ${BREAKPOINTS.tablet}px) { + .comfy-ranking-sidebar { + width: 100% !important; + max-width: 100vw !important; + } + + .comfy-ranking-card { + padding: 12px !important; + } + + /* 触摸友好的按钮尺寸 */ + .comfy-ranking-root button, + .comfy-ranking-root .clickable { + min-height: 44px; + min-width: 44px; + } + + /* 增大点击区域 */ + .comfy-ranking-root input, + .comfy-ranking-root textarea, + .comfy-ranking-root select { + padding: 12px !important; + font-size: 16px !important; /* 防止iOS缩放 */ + } + } + + /* 平板适配 */ + @media (min-width: ${BREAKPOINTS.tablet}px) and (max-width: ${BREAKPOINTS.desktop}px) { + .comfy-ranking-sidebar { + width: 400px !important; + } + } + + /* 触摸设备优化 */ + @media (hover: none) and (pointer: coarse) { + .comfy-ranking-root button:active { + transform: scale(0.97); + } + + /* 禁用悬停效果 */ + .comfy-ranking-root .hover-effect:hover { + background: transparent !important; + } + } + `); +} + +/** + * 添加安全区域适配(iPhone刘海屏等) + */ +export function applySafeAreaInsets() { + _injectStyleTag('comfy-ranking-safe-area', ` + .comfy-ranking-root { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } + + .comfy-ranking-bottom-bar { + padding-bottom: env(safe-area-inset-bottom); + } + `); +} + + +// ========================================== +// 🌐 P2-6: 多语言支持 (i18n) - 已拆分到独立文件 +// ========================================== +// 国际化模块已拆分到独立文件,此处保持向后兼容导出 +export { t, tIfExists, setLanguage, getLanguage, initI18n, addTranslations } from './用户体验_国际化.js'; + + + +// ========================================== +// 🚀 统一初始化 +// ========================================== + +/** + * 初始化所有用户体验增强功能 + */ +export function initUXEnhancements() { + // 注入主题变量 + injectThemeVariables(); + + // 注入响应式样式 + injectResponsiveStyles(); + + // 应用安全区域 + applySafeAreaInsets(); + + // 初始化快捷键 + initKeyboardShortcuts(); + + // 初始化 i18n + initI18n(); + + console.log('🎯 P2 用户体验增强已初始化'); +} + + +// ========================================== +// 🔧 工具函数 +// ========================================== + +/** + * 防止双击提交 + * @param {HTMLButtonElement} button - 按钮元素 + * @param {number} delay - 禁用时长(毫秒) + */ +export function preventDoubleClick(button, delay = 1000) { + if (button.disabled) return false; + + button.disabled = true; + setTimeout(() => { + button.disabled = false; + }, delay); + + return true; +} + +/** + * 平滑滚动到元素 + * @param {HTMLElement} element - 目标元素 + * @param {Object} options - 配置选项 + */ +export function scrollToElement(element, options = {}) { + const { + behavior = 'smooth', + block = 'start', + offset = 0 + } = options; + + const top = element.getBoundingClientRect().top + window.pageYOffset + offset; + + window.scrollTo({ + top, + behavior + }); +} + +/** + * 复制到剪贴板(带反馈) + * @param {string} text - 要复制的文本 + */ +export async function copyWithFeedback(text) { + try { + await navigator.clipboard.writeText(text); + // 使用 UI 组件的 toast + const { showToast } = await import('./UI交互提示组件.js'); + showToast(t('common.success'), 'success'); + return true; + } catch { + return false; + } +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\350\247\206\351\242\221\346\222\255\346\224\276\345\231\250\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\350\247\206\351\242\221\346\222\255\346\224\276\345\231\250\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..561205538d087230396fb5a2dd9850316f79df7b --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\350\247\206\351\242\221\346\222\255\346\224\276\345\231\250\347\273\204\344\273\266.js" @@ -0,0 +1,1191 @@ +// 前端页面/components/视频播放器组件.js +// ========================================== +// 🎬 自定义视频播放器组件 +// ========================================== +// 功能:内嵌视频播放 + 全屏沉浸式播放,支持自定义控制栏、键盘快捷键 +// 接口:getVideoPlayerHTML / setupVideoPlayerEvents / cleanupVideoPlayer / openFullscreenVideo +// ========================================== + +import { t } from './用户体验增强.js'; + +// ========================================== +// 🎨 CSS 样式(仅注入一次) +// ========================================== +let stylesInjected = false; +function injectVideoPlayerStyles() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement('style'); + style.textContent = ` +.video-player-container { + position: relative; + overflow: hidden; + border-radius: 8px; + max-height: 500px; + background: #000; + width: 100%; + outline: none; +} +.video-player-container video { + width: 100%; + height: 100%; + max-height: 500px; + object-fit: contain; + display: block; + cursor: pointer; +} +.video-poster-overlay { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 5; + transition: opacity 0.3s ease; +} +.video-poster-overlay img { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + object-fit: cover; +} +.video-play-btn-large { + position: relative; + z-index: 2; + width: 64px; height: 64px; + border-radius: 50%; + background: rgba(255,255,255,0.85); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, background 0.2s ease; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} +.video-play-btn-large::after { + content: ''; + width: 0; height: 0; + margin-left: 6px; + border-style: solid; + border-width: 12px 0 12px 20px; + border-color: transparent transparent transparent #333; +} +.video-poster-overlay:hover .video-play-btn-large { + transform: scale(1.08); + background: rgba(255,255,255,0.95); +} +.video-controls-bar { + position: absolute; + bottom: 0; left: 0; right: 0; + background: linear-gradient(transparent, rgba(0,0,0,0.8)); + padding: 8px 10px 6px; + z-index: 10; + transition: opacity 0.3s ease; + opacity: 1; +} +.video-controls-bar.hidden { + opacity: 0; + pointer-events: none; +} +.video-progress-wrap { + position: relative; + width: 100%; + height: 4px; + background: rgba(255,255,255,0.2); + border-radius: 2px; + cursor: pointer; + margin-bottom: 8px; + transition: height 0.2s ease; +} +.video-progress-wrap:hover { + height: 8px; +} +.video-progress-buffered { + position: absolute; + top: 0; left: 0; + height: 100%; + background: rgba(255,255,255,0.3); + border-radius: 2px; + width: 0; + pointer-events: none; +} +.video-progress-played { + position: absolute; + top: 0; left: 0; + height: 100%; + background: #4CAF50; + border-radius: 2px; + width: 0; + pointer-events: none; +} +.video-progress-handle { + position: absolute; + top: 50%; + left: 0; + width: 12px; height: 12px; + background: #4CAF50; + border-radius: 50%; + transform: translate(-50%, -50%); + cursor: grab; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} +.video-progress-wrap:hover .video-progress-handle, +.video-progress-handle.dragging { + opacity: 1; +} +.video-progress-tooltip { + position: absolute; + bottom: 16px; + transform: translateX(-50%); + background: rgba(0,0,0,0.8); + color: #fff; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 3; +} +.video-progress-wrap:hover .video-progress-tooltip.show { + opacity: 1; +} +.video-controls-row { + display: flex; + align-items: center; + gap: 8px; + color: #fff; + font-size: 13px; +} +.video-controls-row button { + background: none; + border: none; + color: #fff; + font-size: 14px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + min-width: 28px; + transition: background 0.2s ease; +} +.video-controls-row button:hover { + background: rgba(255,255,255,0.2); +} +.video-time { + font-variant-numeric: tabular-nums; + min-width: 80px; + user-select: none; +} +.video-volume-wrap { + display: flex; + align-items: center; + gap: 4px; +} +.video-volume-slider { + width: 60px; + cursor: pointer; + accent-color: #4CAF50; +} +.video-speed-wrap { + position: relative; +} +.video-speed-menu { + position: absolute; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + background: rgba(30,30,30,0.95); + border-radius: 6px; + padding: 4px 0; + min-width: 60px; + display: none; + flex-direction: column; + z-index: 20; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); +} +.video-speed-menu.open { + display: flex; +} +.video-speed-menu > div { + padding: 6px 12px; + font-size: 12px; + color: #fff; + cursor: pointer; + text-align: center; + transition: background 0.15s ease; +} +.video-speed-menu > div:hover, +.video-speed-menu > div.active { + background: rgba(76,175,80,0.3); +} +.fullscreen-video-overlay { + position: fixed; + top: 0; left: 0; + width: 100vw; height: 100vh; + background: #000; + z-index: 99999; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + animation: videoFadeIn 0.25s ease; +} +@keyframes videoFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +.fullscreen-video-overlay .fs-video-close { + position: absolute; + top: 16px; right: 16px; + width: 44px; height: 44px; + background: rgba(255,255,255,0.1); + border: none; + border-radius: 8px; + color: #fff; + font-size: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + transition: background 0.2s ease; +} +.fullscreen-video-overlay .fs-video-close:hover { + background: rgba(255,255,255,0.25); +} +.fullscreen-video-overlay video { + width: 100vw; + height: 100vh; + object-fit: contain; +} +.fullscreen-video-overlay .video-controls-bar { + padding: 12px 16px 10px; + font-size: 15px; +} +.fullscreen-video-overlay .video-controls-row button { + font-size: 16px; + padding: 6px 10px; + min-width: 32px; +} +.fullscreen-video-overlay .video-progress-wrap { + margin-bottom: 10px; +} +.fullscreen-video-overlay .video-volume-slider { + width: 80px; +} +`; + document.head.appendChild(style); +} + +// ========================================== +// 🔧 辅助函数 +// ========================================== +function formatTime(seconds) { + if (!isFinite(seconds) || seconds < 0) return '0:00'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function getVolumeIcon(volume, muted) { + if (muted || volume <= 0) return '🔇'; + if (volume < 0.3) return '🔈'; + if (volume < 0.7) return '🔉'; + return '🔊'; +} + +function getPlayIcon(isPlaying) { + return isPlaying ? '⏸' : '▶'; +} + +// ========================================== +// 🔧 模块级共享辅助函数(内嵌+全屏播放器共用) +// ========================================== + +/** + * 更新播放按钮标志与 tooltip + * @param {HTMLElement} btnPlay + * @param {HTMLVideoElement} video + */ +function _updatePlayButton(btnPlay, video) { + if (!btnPlay) return; + const playing = !video.paused && !video.ended; + btnPlay.textContent = getPlayIcon(playing); + btnPlay.title = playing ? (t('video.pause') || '暂停') : (t('video.play') || '播放'); +} + +/** + * 更新进度条 UI + * @param {HTMLElement} progressPlayed + * @param {HTMLElement} progressHandle + * @param {HTMLElement} timeDisplay + * @param {HTMLVideoElement} video + */ +function _updateProgressUI(progressPlayed, progressHandle, timeDisplay, video) { + if (!video.duration || !isFinite(video.duration)) return; + const pct = (video.currentTime / video.duration) * 100; + if (progressPlayed) progressPlayed.style.width = pct + '%'; + if (progressHandle) progressHandle.style.left = pct + '%'; + if (timeDisplay) timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`; +} + +/** + * 更新缓冲进度 + * @param {HTMLElement} progressBuffered + * @param {HTMLVideoElement} video + */ +function _updateBuffered(progressBuffered, video) { + if (!video.buffered || !video.duration || !progressBuffered) return; + let end = 0; + for (let i = 0; i < video.buffered.length; i++) { + if (video.buffered.start(i) <= video.currentTime && video.buffered.end(i) >= video.currentTime) { + end = Math.max(end, video.buffered.end(i)); + } + } + progressBuffered.style.width = ((end / video.duration) * 100) + '%'; +} + +/** + * 更新音量图标 + * @param {HTMLElement} btnVolume + * @param {HTMLVideoElement} video + */ +function _updateVolumeIcon(btnVolume, video) { + if (!btnVolume) return; + btnVolume.textContent = getVolumeIcon(video.volume, video.muted); +} + +// ========================================== +// 📺 Part 1: 内嵌播放器 +// ========================================== + +/** + * 生成视频播放器 HTML + * @param {string} videoUrl - 视频地址 + * @param {string} posterUrl - 封面图地址 + * @param {Object} options - 可选配置 + * @returns {string} HTML 字符串 + */ +export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) { + injectVideoPlayerStyles(); + const safeVideo = (videoUrl || '').replace(/"/g, '"'); + const safePoster = (posterUrl || '').replace(/"/g, '"'); + + return ` +
+ +
+ +
+
+
+
+
+
+
+
0:00
+
+
+ + 0:00 / 0:00 +
+ + +
+
+ +
+
0.5x
+
1x
+
1.25x
+
1.5x
+
2x
+
+
+ +
+
+
+`; +} + +/** + * 设置视频播放器事件 + * @param {HTMLElement} container - 包含 .video-player-container 的容器 + */ +export function setupVideoPlayerEvents(container) { + const player = container.querySelector('.video-player-container'); + if (!player) return; + + const video = player.querySelector('video'); + const posterOverlay = player.querySelector('.video-poster-overlay'); + const controlsBar = player.querySelector('.video-controls-bar'); + const progressWrap = player.querySelector('.video-progress-wrap'); + const progressBuffered = player.querySelector('.video-progress-buffered'); + const progressPlayed = player.querySelector('.video-progress-played'); + const progressHandle = player.querySelector('.video-progress-handle'); + const progressTooltip = player.querySelector('.video-progress-tooltip'); + const btnPlay = player.querySelector('.video-btn-play'); + const btnVolume = player.querySelector('.video-btn-volume'); + const volumeSlider = player.querySelector('.video-volume-slider'); + const btnSpeed = player.querySelector('.video-btn-speed'); + const speedMenu = player.querySelector('.video-speed-menu'); + const btnFullscreen = player.querySelector('.video-btn-fullscreen'); + const timeDisplay = player.querySelector('.video-time'); + + if (!video) return; + + // 设置按钮文字(fallback) + if (btnPlay) btnPlay.title = t('video.play') || '播放'; + if (btnVolume) btnVolume.title = t('video.volume') || '音量'; + if (btnSpeed) btnSpeed.title = t('video.speed') || '播放速度'; + if (btnFullscreen) btnFullscreen.title = t('video.fullscreen') || '全屏'; + + let isDraggingProgress = false; + let wasPlayingBeforeDrag = false; + let hideControlsTimer = null; + let isFullscreenMode = false; + + // 共享辅助函数(闭包适配) + const updatePlayButton = () => _updatePlayButton(btnPlay, video); + const updateProgressUI = () => _updateProgressUI(progressPlayed, progressHandle, timeDisplay, video); + const updateBuffered = () => _updateBuffered(progressBuffered, video); + const updateVolumeIcon = () => _updateVolumeIcon(btnVolume, video); + + // 控制栏自动隐藏 + function showControls() { + if (!controlsBar) return; + controlsBar.classList.remove('hidden'); + if (hideControlsTimer) clearTimeout(hideControlsTimer); + if (!video.paused && !video.ended) { + hideControlsTimer = setTimeout(() => { + if (!video.paused && !video.ended) { + controlsBar.classList.add('hidden'); + } + }, 3000); + } + } + + function resetHideTimer() { + showControls(); + } + + // Seek 到指定比例 + function seekToRatio(ratio) { + if (!video.duration || !isFinite(video.duration)) return; + ratio = Math.max(0, Math.min(1, ratio)); + video.currentTime = ratio * video.duration; + } + + // 获取点击/鼠标位置对应的比例 + function getRatioFromEvent(e, element) { + const rect = element.getBoundingClientRect(); + const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX; + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + } + + // 播放/暂停切换 + function togglePlay() { + if (video.paused || video.ended) { + video.play().catch(() => {}); + } else { + video.pause(); + } + } + + // ===== 事件绑定 ===== + + // 1. 封面层点击 + if (posterOverlay) { + posterOverlay.addEventListener('click', () => { + video.play().catch(() => {}); + }); + } + + // 2. 视频区域点击 + video.addEventListener('click', (e) => { + e.stopPropagation(); + togglePlay(); + }); + + // 3. 播放/暂停按钮 + if (btnPlay) { + btnPlay.addEventListener('click', (e) => { + e.stopPropagation(); + togglePlay(); + }); + } + + // 4. 进度条交互 + video.addEventListener('timeupdate', () => { + if (!isDraggingProgress) updateProgressUI(); + }); + video.addEventListener('progress', updateBuffered); + video.addEventListener('loadedmetadata', () => { + updateProgressUI(); + updateBuffered(); + }); + + if (progressWrap) { + // 点击进度条 + progressWrap.addEventListener('click', (e) => { + if (isDraggingProgress) return; + e.stopPropagation(); + seekToRatio(getRatioFromEvent(e, progressWrap)); + }); + + // 进度条 hover tooltip + progressWrap.addEventListener('mousemove', (e) => { + if (!video.duration || !isFinite(video.duration)) return; + const ratio = getRatioFromEvent(e, progressWrap); + const time = ratio * video.duration; + if (progressTooltip) { + progressTooltip.textContent = formatTime(time); + progressTooltip.style.left = (ratio * 100) + '%'; + progressTooltip.classList.add('show'); + } + }); + progressWrap.addEventListener('mouseleave', () => { + if (progressTooltip) progressTooltip.classList.remove('show'); + }); + + // 拖拽把手 + if (progressHandle) { + progressHandle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + e.preventDefault(); + isDraggingProgress = true; + progressHandle.classList.add('dragging'); + wasPlayingBeforeDrag = !video.paused && !video.ended; + if (wasPlayingBeforeDrag) video.pause(); + }); + } + // 在进度条任意位置按下也可以拖拽 + progressWrap.addEventListener('mousedown', (e) => { + if (e.target === progressHandle) return; + isDraggingProgress = true; + if (progressHandle) progressHandle.classList.add('dragging'); + wasPlayingBeforeDrag = !video.paused && !video.ended; + if (wasPlayingBeforeDrag) video.pause(); + const ratio = getRatioFromEvent(e, progressWrap); + seekToRatio(ratio); + if (progressPlayed) progressPlayed.style.width = (ratio * 100) + '%'; + if (progressHandle) progressHandle.style.left = (ratio * 100) + '%'; + }); + } + + const handleMouseMove = (e) => { + if (!isDraggingProgress || !progressWrap) return; + const ratio = getRatioFromEvent(e, progressWrap); + seekToRatio(ratio); + if (progressPlayed) progressPlayed.style.width = (ratio * 100) + '%'; + if (progressHandle) progressHandle.style.left = (ratio * 100) + '%'; + if (progressTooltip) { + progressTooltip.textContent = formatTime(video.currentTime); + progressTooltip.style.left = (ratio * 100) + '%'; + progressTooltip.classList.add('show'); + } + }; + + const handleMouseUp = () => { + if (!isDraggingProgress) return; + isDraggingProgress = false; + if (progressHandle) progressHandle.classList.remove('dragging'); + if (progressTooltip) progressTooltip.classList.remove('show'); + if (wasPlayingBeforeDrag) video.play().catch(() => {}); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + // 5. 音量控制 + if (volumeSlider) { + volumeSlider.addEventListener('input', () => { + const val = parseInt(volumeSlider.value, 10) / 100; + video.volume = val; + if (val > 0 && video.muted) video.muted = false; + updateVolumeIcon(); + }); + } + if (btnVolume) { + btnVolume.addEventListener('click', (e) => { + e.stopPropagation(); + video.muted = !video.muted; + updateVolumeIcon(); + }); + } + + // 6. 播放速度 + function closeSpeedMenu() { + if (speedMenu) speedMenu.classList.remove('open'); + } + + if (btnSpeed && speedMenu) { + btnSpeed.addEventListener('click', (e) => { + e.stopPropagation(); + speedMenu.classList.toggle('open'); + }); + speedMenu.querySelectorAll('div[data-speed]').forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation(); + const speed = parseFloat(item.dataset.speed); + video.playbackRate = speed; + btnSpeed.textContent = speed + 'x'; + speedMenu.querySelectorAll('div').forEach(d => d.classList.remove('active')); + item.classList.add('active'); + closeSpeedMenu(); + }); + }); + } + const handleDocClickForSpeed = (e) => { + if (speedMenu && !speedMenu.contains(e.target) && e.target !== btnSpeed) { + closeSpeedMenu(); + } + }; + document.addEventListener('click', handleDocClickForSpeed); + + // 7. 全屏按钮 + if (btnFullscreen) { + btnFullscreen.addEventListener('click', (e) => { + e.stopPropagation(); + const currentTime = video.currentTime; + video.pause(); // 先暂停内嵌播放器,避免与全屏播放器同时出声 + openFullscreenVideo( + player.dataset.videoUrl || '', + player.dataset.posterUrl || '', + { + currentTime, + volume: video.volume, + muted: video.muted, + playbackRate: video.playbackRate, + onClose: (returnTime) => { + video.currentTime = returnTime; + updateProgressUI(); + updatePlayButton(); // 同步播放按钮状态为暂停 + } + } + ); + }); + } + + // 8. 键盘快捷键 + const handleKeydown = (e) => { + if (document.activeElement !== player) return; + switch (e.key) { + case ' ': + case 'k': + e.preventDefault(); + togglePlay(); + break; + case 'ArrowLeft': + e.preventDefault(); + video.currentTime = Math.max(0, video.currentTime - 5); + break; + case 'ArrowRight': + e.preventDefault(); + video.currentTime = Math.min(video.duration || 0, video.currentTime + 5); + break; + case 'ArrowUp': + e.preventDefault(); + video.volume = Math.min(1, video.volume + 0.1); + if (video.volume > 0) video.muted = false; + if (volumeSlider) volumeSlider.value = Math.round(video.volume * 100); + updateVolumeIcon(); + break; + case 'ArrowDown': + e.preventDefault(); + video.volume = Math.max(0, video.volume - 0.1); + if (volumeSlider) volumeSlider.value = Math.round(video.volume * 100); + updateVolumeIcon(); + break; + case 'f': + case 'F': + e.preventDefault(); + if (btnFullscreen) btnFullscreen.click(); + break; + case 'm': + case 'M': + e.preventDefault(); + video.muted = !video.muted; + updateVolumeIcon(); + break; + } + }; + player.addEventListener('keydown', handleKeydown); + + // 点击播放器获取焦点 + player.addEventListener('click', () => { + player.focus(); + }); + + // 9. 控制栏自动隐藏 + player.addEventListener('mousemove', resetHideTimer); + player.addEventListener('mouseenter', resetHideTimer); + video.addEventListener('play', showControls); + video.addEventListener('pause', showControls); + video.addEventListener('ended', () => { + if (controlsBar) controlsBar.classList.remove('hidden'); + }); + + // 10. 拖拽防冒泡 + const stopDrag = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + player.addEventListener('dragstart', stopDrag); + player.addEventListener('dragover', stopDrag); + player.addEventListener('drop', stopDrag); + + // 11. 视频结束回到封面 + video.addEventListener('ended', () => { + if (posterOverlay) { + posterOverlay.style.display = 'flex'; + posterOverlay.style.opacity = '1'; + } + updatePlayButton(); + }); + + // 播放时隐藏封面 + video.addEventListener('play', () => { + if (posterOverlay) { + posterOverlay.style.opacity = '0'; + setTimeout(() => { + if (!video.paused) posterOverlay.style.display = 'none'; + }, 300); + } + updatePlayButton(); + showControls(); + }); + + video.addEventListener('pause', () => { + updatePlayButton(); + }); + + // 初始状态 + updateVolumeIcon(); + updatePlayButton(); + + // 🔧 清理函数注册 + const cleanup = () => { + if (hideControlsTimer) clearTimeout(hideControlsTimer); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('click', handleDocClickForSpeed); + video.pause(); + }; + container._videoPlayerCleanup = cleanup; +} + +/** + * 清理视频播放器 + * @param {HTMLElement} container - 包含 .video-player-container 的容器 + */ +export function cleanupVideoPlayer(container) { + if (container && container._videoPlayerCleanup) { + container._videoPlayerCleanup(); + container._videoPlayerCleanup = null; + } +} + +// ========================================== +// 📺 Part 2: 全屏播放器 +// ========================================== + +/** + * 打开全屏视频播放器 + * @param {string} videoUrl - 视频地址 + * @param {string} posterUrl - 封面图地址 + * @param {Object} options - 可选配置 + * @param {number} options.currentTime - 起始播放时间 + * @param {number} options.volume - 初始音量 + * @param {boolean} options.muted - 是否静音 + * @param {number} options.playbackRate - 播放速度 + * @param {Function} options.onClose - 关闭回调(currentTime) + */ +export function openFullscreenVideo(videoUrl, posterUrl, options = {}) { + injectVideoPlayerStyles(); + + const overlay = document.createElement('div'); + overlay.className = 'fullscreen-video-overlay'; + + const safeVideo = (videoUrl || '').replace(/"/g, '"'); + const safePoster = (posterUrl || '').replace(/"/g, '"'); + const startTime = options.currentTime || 0; + const startVolume = typeof options.volume === 'number' ? options.volume : 1; + const startMuted = !!options.muted; + const startRate = options.playbackRate || 1; + + overlay.innerHTML = ` + + +
+
+
+
+
+
0:00
+
+
+ + 0:00 / 0:00 +
+ + +
+
+ +
+
0.5x
+
1x
+
1.25x
+
1.5x
+
2x
+
+
+ +
+
+ `; + + document.body.appendChild(overlay); + + const video = overlay.querySelector('video'); + const closeBtn = overlay.querySelector('.fs-video-close'); + const posterOverlay = null; // 全屏模式不需要封面层 + + // 初始化视频状态 + video.currentTime = startTime; + video.volume = startVolume; + video.muted = startMuted; + video.playbackRate = startRate; + + // 复用内嵌播放器的事件绑定逻辑 + const controlsBar = overlay.querySelector('.video-controls-bar'); + const progressWrap = overlay.querySelector('.video-progress-wrap'); + const progressBuffered = overlay.querySelector('.video-progress-buffered'); + const progressPlayed = overlay.querySelector('.video-progress-played'); + const progressHandle = overlay.querySelector('.video-progress-handle'); + const progressTooltip = overlay.querySelector('.video-progress-tooltip'); + const btnPlay = overlay.querySelector('.video-btn-play'); + const btnVolume = overlay.querySelector('.video-btn-volume'); + const volumeSlider = overlay.querySelector('.video-volume-slider'); + const btnSpeed = overlay.querySelector('.video-btn-speed'); + const speedMenu = overlay.querySelector('.video-speed-menu'); + const btnFullscreen = overlay.querySelector('.video-btn-fullscreen'); + const timeDisplay = overlay.querySelector('.video-time'); + + if (volumeSlider) volumeSlider.value = Math.round(startVolume * 100); + if (btnSpeed) btnSpeed.textContent = startRate + 'x'; + + let isDraggingProgress = false; + let wasPlayingBeforeDrag = false; + let hideControlsTimer = null; + let destroyed = false; + + function destroy(returnTime) { + if (destroyed) return; + destroyed = true; + if (hideControlsTimer) clearTimeout(hideControlsTimer); + document.removeEventListener('keydown', handleKeydown); + document.removeEventListener('click', handleDocClickForSpeed); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + video.pause(); + + // 退出 Fullscreen API + if (document.fullscreenElement) { + try { document.exitFullscreen(); } catch (e) {} + } + + overlay.style.transition = 'opacity 0.25s ease'; + overlay.style.opacity = '0'; + setTimeout(() => { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (typeof options.onClose === 'function') { + options.onClose(typeof returnTime === 'number' ? returnTime : video.currentTime); + } + }, 250); + } + + function updatePlayButton() { + _updatePlayButton(btnPlay, video); + } + + function updateProgressUI() { + _updateProgressUI(progressPlayed, progressHandle, timeDisplay, video); + } + + function updateBuffered() { + _updateBuffered(progressBuffered, video); + } + + function updateVolumeIcon() { + _updateVolumeIcon(btnVolume, video); + } + + function showControls() { + if (!controlsBar) return; + controlsBar.classList.remove('hidden'); + if (hideControlsTimer) clearTimeout(hideControlsTimer); + if (!video.paused && !video.ended) { + hideControlsTimer = setTimeout(() => { + if (!video.paused && !video.ended) controlsBar.classList.add('hidden'); + }, 3000); + } + } + + function resetHideTimer() { + showControls(); + } + + function seekToRatio(ratio) { + if (!video.duration || !isFinite(video.duration)) return; + ratio = Math.max(0, Math.min(1, ratio)); + video.currentTime = ratio * video.duration; + } + + function getRatioFromEvent(e, element) { + const rect = element.getBoundingClientRect(); + const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX; + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + } + + function togglePlay() { + if (video.paused || video.ended) { + video.play().catch(() => {}); + } else { + video.pause(); + } + } + + // 播放/暂停按钮 + btnPlay.addEventListener('click', (e) => { + e.stopPropagation(); + togglePlay(); + }); + + // 视频点击切换 + video.addEventListener('click', (e) => { + e.stopPropagation(); + togglePlay(); + }); + + // 进度条 + video.addEventListener('timeupdate', () => { + if (!isDraggingProgress) updateProgressUI(); + }); + video.addEventListener('progress', updateBuffered); + video.addEventListener('loadedmetadata', () => { + updateProgressUI(); + updateBuffered(); + }); + + progressWrap.addEventListener('click', (e) => { + if (isDraggingProgress) return; + e.stopPropagation(); + seekToRatio(getRatioFromEvent(e, progressWrap)); + }); + + progressWrap.addEventListener('mousemove', (e) => { + if (!video.duration || !isFinite(video.duration)) return; + const ratio = getRatioFromEvent(e, progressWrap); + if (progressTooltip) { + progressTooltip.textContent = formatTime(ratio * video.duration); + progressTooltip.style.left = (ratio * 100) + '%'; + progressTooltip.classList.add('show'); + } + }); + progressWrap.addEventListener('mouseleave', () => { + if (progressTooltip) progressTooltip.classList.remove('show'); + }); + + progressHandle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + e.preventDefault(); + isDraggingProgress = true; + progressHandle.classList.add('dragging'); + wasPlayingBeforeDrag = !video.paused && !video.ended; + if (wasPlayingBeforeDrag) video.pause(); + }); + progressWrap.addEventListener('mousedown', (e) => { + if (e.target === progressHandle) return; + isDraggingProgress = true; + progressHandle.classList.add('dragging'); + wasPlayingBeforeDrag = !video.paused && !video.ended; + if (wasPlayingBeforeDrag) video.pause(); + const ratio = getRatioFromEvent(e, progressWrap); + seekToRatio(ratio); + progressPlayed.style.width = (ratio * 100) + '%'; + progressHandle.style.left = (ratio * 100) + '%'; + }); + + const handleMouseMove = (e) => { + if (!isDraggingProgress) return; + const ratio = getRatioFromEvent(e, progressWrap); + seekToRatio(ratio); + progressPlayed.style.width = (ratio * 100) + '%'; + progressHandle.style.left = (ratio * 100) + '%'; + if (progressTooltip) { + progressTooltip.textContent = formatTime(video.currentTime); + progressTooltip.style.left = (ratio * 100) + '%'; + progressTooltip.classList.add('show'); + } + }; + const handleMouseUp = () => { + if (!isDraggingProgress) return; + isDraggingProgress = false; + progressHandle.classList.remove('dragging'); + if (progressTooltip) progressTooltip.classList.remove('show'); + if (wasPlayingBeforeDrag) video.play().catch(() => {}); + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + // 音量 + volumeSlider.addEventListener('input', () => { + const val = parseInt(volumeSlider.value, 10) / 100; + video.volume = val; + if (val > 0 && video.muted) video.muted = false; + updateVolumeIcon(); + }); + btnVolume.addEventListener('click', (e) => { + e.stopPropagation(); + video.muted = !video.muted; + updateVolumeIcon(); + }); + + // 播放速度 + function closeSpeedMenu() { + if (speedMenu) speedMenu.classList.remove('open'); + } + btnSpeed.addEventListener('click', (e) => { + e.stopPropagation(); + speedMenu.classList.toggle('open'); + }); + speedMenu.querySelectorAll('div[data-speed]').forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation(); + const speed = parseFloat(item.dataset.speed); + video.playbackRate = speed; + btnSpeed.textContent = speed + 'x'; + speedMenu.querySelectorAll('div').forEach(d => d.classList.remove('active')); + item.classList.add('active'); + closeSpeedMenu(); + }); + }); + const handleDocClickForSpeed = (e) => { + if (speedMenu && !speedMenu.contains(e.target) && e.target !== btnSpeed) { + closeSpeedMenu(); + } + }; + document.addEventListener('click', handleDocClickForSpeed); + + // 全屏按钮(在全屏模式下退出全屏) + btnFullscreen.addEventListener('click', (e) => { + e.stopPropagation(); + destroy(video.currentTime); + }); + + // 关闭按钮 + closeBtn.addEventListener('click', () => destroy(video.currentTime)); + + // 键盘快捷键 + const handleKeydown = (e) => { + switch (e.key) { + case 'Escape': + e.preventDefault(); + destroy(video.currentTime); + break; + case ' ': + case 'k': + e.preventDefault(); + togglePlay(); + break; + case 'ArrowLeft': + e.preventDefault(); + video.currentTime = Math.max(0, video.currentTime - 5); + break; + case 'ArrowRight': + e.preventDefault(); + video.currentTime = Math.min(video.duration || 0, video.currentTime + 5); + break; + case 'ArrowUp': + e.preventDefault(); + video.volume = Math.min(1, video.volume + 0.1); + if (video.volume > 0) video.muted = false; + volumeSlider.value = Math.round(video.volume * 100); + updateVolumeIcon(); + break; + case 'ArrowDown': + e.preventDefault(); + video.volume = Math.max(0, video.volume - 0.1); + volumeSlider.value = Math.round(video.volume * 100); + updateVolumeIcon(); + break; + case 'f': + case 'F': + e.preventDefault(); + destroy(video.currentTime); + break; + case 'm': + case 'M': + e.preventDefault(); + video.muted = !video.muted; + updateVolumeIcon(); + break; + } + }; + document.addEventListener('keydown', handleKeydown); + + // 控制栏自动隐藏 + overlay.addEventListener('mousemove', resetHideTimer); + overlay.addEventListener('mouseenter', resetHideTimer); + video.addEventListener('play', showControls); + video.addEventListener('pause', showControls); + video.addEventListener('ended', () => { + if (controlsBar) controlsBar.classList.remove('hidden'); + }); + + // 播放/暂停状态更新 + video.addEventListener('play', updatePlayButton); + video.addEventListener('pause', updatePlayButton); + video.addEventListener('ended', updatePlayButton); + + // 尝试调用 Fullscreen API + const requestFS = () => { + if (overlay.requestFullscreen) { + overlay.requestFullscreen().catch(() => {}); + } else if (overlay.webkitRequestFullscreen) { + overlay.webkitRequestFullscreen(); + } + }; + requestFS(); + + // 自动播放 + video.play().catch(() => {}); + + updateVolumeIcon(); + updatePlayButton(); + showControls(); +} + +// ========================================== +// 📦 默认导出 +// ========================================== +export default { + getVideoPlayerHTML, + setupVideoPlayerEvents, + cleanupVideoPlayer, + openFullscreenVideo +}; diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\351\241\266\351\203\250\345\257\274\350\210\252\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\351\241\266\351\203\250\345\257\274\350\210\252\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..a114b7f6ead046b86bfb1724efd2df557381f903 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\351\241\266\351\203\250\345\257\274\350\210\252\347\273\204\344\273\266.js" @@ -0,0 +1,299 @@ +// 前端页面/components/顶部导航组件.js +import { createAuthView } from "../auth/用户注册登录组件.js"; +import { openUserProfileModal } from "../profile/个人中心视图.js"; +import { openChatModal } from "../social/私信聊天组件.js"; +import { api } from "../core/网络请求API.js"; +import { showToast } from "./UI交互提示组件.js"; +import { showAboutInfo } from "./关于插件组件.js"; +import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js"; +import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增 +import { CACHE } from "../core/全局配置.js"; +import { t } from "./用户体验增强.js"; // 🌐 多语言支持 +import { createTopBanner } from "./顶部广告组件.js"; +import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器 + +// 🚀 新增:消息轮询定时器 +let messagePollingTimer = null; +let isPollingActive = false; // 🔧 P3优化:防止重复启动轮询 + +// 🔧 P1修复:window 监听只安装一次并始终指向最新导航实例 +// (createTopNav 会随侧边栏重建反复调用,若每次 addEventListener 会累积旧闭包, +// 旧闭包持有的 bellBtn/userActionBtn 已脱离 DOM,轮询徽标更新也随之失效) +let _navInstance = null; +let _navWindowHandlers = null; +function _registerNavWindowListeners() { + if (_navWindowHandlers) { + for (const { event, handler } of _navWindowHandlers) window.removeEventListener(event, handler); + } + _navWindowHandlers = [ + { event: "comfy-user-logout", handler: () => { if (_navInstance) _navInstance.onLogout(); } }, + // 🔧 修复:凭证失效(401)时除停止轮询外,同步重置导航登录态(原实现未重置,头像按钮仍显示已登录) + { event: "comfy-ranking-auth-expired", handler: () => { stopMessagePolling(); if (_navInstance) _navInstance.onLogout(); } }, + { event: "storage", handler: (event) => { if (_navInstance) _navInstance.onStorage(event); } } + ]; + for (const { event, handler } of _navWindowHandlers) window.addEventListener(event, handler); +} + +export function createTopNav() { + const userHeader = document.createElement("div"); + Object.assign(userHeader.style, { + display: "flex", justifyContent: "space-between", alignItems: "center", + padding: "15px 10px", backgroundColor: "#1a1a1a", borderBottom: "1px solid var(--border-color, #333)" + }); + + const titleSpan = document.createElement("div"); + titleSpan.innerHTML = `ComfyUI ${t('nav.community') || '社区精选'}`; + + Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" }); + titleSpan.title = "查看关于本插件与作者信息"; + titleSpan.onmouseover = () => titleSpan.style.opacity = "0.8"; + titleSpan.onmouseout = () => titleSpan.style.opacity = "1"; + titleSpan.onclick = () => showAboutInfo(currentUser); + + // ⚙️ 设置按钮(放在社区精选旁边) + const settingsBtn = document.createElement("button"); + Object.assign(settingsBtn.style, { + background: "transparent", + border: "none", + color: "#888", + cursor: "pointer", + fontSize: "16px", + marginLeft: "8px", + padding: "4px", + transition: "0.2s", + verticalAlign: "middle" + }); + settingsBtn.innerHTML = "⚙️"; + settingsBtn.title = t('nav.settings'); + settingsBtn.onmouseover = () => { settingsBtn.style.color = "#4CAF50"; settingsBtn.style.transform = "rotate(30deg)"; }; + settingsBtn.onmouseout = () => { settingsBtn.style.color = "#888"; settingsBtn.style.transform = "rotate(0deg)"; }; + settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsPage(); }; + + // 📦 将标题和设置按钮包裹在一起 + const titleWrapper = document.createElement("div"); + titleWrapper.style.display = "flex"; + titleWrapper.style.alignItems = "center"; + titleWrapper.appendChild(titleSpan); + titleWrapper.appendChild(settingsBtn); + + const userActionBtn = document.createElement("button"); + Object.assign(userActionBtn.style, { padding: "6px 12px", backgroundColor: "#333", color: "#fff", border: "1px solid #555", borderRadius: "4px", cursor: "pointer", fontSize: "12px", transition: "background 0.2s" }); + + const chatEntryBtn = document.createElement("button"); + Object.assign(chatEntryBtn.style, { background: "transparent", border: "none", color: "#aaa", cursor: "pointer", fontSize: "18px", marginRight: "10px", transition: "0.2s" }); + chatEntryBtn.innerHTML = `✉️`; + chatEntryBtn.title = t('social.chat'); + + const bellBtn = document.createElement("button"); + Object.assign(bellBtn.style, { background: "transparent", border: "none", color: "#aaa", cursor: "pointer", fontSize: "18px", position: "relative", marginRight: "15px" }); + bellBtn.innerHTML = `🔔`; + + let currentUser = null; + try { + const savedUserStr = localStorage.getItem("ComfyCommunity_User"); + if (savedUserStr) { + const data = JSON.parse(savedUserStr); + if (data.expireAt && Date.now() < data.expireAt) { currentUser = data.user; } + else { localStorage.removeItem("ComfyCommunity_User"); localStorage.removeItem("ComfyCommunity_Token"); } + } else { + const tempUserStr = sessionStorage.getItem("ComfyCommunity_User"); + if (tempUserStr) { currentUser = JSON.parse(tempUserStr).user; } + } + } catch (e) {} + + // 🔧 P1修复:登出/token失效/storage 监听已提升至模块级统一注册(见 _registerNavWindowListeners), + // 避免每次 createTopNav 重复 addEventListener 导致闭包累积与轮询失效 + + chatEntryBtn.onclick = () => { + if (!currentUser) return showToast(t('auth.login_required') || "请先登录您的社区账号!", "warning"); + openChatModal(currentUser); + }; + + // 【核心瘦身】:直接调用拆分出去的独立组件模块 + bellBtn.onclick = () => openNotificationCenter(currentUser, bellBtn); + + const updateUserButtonState = () => { + if (currentUser) { + userActionBtn.innerHTML = `👤 ${currentUser.name || currentUser.account || t('common.unknown_user') || '未知用户'}`; + userActionBtn.style.backgroundColor = "#2196F3"; + userActionBtn.style.borderColor = "#2196F3"; + userActionBtn.onclick = () => openUserProfileModal(currentUser); + loadUnreadCount(currentUser, bellBtn); + + // 🚀 新增:启动消息定时轮询 + startMessagePolling(currentUser, bellBtn); + } else { + // 🚀 新增:停止轮询 + stopMessagePolling(); + + userActionBtn.innerHTML = `🔑 ${t('nav.login')}`; + userActionBtn.style.backgroundColor = "#333"; + userActionBtn.style.borderColor = "#555"; + bellBtn.querySelector("#unread-badge").style.display = "none"; + + userActionBtn.onclick = () => { + const view = createAuthView(async (formData) => { + try { + userActionBtn.innerHTML = `⏳ ${t('common.loading')}`; + let userData; let token; let isRemember = false; + + if (formData.type === "reset") { + userActionBtn.innerHTML = `⏳ ${t('auth.resetting_password') || '修改密码中...'}`; + + // 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲! + await api.resetPassword(formData); + + showToast(t('auth.password_reset_success') || "密码修改成功!请使用新密码重新登录。", "success"); + window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return; + } + if (formData.type === "register") { + // 🚀 核心修复:/api/upload 需要登录凭证,注册时尚无 token 必然 401。 + // 调整时序为:先注册(不带头像)→ 自动登录拿到 token → 再上传头像并写入资料。 + const pendingAvatarFile = formData.avatarFile || null; + delete formData.avatarFile; + delete formData.avatarDataUrl; // base64 预览值会被后端拒绝,不随注册提交 + + userActionBtn.innerHTML = `⏳ ${t('auth.registering') || '注册账号中...'}`; + await api.register(formData); + showToast(t('auth.register_success') + (t('auth.auto_login') || '!正在为您自动登录...'), "success"); + // 注册后自动登录,默认保持登录 + const res = await api.login(formData.account, formData.password, true); + userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res }; + token = res.token; isRemember = true; + + // 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization) + if (pendingAvatarFile && token) { + userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar') || '上传头像中...'}`; + // 提前写入 token,确保上传与资料更新请求能通过认证 + localStorage.setItem("ComfyCommunity_Token", token); + try { + // 统一走带 processAvatar 压缩的包装器,规避后端 2MB 头像限制 + const cloudAvatarUrl = await uploadFile(pendingAvatarFile, "avatar"); + if (cloudAvatarUrl) { + const updateRes = await api.updateUserProfile(formData.account, { avatarDataUrl: cloudAvatarUrl }); + userData = { ...userData, ...(updateRes.data || {}) }; + if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl; + } else { + showToast(t('auth.avatar_set_failed') || "⚠️ 头像设置失败,您可稍后在个人设置中重新上传", "warning"); + } + } catch (avatarErr) { + console.warn("注册后设置头像失败:", avatarErr.message || avatarErr); + showToast(t('auth.avatar_set_failed') || "⚠️ 头像设置失败,您可稍后在个人设置中重新上传", "warning"); + } + } + } else if (formData.type === "login") { + const res = await api.login(formData.account, formData.password, formData.remember); + token = res.token; isRemember = formData.remember; + try { + if (isRemember) localStorage.setItem("ComfyCommunity_Token", token); + else sessionStorage.setItem("ComfyCommunity_Token", token); + const profileRes = await api.getUserProfile(formData.account); + userData = { account: formData.account, ...profileRes.data }; + } catch (e) { + userData = { account: formData.account, name: res.name || res.data?.name || formData.account, avatar: res.avatar || res.data?.avatar }; + } + showToast(t('auth.login_success'), "success"); + } + + currentUser = userData; + const sessionData = JSON.stringify({ user: currentUser, expireAt: Date.now() + 30 * 24 * 60 * 60 * 1000 }); + if (isRemember) { localStorage.setItem("ComfyCommunity_User", sessionData); localStorage.setItem("ComfyCommunity_Token", token); } + else { sessionStorage.setItem("ComfyCommunity_User", sessionData); sessionStorage.setItem("ComfyCommunity_Token", token); } + + window.dispatchEvent(new CustomEvent("comfy-route-back")); + updateUserButtonState(); + window.dispatchEvent(new CustomEvent("comfy-user-login")); + } catch (err) { + showToast(`${t('common.error')}: ` + err.message, "error"); updateUserButtonState(); + } + }); + window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } })); + }; + } + }; + + // 🔄 跨标签页登录状态同步:其他标签页修改 localStorage 时触发 + // 🔧 P1修复:改为模块级统一注册的 storage 监听回调(onStorage),不再逐实例 addEventListener + const handleCrossTabStorage = (event) => { + if (event.key === 'ComfyCommunity_Token' || event.key === 'ComfyCommunity_User') { + if (!event.newValue) { + // 其他标签页登出 → 本标签页也同步登出 + if (currentUser) { + currentUser = null; + updateUserButtonState(); + } + } else if (event.key === 'ComfyCommunity_User') { + // 其他标签页登入了不同账号 → 刷新本标签页用户状态 + try { + const data = JSON.parse(event.newValue); + if (data.user && data.user.account !== (currentUser && currentUser.account)) { + currentUser = data.user; + updateUserButtonState(); + } + } catch (e) {} + } + } + }; + + updateUserButtonState(); + + // 🔧 P1修复:注册为当前活跃实例,模块级监听将路由到本实例的回调 + _navInstance = { + onLogout: () => { currentUser = null; updateUserButtonState(); }, + onStorage: handleCrossTabStorage + }; + _registerNavWindowListeners(); + + const actionWrapper = document.createElement("div"); + actionWrapper.style.display = "flex"; actionWrapper.style.alignItems = "center"; + actionWrapper.appendChild(chatEntryBtn); + actionWrapper.appendChild(bellBtn); + actionWrapper.appendChild(userActionBtn); + + userHeader.appendChild(titleWrapper); // ⚙️ 改为 titleWrapper + // 顶部广告横幅 + const banner = createTopBanner(); + if (banner) userHeader.appendChild(banner); + userHeader.appendChild(actionWrapper); + + return { + dom: userHeader, + getCurrentUser: () => currentUser + }; +} + +// 🚀 新增:启动消息定时轮询 +function startMessagePolling(currentUser, bellBtn) { + if (!CACHE.MESSAGE_POLL.ENABLED) return; + if (isPollingActive) return; // 🔧 P3优化:防止重复启动 + + // 清除旧的定时器 + stopMessagePolling(); + isPollingActive = true; + + // 创建新的定时器,每隔一定时间检查新消息 + messagePollingTimer = setInterval(async () => { + // 🔧 P3优化:双重检查,确保用户仍在线 + if (!currentUser || !isPollingActive) { + stopMessagePolling(); + return; + } + try { + await loadUnreadCount(currentUser, bellBtn); + } catch (e) { + // 静默失败,不影响用户体验 + } + }, CACHE.MESSAGE_POLL.INTERVAL); + + console.log(`📨 消息轮询已启动,间隔 ${CACHE.MESSAGE_POLL.INTERVAL / 1000} 秒`); +} + +// 🚀 新增:停止消息定时轮询 +function stopMessagePolling() { + isPollingActive = false; // 🔧 P3优化:先标记为停止 + if (messagePollingTimer) { + clearInterval(messagePollingTimer); + messagePollingTimer = null; + console.log('📨 消息轮询已停止'); + } +} \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\351\241\266\351\203\250\345\271\277\345\221\212\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\351\241\266\351\203\250\345\271\277\345\221\212\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..0ae0d33555eb262e7de22c19b34df08b4f754e81 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\351\241\266\351\203\250\345\271\277\345\221\212\347\273\204\344\273\266.js" @@ -0,0 +1,322 @@ +// 前端页面/components/顶部广告组件.js + +import { api } from "../core/网络请求API.js"; + +// ========== 广告配置数据(默认值 / fallback) ========== +const BANNER_CONFIG = { + // 横幅图片URL(支持GIF) + bannerImage: "", + // 广告标题 + title: "创世者计划", + // 广告描述 + description: "100位优质创作者,瓜分平台未来两年收益", + // 详情页内容(HTML字符串或纯文本) + detailContent: "详细广告内容...", + // 详情页大图 + detailImage: "", + // 是否启用广告 + enabled: true +}; + +// 运行时配置(异步加载后更新) +let activeConfig = { ...BANNER_CONFIG }; + +// 模块级 banner 容器引用,供 refreshBanner() 使用 +let bannerContainer = null; + +// 本地缓存配置 +const BANNER_CACHE_KEY = "ComfyRanking_BannerConfig"; +const BANNER_CACHE_TTL = 60 * 60 * 1000; // 1小时缓存 + +/** + * 读取本地广告配置缓存(非破坏性,不删除过期数据) + * @returns {{ data: Object|null, fresh: boolean }} data 为缓存内容(无缓存为 null),fresh 表示是否仍在 TTL 内 + */ +function readBannerCache() { + try { + const cached = localStorage.getItem(BANNER_CACHE_KEY); + if (cached) { + const { data, timestamp } = JSON.parse(cached); + return { data: data || null, fresh: Date.now() - timestamp < BANNER_CACHE_TTL }; + } + } catch (parseErr) { + // 缓存数据损坏,清除 + localStorage.removeItem(BANNER_CACHE_KEY); + } + return { data: null, fresh: false }; +} + +/** + * 后台静默同步广告配置:不阻塞首屏渲染 + * 成功后更新缓存并刷新 DOM;失败时保留当前已展示的内容 + */ +async function syncBannerInBackground() { + try { + const res = await api.getPublicBannerConfig(); + if (res && res.status === "success") { + if (res.data) { + // 只缓存有效配置,不缓存 null(禁用后再启用可立即生效) + localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({ + data: res.data, + timestamp: Date.now() + })); + activeConfig = { ...BANNER_CONFIG, ...res.data }; + } else { + // 广告已禁用,清除旧缓存避免显示过期内容 + localStorage.removeItem(BANNER_CACHE_KEY); + activeConfig = { ...BANNER_CONFIG }; + } + updateBannerDOM(); + } + } catch (e) { + // 网络失败:保留当前展示内容(首屏已由缓存或默认值渲染) + console.warn("后台更新广告配置失败(保留当前内容):", e); + } +} + +/** + * 创建顶部广告横幅组件 + * @returns {HTMLElement} 广告容器DOM(同步返回,内容异步填充) + */ +export function createTopBanner() { + // 创建广告容器(初始隐藏,异步加载后决定是否显示) + const container = document.createElement("div"); + Object.assign(container.style, { + flex: "1", + display: "none", + justifyContent: "center", + alignItems: "center", + margin: "0 15px", + maxHeight: "42px", + overflow: "hidden", + borderRadius: "6px", + cursor: "pointer", + transition: "opacity 0.2s" + }); + + // 保存模块级引用 + bannerContainer = container; + + // ⚡ 完全不阻塞首屏:先读本地缓存(过期也立即使用)并立即渲染, + // 网络请求一律退到后台静默同步,保证断网/云端不通时横幅不受影响 + // (与插件榜/提示词等榜单的缓存优先策略一致) + const { data: cachedData, fresh } = readBannerCache(); + if (cachedData) { + activeConfig = { ...BANNER_CONFIG, ...cachedData }; + updateBannerDOM(); + } + // 缓存新鲜则无需请求;过期或无缓存时后台静默同步 + if (!fresh) { + syncBannerInBackground(); + } + + return container; +} + +/** + * 根据 activeConfig 更新 banner 容器 DOM 内容 + */ +function updateBannerDOM() { + if (!bannerContainer) return; + + // 清空现有内容 + bannerContainer.innerHTML = ""; + + // 未启用或无横幅图片时不显示 + if (!activeConfig.enabled || !activeConfig.bannerImage) { + bannerContainer.style.display = "none"; + return; + } + + // 创建横幅图片 + const img = document.createElement("img"); + img.src = activeConfig.bannerImage; + img.alt = activeConfig.title || "广告"; + Object.assign(img.style, { + maxWidth: "100%", + maxHeight: "100%", + objectFit: "contain" + }); + + bannerContainer.appendChild(img); + bannerContainer.style.display = "flex"; + + // 鼠标悬停效果 + bannerContainer.onmouseover = () => { bannerContainer.style.opacity = "0.85"; }; + bannerContainer.onmouseout = () => { bannerContainer.style.opacity = "1"; }; + + // 点击打开广告详情页 + bannerContainer.onclick = () => { + const view = createBannerDetailView(); + window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } })); + }; +} + +/** + * 刷新顶部广告横幅(重新加载配置并更新DOM) + */ +export async function refreshBanner() { + // 清除自定义缓存 + localStorage.removeItem(BANNER_CACHE_KEY); + try { + // 绕过请求级缓存,强制从服务器获取最新配置 + const res = await api.getPublicBannerConfig({ noCache: true }); + if (res && res.status === "success" && res.data) { + activeConfig = { ...BANNER_CONFIG, ...res.data }; + // 更新本地缓存 + localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({ + data: res.data, + timestamp: Date.now() + })); + } else { + activeConfig = { ...BANNER_CONFIG }; + } + } catch (e) { + console.warn("刷新广告配置失败", e); + } + updateBannerDOM(); +} + +/** + * 创建广告详情页视图 + * @returns {HTMLElement} 详情页DOM + */ +function createBannerDetailView() { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + backgroundColor: "#1a1a1a", + color: "#fff" + }); + + // 顶部返回栏 + const topBar = document.createElement("div"); + Object.assign(topBar.style, { + display: "flex", + alignItems: "center", + padding: "12px 16px", + borderBottom: "1px solid #333", + flexShrink: "0" + }); + + const backBtn = document.createElement("button"); + Object.assign(backBtn.style, { + marginLeft: "15px", + marginTop: "20px", + background: "rgba(51,51,51,0.8)", + border: "1px solid rgba(85,85,85,0.8)", + color: "#fff", + cursor: "pointer", + fontSize: "13px", + padding: "6px 14px", + borderRadius: "6px", + fontWeight: "bold", + display: "flex", + alignItems: "center", + gap: "6px", + boxShadow: "0 2px 4px rgba(0,0,0,0.3)", + transition: "0.2s" + }); + backBtn.textContent = "⬅ 返回"; + backBtn.onmouseover = () => { + backBtn.style.background = "#4CAF50"; + backBtn.style.borderColor = "#4CAF50"; + }; + backBtn.onmouseout = () => { + backBtn.style.background = "rgba(51,51,51,0.8)"; + backBtn.style.borderColor = "rgba(85,85,85,0.8)"; + }; + backBtn.onclick = () => { + window.dispatchEvent(new CustomEvent("comfy-route-back")); + }; + + const pageTitle = document.createElement("span"); + pageTitle.textContent = activeConfig.title || "广告详情"; + Object.assign(pageTitle.style, { + marginLeft: "12px", + fontSize: "16px", + fontWeight: "bold" + }); + + topBar.appendChild(backBtn); + topBar.appendChild(pageTitle); + + // 内容区(可滚动) + const content = document.createElement("div"); + Object.assign(content.style, { + flex: "1", + overflowY: "auto", + padding: "24px 20px", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "20px" + }); + + // 广告标题 + if (activeConfig.title) { + const titleEl = document.createElement("h2"); + titleEl.textContent = activeConfig.title; + Object.assign(titleEl.style, { + margin: "0", + fontSize: "22px", + fontWeight: "bold", + textAlign: "center" + }); + content.appendChild(titleEl); + } + + // 广告描述 + if (activeConfig.description) { + const descEl = document.createElement("p"); + descEl.textContent = activeConfig.description; + Object.assign(descEl.style, { + margin: "0", + fontSize: "15px", + color: "#aaa", + textAlign: "center", + lineHeight: "1.6" + }); + content.appendChild(descEl); + } + + // 广告大图 + if (activeConfig.detailImage) { + const detailImg = document.createElement("img"); + detailImg.src = activeConfig.detailImage; + detailImg.alt = activeConfig.title || "广告详情图"; + Object.assign(detailImg.style, { + maxWidth: "100%", + borderRadius: "8px" + }); + content.appendChild(detailImg); + } + + // 详细内容 + if (activeConfig.detailContent) { + const detailEl = document.createElement("div"); + Object.assign(detailEl.style, { + width: "100%", + maxWidth: "600px", + padding: "20px", + backgroundColor: "#252525", + borderRadius: "8px", + lineHeight: "1.8", + fontSize: "14px", + color: "#ddd", + whiteSpace: "pre-wrap", + wordWrap: "break-word" + }); + // 支持HTML内容 + detailEl.innerHTML = activeConfig.detailContent; + content.appendChild(detailEl); + } + + wrapper.appendChild(topBar); + wrapper.appendChild(content); + + return wrapper; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/core/\344\276\247\350\276\271\346\240\217\344\270\273\347\250\213\345\272\217.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/core/\344\276\247\350\276\271\346\240\217\344\270\273\347\250\213\345\272\217.js" new file mode 100644 index 0000000000000000000000000000000000000000..140d56f6a25a48cc37ec1a38bcd3766aea231682 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/core/\344\276\247\350\276\271\346\240\217\344\270\273\347\250\213\345\272\217.js" @@ -0,0 +1,708 @@ +// 前端页面/core/侧边栏主程序.js +// ========================================== +// 🎯 侧边栏功能主体模块 +// ========================================== +// 职责:构建侧边栏 DOM、管理 Tab/排序/搜索交互 +// 由 侧边栏入口注册.js 动态加载 +// ========================================== + +import { createPublishView } from "../market/发布内容组件.js"; +import { createPublishTaskView } from "../task/发布任务组件.js"; // 🎯 新增:任务发布 +import { createPublishPostView } from "../post/发布帖子组件.js"; // 🎯 新增:帖子发布 +import { createPublishPromptView } from "../prompt/发布提示词组件.js"; // 🎯 新增:提示词发布 +import { createTopNav } from "../components/顶部导航组件.js"; +import { loadSidebarContent } from "./侧边栏数据引擎.js"; +import { createItemDetailView } from "../market/资源详情页面组件.js"; +import { showToast } from "../components/UI交互提示组件.js"; +import { api } from "./网络请求API.js"; // 🔴 编辑模式需调用详情API获取完整数据 +import { CACHE, getBackgroundKey, IS_WEB_MODE } from "./全局配置.js"; +import { debounce } from "../components/性能优化工具.js"; +import { cleanupImageSandbox } from "../components/图片沙盒组件.js"; // 🔧 P3优化:导入清理函数 +import { getVersionConfig, formatVersionString } from "../components/关于插件组件.js"; // 🏷️ 动态版本号 +// 🎯 P2 用户体验增强 +import { initUXEnhancements, t } from "../components/用户体验增强.js"; + +// 初始化 UX 增强 +try { + initUXEnhancements(); +} catch (e) { + console.warn('🎯 UX 增强初始化失败:', e); +} + +// 🚫 注入 Tab 文案样式(模块加载时执行一次) +// Tab 文案用伪元素 content 渲染而非文本节点,使其不存在于 DOM 树中, +// 从而既避开 ComfyUI 翻译插件(只改写文本节点 / innerText / title / option.text), +// 也避开浏览器翻译扩展;否则英文界面下 Tools 会被译成“工具” +(function _injectTabLabelStyle() { + if (document.getElementById('hub-tab-label-style')) return; + const style = document.createElement('style'); + style.id = 'hub-tab-label-style'; + style.textContent = '.hub-tab-btn::before { content: var(--tab-label); }'; + document.head.appendChild(style); +})(); + +// 🚫 筛选框选项防翻译:原生