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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user