feat(admin): add data tools for export, import, and wipe

Add admin.data.export and admin.data.import permissions
Implement backend logic and API endpoints for data bundle management
Add Data Tools tab to admin interface with scope selection
Support Pokemon, Habitats, Items, Recipes, and Daily CheckList scopes
This commit is contained in:
2026-05-04 00:56:37 +08:00
parent fa06d24826
commit f2a8b67ebf
8 changed files with 941 additions and 4 deletions

View File

@@ -296,6 +296,24 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
scope: DataToolScope;
count: number;
}
export interface DataToolsSummary {
scopes: DataToolScopeSummary[];
}
export interface DataToolsBundle {
version: 1;
exportedAt: string;
scopes: DataToolScope[];
data: Partial<Record<DataToolScope, Record<string, unknown[]>>>;
}
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
@@ -906,6 +924,10 @@ export const api = {
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),

View File

@@ -835,6 +835,59 @@ button:disabled,
gap: 12px;
}
.data-tool-grid {
display: grid;
gap: 0;
}
.data-tool-panel {
display: grid;
gap: 14px;
padding: 18px 0;
border-bottom: 1px solid var(--line);
}
.data-tool-panel:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.data-tool-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.data-tool-panel__header h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
font-weight: 900;
}
.data-tool-scope-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.data-tool-scope {
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
color: var(--ink-soft);
font-weight: 800;
}
.data-tool-scope input {
width: 18px;
height: 18px;
}
.pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0;

View File

@@ -24,6 +24,7 @@ import {
iconRecipe,
iconSave,
iconTranslate,
iconUpload,
type AppIcon
} from '../icons';
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
@@ -36,6 +37,9 @@ import {
type AuthUser,
type AdminUser,
type ConfigType,
type DataToolScope,
type DataToolsBundle,
type DataToolsSummary,
type DailyChecklistItem,
type GameVersion,
type Habitat,
@@ -65,6 +69,7 @@ type AdminTab =
| 'permissions'
| 'rateLimits'
| 'aiModeration'
| 'dataTools'
| 'config'
| 'languages'
| 'wordings'
@@ -97,6 +102,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'upload',
'fetch'
];
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
@@ -113,6 +119,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
permissions: iconKey,
rateLimits: iconAdmin,
aiModeration: iconAdmin,
dataTools: iconAdmin,
config: iconAdmin,
languages: iconTranslate,
wordings: iconTranslate,
@@ -140,7 +147,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
]
},
{
@@ -200,6 +208,7 @@ const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const dataToolsSummary = ref<DataToolsSummary | null>(null);
const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
@@ -251,10 +260,16 @@ const userRoleModalOpen = ref(false);
const roleModalOpen = ref(false);
const rolePermissionsModalOpen = ref(false);
const permissionModalOpen = ref(false);
const dataToolImportModalOpen = ref(false);
const dataToolWipeModalOpen = ref(false);
const wordingLocale = ref(getCurrentLocale());
const wordingModule = ref('');
const wordingSurface = ref<SystemWordingSurface | ''>('');
const wordingMissingOnly = ref(false);
const selectedExportScopes = ref<DataToolScope[]>(['pokemon']);
const selectedWipeScopes = ref<DataToolScope[]>([]);
const pendingImportBundle = ref<DataToolsBundle | null>(null);
const dataToolConfirmText = ref('');
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
@@ -352,6 +367,23 @@ const rateLimitPolicyOptions = computed<Array<{ value: RateLimitPolicyKey; label
{ value: 'upload', label: t('pages.admin.rateLimitUpload') },
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
]);
const dataToolScopeOptions = computed<Array<{ value: DataToolScope; label: string; count: number }>>(() =>
dataToolScopeKeys.map((scope) => ({
value: scope,
label: t(`pages.admin.dataToolScope${scope.charAt(0).toUpperCase()}${scope.slice(1)}`),
count: dataToolsSummary.value?.scopes.find((item) => item.scope === scope)?.count ?? 0
}))
);
const importScopeLabels = computed(() =>
(pendingImportBundle.value?.scopes ?? [])
.map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope)
.join(' / ')
);
const wipeScopeLabels = computed(() =>
selectedWipeScopes.value
.map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope)
.join(' / ')
);
const filteredWordingRows = computed(() =>
wordingRows.value.filter((item) => {
if (wordingModule.value && item.module !== wordingModule.value) return false;
@@ -383,6 +415,36 @@ function canAny(permissionKey: string | string[]) {
return Array.isArray(permissionKey) ? permissionKey.some((key) => can(key)) : can(permissionKey);
}
function toggleDataToolScope(values: DataToolScope[], scope: DataToolScope) {
const nextValues = new Set(values);
if (nextValues.has(scope)) {
nextValues.delete(scope);
} else {
nextValues.add(scope);
}
return normalizeDataToolScopes([...nextValues]);
}
function normalizeDataToolScopes(scopes: DataToolScope[]) {
const nextValues = new Set(scopes);
if (nextValues.has('items')) {
nextValues.add('recipes');
}
return dataToolScopeKeys.filter((item) => nextValues.has(item));
}
function dataToolScopeLocked(values: DataToolScope[], scope: DataToolScope) {
return scope === 'recipes' && values.includes('items');
}
function toggleExportScope(scope: DataToolScope) {
selectedExportScopes.value = toggleDataToolScope(selectedExportScopes.value, scope);
}
function toggleWipeScope(scope: DataToolScope) {
selectedWipeScopes.value = toggleDataToolScope(selectedWipeScopes.value, scope);
}
function dragSortLabel(name: string) {
return t('pages.admin.dragSort', { name });
}
@@ -928,6 +990,10 @@ async function loadRateLimitSettings() {
resetRateLimitForm(rateLimitSettings.value);
}
async function loadDataToolsSummary() {
dataToolsSummary.value = await api.dataToolsSummary();
}
async function reloadWordings() {
await run(loadWordings);
}
@@ -1051,6 +1117,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
if (activeTab.value === 'dataTools') await loadDataToolsSummary();
if (activeTab.value === 'checklist') await loadChecklist();
if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') await loadItems();
@@ -1155,6 +1222,117 @@ async function removeHabitat(id: number) {
});
}
function downloadDataToolsBundle(bundle: DataToolsBundle) {
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `pokopia-data-${bundle.exportedAt.slice(0, 10)}.json`;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function exportDataTools() {
const scopes = normalizeDataToolScopes(selectedExportScopes.value);
if (!scopes.length) {
message.value = t('pages.admin.dataToolSelectScope');
return;
}
selectedExportScopes.value = scopes;
await run(async () => {
const bundle = await api.exportDataTools(scopes);
downloadDataToolsBundle(bundle);
});
}
function openWipeDataTools() {
const scopes = normalizeDataToolScopes(selectedWipeScopes.value);
if (!scopes.length) {
message.value = t('pages.admin.dataToolSelectScope');
return;
}
selectedWipeScopes.value = scopes;
dataToolConfirmText.value = '';
dataToolWipeModalOpen.value = true;
}
function closeWipeDataToolsModal() {
dataToolWipeModalOpen.value = false;
dataToolConfirmText.value = '';
}
async function confirmWipeDataTools() {
if (dataToolConfirmText.value !== 'WIPE') {
return;
}
await run(async () => {
dataToolsSummary.value = await api.wipeDataTools(normalizeDataToolScopes(selectedWipeScopes.value));
selectedWipeScopes.value = [];
closeWipeDataToolsModal();
});
}
function validDataToolsBundle(value: unknown): value is DataToolsBundle {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const bundle = value as Partial<DataToolsBundle>;
return (
bundle.version === 1 &&
Array.isArray(bundle.scopes) &&
bundle.scopes.length > 0 &&
bundle.scopes.every((scope) => dataToolScopeKeys.includes(scope)) &&
Boolean(bundle.data) &&
typeof bundle.data === 'object' &&
!Array.isArray(bundle.data)
);
}
async function selectImportDataToolsFile(event: Event) {
const input = event.target instanceof HTMLInputElement ? event.target : null;
const file = input?.files?.[0];
if (input) {
input.value = '';
}
if (!file) {
return;
}
try {
const bundle = JSON.parse(await file.text()) as unknown;
if (!validDataToolsBundle(bundle)) {
message.value = t('pages.admin.dataToolInvalidBundle');
return;
}
pendingImportBundle.value = { ...bundle, scopes: normalizeDataToolScopes(bundle.scopes) };
dataToolConfirmText.value = '';
dataToolImportModalOpen.value = true;
} catch {
message.value = t('pages.admin.dataToolInvalidBundle');
}
}
function closeImportDataToolsModal() {
dataToolImportModalOpen.value = false;
pendingImportBundle.value = null;
dataToolConfirmText.value = '';
}
async function confirmImportDataTools() {
if (!pendingImportBundle.value || dataToolConfirmText.value !== 'IMPORT') {
return;
}
await run(async () => {
dataToolsSummary.value = await api.importDataTools(pendingImportBundle.value as DataToolsBundle);
closeImportDataToolsModal();
});
}
async function removeRole(id: number) {
await run(async () => {
await api.deleteRole(id);
@@ -1425,6 +1603,78 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'dataTools'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.dataTools') }}</h2>
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy" @click="run(loadDataToolsSummary)">
<Icon :icon="iconAdmin" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolRefresh') }}
</button>
</div>
<div class="data-tool-grid">
<section class="data-tool-panel" :aria-label="t('pages.admin.dataToolExport')">
<div class="data-tool-panel__header">
<h3>{{ t('pages.admin.dataToolExport') }}</h3>
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy || !can('admin.data.export')" @click="exportDataTools">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolExportButton') }}
</button>
</div>
<div class="data-tool-scope-list">
<label v-for="option in dataToolScopeOptions" :key="`export-${option.value}`" class="data-tool-scope">
<input
type="checkbox"
:checked="selectedExportScopes.includes(option.value)"
:disabled="busy || !can('admin.data.export') || dataToolScopeLocked(selectedExportScopes, option.value)"
@change="toggleExportScope(option.value)"
/>
<span>{{ option.label }}</span>
<span class="config-flag">{{ option.count }}</span>
</label>
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolUploadsNote') }}</p>
</section>
<section class="data-tool-panel" :aria-label="t('pages.admin.dataToolImport')">
<div class="data-tool-panel__header">
<h3>{{ t('pages.admin.dataToolImport') }}</h3>
</div>
<div class="field">
<label for="data-tools-import-file">{{ t('pages.admin.dataToolImportFile') }}</label>
<input id="data-tools-import-file" type="file" accept="application/json,.json" :disabled="busy || !can('admin.data.import')" @change="selectImportDataToolsFile" />
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
</section>
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
<div class="data-tool-panel__header">
<h3>{{ t('pages.admin.dataToolWipe') }}</h3>
<button type="button" class="ui-button ui-button--red ui-button--small" :disabled="busy || !can('admin.data.import')" @click="openWipeDataTools">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolWipeButton') }}
</button>
</div>
<div class="data-tool-scope-list">
<label v-for="option in dataToolScopeOptions" :key="`wipe-${option.value}`" class="data-tool-scope">
<input
type="checkbox"
:checked="selectedWipeScopes.includes(option.value)"
:disabled="busy || !can('admin.data.import') || dataToolScopeLocked(selectedWipeScopes, option.value)"
@change="toggleWipeScope(option.value)"
/>
<span>{{ option.label }}</span>
<span class="config-flag">{{ option.count }}</span>
</label>
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolReplaceNote') }}</p>
</section>
</div>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.config') }}</h2>
@@ -1802,6 +2052,44 @@ onMounted(() => {
</div>
</div>
<Modal v-if="dataToolImportModalOpen" :title="t('pages.admin.dataToolImport')" :close-label="t('common.close')" @close="closeImportDataToolsModal">
<form id="admin-data-tool-import-form" class="modal-edit-form" @submit.prevent="confirmImportDataTools">
<p class="meta-line">{{ t('pages.admin.dataToolImportConfirm', { scopes: importScopeLabels }) }}</p>
<div class="field">
<label for="data-tool-import-confirm">{{ t('pages.admin.dataToolConfirmImport') }}</label>
<input id="data-tool-import-confirm" v-model="dataToolConfirmText" autocomplete="off" required />
</div>
</form>
<template #footer>
<button type="button" class="link-button" :disabled="busy" @click="closeImportDataToolsModal">
{{ t('common.cancel') }}
</button>
<button type="submit" form="admin-data-tool-import-form" class="link-button" :disabled="busy || dataToolConfirmText !== 'IMPORT'">
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolImportButton') }}
</button>
</template>
</Modal>
<Modal v-if="dataToolWipeModalOpen" :title="t('pages.admin.dataToolWipe')" :close-label="t('common.close')" @close="closeWipeDataToolsModal">
<form id="admin-data-tool-wipe-form" class="modal-edit-form" @submit.prevent="confirmWipeDataTools">
<p class="meta-line">{{ t('pages.admin.dataToolWipeConfirm', { scopes: wipeScopeLabels }) }}</p>
<div class="field">
<label for="data-tool-wipe-confirm">{{ t('pages.admin.dataToolConfirmWipe') }}</label>
<input id="data-tool-wipe-confirm" v-model="dataToolConfirmText" autocomplete="off" required />
</div>
</form>
<template #footer>
<button type="button" class="link-button" :disabled="busy" @click="closeWipeDataToolsModal">
{{ t('common.cancel') }}
</button>
<button type="submit" form="admin-data-tool-wipe-form" class="link-button" :disabled="busy || dataToolConfirmText !== 'WIPE'">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolWipeButton') }}
</button>
</template>
</Modal>
<Modal v-if="userRoleModalOpen" :title="userRoleModalTitle" :close-label="t('common.close')" size="wide" @close="closeUserRoleModal">
<form id="admin-user-roles-form" class="modal-edit-form" @submit.prevent="saveUserRoles">
<div v-if="editingUser" class="access-modal-heading">