feat(life): replace multiple tags with single category for posts

Add default category support and enforce one category per Life Post
Update UI filters, forms, and translations to reflect category semantics
This commit is contained in:
2026-05-03 17:34:32 +08:00
parent 18baf7b513
commit 6782ddd101
8 changed files with 264 additions and 172 deletions

View File

@@ -40,6 +40,7 @@ import {
type Habitat,
type Item,
type Language,
type LifeCategory,
type NamedEntity,
type Permission,
type PermissionPayload,
@@ -69,7 +70,7 @@ type AdminTab =
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
type EditableConfig = (NamedEntity | Skill | LifeCategory) & { hasItemDrop?: boolean; isDefault?: boolean };
const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile,
@@ -132,7 +133,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
});
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean }>>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
@@ -141,7 +142,7 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
{ key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeTags') }
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true }
]);
const activeTab = ref<AdminTab>('config');
@@ -162,7 +163,7 @@ const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
const message = ref('');
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false, isDefault: false });
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
@@ -375,7 +376,7 @@ async function loadLanguages() {
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false };
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false };
}
function resetChecklistForm() {
@@ -435,7 +436,13 @@ function closeConfigModal() {
}
function editConfig(item: EditableConfig) {
configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true };
configForm.value = {
id: item.id,
name: item.baseName ?? item.name,
translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true,
isDefault: item.isDefault === true
};
configModalOpen.value = true;
}
@@ -709,7 +716,8 @@ async function saveConfig() {
const payload = {
name: configBaseNameForSave(),
translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined
};
if (configForm.value.id) {
@@ -1270,7 +1278,9 @@ onMounted(() => {
>
<template #default="{ item }">
<span class="reorderable-row-title">
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
{{ item.name }}
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
</span>
<span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
@@ -1799,6 +1809,12 @@ onMounted(() => {
{{ t('pages.admin.hasItemDrop') }}
</label>
</div>
<div v-if="selectedConfig.supportsDefault" class="check-row">
<label>
<input v-model="configForm.isDefault" type="checkbox" />
{{ t('pages.admin.defaultCategory') }}
</label>
</div>
</form>
<template #footer>

View File

@@ -35,10 +35,10 @@ import {
type AiModerationStatus,
type AuthUser,
type Language,
type LifeCategory,
type LifeComment,
type LifePost,
type LifeReactionType,
type NamedEntity
type LifeReactionType
} from '../services/api';
type LifeCommentPageState = {
@@ -54,7 +54,7 @@ type LifeCommentPageState = {
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const lifeTags = ref<NamedEntity[]>([]);
const lifeCategories = ref<LifeCategory[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
@@ -63,10 +63,10 @@ const authReady = ref(false);
const busy = ref(false);
const searchDraft = ref('');
const submittedSearch = ref('');
const activeTagId = ref('all');
const activeCategoryId = ref('all');
const activeLanguageCode = ref('all');
const body = ref('');
const selectedTagIds = ref<string[]>([]);
const selectedCategoryId = ref('');
const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false);
const formError = ref('');
@@ -97,7 +97,7 @@ let postsRequestId = 0;
const nextCursor = ref<string | null>(null);
const hasMorePosts = ref(false);
const loadMorePaused = ref(false);
const allTagValue = 'all';
const allCategoryValue = 'all';
const allLanguageValue = 'all';
const reactionOptions = [
@@ -117,21 +117,25 @@ const canReact = computed(() => can('life.reactions.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim());
const selectedFeedTagId = computed(() => {
const tagId = Number(activeTagId.value);
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
const selectedFeedCategoryId = computed(() => {
const categoryId = Number(activeCategoryId.value);
return activeCategoryId.value === allCategoryValue || !Number.isInteger(categoryId) || categoryId <= 0 ? undefined : categoryId;
});
const selectedFeedLanguageCode = computed(() =>
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
);
const tagFilterOptions = computed<TabOption[]>(() => [
{ value: allTagValue, label: t('pages.life.allTags') },
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
const categoryFilterOptions = computed<TabOption[]>(() => [
{ value: allCategoryValue, label: t('pages.life.allCategories') },
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
]);
const languageFilterOptions = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
});
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
const submitLabel = computed(() => {
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
@@ -158,13 +162,19 @@ async function loadCurrentUser() {
}
}
async function loadLifeTags() {
async function loadLifeCategories() {
try {
const options = await api.options();
lifeTags.value = options.lifeTags;
lifeCategories.value = options.lifeCategories;
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
activeTagId.value = allTagValue;
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = allCategoryValue;
}
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
@@ -199,7 +209,7 @@ async function loadPosts() {
const page = await api.lifePosts({
limit: lifePostPageSize,
search: searchQuery.value,
tagId: selectedFeedTagId.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value
});
if (requestId !== postsRequestId) {
@@ -241,7 +251,7 @@ async function loadMorePosts() {
cursor,
limit: lifePostPageSize,
search: searchQuery.value,
tagId: selectedFeedTagId.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value
});
if (requestId !== postsRequestId) {
@@ -266,7 +276,7 @@ async function loadMorePosts() {
function resetForm() {
body.value = '';
selectedTagIds.value = [];
selectedCategoryId.value = '';
editingPostId.value = null;
formError.value = '';
}
@@ -274,13 +284,14 @@ function resetForm() {
function payload() {
return {
body: body.value.trim(),
tagIds: selectedLifeTagIds(),
categoryId: selectedLifeCategoryId() ?? 0,
languageCode: selectedFeedLanguageCode.value ?? null
};
}
function selectedLifeTagIds() {
return selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0);
function selectedLifeCategoryId() {
const categoryId = Number(selectedCategoryId.value);
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
}
function submitSearch() {
@@ -314,16 +325,17 @@ function retryLoadMore() {
function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase();
const tagId = selectedFeedTagId.value;
const categoryId = selectedFeedCategoryId.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
const matchesLanguage =
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
return matchesSearch && matchesTag && matchesLanguage;
return matchesSearch && matchesCategory && matchesLanguage;
}
function openCreatePostModal() {
resetForm();
selectedCategoryId.value = defaultLifeCategoryId.value;
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
}
@@ -344,9 +356,9 @@ async function submitPost() {
return;
}
if (selectedLifeTagIds().length === 0) {
formError.value = t('pages.life.tagRequired');
document.getElementById('life-post-tags')?.focus();
if (selectedLifeCategoryId() === null) {
formError.value = t('pages.life.categoryRequired');
document.getElementById('life-post-category')?.focus();
return;
}
@@ -696,7 +708,7 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
function startEdit(post: LifePost) {
editingPostId.value = post.id;
body.value = post.body;
selectedTagIds.value = post.tags.map((tag) => String(tag.id));
selectedCategoryId.value = post.category ? String(post.category.id) : '';
formError.value = '';
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
@@ -879,7 +891,7 @@ function observeLoadMore() {
}
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
watch(activeTagId, () => {
watch(activeCategoryId, () => {
void loadPosts();
});
watch(activeLanguageCode, () => {
@@ -889,7 +901,7 @@ watch(activeLanguageCode, () => {
});
watch(locale, () => {
void loadLanguages();
void loadLifeTags();
void loadLifeCategories();
void loadPosts();
});
@@ -898,7 +910,7 @@ onMounted(() => {
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
void loadCurrentUser();
void loadLanguages();
void loadLifeTags();
void loadLifeCategories();
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
@@ -981,13 +993,14 @@ onUnmounted(() => {
</div>
<div class="field">
<label for="life-post-tags">{{ t('pages.life.tags') }}</label>
<label for="life-post-category">{{ t('pages.life.category') }}</label>
<TagsSelect
id="life-post-tags"
v-model="selectedTagIds"
:options="lifeTags"
:placeholder="t('pages.life.tagPlaceholder')"
:search-placeholder="t('pages.life.searchTags')"
id="life-post-category"
v-model="selectedCategoryId"
:options="lifeCategories"
:multiple="false"
:placeholder="t('pages.life.categoryPlaceholder')"
:search-placeholder="t('pages.life.searchCategories')"
dropdown-strategy="fixed"
/>
</div>
@@ -1015,7 +1028,7 @@ onUnmounted(() => {
</Modal>
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
@@ -1083,8 +1096,8 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
<span class="life-post__tag">{{ post.category.name }}</span>
</div>
<div class="life-post__engagement">

View File

@@ -593,7 +593,7 @@ function contentTypeLabel(contentType: string): string {
'item-usages': t('config.itemUsages'),
'acquisition-methods': t('config.acquisitionMethods'),
maps: t('config.maps'),
'life-tags': t('config.lifeTags')
'life-tags': t('config.lifeCategories')
};
return labels[contentType] ?? t('pages.profile.otherContributions');
}
@@ -726,8 +726,8 @@ onMounted(() => {
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
<span class="life-post__tag">{{ post.category.name }}</span>
</div>
<div class="profile-feed-card__metrics">