You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

748 lines
32 KiB

/*
* 网页端本地数据层
*
* 这个文件让网页端在没有后端的情况下也能完整演示:登录/注册、发布、草稿、
* 广场、个人中心、详情、评论、点赞、收藏全部走这里,数据保存在浏览器
* 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)));
}
};
})();