feat: implement community editing with audit logs and user attribution

Add created/updated tracking and edit logs to all wiki tables
Restrict create/update/delete operations to verified users
Display edit metadata on frontend detail and list views
This commit is contained in:
2026-04-30 11:53:29 +08:00
parent 9af8c98401
commit 0f5ff7be15
16 changed files with 537 additions and 90 deletions

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { EditInfo } from '../services/api';
defineProps<{
entity: EditInfo;
}>();
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<template>
<p class="edit-meta">
最后编辑{{ entity.updatedBy?.displayName ?? '系统' }} / {{ formatDateTime(entity.updatedAt) }}
</p>
</template>

View File

@@ -10,6 +10,7 @@ import AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
import { api, getAuthToken, setAuthToken } from '../services/api';
export const router = createRouter({
history: createWebHistory(),
@@ -29,3 +30,21 @@ export const router = createRouter({
],
scrollBehavior: () => ({ top: 0 })
});
router.beforeEach(async (to) => {
if (to.path !== '/admin') {
return true;
}
if (!getAuthToken()) {
return { path: '/login', query: { redirect: to.fullPath } };
}
try {
await api.me();
return true;
} catch {
setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } };
}
});

View File

@@ -11,7 +11,19 @@ export interface Skill extends NamedEntity {
subcategory: string | null;
}
export interface Pokemon {
export interface UserSummary {
id: number;
displayName: string;
}
export interface EditInfo {
createdAt: string;
updatedAt: string;
createdBy: UserSummary | null;
updatedBy: UserSummary | null;
}
export interface Pokemon extends EditInfo {
id: number;
name: string;
environment: NamedEntity;
@@ -30,7 +42,7 @@ export interface PokemonDetail extends Pokemon {
}>;
}
export interface Habitat {
export interface Habitat extends EditInfo {
id: number;
name: string;
recipe: Array<NamedEntity & { quantity: number }>;
@@ -46,7 +58,7 @@ export interface HabitatDetail extends Habitat {
}>;
}
export interface Item {
export interface Item extends EditInfo {
id: number;
name: string;
category: NamedEntity;
@@ -65,7 +77,7 @@ export interface ItemDetail extends Item {
relatedHabitats: Array<NamedEntity & { quantity: number }>;
}
export interface Recipe {
export interface Recipe extends EditInfo {
id: number;
name: string;
materials: Array<NamedEntity & { quantity: number }>;

View File

@@ -392,6 +392,13 @@ select {
color: #657067;
}
.edit-meta {
margin: 0;
color: #7b766a;
font-size: 13px;
font-weight: 700;
}
.chips {
display: flex;
flex-wrap: wrap;

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import TagsSelect from '../components/TagsSelect.vue';
import {
api,
type AuthUser,
type ConfigType,
type Habitat,
type HabitatDetail,
@@ -59,6 +60,7 @@ const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]);
const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
@@ -94,6 +96,7 @@ const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: ite
const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
);
const canEdit = computed(() => currentUser.value?.emailVerified === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -179,10 +182,27 @@ async function loadCurrentTab() {
}
function setTab(tab: AdminTab) {
if (!canEdit.value) {
message.value = '请先完成邮箱验证';
return;
}
activeTab.value = tab;
void run(loadCurrentTab);
}
async function loadAdmin() {
const response = await api.me();
currentUser.value = response.user;
if (!response.user.emailVerified) {
message.value = '请先完成邮箱验证';
return;
}
await loadCurrentTab();
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', subcategory: '' };
}
@@ -442,7 +462,7 @@ async function removeHabitat(id: number) {
}
onMounted(() => {
void run(loadCurrentTab);
void run(loadAdmin);
});
</script>
@@ -455,7 +475,7 @@ onMounted(() => {
</div>
</div>
<div class="tabs" role="tablist" aria-label="管理模块">
<div v-if="canEdit" class="tabs" role="tablist" aria-label="管理模块">
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
{{ tab.label }}
</button>
@@ -463,7 +483,7 @@ onMounted(() => {
<p v-if="message" class="status">{{ message }}</p>
<section v-if="activeTab === 'config'" class="admin-layout">
<section v-if="canEdit && activeTab === 'config'" class="admin-layout">
<form class="detail-section" @submit.prevent="saveConfig">
<h2>系统配置</h2>
<div class="field">
@@ -500,7 +520,7 @@ onMounted(() => {
</div>
</section>
<section v-if="activeTab === 'pokemon' && options" class="admin-layout">
<section v-if="canEdit && activeTab === 'pokemon' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="savePokemon">
<h2>Pokemon</h2>
<div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div>
@@ -562,7 +582,7 @@ onMounted(() => {
</div>
</section>
<section v-if="activeTab === 'items' && options" class="admin-layout">
<section v-if="canEdit && activeTab === 'items' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveItem">
<h2>物品</h2>
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
@@ -637,7 +657,7 @@ onMounted(() => {
</div>
</section>
<section v-if="activeTab === 'recipes' && options" class="admin-layout">
<section v-if="canEdit && activeTab === 'recipes' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveRecipe">
<h2>材料单</h2>
<div class="field">
@@ -699,7 +719,7 @@ onMounted(() => {
</div>
</section>
<section v-if="activeTab === 'habitats' && options" class="admin-layout">
<section v-if="canEdit && activeTab === 'habitats' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveHabitat">
<h2>栖息地</h2>
<div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import { api, type HabitatDetail } from '../services/api';
@@ -83,6 +84,7 @@ onMounted(async () => {
<div>
<h1 class="page-title">{{ habitat.name }}</h1>
<p class="page-subtitle">栖息地详情</p>
<EditMeta :entity="habitat" />
</div>
<RouterLink class="link-button" to="/habitats">返回列表</RouterLink>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import { api, type Habitat } from '../services/api';
@@ -25,6 +26,7 @@ onMounted(async () => {
<div v-else class="grid">
<RouterLink v-for="item in habitats" :key="item.id" class="entity-card" :to="`/habitats/${item.id}`">
<h2>{{ item.name }}</h2>
<EditMeta :entity="item" />
<EntityChips :items="item.recipe" />
<EntityChips :items="item.pokemon ?? []" />
</RouterLink>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import { api, type ItemDetail } from '../services/api';
@@ -31,6 +32,7 @@ onMounted(async () => {
<div>
<h1 class="page-title">{{ item.name }}</h1>
<p class="page-subtitle">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
<EditMeta :entity="item" />
</div>
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { api, type Item, type Options, type Recipe } from '../services/api';
@@ -94,6 +95,7 @@ watch([tab, itemQuery], loadItems);
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
<h2>{{ item.name }}</h2>
<p class="meta-line">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
<EditMeta :entity="item" />
<EntityChips :items="item.tags" />
</RouterLink>
</div>
@@ -101,6 +103,7 @@ watch([tab, itemQuery], loadItems);
<div v-else class="grid">
<RouterLink v-for="item in recipes" :key="item.id" class="entity-card" :to="`/recipes/${item.id}`">
<h2>{{ item.name }}</h2>
<EditMeta :entity="item" />
<EntityChips :items="item.materials" />
</RouterLink>
</div>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import { api, type PokemonDetail } from '../services/api';
@@ -83,6 +84,7 @@ onMounted(async () => {
<div>
<h1 class="page-title">#{{ pokemon.id }} {{ pokemon.name }}</h1>
<p class="page-subtitle">喜欢的环境{{ pokemon.environment.name }}</p>
<EditMeta :entity="pokemon" />
</div>
<RouterLink class="link-button" to="/pokemon">返回列表</RouterLink>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { api, type Options, type Pokemon } from '../services/api';
@@ -92,6 +93,7 @@ watch(query, loadPokemon);
<RouterLink v-for="item in pokemon" :key="item.id" class="entity-card" :to="`/pokemon/${item.id}`">
<h2>#{{ item.id }} {{ item.name }}</h2>
<p class="meta-line">喜欢的环境{{ item.environment.name }}</p>
<EditMeta :entity="item" />
<EntityChips :items="item.skills" />
<EntityChips :items="item.favorite_things" />
</RouterLink>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import { api, type RecipeDetail } from '../services/api';
@@ -19,6 +20,7 @@ onMounted(async () => {
<div>
<h1 class="page-title">{{ recipe.name }}</h1>
<p class="page-subtitle">材料单详情</p>
<EditMeta :entity="recipe" />
</div>
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
</div>