feat: implement community editing with audit logs and user attribution
Add created/updated tracking and edit logs to all wiki tables Restrict create/update/delete operations to verified users Display edit metadata on frontend detail and list views
This commit is contained in:
@@ -129,3 +129,11 @@ Eg: 名称:乱撒,二级分类:棉花
|
|||||||
- 登录后可获取当前用户信息
|
- 登录后可获取当前用户信息
|
||||||
- 用户可退出登录
|
- 用户可退出登录
|
||||||
- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据
|
- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据
|
||||||
|
|
||||||
|
## Community 编辑
|
||||||
|
|
||||||
|
- 所有人都可浏览 Wiki 内容
|
||||||
|
- 已注册并完成邮箱验证的用户都可编辑 Wiki 内容
|
||||||
|
- 每次创建、修改、删除 Wiki 内容都需要记录编辑者
|
||||||
|
- Wiki 内容展示最后编辑者和最后编辑时间
|
||||||
|
- 编辑署名只展示必要用户信息,不暴露邮箱、token、hash 或内部元数据
|
||||||
|
|||||||
@@ -186,3 +186,73 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
|||||||
rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3),
|
rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3),
|
||||||
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
entity_type text NOT NULL,
|
||||||
|
entity_id integer NOT NULL,
|
||||||
|
action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
||||||
|
user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
||||||
|
ON wiki_edit_logs(entity_type, entity_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
||||||
|
ON wiki_edit_logs(user_id);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type HabitatPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ValidationError = Error & { statusCode: number };
|
type ValidationError = Error & { statusCode: number };
|
||||||
|
type EditAction = 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
@@ -89,6 +90,39 @@ function optionSelect(tableName: string): Promise<Array<{ id: number; name: stri
|
|||||||
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
|
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
||||||
|
return `
|
||||||
|
${entityAlias}.created_at AS "createdAt",
|
||||||
|
${entityAlias}.updated_at AS "updatedAt",
|
||||||
|
CASE
|
||||||
|
WHEN ${createdAlias}.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', ${createdAlias}.id, 'displayName', ${createdAlias}.display_name)
|
||||||
|
END AS "createdBy",
|
||||||
|
CASE
|
||||||
|
WHEN ${updatedAlias}.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', ${updatedAlias}.id, 'displayName', ${updatedAlias}.display_name)
|
||||||
|
END AS "updatedBy"
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
||||||
|
return `
|
||||||
|
LEFT JOIN users ${createdAlias} ON ${createdAlias}.id = ${entityAlias}.created_by_user_id
|
||||||
|
LEFT JOIN users ${updatedAlias} ON ${updatedAlias}.id = ${entityAlias}.updated_by_user_id
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configSelect(definition: ConfigDefinition): string {
|
||||||
|
return definition.hasSubcategory ? 'c.id, c.name, c.subcategory' : 'c.id, c.name';
|
||||||
|
}
|
||||||
|
|
||||||
|
function configOrder(definition: ConfigDefinition): string {
|
||||||
|
return definition.order
|
||||||
|
.split(', ')
|
||||||
|
.map((column) => `c.${column}`)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
function validationError(message: string): ValidationError {
|
function validationError(message: string): ValidationError {
|
||||||
const error = new Error(message) as ValidationError;
|
const error = new Error(message) as ValidationError;
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -158,10 +192,27 @@ async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recordEditLog(
|
||||||
|
client: DbClient,
|
||||||
|
entityType: string,
|
||||||
|
entityId: number,
|
||||||
|
action: EditAction,
|
||||||
|
userId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`,
|
||||||
|
[entityType, entityId, action, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pokemonProjection = `
|
const pokemonProjection = `
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.name,
|
p.name,
|
||||||
|
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
|
||||||
json_build_object('id', e.id, 'name', e.name) AS environment,
|
json_build_object('id', e.id, 'name', e.name) AS environment,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'subcategory', s.subcategory) ORDER BY s.name, s.subcategory)
|
SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'subcategory', s.subcategory) ORDER BY s.name, s.subcategory)
|
||||||
@@ -177,6 +228,7 @@ const pokemonProjection = `
|
|||||||
), '[]'::json) AS favorite_things
|
), '[]'::json) AS favorite_things
|
||||||
FROM pokemon p
|
FROM pokemon p
|
||||||
JOIN environments e ON e.id = p.environment_id
|
JOIN environments e ON e.id = p.environment_id
|
||||||
|
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getOptions() {
|
export async function getOptions() {
|
||||||
@@ -218,43 +270,107 @@ export function isConfigType(type: string): type is ConfigType {
|
|||||||
|
|
||||||
export async function listConfig(type: ConfigType) {
|
export async function listConfig(type: ConfigType) {
|
||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
return query(`SELECT ${definition.select} FROM ${definition.table} ORDER BY ${definition.order}`);
|
return query(
|
||||||
|
`
|
||||||
|
SELECT ${configSelect(definition)}, ${auditSelect('c')}
|
||||||
|
FROM ${definition.table} c
|
||||||
|
${auditJoins('c')}
|
||||||
|
ORDER BY ${configOrder(definition)}
|
||||||
|
`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createConfig(type: ConfigType, payload: Record<string, unknown>) {
|
async function getConfigById(type: ConfigType, id: number) {
|
||||||
|
const definition = configDefinitions[type];
|
||||||
|
return queryOne(
|
||||||
|
`
|
||||||
|
SELECT ${configSelect(definition)}, ${auditSelect('c')}
|
||||||
|
FROM ${definition.table} c
|
||||||
|
${auditJoins('c')}
|
||||||
|
WHERE c.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number) {
|
||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null;
|
const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null;
|
||||||
|
|
||||||
if (definition.hasSubcategory) {
|
const id = await withTransaction(async (client) => {
|
||||||
return queryOne(
|
const result = definition.hasSubcategory
|
||||||
`INSERT INTO ${definition.table} (name, subcategory) VALUES ($1, $2) RETURNING ${definition.select}`,
|
? await client.query<{ id: number }>(
|
||||||
[name, subcategory]
|
`
|
||||||
|
INSERT INTO ${definition.table} (name, subcategory, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $3)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[name, subcategory, userId]
|
||||||
|
)
|
||||||
|
: await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO ${definition.table} (name, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $2)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[name, userId]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return queryOne(`INSERT INTO ${definition.table} (name) VALUES ($1) RETURNING ${definition.select}`, [name]);
|
const createdId = result.rows[0].id;
|
||||||
|
await recordEditLog(client, type, createdId, 'create', userId);
|
||||||
|
return createdId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return getConfigById(type, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateConfig(type: ConfigType, id: number, payload: Record<string, unknown>) {
|
export async function updateConfig(type: ConfigType, id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null;
|
const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null;
|
||||||
|
|
||||||
if (definition.hasSubcategory) {
|
const updated = await withTransaction(async (client) => {
|
||||||
return queryOne(
|
const result = definition.hasSubcategory
|
||||||
`UPDATE ${definition.table} SET name = $1, subcategory = $2 WHERE id = $3 RETURNING ${definition.select}`,
|
? await client.query(
|
||||||
[name, subcategory, id]
|
`
|
||||||
|
UPDATE ${definition.table}
|
||||||
|
SET name = $1, subcategory = $2, updated_by_user_id = $3, updated_at = now()
|
||||||
|
WHERE id = $4
|
||||||
|
`,
|
||||||
|
[name, subcategory, userId, id]
|
||||||
|
)
|
||||||
|
: await client.query(
|
||||||
|
`
|
||||||
|
UPDATE ${definition.table}
|
||||||
|
SET name = $1, updated_by_user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
[name, userId, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryOne(`UPDATE ${definition.table} SET name = $1 WHERE id = $2 RETURNING ${definition.select}`, [name, id]);
|
await recordEditLog(client, type, id, 'update', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated ? getConfigById(type, id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteConfig(type: ConfigType, id: number) {
|
export async function deleteConfig(type: ConfigType, id: number, userId: number) {
|
||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const result = await pool.query(`DELETE FROM ${definition.table} WHERE id = $1`, [id]);
|
return withTransaction(async (client) => {
|
||||||
return (result.rowCount ?? 0) > 0;
|
const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, type, id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPokemon(paramsQuery: QueryParams) {
|
export async function listPokemon(paramsQuery: QueryParams) {
|
||||||
@@ -368,42 +484,56 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPokemon(payload: Record<string, unknown>) {
|
export async function createPokemon(payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanPokemonPayload(payload);
|
const cleanPayload = cleanPokemonPayload(payload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
await client.query('INSERT INTO pokemon (id, name, environment_id) VALUES ($1, $2, $3)', [
|
await client.query(
|
||||||
cleanPayload.id,
|
`
|
||||||
cleanPayload.name,
|
INSERT INTO pokemon (id, name, environment_id, created_by_user_id, updated_by_user_id)
|
||||||
cleanPayload.environmentId
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
]);
|
`,
|
||||||
|
[cleanPayload.id, cleanPayload.name, cleanPayload.environmentId, userId]
|
||||||
|
);
|
||||||
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
||||||
|
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId);
|
||||||
return cleanPayload.id;
|
return cleanPayload.id;
|
||||||
});
|
});
|
||||||
return getPokemon(id);
|
return getPokemon(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePokemon(id: number, payload: Record<string, unknown>) {
|
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanPokemonPayload({ ...payload, id });
|
const cleanPayload = cleanPokemonPayload({ ...payload, id });
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query('UPDATE pokemon SET name = $1, environment_id = $2 WHERE id = $3', [
|
const result = await client.query(
|
||||||
cleanPayload.name,
|
`
|
||||||
cleanPayload.environmentId,
|
UPDATE pokemon
|
||||||
id
|
SET name = $1, environment_id = $2, updated_by_user_id = $3, updated_at = now()
|
||||||
]);
|
WHERE id = $4
|
||||||
|
`,
|
||||||
|
[cleanPayload.name, cleanPayload.environmentId, userId, id]
|
||||||
|
);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replacePokemonRelations(client, id, cleanPayload);
|
await replacePokemonRelations(client, id, cleanPayload);
|
||||||
|
await recordEditLog(client, 'pokemon', id, 'update', userId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getPokemon(id) : null;
|
return updated ? getPokemon(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePokemon(id: number) {
|
export async function deletePokemon(id: number, userId: number) {
|
||||||
const result = await pool.query('DELETE FROM pokemon WHERE id = $1', [id]);
|
return withTransaction(async (client) => {
|
||||||
return (result.rowCount ?? 0) > 0;
|
const result = await client.query<{ id: number }>('DELETE FROM pokemon WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, 'pokemon', id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listHabitats() {
|
export async function listHabitats() {
|
||||||
@@ -411,6 +541,7 @@ export async function listHabitats() {
|
|||||||
SELECT
|
SELECT
|
||||||
h.id,
|
h.id,
|
||||||
h.name,
|
h.name,
|
||||||
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
||||||
FROM habitat_recipe_items hri
|
FROM habitat_recipe_items hri
|
||||||
@@ -424,6 +555,7 @@ export async function listHabitats() {
|
|||||||
WHERE hp.habitat_id = h.id
|
WHERE hp.habitat_id = h.id
|
||||||
), '[]'::json) AS pokemon
|
), '[]'::json) AS pokemon
|
||||||
FROM habitats h
|
FROM habitats h
|
||||||
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
||||||
ORDER BY h.name
|
ORDER BY h.name
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -434,6 +566,7 @@ export async function getHabitat(id: number) {
|
|||||||
SELECT
|
SELECT
|
||||||
h.id,
|
h.id,
|
||||||
h.name,
|
h.name,
|
||||||
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
||||||
FROM habitat_recipe_items hri
|
FROM habitat_recipe_items hri
|
||||||
@@ -441,6 +574,7 @@ export async function getHabitat(id: number) {
|
|||||||
WHERE hri.habitat_id = h.id
|
WHERE hri.habitat_id = h.id
|
||||||
), '[]'::json) AS recipe
|
), '[]'::json) AS recipe
|
||||||
FROM habitats h
|
FROM habitats h
|
||||||
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
||||||
WHERE h.id = $1
|
WHERE h.id = $1
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
@@ -532,43 +666,61 @@ async function replaceHabitatRelations(client: DbClient, habitatId: number, payl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createHabitat(payload: Record<string, unknown>) {
|
export async function createHabitat(payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanHabitatPayload(payload);
|
const cleanPayload = cleanHabitatPayload(payload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
const result = await client.query<{ id: number }>('INSERT INTO habitats (name) VALUES ($1) RETURNING id', [
|
const result = await client.query<{ id: number }>(
|
||||||
cleanPayload.name
|
`
|
||||||
]);
|
INSERT INTO habitats (name, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $2)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[cleanPayload.name, userId]
|
||||||
|
);
|
||||||
const habitatId = result.rows[0].id;
|
const habitatId = result.rows[0].id;
|
||||||
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
||||||
|
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
|
||||||
return habitatId;
|
return habitatId;
|
||||||
});
|
});
|
||||||
return getHabitat(id);
|
return getHabitat(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHabitat(id: number, payload: Record<string, unknown>) {
|
export async function updateHabitat(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanHabitatPayload(payload);
|
const cleanPayload = cleanHabitatPayload(payload);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query('UPDATE habitats SET name = $1 WHERE id = $2', [cleanPayload.name, id]);
|
const result = await client.query(
|
||||||
|
'UPDATE habitats SET name = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
|
||||||
|
[cleanPayload.name, userId, id]
|
||||||
|
);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replaceHabitatRelations(client, id, cleanPayload);
|
await replaceHabitatRelations(client, id, cleanPayload);
|
||||||
|
await recordEditLog(client, 'habitats', id, 'update', userId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getHabitat(id) : null;
|
return updated ? getHabitat(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHabitat(id: number) {
|
export async function deleteHabitat(id: number, userId: number) {
|
||||||
const result = await pool.query('DELETE FROM habitats WHERE id = $1', [id]);
|
return withTransaction(async (client) => {
|
||||||
return (result.rowCount ?? 0) > 0;
|
const result = await client.query<{ id: number }>('DELETE FROM habitats WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, 'habitats', id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemProjection = `
|
const itemProjection = `
|
||||||
SELECT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
i.name,
|
i.name,
|
||||||
|
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
||||||
json_build_object('id', c.id, 'name', c.name) AS category,
|
json_build_object('id', c.id, 'name', c.name) AS category,
|
||||||
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage,
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -585,6 +737,7 @@ const itemProjection = `
|
|||||||
FROM items i
|
FROM items i
|
||||||
JOIN item_categories c ON c.id = i.category_id
|
JOIN item_categories c ON c.id = i.category_id
|
||||||
LEFT JOIN item_usages u ON u.id = i.usage_id
|
LEFT JOIN item_usages u ON u.id = i.usage_id
|
||||||
|
${auditJoins('i', 'item_created_user', 'item_updated_user')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function listItems(paramsQuery: QueryParams) {
|
export async function listItems(paramsQuery: QueryParams) {
|
||||||
@@ -649,6 +802,7 @@ export async function getItem(id: number) {
|
|||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
result_item.name,
|
result_item.name,
|
||||||
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||||
FROM recipe_acquisition_methods ram
|
FROM recipe_acquisition_methods ram
|
||||||
@@ -664,6 +818,7 @@ export async function getItem(id: number) {
|
|||||||
json_build_object('id', result_item.id, 'name', result_item.name) AS item
|
json_build_object('id', result_item.id, 'name', result_item.name) AS item
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
JOIN items result_item ON result_item.id = r.item_id
|
||||||
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
WHERE r.item_id = $1
|
WHERE r.item_id = $1
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
@@ -719,14 +874,23 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createItem(payload: Record<string, unknown>) {
|
export async function createItem(payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanItemPayload(payload);
|
const cleanPayload = cleanItemPayload(payload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
const result = await client.query<{ id: number }>(
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO items (name, category_id, usage_id, dyeable, dual_dyeable, pattern_editable)
|
INSERT INTO items (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
name,
|
||||||
|
category_id,
|
||||||
|
usage_id,
|
||||||
|
dyeable,
|
||||||
|
dual_dyeable,
|
||||||
|
pattern_editable,
|
||||||
|
created_by_user_id,
|
||||||
|
updated_by_user_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -735,17 +899,19 @@ export async function createItem(payload: Record<string, unknown>) {
|
|||||||
cleanPayload.usageId,
|
cleanPayload.usageId,
|
||||||
cleanPayload.dyeable,
|
cleanPayload.dyeable,
|
||||||
cleanPayload.dualDyeable,
|
cleanPayload.dualDyeable,
|
||||||
cleanPayload.patternEditable
|
cleanPayload.patternEditable,
|
||||||
|
userId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const itemId = result.rows[0].id;
|
const itemId = result.rows[0].id;
|
||||||
await replaceItemRelations(client, itemId, cleanPayload);
|
await replaceItemRelations(client, itemId, cleanPayload);
|
||||||
|
await recordEditLog(client, 'items', itemId, 'create', userId);
|
||||||
return itemId;
|
return itemId;
|
||||||
});
|
});
|
||||||
return getItem(id);
|
return getItem(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateItem(id: number, payload: Record<string, unknown>) {
|
export async function updateItem(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanItemPayload(payload);
|
const cleanPayload = cleanItemPayload(payload);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
@@ -757,8 +923,10 @@ export async function updateItem(id: number, payload: Record<string, unknown>) {
|
|||||||
usage_id = $3,
|
usage_id = $3,
|
||||||
dyeable = $4,
|
dyeable = $4,
|
||||||
dual_dyeable = $5,
|
dual_dyeable = $5,
|
||||||
pattern_editable = $6
|
pattern_editable = $6,
|
||||||
WHERE id = $7
|
updated_by_user_id = $7,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $8
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
@@ -767,6 +935,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>) {
|
|||||||
cleanPayload.dyeable,
|
cleanPayload.dyeable,
|
||||||
cleanPayload.dualDyeable,
|
cleanPayload.dualDyeable,
|
||||||
cleanPayload.patternEditable,
|
cleanPayload.patternEditable,
|
||||||
|
userId,
|
||||||
id
|
id
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -774,14 +943,22 @@ export async function updateItem(id: number, payload: Record<string, unknown>) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replaceItemRelations(client, id, cleanPayload);
|
await replaceItemRelations(client, id, cleanPayload);
|
||||||
|
await recordEditLog(client, 'items', id, 'update', userId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getItem(id) : null;
|
return updated ? getItem(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteItem(id: number) {
|
export async function deleteItem(id: number, userId: number) {
|
||||||
const result = await pool.query('DELETE FROM items WHERE id = $1', [id]);
|
return withTransaction(async (client) => {
|
||||||
return (result.rowCount ?? 0) > 0;
|
const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, 'items', id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRecipes() {
|
export async function listRecipes() {
|
||||||
@@ -789,6 +966,7 @@ export async function listRecipes() {
|
|||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
result_item.name,
|
result_item.name,
|
||||||
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
||||||
FROM recipe_materials rm
|
FROM recipe_materials rm
|
||||||
@@ -797,6 +975,7 @@ export async function listRecipes() {
|
|||||||
), '[]'::json) AS materials
|
), '[]'::json) AS materials
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
JOIN items result_item ON result_item.id = r.item_id
|
||||||
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
ORDER BY result_item.name
|
ORDER BY result_item.name
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -807,6 +986,7 @@ export async function getRecipe(id: number) {
|
|||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
result_item.name,
|
result_item.name,
|
||||||
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||||
FROM recipe_acquisition_methods ram
|
FROM recipe_acquisition_methods ram
|
||||||
@@ -822,6 +1002,7 @@ export async function getRecipe(id: number) {
|
|||||||
json_build_object('id', result_item.id, 'name', result_item.name) AS item
|
json_build_object('id', result_item.id, 'name', result_item.name) AS item
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
JOIN items result_item ON result_item.id = r.item_id
|
||||||
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
WHERE r.id = $1
|
WHERE r.id = $1
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
@@ -863,37 +1044,54 @@ async function ensureItemExists(client: DbClient, itemId: number): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecipe(payload: Record<string, unknown>) {
|
export async function createRecipe(payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanRecipePayload(payload);
|
const cleanPayload = cleanRecipePayload(payload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
await ensureItemExists(client, cleanPayload.itemId);
|
await ensureItemExists(client, cleanPayload.itemId);
|
||||||
const result = await client.query<{ id: number }>('INSERT INTO recipes (item_id) VALUES ($1) RETURNING id', [
|
const result = await client.query<{ id: number }>(
|
||||||
cleanPayload.itemId
|
`
|
||||||
]);
|
INSERT INTO recipes (item_id, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $2)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[cleanPayload.itemId, userId]
|
||||||
|
);
|
||||||
const recipeId = result.rows[0].id;
|
const recipeId = result.rows[0].id;
|
||||||
await replaceRecipeRelations(client, recipeId, cleanPayload);
|
await replaceRecipeRelations(client, recipeId, cleanPayload);
|
||||||
|
await recordEditLog(client, 'recipes', recipeId, 'create', userId);
|
||||||
return recipeId;
|
return recipeId;
|
||||||
});
|
});
|
||||||
return getRecipe(id);
|
return getRecipe(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecipe(id: number, payload: Record<string, unknown>) {
|
export async function updateRecipe(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanRecipePayload(payload);
|
const cleanPayload = cleanRecipePayload(payload);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
await ensureItemExists(client, cleanPayload.itemId);
|
await ensureItemExists(client, cleanPayload.itemId);
|
||||||
const result = await client.query('UPDATE recipes SET item_id = $1 WHERE id = $2', [cleanPayload.itemId, id]);
|
const result = await client.query(
|
||||||
|
'UPDATE recipes SET item_id = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
|
||||||
|
[cleanPayload.itemId, userId, id]
|
||||||
|
);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await replaceRecipeRelations(client, id, cleanPayload);
|
await replaceRecipeRelations(client, id, cleanPayload);
|
||||||
|
await recordEditLog(client, 'recipes', id, 'update', userId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return updated ? getRecipe(id) : null;
|
return updated ? getRecipe(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecipe(id: number) {
|
export async function deleteRecipe(id: number, userId: number) {
|
||||||
const result = await pool.query('DELETE FROM recipes WHERE id = $1', [id]);
|
return withTransaction(async (client) => {
|
||||||
return (result.rowCount ?? 0) > 0;
|
const result = await client.query<{ id: number }>('DELETE FROM recipes WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, 'recipes', id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail } from './auth.ts';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
|
||||||
import { initializeDatabase, pool } from './db.ts';
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
createConfig,
|
createConfig,
|
||||||
@@ -71,6 +72,23 @@ function getBearerToken(authorization: string | undefined): string | null {
|
|||||||
return scheme === 'Bearer' && token ? token : null;
|
return scheme === 'Bearer' && token ? token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
||||||
|
const token = getBearerToken(request.headers.authorization);
|
||||||
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
reply.code(401).send({ message: '请先登录' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
reply.code(403).send({ message: '请先完成邮箱验证' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/auth/register', async (request, reply) =>
|
app.post('/api/auth/register', async (request, reply) =>
|
||||||
reply.code(201).send(await registerUser(request.body as Record<string, unknown>))
|
reply.code(201).send(await registerUser(request.body as Record<string, unknown>))
|
||||||
);
|
);
|
||||||
@@ -114,11 +132,18 @@ app.get('/api/pokemon/:id', async (request, reply) => {
|
|||||||
return pokemon;
|
return pokemon;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/pokemon', async (request, reply) => reply.code(201).send(await createPokemon(request.body as Record<string, unknown>)));
|
app.post('/api/pokemon', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/pokemon/:id', async (request, reply) => {
|
app.put('/api/pokemon/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>);
|
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
|
||||||
if (!pokemon) {
|
if (!pokemon) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -128,8 +153,12 @@ app.put('/api/pokemon/:id', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/pokemon/:id', async (request, reply) => {
|
app.delete('/api/pokemon/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deletePokemon(Number(id));
|
const deleted = await deletePokemon(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,11 +175,18 @@ app.get('/api/habitats/:id', async (request, reply) => {
|
|||||||
return habitat;
|
return habitat;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/habitats', async (request, reply) => reply.code(201).send(await createHabitat(request.body as Record<string, unknown>)));
|
app.post('/api/habitats', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/habitats/:id', async (request, reply) => {
|
app.put('/api/habitats/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>);
|
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
|
||||||
if (!habitat) {
|
if (!habitat) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -160,8 +196,12 @@ app.put('/api/habitats/:id', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/habitats/:id', async (request, reply) => {
|
app.delete('/api/habitats/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteHabitat(Number(id));
|
const deleted = await deleteHabitat(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,11 +218,18 @@ app.get('/api/items/:id', async (request, reply) => {
|
|||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/items', async (request, reply) => reply.code(201).send(await createItem(request.body as Record<string, unknown>)));
|
app.post('/api/items', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/items/:id', async (request, reply) => {
|
app.put('/api/items/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const item = await updateItem(Number(id), request.body as Record<string, unknown>);
|
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -192,8 +239,12 @@ app.put('/api/items/:id', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/items/:id', async (request, reply) => {
|
app.delete('/api/items/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteItem(Number(id));
|
const deleted = await deleteItem(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,11 +261,18 @@ app.get('/api/recipes/:id', async (request, reply) => {
|
|||||||
return recipe;
|
return recipe;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/recipes', async (request, reply) => reply.code(201).send(await createRecipe(request.body as Record<string, unknown>)));
|
app.post('/api/recipes', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/recipes/:id', async (request, reply) => {
|
app.put('/api/recipes/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>);
|
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -224,12 +282,20 @@ app.put('/api/recipes/:id', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/recipes/:id', async (request, reply) => {
|
app.delete('/api/recipes/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteRecipe(Number(id));
|
const deleted = await deleteRecipe(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { type } = request.params as { type: string };
|
const { type } = request.params as { type: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -238,28 +304,40 @@ app.get('/api/admin/config/:type', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/config/:type', async (request, reply) => {
|
app.post('/api/admin/config/:type', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { type } = request.params as { type: string };
|
const { type } = request.params as { type: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
return reply.code(201).send(await createConfig(type, request.body as Record<string, unknown>));
|
return reply.code(201).send(await createConfig(type, request.body as Record<string, unknown>, user.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { type, id } = request.params as { type: string; id: string };
|
const { type, id } = request.params as { type: string; id: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>);
|
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
return config ? config : reply.code(404).send({ message: 'Not found' });
|
return config ? config : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { type, id } = request.params as { type: string; id: string };
|
const { type, id } = request.params as { type: string; id: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
const deleted = await deleteConfig(type, Number(id));
|
const deleted = await deleteConfig(type, Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
20
frontend/src/components/EditMeta.vue
Normal file
20
frontend/src/components/EditMeta.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { EditInfo } from '../services/api';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entity: EditInfo;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p class="edit-meta">
|
||||||
|
最后编辑:{{ entity.updatedBy?.displayName ?? '系统' }} / {{ formatDateTime(entity.updatedAt) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -10,6 +10,7 @@ import AdminView from '../views/AdminView.vue';
|
|||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||||
|
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -29,3 +30,21 @@ export const router = createRouter({
|
|||||||
],
|
],
|
||||||
scrollBehavior: () => ({ top: 0 })
|
scrollBehavior: () => ({ top: 0 })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
if (to.path !== '/admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getAuthToken()) {
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.me();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setAuthToken(null);
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,19 @@ export interface Skill extends NamedEntity {
|
|||||||
subcategory: string | null;
|
subcategory: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pokemon {
|
export interface UserSummary {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditInfo {
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: UserSummary | null;
|
||||||
|
updatedBy: UserSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pokemon extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
environment: NamedEntity;
|
environment: NamedEntity;
|
||||||
@@ -30,7 +42,7 @@ export interface PokemonDetail extends Pokemon {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Habitat {
|
export interface Habitat extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
recipe: Array<NamedEntity & { quantity: number }>;
|
recipe: Array<NamedEntity & { quantity: number }>;
|
||||||
@@ -46,7 +58,7 @@ export interface HabitatDetail extends Habitat {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
category: NamedEntity;
|
category: NamedEntity;
|
||||||
@@ -65,7 +77,7 @@ export interface ItemDetail extends Item {
|
|||||||
relatedHabitats: Array<NamedEntity & { quantity: number }>;
|
relatedHabitats: Array<NamedEntity & { quantity: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Recipe {
|
export interface Recipe extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
materials: Array<NamedEntity & { quantity: number }>;
|
materials: Array<NamedEntity & { quantity: number }>;
|
||||||
|
|||||||
@@ -392,6 +392,13 @@ select {
|
|||||||
color: #657067;
|
color: #657067;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: #7b766a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.chips {
|
.chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
type AuthUser,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
type Habitat,
|
type Habitat,
|
||||||
type HabitatDetail,
|
type HabitatDetail,
|
||||||
@@ -59,6 +60,7 @@ const pokemonRows = ref<Pokemon[]>([]);
|
|||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const recipeRows = ref<Recipe[]>([]);
|
const recipeRows = ref<Recipe[]>([]);
|
||||||
const habitatRows = ref<Habitat[]>([]);
|
const habitatRows = ref<Habitat[]>([]);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
@@ -94,6 +96,7 @@ const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: ite
|
|||||||
const pokemonSelectOptions = computed(() =>
|
const pokemonSelectOptions = computed(() =>
|
||||||
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
|
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
|
||||||
);
|
);
|
||||||
|
const canEdit = computed(() => currentUser.value?.emailVerified === true);
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
@@ -179,10 +182,27 @@ async function loadCurrentTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTab(tab: AdminTab) {
|
function setTab(tab: AdminTab) {
|
||||||
|
if (!canEdit.value) {
|
||||||
|
message.value = '请先完成邮箱验证';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
activeTab.value = tab;
|
activeTab.value = tab;
|
||||||
void run(loadCurrentTab);
|
void run(loadCurrentTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAdmin() {
|
||||||
|
const response = await api.me();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
|
||||||
|
if (!response.user.emailVerified) {
|
||||||
|
message.value = '请先完成邮箱验证';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCurrentTab();
|
||||||
|
}
|
||||||
|
|
||||||
function resetConfigForm() {
|
function resetConfigForm() {
|
||||||
configForm.value = { id: 0, name: '', subcategory: '' };
|
configForm.value = { id: 0, name: '', subcategory: '' };
|
||||||
}
|
}
|
||||||
@@ -442,7 +462,7 @@ async function removeHabitat(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void run(loadCurrentTab);
|
void run(loadAdmin);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -455,7 +475,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs" role="tablist" aria-label="管理模块">
|
<div v-if="canEdit" class="tabs" role="tablist" aria-label="管理模块">
|
||||||
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
|
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
@@ -463,7 +483,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<p v-if="message" class="status">{{ message }}</p>
|
<p v-if="message" class="status">{{ message }}</p>
|
||||||
|
|
||||||
<section v-if="activeTab === 'config'" class="admin-layout">
|
<section v-if="canEdit && activeTab === 'config'" class="admin-layout">
|
||||||
<form class="detail-section" @submit.prevent="saveConfig">
|
<form class="detail-section" @submit.prevent="saveConfig">
|
||||||
<h2>系统配置</h2>
|
<h2>系统配置</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -500,7 +520,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'pokemon' && options" class="admin-layout">
|
<section v-if="canEdit && activeTab === 'pokemon' && options" class="admin-layout">
|
||||||
<form class="detail-section" @submit.prevent="savePokemon">
|
<form class="detail-section" @submit.prevent="savePokemon">
|
||||||
<h2>Pokemon</h2>
|
<h2>Pokemon</h2>
|
||||||
<div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div>
|
<div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div>
|
||||||
@@ -562,7 +582,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'items' && options" class="admin-layout">
|
<section v-if="canEdit && activeTab === 'items' && options" class="admin-layout">
|
||||||
<form class="detail-section" @submit.prevent="saveItem">
|
<form class="detail-section" @submit.prevent="saveItem">
|
||||||
<h2>物品</h2>
|
<h2>物品</h2>
|
||||||
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
|
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
|
||||||
@@ -637,7 +657,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'recipes' && options" class="admin-layout">
|
<section v-if="canEdit && activeTab === 'recipes' && options" class="admin-layout">
|
||||||
<form class="detail-section" @submit.prevent="saveRecipe">
|
<form class="detail-section" @submit.prevent="saveRecipe">
|
||||||
<h2>材料单</h2>
|
<h2>材料单</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -699,7 +719,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'habitats' && options" class="admin-layout">
|
<section v-if="canEdit && activeTab === 'habitats' && options" class="admin-layout">
|
||||||
<form class="detail-section" @submit.prevent="saveHabitat">
|
<form class="detail-section" @submit.prevent="saveHabitat">
|
||||||
<h2>栖息地</h2>
|
<h2>栖息地</h2>
|
||||||
<div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div>
|
<div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import { api, type HabitatDetail } from '../services/api';
|
import { api, type HabitatDetail } from '../services/api';
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ habitat.name }}</h1>
|
<h1 class="page-title">{{ habitat.name }}</h1>
|
||||||
<p class="page-subtitle">栖息地详情</p>
|
<p class="page-subtitle">栖息地详情</p>
|
||||||
|
<EditMeta :entity="habitat" />
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="link-button" to="/habitats">返回列表</RouterLink>
|
<RouterLink class="link-button" to="/habitats">返回列表</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import { api, type Habitat } from '../services/api';
|
import { api, type Habitat } from '../services/api';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ onMounted(async () => {
|
|||||||
<div v-else class="grid">
|
<div v-else class="grid">
|
||||||
<RouterLink v-for="item in habitats" :key="item.id" class="entity-card" :to="`/habitats/${item.id}`">
|
<RouterLink v-for="item in habitats" :key="item.id" class="entity-card" :to="`/habitats/${item.id}`">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
|
<EditMeta :entity="item" />
|
||||||
<EntityChips :items="item.recipe" />
|
<EntityChips :items="item.recipe" />
|
||||||
<EntityChips :items="item.pokemon ?? []" />
|
<EntityChips :items="item.pokemon ?? []" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import { api, type ItemDetail } from '../services/api';
|
import { api, type ItemDetail } from '../services/api';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ item.name }}</h1>
|
<h1 class="page-title">{{ item.name }}</h1>
|
||||||
<p class="page-subtitle">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
<p class="page-subtitle">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
||||||
|
<EditMeta :entity="item" />
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { api, type Item, type Options, type Recipe } from '../services/api';
|
import { api, type Item, type Options, type Recipe } from '../services/api';
|
||||||
@@ -94,6 +95,7 @@ watch([tab, itemQuery], loadItems);
|
|||||||
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
|
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<p class="meta-line">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
<p class="meta-line">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
||||||
|
<EditMeta :entity="item" />
|
||||||
<EntityChips :items="item.tags" />
|
<EntityChips :items="item.tags" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +103,7 @@ watch([tab, itemQuery], loadItems);
|
|||||||
<div v-else class="grid">
|
<div v-else class="grid">
|
||||||
<RouterLink v-for="item in recipes" :key="item.id" class="entity-card" :to="`/recipes/${item.id}`">
|
<RouterLink v-for="item in recipes" :key="item.id" class="entity-card" :to="`/recipes/${item.id}`">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
|
<EditMeta :entity="item" />
|
||||||
<EntityChips :items="item.materials" />
|
<EntityChips :items="item.materials" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import { api, type PokemonDetail } from '../services/api';
|
import { api, type PokemonDetail } from '../services/api';
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">#{{ pokemon.id }} {{ pokemon.name }}</h1>
|
<h1 class="page-title">#{{ pokemon.id }} {{ pokemon.name }}</h1>
|
||||||
<p class="page-subtitle">喜欢的环境:{{ pokemon.environment.name }}</p>
|
<p class="page-subtitle">喜欢的环境:{{ pokemon.environment.name }}</p>
|
||||||
|
<EditMeta :entity="pokemon" />
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="link-button" to="/pokemon">返回列表</RouterLink>
|
<RouterLink class="link-button" to="/pokemon">返回列表</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { api, type Options, type Pokemon } from '../services/api';
|
import { api, type Options, type Pokemon } from '../services/api';
|
||||||
@@ -92,6 +93,7 @@ watch(query, loadPokemon);
|
|||||||
<RouterLink v-for="item in pokemon" :key="item.id" class="entity-card" :to="`/pokemon/${item.id}`">
|
<RouterLink v-for="item in pokemon" :key="item.id" class="entity-card" :to="`/pokemon/${item.id}`">
|
||||||
<h2>#{{ item.id }} {{ item.name }}</h2>
|
<h2>#{{ item.id }} {{ item.name }}</h2>
|
||||||
<p class="meta-line">喜欢的环境:{{ item.environment.name }}</p>
|
<p class="meta-line">喜欢的环境:{{ item.environment.name }}</p>
|
||||||
|
<EditMeta :entity="item" />
|
||||||
<EntityChips :items="item.skills" />
|
<EntityChips :items="item.skills" />
|
||||||
<EntityChips :items="item.favorite_things" />
|
<EntityChips :items="item.favorite_things" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import { api, type RecipeDetail } from '../services/api';
|
import { api, type RecipeDetail } from '../services/api';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ recipe.name }}</h1>
|
<h1 class="page-title">{{ recipe.name }}</h1>
|
||||||
<p class="page-subtitle">材料单详情</p>
|
<p class="page-subtitle">材料单详情</p>
|
||||||
|
<EditMeta :entity="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user