/* * 网页端本地数据层 * * 这个文件让网页端在没有后端的情况下也能完整演示:登录/注册、发布、草稿、 * 广场、个人中心、详情、评论、点赞、收藏全部走这里,数据保存在浏览器 * localStorage,视频文件存 IndexedDB。接口路径和返回结构尽量保持与真实 * 后端一致,通过 youthome:web:dataSource 在本地与真实接口间切换。 */ (() => { 'use strict'; const DB_KEY = 'youthome:web:db:v1'; const TOKEN_KEY = 'youthome:web:accessToken'; const USER_KEY = 'youthome:web:authUser'; const SCHOOL_KEY = 'youthome:web:selectedSchool'; const IMAGE_MAX_EDGE = 1280; const DB_VERSION = 1; const now = () => Date.now(); const uid = () => (window.crypto?.randomUUID ? window.crypto.randomUUID() : `id-${now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`); const pad = (value) => String(value).padStart(2, '0'); const stamp = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const relative = (value) => { const ms = typeof value === 'number' ? value : Date.parse(value); if (!Number.isFinite(ms)) return '刚刚'; const diff = Math.max(0, now() - ms); if (diff < 60000) return '刚刚'; if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`; if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`; return stamp(ms).slice(0, 10); }; const hash = (text) => { let value = 0; for (let index = 0; index < String(text).length; index += 1) { value = (value * 31 + String(text).charCodeAt(index)) % 100000; } return value; }; const seedUsers = () => [ { id: 'demo-linxiaxia', phone: '13800000000', password: 'qinghe123', nickname: '林小夏', universityName: '复旦大学', bio: '热爱编程的大二学生,正在学习前端开发,欢迎一起交流探讨技术~', avatar: '', points: 2180, fans: 1256, joinedActivities: [], createdAt: now() - 86400000 * 30 }, { id: 'demo-linwanxing', phone: '13800000001', password: 'qinghe123', nickname: '林晚星', universityName: '复旦大学', bio: '图书馆常驻选手', createdAt: now() - 86400000 * 20 }, { id: 'demo-chenpipi', phone: '13800000002', password: 'qinghe123', nickname: '陈皮皮', universityName: '同济大学', bio: '干饭第一名', createdAt: now() - 86400000 * 20 }, { id: 'demo-sumubai', phone: '13800000003', password: 'qinghe123', nickname: '苏慕白', universityName: '华东师范大学', bio: '随手拍校园', createdAt: now() - 86400000 * 18 }, { id: 'demo-zhouyou', phone: '13800000004', password: 'qinghe123', nickname: '周游世界', universityName: '上海交通大学', bio: '穿搭 & 旅行', createdAt: now() - 86400000 * 15 }, { id: 'demo-lixuezhang', phone: '13800000005', password: 'qinghe123', nickname: '李学长', universityName: '上海大学', bio: '考研上岸经验分享', createdAt: now() - 86400000 * 12 }, { id: 'demo-zhaoyutong', phone: '13800000006', password: 'qinghe123', nickname: '赵雨桐', universityName: '东华大学', bio: '探店 + 咖啡', createdAt: now() - 86400000 * 10 } ]; const seedMoments = (users) => { const byName = (name) => users.find((item) => item.nickname === name); const minute = 60000; const rows = [ { name: '林晚星', content: '期末周图书馆生存指南|亲测有效的复习方法分享,先睡够再刷题真的有用。', tags: ['期末复习日常', '校园学习'], minutes: 26 }, { name: '陈皮皮', content: '食堂新出的麻辣香锅也太绝了吧,加双份土豆片,人均 15 吃到扶墙。', tags: ['食堂探店'], minutes: 96 }, { name: '苏慕白', content: '校园秋景|银杏大道随手拍都是壁纸,早上七点人最少,光线也最好。', tags: ['校园风景'], minutes: 260 }, { name: '周游世界', content: '大学生平价穿搭分享|一周不重样,预算 300 以内,链接都放在评论区啦。', tags: ['穿搭', '省钱攻略'], minutes: 420 }, { name: '李学长', content: '考研上岸经验贴|双非逆袭 985,专业课复习时间线整理好了,希望对学弟学妹有用。', tags: ['考研', '经验分享'], minutes: 1500 }, { name: '赵雨桐', content: '探店|学校西门新开的咖啡店也太好拍了,靠窗的位置下午三点最好看。', tags: ['探店', '咖啡'], minutes: 2600 } ]; return rows.map((row, index) => { const author = byName(row.name); return { id: `demo-moment-${index + 1}`, userId: author.id, userName: author.nickname, userAvatar: author.nickname.slice(0, 1), content: row.content, mediaUrls: [], tags: row.tags, category: '校园生活', scope: 'CAMPUS', reviewStatus: 'approved', anonymous: false, isYouthVideo: false, viewCount: 12 + hash(row.content) % 200, shareCount: hash(row.name) % 7, createdAt: now() - row.minutes * minute, updatedAt: now() - row.minutes * minute }; }); }; const seedCommentLikes = {}; const seedComments = (moments, users) => { const target = moments[0]; const zhang = users.find((item) => item.nickname === '林晚星'); const chen = users.find((item) => item.nickname === '陈皮皮'); const first = { id: uid(), momentId: target.id, parentId: null, userId: chen.id, userName: chen.nickname, content: '收藏了,明天就去图书馆试试。', createdAt: now() - 1200000 }; const reply = { id: uid(), momentId: target.id, parentId: first.id, userId: zhang.id, userName: zhang.nickname, content: '补充一句:睡够真的很重要,别硬撑。', createdAt: now() - 600000 }; seedCommentLikes[first.id] = [zhang.id]; return [first, reply]; }; const createSeed = () => { const users = seedUsers(); users.forEach((user) => { user.avatar = user.avatar || ''; user.points = Number.isFinite(user.points) ? user.points : 0; user.fans = Number.isFinite(user.fans) ? user.fans : 0; user.joinedActivities = Array.isArray(user.joinedActivities) ? user.joinedActivities : []; user.redemptions = Array.isArray(user.redemptions) ? user.redemptions : []; }); const moments = seedMoments(users); const comments = seedComments(moments, users); return { version: DB_VERSION, users, moments, activities: [], comments, drafts: [], likes: {}, favorites: {}, commentLikes: { ...seedCommentLikes }, views: {} }; }; const readDb = () => { let parsed = null; try { parsed = JSON.parse(localStorage.getItem(DB_KEY) || 'null'); } catch (error) { parsed = null; } if (!parsed || parsed.version !== DB_VERSION || !Array.isArray(parsed.moments)) { parsed = createSeed(); writeDb(parsed); } parsed.users = Array.isArray(parsed.users) ? parsed.users : []; parsed.users.forEach((user) => { user.avatar = user.avatar || ''; user.points = Number.isFinite(user.points) ? user.points : 0; user.fans = Number.isFinite(user.fans) ? user.fans : 0; user.joinedActivities = Array.isArray(user.joinedActivities) ? user.joinedActivities : []; user.redemptions = Array.isArray(user.redemptions) ? user.redemptions : []; }); parsed.comments = Array.isArray(parsed.comments) ? parsed.comments : []; parsed.drafts = Array.isArray(parsed.drafts) ? parsed.drafts : []; parsed.activities = Array.isArray(parsed.activities) ? parsed.activities : []; parsed.likes = parsed.likes && typeof parsed.likes === 'object' ? parsed.likes : {}; parsed.favorites = parsed.favorites && typeof parsed.favorites === 'object' ? parsed.favorites : {}; parsed.views = parsed.views && typeof parsed.views === 'object' ? parsed.views : {}; parsed.commentLikes = parsed.commentLikes && typeof parsed.commentLikes === 'object' ? parsed.commentLikes : {}; return parsed; }; const writeDb = (db) => { try { localStorage.setItem(DB_KEY, JSON.stringify(db)); } catch (error) { throw new Error('本地存储空间不足,请先清理草稿或减少图片数量'); } }; const token = () => sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY); const currentUser = () => { try { return JSON.parse(sessionStorage.getItem(USER_KEY) || localStorage.getItem(USER_KEY) || 'null'); } catch (error) { return null; } }; const publicUser = (user) => ({ id: user.id, phone: user.phone, nickname: user.nickname, universityName: user.universityName, bio: user.bio || '', avatar: user.avatar || '' }); const requireUser = () => { const user = currentUser(); if (!user || !token()) throw new Error('请先登录后再操作'); const db = readDb(); const found = db.users.find((item) => item.id === user.id); if (!found) throw new Error('登录已失效,请重新登录'); return found; }; const refreshSessionUser = (user) => { const storage = sessionStorage.getItem(TOKEN_KEY) ? sessionStorage : localStorage; storage.setItem(USER_KEY, JSON.stringify(publicUser(user))); }; const likeList = (db, bucket, momentId) => (db[bucket][momentId] = db[bucket][momentId] || []); const toMomentDto = (db, moment, viewerId) => { const likes = db.likes[moment.id] || []; const createdAtMs = typeof moment.createdAt === 'number' ? moment.createdAt : Date.parse(moment.createdAt); const favorites = db.favorites[moment.id] || []; const comments = db.comments.filter((item) => item.momentId === moment.id); return { ...moment, createdAt: typeof moment.createdAt === 'number' ? stamp(moment.createdAt) : moment.createdAt, createdAtMs, createdAgo: relative(createdAtMs), likeCount: likes.length, favoriteCount: favorites.length, commentCount: comments.length, viewCount: moment.viewCount || 0, isLiked: Boolean(viewerId) && likes.includes(viewerId), isFavorited: Boolean(viewerId) && favorites.includes(viewerId) }; }; const sortByCreatedDesc = (list) => [...list].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); const paginate = (list, page, size) => { const safePage = Math.max(Number(page) || 1, 1); const safeSize = Math.min(Math.max(Number(size) || 20, 1), 100); return { items: list.slice((safePage - 1) * safeSize, safePage * safeSize), total: list.length, page: safePage, size: safeSize }; }; const loadImage = (src) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('图片解析失败')); image.src = src; }); const readFileAsDataUrl = (file) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('图片读取失败')); reader.readAsDataURL(file); }); const compressImage = async (file) => { const dataUrl = await readFileAsDataUrl(file); try { const image = await loadImage(dataUrl); const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(image.width, image.height)); if (scale === 1 && file.size < 400 * 1024) return dataUrl; const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(image.width * scale)); canvas.height = Math.max(1, Math.round(image.height * scale)); const context = canvas.getContext('2d'); context.drawImage(image, 0, 0, canvas.width, canvas.height); return canvas.toDataURL('image/jpeg', 0.78); } catch (error) { return dataUrl; } }; const openMediaDb = () => new Promise((resolve, reject) => { const request = indexedDB.open('youthome:web:media', 1); request.onupgradeneeded = () => request.result.createObjectStore('videos'); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(new Error('无法打开本地视频存储')); }); const saveVideo = async (file) => { const db = await openMediaDb(); const id = uid(); try { await new Promise((resolve, reject) => { const tx = db.transaction('videos', 'readwrite'); tx.objectStore('videos').put(file, id); tx.oncomplete = resolve; tx.onerror = () => reject(new Error('本地空间不足,视频未能保存')); tx.onabort = () => reject(new Error('本地空间不足,视频未能保存')); }); return `local-video:${id}`; } finally { db.close(); } }; const videoUrl = async (reference) => { if (!String(reference || '').startsWith('local-video:')) return reference || ''; const db = await openMediaDb(); try { const blob = await new Promise((resolve, reject) => { const request = db.transaction('videos').objectStore('videos').get(reference.slice(12)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(new Error('本地视频读取失败')); }); if (!blob) throw new Error('本地视频文件已丢失,请重新上传'); return URL.createObjectURL(blob); } finally { db.close(); } }; const handlers = { 'POST /auth/captcha': async () => ({ sent: true }), 'GET /auth/captcha': async () => ({ sent: true }), 'POST /auth/login': async ({ body }) => { const db = readDb(); const user = db.users.find((item) => item.phone === String(body.phone || '').trim()); if (!user) throw new Error('该手机号还没有注册,请先注册或使用验证码登录'); if (user.password !== body.password) throw new Error('手机号或密码不正确'); return { token: `local-${uid()}`, user: publicUser(user) }; }, 'POST /auth/login-captcha': async ({ body }) => { const phone = String(body.phone || '').trim(); if (!/^\d{6}$/.test(String(body.captcha || ''))) throw new Error('验证码为 6 位数字'); const db = readDb(); let user = db.users.find((item) => item.phone === phone); if (!user) { user = { id: uid(), phone, password: '', nickname: `青核用户${phone.slice(-4)}`, universityName: localStorage.getItem(SCHOOL_KEY) || '复旦大学', bio: '', createdAt: now() }; db.users.push(user); writeDb(db); } return { token: `local-${uid()}`, user: publicUser(user) }; }, 'POST /auth/register': async ({ body }) => { const phone = String(body.phone || '').trim(); const db = readDb(); if (db.users.some((item) => item.phone === phone)) throw new Error('该手机号已注册,请直接登录'); const user = { id: uid(), phone, password: body.password || '', nickname: `青核用户${phone.slice(-4)}`, universityName: body.universityName || localStorage.getItem(SCHOOL_KEY) || '复旦大学', bio: '', createdAt: now() }; db.users.push(user); writeDb(db); return { token: `local-${uid()}`, user: publicUser(user) }; }, 'POST /file/upload/image': async ({ body }) => { const file = body instanceof FormData ? body.get('file') : null; if (!file) throw new Error('没有读取到图片文件'); return { url: await compressImage(file) }; }, 'POST /file/upload/video': async ({ body }) => { const file = body instanceof FormData ? body.get('file') : null; if (!file || !file.type.startsWith('video/')) throw new Error('请选择视频文件'); return { url: await saveVideo(file) }; }, 'POST /campus/activities': async ({ body }) => { const user = requireUser(); const db = readDb(); const activity = { ...body, id: uid(), creatorId: user.id, creatorName: user.nickname, universityName: user.universityName, currentCount: 0, reviewStatus: 'approved', createdAt: stamp(now()), local: true }; db.activities.unshift(activity); writeDb(db); return activity; }, 'GET /campus/activities/detail': async ({ query }) => { const activity = readDb().activities.find((item) => item.id === query.get('id')); if (!activity) throw new Error('活动不存在或已下架'); return activity; }, 'POST /draft/save': async ({ body }) => { const user = requireUser(); const db = readDb(); const draftId = body.draftId || uid(); const record = { draftId, userId: user.id, contentType: body.contentType || 'MOMENT', content: body.content || '', tags: Array.isArray(body.tags) ? body.tags : [], mediaUrls: Array.isArray(body.mediaUrls) ? body.mediaUrls : [], category: body.category || '校园生活', scope: body.scope || 'CAMPUS', updatedAt: stamp(now()) }; const index = db.drafts.findIndex((item) => item.draftId === draftId); if (index >= 0) db.drafts[index] = record; else db.drafts.push(record); writeDb(db); return { draftId, version: 1, updatedAt: record.updatedAt }; }, 'GET /draft/list': async () => { const user = requireUser(); const db = readDb(); return sortByCreatedDesc(db.drafts.filter((item) => item.userId === user.id)) .map((item) => ({ ...item, createdAt: item.updatedAt })); }, 'DELETE /draft': async ({ query }) => { const user = requireUser(); const db = readDb(); const id = query.get('id'); db.drafts = db.drafts.filter((item) => !(item.draftId === id && item.userId === user.id)); writeDb(db); return null; }, 'POST /campus/moments': async ({ body }) => { const user = requireUser(); const content = String(body.content || '').trim(); if (!content) throw new Error('请输入动态内容'); if (content.length > 500) throw new Error('动态不能超过 500 字'); const db = readDb(); const moment = { id: uid(), userId: user.id, userName: user.nickname, userAvatar: user.nickname.slice(0, 1), content, mediaUrls: Array.isArray(body.mediaUrls) ? body.mediaUrls.filter(Boolean) : [], tags: Array.isArray(body.tags) ? body.tags : [], category: body.category || '校园生活', scope: body.scope === 'PERSONAL' ? 'PERSONAL' : 'CAMPUS', reviewStatus: 'approved', anonymous: false, isYouthVideo: Boolean(body.isYouthVideo), videoOriginalUrl: body.videoOriginalUrl || '', playUrl: body.videoOriginalUrl || '', videoCoverUrl: body.videoCoverUrl || '', videoDuration: body.videoDuration || 0, viewCount: 0, shareCount: 0, createdAt: now(), updatedAt: now(), local: true }; db.moments.push(moment); writeDb(db); return toMomentDto(db, moment, user.id); }, 'GET /campus/moments': async ({ query }) => { const viewer = currentUser(); const db = readDb(); const scope = query.get('scope'); const list = db.moments .filter((item) => item.reviewStatus === 'approved') .filter((item) => (scope ? item.scope === scope : true)) .filter((item) => item.scope !== 'PERSONAL' || item.userId === viewer?.id); return paginate(sortByCreatedDesc(list).map((item) => toMomentDto(db, item, viewer?.id)), query.get('page'), query.get('size')); }, 'GET /campus/moments/youth': async ({ query }) => { const db = readDb(); const viewer = currentUser(); const videos = sortByCreatedDesc(db.moments.filter((item) => item.isYouthVideo && item.reviewStatus === 'approved')); return paginate(videos.map((item) => toMomentDto(db, item, viewer?.id)), query.get('page'), query.get('size')); }, 'GET /campus/moments/my': async ({ query }) => { const user = requireUser(); const db = readDb(); const list = sortByCreatedDesc(db.moments.filter((item) => item.userId === user.id)); return paginate(list.map((item) => toMomentDto(db, item, user.id)), query.get('page'), query.get('size')); }, 'GET /campus/moments/detail': async ({ query }) => { const viewer = currentUser(); const db = readDb(); const moment = db.moments.find((item) => item.id === query.get('id')); if (!moment) throw new Error('这条动态不存在或已删除'); if (moment.scope === 'PERSONAL' && moment.userId !== viewer?.id) throw new Error('这条动态仅好友可见'); if (viewer) { const lastView = db.views[moment.id] || {}; // 30 分钟内重复打开不重复计数,与后端口径一致 if (!lastView[viewer.id] || now() - lastView[viewer.id] >= 30 * 60000) { moment.viewCount = (moment.viewCount || 0) + 1; lastView[viewer.id] = now(); db.views[moment.id] = lastView; writeDb(db); } } return toMomentDto(db, moment, viewer?.id); }, 'GET /campus/moments/comments': async ({ query }) => { const viewer = currentUser(); const db = readDb(); const momentId = query.get('momentId'); const toDto = (comment) => { const likes = db.commentLikes[comment.id] || []; return { id: comment.id, momentId: comment.momentId, parentId: comment.parentId || null, userId: comment.userId, userName: comment.userName, content: comment.content, createdAt: typeof comment.createdAt === 'number' ? stamp(comment.createdAt) : comment.createdAt, createdAgo: relative(comment.createdAt), likeCount: likes.length, isLiked: Boolean(viewer?.id) && likes.includes(viewer.id) }; }; const all = db.comments.filter((item) => item.momentId === momentId); const top = all.filter((item) => !item.parentId); const items = sortByCreatedDesc(top).map((comment) => ({ ...toDto(comment), replies: all .filter((item) => item.parentId === comment.id) .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)) .map(toDto) })); return { items, total: top.length, all: all.length }; }, 'POST /campus/moments/comments': async ({ body }) => { const user = requireUser(); const content = String(body.content || '').trim(); if (!content) throw new Error('请输入评论内容'); const db = readDb(); if (!db.moments.some((item) => item.id === body.momentId)) throw new Error('这条动态不存在或已删除'); const parentId = body.parentId || null; if (parentId && !db.comments.some((item) => item.id === parentId)) throw new Error('要回复的评论不存在了'); const comment = { id: uid(), momentId: body.momentId, parentId, userId: user.id, userName: user.nickname, content, createdAt: now() }; db.comments.push(comment); writeDb(db); return { ...comment, createdAt: stamp(comment.createdAt) }; }, 'POST /campus/moments/comments/like': async ({ query }) => { const user = requireUser(); const db = readDb(); const id = query.get('commentId') || query.get('id'); if (!db.comments.some((item) => item.id === id)) throw new Error('这条评论不存在或已删除'); const list = db.commentLikes[id] = db.commentLikes[id] || []; const index = list.indexOf(user.id); if (index >= 0) list.splice(index, 1); else list.push(user.id); writeDb(db); return { liked: index < 0, likeCount: list.length }; }, 'POST /campus/moments/like': async ({ query }) => { const user = requireUser(); const db = readDb(); const id = query.get('id'); if (!db.moments.some((item) => item.id === id)) throw new Error('这条动态不存在或已删除'); const list = likeList(db, 'likes', id); const index = list.indexOf(user.id); if (index >= 0) list.splice(index, 1); else list.push(user.id); writeDb(db); return { liked: index < 0, likeCount: list.length }; }, 'POST /campus/moments/favorite': async ({ query }) => { const user = requireUser(); const db = readDb(); const id = query.get('id'); if (!db.moments.some((item) => item.id === id)) throw new Error('这条动态不存在或已删除'); const list = likeList(db, 'favorites', id); const index = list.indexOf(user.id); if (index >= 0) list.splice(index, 1); else list.push(user.id); writeDb(db); return { favorited: index < 0, favoriteCount: list.length }; }, 'POST /campus/moments/share': async ({ query }) => { const db = readDb(); const moment = db.moments.find((item) => item.id === query.get('id')); if (!moment) throw new Error('这条动态不存在或已删除'); moment.shareCount = (moment.shareCount || 0) + 1; writeDb(db); return null; } }; const parseBody = async (body) => { if (!body) return {}; if (body instanceof FormData) return body; if (typeof body === 'string') { try { return JSON.parse(body); } catch (error) { return {}; } } return body; }; const request = async (path, options = {}) => { const method = String(options.method || 'GET').toUpperCase(); const url = new URL(path, location.origin); const route = url.pathname.replace(/^\/api/, ''); const body = await parseBody(options.body); let handler = handlers[`${method} ${route}`]; if (!handler && method === 'DELETE' && route.startsWith('/draft/')) { const query = new URLSearchParams({ id: route.split('/').pop() }); await delay(90); return handlers['DELETE /draft']({ body, query }); } if (!handler) throw new Error(`本地数据层暂不支持该请求:${method} ${route}`); await delay(90); return handler({ body, query: url.searchParams, method }); }; window.YouthomeLocal = { request, token, currentUser, readDb, writeDb, videoUrl, useDemoAccount() { const db = readDb(); const current = currentUser(); if (current && db.users.some((item) => item.id === current.id) && token()) return current; const account = db.users[0]; window.YouthomeLocal.saveSession(`local-${uid()}`, publicUser(account)); return publicUser(account); }, reset() { localStorage.removeItem(DB_KEY); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(USER_KEY); localStorage.setItem('youthome:web:dataSource', 'local'); }, refreshSessionUser, saveSession(token, user, remember = true) { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(USER_KEY); const storage = remember ? localStorage : sessionStorage; storage.setItem(TOKEN_KEY, token); storage.setItem(USER_KEY, JSON.stringify(user)); }, updateProfile(patch) { const user = requireUser(); const db = readDb(); const record = db.users.find((item) => item.id === user.id); Object.assign(record, patch); db.moments.filter((item) => item.userId === user.id).forEach((item) => { item.userName = record.nickname; item.userAvatar = record.nickname.slice(0, 1); }); db.comments.filter((item) => item.userId === user.id).forEach((item) => { item.userName = record.nickname; }); writeDb(db); refreshSessionUser(record); return publicUser(record); }, coverIndex(id) { return 1 + (hash(id) % 5); }, relative, account() { const session = currentUser(); if (!session) return null; const db = readDb(); return db.users.find((item) => item.id === session.id) || null; }, addPoints(delta) { const user = requireUser(); const db = readDb(); const record = db.users.find((item) => item.id === user.id); record.points = Math.max(0, (record.points || 0) + Number(delta || 0)); writeDb(db); refreshSessionUser(record); return record.points; }, redeem(cost, name) { const user = requireUser(); const db = readDb(); const record = db.users.find((item) => item.id === user.id); const price = Number(cost) || 0; if ((record.points || 0) < price) throw new Error(`还差 ${price - (record.points || 0)} 积分`); record.points = (record.points || 0) - price; record.redemptions = record.redemptions || []; record.redemptions.unshift({ name, cost: price, at: stamp(now()) }); writeDb(db); refreshSessionUser(record); return { points: record.points, count: record.redemptions.length }; }, redemptions() { const record = window.YouthomeLocal.account(); return record ? (record.redemptions || []).slice() : []; }, joinActivity(snapshot) { const user = requireUser(); const db = readDb(); const record = db.users.find((item) => item.id === user.id); record.joinedActivities = record.joinedActivities || []; const existing = record.joinedActivities.find((item) => item.id === snapshot.id); if (existing) return existing; const entry = { id: snapshot.id, title: snapshot.title || '未命名活动', time: snapshot.time || '', place: snapshot.place || '', status: 'pending', joinedAt: stamp(now()) }; record.joinedActivities.unshift(entry); writeDb(db); return entry; }, leaveActivity(id) { const user = requireUser(); const db = readDb(); const record = db.users.find((item) => item.id === user.id); record.joinedActivities = (record.joinedActivities || []).filter((item) => item.id !== id); writeDb(db); return record.joinedActivities; }, isJoined(id) { const record = window.YouthomeLocal.account(); return Boolean(record && (record.joinedActivities || []).some((item) => item.id === id)); }, joinedActivities() { const record = window.YouthomeLocal.account(); return record ? (record.joinedActivities || []).slice() : []; }, bucketMoments(bucket) { const record = window.YouthomeLocal.account(); if (!record) return []; const db = readDb(); return Object.keys(db[bucket] || {}) .filter((id) => (db[bucket][id] || []).includes(record.id)) .map((id) => db.moments.find((moment) => moment.id === id)) .filter(Boolean) .reverse(); }, viewedMoments() { const record = window.YouthomeLocal.account(); if (!record) return []; const db = readDb(); return Object.keys(db.views || {}) .filter((id) => db.views[id] && db.views[id][record.id]) .sort((a, b) => db.views[b][record.id] - db.views[a][record.id]) .map((id) => db.moments.find((moment) => moment.id === id)) .filter(Boolean); }, removeFromBucket(bucket, momentId) { const record = requireUser(); const db = readDb(); const list = db[bucket][momentId] || []; db[bucket][momentId] = list.filter((id) => id !== record.id); writeDb(db); return true; }, following() { return Object.keys(localStorage) .filter((key) => key.startsWith('youthome:web:follow:') && localStorage.getItem(key) === 'true') .map((key) => key.replace('youthome:web:follow:', '')); }, isFollowing(name) { return localStorage.getItem('youthome:web:follow:' + name) === 'true'; }, setFollowing(name, active) { localStorage.setItem('youthome:web:follow:' + name, String(Boolean(active))); } }; })();