feat(acquisition): add category grouping for acquisition methods
Add category field to acquisition_methods table with 'General' default Group acquisition methods by category in item and recipe detail views Enable category management in admin configuration
This commit is contained in:
@@ -505,6 +505,7 @@
|
|||||||
### 入手方式
|
### 入手方式
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
|
- Category:用于将入手方式分组展示;未单独维护分类主数据,默认可使用 `General`
|
||||||
- 可关联到物品和材料单。
|
- 可关联到物品和材料单。
|
||||||
|
|
||||||
### 地图
|
### 地图
|
||||||
@@ -712,7 +713,7 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
||||||
- 分类
|
- 分类
|
||||||
- 用途
|
- 用途
|
||||||
- 入手方式
|
- 入手方式:按 Category 分类表格展示,左侧为 Category,右侧为该分类下的入手方式 value 逐行展示
|
||||||
- 客制化
|
- 客制化
|
||||||
- 标签
|
- 标签
|
||||||
- Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
|
- Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
|
||||||
@@ -786,7 +787,7 @@ Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `i
|
|||||||
|
|
||||||
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
|
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
|
||||||
- 结果物品名称、分类和用途;`GET /api/recipes/:id` 的 `item` 字段返回展示所需的 `id`、`name`、`image`、`category`、`usage`
|
- 结果物品名称、分类和用途;`GET /api/recipes/:id` 的 `item` 字段返回展示所需的 `id`、`name`、`image`、`category`、`usage`
|
||||||
- 入手方式
|
- 入手方式:按 Category 分类表格展示,左侧为 Category,右侧为该分类下的入手方式 value 逐行展示
|
||||||
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
|
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
|
||||||
- 最后编辑信息
|
- 最后编辑信息
|
||||||
- 讨论
|
- 讨论
|
||||||
|
|||||||
@@ -1169,6 +1169,7 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
|||||||
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
|
category text NOT NULL DEFAULT 'General',
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
@@ -1565,6 +1566,9 @@ ALTER TABLE environments
|
|||||||
ALTER TABLE favorite_things
|
ALTER TABLE favorite_things
|
||||||
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;
|
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;
|
||||||
|
|
||||||
|
ALTER TABLE acquisition_methods
|
||||||
|
ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'General';
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ type ConfigType =
|
|||||||
type ConfigDefinition = {
|
type ConfigDefinition = {
|
||||||
table: string;
|
table: string;
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
|
hasCategory?: boolean;
|
||||||
hasItemDrop?: boolean;
|
hasItemDrop?: boolean;
|
||||||
hasTrading?: boolean;
|
hasTrading?: boolean;
|
||||||
hasChangeLog?: boolean;
|
hasChangeLog?: boolean;
|
||||||
@@ -663,6 +664,7 @@ type DailyChecklistChangeSource = {
|
|||||||
} & TranslationChangeSource;
|
} & TranslationChangeSource;
|
||||||
type ConfigChangeSource = {
|
type ConfigChangeSource = {
|
||||||
name: string;
|
name: string;
|
||||||
|
category?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
opposite?: { name: string } | null;
|
opposite?: { name: string } | null;
|
||||||
hasItemDrop?: boolean;
|
hasItemDrop?: boolean;
|
||||||
@@ -734,7 +736,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|||||||
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
|
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
|
||||||
environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
|
environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
|
||||||
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
|
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
|
||||||
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods', hasCategory: true },
|
||||||
maps: { table: 'maps', entityType: 'maps' },
|
maps: { table: 'maps', entityType: 'maps' },
|
||||||
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
|
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
|
||||||
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
|
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
|
||||||
@@ -1042,10 +1044,12 @@ async function deleteEntityTranslations(client: DbClient, entityType: EntityType
|
|||||||
function optionSelect(
|
function optionSelect(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
entityType: EntityType,
|
entityType: EntityType,
|
||||||
locale: string
|
locale: string,
|
||||||
): Promise<Array<{ id: number; name: string }>> {
|
includeCategory = false
|
||||||
|
): Promise<Array<{ id: number; name: string; category?: string }>> {
|
||||||
const name = localizedName(entityType, 'o', locale);
|
const name = localizedName(entityType, 'o', locale);
|
||||||
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
const columns = includeCategory ? `o.id, ${name} AS name, o.category` : `o.id, ${name} AS name`;
|
||||||
|
return query(`SELECT ${columns} FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemListLabel(option: SystemListOption, locale: string): string {
|
function systemListLabel(option: SystemListOption, locale: string): string {
|
||||||
@@ -1178,6 +1182,9 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
|
|||||||
if (definition.hasDescription) {
|
if (definition.hasDescription) {
|
||||||
columns.push(`c.description`);
|
columns.push(`c.description`);
|
||||||
}
|
}
|
||||||
|
if (definition.hasCategory) {
|
||||||
|
columns.push(`c.category`);
|
||||||
|
}
|
||||||
if (definition.oppositeColumn) {
|
if (definition.oppositeColumn) {
|
||||||
columns.push(
|
columns.push(
|
||||||
`
|
`
|
||||||
@@ -1247,6 +1254,11 @@ function cleanOptionalText(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanConfigCategory(value: unknown): string {
|
||||||
|
const category = cleanOptionalText(value);
|
||||||
|
return category || 'General';
|
||||||
|
}
|
||||||
|
|
||||||
function isStaticImageFileName(fileName: string): boolean {
|
function isStaticImageFileName(fileName: string): boolean {
|
||||||
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
|
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
|
||||||
}
|
}
|
||||||
@@ -2745,6 +2757,7 @@ function configEditChanges(
|
|||||||
before: ConfigChangeSource,
|
before: ConfigChangeSource,
|
||||||
after: {
|
after: {
|
||||||
name: string;
|
name: string;
|
||||||
|
category?: string;
|
||||||
description: string;
|
description: string;
|
||||||
translations: TranslationInput;
|
translations: TranslationInput;
|
||||||
oppositeName: string;
|
oppositeName: string;
|
||||||
@@ -2756,6 +2769,9 @@ function configEditChanges(
|
|||||||
const changes: EditChange[] = [];
|
const changes: EditChange[] = [];
|
||||||
pushChange(changes, 'Name', before.name, after.name);
|
pushChange(changes, 'Name', before.name, after.name);
|
||||||
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
||||||
|
if (definition.hasCategory) {
|
||||||
|
pushChange(changes, 'Category', before.category, after.category);
|
||||||
|
}
|
||||||
if (definition.hasDescription) {
|
if (definition.hasDescription) {
|
||||||
pushChange(changes, 'Description', before.description, after.description);
|
pushChange(changes, 'Description', before.description, after.description);
|
||||||
}
|
}
|
||||||
@@ -2883,7 +2899,7 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
skillOptions(locale),
|
skillOptions(locale),
|
||||||
optionSelect('environments', 'environments', locale),
|
optionSelect('environments', 'environments', locale),
|
||||||
optionSelect('favorite_things', 'favorite-things', locale),
|
optionSelect('favorite_things', 'favorite-things', locale),
|
||||||
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
optionSelect('acquisition_methods', 'acquisition-methods', locale, true),
|
||||||
optionSelect('maps', 'maps', locale),
|
optionSelect('maps', 'maps', locale),
|
||||||
gameVersionOptions(locale),
|
gameVersionOptions(locale),
|
||||||
optionSelect('dish_flavors', 'dish-flavors', locale),
|
optionSelect('dish_flavors', 'dish-flavors', locale),
|
||||||
@@ -5581,6 +5597,7 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
|
|||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
const translations = cleanTranslations(payload.translations, ['name']);
|
const translations = cleanTranslations(payload.translations, ['name']);
|
||||||
|
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
|
||||||
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
||||||
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
||||||
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||||
@@ -5595,6 +5612,10 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
|
|||||||
columns.push('description');
|
columns.push('description');
|
||||||
values.push(description);
|
values.push(description);
|
||||||
}
|
}
|
||||||
|
if (definition.hasCategory) {
|
||||||
|
columns.push('category');
|
||||||
|
values.push(category);
|
||||||
|
}
|
||||||
if (definition.hasItemDrop) {
|
if (definition.hasItemDrop) {
|
||||||
columns.push('has_item_drop');
|
columns.push('has_item_drop');
|
||||||
values.push(hasItemDrop);
|
values.push(hasItemDrop);
|
||||||
@@ -5653,6 +5674,7 @@ export async function updateConfig(
|
|||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
const translations = cleanTranslations(payload.translations, ['name']);
|
const translations = cleanTranslations(payload.translations, ['name']);
|
||||||
|
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
|
||||||
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
||||||
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
||||||
const before = await getConfigById(type, id, defaultLocale);
|
const before = await getConfigById(type, id, defaultLocale);
|
||||||
@@ -5675,6 +5697,10 @@ export async function updateConfig(
|
|||||||
values.push(description);
|
values.push(description);
|
||||||
assignments.push(`description = $${values.length}`);
|
assignments.push(`description = $${values.length}`);
|
||||||
}
|
}
|
||||||
|
if (definition.hasCategory) {
|
||||||
|
values.push(category);
|
||||||
|
assignments.push(`category = $${values.length}`);
|
||||||
|
}
|
||||||
if (definition.hasItemDrop) {
|
if (definition.hasItemDrop) {
|
||||||
values.push(hasItemDrop);
|
values.push(hasItemDrop);
|
||||||
assignments.push(`has_item_drop = $${values.length}`);
|
assignments.push(`has_item_drop = $${values.length}`);
|
||||||
@@ -5735,6 +5761,7 @@ export async function updateConfig(
|
|||||||
const changes = before
|
const changes = before
|
||||||
? configEditChanges(definition, before as ConfigChangeSource, {
|
? configEditChanges(definition, before as ConfigChangeSource, {
|
||||||
name,
|
name,
|
||||||
|
category,
|
||||||
description,
|
description,
|
||||||
translations,
|
translations,
|
||||||
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
|
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
|
||||||
@@ -6813,7 +6840,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT am.id, ${acquisitionMethodName} AS name
|
SELECT am.id, ${acquisitionMethodName} AS name, am.category
|
||||||
FROM item_acquisition_methods iam
|
FROM item_acquisition_methods iam
|
||||||
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
||||||
WHERE iam.item_id = $1
|
WHERE iam.item_id = $1
|
||||||
@@ -6828,7 +6855,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
${resultItemName} AS name,
|
${resultItemName} AS name,
|
||||||
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}, 'category', am.category) ORDER BY ${orderByEntity('am')})
|
||||||
FROM recipe_acquisition_methods ram
|
FROM recipe_acquisition_methods ram
|
||||||
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||||
WHERE ram.recipe_id = r.id
|
WHERE ram.recipe_id = r.id
|
||||||
@@ -7496,7 +7523,7 @@ export async function getRecipe(id: number, locale = defaultLocale) {
|
|||||||
${resultItemName} AS name,
|
${resultItemName} AS name,
|
||||||
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}, 'category', am.category) ORDER BY ${orderByEntity('am')})
|
||||||
FROM recipe_acquisition_methods ram
|
FROM recipe_acquisition_methods ram
|
||||||
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||||
WHERE ram.recipe_id = r.id
|
WHERE ram.recipe_id = r.id
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface NamedEntity {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
|
category?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
opposite?: NamedEntity | null;
|
opposite?: NamedEntity | null;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
@@ -1626,7 +1627,7 @@ export const api = {
|
|||||||
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (
|
createConfig: (
|
||||||
type: ConfigType,
|
type: ConfigType,
|
||||||
payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||||
) =>
|
) =>
|
||||||
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||||
@@ -1634,7 +1635,7 @@ export const api = {
|
|||||||
updateConfig: (
|
updateConfig: (
|
||||||
type: ConfigType,
|
type: ConfigType,
|
||||||
id: number,
|
id: number,
|
||||||
payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||||
) =>
|
) =>
|
||||||
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||||
|
|||||||
@@ -5738,6 +5738,50 @@ button:disabled,
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-method-table {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-method-table th,
|
||||||
|
.entity-method-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-method-table tr:first-child th,
|
||||||
|
.entity-method-table tr:first-child td {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-method-table th {
|
||||||
|
width: 32%;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-method-table td {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-method-table td span {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-profile-title-link {
|
.entity-profile-title-link {
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
color: var(--pokemon-blue-deep);
|
color: var(--pokemon-blue-deep);
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
|||||||
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
||||||
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
||||||
type EditableConfig = (NamedEntity | Skill | GameVersion) & {
|
type EditableConfig = (NamedEntity | Skill | GameVersion) & {
|
||||||
|
category?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
opposite?: NamedEntity | null;
|
opposite?: NamedEntity | null;
|
||||||
hasItemDrop?: boolean;
|
hasItemDrop?: boolean;
|
||||||
@@ -210,6 +211,7 @@ const configTypes = computed<
|
|||||||
supportsItemDrop?: boolean;
|
supportsItemDrop?: boolean;
|
||||||
supportsTrading?: boolean;
|
supportsTrading?: boolean;
|
||||||
supportsChangeLog?: boolean;
|
supportsChangeLog?: boolean;
|
||||||
|
supportsCategory?: boolean;
|
||||||
supportsDescription?: boolean;
|
supportsDescription?: boolean;
|
||||||
supportsOpposite?: boolean;
|
supportsOpposite?: boolean;
|
||||||
}>
|
}>
|
||||||
@@ -218,7 +220,7 @@ const configTypes = computed<
|
|||||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
|
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
|
||||||
{ key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
|
{ key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
|
||||||
{ key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true },
|
{ key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true },
|
||||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
{ key: 'acquisition-methods', label: t('config.acquisitionMethods'), supportsCategory: true },
|
||||||
{ key: 'maps', label: t('config.maps') },
|
{ key: 'maps', label: t('config.maps') },
|
||||||
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
|
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
|
||||||
{ key: 'dish-flavors', label: t('config.dishFlavors') }
|
{ key: 'dish-flavors', label: t('config.dishFlavors') }
|
||||||
@@ -254,6 +256,7 @@ const message = ref('');
|
|||||||
const configForm = ref({
|
const configForm = ref({
|
||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
|
category: 'General',
|
||||||
description: '',
|
description: '',
|
||||||
oppositeId: '',
|
oppositeId: '',
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
@@ -632,7 +635,7 @@ async function loadModuleSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetConfigForm() {
|
function resetConfigForm() {
|
||||||
configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
|
configForm.value = { id: 0, name: '', category: 'General', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetChecklistForm() {
|
function resetChecklistForm() {
|
||||||
@@ -740,6 +743,7 @@ function editConfig(item: EditableConfig) {
|
|||||||
configForm.value = {
|
configForm.value = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.baseName ?? item.name,
|
name: item.baseName ?? item.name,
|
||||||
|
category: item.category ?? 'General',
|
||||||
description: item.description ?? '',
|
description: item.description ?? '',
|
||||||
oppositeId: item.opposite ? String(item.opposite.id) : '',
|
oppositeId: item.opposite ? String(item.opposite.id) : '',
|
||||||
translations: item.translations ?? {},
|
translations: item.translations ?? {},
|
||||||
@@ -1143,6 +1147,7 @@ async function saveConfig() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: configBaseNameForSave(),
|
name: configBaseNameForSave(),
|
||||||
translations: configForm.value.translations,
|
translations: configForm.value.translations,
|
||||||
|
category: selectedConfig.value.supportsCategory ? configForm.value.category : undefined,
|
||||||
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
|
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
|
||||||
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
|
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
|
||||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
||||||
@@ -2231,6 +2236,7 @@ onMounted(() => {
|
|||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<span class="reorderable-row-title">
|
<span class="reorderable-row-title">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
|
<span v-if="selectedConfig.supportsCategory" class="config-flag">{{ item.category ?? 'General' }}</span>
|
||||||
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
|
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
|
||||||
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||||
<span v-if="tradingModuleEnabled && item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
|
<span v-if="tradingModuleEnabled && item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
|
||||||
@@ -3064,6 +3070,10 @@ onMounted(() => {
|
|||||||
<label for="config-name">{{ t('common.name') }}</label>
|
<label for="config-name">{{ t('common.name') }}</label>
|
||||||
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
|
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedConfig.supportsCategory" class="field">
|
||||||
|
<label for="config-category">{{ t('pages.admin.category') }}</label>
|
||||||
|
<input id="config-category" v-model="configForm.category" />
|
||||||
|
</div>
|
||||||
<div v-if="selectedConfig.supportsDescription" class="field">
|
<div v-if="selectedConfig.supportsDescription" class="field">
|
||||||
<label for="config-description">{{ t('pages.admin.description') }}</label>
|
<label for="config-description">{{ t('pages.admin.description') }}</label>
|
||||||
<textarea id="config-description" v-model="configForm.description"></textarea>
|
<textarea id="config-description" v-model="configForm.description"></textarea>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||||
import { api, type AuthUser, type ItemDetail, type ModuleSettings } from '../services/api';
|
import { api, type AuthUser, type ItemDetail, type ModuleSettings, type NamedEntity } from '../services/api';
|
||||||
import ItemEdit from './ItemEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -72,6 +72,7 @@ const possibleTagEvidenceSections = computed(() => [
|
|||||||
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
|
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
|
||||||
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
||||||
]);
|
]);
|
||||||
|
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(item.value?.acquisitionMethods ?? []));
|
||||||
|
|
||||||
const { data: moduleSettings } = useAsyncData<ModuleSettings>(
|
const { data: moduleSettings } = useAsyncData<ModuleSettings>(
|
||||||
'module-settings',
|
'module-settings',
|
||||||
@@ -185,6 +186,17 @@ function activeItemRouteId(): string | null {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupAcquisitionMethods(methods: NamedEntity[]) {
|
||||||
|
const groups = new Map<string, NamedEntity[]>();
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
const category = method.category?.trim() || t('common.none');
|
||||||
|
groups.set(category, [...(groups.get(category) ?? []), method]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.entries()].map(([category, values]) => ({ category, values }));
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
currentUser.value = (await api.me()).user;
|
currentUser.value = (await api.me()).user;
|
||||||
@@ -341,7 +353,16 @@ watch(initialItem, applyInitialItem, { immediate: true });
|
|||||||
</div>
|
</div>
|
||||||
<div class="entity-profile-group">
|
<div class="entity-profile-group">
|
||||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||||
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
|
<table v-if="acquisitionMethodGroups.length" class="entity-method-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="group in acquisitionMethodGroups" :key="group.category">
|
||||||
|
<th scope="row">{{ group.category }}</th>
|
||||||
|
<td>
|
||||||
|
<span v-for="method in group.values" :key="method.id">{{ method.name }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="entity-profile-group">
|
<div class="entity-profile-group">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
||||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||||
import { api, type AuthUser, type RecipeDetail } from '../services/api';
|
import { api, type AuthUser, type NamedEntity, type RecipeDetail } from '../services/api';
|
||||||
import RecipeEdit from './RecipeEdit.vue';
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -41,6 +41,7 @@ const recipeSubtitle = computed(() => {
|
|||||||
|
|
||||||
return categoryName ?? t('pages.recipes.detailSubtitle');
|
return categoryName ?? t('pages.recipes.detailSubtitle');
|
||||||
});
|
});
|
||||||
|
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(recipe.value?.acquisition_methods ?? []));
|
||||||
|
|
||||||
const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
|
const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
|
||||||
`recipe-detail:${String(route.params.id)}:${locale.value}`,
|
`recipe-detail:${String(route.params.id)}:${locale.value}`,
|
||||||
@@ -75,6 +76,17 @@ function applyInitialRecipe(value: RecipeDetail | null | undefined) {
|
|||||||
initialRecipeLoaded.value = true;
|
initialRecipeLoaded.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupAcquisitionMethods(methods: NamedEntity[]) {
|
||||||
|
const groups = new Map<string, NamedEntity[]>();
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
const category = method.category?.trim() || t('common.none');
|
||||||
|
groups.set(category, [...(groups.get(category) ?? []), method]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.entries()].map(([category, values]) => ({ category, values }));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRecipeDetail() {
|
async function loadRecipeDetail() {
|
||||||
try {
|
try {
|
||||||
const nextRecipe = await api.recipeDetail(String(route.params.id));
|
const nextRecipe = await api.recipeDetail(String(route.params.id));
|
||||||
@@ -214,7 +226,16 @@ watch(initialRecipe, applyInitialRecipe, { immediate: true });
|
|||||||
<div class="entity-profile-groups">
|
<div class="entity-profile-groups">
|
||||||
<div class="entity-profile-group">
|
<div class="entity-profile-group">
|
||||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||||
<EntityChips v-if="recipe.acquisition_methods.length" :items="recipe.acquisition_methods" />
|
<table v-if="acquisitionMethodGroups.length" class="entity-method-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="group in acquisitionMethodGroups" :key="group.category">
|
||||||
|
<th scope="row">{{ group.category }}</th>
|
||||||
|
<td>
|
||||||
|
<span v-for="method in group.values" :key="method.id">{{ method.name }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="entity-profile-group">
|
<div class="entity-profile-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user