diff --git a/DESIGN.md b/DESIGN.md index 00d5e94..e6863a6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5,7 +5,7 @@ - Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。 - 所有人都可以浏览 Wiki 内容。 - 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。 -- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList 为主要浏览入口。 +- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life 为主要浏览入口。 - 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。 ## 技术栈 @@ -354,6 +354,30 @@ Pokemon 出现配置: - 已验证用户可新增、编辑、删除 Task。 - 已验证用户可通过 Handle 拖拽排序。 +## Life + +Life 是社区生活分享信息流,类似轻量社交动态。 + +Life Post 可配置: + +- Post 内容正文 +- 创建者、最后编辑者、创建时间、最后编辑时间 + +前台行为: + +- 所有人都可以浏览 Life 信息流。 +- 信息流按创建时间倒序展示。 +- 已注册并完成邮箱验证的用户可以发布 Life Post。 +- 作者本人可以编辑、删除自己的 Life Post。 +- 当前没有点赞、评论、图片上传、转发、分页、置顶或单独审核流程。 +- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 + +API 暴露边界: + +- Life Post 作者信息只返回 `id` 和 `displayName`。 +- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 +- 非作者不能编辑或删除其他用户的 Life Post。 + ## 前端交互与 UI - UI 风格以 `DesignGuidelines.html` 为准。 @@ -372,6 +396,7 @@ Pokemon 出现配置: - `/items/:id/edit` - `/recipes/new` - `/recipes/:id/edit` +- Life 使用信息流内联发布与编辑,不使用路由驱动 Modal。 - 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。 - 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。 @@ -390,6 +415,7 @@ Pokemon 出现配置: - `GET /api/items/:id` - `GET /api/recipes` - `GET /api/recipes/:id` +- `GET /api/life-posts` 认证 API: @@ -402,6 +428,10 @@ Pokemon 出现配置: 已验证用户编辑 API: - Pokemon、栖息地、物品、材料单的创建、更新、删除。 +- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。 + - `POST /api/life-posts` + - `PUT /api/life-posts/:id` + - `DELETE /api/life-posts/:id` - 每日 CheckList 的创建、更新、删除、排序。 - 全局配置项的创建、更新、删除、排序。 - 语言的创建、更新、删除、排序。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 6f28326..81ad32c 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -119,6 +119,21 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items ( CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx ON daily_checklist_items(sort_order, id); +CREATE TABLE IF NOT EXISTS life_posts ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE life_posts DROP COLUMN IF EXISTS link_url; +ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title; + +CREATE INDEX IF NOT EXISTS life_posts_created_at_idx + ON life_posts(created_at DESC, id DESC); + CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 6010290..7e5d16a 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -104,6 +104,10 @@ type DailyChecklistPayload = { translations: TranslationInput; }; +type LifePostPayload = { + body: string; +}; + type HabitatPayload = { name: string; translations: TranslationInput; @@ -1168,6 +1172,99 @@ export async function deleteDailyChecklistItem(id: number, userId: number) { }); } +function cleanLifePostPayload(payload: Record): LifePostPayload { + const body = cleanName(payload.body, 'Please enter a post'); + if (body.length > 2000) { + throw validationError('Post is too long'); + } + + return { body }; +} + +function lifePostProjection(): string { + return ` + SELECT + lp.id, + lp.body, + lp.created_at AS "createdAt", + lp.updated_at AS "updatedAt", + CASE + WHEN created_user.id IS NULL THEN NULL + ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name) + END AS author, + CASE + WHEN updated_user.id IS NULL THEN NULL + ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name) + END AS "updatedBy" + FROM life_posts lp + LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id + LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id + `; +} + +export function listLifePosts() { + return query(` + ${lifePostProjection()} + ORDER BY lp.created_at DESC, lp.id DESC + `); +} + +async function getLifePostById(id: number) { + return queryOne( + ` + ${lifePostProjection()} + WHERE lp.id = $1 + `, + [id] + ); +} + +export async function createLifePost(payload: Record, userId: number) { + const cleanPayload = cleanLifePostPayload(payload); + + const result = await queryOne<{ id: number }>( + ` + INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $2) + RETURNING id + `, + [cleanPayload.body, userId] + ); + + return getLifePostById(result?.id ?? 0); +} + +export async function updateLifePost(id: number, payload: Record, userId: number) { + const cleanPayload = cleanLifePostPayload(payload); + + const result = await queryOne<{ id: number }>( + ` + UPDATE life_posts + SET body = $1, updated_by_user_id = $2, updated_at = now() + WHERE id = $3 + AND created_by_user_id = $2 + RETURNING id + `, + [cleanPayload.body, userId, id] + ); + + return result ? getLifePostById(result.id) : null; +} + +export async function deleteLifePost(id: number, userId: number) { + const result = await queryOne<{ id: number }>( + ` + DELETE FROM life_posts + WHERE id = $1 + AND created_by_user_id = $2 + RETURNING id + `, + [id, userId] + ); + + return Boolean(result); +} + export function isConfigType(type: string): type is ConfigType { return Object.hasOwn(configDefinitions, type); } diff --git a/backend/src/server.ts b/backend/src/server.ts index 61e2678..54e54f7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,7 @@ import { createHabitat, createItem, createLanguage, + createLifePost, createPokemon, createRecipe, deleteConfig, @@ -17,6 +18,7 @@ import { deleteHabitat, deleteItem, deleteLanguage, + deleteLifePost, deletePokemon, deleteRecipe, getHabitat, @@ -30,6 +32,7 @@ import { listHabitats, listItems, listLanguages, + listLifePosts, listPokemon, listRecipes, reorderConfig, @@ -44,6 +47,7 @@ import { updateHabitat, updateItem, updateLanguage, + updateLifePost, updatePokemon, updateRecipe } from './queries.ts'; @@ -171,6 +175,33 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request))); app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); +app.get('/api/life-posts', async () => listLifePosts()); + +app.post('/api/life-posts', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createLifePost(request.body as Record, user.id)) : undefined; +}); + +app.put('/api/life-posts/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const post = await updateLifePost(Number(id), request.body as Record, user.id); + return post ? post : reply.code(404).send({ message: 'Not found' }); +}); + +app.delete('/api/life-posts/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const deleted = await deleteLifePost(Number(id), user.id); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/pokemon', async (request) => listPokemon(request.query as Record, requestLocale(request)) ); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4207998..e291db6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import AppShell from './components/AppShell.vue'; -import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconPokemon, iconRecipe } from './icons'; +import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconLife, iconPokemon, iconRecipe } from './icons'; import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n'; import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api'; @@ -24,6 +24,7 @@ const navItems = computed(() => [ { label: t('nav.items'), to: '/items', icon: iconItem }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.checklist'), to: '/checklist', icon: iconChecklist }, + { label: t('nav.life'), to: '/life', icon: iconLife }, { label: t('nav.admin'), to: '/admin', icon: iconAdmin } ]); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 9b87a38..f998afc 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -44,6 +44,7 @@ const messages = { items: 'Items', recipes: 'Recipes', checklist: 'CheckList', + life: 'Life', admin: 'Admin', main: 'Main navigation', language: 'Language', @@ -220,6 +221,35 @@ const messages = { newTask: 'New task', editTask: 'Edit task' }, + life: { + title: 'Life', + subtitle: 'Share favourite thoughts, tips, and community finds.', + kicker: 'Community Feed', + composerTitle: 'Share something', + composerPrompt: 'What would you like to share?', + bodyLabel: 'Post', + bodyPlaceholder: 'Share a thought, tip, or discovery...', + publish: 'Post', + publishing: 'Posting', + update: 'Update', + updating: 'Updating', + cancelEdit: 'Cancel edit', + empty: 'No posts yet', + loading: 'Loading Life feed', + loginPrompt: 'Log in with a verified email to post.', + verifyPrompt: 'Complete email verification to post.', + editPost: 'Edit post', + deletePost: 'Delete post', + saveEdit: 'Save edit', + postFailed: 'Post failed', + saveFailed: 'Save failed', + deleteFailed: 'Delete failed', + bodyRequired: 'Please enter a post.', + byUnknown: 'Community member', + edited: 'Edited', + deleteConfirm: 'Delete this post?', + charactersLeft: '{count} characters left' + }, admin: { title: 'Admin', subtitle: 'Maintain system configuration and manage Wiki records.', @@ -327,6 +357,7 @@ const messages = { items: '物品', recipes: '材料单', checklist: 'CheckList', + life: 'Life', admin: '管理', main: '主导航', language: '语言', @@ -503,6 +534,35 @@ const messages = { newTask: '新增 Task', editTask: '编辑 Task' }, + life: { + title: 'Life', + subtitle: '分享喜欢的心得、想法和社区发现。', + kicker: '社区动态', + composerTitle: '分享动态', + composerPrompt: '想分享什么?', + bodyLabel: '动态内容', + bodyPlaceholder: '分享一段想法、心得或发现……', + publish: '发布', + publishing: '发布中', + update: '更新', + updating: '更新中', + cancelEdit: '取消编辑', + empty: '暂无动态', + loading: '正在加载 Life 动态', + loginPrompt: '使用已验证邮箱登录后即可发布。', + verifyPrompt: '完成邮箱验证后即可发布。', + editPost: '编辑动态', + deletePost: '删除动态', + saveEdit: '保存编辑', + postFailed: '发布失败', + saveFailed: '保存失败', + deleteFailed: '删除失败', + bodyRequired: '请输入动态内容。', + byUnknown: '社区成员', + edited: '已编辑', + deleteConfirm: '确认删除这条动态?', + charactersLeft: '还可以输入 {count} 个字符' + }, admin: { title: '管理', subtitle: '维护系统配置,查看并删除 Wiki 数据记录。', diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 66cc316..8b1fdc7 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -15,6 +15,7 @@ export const iconError: AppIcon = 'mdi:close-circle-outline'; export const iconHabitat: AppIcon = 'mdi:pine-tree'; export const iconInfo: AppIcon = 'mdi:information-outline'; export const iconItem: AppIcon = 'mdi:bag-personal-outline'; +export const iconLife: AppIcon = 'mdi:post-outline'; export const iconLogin: AppIcon = 'mdi:login'; export const iconLogout: AppIcon = 'mdi:logout'; export const iconMail: AppIcon = 'mdi:email-fast-outline'; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2bad664..f85ec60 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,7 @@ import ItemDetail from '../views/ItemDetail.vue'; import RecipeList from '../views/RecipeList.vue'; import RecipeDetail from '../views/RecipeDetail.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue'; +import LifeView from '../views/LifeView.vue'; import AdminView from '../views/AdminView.vue'; import LoginView from '../views/LoginView.vue'; import RegisterView from '../views/RegisterView.vue'; @@ -35,6 +36,7 @@ export const router = createRouter({ { path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } }, { path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail }, { path: '/checklist', component: DailyChecklistView }, + { path: '/life', component: LifeView }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/login', component: LoginView }, { path: '/register', component: RegisterView }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3c87009..2ec3c99 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -173,6 +173,15 @@ export interface DailyChecklistItem { translations?: TranslationMap; } +export interface LifePost { + id: number; + body: string; + createdAt: string; + updatedAt: string; + author: UserSummary | null; + updatedBy: UserSummary | null; +} + export interface RecipeDetail extends Recipe { acquisition_methods: NamedEntity[]; editHistory: EditHistoryEntry[]; @@ -275,6 +284,10 @@ export interface DailyChecklistPayload { translations?: TranslationMap; } +export interface LifePostPayload { + body: string; +} + export function buildQuery(params: Record): string { const search = new URLSearchParams(); @@ -404,6 +417,11 @@ export const api = { logout: () => postEmpty('/api/auth/logout'), options: () => getJson('/api/options'), dailyChecklist: () => getJson('/api/daily-checklist'), + lifePosts: () => getJson('/api/life-posts'), + createLifePost: (payload: LifePostPayload) => sendJson('/api/life-posts', 'POST', payload), + updateLifePost: (id: string | number, payload: LifePostPayload) => + sendJson(`/api/life-posts/${id}`, 'PUT', payload), + deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`), createDailyChecklistItem: (payload: DailyChecklistPayload) => sendJson('/api/admin/daily-checklist', 'POST', payload), updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 0a49114..72f2f94 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1179,6 +1179,143 @@ button:disabled, justify-content: flex-start; } +.life-composer, +.life-post { + display: grid; + gap: 14px; + padding: 16px; + border: 2px solid var(--line-strong); + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--shadow-control); +} + +.life-composer__header { + display: grid; + gap: 4px; +} + +.life-composer__header h2 { + margin: 0; + color: var(--ink); + font-family: var(--font-display); + font-size: 24px; + font-weight: 950; + line-height: 1.15; +} + +.life-composer__header p, +.life-form__counter { + margin: 0; + color: var(--muted); + font-size: 14px; + font-weight: 750; +} + +.life-composer__auth-skeleton, +.life-form, +.life-feed__list { + display: grid; + gap: 14px; +} + +.life-form__counter { + justify-self: end; +} + +.life-form__error { + margin: 0; + color: var(--danger); + font-weight: 850; +} + +.life-form__actions, +.life-auth-note { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.life-auth-note { + justify-content: space-between; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.life-auth-note p { + margin: 0; + color: var(--ink-soft); + font-weight: 850; +} + +.life-post { + box-shadow: var(--shadow-soft); +} + +.life-post__header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 12px; +} + +.life-post__avatar { + width: 46px; + height: 46px; + display: grid; + place-items: center; + border: 2px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--pokemon-yellow); + box-shadow: 0 3px 0 var(--line-strong); + color: #172036; + font-family: var(--font-display); + font-size: 20px; + font-weight: 950; +} + +.life-post__byline { + display: grid; + gap: 2px; + min-width: 0; +} + +.life-post__byline strong { + overflow: hidden; + color: var(--ink); + font-weight: 950; + text-overflow: ellipsis; + white-space: nowrap; +} + +.life-post__byline span { + color: var(--muted); + font-size: 13px; + font-weight: 750; +} + +.life-post__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.life-post__actions .ui-button { + min-height: 44px; +} + +.life-post__body { + margin: 0; + color: var(--ink); + line-height: 1.65; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + .reorderable-row { position: relative; flex-wrap: wrap; @@ -2344,6 +2481,15 @@ button:disabled, grid-template-columns: 1fr; } + .life-post__header { + grid-template-columns: auto minmax(0, 1fr); + } + + .life-post__actions { + grid-column: 1 / -1; + justify-content: flex-start; + } + .appearance-list li { grid-template-columns: 1fr; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue new file mode 100644 index 0000000..3fad91b --- /dev/null +++ b/frontend/src/views/LifeView.vue @@ -0,0 +1,277 @@ + + +