Compare commits

..

3 Commits

Author SHA1 Message Date
a0e07f101a perf(pokemon): cache fetch options locally to reduce API requests
Add `all` parameter to fetch-options API to retrieve the full list.
Fetch all options once and filter locally in the Pokemon edit view to improve search responsiveness.
2026-05-03 22:34:49 +08:00
df212a4e27 feat(pokemon): decouple official data ID from display ID during fetch
Allow fetching data and images using official identifiers regardless of the custom display ID.
Extract data ID directly from image paths instead of relying on the display ID.
Only auto-fill display ID from fetched data if the field is currently empty.
2026-05-03 22:23:29 +08:00
deb0b54e71 feat(admin): make user rate limits configurable via admin UI
Add rate_limit_settings table and corresponding admin permissions
Replace static user rate limits with dynamic in-memory counters
Add interface in admin panel to configure rate limit policies
2026-05-03 22:11:41 +08:00
9 changed files with 581 additions and 183 deletions

View File

@@ -209,10 +209,17 @@
## 滥用防护与限流
- 后端使用 `@fastify/rate-limit` 在应用层执行限流;默认内存存储适用于当前单实例运行,后续多实例部署需要切换到共享存储或反向代理层限流。
- 后端使用 `@fastify/rate-limit` 和应用内用户级计数在应用层执行限流;默认内存存储适用于当前单实例运行,后续多实例部署需要切换到共享存储或反向代理层限流。
- Fastify 默认不信任代理转发 IP部署在可信反向代理后方时可设置 `TRUST_PROXY=true`,让 IP 限流使用代理解析后的客户端 IP。
- 限流 key 不对外暴露;邮箱限流使用规范化小写邮箱生成内部 key用户限流使用当前登录用户 ID路由限流使用 HTTP method + route pattern。
- 限流 key 不对外暴露;邮箱限流使用规范化小写邮箱生成内部 key已登录用户限流使用当前登录用户 ID路由限流使用 HTTP method + route pattern。
- 触发限流时 API 返回 429 和本地化通用错误文案,并带 `Retry-After` 与 rate limit headers响应不得返回邮箱、用户 ID、内部 key、token/hash 或调试信息。
- 可配置的已登录用户限流存储在 `rate_limit_settings`
- `settings`JSON object保存各用户级限流策略的 `maxRequests``timeWindowSeconds``cooldownSeconds`
- `updated_by_user_id`
- `created_at`
- `updated_at`
- 管理端 Access 分组提供 Rate limits 设置区;查看需要 `admin.rate-limits.read`,更新需要 `admin.rate-limits.update`
- 已登录用户级限流策略仅按用户 ID 计数,不再叠加写入路由 IP 限流或用户 + 路由写入限流;认证入口和受保护路由的 IP 防护仍保留。
- 认证入口限流:
- 注册、登录、验证邮箱、请求重置密码、提交重置密码均按 IP + 路由限制为 20 次 / 10 分钟。
- 登录额外按邮箱限制为 5 次 / 15 分钟。
@@ -220,17 +227,14 @@
- 请求重置密码额外按邮箱限制为 3 次 / 1 小时,并按 IP + 路由限制为 10 次 / 15 分钟。
- 提交重置密码额外按 IP + 路由限制为 10 次 / 15 分钟。
- 已登录保护路由按 IP + 路由限制为 120 次 / 10 分钟,避免单一来源反复触发鉴权查询。
- 写入路由通用限流:
- 写入路由按 IP + 路由限制为 90 次 / 10 分钟
- 写入路由按用户 ID + 路由限制为 30 次 / 10 分钟
- 用户账号资料写入按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。
- Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList、配置项和排序按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 管理写入(用户角色、角色、权限、语言和系统文案)按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 上传按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
- 用户账号资料写入默认按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。
- 管理写入System config 配置项、用户角色、角色、权限、语言、系统文案、AI 审核设置和限流设置)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间
- Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间
- 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
- Community 写入:
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
- Life reaction 写入按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
- Pokemon Fetch 数据和图片候选查询按 IP + 路由限制为 60 次 / 10 分钟,按用户 ID 限制为 60 次 / 10 分钟,按用户 ID + 路由限制为 30 次 / 10 分钟,并有 1 秒冷却时间。
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
- Life reaction 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
## Community 编辑与审计
@@ -433,19 +437,21 @@ Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,
Pokemon 编辑表单使用标签页组织字段:
- 编辑表单提供 Fetch data 功能:
- 已验证且拥有 `pokemon.fetch` 权限的用户可输入 data identifier 或 Pokemon ID从同一个搜索输入查询基础资料或图片候选。
- 已验证且拥有 `pokemon.fetch` 权限的用户可在 Fetch 输入框输入 data identifier 或官方 data Pokemon ID从同一个搜索输入查询基础资料或图片候选。
- Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称结果只展示 `#ID`、名称和 identifier。
- Fetch 搜索结果默认关闭只在用户主动点击输入框或输入内容时展开Escape、失焦 / 点击外部、选择结果后关闭。
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
- Fetch 只填入 CSV 可提供的字段ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
- Fetch 只填入 CSV 可提供的字段:官方 data ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
- Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
- Fetch 不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en``ja``ko``fr``de``es``it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans``zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`Type ID 与 `data/localized_type_name.csv``frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
- 编辑表单提供 Pokemon 图片选择功能:
- 已验证且拥有 `pokemon.fetch` 权限的用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
- 已验证且拥有 `pokemon.fetch` 权限的用户通过 Fetch data 的同一个 data identifier / 官方 data Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
- 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。
- 静态图片与官方 data identifier / 官方 data Pokemon ID 关联,不与 Pokopia 可编辑展示 ID 关联;用户修改 Pokopia 展示 ID 后,已选静态图片仍可保存。
- 图片选择不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述API 对外返回可直接展示的图片 URL但不暴露内部校验状态。
@@ -749,7 +755,7 @@ API 暴露边界:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions。
- 访问权限Users、Roles、Permissions、Rate limits
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
- 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。
- 导航和主要操作使用图标增强识别。
@@ -846,7 +852,7 @@ API 暴露边界:
受权限保护的编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create``update``delete` 权限。
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要 `pokemon.fetch`;只返回 `id``identifier``name`
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;支持 `all=true` 返回完整候选列表供前端本地筛选;需要 `pokemon.fetch`;只返回 `id``identifier``name`
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要 `pokemon.fetch`;不直接保存 Pokemon。
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id``identifier` 和图片候选列表。
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon``items``habitats`;返回图片历史记录项和可展示 URL。
@@ -873,6 +879,9 @@ API 暴露边界:
- `DELETE /api/life-posts/:id/rating`
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
- 限流设置的查看和更新通过 Access 权限控制:
- `GET /api/admin/rate-limits`:需要 `admin.rate-limits.read`
- `PUT /api/admin/rate-limits`:需要 `admin.rate-limits.update`
- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。
- 系统级文案的查看和更新需要对应 `admin.wordings.*` 权限。
- `GET /api/admin/system-wordings`

View File

@@ -177,6 +177,18 @@ CREATE TABLE IF NOT EXISTS ai_moderation_cache (
CHECK (length(model) BETWEEN 1 AND 120)
);
CREATE TABLE IF NOT EXISTS rate_limit_settings (
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
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()
);
INSERT INTO rate_limit_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (key, name, description, category, system_permission)
VALUES
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
@@ -200,6 +212,8 @@ VALUES
('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true),
('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true),
('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true),
('admin.rate-limits.read', 'View rate limits', 'View user rate limit settings.', 'Rate limits', true),
('admin.rate-limits.update', 'Update rate limits', 'Edit user rate limit settings.', 'Rate limits', true),
('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true),
('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true),
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
@@ -284,6 +298,8 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'admin.wordings.update',
'admin.ai-moderation.read',
'admin.ai-moderation.update',
'admin.rate-limits.read',
'admin.rate-limits.update',
'admin.config.read',
'admin.config.create',
'admin.config.update',
@@ -345,6 +361,16 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'admin.rate-limits.read',
'admin.rate-limits.update'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r

View File

@@ -1520,13 +1520,28 @@ function pokemonImageLabel(image: PokemonImage | null | undefined): string {
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
}
function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null {
function pokemonImageDataIdFromPath(path: string): number | null {
const match = path.match(/^\/sprites\/pokemon\/(?:.+\/)?([1-9]\d*)\.(?:png|gif|svg)$/);
if (!match) {
return null;
}
const id = Number(match[1]);
return Number.isSafeInteger(id) ? id : null;
}
function pokemonImageCandidateForPath(path: string): PokemonImage | null {
const cleanPath = path.trim();
const id = pokemonImageDataIdFromPath(cleanPath);
if (!id) {
return null;
}
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
return candidate ? pokemonImageWithUrl(candidate) : null;
}
function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | null {
function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | null {
const path = typeof value === 'string' ? value.trim() : '';
if (path === '') {
return null;
@@ -1541,13 +1556,13 @@ function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | nu
url: uploadImageUrl(path),
style: 'Upload',
version: 'Community upload',
variant: `#${pokemonId}`,
variant: `#${displayId}`,
description: '',
source: 'upload'
};
}
const image = pokemonImageCandidateForPath(pokemonId, path);
const image = pokemonImageCandidateForPath(path);
if (!image) {
throw validationError('server.validation.pokemonImagePathInvalid');
}
@@ -1789,12 +1804,13 @@ function pokemonFetchOptionMatches(
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
const search = asString(paramsQuery.search)?.trim() ?? '';
const includeAll = asString(paramsQuery.all) === 'true';
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
const rows = data.pokemonRows.filter(
(row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
);
return data.pokemonRows
.filter((row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search))
.slice(0, 20)
.map((row) => pokemonFetchOption(row, data, languages, locale));
return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
}
function displayValue(value: string | null | undefined): string {

View File

@@ -229,10 +229,27 @@ function serverMessage(
}
type RateLimitCheck = ReturnType<typeof app.createRateLimit>;
type RateLimitResult = Awaited<ReturnType<RateLimitCheck>>;
type RateLimitPolicy = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
type RateLimitedRequest = FastifyRequest & {
rateLimitUserId?: number;
type RateLimitPolicySettings = {
maxRequests: number;
timeWindowSeconds: number;
cooldownSeconds: number;
};
type RateLimitPolicySettingsMap = Record<RateLimitPolicy, RateLimitPolicySettings>;
type RateLimitFailure = {
max: number;
ttlInSeconds: number;
isBanned?: boolean;
};
type RateLimitSettingsRow = {
settings: unknown;
updatedAt: Date | string;
updatedBy: { id: number; displayName: string } | null;
};
type PublicRateLimitSettings = {
policies: RateLimitPolicySettingsMap;
updatedAt: Date | string | null;
updatedBy: { id: number; displayName: string } | null;
};
function hashRateLimitPart(value: string): string {
@@ -249,14 +266,6 @@ function emailRateLimitPart(request: FastifyRequest): string {
return hashRateLimitPart(email || 'missing-email');
}
function userRateLimitPart(request: FastifyRequest): string {
return String((request as RateLimitedRequest).rateLimitUserId ?? 'anonymous');
}
function userRouteRateLimitKey(scope: string, request: FastifyRequest): string {
return `${scope}:user:${userRateLimitPart(request)}:route:${routeRateLimitPart(request)}`;
}
function ipRouteRateLimitKey(scope: string, request: FastifyRequest): string {
return `${scope}:ip:${hashRateLimitPart(request.ip)}:route:${routeRateLimitPart(request)}`;
}
@@ -291,139 +300,198 @@ const protectedRouteIpRateLimit = app.createRateLimit({
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('protected', request)
});
const writeRouteIpRateLimit = app.createRateLimit({
max: 90,
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('write', request)
});
const userRouteWriteRateLimit = app.createRateLimit({
max: 30,
timeWindow: '10 minutes',
keyGenerator: (request) => userRouteRateLimitKey('write', request)
});
const fetchRouteIpRateLimit = app.createRateLimit({
max: 60,
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('fetch', request)
});
const userRouteFetchRateLimit = app.createRateLimit({
max: 30,
timeWindow: '10 minutes',
keyGenerator: (request) => userRouteRateLimitKey('fetch', request)
});
const userRateLimitPolicies: Record<RateLimitPolicy, RateLimitCheck[]> = {
accountWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 20,
timeWindow: '1 hour',
keyGenerator: (request) => `account-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '5 seconds',
keyGenerator: (request) => `account-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
adminWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 120,
timeWindow: '1 hour',
keyGenerator: (request) => `admin-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '2 seconds',
keyGenerator: (request) => `admin-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
communityReaction: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 120,
timeWindow: '1 hour',
keyGenerator: (request) => `community-reaction:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '1 second',
keyGenerator: (request) => `community-reaction-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
communityWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 60,
timeWindow: '1 hour',
keyGenerator: (request) => `community-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '5 seconds',
keyGenerator: (request) => `community-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
fetch: [
fetchRouteIpRateLimit,
app.createRateLimit({
max: 60,
timeWindow: '10 minutes',
keyGenerator: (request) => `fetch:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '1 second',
keyGenerator: (request) => `fetch-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteFetchRateLimit
],
upload: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 20,
timeWindow: '1 hour',
keyGenerator: (request) => `upload:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '30 seconds',
keyGenerator: (request) => `upload-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
],
wikiWrite: [
writeRouteIpRateLimit,
app.createRateLimit({
max: 120,
timeWindow: '1 hour',
keyGenerator: (request) => `wiki-write:user:${userRateLimitPart(request)}`
}),
app.createRateLimit({
max: 1,
timeWindow: '2 seconds',
keyGenerator: (request) => `wiki-write-cooldown:user:${userRateLimitPart(request)}`
}),
userRouteWriteRateLimit
]
const rateLimitPolicyKeys: RateLimitPolicy[] = [
'accountWrite',
'adminWrite',
'communityReaction',
'communityWrite',
'fetch',
'upload',
'wikiWrite'
];
const defaultUserRateLimitSettings: RateLimitPolicySettingsMap = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
};
const rateLimitSettingsCacheTtlMs = 30_000;
const minRateLimitWindowSeconds = 60;
const maxRateLimitWindowSeconds = 24 * 60 * 60;
const maxRateLimitRequests = 5_000;
const maxRateLimitCooldownSeconds = 60 * 60;
let rateLimitSettingsCache: { settings: RateLimitPolicySettingsMap; expiresAt: number } | null = null;
let lastRateLimitSweepAt = 0;
const userRateLimitWindows = new Map<string, { count: number; resetAt: number }>();
const userRateLimitCooldowns = new Map<string, number>();
function cloneRateLimitSettings(settings: RateLimitPolicySettingsMap): RateLimitPolicySettingsMap {
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [policy, { ...settings[policy] }])
) as RateLimitPolicySettingsMap;
}
function cleanRateLimitInteger(value: unknown, fallback: number, min: number, max: number): number {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
return fallback;
}
return numeric;
}
function cleanRateLimitPolicySettings(value: unknown, fallback: RateLimitPolicySettings): RateLimitPolicySettings {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
maxRequests: cleanRateLimitInteger(raw.maxRequests, fallback.maxRequests, 1, maxRateLimitRequests),
timeWindowSeconds: cleanRateLimitInteger(
raw.timeWindowSeconds,
fallback.timeWindowSeconds,
minRateLimitWindowSeconds,
maxRateLimitWindowSeconds
),
cooldownSeconds: cleanRateLimitInteger(raw.cooldownSeconds, fallback.cooldownSeconds, 0, maxRateLimitCooldownSeconds)
};
}
function normalizeRateLimitSettings(value: unknown): RateLimitPolicySettingsMap {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
cleanRateLimitPolicySettings(raw[policy], defaultUserRateLimitSettings[policy])
])
) as RateLimitPolicySettingsMap;
}
function publicRateLimitSettings(row: RateLimitSettingsRow | null, settings: RateLimitPolicySettingsMap): PublicRateLimitSettings {
return {
policies: cloneRateLimitSettings(settings),
updatedAt: row?.updatedAt ?? null,
updatedBy: row?.updatedBy ?? null
};
}
async function rateLimitSettingsRow(): Promise<RateLimitSettingsRow | null> {
const result = await pool.query<RateLimitSettingsRow>(
`
SELECT
s.settings,
s.updated_at AS "updatedAt",
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 rate_limit_settings s
LEFT JOIN users updated_user ON updated_user.id = s.updated_by_user_id
WHERE s.id = true
`
);
return result.rows[0] ?? null;
}
async function runtimeRateLimitSettings(): Promise<RateLimitPolicySettingsMap> {
const now = Date.now();
if (rateLimitSettingsCache && rateLimitSettingsCache.expiresAt > now) {
return rateLimitSettingsCache.settings;
}
const row = await rateLimitSettingsRow();
const settings = normalizeRateLimitSettings(row?.settings);
rateLimitSettingsCache = { settings, expiresAt: now + rateLimitSettingsCacheTtlMs };
return settings;
}
async function getRateLimitSettings(): Promise<PublicRateLimitSettings> {
const row = await rateLimitSettingsRow();
return publicRateLimitSettings(row, normalizeRateLimitSettings(row?.settings));
}
async function updateRateLimitSettings(payload: Record<string, unknown>, userId: number): Promise<PublicRateLimitSettings> {
const policies = payload.policies && typeof payload.policies === 'object' ? payload.policies : payload;
const settings = normalizeRateLimitSettings(policies);
await pool.query(
`
INSERT INTO rate_limit_settings (id, settings, updated_by_user_id, updated_at)
VALUES (true, $1::jsonb, $2, now())
ON CONFLICT (id)
DO UPDATE SET settings = EXCLUDED.settings,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()
`,
[JSON.stringify(settings), userId]
);
rateLimitSettingsCache = { settings, expiresAt: Date.now() + rateLimitSettingsCacheTtlMs };
return getRateLimitSettings();
}
function sweepUserRateLimitEntries(now: number): void {
if (now - lastRateLimitSweepAt < 60_000) {
return;
}
lastRateLimitSweepAt = now;
for (const [key, bucket] of userRateLimitWindows.entries()) {
if (bucket.resetAt <= now) {
userRateLimitWindows.delete(key);
}
}
for (const [key, resetAt] of userRateLimitCooldowns.entries()) {
if (resetAt <= now) {
userRateLimitCooldowns.delete(key);
}
}
}
function checkUserPolicyRateLimit(userId: number, policy: RateLimitPolicy, settings: RateLimitPolicySettings): RateLimitFailure | null {
const now = Date.now();
sweepUserRateLimitEntries(now);
const cooldownKey = `${policy}:user:${userId}:cooldown`;
const cooldownResetAt = userRateLimitCooldowns.get(cooldownKey) ?? 0;
if (cooldownResetAt > now) {
return { max: 1, ttlInSeconds: Math.ceil((cooldownResetAt - now) / 1000) };
}
const windowKey = `${policy}:user:${userId}:window`;
const windowResetMs = settings.timeWindowSeconds * 1000;
const existingBucket = userRateLimitWindows.get(windowKey);
const bucket =
existingBucket && existingBucket.resetAt > now
? existingBucket
: { count: 0, resetAt: now + windowResetMs };
if (bucket.count >= settings.maxRequests) {
userRateLimitWindows.set(windowKey, bucket);
return { max: settings.maxRequests, ttlInSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
bucket.count += 1;
userRateLimitWindows.set(windowKey, bucket);
if (settings.cooldownSeconds > 0) {
userRateLimitCooldowns.set(cooldownKey, now + settings.cooldownSeconds * 1000);
}
return null;
}
async function sendRateLimited(
request: FastifyRequest,
reply: FastifyReply,
result: Extract<RateLimitResult, { isAllowed: false }>
result: RateLimitFailure
): Promise<false> {
const retryAfter = Math.max(1, result.ttlInSeconds);
reply.header('retry-after', retryAfter);
reply.header('x-ratelimit-limit', result.max);
reply.header('x-ratelimit-remaining', 0);
reply.header('x-ratelimit-reset', retryAfter);
reply.code(result.isBanned ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
reply.code(result.isBanned === true ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
return false;
}
@@ -456,8 +524,9 @@ async function enforceUserRateLimits(
user: AuthUser,
policy: RateLimitPolicy
): Promise<boolean> {
(request as RateLimitedRequest).rateLimitUserId = user.id;
return enforceRateLimits(request, reply, userRateLimitPolicies[policy]);
const settings = (await runtimeRateLimitSettings())[policy];
const result = checkUserPolicyRateLimit(user.id, policy, settings);
return result ? sendRateLimited(request, reply, result) : true;
}
function badRequest(message: string): Error & { statusCode: number } {
@@ -1450,6 +1519,19 @@ app.put('/api/admin/ai-moderation', async (request, reply) => {
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
return user ? getRateLimitSettings() : undefined;
});
app.put('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.rate-limits.update', 'adminWrite');
if (!user) {
return;
}
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) {
@@ -1463,7 +1545,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
});
app.post('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'adminWrite');
if (!user) {
return;
}
@@ -1477,7 +1559,7 @@ app.post('/api/admin/config/:type', async (request, reply) => {
});
app.put('/api/admin/config/:type/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'adminWrite');
if (!user) {
return;
}
@@ -1489,7 +1571,7 @@ app.put('/api/admin/config/:type/order', async (request, reply) => {
});
app.put('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
if (!user) {
return;
}
@@ -1502,7 +1584,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => {
});
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'wikiWrite');
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'adminWrite');
if (!user) {
return;
}

View File

@@ -636,6 +636,7 @@ export interface EntityDiscussionCommentPayload {
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
export type RateLimitPolicyKey = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
export interface AiModerationSettings {
enabled: boolean;
@@ -660,6 +661,22 @@ export interface AiModerationSettingsPayload {
clearApiKey?: boolean;
}
export interface RateLimitPolicySettings {
maxRequests: number;
timeWindowSeconds: number;
cooldownSeconds: number;
}
export interface RateLimitSettings {
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
updatedAt: string | null;
updatedBy: UserSummary | null;
}
export interface RateLimitSettingsPayload {
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
}
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const search = new URLSearchParams();
@@ -833,6 +850,9 @@ export const api = {
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
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 }),
@@ -988,8 +1008,11 @@ export const api = {
pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
getJson<PokemonFetchOption[]>(
`/api/pokemon/fetch-options${buildQuery({ search: search.trim(), all: all ? true : undefined })}`,
signal
),
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
fetchPokemonImageOptions: (identifier: string) =>
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),

View File

@@ -806,6 +806,35 @@ button:disabled,
max-width: 680px;
}
.rate-limit-list {
display: grid;
gap: 0;
}
.rate-limit-row {
display: grid;
gap: 10px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.rate-limit-row:last-child {
border-bottom: 0;
}
.rate-limit-row h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
font-weight: 900;
}
.rate-limit-fields {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 12px;
}
.pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0;
@@ -6002,7 +6031,8 @@ button:disabled,
.life-toolbar,
.life-toolbar__search,
.life-toolbar__filters {
.life-toolbar__filters,
.rate-limit-fields {
grid-template-columns: 1fr;
}

View File

@@ -46,6 +46,10 @@ import {
type Permission,
type PermissionPayload,
type Pokemon,
type RateLimitPolicyKey,
type RateLimitPolicySettings,
type RateLimitSettings,
type RateLimitSettingsPayload,
type Recipe,
type RoleDetail,
type RolePayload,
@@ -59,6 +63,7 @@ type AdminTab =
| 'users'
| 'roles'
| 'permissions'
| 'rateLimits'
| 'aiModeration'
| 'config'
| 'languages'
@@ -77,11 +82,36 @@ type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
isRateable?: boolean;
changeLog?: string;
};
type RateLimitPolicyForm = {
maxRequests: number;
timeWindowMinutes: number;
cooldownSeconds: number;
};
const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'accountWrite',
'adminWrite',
'wikiWrite',
'communityWrite',
'communityReaction',
'upload',
'fetch'
];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
};
const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile,
roles: iconKey,
permissions: iconKey,
rateLimits: iconAdmin,
aiModeration: iconAdmin,
config: iconAdmin,
languages: iconTranslate,
@@ -128,7 +158,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
items: [
{ key: 'users', label: t('pages.admin.users'), permission: 'admin.users.read' },
{ key: 'roles', label: t('pages.admin.roles'), permission: 'admin.roles.read' },
{ key: 'permissions', label: t('pages.admin.permissions'), permission: 'admin.permissions.read' }
{ key: 'permissions', label: t('pages.admin.permissions'), permission: 'admin.permissions.read' },
{ key: 'rateLimits', label: t('pages.admin.rateLimits'), permission: 'admin.rate-limits.read' }
]
}
];
@@ -168,6 +199,7 @@ const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
@@ -194,6 +226,18 @@ const aiModerationForm = ref({
apiKey: '',
clearApiKey: false
});
const rateLimitForm = ref<Record<RateLimitPolicyKey, RateLimitPolicyForm>>(
Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
{
maxRequests: defaultRateLimitPolicies[policy].maxRequests,
timeWindowMinutes: defaultRateLimitPolicies[policy].timeWindowSeconds / 60,
cooldownSeconds: defaultRateLimitPolicies[policy].cooldownSeconds
}
])
) as Record<RateLimitPolicyKey, RateLimitPolicyForm>
);
const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
@@ -299,6 +343,15 @@ const aiModerationAuthModeOptions = computed<Array<{ value: AiModerationAuthMode
{ value: 'bearer-token', label: t('pages.admin.aiModerationAuthBearer') },
{ value: 'query-key', label: t('pages.admin.aiModerationAuthQueryKey') }
]);
const rateLimitPolicyOptions = computed<Array<{ value: RateLimitPolicyKey; label: string }>>(() => [
{ value: 'accountWrite', label: t('pages.admin.rateLimitAccountWrite') },
{ value: 'adminWrite', label: t('pages.admin.rateLimitAdminWrite') },
{ value: 'wikiWrite', label: t('pages.admin.rateLimitWikiWrite') },
{ value: 'communityWrite', label: t('pages.admin.rateLimitCommunityWrite') },
{ value: 'communityReaction', label: t('pages.admin.rateLimitCommunityReaction') },
{ value: 'upload', label: t('pages.admin.rateLimitUpload') },
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
]);
const filteredWordingRows = computed(() =>
wordingRows.value.filter((item) => {
if (wordingModule.value && item.module !== wordingModule.value) return false;
@@ -422,6 +475,22 @@ function resetAiModerationForm(settings: AiModerationSettings | null = aiModerat
};
}
function resetRateLimitForm(settings: RateLimitSettings | null = rateLimitSettings.value) {
rateLimitForm.value = Object.fromEntries(
rateLimitPolicyKeys.map((policy) => {
const policySettings = settings?.policies[policy] ?? defaultRateLimitPolicies[policy];
return [
policy,
{
maxRequests: policySettings.maxRequests,
timeWindowMinutes: Math.max(1, Math.round(policySettings.timeWindowSeconds / 60)),
cooldownSeconds: policySettings.cooldownSeconds
}
];
})
) as Record<RateLimitPolicyKey, RateLimitPolicyForm>;
}
function resetUserRoleForm() {
userRoleForm.value = { userId: 0, roleIds: [] };
}
@@ -854,6 +923,11 @@ async function loadAiModerationSettings() {
resetAiModerationForm(aiModerationSettings.value);
}
async function loadRateLimitSettings() {
rateLimitSettings.value = await api.rateLimitSettings();
resetRateLimitForm(rateLimitSettings.value);
}
async function reloadWordings() {
await run(loadWordings);
}
@@ -893,6 +967,24 @@ async function saveAiModerationSettings() {
});
}
async function saveRateLimitSettings() {
await run(async () => {
const policies = Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
{
maxRequests: rateLimitForm.value[policy].maxRequests,
timeWindowSeconds: rateLimitForm.value[policy].timeWindowMinutes * 60,
cooldownSeconds: rateLimitForm.value[policy].cooldownSeconds
}
])
) as RateLimitSettingsPayload['policies'];
rateLimitSettings.value = await api.updateRateLimitSettings({ policies });
resetRateLimitForm(rateLimitSettings.value);
});
}
async function saveUserRoles() {
await run(async () => {
userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds);
@@ -955,6 +1047,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'users') await loadUsers();
if (activeTab.value === 'roles') await loadRoles();
if (activeTab.value === 'permissions') await loadPermissions();
if (activeTab.value === 'rateLimits') await loadRateLimitSettings();
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
@@ -1235,6 +1328,64 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'rateLimits'" class="detail-section">
<form class="modal-edit-form" @submit.prevent="saveRateLimitSettings">
<div class="detail-section__header">
<h2>{{ t('pages.admin.rateLimits') }}</h2>
<button v-if="can('admin.rate-limits.update')" class="ui-button ui-button--primary ui-button--small" type="submit" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</div>
<div class="rate-limit-list">
<div v-for="policy in rateLimitPolicyOptions" :key="policy.value" class="rate-limit-row">
<h3>{{ policy.label }}</h3>
<div class="rate-limit-fields">
<div class="field">
<label :for="`rate-limit-${policy.value}-max`">{{ t('pages.admin.rateLimitMaxRequests') }}</label>
<input
:id="`rate-limit-${policy.value}-max`"
v-model.number="rateLimitForm[policy.value].maxRequests"
type="number"
min="1"
max="5000"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
<div class="field">
<label :for="`rate-limit-${policy.value}-window`">{{ t('pages.admin.rateLimitWindowMinutes') }}</label>
<input
:id="`rate-limit-${policy.value}-window`"
v-model.number="rateLimitForm[policy.value].timeWindowMinutes"
type="number"
min="1"
max="1440"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
<div class="field">
<label :for="`rate-limit-${policy.value}-cooldown`">{{ t('pages.admin.rateLimitCooldownSeconds') }}</label>
<input
:id="`rate-limit-${policy.value}-cooldown`"
v-model.number="rateLimitForm[policy.value].cooldownSeconds"
type="number"
min="0"
max="3600"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
</div>
</div>
</div>
</form>
</section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.checklist') }}</h2>

View File

@@ -52,6 +52,9 @@ const message = ref('');
const fetchInput = ref<HTMLInputElement | null>(null);
const fetchIdentifier = ref('');
const fetchOptions = ref<PokemonFetchOption[]>([]);
const allFetchOptions = ref<PokemonFetchOption[]>([]);
const fetchOptionsLoaded = ref(false);
const fetchOptionsFailed = ref(false);
const fetchResultsStyle = ref<CSSProperties>({});
const imageOptions = ref<PokemonImage[]>([]);
const currentPokemonImage = ref<PokemonImage | null>(null);
@@ -62,6 +65,7 @@ const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
let fetchOptionsController: AbortController | null = null;
let fetchPositionFrame = 0;
const fetchOptionsLimit = 20;
function defaultPokemonStats(): PokemonStats {
return {
@@ -253,14 +257,9 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
}
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
if (isEditing.value && fetchedPokemon.id !== pokemonIdForSave()) {
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
return false;
}
pokemonForm.value = {
...pokemonForm.value,
id: isEditing.value ? pokemonForm.value.id : String(fetchedPokemon.id),
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
name: fetchedPokemon.name,
genus: fetchedPokemon.genus,
heightInches: fetchedPokemon.heightInches,
@@ -333,24 +332,57 @@ function cancelFetchOptionsRequest() {
fetchOptionsLoading.value = false;
}
function resetFetchOptionsCache() {
cancelFetchOptionsRequest();
allFetchOptions.value = [];
fetchOptions.value = [];
fetchOptionsLoaded.value = false;
fetchOptionsFailed.value = false;
}
function fetchOptionLabel(option: PokemonFetchOption) {
return `#${option.id} ${option.name}`;
}
function fetchOptionMatchesSearch(option: PokemonFetchOption, search: string) {
if (!search) {
return true;
}
const keyword = search.toLowerCase();
return [String(option.id), option.identifier, option.name].some((value) => value.toLowerCase().includes(keyword));
}
function applyLocalFetchOptions() {
const search = fetchIdentifier.value.trim();
fetchOptions.value = allFetchOptions.value.filter((option) => fetchOptionMatchesSearch(option, search)).slice(0, fetchOptionsLimit);
}
async function loadFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
if (fetchOptionsLoaded.value || fetchOptionsFailed.value) {
applyLocalFetchOptions();
return;
}
if (fetchOptionsController) {
return;
}
cancelFetchOptionsRequest();
const controller = new AbortController();
fetchOptionsController = controller;
fetchOptionsLoading.value = true;
try {
const rows = await api.pokemonFetchOptions(fetchIdentifier.value, controller.signal);
const rows = await api.pokemonFetchOptions('', controller.signal, true);
if (fetchOptionsController === controller) {
fetchOptions.value = rows;
allFetchOptions.value = rows;
fetchOptionsLoaded.value = true;
applyLocalFetchOptions();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
@@ -358,6 +390,7 @@ async function loadFetchOptions() {
}
if (fetchOptionsController === controller) {
fetchOptions.value = [];
fetchOptionsFailed.value = true;
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
}
} finally {
@@ -373,6 +406,11 @@ function refreshFetchOptions() {
return;
}
if (fetchOptionsLoaded.value || fetchOptionsFailed.value) {
applyLocalFetchOptions();
return;
}
void loadFetchOptions();
}
@@ -451,6 +489,9 @@ function closeFetchOptions() {
fetchResultsStyle.value = {};
removeFetchPositionListeners();
cancelFetchOptionsRequest();
if (!fetchOptionsLoaded.value) {
fetchOptionsFailed.value = false;
}
}
function handleFetchIdentifierInput() {
@@ -477,7 +518,7 @@ async function fetchPokemonByIdentifier(identifierValue?: string) {
return;
}
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
const identifier = (identifierValue ?? fetchIdentifier.value).trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
return;
@@ -552,7 +593,7 @@ async function fetchPokemonImages() {
return;
}
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
const identifier = fetchIdentifier.value.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
return;
@@ -563,12 +604,6 @@ async function fetchPokemonImages() {
try {
const result = await api.fetchPokemonImageOptions(identifier);
const currentId = pokemonIdForSave();
if (Number.isInteger(currentId) && currentId > 0 && result.id !== currentId) {
message.value = t('pages.pokemon.fetchIdMismatch', { id: result.id });
return;
}
fetchIdentifier.value = result.identifier;
imageOptions.value = result.images;
@@ -681,6 +716,10 @@ onBeforeUnmount(() => {
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
watch(fetchIdentifier, refreshFetchOptions);
watch(locale, () => {
resetFetchOptionsCache();
refreshFetchOptions();
});
</script>
<template>

View File

@@ -838,6 +838,17 @@ export const systemWordingMessages = {
aiModerationApiKeyMissing: 'API Key missing',
aiModerationClearApiKey: 'Clear saved API Key',
aiModerationSettings: 'AI moderation settings',
rateLimits: 'Rate limits',
rateLimitMaxRequests: 'Max requests',
rateLimitWindowMinutes: 'Window minutes',
rateLimitCooldownSeconds: 'Cooldown seconds',
rateLimitAccountWrite: 'Account writes',
rateLimitAdminWrite: 'Management writes',
rateLimitWikiWrite: 'Wiki content writes',
rateLimitCommunityWrite: 'Community writes',
rateLimitCommunityReaction: 'Community reactions',
rateLimitUpload: 'Uploads',
rateLimitFetch: 'Pokemon fetch',
wordingLocale: 'Locale',
wordingModule: 'Module',
wordingSurface: 'Surface',
@@ -1913,6 +1924,17 @@ export const systemWordingMessages = {
aiModerationApiKeyMissing: 'API Key 未配置',
aiModerationClearApiKey: '清除已保存 API Key',
aiModerationSettings: 'AI 审核设置',
rateLimits: '限流',
rateLimitMaxRequests: '最大请求数',
rateLimitWindowMinutes: '窗口分钟数',
rateLimitCooldownSeconds: '冷却秒数',
rateLimitAccountWrite: '账号写入',
rateLimitAdminWrite: '管理写入',
rateLimitWikiWrite: 'Wiki 内容写入',
rateLimitCommunityWrite: '社区写入',
rateLimitCommunityReaction: '社区 Reaction',
rateLimitUpload: '上传',
rateLimitFetch: 'Pokemon Fetch',
wordingLocale: '语言',
wordingModule: '模块',
wordingSurface: '端',