(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 = `
`;
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 = ``;
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 = `
`;
}
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();
});
})();