feat: 외교부 기능 추가 및 외교 문서 관련 데이터베이스 모델 생성
This commit is contained in:
@@ -18,6 +18,7 @@ import { worldRouter } from './router/world/index.js';
|
||||
import { auctionRouter } from './router/auction/index.js';
|
||||
import { tournamentRouter } from './router/tournament/index.js';
|
||||
import { boardRouter } from './router/board/index.js';
|
||||
import { diplomacyRouter } from './router/diplomacy/index.js';
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
@@ -38,6 +39,7 @@ export const appRouter = router({
|
||||
auction: auctionRouter,
|
||||
tournament: tournamentRouter,
|
||||
board: boardRouter,
|
||||
diplomacy: diplomacyRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
|
||||
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
|
||||
|
||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
select: { meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
const general = await getMyGeneral(ctx);
|
||||
return resolveNationPermission(general, nation.meta, true);
|
||||
};
|
||||
|
||||
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
|
||||
if (state === 'ACTIVATED') return 'ACTIVATED';
|
||||
if (state === 'CANCELLED') return 'CANCELLED';
|
||||
if (state === 'REPLACED') return 'REPLACED';
|
||||
return 'PROPOSED';
|
||||
};
|
||||
|
||||
export const diplomacyRouter = router({
|
||||
getLetters: authedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
if (permission < 1) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const letters = await ctx.db.diplomacyLetter.findMany({
|
||||
where: {
|
||||
state: { not: 'CANCELLED' },
|
||||
OR: [{ srcNationId: me.nationId }, { destNationId: me.nationId }],
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
where: { id: { not: me.nationId } },
|
||||
select: { id: true, name: true, color: true, level: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
const result = letters.map((letter) => {
|
||||
const aux = asRecord(letter.aux);
|
||||
const src = asRecord(aux.src);
|
||||
const dest = asRecord(aux.dest);
|
||||
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
|
||||
const detail = permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : letter.textDetail;
|
||||
const reason = asRecord(aux.reason);
|
||||
|
||||
return {
|
||||
id: letter.id,
|
||||
src: {
|
||||
nationId: letter.srcNationId,
|
||||
nationName: typeof src.nationName === 'string' ? src.nationName : '',
|
||||
nationColor: typeof src.nationColor === 'string' ? src.nationColor : '',
|
||||
generalId: typeof src.generalId === 'number' ? src.generalId : null,
|
||||
generalName: typeof src.generalName === 'string' ? src.generalName : null,
|
||||
generalIcon: typeof src.generalIcon === 'string' ? src.generalIcon : null,
|
||||
},
|
||||
dest: {
|
||||
nationId: letter.destNationId,
|
||||
nationName: typeof dest.nationName === 'string' ? dest.nationName : '',
|
||||
nationColor: typeof dest.nationColor === 'string' ? dest.nationColor : '',
|
||||
generalId: typeof dest.generalId === 'number' ? dest.generalId : null,
|
||||
generalName: typeof dest.generalName === 'string' ? dest.generalName : null,
|
||||
generalIcon: typeof dest.generalIcon === 'string' ? dest.generalIcon : null,
|
||||
},
|
||||
prevId: letter.prevId,
|
||||
state: mapLetterState(letter.state),
|
||||
stateOpt,
|
||||
brief: letter.textBrief,
|
||||
detail,
|
||||
date: letter.date.toISOString(),
|
||||
reason: {
|
||||
who: typeof reason.who === 'number' ? reason.who : null,
|
||||
action: typeof reason.action === 'string' ? reason.action : null,
|
||||
text: typeof reason.reason === 'string' ? reason.reason : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
letters: result,
|
||||
nations,
|
||||
myNationId: me.nationId,
|
||||
permission,
|
||||
};
|
||||
}),
|
||||
sendLetter: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
destNationId: z.number().int().positive(),
|
||||
prevId: z.number().int().positive().nullable().optional(),
|
||||
brief: z.string().trim().min(1).max(2000),
|
||||
detail: z.string().trim().max(20000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
if (input.destNationId === me.nationId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자국으로 보낼 수 없습니다.' });
|
||||
}
|
||||
|
||||
let destNationId = input.destNationId;
|
||||
let prevId = input.prevId ?? null;
|
||||
|
||||
if (prevId && prevId < 1) {
|
||||
prevId = null;
|
||||
}
|
||||
|
||||
if (prevId) {
|
||||
const prevLetter = await ctx.db.diplomacyLetter.findFirst({
|
||||
where: {
|
||||
id: prevId,
|
||||
OR: [
|
||||
{
|
||||
srcNationId: { in: [me.nationId, destNationId] },
|
||||
destNationId: { in: [me.nationId, destNationId] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!prevLetter) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '이전 문서가 없습니다.' });
|
||||
}
|
||||
|
||||
const newer = await ctx.db.diplomacyLetter.findFirst({
|
||||
where: {
|
||||
prevId,
|
||||
state: { not: 'CANCELLED' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (newer) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
|
||||
}
|
||||
|
||||
if (prevLetter.state === 'PROPOSED') {
|
||||
const aux = asRecord(prevLetter.aux);
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
action: 'new_letter',
|
||||
reason: 'new_letter',
|
||||
};
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: prevId },
|
||||
data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
|
||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
}
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
where: { id: { in: [me.nationId, destNationId] } },
|
||||
select: { id: true, name: true, color: true },
|
||||
});
|
||||
if (nations.length !== 2) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' });
|
||||
}
|
||||
|
||||
const srcNation = nations.find((nation) => nation.id === me.nationId);
|
||||
const destNation = nations.find((nation) => nation.id === destNationId);
|
||||
if (!srcNation || !destNation) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' });
|
||||
}
|
||||
|
||||
const aux = {
|
||||
src: {
|
||||
nationName: srcNation.name,
|
||||
nationColor: srcNation.color,
|
||||
generalId: me.id,
|
||||
generalName: me.name,
|
||||
generalIcon: null,
|
||||
},
|
||||
dest: {
|
||||
nationName: destNation.name,
|
||||
nationColor: destNation.color,
|
||||
},
|
||||
};
|
||||
|
||||
const created = await ctx.db.diplomacyLetter.create({
|
||||
data: {
|
||||
srcNationId: srcNation.id,
|
||||
destNationId: destNation.id,
|
||||
prevId,
|
||||
state: 'PROPOSED',
|
||||
textBrief: input.brief,
|
||||
textDetail: input.detail,
|
||||
srcSignerId: me.id,
|
||||
aux: aux as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { id: created.id };
|
||||
}),
|
||||
respondLetter: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
letterId: z.number().int().positive(),
|
||||
agree: z.boolean(),
|
||||
reason: z.string().trim().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const letter = await ctx.db.diplomacyLetter.findFirst({
|
||||
where: {
|
||||
id: input.letterId,
|
||||
destNationId: me.nationId,
|
||||
state: 'PROPOSED',
|
||||
},
|
||||
});
|
||||
if (!letter) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||
}
|
||||
|
||||
const aux = asRecord(letter.aux);
|
||||
if (input.agree) {
|
||||
const dest = asRecord(aux.dest);
|
||||
dest.generalId = me.id;
|
||||
dest.generalName = me.name;
|
||||
dest.generalIcon = null;
|
||||
aux.dest = dest;
|
||||
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: {
|
||||
state: 'ACTIVATED',
|
||||
destSignerId: me.id,
|
||||
aux: aux as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
let prevId = letter.prevId;
|
||||
while (prevId) {
|
||||
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
|
||||
if (!prevLetter || prevLetter.state === 'CANCELLED') {
|
||||
break;
|
||||
}
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: prevId },
|
||||
data: { state: 'REPLACED' },
|
||||
});
|
||||
prevId = prevLetter.prevId;
|
||||
}
|
||||
} else {
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
action: 'disagree',
|
||||
reason: input.reason ?? '',
|
||||
};
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
rollbackLetter: authedProcedure
|
||||
.input(z.object({ letterId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const letter = await ctx.db.diplomacyLetter.findFirst({
|
||||
where: {
|
||||
id: input.letterId,
|
||||
srcNationId: me.nationId,
|
||||
state: 'PROPOSED',
|
||||
},
|
||||
});
|
||||
if (!letter) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||
}
|
||||
|
||||
const aux = asRecord(letter.aux);
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
action: 'cancelled',
|
||||
reason: '회수',
|
||||
};
|
||||
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
destroyLetter: authedProcedure
|
||||
.input(z.object({ letterId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const letter = await ctx.db.diplomacyLetter.findFirst({
|
||||
where: {
|
||||
id: input.letterId,
|
||||
state: 'ACTIVATED',
|
||||
OR: [{ srcNationId: me.nationId }, { destNationId: me.nationId }],
|
||||
},
|
||||
});
|
||||
if (!letter) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||
}
|
||||
|
||||
const aux = asRecord(letter.aux);
|
||||
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
|
||||
const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest';
|
||||
|
||||
if (stateOpt === myStateOpt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
|
||||
}
|
||||
|
||||
if (stateOpt && stateOpt !== myStateOpt) {
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
action: 'destroy',
|
||||
reason: '파기',
|
||||
};
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
return { state: 'CANCELLED' };
|
||||
}
|
||||
|
||||
aux.state_opt = myStateOpt;
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
return { state: 'ACTIVATED' };
|
||||
}),
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import MySettingsView from '../views/MySettingsView.vue';
|
||||
import BoardView from '../views/BoardView.vue';
|
||||
import NationAffairsView from '../views/NationAffairsView.vue';
|
||||
import ScoutMessageView from '../views/ScoutMessageView.vue';
|
||||
import DiplomacyView from '../views/DiplomacyView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -81,6 +82,15 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/diplomacy',
|
||||
name: 'diplomacy',
|
||||
component: DiplomacyView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/nation/generals',
|
||||
name: 'nation-generals',
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type DiplomacyResponse = Awaited<ReturnType<typeof trpc.diplomacy.getLetters.query>>;
|
||||
type DiplomacyLetter = DiplomacyResponse['letters'][number];
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const data = ref<DiplomacyResponse | null>(null);
|
||||
const historyOpen = ref<Record<number, boolean>>({});
|
||||
|
||||
const editable = computed(() => (data.value?.permission ?? 0) >= 4);
|
||||
|
||||
const selectedDestNationId = ref<number | null>(null);
|
||||
const selectedPrevId = ref<number | null>(null);
|
||||
const briefHtml = ref('');
|
||||
const detailHtml = ref('');
|
||||
|
||||
const briefEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Image.configure({ inline: false }),
|
||||
Placeholder.configure({ placeholder: '국가 내 공개 내용을 입력하세요.' }),
|
||||
],
|
||||
content: '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'letter-editor',
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
briefHtml.value = editor.getHTML();
|
||||
},
|
||||
});
|
||||
|
||||
const detailEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Image.configure({ inline: false }),
|
||||
Placeholder.configure({ placeholder: '외교 권한 전용 내용을 입력하세요.' }),
|
||||
],
|
||||
content: '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'letter-editor',
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
detailHtml.value = editor.getHTML();
|
||||
},
|
||||
});
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadTarget = ref<'brief' | 'detail'>('brief');
|
||||
const uploadBusy = ref(false);
|
||||
|
||||
const readFileAsDataUrl = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('이미지를 읽을 수 없습니다.'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('이미지를 읽는 중 오류가 발생했습니다.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const onSelectImage = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || uploadBusy.value) return;
|
||||
uploadBusy.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
const result = await trpc.board.uploadImage.mutate({ dataUrl });
|
||||
const target = uploadTarget.value === 'brief' ? briefEditor.value : detailEditor.value;
|
||||
target?.chain().focus().setImage({ src: result.url, alt: file.name }).run();
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.';
|
||||
} finally {
|
||||
uploadBusy.value = false;
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addLink = (target: 'brief' | 'detail') => {
|
||||
const url = window.prompt('링크 주소를 입력하세요');
|
||||
if (!url) return;
|
||||
const editor = target === 'brief' ? briefEditor.value : detailEditor.value;
|
||||
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
const loadLetters = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
const result = await trpc.diplomacy.getLetters.query();
|
||||
data.value = result;
|
||||
if (!selectedDestNationId.value && result.nations.length) {
|
||||
selectedDestNationId.value = result.nations[0]?.id ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : '외교 문서를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
selectedPrevId.value = null;
|
||||
briefEditor.value?.commands.setContent('');
|
||||
detailEditor.value?.commands.setContent('');
|
||||
briefHtml.value = '';
|
||||
detailHtml.value = '';
|
||||
};
|
||||
|
||||
const applyPrevLetter = () => {
|
||||
if (!selectedPrevId.value || !data.value) {
|
||||
return;
|
||||
}
|
||||
const letter = data.value.letters.find((item) => item.id === selectedPrevId.value);
|
||||
if (!letter) return;
|
||||
briefEditor.value?.commands.setContent(letter.brief ?? '');
|
||||
detailEditor.value?.commands.setContent(letter.detail ?? '');
|
||||
};
|
||||
|
||||
const sendLetter = async () => {
|
||||
if (!editable.value || !selectedDestNationId.value) return;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
await trpc.diplomacy.sendLetter.mutate({
|
||||
destNationId: selectedDestNationId.value,
|
||||
prevId: selectedPrevId.value,
|
||||
brief: briefHtml.value,
|
||||
detail: detailHtml.value,
|
||||
});
|
||||
resetForm();
|
||||
await loadLetters();
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : '문서 전송에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const respondLetter = async (letterId: number, agree: boolean, reason?: string) => {
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
await trpc.diplomacy.respondLetter.mutate({ letterId, agree, reason });
|
||||
await loadLetters();
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : '요청 처리에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackLetter = async (letterId: number) => {
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
await trpc.diplomacy.rollbackLetter.mutate({ letterId });
|
||||
await loadLetters();
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : '문서 회수에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const destroyLetter = async (letterId: number) => {
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
await trpc.diplomacy.destroyLetter.mutate({ letterId });
|
||||
await loadLetters();
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : '파기 요청에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const prevOptions = computed(() =>
|
||||
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
|
||||
);
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
|
||||
|
||||
const stateLabelMap: Record<DiplomacyLetter['state'], string> = {
|
||||
PROPOSED: '제안',
|
||||
ACTIVATED: '승인',
|
||||
CANCELLED: '종료',
|
||||
REPLACED: '대체',
|
||||
};
|
||||
|
||||
const toggleHistory = (letterId: number) => {
|
||||
historyOpen.value[letterId] = !historyOpen.value[letterId];
|
||||
};
|
||||
|
||||
const getPrevLetter = (letter: DiplomacyLetter) =>
|
||||
data.value?.letters.find((item) => item.id === letter.prevId) ?? null;
|
||||
|
||||
const canRespond = (letter: DiplomacyLetter) =>
|
||||
editable.value && data.value?.myNationId === letter.dest.nationId && letter.state === 'PROPOSED';
|
||||
|
||||
const canRollback = (letter: DiplomacyLetter) =>
|
||||
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
|
||||
|
||||
const canDestroy = (letter: DiplomacyLetter) =>
|
||||
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
||||
|
||||
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
|
||||
|
||||
onMounted(() => {
|
||||
loadLetters();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
briefEditor.value?.destroy();
|
||||
detailEditor.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="diplomacy-view">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>외교부</h1>
|
||||
<p class="subtitle">외교 문서를 작성하고 버전 기록을 확인합니다.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="ghost" @click="loadLetters">수동 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||
|
||||
<section class="panel" v-if="editable">
|
||||
<div class="panel-header">
|
||||
<h2>새 외교 문서 작성</h2>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
이전 문서
|
||||
<select v-model.number="selectedPrevId" @change="applyPrevLetter">
|
||||
<option :value="null">없음</option>
|
||||
<option v-for="letter in prevOptions" :key="letter.id" :value="letter.id">
|
||||
#{{ letter.id }} {{ letter.src.nationName }} ↔ {{ letter.dest.nationName }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
대상 국가
|
||||
<select v-model.number="selectedDestNationId">
|
||||
<option v-for="nation in data?.nations ?? []" :key="nation.id" :value="nation.id">
|
||||
{{ nation.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(국가 내 공개)</div>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</button>
|
||||
<button type="button" @click="addLink('brief')">링크</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
||||
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
||||
</div>
|
||||
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(외교권자 전용)</div>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</button>
|
||||
<button type="button" @click="addLink('detail')">링크</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
||||
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
||||
</div>
|
||||
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
||||
</div>
|
||||
<div class="submit-row">
|
||||
<button type="button" class="primary" @click="sendLetter">전송</button>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" accept="image/*" class="hidden" @change="onSelectImage" />
|
||||
</section>
|
||||
|
||||
<section v-if="!editable" class="panel">
|
||||
<p class="hint">문서 작성 권한은 군주/수뇌에게만 제공됩니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="letter-list">
|
||||
<article v-for="letter in data?.letters ?? []" :key="letter.id" class="letter-card">
|
||||
<header class="letter-header">
|
||||
<h3>#{{ letter.id }} {{ letter.src.nationName }} ↔ {{ letter.dest.nationName }}</h3>
|
||||
<div class="letter-meta">
|
||||
<span class="state-badge" :data-state="letter.state">{{ stateLabelMap[letter.state] }}</span>
|
||||
<span>{{ formatDate(letter.date) }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="letter-body">
|
||||
<div class="letter-section">
|
||||
<h4>내용(국가 내 공개)</h4>
|
||||
<div class="letter-text" v-html="letter.brief" />
|
||||
</div>
|
||||
<div class="letter-section">
|
||||
<h4>내용(외교권자 전용)</h4>
|
||||
<div class="letter-text" v-html="letter.detail" />
|
||||
</div>
|
||||
<div v-if="letter.prevId" class="letter-history">
|
||||
<button type="button" class="ghost" @click="toggleHistory(letter.id)">
|
||||
이전 문서 {{ historyOpen[letter.id] ? '접기' : '보기' }}
|
||||
</button>
|
||||
<div v-if="historyOpen[letter.id]" class="history-panel">
|
||||
<template v-if="getPrevLetter(letter)">
|
||||
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔ {{ getPrevLetter(letter)?.dest.nationName }}</p>
|
||||
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
|
||||
</template>
|
||||
<p v-else class="hint">이전 문서를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="letter-actions">
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
|
||||
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
||||
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
||||
<button v-if="canRenew(letter)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="loading">불러오는 중...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diplomacy-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: #0f1118;
|
||||
color: #e6e8ef;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #9aa3b8;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #141826;
|
||||
color: #c7d0e0;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #181b26;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #23283a;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #0f1118;
|
||||
color: #f5f6fa;
|
||||
}
|
||||
|
||||
.editor-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #1e2232;
|
||||
color: #d8dff0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button.active {
|
||||
background: #3b425c;
|
||||
}
|
||||
|
||||
.letter-editor {
|
||||
min-height: 160px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #0f1118;
|
||||
color: #f5f6fa;
|
||||
}
|
||||
|
||||
.letter-editor :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.primary {
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.letter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.letter-card {
|
||||
background: #161a24;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #202638;
|
||||
}
|
||||
|
||||
.letter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.letter-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: #9aa3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: #2b3348;
|
||||
color: #e6e8ef;
|
||||
}
|
||||
|
||||
.state-badge[data-state='PROPOSED'] {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.state-badge[data-state='ACTIVATED'] {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.state-badge[data-state='REPLACED'] {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.state-badge[data-state='CANCELLED'] {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.letter-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.letter-text {
|
||||
padding: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.letter-history {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: #11131a;
|
||||
}
|
||||
|
||||
.letter-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -94,7 +94,8 @@ watch(
|
||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/affairs">내무부</RouterLink>
|
||||
<RouterLink class="ghost" to="/diplomacy">외교부</RouterLink>
|
||||
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
||||
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
|
||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
|
||||
@@ -37,6 +37,13 @@ enum AuctionType {
|
||||
UNIQUE_ITEM
|
||||
}
|
||||
|
||||
enum DiplomacyLetterState {
|
||||
PROPOSED
|
||||
ACTIVATED
|
||||
CANCELLED
|
||||
REPLACED
|
||||
}
|
||||
|
||||
model WorldState {
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioCode String @map("scenario_code")
|
||||
@@ -187,6 +194,25 @@ model Diplomacy {
|
||||
@@map("diplomacy")
|
||||
}
|
||||
|
||||
model DiplomacyLetter {
|
||||
id Int @id @default(autoincrement())
|
||||
srcNationId Int @map("src_nation_id")
|
||||
destNationId Int @map("dest_nation_id")
|
||||
prevId Int? @map("prev_id")
|
||||
state DiplomacyLetterState @default(PROPOSED)
|
||||
textBrief String @map("text_brief")
|
||||
textDetail String @map("text_detail")
|
||||
date DateTime @default(now()) @map("date")
|
||||
srcSignerId Int @map("src_signer")
|
||||
destSignerId Int? @map("dest_signer")
|
||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||
|
||||
@@index([srcNationId, destNationId])
|
||||
@@index([destNationId, srcNationId])
|
||||
@@index([state, date])
|
||||
@@map("diplomacy_letter")
|
||||
}
|
||||
|
||||
model Event {
|
||||
id Int @id @default(autoincrement())
|
||||
targetCode String @map("target_code")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TYPE "diplomacy_letter_state" AS ENUM ('PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED');
|
||||
|
||||
CREATE TABLE "diplomacy_letter" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"src_nation_id" INTEGER NOT NULL,
|
||||
"dest_nation_id" INTEGER NOT NULL,
|
||||
"prev_id" INTEGER,
|
||||
"state" "diplomacy_letter_state" NOT NULL DEFAULT 'PROPOSED',
|
||||
"text_brief" TEXT NOT NULL,
|
||||
"text_detail" TEXT NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"src_signer" INTEGER NOT NULL,
|
||||
"dest_signer" INTEGER,
|
||||
"aux" JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX "diplomacy_letter_src_dest_idx" ON "diplomacy_letter"("src_nation_id", "dest_nation_id");
|
||||
CREATE INDEX "diplomacy_letter_dest_src_idx" ON "diplomacy_letter"("dest_nation_id", "src_nation_id");
|
||||
CREATE INDEX "diplomacy_letter_state_date_idx" ON "diplomacy_letter"("state", "date");
|
||||
@@ -9,6 +9,7 @@ export interface DatabaseClient {
|
||||
city: GamePrisma.CityDelegate;
|
||||
nation: GamePrisma.NationDelegate;
|
||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||
diplomacyLetter: GamePrisma.DiplomacyLetterDelegate;
|
||||
generalTurn: GamePrisma.GeneralTurnDelegate;
|
||||
nationTurn: GamePrisma.NationTurnDelegate;
|
||||
troop: GamePrisma.TroopDelegate;
|
||||
|
||||
Reference in New Issue
Block a user