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