(function () { 'use strict'; const page = location.pathname.split('/').pop() || 'feed.html'; const sidebar = document.querySelector('.sidebar'); let app = sidebar && sidebar.closest('.app'); if (sidebar && !app) { const content = sidebar.nextElementSibling; app = document.createElement('div'); app.className = 'app'; sidebar.parentNode.insertBefore(app, sidebar); app.appendChild(sidebar); if (content && content.tagName !== 'SCRIPT') app.appendChild(content); } if (!sidebar || !app) { const showStandaloneToast = (message, type = 'info') => { document.querySelector('.web-toast')?.remove(); const toast = document.createElement('div'); toast.className = `web-toast${type === 'error' ? ' is-error' : ''}`; toast.setAttribute('role', 'status'); toast.textContent = message; document.body.appendChild(toast); window.setTimeout(() => toast.remove(), 2400); }; if (page === 'register.html') { const selectedSchool = localStorage.getItem('youthome:web:selectedSchool'); const schoolLabel = document.querySelector('.school-select .text'); if (selectedSchool && schoolLabel) { schoolLabel.textContent = selectedSchool; schoolLabel.classList.remove('placeholder'); } window.selectSchool = () => { location.href = 'select-school.html'; }; window.doRegister = () => { const phone = document.querySelector('input[type="tel"]'); const code = document.querySelector('input[placeholder*="验证码"]'); const password = document.querySelector('input[type="password"]'); const agreement = document.querySelector('input[type="checkbox"]'); if (!selectedSchool) { showStandaloneToast('请先选择学校', 'error'); return; } if (!/^1\d{10}$/.test(phone?.value.trim() || '')) { showStandaloneToast('请输入正确的 11 位手机号', 'error'); phone?.focus(); return; } if (!/^\d{6}$/.test(code?.value.trim() || '')) { showStandaloneToast('请输入 6 位验证码', 'error'); code?.focus(); return; } if (!/^(?=.*[A-Za-z])(?=.*\d).{8,20}$/.test(password?.value || '')) { showStandaloneToast('密码需为 8-20 位且包含字母和数字', 'error'); password?.focus(); return; } if (!agreement?.checked) { showStandaloneToast('请阅读并同意用户协议和隐私政策', 'error'); return; } location.href = 'register-success.html'; }; } if (page === 'login.html') { window.goLogin = () => { const active = document.querySelector('.auth-panel.active, .login-form.active') || document; const phone = active.querySelector('input[type="tel"]'); const credential = active.querySelector('input[type="password"], input[placeholder*="验证码"]'); if (!/^1\d{10}$/.test(phone?.value.trim() || '')) { showStandaloneToast('请输入正确的 11 位手机号', 'error'); phone?.focus(); return; } if (!credential?.value.trim()) { showStandaloneToast('请输入密码或验证码', 'error'); credential?.focus(); return; } location.href = 'feed.html'; }; } if (page === 'forgot-password.html') { const originalGoStep = window.goStep; window.goStep = (step) => { const phone = document.querySelector('input[type="tel"]'); const code = document.querySelector('input[placeholder*="验证码"]'); const passwords = Array.from(document.querySelectorAll('input[type="password"]')); if (step === 2 && (!/^1\d{10}$/.test(phone?.value.trim() || '') || !/^\d{6}$/.test(code?.value.trim() || ''))) { showStandaloneToast('请输入正确的手机号和 6 位验证码', 'error'); return; } if (step === 3 && (passwords[0]?.value !== passwords[1]?.value || !/^(?=.*[A-Za-z])(?=.*\d).{8,20}$/.test(passwords[0]?.value || ''))) { showStandaloneToast('两次密码需一致,且包含字母和数字', 'error'); return; } originalGoStep?.(step); }; } return; } const liveApi = window.YouthomeApi; if (page !== 'help.html' && liveApi && !liveApi.isLocal() && !liveApi.token()) { const next = page + location.search; location.replace(`login.html?next=${encodeURIComponent(next)}`); return; } const icon = { plus: '', chevronDown: '', feed: '', video: '', calendar: '', user: '', bell: '', settings: '', arrow: '', menu: '', search: '' }; const groups = { feed: ['feed.html', 'post-detail.html', 'post-detail-1.html', 'topic-detail.html', 'search.html', 'user.html'], video: ['video.html', 'video-detail.html', 'publish-video.html'], activities: ['activities.html', 'activity-detail.html', 'activity-create.html', 'my-activity-apply.html'], me: ['me.html', 'me-edit.html', 'favorites.html', 'wallet.html', 'drafts.html', 'authentication.html', 'follow.html', 'help.html'], notification: ['notification.html', 'chat.html'], settings: ['settings.html'] }; const notificationHref = 'notification.html?v=20261013-message-links-3'; const fromNotification = ['post-detail.html', 'user.html'].includes(page) && new URLSearchParams(location.search).has('notice'); const activeKey = fromNotification ? 'notification' : Object.keys(groups).find((key) => groups[key].includes(page)) || 'feed'; const navLink = (key, label, target, iconMarkup, badge) => ` `; sidebar.innerHTML = `
青 青核 Youthome 校园青年社区
`; const main = sidebar.nextElementSibling; if (main) main.classList.add('web-shell-main'); const title = (document.title || '青核 Youthome').split(' - ')[0].trim(); const topbar = document.createElement('header'); topbar.className = 'web-topbar'; topbar.innerHTML = `
${title}
`; document.body.appendChild(topbar); const backdrop = document.createElement('button'); backdrop.className = 'web-drawer-backdrop'; backdrop.type = 'button'; backdrop.setAttribute('aria-label', '关闭导航'); document.body.appendChild(backdrop); document.body.classList.add('web-shell-ready'); const showWebToast = (message, type = 'info') => { document.querySelector('.web-toast')?.remove(); const toast = document.createElement('div'); toast.className = `web-toast${type === 'error' ? ' is-error' : ''}`; toast.setAttribute('role', 'status'); toast.textContent = message; document.body.appendChild(toast); window.setTimeout(() => toast.remove(), 2400); }; const unreadNotificationIds = ['like-canteen', 'comment-canteen', 'mention-milk-tea']; const notificationReadKey = (id) => `youthome:web:notification-read:${id}`; const updateNotificationIndicators = () => { const unread = unreadNotificationIds.filter((id) => localStorage.getItem(notificationReadKey(id)) !== 'true').length; document.querySelectorAll('.web-nav-badge').forEach((badge) => { badge.textContent = String(unread); badge.hidden = unread === 0; }); const dot = document.querySelector('.web-notification-dot'); if (dot) dot.hidden = unread === 0; }; const refreshRemoteUnread = () => { const api = window.YouthomeApi; if (!api || api.isLocal() || !api.token()) return; api.get('/campus/notifications?isRead=false&page=1&size=1') .then((data) => { const unread = Number(data?.total || 0); document.querySelectorAll('.web-nav-badge').forEach((badge) => { badge.textContent = String(unread); badge.hidden = unread === 0; }); const dot = document.querySelector('.web-notification-dot'); if (dot) dot.hidden = unread === 0; }) .catch(() => {}); }; window.webRefreshNotificationIndicators = () => { updateNotificationIndicators(); refreshRemoteUnread(); }; updateNotificationIndicators(); refreshRemoteUnread(); // 登录后,侧栏和顶栏应当显示当前账号,而不是页面里写死的示例人物 const applyAccountIdentity = () => { // 真实后端登录时本地数据层里没有这个账号,必须优先用登录态 const account = window.YouthomeApi?.currentUser?.() || window.YouthomeLocal?.account?.() || null; if (!account) return; const setAvatar = (node) => { if (!node) return; node.textContent = ''; const avatarUrl = window.YouthomeApi?.avatarUrl ? window.YouthomeApi.avatarUrl(account.avatar) : account.avatar; if (avatarUrl) { const img = document.createElement('img'); img.src = avatarUrl; img.alt = account.nickname; node.appendChild(img); } else { node.textContent = (account.nickname || '我').slice(0, 1); } }; const button = document.querySelector('.web-user-button'); const nameNode = button?.querySelector('.web-user-name'); const schoolNode = button?.querySelector('.web-user-school'); if (nameNode) nameNode.textContent = account.nickname; if (schoolNode) schoolNode.textContent = account.universityName || '校园用户'; setAvatar(button?.querySelector('.web-user-avatar')); setAvatar(document.querySelector('.web-topbar-avatar')); }; window.webApplyAccountIdentity = applyAccountIdentity; applyAccountIdentity(); if (liveApi && !liveApi.isLocal() && liveApi.token()) { liveApi.profile().then((profile) => { if (profile) { liveApi.updateSessionUser(profile); applyAccountIdentity(); } }).catch(() => {}); } const makeKeyboardClickable = (element, handler) => { if (!element) return; element.tabIndex = 0; if (!element.getAttribute('role')) element.setAttribute('role', 'button'); element.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); handler(event); }); }; const setPressed = (elements, active) => { elements.forEach((element) => { const selected = element === active; element.classList.toggle('active', selected); element.setAttribute('aria-pressed', String(selected)); }); }; const addEmptyState = (container, title, description) => { container?.querySelector('.web-empty-state')?.remove(); if (!container) return; const empty = document.createElement('div'); empty.className = 'web-empty-state'; empty.innerHTML = `
${title}${description}
`; container.appendChild(empty); }; const makeLinkedCard = (card, href) => { if (!card) return; card.tabIndex = 0; card.setAttribute('role', 'link'); card.addEventListener('click', (event) => { // 注意:卡片内的自有点击控件(如“取消收藏”)必须放行,否则会被这里拦掉 if (event.target.closest('button, a, input, textarea, select, .post-action, .video-likes, .post-footer-collect, [data-card-action]')) return; event.preventDefault(); event.stopImmediatePropagation(); location.href = href; }, true); card.addEventListener('keydown', (event) => { if (event.key === 'Enter') location.href = href; }); }; const parseCompactNumber = (value) => { const text = String(value || '').trim().toLowerCase(); const number = Number.parseFloat(text) || 0; if (text.includes('万')) return number * 10000; if (text.includes('k')) return number * 1000; return number; }; const notificationPosts = { canteen: { title: '今天食堂的新菜也太好吃了吧!', time: '2分钟前', views: '386', likes: 38, shares: 6, body: [ '今天在食堂尝到了新上的番茄牛腩饭,牛腩炖得很软,配菜也很足。', '在一食堂二楼靠窗的窗口,午饭时间排队不算长。想换口味的同学可以试试!' ], tags: ['#校园美食', '#食堂新菜'], comments: [ { id: 'li-mengqi', name: '李梦琪', text: '好棒!我也想去~', time: '15分钟前' }, { id: 'zhang-zixuan', name: '张子轩', text: '下次去食堂就点这个!', time: '刚刚' } ] }, 'milk-tea': { title: '学校西门的奶茶店,最近常去的一家', time: '昨天', views: '542', likes: 64, shares: 9, body: [ '放学路过西门,发现巷口新开了一家奶茶店。点了少糖的茉莉奶绿,茶味很清爽。', '店在西门出来右转约两百米的位置,门口有一排绿色座椅。想一起去尝尝的同学可以在评论里约时间。' ], tags: ['#校园生活', '#西门美食'], comments: [ { id: 'wang-haoran', name: '王浩然', text: '@林小夏 你说的那家奶茶店在哪呀?我也想去尝尝', time: '1小时前' }, { id: 'lin-xiaoxia', name: '林小夏', text: '西门出来右转约两百米,绿色座椅那家~', time: '45分钟前' } ] }, 'study-week': { title: '期末复习周开始了,今天在图书馆学习了8小时!高数太难了', time: '2小时前', views: '1,248', likes: 48, shares: 0, commentCount: 12, body: ['期末复习周开始了,今天在图书馆学习了8小时。高数太难了,继续加油!'], tags: ['#期末复习', '#图书馆'], comments: [] }, 'hotpot-weekend': { title: '周末和室友去吃了火锅,太满足了!这家店的毛肚真的绝了', time: '周末', views: '856', likes: 86, shares: 0, commentCount: 18, body: ['周末和室友去吃了火锅,太满足了!这家店的毛肚真的绝了。'], tags: ['#校园美食', '#周末生活'], comments: [] }, 'vscode-extensions': { title: '分享一个超好用的VS Code插件合集,提升开发效率必备!', time: '3天前', views: '1,632', likes: 128, shares: 0, commentCount: 34, body: ['分享一个超好用的 VS Code 插件合集,提升开发效率必备!'], tags: ['#开发工具', '#学习分享'], comments: [] }, 'campus-sunset': { title: '今天的天空好美啊,随手拍了一张,校园的晚霞永远最治愈', time: '4天前', views: '743', likes: 64, shares: 0, commentCount: 8, body: ['今天的天空好美啊,随手拍了一张。校园的晚霞永远最治愈。'], tags: ['#校园生活', '#校园晚霞'], comments: [] } }; const profileVideos = { 'study-vlog': { title: '期末复习vlog | 图书馆打卡12小时高效学习日常', views: '4.3万', likes: '1567', duration: '03:24', tags: ['#期末复习', '#图书馆'] }, 'dorm-makeover': { title: '宿舍改造vlog | 花500块变身ins风小窝', views: '8.2万', likes: '1823', duration: '01:47', tags: ['#宿舍生活', '#宿舍改造'] }, 'volunteer-day': { title: '志愿服务vlog | 周末敬老院做义工的一天', views: '2.4万', likes: '678', duration: '02:15', tags: ['#志愿服务', '#周末生活'] }, 'campus-food': { title: '校园美食探店 | 学校周边最好吃的10家店测评', views: '6.7万', likes: '2341', duration: '04:02', tags: ['#校园美食', '#探店'] }, 'freshman-guide': { title: '大一新生必看 | 校园生存指南与社团选择建议', views: '12.5万', likes: '4521', duration: '05:30', tags: ['#新生指南', '#校园生活'] } }; const ensureHls = () => new Promise((resolve) => { if (window.Hls) { resolve(true); return; } const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js'; script.onload = () => resolve(Boolean(window.Hls)); script.onerror = () => resolve(false); document.head.appendChild(script); }); const initApiVideo = async (api) => { const grid = document.querySelector('.video-grid'); const tabs = Array.from(document.querySelectorAll('.category-tab')); const loadButton = document.querySelector('.load-more-btn'); if (!grid) return; const template = grid.querySelector('.video-card')?.cloneNode(true); if (!template) return; grid.replaceChildren(); addEmptyState(grid, '正在加载视频…', ''); if (tabs[0]) tabs[0].textContent = '最新'; tabs.slice(1).forEach((tab) => { tab.hidden = true; }); let page = 1; let total = 0; let loading = false; const buildCard = (item) => { const card = template.cloneNode(true); const thumbnail = card.querySelector('.video-thumbnail'); const cover = thumbnail?.querySelector('.video-thumbnail-gradient'); if (cover) cover.style.background = item.videoCoverUrl ? `url("${item.videoCoverUrl}") center / cover` : '#e8edf0'; const duration = card.querySelector('.video-duration'); if (!item.playUrl) { if (duration) duration.textContent = '转码中'; card.classList.add('is-transcoding'); } else if (duration) { duration.remove(); } const title = card.querySelector('.video-title'); if (title) title.textContent = item.content || '未命名视频'; const name = card.querySelector('.author-name'); if (name) name.textContent = item.userName || '青核用户'; const avatar = card.querySelector('.author-avatar'); if (avatar) avatar.textContent = (item.userName || '青').slice(0, 1); const likes = card.querySelector('.video-likes span'); if (likes) likes.textContent = String(item.likeCount || 0); const views = card.querySelector('.video-views'); if (views) views.textContent = `${item.viewCount || 0} 次播放`; card.addEventListener('click', () => { location.href = `video-detail.html?id=${encodeURIComponent(item.id)}`; }); return card; }; const load = async (append) => { if (loading) return; loading = true; try { const data = await api.get(`/campus/moments/youth?page=${page}&size=20`); const items = data?.items || []; total = Number(data?.total || 0); if (!append) grid.replaceChildren(); grid.append(...items.map(buildCard)); if (!items.length && !append) addEmptyState(grid, '还没有视频', '发布第一条校园视频吧。'); if (loadButton) { const shown = grid.querySelectorAll('.video-card').length; loadButton.hidden = shown >= total; loadButton.textContent = shown >= total ? '已加载全部' : '加载更多'; } } catch (error) { if (!append) { grid.replaceChildren(); addEmptyState(grid, '视频加载失败', error.message || '请稍后刷新重试'); } showWebToast(error.message || '视频加载失败', 'error'); } finally { loading = false; } }; if (loadButton) { loadButton.addEventListener('click', () => { page += 1; load(true); }); } await load(false); }; const initVideo = () => { const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiVideo(api); const tabs = Array.from(document.querySelectorAll('.category-tab')); const cards = Array.from(document.querySelectorAll('.video-card')); const grid = document.querySelector('.video-grid'); const loadButton = document.querySelector('.load-more-btn'); if (!tabs.length || !cards.length || !grid) return; const categories = [ ['音乐', '校园'], ['生活', '校园'], ['校园', '生活'], ['美食', '生活'], ['生活'], ['运动', '校园'], ['舞蹈'], ['科技'], ['生活'], ['校园', '生活'], ['科技', '校园'], ['科技', '校园'] ]; let activeCategory = new URLSearchParams(location.search).get('category') || '推荐'; let expanded = false; cards.forEach((card, index) => { card.dataset.categories = (categories[index] || ['生活']).join(','); makeLinkedCard(card, 'video-detail.html'); const like = card.querySelector('.video-likes'); if (like) { makeKeyboardClickable(like, () => like.click()); like.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const liked = like.classList.toggle('is-liked'); like.setAttribute('aria-pressed', String(liked)); showWebToast(liked ? '已喜欢该视频' : '已取消喜欢'); }); } }); const localVideos = window.YouthomeLocal?.readDb().moments.filter((item) => item.isYouthVideo && item.reviewStatus === 'approved') || []; localVideos.forEach((item) => { const card = cards[0].cloneNode(true); card.classList.add('web-live-video'); card.dataset.categories = '校园,生活'; card.querySelector('.video-thumbnail-gradient').style.background = item.videoCoverUrl ? `url("${item.videoCoverUrl}") center / cover` : ''; card.querySelector('.video-title').textContent = item.content.split('\n')[0]; card.querySelector('.author-name').textContent = item.userName || '我'; card.querySelector('.author-avatar').textContent = (item.userName || '我').slice(0, 1); card.querySelector('.video-duration').textContent = `${String(Math.floor((item.videoDuration || 0) / 60)).padStart(2, '0')}:${String((item.videoDuration || 0) % 60).padStart(2, '0')}`; card.querySelector('.video-views').lastChild.textContent = ' ' + (item.viewCount || 0); makeLinkedCard(card, `video-detail.html?id=${encodeURIComponent(item.id)}`); grid.prepend(card); cards.unshift(card); }); const render = () => { const matched = cards.filter((card) => activeCategory === '推荐' || card.dataset.categories.split(',').includes(activeCategory)); cards.forEach((card) => { card.hidden = true; }); matched.slice(0, expanded ? matched.length : 8).forEach((card) => { card.hidden = false; }); grid.querySelector('.web-empty-state')?.remove(); if (!matched.length) addEmptyState(grid, `暂无${activeCategory}视频`, '换个分类看看校园里的其他内容。'); if (loadButton) { loadButton.hidden = matched.length <= 8; loadButton.disabled = expanded; loadButton.textContent = expanded ? '已加载全部' : `加载更多(还有 ${Math.max(0, matched.length - 8)} 条)`; } const params = new URLSearchParams(); if (activeCategory !== '推荐') params.set('category', activeCategory); history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { activeCategory = tab.textContent.trim(); expanded = false; setPressed(tabs, tab); render(); }); }); const requestedTab = tabs.find((tab) => tab.textContent.trim() === activeCategory) || tabs[0]; activeCategory = requestedTab.textContent.trim(); setPressed(tabs, requestedTab); loadButton?.addEventListener('click', () => { expanded = true; render(); }); render(); }; const initFeed = () => { if (window.YouthomeApi && !window.YouthomeApi.isLocal()) return; const tabs = Array.from(document.querySelectorAll('.feed-tab')); const cards = Array.from(document.querySelectorAll('.feed-card')); const feed = document.querySelector('.feed-grid, .feed-list, .feed-content') || cards[0]?.parentElement; if (!tabs.length || !cards.length || !feed) return; const following = new Set([0, 2, 4, 7, 9]); const hot = new Set([1, 3, 5, 6, 10]); cards.forEach((card) => makeLinkedCard(card, 'post-detail.html')); const render = (label) => { cards.forEach((card, index) => { card.hidden = label === '关注' ? !following.has(index) : label === '热门' ? !hot.has(index) : false; }); const params = new URLSearchParams(); if (label !== '推荐') params.set('tab', label); history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { setPressed(tabs, tab); render(tab.textContent.trim()); }); }); const requested = new URLSearchParams(location.search).get('tab'); const selected = tabs.find((tab) => tab.textContent.trim() === requested) || tabs[0]; setPressed(tabs, selected); render(selected.textContent.trim()); document.querySelectorAll('.topic-item').forEach((item) => { const topic = item.querySelector('.topic-name')?.textContent.trim().replace(/^#/, '') || '热门话题'; makeLinkedCard(item, `topic-detail.html?topic=${encodeURIComponent(topic)}`); }); const topicMore = document.querySelector('.topic-more-button'); const extraTopics = Array.from(document.querySelectorAll('.topic-item-extra')); topicMore?.addEventListener('click', () => { const expanded = topicMore.getAttribute('aria-expanded') !== 'true'; topicMore.setAttribute('aria-expanded', String(expanded)); extraTopics.forEach((item) => { item.hidden = !expanded; }); topicMore.firstChild.textContent = expanded ? '收起' : '更多'; }); const recommendations = [ ['苏梦瑶', '中国人民大学 · 摄影', 'avatar-05-female-ponytail-medium.jpg'], ['周浩然', '同济大学 · 建筑设计', 'avatar-08-male-messy-medium.jpg'], ['黄诗雅', '中山大学 · 音乐', 'avatar-09-female-bob-dark.jpg'], ['吴一凡', '南京大学 · 旅行博主', 'avatar-01-female-short-light.jpg'], ['陈念', '浙江大学 · 产品设计', 'avatar-02-male-short-medium.jpg'], ['许知夏', '武汉大学 · 新闻传播', 'avatar-07-female-twintails-light.jpg'], ['顾言', '北京大学 · 篮球', 'avatar-06-male-buzz-dark.jpg'], ['林乔', '厦门大学 · 旅行摄影', 'avatar-04-male-curly-light.jpg'], ['沈星澜', '四川大学 · 插画', 'avatar-03-female-long-dark.jpg'], ['陆川', '西安交通大学 · 编程', 'avatar-10-male-parted-light.jpg'], ['江予安', '华中科技大学 · 音乐', 'avatar-flat-female-01.jpg'], ['秦越', '山东大学 · 运动', 'avatar-flat-male-01.jpg'] ]; const userList = document.querySelector('.user-list'); const refresh = document.querySelector('.recommendation-refresh'); let recommendationBatch = 0; const renderRecommendations = () => { if (!userList) return; const start = (recommendationBatch * 4) % recommendations.length; const batch = Array.from({ length: 4 }, (_, offset) => recommendations[(start + offset) % recommendations.length]); userList.innerHTML = ''; batch.forEach(([name, description, avatar], index) => { const followed = localStorage.getItem(`youthome:web:follow:${name}`) === 'true'; const item = document.createElement('div'); item.className = 'user-item'; item.tabIndex = 0; item.setAttribute('role', 'link'); item.dataset.userName = name; item.innerHTML = `
${name}
${name}
${description}
`; const openUser = () => { location.href = `user.html?name=${encodeURIComponent(name)}`; }; item.addEventListener('click', (event) => { if (!event.target.closest('.follow-btn')) openUser(); }); item.addEventListener('keydown', (event) => { if ((event.key === 'Enter' || event.key === ' ') && !event.target.closest('.follow-btn')) { event.preventDefault(); openUser(); } }); item.querySelector('.follow-btn').addEventListener('click', (event) => { event.stopPropagation(); const button = event.currentTarget; const active = button.classList.toggle('followed'); button.textContent = active ? '已关注' : '关注'; localStorage.setItem(`youthome:web:follow:${name}`, String(active)); showWebToast(active ? `已关注 ${name}` : `已取消关注 ${name}`); }); userList.appendChild(item); }); }; refresh?.addEventListener('click', () => { recommendationBatch = (recommendationBatch + 1) % Math.ceil(recommendations.length / 4); refresh.classList.remove('is-refreshing'); void refresh.offsetWidth; refresh.classList.add('is-refreshing'); renderRecommendations(); showWebToast('已更新推荐关注'); }); renderRecommendations(); const searchInput = document.querySelector('.search-bar-input'); const searchButton = document.querySelector('.search-bar-btn'); const openSearch = () => { const query = searchInput?.value.trim(); location.href = query ? `search.html?q=${encodeURIComponent(query)}` : 'search.html'; }; searchButton?.addEventListener('click', openSearch); searchInput?.addEventListener('keydown', (event) => { if (event.key === 'Enter') openSearch(); }); }; const initApiSearch = async (api) => { const input = document.getElementById('pageSearchInput'); const searchButton = document.querySelector('.page-search-btn'); const tabs = Array.from(document.querySelectorAll('.search-tab')); const grid = document.querySelector('.result-grid'); const userRow = document.querySelector('.user-results-row'); const countNode = document.querySelector('.result-count'); if (!input || !grid) return; const params = new URLSearchParams(location.search); let type = params.get('type') || 'all'; if (params.get('q')) input.value = params.get('q'); const makeCard = (title, desc, onClick) => { const card = document.createElement('div'); card.className = 'result-card'; card.innerHTML = '
'; card.querySelector('.result-title').textContent = title; card.querySelector('.result-author-sm')?.remove(); card.querySelector('.result-author-name').textContent = desc || ''; card.querySelector('.result-avatar-sm').textContent = String(title || '·').slice(0, 1); card.addEventListener('click', onClick); return card; }; const run = async () => { const keyword = input.value.trim(); if (!keyword) { showWebToast('请输入搜索内容', 'error'); input.focus(); return; } grid.replaceChildren(); userRow?.replaceChildren(); const [users, activities, groups, topics] = await Promise.all([ api.get('/friendships/search?keyword=' + encodeURIComponent(keyword) + '&page=1&size=10').catch(() => ({ items: [] })), api.post('/campus/activities/search?page=1&size=10', { keyword }).catch(() => ({ items: [] })), api.get('/groups/search?keyword=' + encodeURIComponent(keyword) + '&page=1&size=10').catch(() => ({ items: [] })), api.get('/topics/catalog?keyword=' + encodeURIComponent(keyword) + '&page=1&size=10').catch(() => ({ items: [] })) ]); const userItems = users?.items || []; const activityItems = activities?.items || []; const groupItems = groups?.items || []; const topicItems = topics?.items || []; const cards = []; if (type === 'all' || type === 'user') { userItems.forEach((user) => cards.push(makeCard(user.nickname || '用户', user.universityName || '校园用户', () => { location.href = 'user.html?id=' + encodeURIComponent(user.id); }))); } if (type === 'all' || type === 'activity') { activityItems.forEach((item) => cards.push(makeCard(item.title || '未命名活动', item.creatorName || '校园组织', () => { location.href = 'activity-detail.html?id=' + encodeURIComponent(item.id); }))); } if (type === 'all' || type === 'group') { groupItems.forEach((group) => cards.push(makeCard(group.name || group.groupName || '群组', (group.memberCount || 0) + ' 位成员', () => showWebToast('群聊请在 App 中查看')))); } if (type === 'all' || type === 'topic') { topicItems.forEach((topic) => cards.push(makeCard('#' + (topic.name || '话题'), topic.description || '话题讨论', () => { location.href = 'topic-detail.html?topic=' + encodeURIComponent(topic.name); }))); } if (type === 'post' || type === 'video') { const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = '该类型暂未提供搜索接口,可在动态广场浏览'; grid.appendChild(empty); } else if (!cards.length) { const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = '没有找到相关内容'; grid.appendChild(empty); } else { grid.append(...cards); } if (countNode) countNode.textContent = '共 ' + cards.length + ' 条结果'; history.replaceState(null, '', location.pathname + '?q=' + encodeURIComponent(keyword) + (type !== 'all' ? '&type=' + encodeURIComponent(type) : '')); }; tabs.forEach((tab) => { tab.addEventListener('click', () => { tabs.forEach((node) => node.classList.remove('active')); tab.classList.add('active'); type = tab.dataset.tab || 'all'; run(); }); }); searchButton?.addEventListener('click', run); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); run(); } }); if (input.value.trim()) run(); }; const initSearch = () => { const input = document.getElementById('pageSearchInput'); const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiSearch(api); const searchButton = document.querySelector('.page-search-btn'); const tabs = Array.from(document.querySelectorAll('.search-tab')); const chips = Array.from(document.querySelectorAll('.filter-chip')); const periods = Array.from(document.querySelectorAll('.sort-option')); const users = Array.from(document.querySelectorAll('.user-mini-card')); const results = Array.from(document.querySelectorAll('.result-card')); const userRow = document.querySelector('.user-results-row'); const resultGrid = document.querySelector('.result-grid'); const count = document.querySelector('.result-count'); if (!input || !tabs.length || !resultGrid) return; const params = new URLSearchParams(location.search); if (params.get('q')) input.value = params.get('q'); let activeType = params.get('type') || 'all'; let activeSort = params.get('sort') || chips[0]?.textContent.trim() || '综合排序'; let activePeriod = params.get('period') || '全部'; results.forEach((card, index) => { card.dataset.type = card.querySelector('.result-cover.video') ? 'video' : 'post'; card.dataset.age = String((index % 10) + 1); card.dataset.likes = String(parseCompactNumber(card.querySelector('.result-like span')?.textContent)); makeLinkedCard(card, card.dataset.type === 'video' ? 'video-detail.html' : 'post-detail.html'); }); users.forEach((card) => { const name = card.querySelector('.user-mini-name, .user-name, .result-author-name')?.textContent.trim(); makeLinkedCard(card, name ? `user.html?name=${encodeURIComponent(name)}` : 'user.html'); }); const render = () => { const query = input.value.trim().toLowerCase(); const maxAge = activePeriod === '一天内' ? 1 : activePeriod === '一周内' ? 7 : activePeriod === '一月内' ? 30 : Infinity; let matched = results.filter((card) => ( (activeType === 'all' || card.dataset.type === activeType) && (!query || card.textContent.toLowerCase().includes(query)) && Number(card.dataset.age) <= maxAge )); if (activeSort === '最多点赞') matched.sort((a, b) => Number(b.dataset.likes) - Number(a.dataset.likes)); if (activeSort === '最多评论') matched.reverse(); results.forEach((card) => { card.hidden = true; }); matched.forEach((card) => { card.hidden = false; resultGrid.appendChild(card); }); const showUsers = (activeType === 'all' || activeType === 'user') && users.some((card) => !query || card.textContent.toLowerCase().includes(query)); users.forEach((card) => { card.hidden = !showUsers || (!!query && !card.textContent.toLowerCase().includes(query)); }); if (userRow) userRow.parentElement.hidden = !showUsers; resultGrid.querySelector('.web-empty-state')?.remove(); if (!matched.length && !showUsers) addEmptyState(resultGrid, '没有找到相关内容', '换个关键词或筛选条件再试一次。'); const total = matched.length + (showUsers ? users.filter((card) => !card.hidden).length : 0); if (count) count.innerHTML = `找到 ${total} 条与“${input.value.trim() || '全部'}”相关的结果`; const next = new URLSearchParams(); if (input.value.trim()) next.set('q', input.value.trim()); if (activeType !== 'all') next.set('type', activeType); if (activeSort !== (chips[0]?.textContent.trim() || '综合排序')) next.set('sort', activeSort); if (activePeriod !== '全部') next.set('period', activePeriod); history.replaceState(null, '', `${location.pathname}${next.size ? `?${next}` : ''}`); }; const submitSearch = () => { activeType = 'all'; setPressed(tabs, tabs[0]); render(); }; searchButton?.addEventListener('click', submitSearch); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') submitSearch(); }); tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { activeType = tab.dataset.tab; setPressed(tabs, tab); render(); }); }); chips.forEach((chip) => { makeKeyboardClickable(chip, () => chip.click()); chip.addEventListener('click', () => { activeSort = chip.textContent.trim(); setPressed(chips, chip); render(); }); }); periods.forEach((period) => { makeKeyboardClickable(period, () => period.click()); period.addEventListener('click', () => { activePeriod = period.textContent.trim(); setPressed(periods, period); render(); }); }); const selectedTab = tabs.find((tab) => tab.dataset.tab === activeType) || tabs[0]; activeType = selectedTab.dataset.tab; setPressed(tabs, selectedTab); setPressed(chips, chips.find((chip) => chip.textContent.trim() === activeSort) || chips[0]); setPressed(periods, periods.find((period) => period.textContent.trim() === activePeriod) || periods[0]); render(); }; const optionLabel = (element) => { if (element.dataset.filterLabel) return element.dataset.filterLabel; const clone = element.cloneNode(true); clone.querySelectorAll('.filter-checkbox, .filter-option-count, svg').forEach((node) => node.remove()); return clone.textContent.replace(/\.\.\.$/, '').trim(); }; const initApiActivities = async (api) => { const list = document.querySelector('.activity-list'); const search = document.querySelector('.search-input'); const sortOptions = Array.from(document.querySelectorAll('.sort-option')); const sortMenu = document.querySelector('.sort-menu'); const sortLabel = document.querySelector('.sort-dropdown'); const pagination = document.querySelector('.pagination'); const countNode = document.querySelector('.list-header-left b'); const sections = Array.from(document.querySelectorAll('.filter-section')); if (!list) return; const template = list.querySelector('.activity-list-card')?.cloneNode(true); if (!template) return; list.replaceChildren(); addEmptyState(list, '正在加载活动…', ''); // 后端暂不支持学校/费用/人数筛选,真实模式下隐藏这些分组,避免出现“能点但不生效”的筛选 sections.slice(1).forEach((section) => { section.hidden = true; }); const state = { page: 1, size: 10, type: '', keyword: '', sortBy: 'latest', total: 0 }; const buildCard = (item) => { const card = template.cloneNode(true); card.setAttribute('onclick', `location.href='activity-detail.html?id=${encodeURIComponent(item.id)}'`); const ended = item.endTime && Date.parse(String(item.endTime).replace(/-/g, '/')) < Date.now(); const tag = card.querySelector('.activity-cover-tag'); if (tag) { tag.textContent = ended ? '已结束' : '报名中'; tag.className = 'activity-cover-tag' + (ended ? '' : ' signing'); } card.querySelector('.activity-credit-tag')?.remove(); const title = card.querySelector('.activity-list-title'); if (title) title.textContent = item.title || '未命名活动'; const cover = card.querySelector('.activity-list-cover'); const coverImage = item.coverImage || item.coverUrl; if (cover && coverImage) cover.style.background = `url("${coverImage}") center / cover`; const typeTag = card.querySelector('.activity-type-tag'); if (typeTag) typeTag.textContent = item.typeLabel || item.categoryLabel || '活动'; const metas = card.querySelectorAll('.meta-value'); if (metas[0]) metas[0].textContent = [item.startTime, item.endTime].filter(Boolean).join(' - ') || '以主办方通知为准'; if (metas[1]) metas[1].textContent = item.location || '待公布'; if (metas[2]) metas[2].textContent = item.creatorName || '校园组织'; const people = card.querySelector('.people-info'); if (people) { const max = Number(item.maxCount || 0); const current = Number(item.currentCount || 0); const percent = max ? Math.min(100, Math.round((current / max) * 100)) : 0; people.innerHTML = `${current}/${max || '—'} 人已报名
`; } card.querySelector('.fee-info')?.remove(); card.querySelector('.organizer-info')?.remove(); const signup = card.querySelector('.btn-signup'); if (signup) { signup.textContent = '查看详情'; signup.addEventListener('click', (event) => { event.stopPropagation(); location.href = `activity-detail.html?id=${encodeURIComponent(item.id)}`; }); } return card; }; const renderPagination = () => { if (!pagination) return; const pages = Math.max(1, Math.ceil(state.total / state.size)); pagination.replaceChildren(); const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'page-btn'; prev.textContent = '上一页'; prev.disabled = state.page <= 1; prev.addEventListener('click', () => { if (state.page > 1) { state.page -= 1; load(); } }); const info = document.createElement('span'); info.textContent = `第 ${state.page} / ${pages} 页`; info.style.cssText = 'padding: 0 10px; font-size: 13px; color: var(--text-tertiary);'; const next = document.createElement('button'); next.type = 'button'; next.className = 'page-btn'; next.textContent = '下一页'; next.disabled = state.page >= pages; next.addEventListener('click', () => { if (state.page < pages) { state.page += 1; load(); } }); pagination.append(prev, info, next); }; const load = async () => { const params = new URLSearchParams({ page: String(state.page), size: String(state.size) }); if (state.type) params.set('type', state.type); let data; try { data = state.keyword || state.sortBy ? await api.post('/campus/activities/search?' + params.toString(), { type: state.type || undefined, keyword: state.keyword || undefined, sortBy: ({ latest: 'TIME', hot: 'HEAT', soon: 'EVENT_TIME', popular: 'PARTICIPATION' })[state.sortBy] || 'TIME' }) : await api.get('/campus/activities?' + params.toString()); } catch (error) { list.replaceChildren(); addEmptyState(list, '活动加载失败', error.message || '请稍后刷新重试'); showWebToast(error.message || '活动加载失败', 'error'); return; } const items = data?.items || []; state.total = Number(data?.total || 0); list.replaceChildren(...items.map(buildCard)); if (!items.length) addEmptyState(list, '没有找到活动', '换个关键词或筛选条件试试。'); if (countNode) countNode.textContent = String(state.total); renderPagination(); }; // 类型筛选用后端字典重建,保证标签与接口参数一致 api.get('/dicts/items?code=activity_type').then((dict) => { const wrap = sections[0]?.querySelector('.filter-options'); const sample = wrap?.querySelector('.filter-option'); if (wrap && sample) { const makeOption = (label, value) => { const option = sample.cloneNode(true); option.removeAttribute('onclick'); const labelNode = Array.from(option.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); if (labelNode) labelNode.textContent = ' ' + label + ' '; option.querySelector('.filter-option-count')?.remove(); option.classList.toggle('selected', value === ''); option.dataset.value = value; option.addEventListener('click', () => { wrap.querySelectorAll('.filter-option').forEach((node) => node.classList.remove('selected')); option.classList.add('selected'); state.type = value; state.page = 1; load(); }); return option; }; wrap.replaceChildren(); wrap.appendChild(makeOption('全部', '')); (dict || []).forEach((item) => wrap.appendChild(makeOption(item.label, item.value))); } }).catch(() => {}); sortOptions.forEach((option) => { option.removeAttribute('onclick'); option.addEventListener('click', () => { const label = option.textContent.trim(); sortOptions.forEach((node) => node.classList.toggle('active', node === option)); sortMenu?.classList.remove('show'); sortLabel?.classList.remove('active'); const labelNode = sortLabel?.childNodes[0]; if (labelNode && labelNode.nodeType === Node.TEXT_NODE) labelNode.textContent = label; state.sortBy = ({ 最新发布: 'latest', 最热活动: 'hot', 即将开始: 'soon', 报名人数: 'popular' })[label] || 'latest'; state.page = 1; load(); }); }); let timer = null; search?.addEventListener('input', () => { window.clearTimeout(timer); timer = window.setTimeout(() => { state.keyword = search.value.trim(); state.page = 1; load(); }, 350); }); await load(); }; const initActivities = () => { const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiActivities(api); const list = document.querySelector('.activity-list'); const cards = Array.from(document.querySelectorAll('.activity-list-card')); const sections = Array.from(document.querySelectorAll('.filter-section .filter-options')); const pagination = document.querySelector('.pagination'); const resultCount = document.querySelector('.list-header-left b'); const selectedTags = document.querySelector('.filter-selected-tags'); if (!list || !cards.length || sections.length < 5) return; const metadata = [ { type: '比赛竞赛', time: ['本周', '本周末', '下周'], dates: ['12-28'], school: '复旦大学', fee: '免费', people: 186, popularity: 93, soon: 4 }, { type: '学习交流', time: ['本周'], dates: ['12-21'], school: '复旦大学', fee: '免费', people: 48, popularity: 80, soon: 2 }, { type: '公益志愿', time: ['今天', '本周'], dates: ['12-15', '12-16', '12-17', '12-18', '12-19', '12-20', '12-21', '12-22'], school: '复旦大学', fee: '免费', people: 32, popularity: 64, soon: 1 }, { type: '运动健身', time: ['本周', '本周末', '下周'], dates: ['12-28'], school: '复旦大学', fee: '付费', people: 24, popularity: 75, soon: 5 }, { type: '兴趣社团', time: ['本周', '本周末', '下周'], dates: ['12-28'], school: '复旦大学', fee: '付费', people: 38, popularity: 95, soon: 6 }, { type: '线下聚会', time: ['本周'], dates: ['12-25'], school: '复旦大学', fee: '免费', people: 200, popularity: 100, soon: 3 }, { type: '线下聚会', time: ['本周末', '下周'], dates: ['12-29'], school: '上海交通大学', fee: '付费', people: 52, popularity: 87, soon: 7 } ]; const typeLabels = { offline: '线下聚会', sports: '运动健身', study: '学习交流', club: '兴趣社团', competition: '比赛竞赛', volunteer: '公益志愿' }; (window.YouthomeLocal?.readDb().activities || []).slice().reverse().forEach((item) => { const card = cards[0].cloneNode(true); card.classList.add('web-live-activity'); card.setAttribute('onclick', `location.href='activity-detail.html?id=${encodeURIComponent(item.id)}'`); const cover = card.querySelector('.activity-list-cover'); if (cover && (item.coverImage || item.coverUrl)) cover.style.background = `url("${item.coverImage || item.coverUrl}") center / cover`; card.querySelector('.activity-list-title').textContent = item.title; card.querySelector('.activity-type-tag').textContent = typeLabels[item.type] || item.type || '活动'; const values = card.querySelectorAll('.meta-value'); if (values[0]) values[0].textContent = `${item.startTime} - ${item.endTime}`; if (values[1]) values[1].textContent = item.location; if (values[2]) values[2].textContent = item.creatorName || '我'; card.querySelector('.people-info b').textContent = String(item.currentCount || 0); const button = card.querySelector('.btn-signup'); button.textContent = '查看详情'; button.setAttribute('onclick', `event.stopPropagation();location.href='activity-detail.html?id=${encodeURIComponent(item.id)}'`); list.prepend(card); cards.unshift(card); const day = new Date(String(item.startTime).replace(' ', 'T')); metadata.unshift({ type: typeLabels[item.type] || item.type, time: ['今天', '本周', '本周末', '下周'], dates: [`${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`], school: item.universityName || '复旦大学', fee: Number(item.feeAmount) > 0 ? '付费' : '免费', people: Number(item.maxCount) || 20, popularity: 0, soon: 0 }); }); const sectionNames = ['type', 'time', 'school', 'fee', 'people']; let sortMode = '最新发布'; let currentPage = 1; const pageSize = 4; cards.forEach((card, index) => { card.dataset.originalIndex = String(index); card.tabIndex = 0; card.setAttribute('role', 'link'); card.addEventListener('keydown', (event) => { if (event.key === 'Enter') location.href = 'activity-detail.html'; }); }); sections.forEach((section) => { const options = Array.from(section.querySelectorAll('.filter-option')); options.forEach((option, index) => { option.classList.toggle('selected', index === 0); option.setAttribute('aria-pressed', String(index === 0)); makeKeyboardClickable(option, () => window.toggleFilter(option)); }); }); const selectedValues = (section) => Array.from(section.querySelectorAll('.filter-option.selected')) .slice(0) .filter((option) => option !== section.querySelector('.filter-option')) .map(optionLabel); const matchesPeople = (count, labels) => labels.some((label) => ( (label === '10人以下' && count < 10) || (label === '10-30人' && count >= 10 && count <= 30) || (label === '30人以上' && count > 30) )); const syncUrl = () => { const params = new URLSearchParams(); sections.forEach((section, index) => { const values = selectedValues(section); if (values.length) params.set(sectionNames[index], values.join(',')); }); if (sortMode !== '最新发布') params.set('sort', sortMode); if (currentPage > 1) params.set('page', String(currentPage)); history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); }; const renderTags = () => { if (!selectedTags) return; selectedTags.innerHTML = ''; sections.forEach((section) => { selectedValues(section).forEach((label) => { const tag = document.createElement('button'); tag.type = 'button'; tag.className = 'filter-selected-tag'; tag.innerHTML = `${label}`; tag.setAttribute('aria-label', `移除筛选:${label}`); tag.addEventListener('click', () => { const option = Array.from(section.querySelectorAll('.filter-option')).find((item) => optionLabel(item) === label); if (option) window.toggleFilter(option); }); selectedTags.appendChild(tag); }); }); const label = selectedTags.previousElementSibling; if (label) label.hidden = selectedTags.childElementCount === 0; }; const renderPagination = (total) => { if (!pagination) return; const pages = Math.max(1, Math.ceil(total / pageSize)); currentPage = Math.min(currentPage, pages); pagination.innerHTML = ''; if (total <= pageSize) { pagination.hidden = true; return; } pagination.hidden = false; const addButton = (label, target, active = false, disabled = false) => { const button = document.createElement('button'); button.type = 'button'; button.className = `page-btn${active ? ' active' : ''}`; button.textContent = label; button.disabled = disabled; button.setAttribute('aria-label', label === '上一页' || label === '下一页' ? label : `第 ${label} 页`); button.addEventListener('click', () => { currentPage = target; updateActivityView(false); list.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); pagination.appendChild(button); }; addButton('上一页', Math.max(1, currentPage - 1), false, currentPage === 1); for (let number = 1; number <= pages; number += 1) addButton(String(number), number, number === currentPage); addButton('下一页', Math.min(pages, currentPage + 1), false, currentPage === pages); const totalLabel = document.createElement('span'); totalLabel.className = 'page-total'; totalLabel.textContent = `共 ${pages} 页`; pagination.appendChild(totalLabel); }; const updateActivityView = (resetPage = true) => { if (resetPage) currentPage = 1; const values = sections.map(selectedValues); let matched = cards.map((card, index) => ({ card, data: metadata[index] })).filter(({ data }) => ( (!values[0].length || values[0].includes(data.type)) && (!values[1].length || values[1].some((value) => value.startsWith('自定义日期 ') ? data.dates.includes(value.slice(-5)) : data.time.includes(value))) && (!values[2].length || values[2].includes(data.school)) && (!values[3].length || values[3].includes(data.fee)) && (!values[4].length || matchesPeople(data.people, values[4])) )); matched = matched.sort((a, b) => { if (sortMode === '最热活动') return b.data.popularity - a.data.popularity; if (sortMode === '即将开始') return a.data.soon - b.data.soon; if (sortMode === '报名人数') return b.data.people - a.data.people; return Number(a.card.dataset.originalIndex) - Number(b.card.dataset.originalIndex); }); cards.forEach((card) => { card.hidden = true; }); matched.forEach(({ card }) => list.appendChild(card)); renderPagination(matched.length); const start = (currentPage - 1) * pageSize; matched.slice(start, start + pageSize).forEach(({ card }) => { card.hidden = false; }); resultCount.textContent = String(matched.length); list.querySelector('.web-empty-state')?.remove(); if (!matched.length) { const empty = document.createElement('div'); empty.className = 'web-empty-state'; empty.innerHTML = '
没有符合条件的活动换一组筛选条件,或重置后查看全部活动。
'; list.appendChild(empty); } renderTags(); syncUrl(); document.querySelector('.activity-filter-count').textContent = String(values.reduce((sum, value) => sum + value.length, 0)); document.querySelector('.activity-filter-apply').textContent = `查看 ${matched.length} 个活动`; }; window.toggleFilter = (element) => { const section = element.closest('.filter-options'); const options = Array.from(section.querySelectorAll('.filter-option')); const first = options[0]; if (optionLabel(element).startsWith('自定义日期') && !element.dataset.filterLabel) { const picker = document.createElement('input'); picker.type = 'date'; picker.style.cssText = 'position:fixed;left:-9999px;top:0;'; picker.addEventListener('change', () => { if (picker.value) { element.dataset.filterLabel = `自定义日期 ${picker.value.slice(5)}`; element.classList.add('selected'); first.classList.remove('selected'); element.setAttribute('aria-pressed', 'true'); updateActivityView(); } picker.remove(); }); document.body.appendChild(picker); if (picker.showPicker) picker.showPicker(); else picker.click(); return; } if (element === first) { options.forEach((option) => option.classList.remove('selected')); first.classList.add('selected'); } else { element.classList.toggle('selected'); first.classList.remove('selected'); if (!options.slice(1).some((option) => option.classList.contains('selected'))) first.classList.add('selected'); } options.forEach((option) => option.setAttribute('aria-pressed', String(option.classList.contains('selected')))); updateActivityView(); }; window.resetAllFilters = () => { sections.forEach((section) => { Array.from(section.querySelectorAll('.filter-option')).forEach((option, index) => { option.classList.toggle('selected', index === 0); option.setAttribute('aria-pressed', String(index === 0)); }); }); updateActivityView(); showWebToast('筛选条件已重置'); }; window.setSort = (label, event) => { event?.stopPropagation(); sortMode = label; document.getElementById('sortLabel').textContent = label; document.querySelectorAll('.sort-option').forEach((option) => option.classList.toggle('active', option.textContent.trim() === label)); document.getElementById('sortMenu')?.classList.remove('show'); document.querySelector('.sort-dropdown')?.classList.remove('active'); updateActivityView(); }; const filterButton = document.createElement('button'); filterButton.type = 'button'; filterButton.className = 'activity-filter-mobile'; filterButton.innerHTML = `${icon.settings}筛选0`; filterButton.addEventListener('click', () => document.body.classList.add('web-filter-open')); document.querySelector('.list-header-right')?.prepend(filterButton); const applyButton = document.createElement('button'); applyButton.type = 'button'; applyButton.className = 'activity-filter-apply'; applyButton.textContent = '查看活动'; applyButton.addEventListener('click', () => document.body.classList.remove('web-filter-open')); document.querySelector('.filter-sidebar')?.appendChild(applyButton); document.querySelectorAll('.btn-signup').forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); event.stopImmediatePropagation(); button.className = 'btn-signed'; button.textContent = '已报名'; showWebToast('报名成功,可在“我的活动”中查看'); }, true); }); const params = new URLSearchParams(location.search); sections.forEach((section, index) => { const requested = (params.get(sectionNames[index]) || '').split(',').filter(Boolean); if (!requested.length) return; const options = Array.from(section.querySelectorAll('.filter-option')); options.forEach((option) => option.classList.remove('selected')); requested.forEach((label) => { const option = options.find((candidate) => optionLabel(candidate) === label || (label.startsWith('自定义日期 ') && optionLabel(candidate).startsWith('自定义日期'))); if (option && label.startsWith('自定义日期 ')) option.dataset.filterLabel = label; option?.classList.add('selected'); }); if (!options.some((option) => option.classList.contains('selected'))) options[0].classList.add('selected'); options.forEach((option) => option.setAttribute('aria-pressed', String(option.classList.contains('selected')))); }); if (params.get('sort')) { sortMode = params.get('sort'); const sortLabel = document.getElementById('sortLabel'); if (sortLabel) sortLabel.textContent = sortMode; document.querySelectorAll('.sort-option').forEach((option) => option.classList.toggle('active', option.textContent.trim() === sortMode)); } if (Number(params.get('page')) > 0) currentPage = Number(params.get('page')); updateActivityView(false); }; const initApiNotifications = async (api) => { const list = document.querySelector('.message-list'); const categories = Array.from(document.querySelectorAll('.category-item')); if (!list || !categories.length) return; const template = list.querySelector('.message-item')?.cloneNode(true); if (!template) return; const accountId = api.currentUser()?.id || 'anonymous'; const conversationCacheKey = `youthome:web:${accountId}:chat-conversations`; const conversationsTask = api.get('/campus/chat/conversations').then((items) => { try { sessionStorage.setItem(conversationCacheKey, JSON.stringify(items || [])); } catch (error) { /* ignore */ } return items || []; }).catch(() => null); window.__youthomeConversationsTask = conversationsTask; list.replaceChildren(); addEmptyState(list, '正在加载通知…', ''); let items = []; try { items = (await api.get('/campus/notifications?page=1&size=50'))?.items || []; } catch (error) { addEmptyState(list, '通知加载失败', error.message || '请稍后刷新重试'); showWebToast(error.message || '通知加载失败', 'error'); return; } const categoryOf = (type) => { const value = String(type || '').toUpperCase(); if (value.includes('LIKE')) return '点赞'; if (value.includes('COMMENT') || value.includes('REPLY')) return '评论回复'; if (value.includes('MENTION') || value.includes('AT_')) return '@我的'; if (value.includes('FOLLOW') || value.includes('FRIEND')) return '新增关注'; if (value.includes('ACTIVITY')) return '活动提醒'; return '系统公告'; }; list.replaceChildren(...items.map((item) => { const node = template.cloneNode(true); const unread = item.isRead === false; node.classList.toggle('unread', unread); if (!unread) node.querySelector('.message-dot')?.remove(); node.dataset.notificationId = item.id; node.dataset.category = categoryOf(item.type); const name = node.querySelector('.message-username'); if (name) name.textContent = item.title || '通知'; const extra = node.querySelector('.message-extra'); if (extra) extra.textContent = ''; const text = node.querySelector('.message-text'); if (text) text.textContent = item.content || ''; const time = node.querySelector('.message-time'); if (time) time.textContent = window.YouthomeLocal?.relative?.(item.createdAt) || item.createdAt || ''; const avatar = node.querySelector('.message-avatar'); if (avatar) { avatar.textContent = (item.avatar || '青').slice(0, 1); } const link = String(item.link || ''); const target = link.startsWith('/moment/') ? 'post-detail.html?moment=' + encodeURIComponent(link.split('/').pop()) : link.startsWith('/activity/') ? 'activity-detail.html?id=' + encodeURIComponent(link.split('/').pop()) : ''; if (target) node.setAttribute('href', target); else node.removeAttribute('href'); return node; })); // “好友消息”放在左侧分类里,点击进入聊天页 const categoryHost = categories[0]?.parentElement; if (categoryHost && !categoryHost.querySelector('[data-chat-category]')) { const chatCategory = categories[0].cloneNode(true); chatCategory.dataset.chatCategory = 'true'; chatCategory.classList.remove('active'); const nameNode = chatCategory.querySelector('.category-name'); if (nameNode) nameNode.textContent = '好友消息'; const badge = chatCategory.querySelector('.category-badge'); if (badge) { badge.textContent = '0'; badge.classList.add('zero'); } chatCategory.addEventListener('click', () => { const panel = document.querySelector('.message-panel'); const chatView = document.querySelector('.web-chat-view'); if (!chatView) { location.href = 'chat.html'; return; } if (panel) panel.hidden = true; chatView.hidden = false; categories.forEach((node) => node.classList.remove('active')); chatCategory.classList.add('active'); initInlineChat(window.YouthomeApi); }); categories[0].after(chatCategory); conversationsTask.then((list) => { if (!list) return; const unread = list.reduce((sum, item) => sum + Number(item.unreadCount || 0), 0); if (badge) { badge.textContent = String(unread); badge.classList.toggle('zero', unread === 0); } }); } let active = '全部'; const renderFilter = () => { const all = Array.from(list.querySelectorAll('.message-item')); all.forEach((node) => { node.hidden = active !== '全部' && node.dataset.category !== active; }); categories.forEach((category) => { const name = category.querySelector('.category-name')?.textContent.trim() || ''; const unread = all.filter((node) => node.classList.contains('unread') && (name === '全部' || node.dataset.category === name)).length; const badge = category.querySelector('.category-badge'); if (badge) { badge.textContent = String(unread); badge.classList.toggle('zero', unread === 0); } }); const total = all.filter((node) => node.classList.contains('unread')).length; document.querySelectorAll('.web-nav-badge').forEach((badge) => { badge.textContent = String(total); badge.hidden = total === 0; }); }; categories.forEach((category) => { category.addEventListener('click', () => { const chatView = document.querySelector('.web-chat-view'); const panel = document.querySelector('.message-panel'); if (chatView) chatView.hidden = true; if (panel) panel.hidden = false; categories.forEach((node) => node.classList.remove('active')); category.classList.add('active'); active = category.querySelector('.category-name')?.textContent.trim() || '全部'; renderFilter(); }); }); list.querySelectorAll('.message-item').forEach((node) => { node.addEventListener('click', async (event) => { const id = node.dataset.notificationId; const href = node.getAttribute('href'); if (node.classList.contains('unread')) { try { await api.request('/campus/notifications/' + encodeURIComponent(id) + '/read', { method: 'PUT' }); node.classList.remove('unread'); node.querySelector('.message-dot')?.remove(); renderFilter(); } catch (error) { // 后端未实现(当前返回 501)时保留未读状态,不能假装已读 console.warn('标记通知已读失败:', error.message); } } if (!href) event.preventDefault(); }); }); window.markAllRead = async () => { try { await api.request('/campus/notifications/read-all', { method: 'PUT' }); list.querySelectorAll('.message-item').forEach((node) => { node.classList.remove('unread'); node.querySelector('.message-dot')?.remove(); }); renderFilter(); showWebToast('已全部标为已读'); } catch (error) { showWebToast(error.message || '操作失败,请重试', 'error'); } }; renderFilter(); }; const initInlineChat = (api) => { const view = document.querySelector('.web-chat-view'); if (!view || view.dataset.ready === 'true') return; view.dataset.ready = 'true'; const convList = view.querySelector('.web-chat-conv-items'); const msgs = view.querySelector('.web-chat-msgs'); const paneTitle = view.querySelector('.web-chat-pane-title'); const textarea = view.querySelector('.web-chat-textarea'); const send = view.querySelector('.web-chat-send'); const me = api.currentUser() || {}; let conversations = []; let active = null; let historyRequestId = 0; const profiles = {}; const conversationCacheKey = `youthome:web:${me.id || 'anonymous'}:chat-conversations`; const readConversationCache = () => { try { return JSON.parse(sessionStorage.getItem(conversationCacheKey) || 'null'); } catch (error) { return null; } }; const nameOf = async (conv) => { if (conv.groupName || conv.peerName) return conv.groupName || conv.peerName; if (String(conv.conversationType || '').toUpperCase() === 'GROUP') return '群聊'; if (!conv.peerId) return '会话'; if (profiles[conv.peerId]) return profiles[conv.peerId].nickname || '用户'; try { const profile = await api.get('/users/profile?userId=' + encodeURIComponent(conv.peerId)); profiles[conv.peerId] = profile; return profile?.nickname || '用户'; } catch (error) { return '用户'; } }; const renderConvs = async () => { const nodes = []; const names = await Promise.all(conversations.map((conv) => nameOf(conv))); conversations.forEach((conv, index) => { const name = names[index]; const isGroup = String(conv.conversationType || '').toUpperCase() === 'GROUP'; const row = document.createElement('div'); row.className = 'web-chat-conv' + (active && active.id === conv.id ? ' active' : ''); row.innerHTML = '
'; row.querySelector('.web-chat-conv-avatar').textContent = isGroup ? '群' : name.slice(0, 1); row.querySelector('.web-chat-conv-name').textContent = name; row.querySelector('.web-chat-conv-preview').textContent = conv.lastMessage || ''; row.querySelector('.web-chat-conv-time').textContent = String(conv.lastMessageTime || '').slice(5, 16); const unread = row.querySelector('.web-chat-conv-unread'); const unreadCount = Number(conv.unreadCount || 0); unread.textContent = String(unreadCount); unread.classList.toggle('zero', unreadCount === 0); row.addEventListener('click', () => { active = conv; const requestId = ++historyRequestId; if (paneTitle) paneTitle.textContent = name; renderConvs(); loadHistory(conv, requestId); }); nodes.push(row); }); convList.replaceChildren(...nodes); if (!nodes.length) { const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = '还没有会话'; convList.appendChild(empty); } }; const loadHistory = async (conversation = active, requestId = ++historyRequestId) => { if (!conversation) return; const isGroup = String(conversation.conversationType || '').toUpperCase() === 'GROUP'; const path = isGroup ? '/campus/chat/history/group/' + encodeURIComponent(conversation.peerId) + '?page=1&size=50' : '/campus/chat/history/private/' + encodeURIComponent(conversation.peerId) + '?page=1&size=50'; let items = []; try { const data = await api.get(path); items = data?.items || []; } catch (error) { if (requestId !== historyRequestId || active?.id !== conversation.id) return; msgs.replaceChildren(); const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = isGroup ? '无法加载聊天记录(可能已被禁言或群已解散)' : '无法加载聊天记录,请稍后重试'; msgs.appendChild(empty); return; } if (requestId !== historyRequestId || active?.id !== conversation.id) return; msgs.replaceChildren(...items.map((item) => { const self = item.senderId === me.id; const wrapper = document.createElement('div'); wrapper.className = 'web-chat-msg-row' + (self ? ' self' : ''); const avatar = document.createElement('div'); avatar.className = 'web-chat-msg-avatar'; avatar.textContent = self ? (me.nickname || '我').slice(0, 1) : (item.senderName || '对').slice(0, 1); const bubble = document.createElement('div'); bubble.className = 'web-chat-msg'; bubble.textContent = item.content || ''; wrapper.append(avatar, bubble); return wrapper; })); msgs.scrollTop = msgs.scrollHeight; }; const sendMessage = async () => { const content = textarea?.value.trim(); if (!content || !active) return; const isGroup = String(active.conversationType || '').toUpperCase() === 'GROUP'; try { if (isGroup) await api.post('/campus/chat/messages/group/' + encodeURIComponent(active.peerId), { content }); else await api.post('/campus/chat/messages/private', { peerId: active.peerId, content, messageType: 'TEXT' }); if (textarea) textarea.value = ''; await loadHistory(); showWebToast('消息已发送'); } catch (error) { showWebToast(/禁言|mute|禁止|无权限/i.test(String(error?.message || '')) ? '你已被禁言,暂时不能发言' : (error.message || '发送失败'), 'error'); } }; send?.addEventListener('click', sendMessage); textarea?.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); } }); const cachedConversations = readConversationCache(); if (Array.isArray(cachedConversations)) { conversations = cachedConversations; renderConvs(); } else { convList.replaceChildren(); const loading = document.createElement('p'); loading.className = 'web-live-empty'; loading.textContent = '正在加载会话…'; convList.appendChild(loading); } const conversationsRequest = window.__youthomeConversationsTask || api.get('/campus/chat/conversations'); conversationsRequest.then(async (list) => { conversations = list || []; try { sessionStorage.setItem(conversationCacheKey, JSON.stringify(conversations)); } catch (error) { /* ignore */ } await renderConvs(); }).catch((error) => { if (!cachedConversations) { convList.replaceChildren(); const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = error.message || '会话加载失败'; convList.appendChild(empty); } }); window.setInterval(() => { if (active && view && !view.hidden) loadHistory(); }, 3000); }; const initNotifications = () => { const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiNotifications(api); const items = Array.from(document.querySelectorAll('.message-item')); const categories = Array.from(document.querySelectorAll('.category-item')); const list = document.querySelector('.message-list'); if (!items.length || !categories.length || !list) return; const updateBadges = () => { categories.forEach((category) => { const name = category.querySelector('.category-name').textContent.trim(); const count = items.filter((item) => item.classList.contains('unread') && (name === '全部' || item.dataset.category === name)).length; const badge = category.querySelector('.category-badge'); badge.textContent = String(count); badge.classList.toggle('zero', count === 0); }); updateNotificationIndicators(); }; items.forEach((item) => { if (localStorage.getItem(notificationReadKey(item.dataset.notificationId)) === 'true') { item.classList.remove('unread'); item.querySelector('.message-dot')?.remove(); } const open = (event) => { event.preventDefault(); localStorage.setItem(notificationReadKey(item.dataset.notificationId), 'true'); item.classList.remove('unread'); item.querySelector('.message-dot')?.remove(); updateBadges(); location.href = item.getAttribute('href') || item.dataset.href; }; item.addEventListener('click', open); }); window.selectCategory = (element, updateUrl = true) => { categories.forEach((category) => category.classList.remove('active')); element.classList.add('active'); const name = element.querySelector('.category-name').textContent.trim(); if (updateUrl) { const params = new URLSearchParams(); if (name !== '全部') params.set('category', name); history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); } let visible = 0; items.forEach((item) => { item.hidden = name !== '全部' && item.dataset.category !== name; if (!item.hidden) visible += 1; }); list.querySelector('.web-empty-state')?.remove(); if (!visible) { const empty = document.createElement('div'); empty.className = 'web-empty-state'; empty.innerHTML = `
暂无${name}消息新消息会显示在这里。
`; list.appendChild(empty); } }; window.markAllRead = () => { items.forEach((item) => { localStorage.setItem(notificationReadKey(item.dataset.notificationId), 'true'); item.classList.remove('unread'); item.querySelector('.message-dot')?.remove(); }); updateBadges(); showWebToast('消息已全部标为已读'); }; categories.forEach((category) => makeKeyboardClickable(category, () => window.selectCategory(category))); updateBadges(); const requestedCategory = new URLSearchParams(location.search).get('category'); const selectedCategory = categories.find((category) => category.querySelector('.category-name').textContent.trim() === requestedCategory) || categories[0]; window.selectCategory(selectedCategory, false); }; const initSettings = () => { const api = window.YouthomeApi; const isLocal = api?.isLocal ? api.isLocal() : true; const navItems = Array.from(document.querySelectorAll('.settings-nav-item')); const panels = Array.from(document.querySelectorAll('[data-settings-panel]')); if (!navItems.length || !panels.length) return; const pageTitle = document.querySelector('.settings-content > .page-title'); const headingTitle = document.querySelector('.settings-page-heading .page-title') || pageTitle; const headingDescription = document.querySelector('.settings-page-description'); const copy = { account: ['账号安全', '管理登录方式、身份认证与账号访问安全。'], privacy: ['隐私设置', '决定谁可以看到你,以及其他用户如何与你联系。'], notifications: ['通知设置', '只保留真正重要的校园消息提醒。'], general: ['通用设置', '管理网页端播放偏好与本地存储。'], about: ['关于青核', '查看版本信息、帮助内容和服务协议。'] }; const renderPanel = (key, updateUrl = true) => { const selected = navItems.find((item) => item.dataset.settingsSection === key) || navItems[0]; const selectedKey = selected.dataset.settingsSection; navItems.forEach((item) => item.classList.toggle('active', item === selected)); panels.forEach((panel) => { panel.hidden = panel.dataset.settingsPanel !== selectedKey; }); if (headingTitle) headingTitle.textContent = copy[selectedKey][0]; if (headingDescription) headingDescription.textContent = copy[selectedKey][1]; localStorage.setItem('youthome:web:settings-section', selectedKey); if (updateUrl) history.replaceState(null, '', `${location.pathname}?section=${selectedKey}`); document.querySelector('.content-area')?.scrollTo({ top: 0, behavior: 'smooth' }); }; navItems.forEach((item, index) => { makeKeyboardClickable(item, () => item.click()); item.addEventListener('click', () => renderPanel(item.dataset.settingsSection)); }); const requestedSection = new URLSearchParams(location.search).get('section'); const storedSection = localStorage.getItem('youthome:web:settings-section'); renderPanel(requestedSection || storedSection || 'account', false); const settingsRows = Array.from(document.querySelectorAll('.setting-item')); const namedRow = (name) => settingsRows.find((row) => row.querySelector('.setting-name')?.textContent.trim() === name); if (!isLocal) { ['谁可以查看我的动态', '个性化推荐', '评论通知', '新粉丝通知'].forEach((name) => { const row = namedRow(name); if (row) row.style.display = 'none'; }); const socialName = namedRow('点赞通知')?.querySelector('.setting-name'); if (socialName) socialName.textContent = '社交通知'; const socialDesc = namedRow('社交通知')?.querySelector('.setting-desc'); if (socialDesc) socialDesc.textContent = '收到点赞、评论和关注时通知我'; } const remoteFields = { '允许陌生人私信': ['privacy', 'allowStrangerMessage'], '展示在线状态': ['privacy', 'onlineStatusVisible'], '社交通知': ['notifications', 'socialEnabled'], '活动提醒': ['notifications', 'activityEnabled'] }; const remoteState = {}; const remotePaths = { privacy: '/users/privacy', notifications: '/campus/notifications/preferences' }; document.querySelectorAll('.switch').forEach((toggle) => { const name = toggle.closest('.setting-item')?.querySelector('.setting-name')?.textContent.trim() || '设置'; const key = `youthome:web:setting:${name}`; const binding = !isLocal && remoteFields[name]; const stored = binding ? null : localStorage.getItem(key); if (stored !== null) toggle.classList.toggle('on', stored === 'true'); toggle.setAttribute('role', 'switch'); toggle.tabIndex = 0; toggle.setAttribute('aria-label', name); toggle.setAttribute('aria-checked', String(toggle.classList.contains('on'))); const change = async () => { if (toggle.dataset.busy === 'true') return; const enabled = !toggle.classList.contains('on'); if (binding) { const [kind, field] = binding; if (!remoteState[kind]) { showWebToast('设置尚未加载,请稍后重试', 'error'); return; } toggle.dataset.busy = 'true'; try { const payload = kind === 'notifications' ? Object.fromEntries(['pushEnabled', 'systemEnabled', 'socialEnabled', 'activityEnabled', 'chatEnabled', 'quietStart', 'quietEnd'] .filter((key) => Object.prototype.hasOwnProperty.call(remoteState[kind], key)) .map((key) => [key, key === field ? enabled : remoteState[kind][key]])) : { [field]: enabled }; remoteState[kind] = await api.request(remotePaths[kind], { method: 'PUT', body: JSON.stringify(payload) }); } catch (error) { showWebToast(error.message || '设置保存失败', 'error'); delete toggle.dataset.busy; return; } delete toggle.dataset.busy; } else localStorage.setItem(key, String(enabled)); toggle.classList.toggle('on', enabled); toggle.setAttribute('aria-checked', String(enabled)); showWebToast(`${name}已${enabled ? '开启' : '关闭'}`); }; toggle.addEventListener('click', change); toggle.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); change(); }); }); if (!isLocal) { Object.entries(remotePaths).forEach(async ([kind, path]) => { try { remoteState[kind] = await api.get(path); Object.entries(remoteFields).forEach(([name, [fieldKind, field]]) => { if (fieldKind !== kind || typeof remoteState[kind]?.[field] !== 'boolean') return; const toggle = namedRow(name)?.querySelector('.switch'); if (!toggle) return; toggle.classList.toggle('on', remoteState[kind][field]); toggle.setAttribute('aria-checked', String(remoteState[kind][field])); }); } catch (error) { showWebToast(error.message || '设置加载失败', 'error'); } }); } const rows = Array.from(document.querySelectorAll('.setting-item')); const rowByName = (name) => rows.find((row) => row.querySelector('.setting-name')?.textContent.trim() === name); const linkRow = (name, href) => { const row = rowByName(name); if (!row) return; row.addEventListener('click', (event) => { if (event.target.closest('button, .switch')) return; location.href = href; }); makeKeyboardClickable(row, () => { location.href = href; }); }; linkRow('登录密码', 'forgot-password.html'); linkRow('实名认证', 'authentication.html'); document.querySelector('[data-setting-action="devices"]')?.addEventListener('click', () => showWebToast('当前有 2 台设备保持登录')); document.querySelector('[data-setting-action="help"]')?.addEventListener('click', () => { location.href = 'help.html'; }); document.querySelector('[data-setting-action="agreement"]')?.addEventListener('click', () => { location.href = 'help.html?section=agreement'; }); document.querySelector('[data-setting-action="privacy-policy"]')?.addEventListener('click', () => { location.href = 'help.html?section=privacy'; }); const visibility = rowByName('谁可以查看我的动态'); const visibilityValues = ['所有人', '仅关注我的人', '仅自己']; if (isLocal) visibility?.addEventListener('click', () => { const value = visibility.querySelector('.setting-value'); const next = (visibilityValues.indexOf(value.textContent.trim()) + 1) % visibilityValues.length; value.textContent = visibilityValues[next]; localStorage.setItem('youthome:web:setting:visibility', visibilityValues[next]); showWebToast(`动态可见范围已设为“${visibilityValues[next]}”`); }); window.openChangePhoneModal = () => { const phone = window.prompt('请输入新的手机号(演示环境仅保存在本机)'); if (phone === null) return; if (!/^1\d{10}$/.test(phone.trim())) { showWebToast('请输入正确的 11 位手机号', 'error'); return; } rowByName('手机号').querySelector('.setting-value').textContent = `${phone.slice(0, 3)}****${phone.slice(-4)}`; localStorage.setItem('youthome:web:setting:phone', phone); showWebToast('手机号已更新'); }; window.openClearCacheModal = () => { if (!window.confirm('清理缓存只会重置已读状态和临时记录。你发布的动态、草稿、关注和登录状态都会保留,确定继续吗?')) return; // 只清理临时数据:账号、动态、草稿、关注、个人设置都属于用户数据,不能一起删掉 const transientPrefixes = ['youthome:web:notification-read:']; const transientKeys = ['youthome:web:checkedIn', 'youthome:web:walletCheckedIn']; const targets = Object.keys(localStorage).filter((key) => transientKeys.includes(key) || transientPrefixes.some((prefix) => key.startsWith(prefix))); targets.forEach((key) => localStorage.removeItem(key)); const value = rowByName('清理缓存')?.querySelector('.setting-value'); if (value) value.textContent = formatUsage(localUsage()); window.webRefreshNotificationIndicators?.(); showWebToast(targets.length ? `已清理 ${targets.length} 项临时缓存,动态与草稿已保留` : '没有可清理的临时缓存'); }; // 显示真实的本机存储占用,不再使用写死的数值 const localUsage = () => Object.keys(localStorage) .filter((key) => key.startsWith('youthome:web:')) .reduce((bytes, key) => bytes + (localStorage.getItem(key) || '').length * 2, 0); const formatUsage = (bytes) => (bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`); const cacheValue = rowByName('清理缓存')?.querySelector('.setting-value'); if (cacheValue) cacheValue.textContent = formatUsage(localUsage()); const savedPhone = localStorage.getItem('youthome:web:setting:phone'); if (savedPhone) rowByName('手机号').querySelector('.setting-value').textContent = `${savedPhone.slice(0, 3)}****${savedPhone.slice(-4)}`; const savedVisibility = localStorage.getItem('youthome:web:setting:visibility'); if (savedVisibility) visibility.querySelector('.setting-value').textContent = savedVisibility; }; const appendFavoriteCards = (grid, groups, templateNode) => { if (!grid) return; const template = templateNode || grid.querySelector('.post-mini-card'); if (!template) return; const relative = (window.YouthomeLocal?.relative) || (() => '刚刚'); for (const group of groups) { for (const moment of group.items) { const key = `${group.kind}:${moment.id}`; if (grid.querySelector(`.post-mini-card[data-key="${key}"]`)) continue; const card = template.cloneNode(true); card.dataset.kind = group.kind; card.dataset.moment = moment.id; card.dataset.key = key; card.classList.add('web-live-card'); const href = `post-detail.html?moment=${encodeURIComponent(moment.id)}`; card.dataset.href = href; card.setAttribute('onclick', `location.href='${href}'`); const avatar = card.querySelector('.post-avatar'); if (avatar) avatar.textContent = (moment.userName || '青').slice(0, 1); const name = card.querySelector('.post-user-name'); if (name) name.textContent = moment.userName || '青核用户'; const time = card.querySelector('.post-time'); if (time) time.textContent = `${group.label} ${relative(moment.createdAt)}`; const content = card.querySelector('.post-mini-content'); if (content) content.textContent = moment.content; const images = card.querySelector('.post-mini-images'); if (images) { if (moment.mediaUrls?.length) { images.replaceChildren(...moment.mediaUrls.slice(0, 3).map((url) => { const img = document.createElement('img'); img.src = url; img.alt = ''; return img; })); } else { card.classList.add('web-text-card'); images.remove(); } } const stats = card.querySelectorAll('.post-footer-item span'); if (stats[0]) stats[0].textContent = String(moment.likeCount || 0); if (stats[1]) stats[1].textContent = String(moment.commentCount || 0); const collect = card.querySelector('.post-footer-collect'); if (collect && group.kind !== 'favorite') collect.remove(); grid.insertBefore(card, grid.firstElementChild); } } }; const injectRealFavorites = (grid, store) => { if (!grid || !store?.account?.()) return; appendFavoriteCards(grid, [ { kind: 'favorite', label: '收藏于', items: store.bucketMoments('favorites') }, { kind: 'like', label: '点赞于', items: store.bucketMoments('likes') }, { kind: 'view', label: '浏览于', items: store.viewedMoments() } ]); }; const initFavorites = async () => { const tabs = Array.from(document.querySelectorAll('.tab-bar .tab-item')); const grid = document.querySelector('.favorites-grid'); if (!tabs.length || !grid) return; const api = window.YouthomeApi; const isLocal = api?.isLocal ? api.isLocal() : true; if (isLocal) { injectRealFavorites(grid, window.YouthomeLocal); } else { const template = grid.querySelector('.post-mini-card')?.cloneNode(true); grid.replaceChildren(); try { const items = await api.favorites(); appendFavoriteCards(grid, [{ kind: 'favorite', label: '收藏于', items }], template); } catch (error) { showWebToast(error.message || '收藏加载失败', 'error'); } if (!grid.querySelector('.post-mini-card')) { addEmptyState(grid, '还没有收藏内容', '在动态详情里点收藏,内容会出现在这里。'); } } const cards = Array.from(grid.querySelectorAll('.post-mini-card')); const subsets = { 收藏: cards.filter((card) => card.dataset.kind !== 'like' && card.dataset.kind !== 'view'), 喜欢: cards.filter((card) => card.dataset.kind !== 'favorite' && card.dataset.kind !== 'view'), 浏览历史: cards.filter((card) => card.dataset.kind !== 'favorite' && card.dataset.kind !== 'like') }; let activeLabel = tabs[0].textContent.trim(); const render = (label) => { activeLabel = label; const visible = new Set((subsets[label] || cards).filter((card) => card.dataset.removed !== 'true')); cards.forEach((card) => { card.hidden = !visible.has(card); }); grid.querySelector('.web-empty-state')?.remove(); if (!cards.some((card) => !card.hidden)) addEmptyState(grid, `暂无${label}内容`, '浏览或收藏动态后,相关内容会出现在这里。'); const subtitle = document.querySelector('.page-subtitle'); if (subtitle) subtitle.textContent = `${label}内容,共 ${cards.filter((card) => !card.hidden).length} 条`; const params = new URLSearchParams(); if (label !== '收藏') params.set('tab', label); history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { setPressed(tabs, tab); render(tab.textContent.trim()); }); }); cards.forEach((card) => makeLinkedCard(card, card.dataset.href || 'post-detail.html')); grid.querySelectorAll('.post-footer-collect').forEach((button) => { makeKeyboardClickable(button, () => button.click()); button.setAttribute('aria-label', '取消收藏'); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const card = button.closest('.post-mini-card'); if (card?.dataset.kind === 'favorite' && card.dataset.moment) { try { window.YouthomeLocal.removeFromBucket('favorites', card.dataset.moment); } catch (error) { showWebToast(error.message || '操作失败,请重试', 'error'); return; } } card.dataset.removed = 'true'; card.hidden = true; showWebToast('已取消收藏'); render(activeLabel); }); }); const requested = new URLSearchParams(location.search).get('tab'); const selected = tabs.find((tab) => tab.textContent.trim() === requested) || tabs[0]; setPressed(tabs, selected); render(selected.textContent.trim()); }; const initDrafts = () => { if (document.querySelector('script[src*="web-publishing.js"]')) return; const cards = Array.from(document.querySelectorAll('.draft-card')); if (!cards.length) return; const removeCards = (targets) => { targets.forEach((card) => card.remove()); showWebToast(`已删除 ${targets.length} 条草稿`); if (!document.querySelector('.draft-card')) addEmptyState(document.querySelector('.draft-list') || cards[0].parentElement, '草稿箱为空', '未发布的内容会自动保存在这里。'); }; cards.forEach((card) => { card.querySelector('.draft-action-publish')?.addEventListener('click', () => { showWebToast('草稿已发布'); card.remove(); }); card.querySelector('.draft-action-delete')?.addEventListener('click', () => { if (window.confirm('确定删除这条草稿吗?')) removeCards([card]); }); }); const bulkButtons = document.querySelectorAll('.action-bar-right .btn'); bulkButtons[0]?.addEventListener('click', () => { const selected = Array.from(document.querySelectorAll('.draft-card.selected')); selected.forEach((card) => card.remove()); if (selected.length) showWebToast(`已发布 ${selected.length} 条草稿`); }); bulkButtons[1]?.addEventListener('click', () => { const selected = Array.from(document.querySelectorAll('.draft-card.selected')); if (selected.length && window.confirm(`确定删除选中的 ${selected.length} 条草稿吗?`)) removeCards(selected); }); }; const injectFollowedUsers = (grid, store) => { if (!grid || !store?.account?.()) return; const template = grid.querySelector('.user-card'); if (!template) return; const listed = new Set(Array.from(grid.querySelectorAll('.user-name')).map((node) => node.textContent.trim())); store.following().forEach((name, index) => { if (!name || listed.has(name)) return; const card = template.cloneNode(true); card.dataset.kind = 'real-follow'; card.classList.add('web-live-card'); const avatar = card.querySelector('.user-avatar'); if (avatar) { const file = String((index % 10) + 1).padStart(2, '0'); avatar.innerHTML = `${name}`; } const nameNode = card.querySelector('.user-name'); if (nameNode) nameNode.textContent = name; const desc = card.querySelector('.user-desc'); if (desc) desc.textContent = '校园用户'; const button = card.querySelector('.follow-btn'); if (button) { button.className = 'follow-btn followed'; button.innerHTML = '已关注取消关注'; } grid.insertBefore(card, grid.firstElementChild); }); }; const injectApiFollowing = async (grid, api, templateNode) => { if (!grid) return; const template = templateNode || grid.querySelector('.user-card'); if (!template) return; let users = []; try { users = await api.following(); } catch (error) { showWebToast(error.message || '关注列表加载失败', 'error'); return; } const listed = new Set(Array.from(grid.querySelectorAll('.user-name')).map((node) => node.textContent.trim())); users.forEach((user) => { const nickname = user.nickname || user.userName || '校园用户'; if (listed.has(nickname)) return; const card = template.cloneNode(true); card.dataset.kind = 'api-follow'; card.dataset.userId = user.userId || user.id || ''; const avatar = card.querySelector('.user-avatar'); if (avatar) { const url = api.avatarUrl?.(user.avatar) || ''; avatar.textContent = ''; if (url) { const img = document.createElement('img'); img.src = url; img.alt = nickname; avatar.appendChild(img); } else { avatar.textContent = nickname.slice(0, 1); } } const nameNode = card.querySelector('.user-name'); if (nameNode) nameNode.textContent = nickname; const desc = card.querySelector('.user-desc'); if (desc) desc.textContent = user.universityName || '校园用户'; const button = card.querySelector('.follow-btn'); if (button) { button.className = 'follow-btn followed'; button.innerHTML = '已关注取消关注'; button.addEventListener('click', async (event) => { event.stopPropagation(); try { await api.toggleFollow(card.dataset.userId); card.remove(); showWebToast('已取消关注'); } catch (error) { showWebToast(error.message || '取关失败', 'error'); } }); } grid.insertBefore(card, grid.firstElementChild); }); }; const initFollow = async () => { const grid = document.querySelector('.user-grid'); const tabs = Array.from(document.querySelectorAll('.follow-tab')); const inputs = Array.from(document.querySelectorAll('.search-input, .filter-search input')); const filterGroups = Array.from(document.querySelectorAll('.filter-group')); if (!grid) return; const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) { const template = grid.querySelector('.user-card')?.cloneNode(true); grid.replaceChildren(); await injectApiFollowing(grid, api, template); if (!grid.querySelector('.user-card')) { addEmptyState(grid, '还没有关注任何人', '在动态广场的推荐里关注感兴趣的人。'); return; } } else { injectFollowedUsers(grid, window.YouthomeLocal); } const cards = Array.from(grid.querySelectorAll('.user-card')); if (!cards.length) return; const groups = ['其他学校', '同学', '同学', '其他学校', '其他学校', '社团好友']; cards.forEach((card, index) => { card.dataset.group = card.dataset.group || groups[index] || '同学'; card.dataset.popularity = card.dataset.popularity || String([78, 92, 66, 83, 55, 72][index] || 50); const name = card.querySelector('.user-name')?.textContent.trim(); const userId = card.dataset.userId; if (userId) makeLinkedCard(card, `user.html?id=${encodeURIComponent(userId)}`); else if (name) makeLinkedCard(card, `user.html?name=${encodeURIComponent(name)}`); }); let activeTab = '关注'; let activeGroup = '全部关注'; let activeSort = '最近关注'; let query = ''; const render = () => { let matched = cards.filter((card) => { const button = card.querySelector('.follow-btn'); const stateMatch = activeTab === '全部' || (activeTab === '关注' && !button.classList.contains('follow')) || (activeTab === '粉丝' && (button.classList.contains('mutual') || Number(card.dataset.popularity) > 75)) || (activeTab === '互相关注' && button.classList.contains('mutual')); return stateMatch && (activeGroup === '全部关注' || card.dataset.group === activeGroup) && (!query || card.textContent.toLowerCase().includes(query)); }); if (activeSort === '粉丝最多') matched.sort((a, b) => Number(b.dataset.popularity) - Number(a.dataset.popularity)); if (activeSort === '最活跃') matched.reverse(); cards.forEach((card) => { card.hidden = true; }); matched.forEach((card) => { card.hidden = false; grid.appendChild(card); }); grid.querySelector('.web-empty-state')?.remove(); if (!matched.length) addEmptyState(grid, '没有找到用户', '调整分组、关系类型或搜索关键词。'); const count = document.querySelector('.follow-count b'); if (count) count.textContent = String(matched.length); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { activeTab = tab.childNodes[0].textContent.trim(); setPressed(tabs, tab); render(); }); }); const groupOptions = Array.from(filterGroups[0]?.querySelectorAll('.filter-option') || []); groupOptions.forEach((option) => { makeKeyboardClickable(option, () => option.click()); option.addEventListener('click', () => { activeGroup = option.querySelector('span')?.textContent.trim(); setPressed(groupOptions, option); render(); }); }); const sortOptions = Array.from(filterGroups[1]?.querySelectorAll('.filter-option') || []); sortOptions.forEach((option) => { makeKeyboardClickable(option, () => option.click()); option.addEventListener('click', () => { activeSort = option.textContent.trim(); setPressed(sortOptions, option); render(); }); }); inputs.forEach((input) => input.addEventListener('input', () => { query = input.value.trim().toLowerCase(); inputs.forEach((other) => { if (other !== input) other.value = input.value; }); render(); })); window.addFriend = () => { location.href = 'search.html?type=user'; }; const originalToggle = window.toggleFollow; window.toggleFollow = (button) => { originalToggle?.(button); const card = button.closest('.user-card'); const name = card?.querySelector('.user-name')?.textContent.trim(); const following = button.classList.contains('followed') || button.classList.contains('mutual'); if (name && window.YouthomeLocal?.setFollowing) window.YouthomeLocal.setFollowing(name, following); if (card?.dataset.kind === 'real-follow' && !following) { const index = cards.indexOf(card); if (index >= 0) cards.splice(index, 1); card.remove(); showWebToast('已取消关注'); } window.setTimeout(render, 0); }; const params = new URLSearchParams(location.search); const requestedTab = params.get('tab'); const selectedTab = tabs.find((tab) => tab.childNodes[0].textContent.trim() === requestedTab); if (selectedTab) { activeTab = requestedTab; setPressed(tabs, selectedTab); } query = (params.get('q') || '').trim().toLowerCase(); inputs.forEach((input) => { input.value = params.get('q') || ''; }); render(); }; const injectApiActivities = async (list, api, templateNode) => { const template = templateNode || list?.querySelector('.activity-card'); if (!list || !template) return; const [joinedResult, registrationsResult] = await Promise.allSettled([api.joinedActivities(), api.registrations()]); if (joinedResult.status === 'rejected' && registrationsResult.status === 'rejected') { showWebToast('报名记录加载失败,请稍后重试', 'error'); return; } const joined = joinedResult.status === 'fulfilled' ? joinedResult.value : []; const registrations = (registrationsResult.status === 'fulfilled' ? registrationsResult.value : []) .filter((item) => !['CANCELLED', 'REMOVED'].includes(String(item.status || '').toUpperCase())); const covered = new Set(registrations.map((item) => item.activityId)); const details = await Promise.all(registrations.map((item) => api.get('/campus/activities/detail?id=' + encodeURIComponent(item.activityId)).catch(() => null))); const records = [ ...registrations.map((item, index) => ({ kind: 'registration', activityId: item.activityId, registrationId: item.id, registration: item, activity: details[index] || {} })), ...joined.filter((item) => !covered.has(item.id)).map((item) => ({ kind: 'joined', activityId: item.id, activity: item })), ...api.applicationRecords().filter((item) => !covered.has(item.activityId) && !joined.some((activity) => activity.id === item.activityId)).map((item) => ({ kind: 'application', activityId: item.activityId, activity: item })) ]; joined.forEach((item) => api.removeApplication(item.id)); records.forEach((record) => { const item = record.activity; const card = template.cloneNode(true); card.dataset.joinedId = record.activityId; card.dataset.kind = record.kind; if (record.registrationId) card.dataset.registrationId = record.registrationId; const status = card.querySelector('.status-tag'); const ended = item.endTime && Date.parse(String(item.endTime).replace(/-/g, '/')) < Date.now(); const registrationStatus = String(record.registration?.status || '').toUpperCase(); const reviewStatus = String(record.registration?.reviewStatus || '').toUpperCase(); const pendingPayment = registrationStatus === 'PENDING_PAYMENT'; const pendingReview = record.kind === 'application' || reviewStatus === 'PENDING'; const rejected = reviewStatus === 'REJECTED' || registrationStatus === 'REJECTED'; card.dataset.statusGroup = rejected ? 'rejected' : ended ? 'ended' : pendingPayment || pendingReview ? 'pending' : 'approved'; if (status) { status.className = 'status-tag ' + (card.dataset.statusGroup === 'pending' ? 'status-pending' : 'status-approved'); status.textContent = ended ? '已结束' : pendingPayment ? '待支付' : rejected ? '已拒绝' : pendingReview ? '待审核' : '已通过'; } const title = card.querySelector('.activity-title'); if (title) title.textContent = item.title || '未命名活动'; const meta = card.querySelectorAll('.meta-item span'); if (meta[0]) meta[0].textContent = [item.startTime, item.endTime].filter(Boolean).join(' - ') || '以主办方通知为准'; if (meta[1]) meta[1].textContent = item.location || '待公布'; if (meta[2]) meta[2].textContent = (item.currentCount ?? 1) + '/' + (item.maxCount ?? '—') + '人'; const bottom = card.querySelector('.activity-info-bottom'); if (bottom) bottom.textContent = record.kind === 'application' ? '本机保存的申请回执,审核结果以通知为准' : record.registration?.createdAt ? '报名时间:' + record.registration.createdAt : item.creatorName ? '发起人:' + item.creatorName : ''; if (record.kind === 'application') card.querySelector('.activity-actions .btn')?.remove(); list.insertBefore(card, list.firstElementChild); }); }; const initMyActivities = async () => { const list = document.querySelector('.activity-list'); const tabs = Array.from(document.querySelectorAll('.apply-tab')); const input = document.querySelector('.search-input'); const store = window.YouthomeLocal; const api = window.YouthomeApi; const isLocal = api?.isLocal ? api.isLocal() : true; if (!isLocal) { const template = list?.querySelector('.activity-card')?.cloneNode(true); list?.replaceChildren(); await injectApiActivities(list, api, template); if (list && !list.querySelector('.activity-card')) { addEmptyState(list, '还没有报名活动', '在活动页报名后,记录会出现在这里。'); return; } } const template = list?.querySelector('.activity-card'); if (isLocal && list && template && store?.account?.()) { store.joinedActivities().forEach((record) => { const card = template.cloneNode(true); card.dataset.joinedId = record.id; card.classList.add('web-live-card'); const status = card.querySelector('.status-tag'); if (status) { status.className = 'status-tag status-pending'; status.textContent = '待审核'; } const title = card.querySelector('.activity-title'); if (title) title.textContent = record.title; const meta = card.querySelectorAll('.meta-item span'); if (meta[0]) meta[0].textContent = record.time || '以主办方通知为准'; if (meta[1]) meta[1].textContent = record.place || '待公布'; if (meta[2]) meta[2].textContent = '1/200人'; const bottom = card.querySelector('.activity-info-bottom'); if (bottom) bottom.textContent = `报名时间:${record.joinedAt}`; list.insertBefore(card, list.firstElementChild); }); } const cards = Array.from(document.querySelectorAll('.activity-card')); if (!tabs.length || !cards.length || !list) return; const stateOf = (card) => card.dataset.statusGroup || (card.querySelector('.status-pending') ? 'pending' : 'approved'); let state = 'all'; let query = ''; const refreshCounts = () => { tabs.forEach((tab) => { const badge = tab.querySelector('.tab-count'); if (!badge) return; const total = cards.filter((card) => card.isConnected && (tab.dataset.tab === 'all' || stateOf(card) === tab.dataset.tab)).length; badge.textContent = `(${total})`; }); }; const render = () => { const matched = cards.filter((card) => card.isConnected && (state === 'all' || stateOf(card) === state) && (!query || card.textContent.toLowerCase().includes(query))); cards.forEach((card) => { card.hidden = !matched.includes(card); }); list.querySelector('.web-empty-state')?.remove(); if (!matched.length) addEmptyState(list, '没有符合条件的报名记录', '切换状态或更换搜索关键词。'); refreshCounts(); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { state = tab.dataset.tab; setPressed(tabs, tab); render(); }); }); input?.addEventListener('input', () => { query = input.value.trim().toLowerCase(); render(); }); document.querySelector('.filter-btn')?.addEventListener('click', () => { const order = ['all', 'pending', 'approved', 'ended']; state = order[(order.indexOf(state) + 1) % order.length]; const tab = tabs.find((item) => item.dataset.tab === state); if (tab) { setPressed(tabs, tab); render(); } }); cards.forEach((card) => { card.querySelectorAll('.btn, .text-link, .text-link-muted').forEach((control) => { const label = control.textContent.trim(); makeKeyboardClickable(control, () => control.click()); control.addEventListener('click', async (event) => { event.stopPropagation(); if (label.includes('查看详情') || label.includes('活动回顾')) location.href = card.dataset.joinedId ? 'activity-detail.html?id=' + encodeURIComponent(card.dataset.joinedId) : 'activity-detail.html'; else if (label.includes('入场码')) showWebToast('入场码已打开,请在活动现场出示'); else if (label.includes('取消报名') && window.confirm('确定取消该活动报名吗?')) { if (card.dataset.joinedId) { try { if (!isLocal && window.YouthomeApi) { if (card.dataset.registrationId) await window.YouthomeApi.cancelRegistration(card.dataset.registrationId); else await window.YouthomeApi.quitActivity(card.dataset.joinedId); } else window.YouthomeLocal.leaveActivity(card.dataset.joinedId); } catch (error) { showWebToast(error.message || '操作失败,请重试', 'error'); return; } } card.remove(); showWebToast('报名已取消'); render(); } }); }); makeLinkedCard(card, card.dataset.joinedId ? 'activity-detail.html?id=' + encodeURIComponent(card.dataset.joinedId) : 'activity-detail.html'); }); render(); }; const initProfile = () => { if (window.YouthomeApi?.isLocal && !window.YouthomeApi.isLocal()) return; const grid = document.querySelector('.content-grid'); if (grid) { const postTemplate = grid.querySelector('.post-card'); ['milk-tea', 'canteen'].forEach((id) => { const post = notificationPosts[id]; const card = postTemplate.cloneNode(true); card.classList.add('notification-work-card'); card.dataset.post = id; card.dataset.href = `post-detail.html?post=${encodeURIComponent(id)}`; card.querySelector('.card-title').textContent = post.title; const stats = card.querySelectorAll('.card-stat'); stats[0].lastChild.textContent = ` ${post.likes}`; stats[1].lastChild.textContent = ` ${post.comments.length}`; card.querySelectorAll('.post-img-cell').forEach((cell) => cell.style.removeProperty('background')); grid.prepend(card); }); } document.querySelector('.btn-edit')?.addEventListener('click', () => { location.href = 'me-edit.html'; }); document.querySelector('[data-profile-action="cover"]')?.addEventListener('click', () => { location.href = 'me-edit.html'; }); const share = document.querySelector('.btn-share'); share?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(location.href); showWebToast('主页链接已复制'); } catch (error) { showWebToast('可从浏览器地址栏复制主页链接'); } }); const moreButton = document.querySelector('[data-profile-action="more"]'); const moreMenu = document.querySelector('.profile-more-menu'); moreButton?.addEventListener('click', (event) => { event.stopPropagation(); const expanded = moreButton.getAttribute('aria-expanded') !== 'true'; moreButton.setAttribute('aria-expanded', String(expanded)); if (moreMenu) moreMenu.hidden = !expanded; }); document.addEventListener('click', () => { moreButton?.setAttribute('aria-expanded', 'false'); if (moreMenu) moreMenu.hidden = true; }); moreMenu?.addEventListener('click', (event) => event.stopPropagation()); moreMenu?.querySelector('[data-profile-menu="copy"]')?.addEventListener('click', () => share?.click()); moreMenu?.querySelector('[data-profile-menu="settings"]')?.addEventListener('click', () => { location.href = 'settings.html'; }); const tabs = Array.from(document.querySelectorAll('.profile-tab')); const destinations = ['me.html', 'favorites.html', 'favorites.html?tab=喜欢', 'favorites.html?tab=浏览历史']; tabs.forEach((tab, index) => { makeKeyboardClickable(tab, () => { location.href = destinations[index]; }); tab.addEventListener('click', () => { location.href = destinations[index]; }); }); const statTargets = { 关注: 'follow.html?tab=关注', 粉丝: 'follow.html?tab=粉丝', 积分: 'wallet.html' }; document.querySelectorAll('.stat-item').forEach((item) => { const label = item.querySelector('.stat-label')?.textContent.trim(); if (statTargets[label]) makeLinkedCard(item, statTargets[label]); else makeKeyboardClickable(item, () => showWebToast(`${label}数据会随内容互动实时更新`)); }); document.querySelectorAll('.content-card').forEach((card) => { const isVideo = card.querySelector('.card-badge')?.textContent.includes('视频'); makeLinkedCard(card, card.dataset.href || (isVideo ? 'video-detail.html' : 'post-detail.html')); }); document.querySelector('.level-display')?.addEventListener('click', () => { location.href = 'wallet.html'; }); const checkin = document.querySelector('.checkin-btn'); if (checkin) { const checked = localStorage.getItem('youthome:web:checkedIn') === 'true'; if (checked) { checkin.disabled = true; checkin.textContent = '今日已签到'; } checkin.addEventListener('click', () => { localStorage.setItem('youthome:web:checkedIn', 'true'); checkin.disabled = true; checkin.textContent = '今日已签到'; showWebToast('签到成功,积分 +5'); }); } }; const initProfileEdit = () => { const fields = Array.from(document.querySelectorAll('.edit-form input:not([disabled]), .edit-form select, .edit-form textarea')); const nickname = fields.find((field) => field.previousElementSibling?.textContent.includes('昵称')); const bio = document.querySelector('.form-textarea'); const previewName = document.querySelector('.preview-name'); const save = Array.from(document.querySelectorAll('.form-footer .btn')).find((button) => button.textContent.includes('保存')); const updatePreview = () => { if (previewName && nickname) previewName.textContent = nickname.value.trim() || '未设置昵称'; const hint = bio?.nextElementSibling; if (hint && bio) hint.textContent = `${bio.value.length}/200`; }; const account = window.YouthomeApi?.currentUser ? window.YouthomeApi.currentUser() : window.YouthomeLocal?.currentUser?.(); if (account) { if (nickname && account.nickname) nickname.value = account.nickname; if (bio && account.bio) bio.value = account.bio; } fields.forEach((field) => field.addEventListener('input', updatePreview)); save?.addEventListener('click', async () => { if (!nickname || nickname.value.trim().length < 2) { showWebToast('昵称至少需要 2 个字符', 'error'); nickname?.focus(); return; } const api = window.YouthomeApi; const isLocal = api?.isLocal ? api.isLocal() : true; const patchData = { nickname: nickname.value.trim(), bio: bio?.value.trim() || '' }; if (isLocal) { if (!window.YouthomeLocal?.token() || !window.YouthomeLocal?.currentUser()) { showWebToast('请先登录后再修改资料', 'error'); window.setTimeout(() => { location.href = 'login.html?next=me-edit.html'; }, 700); return; } try { window.YouthomeLocal.updateProfile(patchData); showWebToast('资料已保存'); window.setTimeout(() => { location.href = 'me.html'; }, 500); } catch (error) { showWebToast(error.message || '保存失败,请重试', 'error'); } return; } if (!api?.token?.()) { showWebToast('请先登录后再修改资料', 'error'); window.setTimeout(() => { location.href = 'login.html?next=me-edit.html'; }, 700); return; } try { const me = api.currentUser() || {}; await api.request('/profile', { method: 'PUT', body: JSON.stringify({ ...me, ...patchData }) }); api.updateSessionUser(patchData); showWebToast('资料已保存'); window.setTimeout(() => { location.href = 'me.html'; }, 500); } catch (error) { showWebToast(error.message || '保存失败,请重试', 'error'); } }); // 头像上传:本地压缩后存进账号,不再是一句“待接入” const avatarPreview = document.querySelector('.avatar-preview'); const avatarEditIcon = avatarPreview?.querySelector('.avatar-edit-icon'); const avatarFile = document.createElement('input'); avatarFile.type = 'file'; avatarFile.accept = 'image/*'; avatarFile.hidden = true; document.body.appendChild(avatarFile); const setAvatarPreview = (url) => { if (!avatarPreview) return; avatarPreview.textContent = ''; if (url) { const img = document.createElement('img'); img.src = url; img.alt = '头像预览'; avatarPreview.appendChild(img); } if (avatarEditIcon) avatarPreview.appendChild(avatarEditIcon); }; const pickAvatar = () => { if (!window.YouthomeLocal?.token() || !window.YouthomeLocal?.currentUser()) { showWebToast('请先登录后再上传头像', 'error'); return; } avatarFile.click(); }; document.querySelector('.avatar-info .btn')?.addEventListener('click', pickAvatar); document.querySelector('.avatar-edit-icon')?.addEventListener('click', pickAvatar); avatarFile.addEventListener('change', async () => { const file = avatarFile.files?.[0]; avatarFile.value = ''; if (!file) return; if (!file.type.startsWith('image/')) { showWebToast('请选择图片文件', 'error'); return; } if (file.size > 5 * 1024 * 1024) { showWebToast('图片不能超过 5MB', 'error'); return; } try { const api = window.YouthomeApi; const isLocal = api?.isLocal ? api.isLocal() : true; const form = new FormData(); form.append('file', file); let url = ''; if (isLocal) { const uploaded = await window.YouthomeLocal.request('/file/upload/image', { method: 'POST', body: form }); window.YouthomeLocal.updateProfile({ avatar: uploaded.url }); url = uploaded.url; } else { const result = await api.request('/profile/avatar', { method: 'POST', body: form }); url = result?.url || result?.avatar || result?.avatarUrl || ''; if (url) api.updateSessionUser({ avatar: url }); } if (url) setAvatarPreview(url); window.webApplyAccountIdentity?.(); showWebToast('头像已更新'); } catch (error) { showWebToast(error.message || '头像上传失败,请重试', 'error'); } }); const currentAvatar = window.YouthomeApi?.currentUser ? window.YouthomeApi.currentUser()?.avatar : window.YouthomeLocal?.currentUser?.()?.avatar; if (window.YouthomeApi?.avatarUrl) { const url = window.YouthomeApi.avatarUrl(currentAvatar); if (url) setAvatarPreview(url); } else if (currentAvatar) { setAvatarPreview(currentAvatar); } updatePreview(); }; const initApiChat = async (api) => { const convLists = [...document.querySelectorAll('.conv-items')]; const convList = convLists.at(-1); const messages = document.querySelector('.chat-messages'); const textarea = document.querySelector('.chat-textarea'); const send = document.querySelector('.send-btn'); if (!convList || !messages) return; const template = convList.querySelector('.conv-item')?.cloneNode(true); if (!template) return; convLists.slice(0, -1).forEach((node) => node.remove()); document.querySelectorAll('.conv-section').forEach((node) => node.remove()); document.querySelector('.info-panel')?.remove(); document.querySelector('.chat-toolbar')?.remove(); document.querySelector('.chat-header-right')?.replaceChildren(); document.querySelector('.chat-header-info .status')?.remove(); document.querySelector('.chat-header-avatar')?.replaceChildren(); document.querySelector('.chat-header-info .name').textContent = '选择会话'; convList.replaceChildren(); addEmptyState(convList, '正在加载会话…', ''); messages.replaceChildren(); if (textarea) textarea.disabled = true; if (send) send.disabled = true; const me = api.currentUser() || {}; const requestedPeer = new URLSearchParams(location.search).get('peer'); const profiles = {}; let conversations = []; let active = null; let historyRequestId = 0; const nameOf = async (conv) => { if (conv.groupName || conv.peerName) return conv.groupName || conv.peerName; // 群会话的 peerId 不是用户 ID,不能拿去查用户资料(后端会 500) if (String(conv.conversationType || '').toUpperCase() === 'GROUP') return '群聊'; if (!conv.peerId) return '会话'; if (profiles[conv.peerId]) return profiles[conv.peerId].nickname || '用户'; try { const profile = await api.get('/users/profile?userId=' + encodeURIComponent(conv.peerId)); profiles[conv.peerId] = profile; return profile?.nickname || '用户'; } catch (error) { return '用户'; } }; const renderConversations = async () => { const nodes = []; for (const conv of conversations) { const node = template.cloneNode(true); const name = await nameOf(conv); const nameNode = node.querySelector('.conv-name'); if (nameNode) nameNode.textContent = name; const avatar = node.querySelector('.conv-avatar'); if (avatar) { avatar.replaceChildren(); const url = api.avatarUrl(conv.peerAvatar); if (url) { const image = document.createElement('img'); image.src = url; image.alt = ''; avatar.appendChild(image); } else avatar.textContent = name.slice(0, 1); } node.querySelector('.conv-online-dot')?.remove(); const preview = node.querySelector('.conv-preview'); if (preview) preview.textContent = conv.lastMessage || ''; const time = node.querySelector('.conv-time'); if (time) time.textContent = String(conv.lastMessageTime || '').slice(5, 16); const unread = node.querySelector('.conv-unread'); if (unread) { unread.textContent = String(conv.unreadCount || 0); unread.hidden = !conv.unreadCount; } node.classList.toggle('active', Boolean(active && active.id === conv.id)); node.addEventListener('click', async () => { const selectedId = conv.id; const requestId = ++historyRequestId; active = conv; await renderConversations(); await syncHeader(selectedId, requestId); await loadHistory(conv, requestId); }); nodes.push(node); } convList.replaceChildren(...nodes); if (!nodes.length) addEmptyState(convList, '还没有会话', '和好友聊聊或加入群组后,会话会出现在这里。'); const search = document.querySelector('.conv-search .search-input'); if (search?.value.trim()) { const keyword = search.value.trim().toLowerCase(); nodes.forEach((node) => { node.hidden = !node.textContent.toLowerCase().includes(keyword); }); } }; const refreshConversations = async () => { try { const list = (await api.get('/campus/chat/conversations')) || []; const previous = active; const activeId = previous?.id; conversations = list; if (String(activeId || '').startsWith('new-private-') && !conversations.some((item) => item.peerId === previous.peerId && String(item.conversationType).toUpperCase() === 'PRIVATE')) { conversations.unshift(previous); } if (activeId) active = conversations.find((item) => item.id === activeId) || conversations.find((item) => item.peerId === previous.peerId && item.conversationType === previous.conversationType) || null; if (!active && conversations.length) active = conversations[0]; await renderConversations(); await syncHeader(); if (!active) messages.replaceChildren(); } catch (error) { /* 轮询失败不打扰用户 */ } }; const loadHistory = async (conversation = active, requestId = ++historyRequestId) => { if (!conversation) return; const isGroup = String(conversation.conversationType || '').toUpperCase() === 'GROUP'; const path = isGroup ? `/campus/chat/history/group/${encodeURIComponent(conversation.peerId)}?page=1&size=50` : `/campus/chat/history/private/${encodeURIComponent(conversation.peerId)}?page=1&size=50`; let items = []; try { const data = await api.get(path); items = data?.items || []; } catch (error) { showWebToast(error.message || '聊天记录加载失败', 'error'); return; } if (requestId !== historyRequestId || active?.id !== conversation.id) return; messages.replaceChildren(...items.map((item) => { const self = item.senderId === me.id; const row = document.createElement('div'); row.className = 'msg-row' + (self ? ' self' : ''); const who = self ? (me.nickname || '我') : (item.senderGroupNickname || item.senderName || '对方'); row.innerHTML = '
'; row.querySelector('.msg-avatar span').textContent = who.slice(0, 1); row.querySelector('.msg-meta span').textContent = item.createdAt || ''; row.querySelector('.msg-bubble').textContent = item.content || ''; return row; })); messages.scrollTop = messages.scrollHeight; }; const sendMessage = async () => { const content = textarea?.value.trim(); if (!content || !active) return; const isGroup = String(active.conversationType || '').toUpperCase() === 'GROUP'; try { if (isGroup) await api.post('/campus/chat/messages/group/' + encodeURIComponent(active.peerId), { content }); else await api.post('/campus/chat/messages/private', { peerId: active.peerId, content, messageType: 'TEXT' }); if (textarea) textarea.value = ''; await loadHistory(); showWebToast('消息已发送'); } catch (error) { showWebToast(error.message || '发送失败', 'error'); } }; send?.addEventListener('click', sendMessage); textarea?.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); } }); // 环信 v4 实时消息:收到即刷新;连不上时保持 REST 轮询 window.__youthomeIm = { connected: false }; const ensureImSdk = () => new Promise((resolve) => { if (window.WebIM) { resolve(true); return; } const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/easemob-websdk@4.24.3/Easemob-chat.js'; script.onload = () => resolve(Boolean(window.WebIM)); script.onerror = () => resolve(false); document.head.appendChild(script); }); const connectRealtime = async () => { try { if (!(await ensureImSdk())) return; const config = await api.get('/campus/chat/config'); if (!config?.appKey || !config?.username || !config?.password) return; const conn = new window.WebIM.connection({ appKey: config.appKey }); window.__youthomeIm.conn = conn; conn.listen({ onOpened: () => { window.__youthomeIm.connected = true; }, onClosed: () => { window.__youthomeIm.connected = false; }, onError: () => { window.__youthomeIm.connected = false; }, onTextMessage: (message) => { const from = String(message.from || '').replace(/^u/, '').replace(/-/g, ''); const activePeer = String(active?.peerId || '').replace(/-/g, ''); if (active && (!activePeer || from === activePeer)) loadHistory(); refreshConversations(); } }); await conn.open({ user: config.username, pwd: config.password }); window.__youthomeIm.connected = true; } catch (error) { window.__youthomeIm.connected = false; console.warn('环信实时连接失败,继续使用轮询:', error?.message || error); } }; try { conversations = (await api.get('/campus/chat/conversations')) || []; } catch (error) { addEmptyState(convList, '会话加载失败', error.message || '请稍后刷新重试'); showWebToast(error.message || '会话加载失败', 'error'); return; } if (requestedPeer && /^[0-9a-f-]{36}$/i.test(requestedPeer) && requestedPeer !== me.id) { active = conversations.find((item) => item.peerId === requestedPeer && String(item.conversationType).toUpperCase() === 'PRIVATE'); if (!active) { active = { id: `new-private-${requestedPeer}`, peerId: requestedPeer, conversationType: 'PRIVATE' }; conversations.unshift(active); } } else active = conversations[0] || null; let inviteBtn = null; const syncHeader = async (selectedId = active?.id, requestId = historyRequestId) => { const nameNode = document.querySelector('.chat-header-info .name'); const avatarNode = document.querySelector('.chat-header-avatar img'); if (!active) return; const conversation = active; const name = await nameOf(conversation); if (requestId !== historyRequestId || active?.id !== selectedId || active?.id !== conversation.id) return; if (nameNode) nameNode.textContent = name; if (avatarNode) { avatarNode.removeAttribute('src'); avatarNode.alt = name; } const avatarBox = document.querySelector('.chat-header-avatar'); if (avatarBox) { avatarBox.replaceChildren(); const url = api.avatarUrl(active.peerAvatar); if (url) { const image = document.createElement('img'); image.src = url; image.alt = ''; avatarBox.appendChild(image); } else avatarBox.textContent = name.slice(0, 1); } if (textarea) textarea.disabled = false; if (send) send.disabled = false; if (inviteBtn) inviteBtn.hidden = String(active.conversationType || '').toUpperCase() !== 'GROUP'; }; const openGroupInvite = () => { if (!active) return; const overlay = document.createElement('div'); overlay.className = 'web-sheet-overlay'; const panel = document.createElement('div'); panel.className = 'web-sheet-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); const head = document.createElement('div'); head.className = 'web-sheet-head'; const title = document.createElement('div'); title.className = 'web-sheet-title'; title.textContent = '邀请成员'; const closeBtn = document.createElement('button'); closeBtn.className = 'web-sheet-close'; closeBtn.type = 'button'; closeBtn.textContent = '×'; head.append(title, closeBtn); const body = document.createElement('div'); body.className = 'web-sheet-body'; const message = document.createElement('textarea'); message.className = 'web-sheet-textarea'; message.rows = 2; message.placeholder = '给好友留句话(可选,200 字以内)'; body.appendChild(message); const hint = document.createElement('p'); hint.className = 'web-sheet-hint'; hint.textContent = '正在加载好友…'; body.appendChild(hint); const foot = document.createElement('div'); foot.className = 'web-sheet-foot'; const cancel = document.createElement('button'); cancel.className = 'web-sheet-btn ghost'; cancel.type = 'button'; cancel.textContent = '取消'; const submit = document.createElement('button'); submit.className = 'web-sheet-btn primary'; submit.type = 'button'; submit.textContent = '发送邀请'; submit.disabled = true; foot.append(cancel, submit); panel.append(head, body, foot); overlay.appendChild(panel); document.body.appendChild(overlay); const close = () => overlay.remove(); overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); closeBtn.addEventListener('click', close); cancel.addEventListener('click', close); const selected = new Set(); (async () => { try { const data = await api.get('/friendships/my'); const items = Array.isArray(data) ? data : (data?.items || []); if (!items.length) { hint.textContent = '你还没有好友,先添加好友再来邀请'; return; } body.replaceChild(document.createElement('div'), hint); const listBox = body.querySelector('div'); listBox.className = 'web-sheet-list'; body.insertBefore(listBox, body.querySelector('.web-sheet-foot')); items.forEach((item) => { const row = document.createElement('label'); row.className = 'web-sheet-row'; const avatar = document.createElement('span'); avatar.className = 'web-sheet-avatar'; avatar.textContent = (item.nickname || '友').slice(0, 1); const info = document.createElement('span'); info.className = 'web-sheet-row-info'; const name = document.createElement('b'); name.textContent = item.nickname || '好友'; const desc = document.createElement('span'); desc.textContent = item.universityName || ''; info.append(name, desc); const check = document.createElement('input'); check.type = 'checkbox'; check.addEventListener('change', () => { if (check.checked) selected.add(item.id); else selected.delete(item.id); submit.disabled = selected.size === 0; }); row.append(avatar, info, check); listBox.appendChild(row); }); } catch (error) { hint.textContent = error.message || '好友加载失败'; } })(); submit.addEventListener('click', async () => { submit.disabled = true; submit.textContent = '发送中…'; try { await api.post('/groups/' + encodeURIComponent(active.peerId) + '/invitations', { userIds: [...selected], message: message.value.trim() }); close(); showWebToast('邀请已发送'); } catch (error) { submit.disabled = false; submit.textContent = '发送邀请'; showWebToast(error.message || '邀请失败', 'error'); } }); }; const addHeaderActions = () => { const right = document.querySelector('.chat-header-right'); if (!right || right.querySelector('[data-chat-readall]')) return; const readAll = document.createElement('button'); readAll.className = 'chat-header-btn'; readAll.dataset.chatReadall = 'true'; readAll.title = '全部已读'; readAll.textContent = '已读'; readAll.addEventListener('click', async () => { try { await api.request('/campus/chat/read/all', { method: 'PUT' }); await refreshConversations(); showWebToast('已全部标为已读'); } catch (error) { showWebToast(error.message || '操作失败', 'error'); } }); inviteBtn = document.createElement('button'); inviteBtn.className = 'chat-header-btn'; inviteBtn.title = '邀请成员'; inviteBtn.textContent = '邀请'; inviteBtn.hidden = true; inviteBtn.addEventListener('click', openGroupInvite); right.insertBefore(readAll, right.firstElementChild); right.appendChild(inviteBtn); }; addHeaderActions(); await renderConversations(); await syncHeader(); if (active) await loadHistory(); document.querySelector('.conv-search .search-input')?.addEventListener('input', () => renderConversations()); // 轮询拉新(非实时推送):3 秒刷新当前会话,10 秒刷新会话列表 window.setInterval(() => { if (active && document.visibilityState === 'visible') loadHistory(); }, 3000); connectRealtime(); window.setInterval(() => { if (document.visibilityState === 'visible') refreshConversations(); }, 10000); }; const initChat = () => { const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiChat(api); const textarea = document.querySelector('.chat-textarea'); const send = document.querySelector('.send-btn'); const messages = document.querySelector('.chat-messages'); if (!textarea || !send || !messages) return; const sendMessage = () => { const content = textarea.value.trim(); if (!content) return; const row = document.createElement('div'); row.className = 'msg-row self'; row.innerHTML = `
我
刚刚
`; row.querySelector('.msg-bubble').textContent = content; messages.appendChild(row); textarea.value = ''; messages.scrollTop = messages.scrollHeight; showWebToast('消息已发送'); }; send.addEventListener('click', sendMessage); textarea.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); } }); document.querySelectorAll('.chat-header-btn, .chat-toolbar-btn, .info-action-btn').forEach((button) => { button.addEventListener('click', () => showWebToast(`${button.title || button.textContent.trim()}功能将在实时通信接入后开放`)); }); }; const initHelp = () => { const search = document.querySelector('.help-hero input, .help-search input, input[placeholder="搜索常见问题"]'); const faqs = Array.from(document.querySelectorAll('.faq-item')); search?.addEventListener('input', () => { const query = search.value.trim().toLowerCase(); faqs.forEach((item) => { item.hidden = !!query && !item.textContent.toLowerCase().includes(query); }); }); document.querySelectorAll('.category-card').forEach((card) => { makeKeyboardClickable(card, () => card.click()); card.addEventListener('click', () => { const keyword = card.querySelector('.category-title')?.textContent.trim() || ''; if (search) { search.value = keyword; search.dispatchEvent(new Event('input')); } document.querySelector('.faq-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); document.querySelector('.submit-btn')?.addEventListener('click', () => { const subject = document.querySelector('.feedback-form input'); const detail = document.querySelector('.feedback-form textarea'); if (!subject?.value.trim() || !detail?.value.trim()) { showWebToast('请填写问题标题和详细描述', 'error'); return; } showWebToast('反馈已提交,我们会尽快处理'); subject.value = ''; detail.value = ''; }); document.querySelector('.contact-btn')?.addEventListener('click', () => { location.href = 'chat.html'; }); }; const initActivityDetail = () => { const join = document.querySelector('.join-btn:not(.secondary)'); const store = window.YouthomeLocal; const api = window.YouthomeApi; const isLocal = api?.isLocal ? api.isLocal() : true; const title = (document.querySelector('.cover-title')?.textContent || document.title).trim(); const infoValue = (label) => { const item = Array.from(document.querySelectorAll('.side-info-item')) .find((node) => node.querySelector('.label')?.textContent.trim() === label); return (item?.querySelector('.value')?.innerText || '').replace(/\s+/g, ' ').trim(); }; const hashOf = (text) => { let value = 0; for (let index = 0; index < text.length; index += 1) value = (value * 31 + text.charCodeAt(index)) % 1000000; return value; }; const requestedId = new URLSearchParams(location.search).get('id'); const activityId = requestedId || `local-${hashOf(title)}`; const setSideValue = (label, value) => { const item = Array.from(document.querySelectorAll('.side-info-item')) .find((node) => node.querySelector('.label')?.textContent.trim() === label); const target = item?.querySelector('.value'); if (target && value) target.textContent = value; }; const applyActivity = (item) => { if (!item) return; const titleNode = document.querySelector('.cover-title'); if (titleNode) titleNode.textContent = item.title || '未命名活动'; const timeText = [item.startTime, item.endTime].filter(Boolean).join(' - '); setSideValue('活动时间', timeText); setSideValue('活动地点', item.location); setSideValue('报名截止', item.registrationDeadline); setSideValue('活动人数', (item.currentCount ?? 0) + '/' + (item.maxCount ?? '—') + ' 人'); const hostName = document.querySelector('.host-name'); if (hostName) hostName.textContent = item.creatorName || '校园组织'; const hostDesc = document.querySelector('.host-desc'); if (hostDesc) hostDesc.textContent = '活动发起人'; const hostAvatar = document.querySelector('.host-avatar'); if (hostAvatar) hostAvatar.textContent = (item.creatorName || '青').slice(0, 1); const content = document.querySelector('.activity-content'); if (content && item.description) { const paragraph = document.createElement('p'); paragraph.textContent = item.description; content.replaceChildren(paragraph); } const meta = document.querySelectorAll('.activity-detail-meta .meta-tag'); if (meta[0]) meta[0].lastChild.textContent = item.startTime || ''; if (meta[1]) meta[1].lastChild.textContent = item.location || ''; if (meta[2]) meta[2].lastChild.textContent = (item.currentCount || 0) + '人已报名'; if (item.local) { const tags = document.querySelectorAll('.cover-tags .cover-tag'); const typeLabels = { offline: '线下聚会', sports: '运动健身', study: '学习交流', club: '兴趣社团', competition: '比赛竞赛', volunteer: '公益志愿' }; if (tags[1]) tags[1].textContent = typeLabels[item.type] || '活动'; tags[2]?.remove(); const participants = document.querySelector('.participants-section'); if (participants) { participants.querySelector('.participants-title').textContent = '已报名同学 (0)'; participants.querySelector('.participants-avatars')?.replaceChildren(); participants.querySelector('.participants-list')?.replaceChildren(); participants.querySelector('.participants-more')?.remove(); } const detailTabs = document.querySelectorAll('.detail-tab'); if (detailTabs[1]) detailTabs[1].textContent = '参与者 (0)'; if (detailTabs[2]) detailTabs[2].textContent = '讨论 (0)'; } const cover = document.querySelector('.activity-detail-cover'); if (cover) { cover.dataset.activityId = item.id; const coverImage = item.coverImage || item.coverUrl; if (coverImage) cover.style.background = `url("${coverImage}") center / cover`; } document.title = (item.title || '活动详情') + ' - 青核 Youthome'; }; const showNotFound = () => { const titleNode = document.querySelector('.cover-title'); if (titleNode) titleNode.textContent = '活动不存在或已下架'; document.querySelector('.join-btn:not(.secondary)')?.setAttribute('disabled', ''); document.querySelectorAll('.side-info-item .value').forEach((node) => { node.textContent = '—'; }); showWebToast('活动不存在或已下架', 'error'); }; if (isLocal && requestedId && store?.readDb().activities.some((item) => item.id === requestedId)) { applyActivity(store.readDb().activities.find((item) => item.id === requestedId)); } else if (!isLocal && requestedId) { // /activities/view 只记录浏览;详情走 /activities/detail?id=,失败时回退列表匹配兼容旧后端 api.post('/campus/activities/view?id=' + encodeURIComponent(requestedId)).catch(() => {}); api.get('/campus/activities/detail?id=' + encodeURIComponent(requestedId)) .then((item) => { if (item) applyActivity(item); else showNotFound(); }) .catch(async () => { try { const created = await api.get('/campus/activities/created?page=1&size=100'); const own = (created?.items || []).find((row) => row.id === requestedId); if (own) { applyActivity(own); return; } } catch (error) { /* 继续查询公开活动列表 */ } try { const data = await api.get('/campus/activities?page=1&size=100'); const item = (data?.items || []).find((row) => row.id === requestedId); if (item) applyActivity(item); else showNotFound(); } catch (error) { showWebToast(error.message || '活动详情加载失败', 'error'); } }); } const snapshot = { id: activityId, title: document.querySelector('.cover-title')?.textContent.trim() || title, time: infoValue('活动时间'), place: infoValue('活动地点').replace(/([^)]*)/g, '') }; localStorage.removeItem('youthome:web:activityJoined'); const loggedIn = () => Boolean(store?.token?.() && store?.currentUser?.()); const syncJoinButton = async () => { if (!join) return; if (!loggedIn()) return; if (isLocal) { const joined = Boolean(store?.isJoined?.(activityId)); join.textContent = joined ? '已报名' : '立即报名'; join.disabled = joined; return; } if (!requestedId) return; try { const [joined, registrations] = await Promise.all([api.joinedActivities(), api.registrations()]); const registration = registrations.find((item) => item.activityId === requestedId && !['CANCELLED', 'REJECTED', 'REMOVED'].includes(String(item.status || '').toUpperCase())); const confirmed = joined.some((item) => item.id === requestedId); if (confirmed) api.removeApplication(requestedId); const pending = api.applicationRecords().some((item) => item.activityId === requestedId); join.textContent = registration ? (String(registration.paymentStatus || registration.status).toUpperCase().includes('PAY') ? '待支付' : '已报名') : confirmed ? '已报名' : pending ? '待审核' : '立即报名'; join.disabled = Boolean(registration || confirmed || pending); } catch (error) { showWebToast(error.message || '报名状态加载失败', 'error'); } }; const closeSignupModal = () => { document.querySelector('.web-signup-overlay')?.remove(); }; const submitSignup = async (overlay, submitBtn, errorBox) => { submitBtn.disabled = true; submitBtn.textContent = '提交中…'; errorBox.style.display = 'none'; try { if (isLocal) store.joinActivity(snapshot); else { const application = await api.joinActivity(requestedId, overlay.querySelector('.web-signup-remark')?.value.trim() || ''); api.saveApplication({ activityId: requestedId, participantId: application?.id, title: document.querySelector('.cover-title')?.textContent.trim() || title, status: application?.status || 'PENDING', createdAt: new Date().toISOString(), startTime: infoValue('活动时间'), location: infoValue('活动地点') }); join.textContent = String(application?.status || '').toUpperCase() === 'CONFIRMED' ? '已报名' : '待审核'; } if (isLocal) join.textContent = '已报名'; join.disabled = true; closeSignupModal(); showWebToast('报名申请已提交,可在“我的报名”中查看'); } catch (error) { submitBtn.disabled = false; submitBtn.textContent = '提交申请'; errorBox.textContent = error.message || '报名失败,请重试'; errorBox.style.display = 'block'; } }; const buildActivityBox = (liveTitle) => { const activityBox = document.createElement('div'); activityBox.className = 'web-signup-activity'; const rows = [ ['web-signup-activity-name', liveTitle || '未命名活动'], ['web-signup-meta', infoValue('活动时间') || '时间待定'], ['web-signup-meta', infoValue('活动地点') || '地点待定'], ['web-signup-meta', '报名截止:' + (infoValue('报名截止') || '以主办方通知为准')] ]; rows.forEach(([className, text]) => { const node = document.createElement('div'); node.className = className; node.textContent = text; activityBox.appendChild(node); }); return activityBox; }; const openApplicationModal = (user, liveTitle) => { const displayName = user.nickname || user.name || user.phone || '我'; const overlay = document.createElement('div'); overlay.className = 'web-signup-overlay'; const modal = document.createElement('div'); modal.className = 'web-signup-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', '提交报名申请'); const head = document.createElement('div'); head.className = 'web-signup-head'; const headTitle = document.createElement('div'); headTitle.className = 'web-signup-title'; headTitle.textContent = '提交报名申请'; const closeBtn = document.createElement('button'); closeBtn.className = 'web-signup-close'; closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', '关闭'); closeBtn.textContent = '×'; head.append(headTitle, closeBtn); const body = document.createElement('div'); body.className = 'web-signup-body'; const userRow = document.createElement('div'); userRow.className = 'web-signup-user'; const avatar = document.createElement('div'); avatar.className = 'web-signup-avatar'; avatar.textContent = String(displayName).slice(0, 1) || '我'; const userMeta = document.createElement('div'); userMeta.className = 'web-signup-meta'; userMeta.textContent = '以「' + String(displayName) + '」的身份提交申请'; userRow.append(avatar, userMeta); const remark = document.createElement('textarea'); remark.className = 'web-signup-remark'; remark.rows = 2; remark.placeholder = '报名留言(可选)'; const errorBox = document.createElement('div'); errorBox.className = 'web-signup-error'; body.append(buildActivityBox(liveTitle), userRow, remark, errorBox); const foot = document.createElement('div'); foot.className = 'web-signup-foot'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'web-signup-btn ghost'; cancelBtn.type = 'button'; cancelBtn.textContent = '取消'; const submitBtn = document.createElement('button'); submitBtn.className = 'web-signup-btn primary web-signup-submit'; submitBtn.type = 'button'; submitBtn.textContent = '提交申请'; foot.append(cancelBtn, submitBtn); modal.append(head, body, foot); overlay.appendChild(modal); document.body.appendChild(overlay); overlay.addEventListener('click', (event) => { if (event.target === overlay) closeSignupModal(); }); closeBtn.addEventListener('click', closeSignupModal); cancelBtn.addEventListener('click', closeSignupModal); submitBtn.addEventListener('click', () => submitSignup(overlay, submitBtn, errorBox)); }; const openTicketModal = (user, liveTitle, tickets) => { const overlay = document.createElement('div'); overlay.className = 'web-signup-overlay'; const modal = document.createElement('div'); modal.className = 'web-signup-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', '报名信息'); const head = document.createElement('div'); head.className = 'web-signup-head'; const headTitle = document.createElement('div'); headTitle.className = 'web-signup-title'; headTitle.textContent = '填写报名信息'; const closeBtn = document.createElement('button'); closeBtn.className = 'web-signup-close'; closeBtn.type = 'button'; closeBtn.textContent = '×'; head.append(headTitle, closeBtn); const body = document.createElement('div'); body.className = 'web-signup-body'; let ticketId = tickets[0]?.id || ''; const field = (label, input) => { const wrap = document.createElement('label'); wrap.className = 'web-signup-field'; const text = document.createElement('span'); text.textContent = label; wrap.append(text, input); return wrap; }; const nameInput = document.createElement('input'); nameInput.className = 'web-signup-input'; nameInput.placeholder = '请填写姓名'; nameInput.value = user.nickname || ''; const phoneInput = document.createElement('input'); phoneInput.className = 'web-signup-input'; phoneInput.placeholder = '请填写手机号'; phoneInput.value = user.phone || ''; const qtyInput = document.createElement('input'); qtyInput.className = 'web-signup-input'; qtyInput.type = 'number'; qtyInput.min = '1'; qtyInput.value = '1'; const emergencyName = document.createElement('input'); emergencyName.className = 'web-signup-input'; emergencyName.placeholder = '紧急联系人姓名'; const emergencyPhone = document.createElement('input'); emergencyPhone.className = 'web-signup-input'; emergencyPhone.type = 'tel'; emergencyPhone.placeholder = '紧急联系人手机号'; const ticketLabel = document.createElement('div'); ticketLabel.className = 'web-signup-field-label'; ticketLabel.textContent = '选择票档'; body.append(buildActivityBox(liveTitle), ticketLabel); tickets.forEach((ticket, index) => { const label = document.createElement('label'); label.className = 'web-signup-ticket' + (index === 0 ? ' active' : ''); const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'signup-ticket'; radio.checked = index === 0; radio.addEventListener('change', () => { ticketId = ticket.id; }); const info = document.createElement('span'); info.className = 'web-signup-ticket-info'; const name = document.createElement('b'); name.textContent = ticket.name || '票档'; const meta = document.createElement('span'); meta.textContent = (Number(ticket.price) > 0 ? '¥' + Number(ticket.price) : '免费') + ' · 剩余 ' + (ticket.remainingCount ?? ticket.capacity ?? '—'); info.append(name, meta); label.append(radio, info); body.appendChild(label); }); body.append(field('姓名', nameInput), field('手机号', phoneInput), field('数量', qtyInput), field('紧急联系人', emergencyName), field('紧急联系电话', emergencyPhone)); const errorBox = document.createElement('div'); errorBox.className = 'web-signup-error'; body.appendChild(errorBox); const foot = document.createElement('div'); foot.className = 'web-signup-foot'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'web-signup-btn ghost'; cancelBtn.type = 'button'; cancelBtn.textContent = '取消'; const submitBtn = document.createElement('button'); submitBtn.className = 'web-signup-btn primary'; submitBtn.type = 'button'; submitBtn.textContent = '提交报名'; foot.append(cancelBtn, submitBtn); modal.append(head, body, foot); overlay.appendChild(modal); document.body.appendChild(overlay); overlay.addEventListener('click', (event) => { if (event.target === overlay) closeSignupModal(); }); closeBtn.addEventListener('click', closeSignupModal); cancelBtn.addEventListener('click', closeSignupModal); submitBtn.addEventListener('click', async () => { if (!ticketId) { errorBox.textContent = '请选择票档'; errorBox.style.display = 'block'; return; } if (!nameInput.value.trim() || !/^1\d{10}$/.test(phoneInput.value.trim()) || !emergencyName.value.trim() || !/^1\d{10}$/.test(emergencyPhone.value.trim())) { errorBox.textContent = '请填写姓名、手机号和紧急联系人信息'; errorBox.style.display = 'block'; return; } submitBtn.disabled = true; submitBtn.textContent = '提交中…'; errorBox.style.display = 'none'; try { const registration = await api.request('/campus/activities/' + encodeURIComponent(requestedId) + '/registrations', { method: 'POST', body: JSON.stringify({ ticketId, quantity: Math.max(1, Number(qtyInput.value) || 1), attendeeName: nameInput.value.trim(), attendeePhone: phoneInput.value.trim(), universityName: user.universityName || '', emergencyContact: emergencyName.value.trim(), emergencyPhone: emergencyPhone.value.trim() }) }); const paymentPending = String(registration?.status || '').toUpperCase() === 'PENDING_PAYMENT'; join.textContent = paymentPending ? '待支付' : '已报名'; join.disabled = true; closeSignupModal(); showWebToast(paymentPending ? '报名单已创建,请在“我的报名”中查看支付状态' : '报名成功,可在“我的报名”中查看'); } catch (error) { submitBtn.disabled = false; submitBtn.textContent = '提交报名'; errorBox.textContent = error.message || '报名失败,请重试'; errorBox.style.display = 'block'; } }); }; window.openSignupModal = async () => { if (!join || join.disabled) return; if (!loggedIn()) { showWebToast('请先登录后再报名', 'error'); window.setTimeout(() => { location.href = `login.html?next=${encodeURIComponent(`activity-detail.html?id=${activityId}`)}`; }, 700); return; } if (!isLocal && !requestedId) { showWebToast('这条活动来自页面示例,暂不支持报名', 'error'); return; } const liveTitle = (document.querySelector('.cover-title')?.textContent || title).trim(); const user = (api?.currentUser?.() || store?.currentUser?.()) || {}; if (!isLocal && requestedId) { try { const tickets = await api.get('/campus/activities/' + encodeURIComponent(requestedId) + '/tickets'); const list = Array.isArray(tickets) ? tickets : (tickets?.items || []); const enabled = list.filter((ticket) => ticket.enabled !== false); if (enabled.length) { openTicketModal(user, liveTitle, enabled); return; } } catch (error) { /* 无票档时回退为报名申请 */ } } openApplicationModal(user, liveTitle); }; syncJoinButton(); const favorite = document.querySelector('.join-btn.secondary'); favorite?.addEventListener('click', () => { const active = favorite.classList.toggle('is-active'); favorite.textContent = active ? '已收藏' : '收藏活动'; showWebToast(active ? '活动已收藏' : '已取消收藏'); }); const follow = document.querySelector('.host-follow-btn'); follow?.addEventListener('click', () => { const active = follow.classList.toggle('is-followed'); follow.textContent = active ? '已关注' : '关注'; const host = document.querySelector('.host-name')?.textContent.trim(); if (host && store?.setFollowing) store.setFollowing(host, active); }); }; const initNotificationPostDetail = () => { if (page !== 'post-detail.html') return null; const params = new URLSearchParams(location.search); const postId = params.get('post'); const post = notificationPosts[postId]; if (!post) return null; document.body.classList.add('notification-post-detail'); document.title = `${post.title} - 青核 Youthome`; const noticeCategory = { 'like-canteen': '点赞', 'comment-canteen': '评论回复', 'mention-milk-tea': '@我的' }[params.get('notice')]; const breadcrumbs = document.querySelectorAll('.breadcrumb a'); if (breadcrumbs[0]) { breadcrumbs[0].textContent = noticeCategory ? '消息通知' : '动态广场'; breadcrumbs[0].onclick = () => { location.href = noticeCategory ? `${notificationHref}&category=${encodeURIComponent(noticeCategory)}` : 'feed.html'; }; } if (breadcrumbs[1]) { breadcrumbs[1].textContent = '我的动态'; breadcrumbs[1].onclick = () => { location.href = 'me.html'; }; } const meta = document.querySelectorAll('.post-detail-meta > span:not(.dot)'); if (meta[0]) meta[0].textContent = post.time; if (meta[1]) meta[1].textContent = '复旦大学'; if (meta[2]) meta[2].textContent = `浏览 ${post.views}`; const content = document.querySelector('.post-detail-content'); if (content) { content.replaceChildren(); const heading = document.createElement('h1'); heading.className = 'notification-post-title'; heading.textContent = post.title; content.appendChild(heading); post.body.forEach((text) => { const paragraph = document.createElement('p'); paragraph.textContent = text; content.appendChild(paragraph); }); } const images = document.querySelector('.post-detail-images'); if (images) images.hidden = true; const tags = document.querySelector('.post-detail-tags'); if (tags) { tags.replaceChildren(); post.tags.forEach((text) => { const tag = document.createElement('span'); tag.className = 'tag tag-primary'; tag.textContent = text; tags.appendChild(tag); }); } const related = document.querySelector('.related-section'); if (related) related.hidden = true; document.querySelectorAll('.post-detail-avatar, .post-detail-name, .author-avatar, .author-name').forEach((author) => { author.onclick = () => { location.href = 'me.html'; }; }); const authorDescription = document.querySelector('.author-desc'); if (authorDescription) authorDescription.textContent = '复旦大学 · 校园创作者'; const authorStats = document.querySelectorAll('.author-stat-num'); ['42', '1,256', '289'].forEach((value, index) => { if (authorStats[index]) authorStats[index].textContent = value; }); const actionCounts = document.querySelectorAll('.post-detail-actions .count'); if (actionCounts[0]) actionCounts[0].textContent = String(post.likes); if (actionCounts[2]) actionCounts[2].textContent = String(post.shares); const commentList = document.querySelector('.comment-list'); const commentSection = document.querySelector('.comment-section'); if (commentSection) commentSection.id = 'comments'; const commentInput = document.querySelector('.comment-input'); const storageKey = `youthome:web:post-comments:${postId}`; let savedComments = []; try { const stored = JSON.parse(localStorage.getItem(storageKey) || '[]'); if (Array.isArray(stored)) savedComments = stored.filter((entry) => typeof entry.text === 'string'); } catch (error) { savedComments = []; } const allComments = [...post.comments, ...savedComments]; const makeComment = (comment) => { const row = document.createElement('div'); row.className = 'comment-item'; row.id = `comment-${comment.id}`; const avatar = document.createElement('div'); avatar.className = 'comment-avatar'; avatar.textContent = comment.name.slice(0, 1); const body = document.createElement('div'); body.className = 'comment-body'; const nameRow = document.createElement('div'); nameRow.className = 'comment-name-row'; const name = document.createElement('span'); name.className = 'comment-name'; name.textContent = comment.name; nameRow.appendChild(name); if (comment.name === '林小夏') { const authorTag = document.createElement('span'); authorTag.className = 'comment-author-tag'; authorTag.textContent = '作者'; nameRow.appendChild(authorTag); } const content = document.createElement('div'); content.className = 'comment-text'; content.textContent = comment.text; const meta = document.createElement('div'); meta.className = 'comment-meta'; const time = document.createElement('span'); time.textContent = comment.time; const reply = document.createElement('button'); reply.type = 'button'; reply.className = 'notification-comment-reply'; reply.textContent = '回复'; reply.addEventListener('click', () => { if (!commentInput) return; commentInput.value = `@${comment.name} `; commentInput.focus(); }); meta.append(time, reply); body.append(nameRow, content, meta); row.append(avatar, body); return row; }; const updateCommentCount = () => { const countValue = (post.commentCount || post.comments.length) + savedComments.length; if (actionCounts[1]) actionCounts[1].textContent = String(countValue); const count = document.querySelector('.comment-count'); if (count) count.textContent = `共 ${countValue} 条`; }; commentList?.replaceChildren(...allComments.map(makeComment)); if (!allComments.length && post.commentCount) { const unavailable = document.createElement('p'); unavailable.className = 'profile-comments-unavailable'; unavailable.textContent = '原有评论内容尚未接入,暂时无法查看。'; commentList?.appendChild(unavailable); } updateCommentCount(); document.documentElement.classList.remove('notification-route-pending', 'notification-route-failed'); const target = params.get('comment'); if (target) { const comment = document.getElementById(`comment-${target}`); if (comment) { comment.classList.add('is-targeted'); requestAnimationFrame(() => comment.scrollIntoView({ block: 'center' })); } } document.querySelector('.post-detail-actions .action-btn-big.more')?.setAttribute('hidden', ''); const share = document.querySelectorAll('.post-detail-actions .action-btn-big')[2]; share?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(`${location.origin}${location.pathname}?post=${encodeURIComponent(postId)}`); showWebToast('动态链接已复制'); } catch (error) { showWebToast('可从浏览器地址栏复制动态链接'); } }); return { addComment(text) { const comment = { id: `local-${Date.now()}`, name: '林小夏', text, time: '刚刚' }; savedComments.push(comment); localStorage.setItem(storageKey, JSON.stringify(savedComments)); allComments.push(comment); commentList?.querySelector('.profile-comments-unavailable')?.remove(); commentList?.appendChild(makeComment(comment)); updateCommentCount(); } }; }; const initProfileVideoDetail = () => { if (page !== 'video-detail.html') return; const video = profileVideos[new URLSearchParams(location.search).get('video')]; if (!video) return; document.title = `${video.title} - 青核 Youthome`; document.body.classList.add('profile-video-detail'); const title = document.querySelector('.video-title'); if (title) title.textContent = video.title; const meta = document.querySelectorAll('.video-meta-item'); if (meta[0]) meta[0].lastChild.textContent = ` ${video.views}次播放`; if (meta[1]) meta[1].lastChild.textContent = ' 个人中心作品'; const tags = document.querySelector('.video-topics'); if (tags) { tags.replaceChildren(...video.tags.map((label) => { const tag = document.createElement('span'); tag.className = 'video-topic-tag'; tag.textContent = label; return tag; })); } const author = document.querySelector('.author-name'); if (author) author.textContent = '林小夏'; const school = document.querySelector('.author-school'); if (school) school.textContent = '复旦大学'; const avatar = document.querySelector('.author-avatar'); if (avatar) avatar.textContent = '夏'; const description = document.querySelector('.video-desc'); if (description) { description.replaceChildren(); const heading = document.createElement('div'); heading.className = 'video-desc-title'; heading.textContent = '视频简介'; const paragraph = document.createElement('p'); paragraph.textContent = `${video.title}。时长 ${video.duration}。`; description.append(heading, paragraph); } const player = document.querySelector('.video-player-wrap'); if (player) { const notice = document.createElement('p'); notice.className = 'profile-video-unavailable'; notice.textContent = '视频文件尚未接入,暂时无法播放'; player.appendChild(notice); } document.documentElement.classList.remove('profile-video-route-pending'); }; const initDetailActions = () => { if (page === 'post-detail.html' && new URLSearchParams(location.search).has('moment')) return; const videoId = new URLSearchParams(location.search).get('id'); const localVideo = window.YouthomeApi?.isLocal() && window.YouthomeLocal?.readDb().moments.some((item) => item.id === videoId && item.isYouthVideo); if (page === 'video-detail.html' && videoId && window.YouthomeApi && (!window.YouthomeApi.isLocal() || localVideo)) { window.YouthomeApi.get('/campus/moments/detail?id=' + encodeURIComponent(videoId)) .then(async (item) => { const player = document.querySelector('.video-player-wrap'); if (player) { player.replaceChildren(); const showNotice = (text) => { player.replaceChildren(); const notice = document.createElement('p'); notice.className = 'web-live-empty'; notice.textContent = text; player.appendChild(notice); }; const raw = item.playUrl || item.videoOriginalUrl || ''; const resolved = localVideo ? await window.YouthomeLocal.videoUrl(raw) : raw; const url = resolved.replace(/^https?:\/\/video\.yoyoeco\.com\//i, '/media/'); if (!url) { showNotice('视频仍在转码,暂不可播放,请稍后再试'); } else { const video = document.createElement('video'); video.controls = true; video.playsInline = true; video.autoplay = true; video.muted = true; video.preload = 'auto'; if (item.videoCoverUrl) video.poster = item.videoCoverUrl; video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000;display:block;'; video.addEventListener('error', () => showNotice('视频加载失败,请稍后重试')); const box = document.createElement('div'); box.className = 'web-player-box'; box.appendChild(video); player.appendChild(box); const overlay = document.createElement('div'); overlay.className = 'web-player-overlay'; overlay.innerHTML = ''; box.appendChild(overlay); const bigPlay = overlay.querySelector('.web-player-big-play'); const spinner = overlay.querySelector('.web-player-spinner'); const syncPlayerUi = () => { spinner.hidden = !(video.readyState < 3 && !video.paused); bigPlay.hidden = !video.paused; }; video.addEventListener('play', syncPlayerUi); video.addEventListener('pause', syncPlayerUi); video.addEventListener('playing', syncPlayerUi); video.addEventListener('waiting', syncPlayerUi); video.addEventListener('canplay', syncPlayerUi); bigPlay.addEventListener('click', (event) => { event.stopPropagation(); if (video.paused) video.play().catch(() => {}); else video.pause(); }); syncPlayerUi(); if (/\.m3u8(\?|$)/i.test(url)) { ensureHls().then((ok) => { if (ok && window.Hls && window.Hls.isSupported()) { const hls = new window.Hls(); hls.on(window.Hls.Events.ERROR, (event, data) => { if (data.fatal) showNotice('视频加载失败,请稍后重试'); }); hls.loadSource(url); hls.attachMedia(video); hls.on(window.Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; } else { showNotice('当前浏览器不支持该视频格式'); } }); } else { video.src = url; video.play().catch(() => {}); } } } const [videoTitle, ...descriptionLines] = String(item.content || '').split(/\r?\n/); const heading = videoTitle.trim() || '校园视频'; const description = descriptionLines.join('\n').trim(); const title = document.querySelector('.video-title'); if (title) title.textContent = heading; document.title = heading + ' - 青核 Youthome'; const videoMeta = document.querySelectorAll('.video-meta-item'); if (videoMeta[0]) videoMeta[0].lastChild.textContent = ` ${item.viewCount || 0}次播放`; if (videoMeta[1]) videoMeta[1].lastChild.textContent = ` 发布于 ${String(item.createdAt || '').slice(0, 10) || '刚刚'}`; const topics = document.querySelector('.video-topics'); if (topics) topics.replaceChildren(...(item.tags || []).map((tag) => { const node = document.createElement('span'); node.className = 'video-topic-tag'; node.textContent = tag.startsWith('#') ? tag : '#' + tag; return node; })); const actionButtons = document.querySelectorAll('.video-actions .action-btn'); const actionCounts = document.querySelectorAll('.video-actions .count'); if (actionCounts[0]) actionCounts[0].textContent = String(item.likeCount || 0); if (actionCounts[1]) actionCounts[1].textContent = String(item.commentCount || 0); if (actionCounts[2]) actionCounts[2].textContent = String(item.favoriteCount || 0); actionButtons[0]?.classList.toggle('active', Boolean(item.isLiked)); actionButtons[2]?.classList.toggle('active', Boolean(item.isFavorited)); const bindVideoAction = (button, label, handler) => { if (!button) return; button.removeAttribute('onclick'); button.setAttribute('role', 'button'); button.tabIndex = 0; button.setAttribute('aria-label', label); button.addEventListener('click', handler); button.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); button.click(); } }); }; bindVideoAction(actionButtons[0], '点赞视频', async () => { if (actionButtons[0].dataset.busy) return; actionButtons[0].dataset.busy = 'true'; try { const result = await window.YouthomeApi.request('/campus/moments/like?id=' + encodeURIComponent(videoId), { method: 'POST' }); actionButtons[0].classList.toggle('active', Boolean(result.liked)); actionCounts[0].textContent = String(result.likeCount); } catch (error) { showWebToast(error.message || '点赞失败', 'error'); } finally { delete actionButtons[0].dataset.busy; } }); bindVideoAction(actionButtons[1], '查看评论', () => document.querySelector('.comments-section')?.scrollIntoView({ behavior: 'smooth' })); bindVideoAction(actionButtons[2], '收藏视频', async () => { if (actionButtons[2].dataset.busy) return; actionButtons[2].dataset.busy = 'true'; try { const result = await window.YouthomeApi.request('/campus/moments/favorite?id=' + encodeURIComponent(videoId), { method: 'POST' }); actionButtons[2].classList.toggle('active', Boolean(result.favorited)); actionCounts[2].textContent = String(result.favoriteCount); showWebToast(result.favorited ? '已收藏' : '已取消收藏'); } catch (error) { showWebToast(error.message || '收藏失败', 'error'); } finally { delete actionButtons[2].dataset.busy; } }); bindVideoAction(actionButtons[3], '分享视频', async () => { try { await navigator.clipboard.writeText(location.href); showWebToast('视频链接已复制'); } catch (error) { showWebToast('可从浏览器地址栏复制视频链接'); } }); actionButtons[4]?.remove(); document.querySelector('.author-stats')?.remove(); if (item.userId === window.YouthomeApi.currentUser()?.id) document.querySelector('.follow-btn')?.remove(); const descriptionBox = document.querySelector('.video-desc'); if (descriptionBox) { descriptionBox.replaceChildren(); const label = document.createElement('div'); label.className = 'video-desc-title'; label.textContent = '视频简介'; const body = document.createElement('p'); body.textContent = description || '发布者暂未填写简介'; body.style.whiteSpace = 'pre-wrap'; descriptionBox.append(label, body); } const authorHref = item.userId ? `user.html?id=${encodeURIComponent(item.userId)}` : ''; const school = document.querySelector('.author-school'); if (school) school.lastChild.textContent = ' ' + (item.universityName || (item.userId === window.YouthomeApi.currentUser()?.id ? window.YouthomeApi.currentUser()?.universityName : '') || '校园创作者'); document.querySelectorAll('.author-name').forEach((node) => { node.textContent = item.userName || '青核用户'; node.onclick = authorHref ? () => { location.href = authorHref; } : null; node.style.cursor = authorHref ? 'pointer' : ''; if (authorHref) { node.setAttribute('role', 'link'); node.tabIndex = 0; } }); document.querySelectorAll('.author-avatar').forEach((node) => { node.textContent = (item.userName || '青').slice(0, 1); node.onclick = authorHref ? () => { location.href = authorHref; } : null; node.style.cursor = authorHref ? 'pointer' : ''; if (authorHref) { node.setAttribute('role', 'link'); node.tabIndex = 0; } }); const list = document.querySelector('.comment-list'); const input = document.querySelector('.comment-textarea'); const submit = document.querySelector('.comment-submit-btn'); const count = document.querySelector('.comments-count'); const tabs = Array.from(document.querySelectorAll('.comment-tab')); let comments = []; let replyTarget = null; let order = 'hot'; const replyChip = document.createElement('div'); replyChip.className = 'web-reply-chip'; replyChip.hidden = true; const replyLabel = document.createElement('span'); const cancelReply = document.createElement('button'); cancelReply.type = 'button'; cancelReply.textContent = '取消'; cancelReply.addEventListener('click', () => { replyTarget = null; replyChip.hidden = true; }); replyChip.append(replyLabel, cancelReply); document.querySelector('.comment-input-wrap')?.prepend(replyChip); const renderComment = (comment, reply = false) => { const row = document.createElement('div'); row.className = reply ? 'sub-reply' : 'comment-item'; row.dataset.commentId = comment.id; const avatar = document.createElement('div'); avatar.className = reply ? 'sub-reply-avatar' : 'comment-avatar'; avatar.textContent = (comment.userName || '青').slice(0, 1); const body = document.createElement('div'); body.className = reply ? 'sub-reply-body' : 'comment-body'; const head = document.createElement('div'); head.className = reply ? 'sub-reply-name' : 'comment-head'; const name = document.createElement('span'); name.className = 'comment-name'; name.textContent = comment.userName || '青核用户'; head.appendChild(name); if (!reply) { const time = document.createElement('span'); time.className = 'comment-time'; time.textContent = comment.createdAgo || String(comment.createdAt || '').slice(0, 16); head.appendChild(time); } const content = document.createElement('div'); content.className = reply ? 'sub-reply-text' : 'comment-text'; content.textContent = comment.content || ''; const actions = document.createElement('div'); actions.className = reply ? 'sub-reply-footer' : 'comment-footer'; if (reply) { const time = document.createElement('span'); time.textContent = comment.createdAgo || String(comment.createdAt || '').slice(0, 16); actions.appendChild(time); } const like = document.createElement('button'); like.type = 'button'; like.className = 'comment-action' + (comment.isLiked ? ' liked' : ''); like.textContent = '赞 ' + (comment.likeCount || 0); like.addEventListener('click', async () => { if (like.disabled) return; like.disabled = true; try { const result = await window.YouthomeApi.request('/campus/moments/comments/like?commentId=' + encodeURIComponent(comment.id), { method: 'POST' }); like.classList.toggle('liked', Boolean(result.liked)); like.textContent = '赞 ' + result.likeCount; } catch (error) { showWebToast(error.message || '点赞失败', 'error'); } finally { like.disabled = false; } }); const replyButton = document.createElement('button'); replyButton.type = 'button'; replyButton.className = 'comment-action'; replyButton.textContent = '回复'; replyButton.addEventListener('click', () => { replyTarget = { id: comment.parentId || comment.id, name: comment.userName || '青核用户' }; replyLabel.textContent = '正在回复 ' + replyTarget.name; replyChip.hidden = false; input?.focus(); }); actions.append(like, replyButton); body.append(head, content, actions); if (!reply && comment.replies?.length) { const replies = document.createElement('div'); replies.className = 'sub-replies'; comment.replies.forEach((entry) => replies.appendChild(renderComment(entry, true))); body.appendChild(replies); } row.append(avatar, body); return row; }; const showComments = () => { const sorted = [...comments].sort((a, b) => order === 'latest' ? String(b.createdAt || '').localeCompare(String(a.createdAt || '')) : (Number(b.likeCount || 0) - Number(a.likeCount || 0)) || String(b.createdAt || '').localeCompare(String(a.createdAt || ''))); list?.replaceChildren(...sorted.map((comment) => renderComment(comment))); if (list && !sorted.length) { const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = '还没有评论,来说点什么'; list.appendChild(empty); } const total = comments.reduce((sum, comment) => sum + 1 + (comment.replies?.length || 0), 0); if (count) count.textContent = String(total); if (actionCounts[1]) actionCounts[1].textContent = String(total); }; const loadComments = async () => { const result = await window.YouthomeApi.get('/campus/moments/comments?momentId=' + encodeURIComponent(videoId) + '&page=1&size=50'); comments = result?.items || []; showComments(); }; tabs.forEach((tab, index) => tab.addEventListener('click', () => { order = index ? 'latest' : 'hot'; showComments(); })); if (item.allowComment === false) { document.querySelector('.comment-input-area')?.remove(); } else { submit?.addEventListener('click', async () => { const text = input?.value.trim(); if (!text) { showWebToast('请输入评论内容', 'error'); input?.focus(); return; } submit.disabled = true; try { await window.YouthomeApi.post('/campus/moments/comments', { momentId: videoId, content: text, parentId: replyTarget?.id || null }); input.value = ''; replyTarget = null; replyChip.hidden = true; await loadComments(); showWebToast('评论已发布'); } catch (error) { showWebToast(error.message || '评论发布失败', 'error'); } finally { submit.disabled = false; } }); } loadComments().catch((error) => { if (list) list.textContent = error.message || '评论加载失败'; }); }) .catch((error) => showWebToast(error.message || '视频加载失败', 'error')); return; } initProfileVideoDetail(); const notificationPost = initNotificationPostDetail(); const commentInput = document.querySelector('.comment-input, .comment-textarea, textarea[placeholder*="评论"]'); const send = document.querySelector('.btn-send, .comment-submit-btn'); // 回复提示条(静态评论区和动态评论共用同一套样式) let replyTargetName = null; const replyChip = document.createElement('div'); replyChip.className = 'web-reply-chip'; replyChip.hidden = true; const replyChipText = document.createElement('span'); const replyChipClose = document.createElement('button'); replyChipClose.type = 'button'; replyChipClose.textContent = '取消'; replyChip.append(replyChipText, replyChipClose); const chipHost = document.querySelector('.comment-input-wrap') || commentInput?.parentElement; chipHost?.prepend(replyChip); const renderChip = () => { if (!replyTargetName) { replyChip.hidden = true; return; } replyChip.hidden = false; replyChipText.textContent = '正在回复 ' + replyTargetName; }; replyChipClose.addEventListener('click', () => { replyTargetName = null; renderChip(); }); const commentList = document.querySelector('.comment-list'); const bindCommentControls = (scope) => { scope.querySelectorAll('.comment-meta, .reply-meta').forEach((meta) => { const like = meta.querySelector('.like-btn'); if (like && like.dataset.bound !== 'true') { like.dataset.bound = 'true'; like.setAttribute('role', 'button'); like.tabIndex = 0; like.setAttribute('aria-label', '点赞这条评论'); like.addEventListener('click', (event) => { event.stopPropagation(); const active = like.classList.toggle('liked'); const heart = like.querySelector('path'); if (heart) { heart.setAttribute('fill', active ? 'currentColor' : 'none'); heart.setAttribute('stroke', active ? 'none' : 'currentColor'); } const current = Number((like.textContent || '').replace(/[^0-9]/g, '')) || 0; const next = Math.max(0, current + (active ? 1 : -1)); Array.from(like.childNodes).forEach((node) => { if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) node.textContent = ' ' + next; }); showWebToast(active ? '已点赞这条评论' : '已取消点赞'); }); } Array.from(meta.children).forEach((node) => { if (node.textContent.trim() !== '回复' || node.dataset.bound === 'true') return; node.dataset.bound = 'true'; node.setAttribute('role', 'button'); node.tabIndex = 0; node.addEventListener('click', (event) => { event.stopPropagation(); const owner = node.closest('.reply-item') || node.closest('.comment-item'); const who = owner?.querySelector('.reply-name, .comment-name')?.textContent.trim(); replyTargetName = who || null; renderChip(); commentInput?.focus(); }); }); }); }; bindCommentControls(document.querySelector('.comment-list') || document); const appendSampleComment = (text, targetName) => { if (!commentList) return; const row = document.createElement('div'); row.className = 'comment-item'; const avatar = document.createElement('div'); avatar.className = 'comment-avatar'; avatar.setAttribute('style', 'background: linear-gradient(135deg, #73D3E6, #17B0CF)'); avatar.textContent = '我'; const body = document.createElement('div'); body.className = 'comment-body'; const nameRow = document.createElement('div'); nameRow.className = 'comment-name-row'; const name = document.createElement('span'); name.className = 'comment-name'; name.textContent = '我'; nameRow.appendChild(name); const textNode = document.createElement('div'); textNode.className = 'comment-text'; textNode.textContent = targetName ? '@' + targetName + ' ' + text : text; const meta = document.createElement('div'); meta.className = 'comment-meta'; ['刚刚', '点赞 0', '回复'].forEach((label) => { const span = document.createElement('span'); span.textContent = label; meta.appendChild(span); }); body.append(nameRow, textNode, meta); row.append(avatar, body); commentList.querySelector('.web-live-empty')?.remove(); commentList.appendChild(row); bindCommentControls(row); const countNode = document.querySelector('.comment-count'); const total = commentList.querySelectorAll('.comment-item').length; if (countNode) countNode.textContent = '共 ' + total + ' 条'; }; send?.addEventListener('click', () => { const text = commentInput?.value.trim(); if (!text) { showWebToast('请输入评论内容', 'error'); commentInput?.focus(); return; } const target = replyTargetName; if (notificationPost) notificationPost.addComment(text); else appendSampleComment(text, target); showWebToast(target ? '回复已发布' : '评论已发布'); commentInput.value = ''; replyTargetName = null; renderChip(); }); document.querySelectorAll('.related-card').forEach((card) => makeLinkedCard(card, 'post-detail.html')); // 示例详情页里“评论数 / 分享 / 更多”三个按钮此前点了没反应 const sampleActions = Array.from(document.querySelectorAll('.post-detail-actions .action-btn-big')); const bindAction = (node, label, handler) => { if (!node || node.dataset.bound === 'true') return; node.dataset.bound = 'true'; node.setAttribute('role', 'button'); node.tabIndex = 0; node.setAttribute('aria-label', label); node.addEventListener('click', handler); node.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handler(event); } }); }; bindAction(sampleActions[1], '查看评论', () => { document.querySelector('.comment-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelector('.comment-input, .comment-textarea')?.focus(); }); bindAction(sampleActions[2], '分享', async () => { try { await navigator.clipboard.writeText(location.href); showWebToast('链接已复制'); } catch (error) { showWebToast('可从浏览器地址栏复制链接'); } }); bindAction(sampleActions.find((node) => node.classList.contains('more')), '更多操作', (event) => { event.stopPropagation(); const opened = document.getElementById('web-detail-more-menu'); if (opened) { opened.remove(); return; } const menu = document.createElement('div'); menu.id = 'web-detail-more-menu'; menu.className = 'web-detail-more-menu'; menu.innerHTML = ''; sampleActions.find((node) => node.classList.contains('more')).appendChild(menu); menu.addEventListener('click', async (event) => { event.stopPropagation(); const action = event.target.closest('button')?.dataset.action; if (!action) return; menu.remove(); if (action === 'copy') { try { await navigator.clipboard.writeText(location.href); showWebToast('链接已复制'); } catch (error) { showWebToast('可从浏览器地址栏复制链接'); } } else { showWebToast('当前是演示环境,举报不会真的提交'); } }); // 等这次点击的冒泡结束再挂“点外部关闭”,否则菜单会被同一次点击立刻关掉 window.setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 0); }); }; const initApiUser = async (api) => { const params = new URLSearchParams(location.search); const userId = params.get('id'); const userName = params.get('name'); const grid = document.querySelector('.post-list'); const template = grid?.querySelector('.post-mini-card')?.cloneNode(true); const videoGrid = document.querySelector('.video-grid-3'); const videoTemplate = videoGrid?.querySelector('.video-mini-card')?.cloneNode(true); grid?.replaceChildren(); videoGrid?.replaceChildren(); document.querySelectorAll('.tab-state-label, .profile-badges').forEach((node) => node.remove()); const statNodes = [...document.querySelectorAll('.profile-stats .stat-num')]; statNodes.forEach((node) => { node.textContent = '—'; }); const tabs = [...document.querySelectorAll('.profile-tab')]; tabs.slice(2).forEach((tab) => { tab.hidden = true; }); const renderTab = (label) => { if (grid) grid.hidden = label !== '动态'; if (videoGrid) videoGrid.hidden = label !== '视频'; }; tabs.slice(0, 2).forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { setPressed(tabs, tab); renderTab(tab.textContent.trim()); }); }); renderTab('动态'); let profile = null; if (userId) { try { profile = await api.get('/users/profile?userId=' + encodeURIComponent(userId)); } catch (error) { showWebToast(error.message || '用户资料加载失败', 'error'); } } else if (userName) { try { const result = await api.get('/friendships/search?keyword=' + encodeURIComponent(userName) + '&page=1&size=20'); const list = result?.items || []; const match = list.find((item) => (item.nickname || '') === userName) || list[0]; if (match?.id) profile = await api.get('/users/profile?userId=' + encodeURIComponent(match.id)); } catch (error) { /* 搜索失败走空状态 */ } } const nameNode = document.querySelector('.profile-name'); const avatarNode = document.querySelector('.profile-avatar'); const schoolNode = document.querySelector('.profile-school'); const bioNode = document.querySelector('.profile-bio'); if (!profile) { if (nameNode) nameNode.textContent = userName || '用户主页'; if (avatarNode) avatarNode.textContent = (userName || '用').slice(0, 1); if (bioNode) bioNode.textContent = '没有找到这位用户'; if (grid) addEmptyState(grid, '没有找到用户', '请返回后重新选择'); return; } const nickname = profile.nickname || userName || '校园用户'; if (nameNode) nameNode.textContent = nickname; if (avatarNode) avatarNode.textContent = nickname.slice(0, 1); if (schoolNode) { const icon = schoolNode.querySelector('svg'); schoolNode.replaceChildren(...(icon ? [icon] : []), document.createTextNode(' ' + (profile.universityName || '校园用户'))); } if (bioNode) bioNode.textContent = profile.bio || '这个人很神秘,还没有填写简介'; document.title = nickname + ' 的主页 - 青核 Youthome'; if (statNodes[1]) statNodes[1].textContent = String(profile.followersCount ?? 0); if (statNodes[2]) statNodes[2].textContent = String(profile.followingCount ?? 0); const messageButton = document.querySelector('.btn-message'); if (profile.id === api.currentUser()?.id) messageButton?.remove(); else messageButton?.addEventListener('click', () => { location.href = 'chat.html?peer=' + encodeURIComponent(profile.id); }); const follow = document.querySelector('.btn-follow-primary'); if (follow) { try { const status = await api.get('/users/follow-status?userId=' + encodeURIComponent(profile.id)); const following = Boolean(status?.isFollowed); follow.classList.toggle('followed', following); follow.textContent = following ? '已关注' : '+ 关注'; } catch (error) { /* 忽略 */ } follow.addEventListener('click', async () => { try { const result = await api.request('/users/follow?userId=' + encodeURIComponent(profile.id), { method: 'POST' }); const following = Boolean(result?.following ?? result?.isFollowing ?? !follow.classList.contains('followed')); follow.classList.toggle('followed', following); follow.textContent = following ? '已关注' : '+ 关注'; showWebToast(following ? '已关注' : '已取消关注'); } catch (error) { showWebToast(error.message || '操作失败', 'error'); } }); } if (grid && template) { try { const data = await api.get('/campus/moments/user?userId=' + encodeURIComponent(profile.id) + '&page=1&size=20'); const items = (data?.items || []).filter((moment) => !moment.isYouthVideo); if (statNodes[0]) statNodes[0].textContent = String(data?.total ?? items.length); grid.replaceChildren(...items.map((moment) => { const card = template.cloneNode(true); card.removeAttribute('onclick'); const title = card.querySelector('.post-mini-content'); if (title) title.textContent = moment.content || ''; const cover = card.querySelector('.post-cover'); if (cover) { cover.className = 'post-cover'; cover.replaceChildren(); cover.style.background = moment.mediaUrls?.[0] ? `url("${moment.mediaUrls[0]}") center / cover` : '#e8edf0'; } const author = card.querySelector('.post-author-name'); if (author) author.textContent = moment.userName || nickname; const avatar = card.querySelector('.post-author-avatar'); if (avatar) avatar.textContent = (moment.userName || nickname).slice(0, 1); const likes = card.querySelector('.post-stats span'); if (likes) likes.textContent = String(moment.likeCount || 0); card.addEventListener('click', () => { location.href = 'post-detail.html?moment=' + encodeURIComponent(moment.id); }); return card; })); if (!items.length) addEmptyState(grid, '暂无公开动态', '这位用户的公开内容会显示在这里。'); } catch (error) { addEmptyState(grid, '动态加载失败', error.message || '请稍后刷新重试'); showWebToast(error.message || '动态加载失败', 'error'); } } if (videoGrid && videoTemplate) { try { const data = await api.get('/campus/moments/youth?userId=' + encodeURIComponent(profile.id) + '&page=1&size=20'); const items = data?.items || []; videoGrid.replaceChildren(...items.map((video) => { const card = videoTemplate.cloneNode(true); card.removeAttribute('onclick'); const cover = card.querySelector('.video-mini-cover'); if (cover) cover.style.background = video.videoCoverUrl ? `url("${video.videoCoverUrl}") center / cover` : '#e8edf0'; const title = card.querySelector('.video-mini-title'); if (title) title.textContent = video.content || '未命名视频'; const duration = card.querySelector('.video-mini-duration'); if (duration) duration.textContent = video.videoDuration ? `${Math.floor(video.videoDuration / 60)}:${String(video.videoDuration % 60).padStart(2, '0')}` : ''; const meta = card.querySelectorAll('.video-mini-meta span'); if (meta[0]) meta[0].textContent = `${video.viewCount || 0} 播放`; if (meta[1]) meta[1].textContent = `${video.likeCount || 0} 赞`; card.addEventListener('click', () => { location.href = 'video-detail.html?id=' + encodeURIComponent(video.id); }); return card; })); if (!items.length) addEmptyState(videoGrid, '暂无公开视频', '这位用户的公开视频会显示在这里。'); } catch (error) { addEmptyState(videoGrid, '视频加载失败', error.message || '请稍后刷新重试'); } } }; const initUser = () => { const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiUser(api); const requestedName = new URLSearchParams(location.search).get('name'); if (requestedName && requestedName !== '林小夏') { document.body.classList.add('external-user-page'); const knownUsers = { '张子轩': '上海交通大学', '李梦琪': '同济大学', '王浩然': '复旦大学', '陈诗雨': '华东师范大学', '刘一鸣': '上海大学', '赵小妍': '东华大学' }; const name = requestedName.slice(0, 30); document.title = `${name}的主页 - 青核 Youthome`; const avatar = document.querySelector('.profile-avatar'); const nameNode = document.querySelector('.profile-name'); const school = document.querySelector('.profile-school'); const bio = document.querySelector('.profile-bio'); if (avatar) avatar.textContent = name.slice(0, 1); if (nameNode) nameNode.textContent = name; if (school) { school.replaceChildren(school.querySelector('svg'), document.createTextNode(` ${knownUsers[name] || '校园用户'}`)); } if (bio) bio.textContent = '暂未填写个人简介'; document.querySelector('.profile-badges')?.remove(); document.querySelectorAll('.tab-state-label').forEach((label) => label.remove()); document.querySelectorAll('.profile-stats .stat-num').forEach((number) => { number.textContent = '—'; }); document.querySelectorAll('.post-mini-card, .video-mini-card').forEach((card) => card.remove()); const postGrid = document.querySelector('.post-list'); const videoGrid = document.querySelector('.video-grid-3'); const tabs = Array.from(document.querySelectorAll('.profile-tab')); const renderEmpty = (tab) => { if (postGrid) postGrid.hidden = tab === '视频'; if (videoGrid) videoGrid.hidden = tab !== '视频'; const target = tab === '视频' ? videoGrid : postGrid; postGrid?.querySelector('.web-empty-state')?.remove(); videoGrid?.querySelector('.web-empty-state')?.remove(); if (target) addEmptyState(target, `暂无公开${tab}`, '这位用户的公开内容会显示在这里。'); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { setPressed(tabs, tab); renderEmpty(tab.textContent.trim()); }); }); if (tabs[0]) renderEmpty(tabs[0].textContent.trim()); const followButton = document.querySelector('.btn-follow-primary'); if (name === '陈诗雨' && followButton) { followButton.classList.add('followed'); followButton.textContent = '互相关注'; } followButton?.addEventListener('click', () => { const active = followButton.classList.toggle('followed'); followButton.textContent = active ? '已关注' : '+ 关注'; showWebToast(active ? '关注成功' : '已取消关注'); }); // The demo chat has a fixed recipient, so do not open it for another user's profile. document.querySelector('.btn-message')?.remove(); return; } const follow = document.querySelector('.btn-follow-primary'); follow?.addEventListener('click', () => { const active = follow.classList.toggle('followed'); follow.textContent = active ? '已关注' : '+ 关注'; showWebToast(active ? '关注成功' : '已取消关注'); }); document.querySelector('.btn-message')?.addEventListener('click', () => { location.href = 'chat.html'; }); document.querySelectorAll('.post-mini-card').forEach((card) => makeLinkedCard(card, 'post-detail.html')); document.querySelectorAll('.video-mini-card').forEach((card) => makeLinkedCard(card, 'video-detail.html')); const tabs = Array.from(document.querySelectorAll('.profile-tab')); const posts = Array.from(document.querySelectorAll('.post-mini-card')); const videos = Array.from(document.querySelectorAll('.video-mini-card')); const videoGrid = document.querySelector('.video-grid-3'); const postGrid = posts[0]?.parentElement; const labels = Array.from(document.querySelectorAll('.tab-state-label')); const render = (label) => { if (postGrid) postGrid.hidden = label !== '动态'; if (videoGrid) videoGrid.hidden = label !== '视频'; labels.forEach((item) => { item.hidden = true; }); if (label === '收藏') { if (postGrid) postGrid.hidden = false; posts.forEach((card, index) => { card.hidden = index > 3; }); } else posts.forEach((card) => { card.hidden = false; }); if (label === '活动') { if (postGrid) { postGrid.hidden = false; addEmptyState(postGrid, '暂未公开参加的活动', '用户公开的活动记录会显示在这里。'); } } else postGrid?.querySelector('.web-empty-state')?.remove(); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { setPressed(tabs, tab); render(tab.textContent.trim()); }); }); if (tabs.length) { setPressed(tabs, tabs[0]); render(tabs[0].textContent.trim()); } }; const initPublishing = () => { if (page === 'publish-post.html' && !document.querySelector('script[src*="web-publishing.js"]')) { const content = document.querySelector('.post-textarea'); window.saveDraft = () => { localStorage.setItem('youthome:web:postDraft', content?.value || ''); showWebToast('已保存到草稿箱'); }; window.publishPost = () => { if (!content?.value.trim()) { showWebToast('请输入动态内容', 'error'); content?.focus(); return; } localStorage.removeItem('youthome:web:postDraft'); showWebToast('动态发布成功'); window.setTimeout(() => { location.href = 'feed.html'; }, 600); }; document.querySelector('.add-topic-btn')?.addEventListener('click', () => showWebToast('在正文中输入 #话题名称 即可添加话题')); document.querySelectorAll('.tool-btn').forEach((button) => button.addEventListener('click', () => showWebToast(`${button.textContent.trim()}已准备好,可继续编辑`))); const draft = localStorage.getItem('youthome:web:postDraft'); if (draft && content && !content.value.trim()) content.value = draft; } }; const initActivityCreate = () => { document.body.classList.add('activity-create-page'); const titleInput = document.getElementById('activity-title'); const start = document.getElementById('start-time'); const end = document.getElementById('end-time'); const deadline = document.getElementById('registration-deadline'); const locationName = document.getElementById('location-name'); const locationAddress = document.getElementById('location-address'); const save = document.querySelector('.btn-draft'); const publishButtons = document.querySelectorAll('.btn-publish-main, .btn-next'); const previewButton = document.querySelector('.btn-preview'); const coverArea = document.getElementById('cover-upload'); let selectedCoordinates = null; [locationName, locationAddress].forEach((field) => field?.addEventListener('input', () => { selectedCoordinates = null; })); const coverInput = document.createElement('input'); coverInput.type = 'file'; coverInput.accept = 'image/png,image/jpeg,image/webp'; coverInput.hidden = true; coverInput.className = 'web-activity-cover-input'; coverArea.after(coverInput); let coverFile = null; let coverUrl = ''; let submitting = false; const showCover = (url) => { coverArea.style.background = `url("${url}") center / cover`; coverArea.dataset.previewUrl = url; coverArea.classList.add('has-cover'); }; const selectCover = (file) => { if (!file) return; if (!file.type.startsWith('image/')) { showWebToast('请选择图片文件', 'error'); return; } if (file.size > 10 * 1024 * 1024) { showWebToast('封面图片不能超过 10MB', 'error'); return; } coverFile = file; coverUrl = ''; const reader = new FileReader(); reader.onload = () => showCover(reader.result); reader.readAsDataURL(file); }; coverArea.addEventListener('click', () => coverInput.click()); coverInput.addEventListener('change', () => { selectCover(coverInput.files?.[0]); coverInput.value = ''; }); coverArea.addEventListener('dragover', (event) => { event.preventDefault(); coverArea.classList.add('dragover'); }); coverArea.addEventListener('dragleave', () => coverArea.classList.remove('dragover')); coverArea.addEventListener('drop', (event) => { event.preventDefault(); coverArea.classList.remove('dragover'); selectCover(event.dataTransfer.files?.[0]); }); const padTime = (value) => String(value).padStart(2, '0'); const openCalendar = (input) => { const dialog = document.createElement('dialog'); dialog.className = 'web-date-dialog'; const selected = input.value ? new Date(input.value.replace(' ', 'T')) : new Date(Date.now() + 86400000); let year = selected.getFullYear(); let month = selected.getMonth(); let day = selected.getDate(); const head = document.createElement('div'); head.className = 'web-dialog-head'; const title = document.createElement('span'); title.textContent = input.getAttribute('aria-label'); const close = document.createElement('button'); close.type = 'button'; close.textContent = '×'; close.setAttribute('aria-label', '关闭'); close.addEventListener('click', () => dialog.close()); head.append(title, close); const nav = document.createElement('div'); nav.className = 'web-calendar-nav'; const previous = document.createElement('button'); previous.type = 'button'; previous.textContent = '‹'; previous.setAttribute('aria-label', '上个月'); const monthLabel = document.createElement('span'); const next = document.createElement('button'); next.type = 'button'; next.textContent = '›'; next.setAttribute('aria-label', '下个月'); nav.append(previous, monthLabel, next); const grid = document.createElement('div'); grid.className = 'web-calendar-grid'; const renderDays = () => { monthLabel.textContent = `${year} 年 ${month + 1} 月`; grid.replaceChildren(); ['一', '二', '三', '四', '五', '六', '日'].forEach((label) => { const weekday = document.createElement('span'); weekday.textContent = label; grid.appendChild(weekday); }); const offset = (new Date(year, month, 1).getDay() + 6) % 7; for (let index = 0; index < offset; index++) grid.appendChild(document.createElement('span')); const last = new Date(year, month + 1, 0).getDate(); for (let number = 1; number <= last; number++) { const button = document.createElement('button'); button.type = 'button'; button.textContent = String(number); button.classList.toggle('selected', number === day); button.addEventListener('click', () => { day = number; renderDays(); }); grid.appendChild(button); } }; previous.addEventListener('click', () => { const date = new Date(year, month - 1, 1); year = date.getFullYear(); month = date.getMonth(); day = Math.min(day, new Date(year, month + 1, 0).getDate()); renderDays(); }); next.addEventListener('click', () => { const date = new Date(year, month + 1, 1); year = date.getFullYear(); month = date.getMonth(); day = Math.min(day, new Date(year, month + 1, 0).getDate()); renderDays(); }); const timeRow = document.createElement('div'); timeRow.className = 'web-time-row'; const timeLabel = document.createElement('span'); timeLabel.textContent = '具体时间'; const hour = document.createElement('input'); hour.type = 'number'; hour.min = '0'; hour.max = '23'; hour.setAttribute('aria-label', '小时'); hour.value = padTime(input.value ? selected.getHours() : 18); const colon = document.createElement('span'); colon.textContent = ':'; const minute = document.createElement('input'); minute.type = 'number'; minute.min = '0'; minute.max = '59'; minute.setAttribute('aria-label', '分钟'); minute.value = padTime(input.value ? selected.getMinutes() : 0); timeRow.append(timeLabel, hour, colon, minute); const foot = document.createElement('div'); foot.className = 'web-dialog-foot'; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = '取消'; cancel.addEventListener('click', () => dialog.close()); const confirm = document.createElement('button'); confirm.type = 'button'; confirm.className = 'primary'; confirm.textContent = '确定'; confirm.addEventListener('click', () => { const hours = Number(hour.value); const minutes = Number(minute.value); if (!Number.isInteger(hours) || hours < 0 || hours > 23 || !Number.isInteger(minutes) || minutes < 0 || minutes > 59) { showWebToast('请输入正确的时间', 'error'); return; } input.value = `${year}-${padTime(month + 1)}-${padTime(day)} ${padTime(hours)}:${padTime(minutes)}`; input.dispatchEvent(new Event('change', { bubbles: true })); dialog.close(); }); foot.append(cancel, confirm); dialog.append(head, nav, grid, timeRow, foot); document.body.appendChild(dialog); dialog.addEventListener('close', () => dialog.remove(), { once: true }); renderDays(); dialog.showModal(); }; [start, end, deadline].forEach((input) => { input?.addEventListener('click', () => openCalendar(input)); input?.parentElement.querySelector('.time-picker-btn')?.addEventListener('click', () => openCalendar(input)); }); start?.addEventListener('change', () => { if (end) end.min = start.value; }); document.getElementById('activity-desc')?.addEventListener('input', (event) => { document.getElementById('desc-count').textContent = String(event.target.value.length); }); const uploadCover = async () => { if (!coverFile) return coverUrl; const form = new FormData(); form.append('file', coverFile); const result = await window.YouthomeApi.request('/file/upload/image', { method: 'POST', body: form }); if (!result?.url) throw new Error('封面上传失败,请重试'); coverUrl = result.url; coverFile = null; return coverUrl; }; // 统一外壳隐藏了页面顶栏,把草稿/发布移到可见区域 const mainPublish = document.querySelector('.btn-publish-main'); if (save || mainPublish) { const bar = document.createElement('div'); bar.className = 'web-publish-actions'; const heading = document.createElement('strong'); heading.textContent = '发起活动'; bar.appendChild(heading); if (previewButton) bar.appendChild(previewButton); if (save) bar.appendChild(save); if (mainPublish) bar.appendChild(mainPublish); const anchor = document.querySelector('.stepper-wrapper, .edit-form, .content-wrapper, .main-content'); if (anchor) anchor.parentElement.insertBefore(bar, anchor); } const validate = () => { if (!titleInput?.value.trim()) { showWebToast('请输入活动标题', 'error'); titleInput?.focus(); return false; } const typeSelect = document.querySelector('#activity-type'); if (typeSelect && !typeSelect.value) { showWebToast('请选择活动类型', 'error'); typeSelect.focus(); return false; } const descInput = document.querySelector('#activity-desc'); if (descInput && !descInput.value.trim()) { showWebToast('请填写活动介绍', 'error'); descInput.focus(); return false; } if (!coverFile && !coverUrl) { showWebToast('请上传活动封面', 'error'); coverArea.focus(); return false; } if (!start?.value || !end?.value || !deadline?.value) { showWebToast('请填写活动时间和报名截止时间', 'error'); start?.focus(); return false; } const startAt = new Date(start.value.replace(' ', 'T')).getTime(); const endAt = new Date(end.value.replace(' ', 'T')).getTime(); const deadlineAt = new Date(deadline.value.replace(' ', 'T')).getTime(); if (startAt <= Date.now()) { showWebToast('活动开始时间必须晚于现在', 'error'); start.focus(); return false; } if (endAt - startAt < 30 * 60000) { showWebToast('活动时长至少 30 分钟', 'error'); end.focus(); return false; } if (deadlineAt <= Date.now() || deadlineAt > startAt) { showWebToast('报名截止时间应晚于现在且不晚于活动开始', 'error'); deadline.focus(); return false; } if (!locationName?.value.trim()) { showWebToast('请填写活动地点', 'error'); locationName?.focus(); return false; } if (document.querySelector('#fee-toggle .fee-option.active')?.dataset.fee === 'paid' && !(Number(document.getElementById('fee-amount')?.value) > 0)) { showWebToast('请填写活动费用', 'error'); return false; } return true; }; const formData = () => ({ title: titleInput?.value || '', description: document.getElementById('activity-desc')?.value || '', type: document.getElementById('activity-type')?.value || '', start: start?.value || '', end: end?.value || '', deadline: deadline?.value || '', location: locationName?.value || '', address: locationAddress?.value || '', coordinates: selectedCoordinates, maxCount: document.getElementById('people-count')?.value || '20', fee: document.querySelector('#fee-toggle .fee-option.active')?.dataset.fee || 'free', feeAmount: document.getElementById('fee-amount')?.value || '', feeDescription: document.getElementById('fee-desc')?.value || '', coverUrl }); save?.addEventListener('click', async () => { save.disabled = true; try { await uploadCover(); localStorage.setItem('youthome:web:activityDraft', JSON.stringify(formData())); showWebToast('活动草稿已保存'); } catch (error) { showWebToast(error.message || '草稿保存失败', 'error'); } finally { save.disabled = false; } }); try { const draft = JSON.parse(localStorage.getItem('youthome:web:activityDraft') || 'null'); if (draft) { const fields = { 'activity-title': draft.title, 'activity-desc': draft.description, 'activity-type': draft.type, 'start-time': draft.start, 'end-time': draft.end, 'registration-deadline': draft.deadline, 'location-name': draft.location, 'location-address': draft.address, 'people-count': draft.maxCount, 'fee-amount': draft.feeAmount, 'fee-desc': draft.feeDescription }; Object.entries(fields).forEach(([id, value]) => { if (value != null) document.getElementById(id).value = value; }); selectedCoordinates = draft.coordinates || null; if (draft.fee === 'paid') document.querySelector('#fee-toggle .fee-option[data-fee="paid"]')?.click(); if (draft.coverUrl) { coverUrl = draft.coverUrl; showCover(coverUrl); } titleInput.dispatchEvent(new Event('input')); document.getElementById('activity-desc').dispatchEvent(new Event('input')); } } catch (error) { localStorage.removeItem('youthome:web:activityDraft'); } const normalizeTime = (value) => String(value || '').replace('T', ' ') + ':00'; publishButtons.forEach((button) => button.addEventListener('click', async () => { if (submitting) return; if (!validate()) return; const api = window.YouthomeApi; submitting = true; publishButtons.forEach((node) => { node.disabled = true; }); button.disabled = true; try { const uploadedCover = await uploadCover(); const payload = { title: titleInput.value.trim(), description: document.getElementById('activity-desc').value.trim(), type: document.getElementById('activity-type').value, coverImage: uploadedCover, location: [locationName.value.trim(), document.getElementById('location-address').value.trim()].filter(Boolean).join(' '), latitude: selectedCoordinates?.latitude ?? null, longitude: selectedCoordinates?.longitude ?? null, startTime: normalizeTime(start.value), endTime: normalizeTime(end.value), registrationDeadline: normalizeTime(deadline.value), maxCount: document.getElementById('people-limit-switch').classList.contains('active') ? Number(document.getElementById('people-count').value) : 999, feeAmount: document.querySelector('#fee-toggle .fee-option.active')?.dataset.fee === 'paid' ? Number(document.getElementById('fee-amount').value) : 0, feeDescription: document.getElementById('fee-desc').value.trim(), scope: 'CAMPUS' }; const result = await api.post('/campus/activities', payload); if (!result?.id) throw new Error('发布结果缺少活动编号'); localStorage.removeItem('youthome:web:activityDraft'); location.href = 'activity-detail.html?id=' + encodeURIComponent(result.id); } catch (error) { showWebToast(error.message || '创建失败,请重试', 'error'); publishButtons.forEach((node) => { node.disabled = false; }); submitting = false; } })); previewButton?.addEventListener('click', () => { if (!validate()) return; const summary = [titleInput.value.trim(), document.getElementById('activity-desc').value.trim(), `${start.value.replace('T', ' ')} 至 ${end.value.replace('T', ' ')}`, `报名截止:${deadline.value.replace('T', ' ')}`, `地点:${locationName.value.trim()}`]; const dialog = document.createElement('dialog'); dialog.className = 'web-activity-preview'; const heading = document.createElement('h3'); heading.textContent = '活动预览'; const image = document.createElement('img'); image.src = coverArea.dataset.previewUrl; image.alt = '活动封面'; const details = document.createElement('div'); summary.forEach((line) => { const row = document.createElement('p'); row.textContent = line; details.appendChild(row); }); const close = document.createElement('button'); close.type = 'button'; close.textContent = '关闭预览'; close.addEventListener('click', () => dialog.close()); dialog.append(heading, image, details, close); document.body.appendChild(dialog); dialog.addEventListener('close', () => dialog.remove(), { once: true }); dialog.showModal(); }); document.querySelector('.location-locate-btn')?.addEventListener('click', () => { const addressInput = locationAddress; const dialog = document.createElement('dialog'); dialog.className = 'web-map-dialog'; const head = document.createElement('div'); head.className = 'web-dialog-head'; const title = document.createElement('span'); title.textContent = '选择活动地点'; const close = document.createElement('button'); close.type = 'button'; close.textContent = '×'; close.setAttribute('aria-label', '关闭'); close.addEventListener('click', () => dialog.close()); head.append(title, close); const search = document.createElement('div'); search.className = 'web-map-search'; const query = document.createElement('input'); query.type = 'search'; query.placeholder = '搜索地点或地址'; query.value = locationName.value.trim(); const searchButton = document.createElement('button'); searchButton.type = 'button'; searchButton.textContent = '搜索'; search.append(query, searchButton); const canvas = document.createElement('div'); canvas.className = 'web-map-canvas'; const status = document.createElement('div'); status.className = 'web-map-status'; status.textContent = '点击地图选取位置,也可以搜索地点'; const foot = document.createElement('div'); foot.className = 'web-dialog-foot'; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = '取消'; cancel.addEventListener('click', () => dialog.close()); const confirm = document.createElement('button'); confirm.type = 'button'; confirm.className = 'primary'; confirm.textContent = '使用此地点'; confirm.disabled = true; foot.append(cancel, confirm); dialog.append(head, search, canvas, status, foot); document.body.appendChild(dialog); dialog.addEventListener('close', () => dialog.remove(), { once: true }); dialog.showModal(); if (!window.L) { status.textContent = '地图未能加载,请手动填写地点'; return; } const map = L.map(canvas).setView([31.299, 121.504], 15); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(map); let marker = null; let selection = null; let selectionRevision = 0; const pin = L.divIcon({ className: '', html: '
', iconSize: [18, 18], iconAnchor: [9, 9] }); const selectPoint = (lat, lon, name, address) => { selection = { name: name || '地图选取的位置', address: address || `${lat.toFixed(6)}, ${lon.toFixed(6)}`, latitude: lat, longitude: lon }; if (marker) marker.setLatLng([lat, lon]); else marker = L.marker([lat, lon], { icon: pin }).addTo(map); map.panTo([lat, lon]); status.textContent = selection.name + ' · ' + selection.address; confirm.disabled = false; }; const lookup = async (url) => { const response = await fetch(url, { headers: { 'Accept-Language': 'zh-CN,zh' }, signal: AbortSignal.timeout(10000) }); if (!response.ok) throw new Error('地点搜索暂不可用'); return response.json(); }; map.on('click', async (event) => { const { lat, lng } = event.latlng; const revision = ++selectionRevision; selectPoint(lat, lng); try { const place = await lookup(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`); if (revision === selectionRevision && dialog.open) selectPoint(lat, lng, place.name || place.display_name?.split(',')[0], place.display_name); } catch (error) { status.textContent = '已选取坐标,可直接使用或手动修改详细地址'; } }); const searchPlace = async () => { if (!query.value.trim()) return; searchButton.disabled = true; status.textContent = '正在搜索地点…'; try { const places = await lookup('https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query.value.trim())); if (!places.length) { status.textContent = '没有找到地点,请换个关键词'; return; } const place = places[0]; const lat = Number(place.lat); const lon = Number(place.lon); selectionRevision++; map.setView([lat, lon], 17); selectPoint(lat, lon, place.name || place.display_name.split(',')[0], place.display_name); } catch (error) { status.textContent = error.message || '地点搜索暂不可用,请点击地图选点'; } finally { searchButton.disabled = false; } }; searchButton.addEventListener('click', searchPlace); query.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); searchPlace(); } }); confirm.addEventListener('click', () => { if (!selection) return; locationName.value = selection.name; addressInput.value = selection.address.startsWith(selection.name) ? selection.address.slice(selection.name.length).replace(/^[,,\s]+/, '') : selection.address; locationName.dispatchEvent(new Event('input', { bubbles: true })); addressInput.dispatchEvent(new Event('input', { bubbles: true })); selectedCoordinates = { latitude: selection.latitude, longitude: selection.longitude }; dialog.close(); }); dialog.addEventListener('close', () => map.remove(), { once: true }); setTimeout(() => map.invalidateSize(), 0); }); }; const initApiTopic = async (api) => { const params = new URLSearchParams(location.search); let topicName = (params.get('topic') || params.get('name') || '').trim(); const titleNode = document.querySelector('.topic-title'); if (!topicName) topicName = (titleNode?.textContent || '').replace(/^#/, '').trim(); if (!topicName) return; if (titleNode) titleNode.textContent = '#' + topicName; document.title = topicName + ' - 青核 Youthome'; const grid = document.querySelector('.feed-grid'); const tabs = Array.from(document.querySelectorAll('.topic-tab')); if (!grid) return; let sort = 'latest'; const composer = document.createElement('div'); composer.className = 'web-discussion-composer'; const textarea = document.createElement('textarea'); textarea.placeholder = '分享你对这个话题的想法…'; textarea.rows = 2; const publishBtn = document.createElement('button'); publishBtn.type = 'button'; publishBtn.className = 'web-discussion-publish'; publishBtn.textContent = '发布讨论'; composer.append(textarea, publishBtn); const list = document.createElement('div'); list.className = 'web-discussion-list'; grid.replaceChildren(composer, list); const buildCard = (item) => { const card = document.createElement('article'); card.className = 'web-discussion-card'; const head = document.createElement('div'); head.className = 'web-discussion-head'; const avatar = document.createElement('span'); avatar.className = 'web-discussion-avatar'; avatar.textContent = (item.userName || '青').slice(0, 1); const name = document.createElement('b'); name.textContent = item.anonymous ? '匿名' : (item.userName || '青核用户'); const time = document.createElement('span'); time.className = 'web-discussion-time'; time.textContent = String(item.createdAt || '').slice(0, 16); head.append(avatar, name, time); const content = document.createElement('p'); content.className = 'web-discussion-content'; content.textContent = item.content || ''; card.append(head, content); if (item.mediaUrls?.length) { const media = document.createElement('div'); media.className = 'web-discussion-media'; item.mediaUrls.slice(0, 9).forEach((url) => { const img = document.createElement('img'); img.src = url; img.alt = ''; media.appendChild(img); }); card.appendChild(media); } return card; }; const render = async () => { list.replaceChildren(); const loading = document.createElement('p'); loading.className = 'web-live-empty'; loading.textContent = '正在加载讨论…'; list.appendChild(loading); try { const data = await api.get('/topics/' + encodeURIComponent(topicName) + '/discussions?sort=' + sort + '&page=1&size=30'); const items = data?.items || []; list.replaceChildren(...items.map(buildCard)); if (!items.length) { const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = '还没有讨论,来发第一条吧'; list.appendChild(empty); } } catch (error) { list.replaceChildren(); const empty = document.createElement('p'); empty.className = 'web-live-empty'; empty.textContent = error.message || '讨论加载失败'; list.appendChild(empty); } }; publishBtn.addEventListener('click', async () => { if (!api.token?.()) { showWebToast('请先登录后再发布', 'error'); location.href = 'login.html?next=' + encodeURIComponent('topic-detail.html?topic=' + topicName); return; } const content = textarea.value.trim(); if (!content) { showWebToast('请输入讨论内容', 'error'); textarea.focus(); return; } publishBtn.disabled = true; publishBtn.textContent = '发布中…'; try { await api.post('/topics/' + encodeURIComponent(topicName) + '/discussions', { content, mediaUrls: [], anonymous: false }); textarea.value = ''; showWebToast('讨论已发布'); sort = 'latest'; tabs.forEach((tab) => tab.classList.toggle('active', tab.textContent.trim() === '最新')); await render(); } catch (error) { showWebToast(error.message || '发布失败,请重试', 'error'); } finally { publishBtn.disabled = false; publishBtn.textContent = '发布讨论'; } }); tabs.forEach((tab) => { tab.addEventListener('click', () => { tabs.forEach((node) => node.classList.remove('active')); tab.classList.add('active'); sort = tab.textContent.trim() === '热门' ? 'hot' : 'latest'; render(); }); }); api.get('/topics/' + encodeURIComponent(topicName)).then((detail) => { if (!detail) return; const desc = document.querySelector('.topic-desc'); if (desc && detail.description) desc.textContent = detail.description; }).catch(() => {}); await render(); }; const initTopic = () => { const api = window.YouthomeApi; if (api?.isLocal && !api.isLocal()) return initApiTopic(api); const tabs = Array.from(document.querySelectorAll('.topic-tab')); const cards = Array.from(document.querySelectorAll('.post-card')); const list = cards[0]?.parentElement; const load = document.querySelector('.load-more-btn'); if (!tabs.length || !cards.length || !list) return; let expanded = false; const render = (label) => { const matched = cards.filter((card, index) => label === '热门' || label === '最新' || (label === '图文' ? index % 3 !== 1 : index % 3 === 1)); cards.forEach((card) => { card.hidden = true; }); matched.slice(0, expanded ? matched.length : 5).forEach((card) => { card.hidden = false; }); if (load) { load.hidden = matched.length <= 5; load.textContent = expanded ? '已加载全部' : `加载更多(${matched.length - 5})`; } }; cards.forEach((card) => makeLinkedCard(card, 'post-detail.html')); tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => { expanded = false; setPressed(tabs, tab); render(tab.textContent.trim()); }); }); load?.addEventListener('click', () => { expanded = true; render(document.querySelector('.topic-tab.active')?.textContent.trim() || '热门'); }); document.querySelector('.btn-share')?.addEventListener('click', () => showWebToast('话题链接已准备好,可从地址栏复制')); document.querySelectorAll('.related-topic-item').forEach((item) => makeLinkedCard(item, 'topic-detail.html')); render(tabs[0].textContent.trim()); }; const wireSideWidgets = () => { const rotateList = (container, itemSelector) => { const items = Array.from(container.querySelectorAll(itemSelector)); if (items.length < 2) return false; container.appendChild(items[0]); container.querySelectorAll('.hot-rank, .topic-rank, .rank-number').forEach((node, index) => { node.textContent = String(index + 1); }); return true; }; document.querySelectorAll('.widget-more').forEach((button) => { // 动态广场的这两个按钮由 initFeed 处理,避免重复绑定 if (button.classList.contains('topic-more-button') || button.classList.contains('recommendation-refresh')) return; const widget = button.closest('.widget, .sidebar-widget'); if (!widget) return; makeKeyboardClickable(button, () => button.click()); button.addEventListener('click', () => { const label = button.textContent.trim(); const hotList = widget.querySelector('.hot-list'); const related = widget.querySelector('.related-topic-list'); const extras = Array.from(widget.querySelectorAll('.topic-item-extra')); if (extras.length) { const expanded = button.dataset.expanded !== 'true'; button.dataset.expanded = String(expanded); extras.forEach((item) => { item.hidden = !expanded; }); button.textContent = expanded ? '收起' : '更多'; return; } if (/换一换|换一批/.test(label)) { const rotated = (hotList && rotateList(hotList, '.hot-item')) || (related && rotateList(related, '.related-topic-item')); showWebToast(rotated ? '已换一批' : '暂时没有更多内容'); return; } if (widget.querySelector('.related-topic-list') || hotList) { location.href = 'feed.html'; return; } if (widget.querySelector('.active-user-list') || widget.querySelector('.user-list')) { location.href = 'follow.html'; return; } showWebToast('完整列表请在对应页面查看'); }); }); }; const walletLevels = [ { level: 1, name: '青核新芽', need: 0 }, { level: 2, name: '校园达人', need: 500 }, { level: 3, name: '活跃分子', need: 1500 }, { level: 4, name: '校园之星', need: 3000 }, { level: 5, name: '青核之光', need: 6000 } ]; const levelOfPoints = (points) => walletLevels.reduce((current, item) => (points >= item.need ? item : current), walletLevels[0]); const initWallet = () => { const api = window.YouthomeApi; const store = window.YouthomeLocal; const isLocal = api?.isLocal ? api.isLocal() : true; const stamp2 = (value) => String(value).padStart(2, '0'); const dayKey = () => { const date = new Date(); return `${date.getFullYear()}-${stamp2(date.getMonth() + 1)}-${stamp2(date.getDate())}`; }; const sign = document.querySelector('.btn-signin'); const pointsNode = document.querySelector('.points-number'); const statValues = document.querySelectorAll('.points-stats .stat-value'); const statLabels = document.querySelectorAll('.points-stats .stat-label'); const todayEarned = () => Number(localStorage.getItem(`youthome:web:points-today:${dayKey()}`) || 0); const checkedInToday = () => localStorage.getItem(`youthome:web:checkin:${dayKey()}`) === 'true'; let remote = null; const renderLevels = (points) => { const level = levelOfPoints(points); const badge = statValues[2]?.querySelector('.level-badge'); if (badge) { const icon = badge.querySelector('svg'); badge.replaceChildren(...(icon ? [icon] : []), document.createTextNode(` LV.${level.level}`)); } const list = document.querySelector('.level-list'); if (!list) return; list.replaceChildren(...walletLevels.map((item) => { const row = document.createElement('div'); row.className = `level-item${item.level === level.level ? ' is-current' : ''}`; const left = document.createElement('div'); const name = document.createElement('div'); name.className = 'level-name'; name.textContent = `LV.${item.level} ${item.name}`; const need = document.createElement('div'); need.className = 'level-need'; need.textContent = item.need === 0 ? '注册即可获得' : `${item.need} 积分解锁`; left.append(name, need); const tag = document.createElement('span'); tag.className = 'level-tag'; tag.textContent = item.level === level.level ? '当前等级' : points >= item.need ? '已解锁' : '待解锁'; row.append(left, tag); return row; })); }; const renderStats = async () => { let points = 0; if (isLocal) { const account = store?.account?.(); points = account?.points || 0; if (statValues[0]) statValues[0].textContent = `+${todayEarned()}`; if (statLabels[0]) statLabels[0].textContent = '今日获得'; if (statValues[1]) statValues[1].textContent = String((account?.redemptions || []).length); if (statLabels[1]) statLabels[1].textContent = '已兑换'; } else { try { remote = await api.request('/campus/points/account'); } catch (error) { showWebToast(error.message || '积分加载失败', 'error'); remote = null; } points = Number(remote?.points || 0); if (statValues[0]) statValues[0].textContent = '+' + Number(remote?.todayPoints || 0); if (statLabels[0]) statLabels[0].textContent = '今日获得'; if (statValues[1]) statValues[1].textContent = '+' + Number(remote?.totalEarned || 0); if (statLabels[1]) statLabels[1].textContent = '累计获得'; } if (pointsNode) pointsNode.textContent = points.toLocaleString('zh-CN'); renderLevels(points); if (sign) { const done = isLocal ? checkedInToday() : Boolean(remote?.checkedInToday); sign.disabled = done; sign.textContent = done ? '今日已签到' : '签到'; } }; // 标签页:任务中心 / 积分商城 / 等级特权 const panelNames = { 任务中心: 'task', 积分商城: 'mall', 等级特权: 'level' }; const panels = Array.from(document.querySelectorAll('[data-wallet-panel]')); const tabs = Array.from(document.querySelectorAll('.tab-nav .tab-item')); const showPanel = (name) => { const key = panelNames[name] || 'task'; panels.forEach((panel) => { panel.hidden = panel.dataset.walletPanel !== key; }); tabs.forEach((tab) => tab.classList.toggle('active', tab.textContent.trim() === name)); const params = new URLSearchParams(); if (key !== 'task') params.set('tab', name); history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); }; tabs.forEach((tab) => { makeKeyboardClickable(tab, () => tab.click()); tab.addEventListener('click', () => showPanel(tab.textContent.trim())); }); // 签到 localStorage.removeItem('youthome:web:walletCheckedIn'); sign?.addEventListener('click', async () => { if (isLocal) { if (!store?.account?.()) { showWebToast('请先登录后再签到', 'error'); return; } if (checkedInToday()) { showWebToast('今天已经签到过了,明天再来'); return; } try { store.addPoints(5); localStorage.setItem(`youthome:web:checkin:${dayKey()}`, 'true'); localStorage.setItem(`youthome:web:points-today:${dayKey()}`, String(todayEarned() + 5)); } catch (error) { showWebToast(error.message || '签到失败,请重试', 'error'); return; } showWebToast('签到成功,积分 +5'); } else { try { await api.checkIn(); showWebToast('签到成功'); } catch (error) { showWebToast(error.message || '签到失败,请重试', 'error'); return; } } await renderStats(); }); const openPointsDetail = async () => { if (isLocal) { const account = store?.account?.(); showWebToast(`当前 ${account ? (account.points || 0) : 0} 积分,今日获得 ${todayEarned()},已兑换 ${(account?.redemptions || []).length} 次`); return; } const overlay = document.createElement('div'); overlay.className = 'web-sheet-overlay'; const panel = document.createElement('div'); panel.className = 'web-sheet-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); const head = document.createElement('div'); head.className = 'web-sheet-head'; const title = document.createElement('div'); title.className = 'web-sheet-title'; title.textContent = '积分明细'; const closeBtn = document.createElement('button'); closeBtn.className = 'web-sheet-close'; closeBtn.type = 'button'; closeBtn.textContent = '×'; head.append(title, closeBtn); const body = document.createElement('div'); body.className = 'web-sheet-body'; const hint = document.createElement('p'); hint.className = 'web-sheet-hint'; hint.textContent = '正在加载…'; body.appendChild(hint); panel.append(head, body); overlay.appendChild(panel); document.body.appendChild(overlay); const close = () => overlay.remove(); overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); closeBtn.addEventListener('click', close); try { const data = await api.get('/campus/points/transactions?page=1&size=50'); const items = data?.items || []; body.replaceChildren(); if (!items.length) { hint.textContent = '还没有积分记录'; body.appendChild(hint); return; } items.forEach((item) => { const row = document.createElement('div'); row.className = 'web-points-row'; const left = document.createElement('div'); left.className = 'web-points-left'; const name = document.createElement('b'); name.textContent = item.ruleName || item.remark || '积分变动'; const time = document.createElement('span'); time.textContent = String(item.createdAt || '').slice(0, 16); left.append(name, time); const amount = document.createElement('span'); amount.className = 'web-points-amount' + (Number(item.points) > 0 ? ' in' : ' out'); amount.textContent = (Number(item.points) > 0 ? '+' : '') + Number(item.points || 0); row.append(left, amount); body.appendChild(row); }); } catch (error) { hint.textContent = error.message || '积分明细加载失败'; hint.classList.add('error'); } }; document.querySelector('.btn-detail')?.addEventListener('click', openPointsDetail); // 积分商城 const mallGrid = document.querySelector('.mall-grid'); const bindMallButton = (card) => { const name = card.querySelector('.mall-name')?.textContent.trim() || '商品'; const cost = Number((card.querySelector('.mall-points')?.textContent || '').replace(/[^0-9]/g, '')); const button = card.querySelector('.mall-btn'); if (!button || button.dataset.bound === 'true') return; button.dataset.bound = 'true'; makeKeyboardClickable(button, () => button.click()); button.addEventListener('click', async () => { if (isLocal) { if (!store?.account?.()) { showWebToast('请先登录后再兑换', 'error'); return; } try { const result = store.redeem(cost, name); showWebToast(`已兑换「${name}」,剩余 ${result.points} 积分`); await renderStats(); } catch (error) { showWebToast(error.message || '兑换失败', 'error'); } return; } if (!card.dataset.productId) { showWebToast('商品信息不完整,请稍后再试', 'error'); return; } try { await api.purchase(card.dataset.productId); showWebToast(`已兑换「${name}」`); await renderStats(); } catch (error) { showWebToast(error.message || '兑换失败', 'error'); } }); }; const renderApiMall = async () => { if (!mallGrid) return; let products = []; try { products = await api.mallProducts(); } catch (error) { showWebToast(error.message || '商品加载失败', 'error'); return; } const template = mallGrid.querySelector('.mall-card'); if (!template || !products.length) return; const tints = [ 'linear-gradient(135deg, #BCEAF3, #17B0CF)', 'linear-gradient(135deg, #FFE0CC, #FF9F45)', 'linear-gradient(135deg, #D4F7E7, #34C77B)', 'linear-gradient(135deg, #D4B5FF, #9254DE)' ]; mallGrid.replaceChildren(...products.map((product, index) => { const card = template.cloneNode(true); card.dataset.productId = product.id; const cover = card.querySelector('.mall-cover'); if (cover) { cover.style.background = tints[index % tints.length]; cover.textContent = String(product.name || '物').slice(0, 1); } const nameNode = card.querySelector('.mall-name'); if (nameNode) nameNode.textContent = product.name || '未命名商品'; const priceNode = card.querySelector('.mall-points'); if (priceNode && priceNode.lastChild) priceNode.lastChild.textContent = ' ' + (product.price ?? 0); return card; })); mallGrid.querySelectorAll('.mall-card').forEach(bindMallButton); }; document.querySelectorAll('.btn-task-done').forEach((button) => { makeKeyboardClickable(button, () => button.click()); button.addEventListener('click', () => showWebToast('该任务今天已完成,明天可以再领')); }); const taskTargets = { 去发布: 'publish-post.html', 继续浏览: 'feed.html', 继续点赞: 'feed.html', 去评论: 'feed.html', 去分享: 'feed.html', 去认证: 'authentication.html', 继续关注: 'follow.html', 去邀请: 'follow.html' }; document.querySelectorAll('.btn-task:not(.btn-task-done)').forEach((button) => { button.addEventListener('click', () => { location.href = taskTargets[button.textContent.trim()] || 'feed.html'; }); }); const requestedTab = new URLSearchParams(location.search).get('tab'); showPanel(panelNames[requestedTab] ? requestedTab : tabs[0]?.textContent.trim() || '任务中心'); if (isLocal) mallGrid?.querySelectorAll('.mall-card').forEach(bindMallButton); else renderApiMall(); renderStats(); }; const initAuthRouting = () => { if (page === 'register.html') { window.selectSchool = () => { location.href = 'select-school.html'; }; const school = localStorage.getItem('youthome:web:selectedSchool'); const schoolLabel = document.querySelector('.school-select .text'); if (school && schoolLabel) { schoolLabel.textContent = school; schoolLabel.classList.remove('placeholder'); } window.doRegister = () => { const phone = document.querySelector('input[type="tel"]'); const code = document.querySelector('input[placeholder*="验证码"]'); const password = document.querySelector('input[type="password"]'); const agreement = document.querySelector('input[type="checkbox"]'); if (!school) { showWebToast('请先选择学校', 'error'); return; } if (!/^1\d{10}$/.test(phone?.value.trim() || '')) { showWebToast('请输入正确的 11 位手机号', 'error'); phone?.focus(); return; } if (!/^\d{6}$/.test(code?.value.trim() || '')) { showWebToast('请输入 6 位验证码', 'error'); code?.focus(); return; } if (!/^(?=.*[A-Za-z])(?=.*\d).{8,20}$/.test(password?.value || '')) { showWebToast('密码需为 8-20 位且包含字母和数字', 'error'); password?.focus(); return; } if (!agreement?.checked) { showWebToast('请阅读并同意用户协议和隐私政策', 'error'); return; } location.href = 'register-success.html'; }; } if (page === 'select-school.html') { document.querySelectorAll('.school-card').forEach((card) => card.addEventListener('click', () => { localStorage.setItem('youthome:web:selectedSchool', card.dataset.school || card.textContent.trim()); })); window.goNext = () => { const selected = document.querySelector('.school-card.selected') || document.querySelector('.school-card'); if (!selected) { showWebToast('请选择你的学校', 'error'); return; } localStorage.setItem('youthome:web:selectedSchool', selected.dataset.school || selected.textContent.trim()); location.href = 'register.html'; }; } if (page === 'authentication.html') { window.alert = (message) => showWebToast(message, 'error'); window.returnHome = () => { location.href = 'me.html'; }; } }; const navigate = (target) => { if (!target) return; document.body.classList.remove('web-drawer-open'); location.href = target; }; document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-href]'); if (trigger) { event.preventDefault(); navigate(trigger.dataset.href); return; } const publishButton = event.target.closest('.web-publish-button'); const publishWrap = document.querySelector('.web-publish-wrap'); if (publishButton && publishWrap) { const open = publishWrap.classList.toggle('is-open'); publishButton.setAttribute('aria-expanded', String(open)); return; } if (publishWrap && !event.target.closest('.web-publish-wrap')) { publishWrap.classList.remove('is-open'); publishWrap.querySelector('.web-publish-button')?.setAttribute('aria-expanded', 'false'); } }); document.querySelector('.web-brand')?.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') navigate('feed.html'); }); topbar.querySelector('.web-global-search')?.addEventListener('submit', (event) => { event.preventDefault(); const query = event.currentTarget.querySelector('input').value.trim(); location.href = query ? `search.html?q=${encodeURIComponent(query)}` : 'search.html'; }); if (page === 'activities.html') initActivities(); if (page === 'video.html') initVideo(); if (page === 'feed.html') initFeed(); if (page === 'search.html') initSearch(); if (page === 'notification.html') initNotifications(); if (page === 'settings.html') initSettings(); if (page === 'favorites.html') initFavorites(); if (page === 'drafts.html') initDrafts(); if (page === 'follow.html') initFollow(); if (page === 'my-activity-apply.html') initMyActivities(); if (page === 'me.html') initProfile(); if (page === 'me-edit.html') initProfileEdit(); if (page === 'chat.html') initChat(); if (page === 'help.html') initHelp(); if (page === 'activity-detail.html') initActivityDetail(); if (page === 'post-detail.html' || page === 'post-detail-1.html' || page === 'video-detail.html') initDetailActions(); if (page === 'user.html') initUser(); const initVideoPublish = () => { const api = window.YouthomeApi; if (!api) return; const area = document.querySelector('.upload-area'); if (!area) return; const videoInput = document.createElement('input'); videoInput.type = 'file'; videoInput.accept = 'video/mp4,video/quicktime,video/webm'; videoInput.hidden = true; videoInput.className = 'web-video-input'; const coverInput = document.createElement('input'); coverInput.type = 'file'; coverInput.accept = 'image/png,image/jpeg,image/webp'; coverInput.hidden = true; coverInput.className = 'web-cover-input'; area.after(videoInput, coverInput); const preview = document.createElement('video'); preview.className = 'web-video-preview'; preview.controls = true; preview.playsInline = true; preview.preload = 'metadata'; preview.hidden = true; preview.addEventListener('click', (event) => event.stopPropagation()); area.appendChild(preview); const titleInput = document.querySelector('input[placeholder*="视频标题"]'); const description = document.querySelector('textarea[placeholder*="介绍一下"]'); const coverThumb = document.querySelector('.cover-thumb'); const draftKey = 'youthome:web:videoDraft'; const progress = document.querySelector('.upload-progress-wrap'); document.querySelectorAll('.info-item-value').forEach((node) => { node.textContent = '—'; }); progress.hidden = true; progress.querySelector('.progress-bar')?.setAttribute('hidden', ''); progress.querySelector('.progress-percent')?.setAttribute('hidden', ''); let file = null; let videoReference = ''; let videoObjectUrl = ''; let coverFile = null; let coverReference = ''; let publishing = false; const tags = ['校园日常']; const formatDuration = (seconds) => `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`; const setCover = (url) => { coverThumb.style.background = `url("${url}") center / cover`; coverThumb.classList.add('has-cover'); }; const showVideo = async (url) => { if (videoObjectUrl) URL.revokeObjectURL(videoObjectUrl); videoObjectUrl = url; preview.src = url; preview.hidden = false; area.classList.add('has-video'); preview.load(); }; preview.addEventListener('loadedmetadata', () => { const values = document.querySelectorAll('.info-item-value'); if (values[0]) values[0].textContent = formatDuration(preview.duration || 0); if (values[2]) values[2].textContent = `${preview.videoWidth} × ${preview.videoHeight}`; }); preview.addEventListener('error', () => showWebToast('视频文件无法播放,请换用 MP4、MOV 或 WebM 文件', 'error')); const selectVideo = (selected) => { if (!selected) return; if (!/^video\/(mp4|quicktime|webm)$/.test(selected.type)) { showWebToast('请选择 MP4、MOV 或 WebM 视频', 'error'); return; } if (selected.size > 500 * 1024 * 1024) { showWebToast('视频不能超过 500MB', 'error'); return; } file = selected; videoReference = ''; coverFile = null; coverReference = ''; coverThumb.style.background = ''; coverThumb.classList.remove('has-cover'); area.querySelector('.upload-title').textContent = selected.name; area.querySelector('.upload-desc').textContent = '可拖动进度选择封面画面;点击空白处更换文件'; const values = document.querySelectorAll('.info-item-value'); if (values[1]) values[1].textContent = (selected.size / 1024 / 1024).toFixed(1) + ' MB'; if (values[3]) values[3].textContent = selected.name.split('.').pop().toUpperCase(); showVideo(URL.createObjectURL(selected)); }; window.triggerUpload = () => videoInput.click(); videoInput.addEventListener('change', () => { selectVideo(videoInput.files?.[0]); videoInput.value = ''; }); area.addEventListener('dragover', (event) => { event.preventDefault(); area.classList.add('is-dragging'); }); area.addEventListener('dragleave', () => area.classList.remove('is-dragging')); area.addEventListener('drop', (event) => { event.preventDefault(); area.classList.remove('is-dragging'); selectVideo(event.dataTransfer.files?.[0]); }); window.uploadCover = () => coverInput.click(); coverInput.addEventListener('change', () => { const selected = coverInput.files?.[0]; if (!selected) return; if (!selected.type.startsWith('image/')) { showWebToast('请选择图片文件', 'error'); return; } if (selected.size > 10 * 1024 * 1024) { showWebToast('封面图片不能超过 10MB', 'error'); return; } coverFile = selected; coverReference = ''; const reader = new FileReader(); reader.onload = () => setCover(reader.result); reader.readAsDataURL(selected); coverInput.value = ''; }); window.captureCover = async () => { if (!preview.src) { showWebToast('请先选择视频', 'error'); return; } if (preview.readyState < 2) { await Promise.race([ new Promise((resolve) => preview.addEventListener('loadeddata', resolve, { once: true })), new Promise((resolve) => setTimeout(resolve, 5000)) ]); } if (!preview.videoWidth) { showWebToast('视频画面尚未加载,请稍后重试', 'error'); return; } const canvas = document.createElement('canvas'); const scale = Math.min(1, 1280 / preview.videoWidth); canvas.width = Math.round(preview.videoWidth * scale); canvas.height = Math.round(preview.videoHeight * scale); canvas.getContext('2d').drawImage(preview, 0, 0, canvas.width, canvas.height); coverFile = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob && new File([blob], 'video-cover.jpg', { type: 'image/jpeg' })), 'image/jpeg', 0.86)); if (!coverFile) { showWebToast('截取封面失败,请改用上传封面', 'error'); return; } coverReference = ''; setCover(canvas.toDataURL('image/jpeg', 0.86)); showWebToast('已截取当前画面作为封面'); }; const renderTags = () => { const list = document.querySelector('.tags-list'); list.querySelectorAll('.tag').forEach((node) => node.remove()); tags.forEach((tag) => { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'tag tag-primary'; chip.textContent = '#' + tag + ' ×'; chip.title = '移除话题'; chip.addEventListener('click', () => { tags.splice(tags.indexOf(tag), 1); renderTags(); }); list.insertBefore(chip, list.querySelector('.tag-add-btn')); }); }; window.addTopic = () => { if (tags.length >= 5) { showWebToast('最多添加 5 个话题', 'error'); return; } const value = window.prompt('输入话题名称')?.trim().replace(/^#/, ''); if (value && !tags.includes(value)) { tags.push(value.slice(0, 20)); renderTags(); } }; renderTags(); // 统一外壳会隐藏页面原来的顶栏操作区,把“保存草稿/发布”移到可见编辑区 const topBar = document.querySelector('.top-bar-right'); const draftButton = topBar?.querySelector('.btn-secondary'); const publishButton = topBar?.querySelector('.btn-primary'); if (draftButton && publishButton) { const bar = document.createElement('div'); bar.className = 'web-publish-actions'; const heading = document.createElement('strong'); heading.textContent = '发布视频'; bar.append(heading, draftButton, publishButton); const anchor = document.querySelector('.publish-layout, .content-wrapper, .main-content'); if (anchor) anchor.parentElement.insertBefore(bar, anchor); } const upload = async (path, selected) => { const form = new FormData(); form.append('file', selected); const result = await api.request(path, { method: 'POST', body: form }); if (!result?.url) throw new Error('文件上传失败,请重试'); return result.url; }; const ensureVideo = async () => { if (!videoReference && file) videoReference = await upload('/file/upload/video', file); return videoReference; }; window.saveDraft = async () => { try { await ensureVideo(); if (coverFile) { coverReference = await upload('/file/upload/image', coverFile); coverFile = null; } localStorage.setItem(draftKey, JSON.stringify({ title: titleInput.value, description: description.value, videoReference, coverReference, tags })); showWebToast('视频草稿已保存'); } catch (error) { showWebToast(error.message || '草稿保存失败', 'error'); } }; try { const draft = JSON.parse(localStorage.getItem(draftKey) || 'null'); if (draft) { titleInput.value = draft.title || ''; description.value = draft.description || ''; videoReference = draft.videoReference || ''; coverReference = draft.coverReference || ''; if (Array.isArray(draft.tags)) { tags.splice(0, tags.length, ...draft.tags); renderTags(); } if (coverReference) setCover(coverReference); if (videoReference && api.isLocal()) window.YouthomeLocal.videoUrl(videoReference).then(showVideo).catch(() => {}); titleInput.dispatchEvent(new Event('input')); description.dispatchEvent(new Event('input')); } } catch (error) { localStorage.removeItem(draftKey); } window.publishVideo = async () => { if (publishing) return; const title = titleInput.value.trim(); const descriptionText = description.value.trim(); if (!title) { showWebToast('请输入视频标题', 'error'); return; } if (!file && !videoReference) { showWebToast('请先选择视频文件', 'error'); return; } const button = Array.from(document.querySelectorAll('.btn-primary')).find((node) => node.textContent.includes('发布')); publishing = true; if (button) button.disabled = true; try { progress.hidden = false; progress.querySelector('.progress-info span').textContent = '正在保存视频与封面'; const url = await ensureVideo(); if (coverFile) coverReference = await upload('/file/upload/image', coverFile); if (!coverReference) throw new Error('请上传封面或从视频截取一帧'); const visibility = document.querySelector('.radio-item.active')?.textContent.trim(); const switches = document.querySelectorAll('.settings-row .switch'); const moment = await api.post('/campus/moments', { content: descriptionText ? title + '\n' + descriptionText : title, isYouthVideo: true, videoOriginalUrl: url, videoCoverUrl: coverReference, videoDuration: Math.round(preview.duration || 0), tags: [...tags], allowComment: switches[0]?.classList.contains('on') ?? true, allowDownload: switches[1]?.classList.contains('on') ?? false, category: '校园生活', scope: visibility === '公开' ? 'CAMPUS' : 'PERSONAL' }); if (!moment?.id) throw new Error('发布结果缺少视频编号'); localStorage.removeItem(draftKey); location.href = 'video-detail.html?id=' + encodeURIComponent(moment.id); } catch (error) { showWebToast(error.message || '发布失败,请重试', 'error'); if (button) button.disabled = false; } finally { publishing = false; progress.hidden = true; } }; }; if (page === 'publish-post.html' || page === 'publish-video.html') initPublishing(); if (page === 'publish-video.html') initVideoPublish(); if (page === 'activity-create.html') initActivityCreate(); if (page === 'topic-detail.html') initTopic(); if (page === 'wallet.html') initWallet(); if (page === 'search.html' || page === 'topic-detail.html') wireSideWidgets(); if (page === 'register.html' || page === 'select-school.html' || page === 'authentication.html') initAuthRouting(); const closeDrawer = () => document.body.classList.remove('web-drawer-open', 'web-filter-open'); topbar.querySelector('.web-menu-button')?.addEventListener('click', () => document.body.classList.toggle('web-drawer-open')); backdrop.addEventListener('click', closeDrawer); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; closeDrawer(); const publishWrap = document.querySelector('.web-publish-wrap'); publishWrap?.classList.remove('is-open'); publishWrap?.querySelector('.web-publish-button')?.setAttribute('aria-expanded', 'false'); }); window.addEventListener('resize', () => { if (window.innerWidth > 900) closeDrawer(); }); })();