feat(history): add detailed edit history tracking and display panel
Record field-level before/after changes in wiki_edit_logs Replace EditMeta with EditHistoryPanel on entity detail pages Update detail views to use a sidebar layout for history
This commit is contained in:
@@ -262,9 +262,12 @@ CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
|||||||
entity_id integer NOT NULL,
|
entity_id integer NOT NULL,
|
||||||
action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
||||||
user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
changes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE wiki_edit_logs ADD COLUMN IF NOT EXISTS changes jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
||||||
ON wiki_edit_logs(entity_type, entity_id, created_at DESC);
|
ON wiki_edit_logs(entity_type, entity_id, created_at DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,42 @@ type HabitatPayload = {
|
|||||||
|
|
||||||
type ValidationError = Error & { statusCode: number };
|
type ValidationError = Error & { statusCode: number };
|
||||||
type EditAction = 'create' | 'update' | 'delete';
|
type EditAction = 'create' | 'update' | 'delete';
|
||||||
|
type EditChange = {
|
||||||
|
label: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
};
|
||||||
|
type EditHistoryEntry = {
|
||||||
|
action: EditAction;
|
||||||
|
changes: EditChange[];
|
||||||
|
createdAt: Date;
|
||||||
|
user: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
type PokemonChangeSource = {
|
||||||
|
name: string;
|
||||||
|
environment: { name: string };
|
||||||
|
skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
|
||||||
|
favorite_things: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
type ItemChangeSource = {
|
||||||
|
name: string;
|
||||||
|
category: { name: string };
|
||||||
|
usage: { name: string } | null;
|
||||||
|
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
|
||||||
|
noRecipe: boolean;
|
||||||
|
acquisitionMethods: Array<{ name: string }>;
|
||||||
|
tags: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
type HabitatChangeSource = {
|
||||||
|
name: string;
|
||||||
|
recipe: Array<{ name: string; quantity: number }>;
|
||||||
|
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
|
||||||
|
};
|
||||||
|
type RecipeChangeSource = {
|
||||||
|
item: { name: string };
|
||||||
|
acquisition_methods: Array<{ name: string }>;
|
||||||
|
materials: Array<{ name: string; quantity: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
@@ -207,14 +243,228 @@ async function recordEditLog(
|
|||||||
entityType: string,
|
entityType: string,
|
||||||
entityId: number,
|
entityId: number,
|
||||||
action: EditAction,
|
action: EditAction,
|
||||||
userId: number
|
userId: number,
|
||||||
|
changes: EditChange[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id)
|
INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id, changes)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
`,
|
`,
|
||||||
[entityType, entityId, action, userId]
|
[entityType, entityId, action, userId, JSON.stringify(changes)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(value: string | null | undefined): string {
|
||||||
|
const cleanValue = value?.trim() ?? '';
|
||||||
|
return cleanValue === '' ? '无' : cleanValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void {
|
||||||
|
const beforeValue = displayValue(before);
|
||||||
|
const afterValue = displayValue(after);
|
||||||
|
|
||||||
|
if (beforeValue !== afterValue) {
|
||||||
|
changes.push({ label, before: beforeValue, after: afterValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolValue(value: boolean): string {
|
||||||
|
return value ? '是' : '否';
|
||||||
|
}
|
||||||
|
|
||||||
|
function namedListValue(items: Array<{ name: string }> | null | undefined): string {
|
||||||
|
if (!items?.length) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(items.map((item) => item.name))]
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string {
|
||||||
|
if (!items?.length) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => ({ name: item.name, value: `${item.name} x${item.quantity}` }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((item) => item.value)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string {
|
||||||
|
const rows = skills
|
||||||
|
?.filter((skill) => skill.itemDrop)
|
||||||
|
.map((skill) => `${skill.name}:${skill.itemDrop?.name}`)
|
||||||
|
.sort((a, b) => a.localeCompare(b)) ?? [];
|
||||||
|
|
||||||
|
return rows.length ? rows.join(' / ') : '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
function appearanceListValue(
|
||||||
|
rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
|
||||||
|
): string {
|
||||||
|
if (!rows?.length) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => `${row.name}:${row.time_of_day} / ${row.weather} / ${row.rarity} 星 / ${row.map.name}`)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entityNameMap(client: DbClient, tableName: string, ids: number[]): Promise<Map<number, string>> {
|
||||||
|
const uniqueIds = [...new Set(ids)].filter((id) => Number.isInteger(id) && id > 0);
|
||||||
|
if (!uniqueIds.length) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.query<{ id: number; name: string }>(
|
||||||
|
`SELECT id, name FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
||||||
|
[uniqueIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Map(result.rows.map((row) => [row.id, row.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function namesFromIds(ids: number[], namesById: Map<number, string>): string {
|
||||||
|
const names = [...new Set(ids)]
|
||||||
|
.map((id) => namesById.get(id))
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return names.length ? names.join(' / ') : '无';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> {
|
||||||
|
const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId));
|
||||||
|
return quantityListValue(
|
||||||
|
rows
|
||||||
|
.map((row) => {
|
||||||
|
const name = namesById.get(row.itemId);
|
||||||
|
return name ? { name, quantity: row.quantity } : null;
|
||||||
|
})
|
||||||
|
.filter((row): row is { name: string; quantity: number } => row !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pokemonEditChanges(
|
||||||
|
client: DbClient,
|
||||||
|
before: PokemonChangeSource,
|
||||||
|
after: PokemonPayload
|
||||||
|
): Promise<EditChange[]> {
|
||||||
|
const changes: EditChange[] = [];
|
||||||
|
const environmentNames = await entityNameMap(client, 'environments', [after.environmentId]);
|
||||||
|
const skillNames = await entityNameMap(client, 'skills', after.skillIds);
|
||||||
|
const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
|
||||||
|
const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
|
||||||
|
const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId));
|
||||||
|
const afterDrops = after.skillItemDrops
|
||||||
|
.map((drop) => {
|
||||||
|
const skillName = dropSkillNames.get(drop.skillId);
|
||||||
|
const itemName = dropItemNames.get(drop.itemId);
|
||||||
|
return skillName && itemName ? `${skillName}:${itemName}` : null;
|
||||||
|
})
|
||||||
|
.filter((drop): drop is string => drop !== null)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
|
pushChange(changes, '名字', before.name, after.name);
|
||||||
|
pushChange(changes, '喜欢的环境', before.environment.name, environmentNames.get(after.environmentId));
|
||||||
|
pushChange(changes, '特长', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
|
||||||
|
pushChange(changes, '喜欢的东西', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
|
||||||
|
pushChange(changes, '特长掉落物', skillDropListValue(before.skills), afterDrops);
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function itemEditChanges(
|
||||||
|
client: DbClient,
|
||||||
|
before: ItemChangeSource,
|
||||||
|
after: ItemPayload
|
||||||
|
): Promise<EditChange[]> {
|
||||||
|
const changes: EditChange[] = [];
|
||||||
|
const categoryNames = await entityNameMap(client, 'item_categories', [after.categoryId]);
|
||||||
|
const usageNames = await entityNameMap(client, 'item_usages', after.usageId ? [after.usageId] : []);
|
||||||
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
||||||
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
||||||
|
|
||||||
|
pushChange(changes, '名称', before.name, after.name);
|
||||||
|
pushChange(changes, '分类', before.category.name, categoryNames.get(after.categoryId));
|
||||||
|
pushChange(changes, '用途', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
||||||
|
pushChange(changes, '可染色', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
||||||
|
pushChange(changes, '可双区染色', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
|
||||||
|
pushChange(changes, '可改花纹', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
|
||||||
|
pushChange(changes, '无材料单', boolValue(before.noRecipe), boolValue(after.noRecipe));
|
||||||
|
pushChange(changes, '入手方式', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
||||||
|
pushChange(changes, '标签', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function habitatEditChanges(
|
||||||
|
client: DbClient,
|
||||||
|
before: HabitatChangeSource,
|
||||||
|
after: HabitatPayload
|
||||||
|
): Promise<EditChange[]> {
|
||||||
|
const changes: EditChange[] = [];
|
||||||
|
const pokemonNames = await entityNameMap(client, 'pokemon', after.pokemonAppearances.map((row) => row.pokemonId));
|
||||||
|
const mapNames = await entityNameMap(client, 'maps', after.pokemonAppearances.map((row) => row.mapId));
|
||||||
|
const afterAppearances = after.pokemonAppearances
|
||||||
|
.map((row) => {
|
||||||
|
const pokemonName = pokemonNames.get(row.pokemonId);
|
||||||
|
const mapName = mapNames.get(row.mapId);
|
||||||
|
return pokemonName && mapName ? `${pokemonName}:${row.timeOfDay} / ${row.weather} / ${row.rarity} 星 / ${mapName}` : null;
|
||||||
|
})
|
||||||
|
.filter((row): row is string => row !== null)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
|
pushChange(changes, '名称', before.name, after.name);
|
||||||
|
pushChange(changes, '配方', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
||||||
|
pushChange(changes, '可能出现的宝可梦', appearanceListValue(before.pokemon), afterAppearances);
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recipeEditChanges(
|
||||||
|
client: DbClient,
|
||||||
|
before: RecipeChangeSource,
|
||||||
|
after: RecipePayload
|
||||||
|
): Promise<EditChange[]> {
|
||||||
|
const changes: EditChange[] = [];
|
||||||
|
const itemNames = await entityNameMap(client, 'items', [after.itemId]);
|
||||||
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
||||||
|
|
||||||
|
pushChange(changes, '物品', before.item.name, itemNames.get(after.itemId));
|
||||||
|
pushChange(changes, '入手方式', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
||||||
|
pushChange(changes, '需要材料', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials));
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditHistory(entityType: string, entityId: number): Promise<EditHistoryEntry[]> {
|
||||||
|
return query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
l.action,
|
||||||
|
COALESCE(l.changes, '[]'::jsonb) AS changes,
|
||||||
|
l.created_at AS "createdAt",
|
||||||
|
CASE
|
||||||
|
WHEN u.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
||||||
|
END AS user
|
||||||
|
FROM wiki_edit_logs l
|
||||||
|
LEFT JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE l.entity_type = $1
|
||||||
|
AND l.entity_id = $2
|
||||||
|
ORDER BY l.created_at DESC, l.id DESC
|
||||||
|
`,
|
||||||
|
[entityType, entityId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +689,7 @@ export async function getPokemon(id: number) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [habitats, itemDrops, favoriteThingItems] = await Promise.all([
|
const [habitats, itemDrops, favoriteThingItems, editHistory] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -486,7 +736,8 @@ export async function getPokemon(id: number) {
|
|||||||
ORDER BY c.name, i.name
|
ORDER BY c.name, i.name
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
)
|
),
|
||||||
|
getEditHistory('pokemon', id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
||||||
@@ -501,7 +752,7 @@ export async function getPokemon(id: number) {
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return { ...pokemon, skills, habitats, favoriteThingItems };
|
return { ...pokemon, skills, habitats, favoriteThingItems, editHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||||
@@ -601,6 +852,7 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
|||||||
|
|
||||||
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number) {
|
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanPokemonPayload({ ...payload, id });
|
const cleanPayload = cleanPokemonPayload({ ...payload, id });
|
||||||
|
const before = await getPokemon(id);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
@@ -615,7 +867,8 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replacePokemonRelations(client, id, cleanPayload);
|
await replacePokemonRelations(client, id, cleanPayload);
|
||||||
await recordEditLog(client, 'pokemon', id, 'update', userId);
|
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
|
||||||
|
await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getPokemon(id) : null;
|
return updated ? getPokemon(id) : null;
|
||||||
@@ -681,7 +934,8 @@ export async function getHabitat(id: number) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pokemon = await query(
|
const [pokemon, editHistory] = await Promise.all([
|
||||||
|
query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
@@ -697,9 +951,11 @@ export async function getHabitat(id: number) {
|
|||||||
ORDER BY hp.rarity, p.id, m.name
|
ORDER BY hp.rarity, p.id, m.name
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
);
|
),
|
||||||
|
getEditHistory('habitats', id)
|
||||||
|
]);
|
||||||
|
|
||||||
return { ...habitat, pokemon };
|
return { ...habitat, pokemon, editHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
||||||
@@ -785,6 +1041,7 @@ export async function createHabitat(payload: Record<string, unknown>, userId: nu
|
|||||||
|
|
||||||
export async function updateHabitat(id: number, payload: Record<string, unknown>, userId: number) {
|
export async function updateHabitat(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanHabitatPayload(payload);
|
const cleanPayload = cleanHabitatPayload(payload);
|
||||||
|
const before = await getHabitat(id);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
@@ -795,7 +1052,8 @@ export async function updateHabitat(id: number, payload: Record<string, unknown>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replaceHabitatRelations(client, id, cleanPayload);
|
await replaceHabitatRelations(client, id, cleanPayload);
|
||||||
await recordEditLog(client, 'habitats', id, 'update', userId);
|
const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
|
||||||
|
await recordEditLog(client, 'habitats', id, 'update', userId, changes);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getHabitat(id) : null;
|
return updated ? getHabitat(id) : null;
|
||||||
@@ -903,7 +1161,7 @@ export async function getItem(id: number) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon] = await Promise.all([
|
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT am.id, am.name
|
SELECT am.id, am.name
|
||||||
@@ -990,10 +1248,11 @@ export async function getItem(id: number) {
|
|||||||
ORDER BY p.id, s.name
|
ORDER BY p.id, s.name
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
)
|
),
|
||||||
|
getEditHistory('items', id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon };
|
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||||
@@ -1085,6 +1344,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
|
|
||||||
export async function updateItem(id: number, payload: Record<string, unknown>, userId: number) {
|
export async function updateItem(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanItemPayload(payload);
|
const cleanPayload = cleanItemPayload(payload);
|
||||||
|
const before = await getItem(id);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe);
|
await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe);
|
||||||
@@ -1118,7 +1378,8 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replaceItemRelations(client, id, cleanPayload);
|
await replaceItemRelations(client, id, cleanPayload);
|
||||||
await recordEditLog(client, 'items', id, 'update', userId);
|
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
||||||
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getItem(id) : null;
|
return updated ? getItem(id) : null;
|
||||||
@@ -1167,7 +1428,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecipe(id: number) {
|
export async function getRecipe(id: number) {
|
||||||
return queryOne(
|
const recipe = await queryOne(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
@@ -1193,6 +1454,13 @@ export async function getRecipe(id: number) {
|
|||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!recipe) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editHistory = await getEditHistory('recipes', id);
|
||||||
|
return { ...recipe, editHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
||||||
@@ -1257,6 +1525,7 @@ export async function createRecipe(payload: Record<string, unknown>, userId: num
|
|||||||
|
|
||||||
export async function updateRecipe(id: number, payload: Record<string, unknown>, userId: number) {
|
export async function updateRecipe(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanRecipePayload(payload);
|
const cleanPayload = cleanRecipePayload(payload);
|
||||||
|
const before = await getRecipe(id);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
|
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
|
||||||
@@ -1268,7 +1537,8 @@ export async function updateRecipe(id: number, payload: Record<string, unknown>,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replaceRecipeRelations(client, id, cleanPayload);
|
await replaceRecipeRelations(client, id, cleanPayload);
|
||||||
await recordEditLog(client, 'recipes', id, 'update', userId);
|
const changes = before ? await recipeEditChanges(client, before as unknown as RecipeChangeSource, cleanPayload) : [];
|
||||||
|
await recordEditLog(client, 'recipes', id, 'update', userId, changes);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getRecipe(id) : null;
|
return updated ? getRecipe(id) : null;
|
||||||
|
|||||||
112
frontend/src/components/EditHistoryPanel.vue
Normal file
112
frontend/src/components/EditHistoryPanel.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entity: EditInfo;
|
||||||
|
history: EditHistoryEntry[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const actionLabels: Record<EditHistoryAction, string> = {
|
||||||
|
create: '创建',
|
||||||
|
update: '编辑',
|
||||||
|
delete: '删除'
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayName(user: UserSummary | null): string {
|
||||||
|
return user?.displayName ?? '系统';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: EditHistoryAction): string {
|
||||||
|
return actionLabels[action];
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionMark(action: EditHistoryAction): string {
|
||||||
|
return actionLabels[action].charAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function historySummary(entry: EditHistoryEntry): string {
|
||||||
|
if (!entry.changes.length) {
|
||||||
|
return actionLabel(entry.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.changes.map((change) => change.label).join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="edit-history-panel" aria-labelledby="edit-history-panel-title">
|
||||||
|
<div class="edit-history-panel__header">
|
||||||
|
<h2 id="edit-history-panel-title">贡献记录</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="edit-history-summary">
|
||||||
|
<div>
|
||||||
|
<dt>由谁创建</dt>
|
||||||
|
<dd>
|
||||||
|
<strong>{{ displayName(entity.createdBy) }}</strong>
|
||||||
|
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>最后编辑</dt>
|
||||||
|
<dd>
|
||||||
|
<strong>{{ displayName(entity.updatedBy) }}</strong>
|
||||||
|
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
||||||
|
<h3 id="edit-history-list-title">编辑历史</h3>
|
||||||
|
<ol v-if="history.length" class="edit-timeline">
|
||||||
|
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
||||||
|
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
||||||
|
<div class="edit-timeline__body">
|
||||||
|
<details class="edit-history-entry">
|
||||||
|
<summary>
|
||||||
|
<span class="edit-history-entry__title">{{ historySummary(entry) }}</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="edit-history-entry__content">
|
||||||
|
<dl v-if="entry.changes.length" class="edit-change-list">
|
||||||
|
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
|
||||||
|
<dt>{{ change.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="edit-change-list__label">修改前</span>
|
||||||
|
<span>{{ change.before }}</span>
|
||||||
|
<span class="edit-change-list__label">修改后</span>
|
||||||
|
<span>{{ change.after }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="edit-history-detail-meta">
|
||||||
|
<div>
|
||||||
|
<dt>作者</dt>
|
||||||
|
<dd>{{ displayName(entry.user) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>时间</dt>
|
||||||
|
<dd><time :datetime="entry.createdAt">{{ formatDateTime(entry.createdAt) }}</time></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>动作</dt>
|
||||||
|
<dd>{{ actionLabel(entry.action) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p v-else class="meta-line">暂无编辑历史</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
@@ -23,6 +23,21 @@ export interface EditInfo {
|
|||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditHistoryAction = 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
|
export interface EditChange {
|
||||||
|
label: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditHistoryEntry {
|
||||||
|
action: EditHistoryAction;
|
||||||
|
changes: EditChange[];
|
||||||
|
createdAt: string;
|
||||||
|
user: UserSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Pokemon extends EditInfo {
|
export interface Pokemon extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -34,6 +49,7 @@ export interface Pokemon extends EditInfo {
|
|||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
habitats: Array<{
|
habitats: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -52,6 +68,7 @@ export interface Habitat extends EditInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitatDetail extends Habitat {
|
export interface HabitatDetail extends Habitat {
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
pokemon: Array<NamedEntity & {
|
pokemon: Array<NamedEntity & {
|
||||||
time_of_day: string;
|
time_of_day: string;
|
||||||
weather: string;
|
weather: string;
|
||||||
@@ -96,6 +113,7 @@ export interface ItemDetail extends Item {
|
|||||||
recipe: RecipeDetail | null;
|
recipe: RecipeDetail | null;
|
||||||
relatedRecipes: RecipeUsage[];
|
relatedRecipes: RecipeUsage[];
|
||||||
relatedHabitats: HabitatUsage[];
|
relatedHabitats: HabitatUsage[];
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
pokemon: NamedEntity;
|
pokemon: NamedEntity;
|
||||||
skill: NamedEntity;
|
skill: NamedEntity;
|
||||||
@@ -110,6 +128,7 @@ export interface Recipe extends EditInfo {
|
|||||||
|
|
||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
item: NamedEntity;
|
item: NamedEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -917,6 +917,13 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-with-sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
.habitat-detail-stack {
|
.habitat-detail-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -966,6 +973,255 @@ button:disabled,
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-history-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 92px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-panel__header h2,
|
||||||
|
.edit-history-list h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-panel__header h2 {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-list h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary div:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary div:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary dd {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary time,
|
||||||
|
.edit-timeline time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline li:not(:last-child)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 38px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 17px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline__avatar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pokemon-yellow);
|
||||||
|
box-shadow: 0 2px 0 var(--line-strong);
|
||||||
|
color: #172036;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline__body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
padding-bottom: 13px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline li:last-child .edit-timeline__body {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 18px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 850;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry summary::after {
|
||||||
|
content: "";
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
justify-self: center;
|
||||||
|
border-right: 2px solid var(--muted);
|
||||||
|
border-bottom: 2px solid var(--muted);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transition: transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry[open] summary::after {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry__title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry__content {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list dd {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
gap: 3px 8px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list dd span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list__label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta dt,
|
||||||
|
.edit-history-detail-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta dd {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.row-list {
|
.row-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -1429,10 +1685,15 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
|
.detail-with-sidebar,
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-history-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-row__main {
|
.appearance-row__main {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -129,15 +129,13 @@ onMounted(async () => {
|
|||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="habitat.name" subtitle="栖息地详情">
|
<PageHeader :title="habitat.name" subtitle="栖息地详情">
|
||||||
<template #kicker>Habitat Detail</template>
|
<template #kicker>Habitat Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="habitat" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">编辑</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">返回列表</RouterLink>
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">返回列表</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="habitat-detail-stack">
|
<div class="habitat-detail-stack">
|
||||||
<DetailSection title="配方列表">
|
<DetailSection title="配方列表">
|
||||||
<EntityChips :items="habitat.recipe" />
|
<EntityChips :items="habitat.recipe" />
|
||||||
@@ -169,5 +167,8 @@ onMounted(async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -84,15 +84,13 @@ onMounted(async () => {
|
|||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
|
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
|
||||||
<template #kicker>Item Detail</template>
|
<template #kicker>Item Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="item" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">编辑</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">返回列表</RouterLink>
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">返回列表</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<DetailSection title="入手方式">
|
<DetailSection title="入手方式">
|
||||||
<EntityChips :items="item.acquisitionMethods" />
|
<EntityChips :items="item.acquisitionMethods" />
|
||||||
@@ -153,5 +151,8 @@ onMounted(async () => {
|
|||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">无</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -165,15 +165,13 @@ onMounted(async () => {
|
|||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="`喜欢的环境:${pokemon.environment.name}`">
|
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="`喜欢的环境:${pokemon.environment.name}`">
|
||||||
<template #kicker>Pokédex Detail</template>
|
<template #kicker>Pokédex Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="pokemon" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">编辑</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">返回列表</RouterLink>
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">返回列表</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="detail-grid detail-grid--stack">
|
<div class="detail-grid detail-grid--stack">
|
||||||
<DetailSection title="特长">
|
<DetailSection title="特长">
|
||||||
<EntityChips :items="pokemon.skills" />
|
<EntityChips :items="pokemon.skills" />
|
||||||
@@ -238,5 +236,8 @@ onMounted(async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -46,15 +46,13 @@ onMounted(async () => {
|
|||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="recipe.name" subtitle="材料单详情">
|
<PageHeader :title="recipe.name" subtitle="材料单详情">
|
||||||
<template #kicker>Recipe Detail</template>
|
<template #kicker>Recipe Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="recipe" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">编辑</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">返回列表</RouterLink>
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">返回列表</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<DetailSection title="入手方式">
|
<DetailSection title="入手方式">
|
||||||
<EntityChips :items="recipe.acquisition_methods" />
|
<EntityChips :items="recipe.acquisition_methods" />
|
||||||
@@ -64,5 +62,8 @@ onMounted(async () => {
|
|||||||
<EntityChips :items="recipe.materials" />
|
<EntityChips :items="recipe.materials" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user