1953 lines
93 KiB
Vue
1953 lines
93 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
|
import { trpc } from '../utils/trpc';
|
|
|
|
type AdminUserSanctions = {
|
|
bannedUntil?: string;
|
|
mutedUntil?: string;
|
|
suspendedUntil?: string;
|
|
warningCount?: number;
|
|
flags?: string[];
|
|
notes?: string;
|
|
profileIconResetAt?: string;
|
|
serverRestrictions?: Record<
|
|
string,
|
|
{
|
|
blockedFeatures?: string[];
|
|
until?: string;
|
|
reason?: string;
|
|
notes?: string;
|
|
}
|
|
>;
|
|
};
|
|
|
|
type AdminSanctionsPatch = {
|
|
bannedUntil?: string | null;
|
|
mutedUntil?: string | null;
|
|
suspendedUntil?: string | null;
|
|
warningCount?: number | null;
|
|
flags?: string[] | null;
|
|
notes?: string | null;
|
|
profileIconResetAt?: string | null;
|
|
serverRestrictions?: Record<
|
|
string,
|
|
{
|
|
blockedFeatures?: string[];
|
|
until?: string | null;
|
|
reason?: string | null;
|
|
notes?: string | null;
|
|
} | null
|
|
> | null;
|
|
};
|
|
|
|
type AdminUser = {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
roles: string[];
|
|
sanctions: AdminUserSanctions;
|
|
oauthType: string;
|
|
oauthId?: string;
|
|
email?: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type AdminPublicUser = {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
roles: string[];
|
|
createdAt: string;
|
|
};
|
|
|
|
type AdminProfile = {
|
|
profileName: string;
|
|
profile: string;
|
|
scenario: string;
|
|
status: string;
|
|
apiPort: number;
|
|
runtime: {
|
|
apiRunning: boolean;
|
|
daemonRunning: boolean;
|
|
tournamentRunning: boolean;
|
|
};
|
|
buildCommitSha?: string;
|
|
meta: Record<string, unknown>;
|
|
};
|
|
|
|
type ScenarioNationPreview = {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
cities: string[];
|
|
generals: number;
|
|
generalsEx: number;
|
|
generalsNeutral: number;
|
|
};
|
|
|
|
type ScenarioPreview = {
|
|
id: number;
|
|
title: string;
|
|
year: number | null;
|
|
npcCount: number;
|
|
npcExCount: number;
|
|
npcNeutralCount: number;
|
|
nations: ScenarioNationPreview[];
|
|
};
|
|
|
|
type ScenarioCatalogState = {
|
|
scenarios: ScenarioPreview[];
|
|
loading: boolean;
|
|
status: string;
|
|
};
|
|
|
|
type InstallFormState = {
|
|
scenarioId: number;
|
|
turnTermMinutes: number;
|
|
sync: boolean;
|
|
fiction: number;
|
|
extend: boolean;
|
|
blockGeneralCreate: number;
|
|
npcMode: number;
|
|
showImgLevel: number;
|
|
tournamentTrig: boolean;
|
|
joinMode: 'full' | 'onlyRandom';
|
|
autorunUserMinutes: number;
|
|
autorunUserOptions: Record<string, boolean>;
|
|
openAt: string;
|
|
preopenAt: string;
|
|
gitRef: string;
|
|
reason: string;
|
|
};
|
|
|
|
type AdminAction =
|
|
'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN';
|
|
|
|
type AdminClient = {
|
|
system: {
|
|
getNotice: {
|
|
query: () => Promise<{ notice: string }>;
|
|
};
|
|
setNotice: {
|
|
mutate: (input: { notice: string }) => Promise<{ notice: string }>;
|
|
};
|
|
};
|
|
users: {
|
|
getLocalAccountStatus: {
|
|
query: () => Promise<{ enabled: boolean }>;
|
|
};
|
|
createLocal: {
|
|
mutate: (input: {
|
|
username: string;
|
|
password: string;
|
|
displayName?: string;
|
|
}) => Promise<{ user: AdminPublicUser }>;
|
|
};
|
|
lookup: {
|
|
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
|
|
};
|
|
resetPassword: {
|
|
mutate: (input: { userId: string; newPassword?: string }) => Promise<{ password: string }>;
|
|
};
|
|
updateRoles: {
|
|
mutate: (input: {
|
|
userId: string;
|
|
roles: string[];
|
|
mode?: 'set' | 'grant' | 'revoke';
|
|
}) => Promise<{ roles: string[] }>;
|
|
};
|
|
updateSanctions: {
|
|
mutate: (input: {
|
|
userId: string;
|
|
patch: AdminSanctionsPatch;
|
|
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
|
};
|
|
setServerRestriction: {
|
|
mutate: (input: {
|
|
userId: string;
|
|
profile: string;
|
|
restriction: {
|
|
blockedFeatures?: string[];
|
|
until?: string | null;
|
|
reason?: string | null;
|
|
notes?: string | null;
|
|
} | null;
|
|
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
|
};
|
|
resetProfileIcon: {
|
|
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
|
|
};
|
|
forceDelete: {
|
|
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
|
|
};
|
|
};
|
|
profiles: {
|
|
list: {
|
|
query: () => Promise<AdminProfile[]>;
|
|
};
|
|
listScenarios: {
|
|
query: (input?: { gitRef?: string }) => Promise<ScenarioPreview[]>;
|
|
};
|
|
updateMeta: {
|
|
mutate: (input: {
|
|
profileName: string;
|
|
patch: {
|
|
korName?: string | null;
|
|
color?: string | null;
|
|
inGameNotice?: string | null;
|
|
profileImageUrl?: string | null;
|
|
nextSeasonIdx?: number | null;
|
|
};
|
|
}) => Promise<AdminProfile | null>;
|
|
};
|
|
install: {
|
|
mutate: (input: {
|
|
profileName: string;
|
|
install: {
|
|
scenarioId: number;
|
|
turnTermMinutes: number;
|
|
sync: boolean;
|
|
fiction: number;
|
|
extend: boolean;
|
|
blockGeneralCreate: number;
|
|
npcMode: number;
|
|
showImgLevel: number;
|
|
tournamentTrig: boolean;
|
|
joinMode: 'full' | 'onlyRandom';
|
|
autorunUser?: {
|
|
limitMinutes: number;
|
|
options: string[];
|
|
} | null;
|
|
openAt?: string;
|
|
preopenAt?: string;
|
|
gitRef?: string;
|
|
};
|
|
reason?: string;
|
|
}) => Promise<{ ok: boolean; action?: unknown }>;
|
|
};
|
|
requestAction: {
|
|
mutate: (input: {
|
|
profileName: string;
|
|
action: AdminAction;
|
|
durationMinutes?: number;
|
|
scheduledAt?: string;
|
|
reason?: string;
|
|
}) => Promise<{ ok: boolean }>;
|
|
};
|
|
};
|
|
};
|
|
|
|
const adminClient = trpc.admin as unknown as AdminClient;
|
|
|
|
const sessionToken = ref('');
|
|
const sessionTokenStatus = ref('');
|
|
|
|
if (typeof window !== 'undefined') {
|
|
sessionToken.value = window.localStorage.getItem('sammo-session-token') ?? '';
|
|
}
|
|
|
|
const saveSessionToken = () => {
|
|
const value = sessionToken.value.trim();
|
|
if (typeof window !== 'undefined') {
|
|
if (value) {
|
|
window.localStorage.setItem('sammo-session-token', value);
|
|
} else {
|
|
window.localStorage.removeItem('sammo-session-token');
|
|
}
|
|
}
|
|
sessionTokenStatus.value = value ? '저장됨' : '삭제됨';
|
|
};
|
|
|
|
const noticeDraft = ref('');
|
|
const noticeStatus = ref('');
|
|
const noticeLoading = ref(false);
|
|
|
|
const profiles = ref<AdminProfile[]>([]);
|
|
const profilesLoading = ref(false);
|
|
const profileEdits = ref<
|
|
Record<
|
|
string,
|
|
{
|
|
korName: string;
|
|
color: string;
|
|
inGameNotice: string;
|
|
profileImageUrl: string;
|
|
nextSeasonIdx: string;
|
|
}
|
|
>
|
|
>({});
|
|
const profileActions = ref<
|
|
Record<
|
|
string,
|
|
{
|
|
durationMinutes: string;
|
|
scheduledAt: string;
|
|
reason: string;
|
|
}
|
|
>
|
|
>({});
|
|
const profileActionStatus = ref<Record<string, string>>({});
|
|
const scenarioCatalogs = ref<Record<string, ScenarioCatalogState>>({});
|
|
const profileInstalls = ref<Record<string, InstallFormState>>({});
|
|
const profileInstallStatus = ref<Record<string, string>>({});
|
|
|
|
const autorunOptionLabels = [
|
|
{ key: 'develop', label: '내정' },
|
|
{ key: 'warp', label: '순간이동' },
|
|
{ key: 'recruit', label: '징병' },
|
|
{ key: 'recruit_high', label: '모병' },
|
|
{ key: 'train', label: '훈사' },
|
|
{ key: 'battle', label: '출병' },
|
|
{ key: 'chief', label: '기본 사령턴' },
|
|
] as const;
|
|
|
|
const turnTermOptions = [120, 60, 30, 20, 10, 5, 2, 1] as const;
|
|
|
|
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
|
const userLookupValue = ref('');
|
|
const userLoading = ref(false);
|
|
const userError = ref('');
|
|
const userResult = ref<AdminUser | null>(null);
|
|
|
|
const localAccountEnabled = ref(false);
|
|
const localAccountStatus = ref('');
|
|
const localAccountResult = ref('');
|
|
const localAccountLoading = ref(false);
|
|
const localAccountForm = ref({
|
|
username: '',
|
|
password: '',
|
|
displayName: '',
|
|
});
|
|
|
|
const passwordInput = ref('');
|
|
const passwordResult = ref('');
|
|
const passwordStatus = ref('');
|
|
|
|
const rolesInput = ref('');
|
|
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
|
|
const rolesStatus = ref('');
|
|
|
|
const banUntil = ref('');
|
|
const banReason = ref('');
|
|
const banStatus = ref('');
|
|
|
|
const profileIconStatus = ref('');
|
|
|
|
const restrictionProfile = ref('');
|
|
const restrictionFeatures = ref('');
|
|
const restrictionUntil = ref('');
|
|
const restrictionReason = ref('');
|
|
const restrictionNotes = ref('');
|
|
const restrictionStatus = ref('');
|
|
|
|
const forceDeleteStatus = ref('');
|
|
|
|
const hasUser = computed(() => Boolean(userResult.value));
|
|
|
|
const loadLocalAccountStatus = async () => {
|
|
localAccountStatus.value = '';
|
|
try {
|
|
const result = await adminClient.users.getLocalAccountStatus.query();
|
|
localAccountEnabled.value = result.enabled;
|
|
localAccountStatus.value = result.enabled ? '' : 'ENV 설정이 비활성화 상태입니다.';
|
|
} catch (error) {
|
|
localAccountEnabled.value = false;
|
|
localAccountStatus.value = '로컬 계정 생성 설정 확인 실패';
|
|
}
|
|
};
|
|
|
|
const loadNotice = async () => {
|
|
noticeLoading.value = true;
|
|
try {
|
|
const result = await adminClient.system.getNotice.query();
|
|
noticeDraft.value = result.notice;
|
|
} catch (error) {
|
|
noticeStatus.value = '공지 불러오기 실패';
|
|
} finally {
|
|
noticeLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const saveNotice = async () => {
|
|
noticeLoading.value = true;
|
|
noticeStatus.value = '';
|
|
try {
|
|
const result = await adminClient.system.setNotice.mutate({
|
|
notice: noticeDraft.value,
|
|
});
|
|
noticeDraft.value = result.notice;
|
|
noticeStatus.value = '저장 완료';
|
|
} catch (error) {
|
|
noticeStatus.value = '저장 실패';
|
|
} finally {
|
|
noticeLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const ensureProfileBuffers = (profile: AdminProfile) => {
|
|
if (!profileEdits.value[profile.profileName]) {
|
|
const meta = (profile.meta ?? {}) as Record<string, unknown>;
|
|
profileEdits.value[profile.profileName] = {
|
|
korName: String(meta.korName ?? profile.profile),
|
|
color: String(meta.color ?? '#ffffff'),
|
|
inGameNotice: String(meta.inGameNotice ?? ''),
|
|
profileImageUrl: String(meta.profileImageUrl ?? ''),
|
|
nextSeasonIdx:
|
|
typeof meta.nextSeasonIdx === 'number' && Number.isFinite(meta.nextSeasonIdx)
|
|
? String(Math.floor(meta.nextSeasonIdx))
|
|
: '',
|
|
};
|
|
}
|
|
if (!profileActions.value[profile.profileName]) {
|
|
profileActions.value[profile.profileName] = {
|
|
durationMinutes: '',
|
|
scheduledAt: '',
|
|
reason: '',
|
|
};
|
|
}
|
|
};
|
|
|
|
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
|
|
|
const formatLocalInput = (date: Date): string =>
|
|
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(
|
|
date.getMinutes()
|
|
)}`;
|
|
|
|
const toLocalInputValue = (value: unknown): string => {
|
|
if (typeof value !== 'string') {
|
|
return '';
|
|
}
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return '';
|
|
}
|
|
return formatLocalInput(parsed);
|
|
};
|
|
|
|
const readNumber = (value: unknown, fallback: number): number =>
|
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
|
|
const readBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback);
|
|
|
|
const readString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback);
|
|
|
|
const buildAutorunOptionMap = (options?: string[]): Record<string, boolean> => {
|
|
const map: Record<string, boolean> = {};
|
|
autorunOptionLabels.forEach(({ key }) => {
|
|
map[key] = options ? options.includes(key) : true;
|
|
});
|
|
return map;
|
|
};
|
|
|
|
const normalizeGitRefInput = (value: string): string => value.trim();
|
|
|
|
const getScenarioCatalogKey = (gitRef: string): string => normalizeGitRefInput(gitRef);
|
|
|
|
const getScenarioCatalogStateByRef = (gitRef: string): ScenarioCatalogState => {
|
|
const key = getScenarioCatalogKey(gitRef);
|
|
return (
|
|
scenarioCatalogs.value[key] ?? {
|
|
scenarios: [],
|
|
loading: false,
|
|
status: '',
|
|
}
|
|
);
|
|
};
|
|
|
|
const ensureProfileInstallBuffers = (profile: AdminProfile) => {
|
|
if (profileInstalls.value[profile.profileName]) {
|
|
return;
|
|
}
|
|
const meta = (profile.meta ?? {}) as Record<string, unknown>;
|
|
const install = (meta.install ?? {}) as Record<string, unknown>;
|
|
const autorunUser = (install.autorunUser ?? {}) as Record<string, unknown>;
|
|
const autorunOptionsRaw = Array.isArray(autorunUser.options)
|
|
? autorunUser.options.filter((option): option is string => typeof option === 'string')
|
|
: undefined;
|
|
const scenarioId = Number(profile.scenario);
|
|
|
|
const installGitRef = readString(install.gitRef, '');
|
|
const buildCommitRef = typeof profile.buildCommitSha === 'string' ? profile.buildCommitSha : '';
|
|
|
|
profileInstalls.value[profile.profileName] = {
|
|
scenarioId: Number.isFinite(scenarioId) ? scenarioId : readNumber(install.scenarioId, 0),
|
|
turnTermMinutes: readNumber(install.turnTermMinutes, 60),
|
|
sync: readBoolean(install.sync, true),
|
|
fiction: readNumber(install.fiction, 1),
|
|
extend: readBoolean(install.extend, true),
|
|
blockGeneralCreate: readNumber(install.blockGeneralCreate, 0),
|
|
npcMode: readNumber(install.npcMode, 0),
|
|
showImgLevel: readNumber(install.showImgLevel, 3),
|
|
tournamentTrig: readBoolean(install.tournamentTrig, true),
|
|
joinMode: readString(install.joinMode, 'full') === 'onlyRandom' ? 'onlyRandom' : 'full',
|
|
autorunUserMinutes: readNumber(autorunUser.limitMinutes, 1440),
|
|
autorunUserOptions: buildAutorunOptionMap(autorunOptionsRaw),
|
|
openAt: toLocalInputValue(install.openAt),
|
|
preopenAt: toLocalInputValue(install.preopenAt),
|
|
gitRef: installGitRef || buildCommitRef,
|
|
reason: '',
|
|
};
|
|
};
|
|
|
|
const buildScenarioMap = (items: ScenarioPreview[]): Map<number, ScenarioPreview> => {
|
|
const map = new Map<number, ScenarioPreview>();
|
|
items.forEach((scenario) => {
|
|
map.set(scenario.id, scenario);
|
|
});
|
|
return map;
|
|
};
|
|
|
|
const buildScenarioGroups = (items: ScenarioPreview[]): Record<string, ScenarioPreview[]> => {
|
|
const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/;
|
|
const groups: Record<string, ScenarioPreview[]> = {};
|
|
for (const scenario of items) {
|
|
const match = pattern.exec(scenario.title);
|
|
const category = match?.[1] ?? '기타';
|
|
if (!groups[category]) {
|
|
groups[category] = [];
|
|
}
|
|
groups[category].push(scenario);
|
|
}
|
|
return groups;
|
|
};
|
|
|
|
const getScenarioPreview = (profileName: string): ScenarioPreview | null => {
|
|
const install = profileInstalls.value[profileName];
|
|
if (!install) {
|
|
return null;
|
|
}
|
|
const catalog = getScenarioCatalogStateByRef(install.gitRef);
|
|
const map = buildScenarioMap(catalog.scenarios);
|
|
return map.get(install.scenarioId) ?? null;
|
|
};
|
|
|
|
const getScenarioGroups = (profileName: string): Record<string, ScenarioPreview[]> => {
|
|
const install = profileInstalls.value[profileName];
|
|
if (!install) {
|
|
return {};
|
|
}
|
|
const catalog = getScenarioCatalogStateByRef(install.gitRef);
|
|
return buildScenarioGroups(catalog.scenarios);
|
|
};
|
|
|
|
const getScenarioLoading = (profileName: string): boolean => {
|
|
const install = profileInstalls.value[profileName];
|
|
if (!install) {
|
|
return false;
|
|
}
|
|
return getScenarioCatalogStateByRef(install.gitRef).loading;
|
|
};
|
|
|
|
const getScenarioStatus = (profileName: string): string => {
|
|
const install = profileInstalls.value[profileName];
|
|
if (!install) {
|
|
return '';
|
|
}
|
|
return getScenarioCatalogStateByRef(install.gitRef).status;
|
|
};
|
|
|
|
const loadProfiles = async () => {
|
|
profilesLoading.value = true;
|
|
try {
|
|
const result = await adminClient.profiles.list.query();
|
|
result.forEach((profile) => {
|
|
ensureProfileBuffers(profile);
|
|
ensureProfileInstallBuffers(profile);
|
|
});
|
|
profiles.value = result;
|
|
const refs = new Set<string>();
|
|
refs.add('');
|
|
result.forEach((profile) => {
|
|
const install = profileInstalls.value[profile.profileName];
|
|
if (install?.gitRef) {
|
|
refs.add(normalizeGitRefInput(install.gitRef));
|
|
}
|
|
});
|
|
await Promise.all(Array.from(refs).map((gitRef) => loadScenarioCatalog(gitRef)));
|
|
} catch (error) {
|
|
profileActionStatus.value = {
|
|
...profileActionStatus.value,
|
|
global: '프로필 목록을 불러오지 못했습니다.',
|
|
};
|
|
} finally {
|
|
profilesLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadScenarioCatalog = async (gitRef: string) => {
|
|
const key = getScenarioCatalogKey(gitRef);
|
|
const previous = scenarioCatalogs.value[key];
|
|
scenarioCatalogs.value = {
|
|
...scenarioCatalogs.value,
|
|
[key]: {
|
|
scenarios: previous?.scenarios ?? [],
|
|
loading: true,
|
|
status: '',
|
|
},
|
|
};
|
|
try {
|
|
const result = await adminClient.profiles.listScenarios.query(key ? { gitRef: key } : undefined);
|
|
scenarioCatalogs.value = {
|
|
...scenarioCatalogs.value,
|
|
[key]: {
|
|
scenarios: result,
|
|
loading: false,
|
|
status: '',
|
|
},
|
|
};
|
|
} catch (error) {
|
|
scenarioCatalogs.value = {
|
|
...scenarioCatalogs.value,
|
|
[key]: {
|
|
scenarios: previous?.scenarios ?? [],
|
|
loading: false,
|
|
status: '시나리오 목록을 불러오지 못했습니다.',
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
const loadScenariosForProfile = async (profileName: string) => {
|
|
const install = profileInstalls.value[profileName];
|
|
if (!install) {
|
|
return;
|
|
}
|
|
await loadScenarioCatalog(install.gitRef);
|
|
};
|
|
|
|
const updateProfileMeta = async (profileName: string) => {
|
|
const edit = profileEdits.value[profileName];
|
|
if (!edit) {
|
|
return;
|
|
}
|
|
const nextSeasonRaw = edit.nextSeasonIdx.trim();
|
|
const nextSeasonIdx = nextSeasonRaw === '' ? null : Number(nextSeasonRaw);
|
|
if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) {
|
|
profileActionStatus.value = {
|
|
...profileActionStatus.value,
|
|
[profileName]: '다음 시즌 번호는 0 이상 숫자여야 합니다.',
|
|
};
|
|
return;
|
|
}
|
|
const patch = {
|
|
korName: edit.korName.trim() || null,
|
|
color: edit.color.trim() || null,
|
|
inGameNotice: edit.inGameNotice.trim() || null,
|
|
profileImageUrl: edit.profileImageUrl.trim() || null,
|
|
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
|
|
};
|
|
try {
|
|
const updated = await adminClient.profiles.updateMeta.mutate({
|
|
profileName,
|
|
patch,
|
|
});
|
|
profileActionStatus.value = {
|
|
...profileActionStatus.value,
|
|
[profileName]: updated ? '메타 저장 완료' : '메타 저장 실패',
|
|
};
|
|
if (updated) {
|
|
profiles.value = profiles.value.map((item) =>
|
|
item.profileName === profileName ? { ...item, ...updated } : item
|
|
);
|
|
}
|
|
} catch (error) {
|
|
profileActionStatus.value = {
|
|
...profileActionStatus.value,
|
|
[profileName]: '메타 저장 실패',
|
|
};
|
|
}
|
|
};
|
|
|
|
const requestProfileAction = async (profileName: string, action: AdminAction) => {
|
|
const actionState = profileActions.value[profileName];
|
|
const durationMinutes = actionState?.durationMinutes ? Number(actionState.durationMinutes) : undefined;
|
|
const durationValue = durationMinutes && durationMinutes > 0 ? durationMinutes : undefined;
|
|
const scheduledAt = actionState?.scheduledAt ? new Date(actionState.scheduledAt).toISOString() : undefined;
|
|
const reason = actionState?.reason.trim() || undefined;
|
|
try {
|
|
await adminClient.profiles.requestAction.mutate({
|
|
profileName,
|
|
action,
|
|
durationMinutes: durationValue,
|
|
scheduledAt,
|
|
reason,
|
|
});
|
|
profileActionStatus.value = {
|
|
...profileActionStatus.value,
|
|
[profileName]: `요청 완료: ${action}`,
|
|
};
|
|
} catch (error) {
|
|
profileActionStatus.value = {
|
|
...profileActionStatus.value,
|
|
[profileName]: `요청 실패: ${action}`,
|
|
};
|
|
}
|
|
};
|
|
|
|
const requestInstall = async (profileName: string) => {
|
|
const install = profileInstalls.value[profileName];
|
|
if (!install) {
|
|
return;
|
|
}
|
|
const options = Object.entries(install.autorunUserOptions)
|
|
.filter(([, enabled]) => enabled)
|
|
.map(([key]) => key);
|
|
const autorunUser =
|
|
install.autorunUserMinutes > 0 && options.length
|
|
? {
|
|
limitMinutes: install.autorunUserMinutes,
|
|
options,
|
|
}
|
|
: null;
|
|
const openAt = install.openAt ? new Date(install.openAt) : null;
|
|
if (openAt && Number.isNaN(openAt.getTime())) {
|
|
profileInstallStatus.value = {
|
|
...profileInstallStatus.value,
|
|
[profileName]: '오픈 시간이 올바르지 않습니다.',
|
|
};
|
|
return;
|
|
}
|
|
const preopenAt = install.preopenAt ? new Date(install.preopenAt) : null;
|
|
if (preopenAt && Number.isNaN(preopenAt.getTime())) {
|
|
profileInstallStatus.value = {
|
|
...profileInstallStatus.value,
|
|
[profileName]: '가오픈 시간이 올바르지 않습니다.',
|
|
};
|
|
return;
|
|
}
|
|
try {
|
|
const gitRef = normalizeGitRefInput(install.gitRef);
|
|
await adminClient.profiles.install.mutate({
|
|
profileName,
|
|
install: {
|
|
scenarioId: install.scenarioId,
|
|
turnTermMinutes: install.turnTermMinutes,
|
|
sync: install.sync,
|
|
fiction: install.fiction,
|
|
extend: install.extend,
|
|
blockGeneralCreate: install.blockGeneralCreate,
|
|
npcMode: install.npcMode,
|
|
showImgLevel: install.showImgLevel,
|
|
tournamentTrig: install.tournamentTrig,
|
|
joinMode: install.joinMode,
|
|
autorunUser,
|
|
openAt: openAt ? openAt.toISOString() : undefined,
|
|
preopenAt: preopenAt ? preopenAt.toISOString() : undefined,
|
|
gitRef: gitRef ? gitRef : undefined,
|
|
},
|
|
reason: install.reason.trim() || undefined,
|
|
});
|
|
profileInstallStatus.value = {
|
|
...profileInstallStatus.value,
|
|
[profileName]: openAt ? '설치 예약 완료' : '설치 요청 완료',
|
|
};
|
|
await loadProfiles();
|
|
} catch (error) {
|
|
profileInstallStatus.value = {
|
|
...profileInstallStatus.value,
|
|
[profileName]: '설치 요청 실패',
|
|
};
|
|
}
|
|
};
|
|
|
|
const lookupUser = async () => {
|
|
userLoading.value = true;
|
|
userError.value = '';
|
|
passwordStatus.value = '';
|
|
rolesStatus.value = '';
|
|
banStatus.value = '';
|
|
restrictionStatus.value = '';
|
|
profileIconStatus.value = '';
|
|
forceDeleteStatus.value = '';
|
|
try {
|
|
const payload =
|
|
userLookupMode.value === 'id'
|
|
? { id: userLookupValue.value.trim() }
|
|
: userLookupMode.value === 'email'
|
|
? { email: userLookupValue.value.trim() }
|
|
: { username: userLookupValue.value.trim() };
|
|
const result = await adminClient.users.lookup.query(payload);
|
|
if (!result) {
|
|
userResult.value = null;
|
|
userError.value = '사용자를 찾을 수 없습니다.';
|
|
return;
|
|
}
|
|
userResult.value = result;
|
|
} catch (error) {
|
|
userError.value = '조회 실패';
|
|
} finally {
|
|
userLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const resetUserPassword = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
passwordStatus.value = '';
|
|
passwordResult.value = '';
|
|
try {
|
|
const result = await adminClient.users.resetPassword.mutate({
|
|
userId: userResult.value.id,
|
|
newPassword: passwordInput.value.trim() || undefined,
|
|
});
|
|
passwordResult.value = result.password;
|
|
passwordStatus.value = '초기화 완료';
|
|
passwordInput.value = '';
|
|
} catch (error) {
|
|
passwordStatus.value = '초기화 실패';
|
|
}
|
|
};
|
|
|
|
const updateUserRoles = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
const roles = rolesInput.value
|
|
.split(',')
|
|
.map((role) => role.trim())
|
|
.filter(Boolean);
|
|
if (!roles.length) {
|
|
rolesStatus.value = '역할을 입력하세요.';
|
|
return;
|
|
}
|
|
rolesStatus.value = '';
|
|
try {
|
|
const result = await adminClient.users.updateRoles.mutate({
|
|
userId: userResult.value.id,
|
|
roles,
|
|
mode: rolesMode.value,
|
|
});
|
|
userResult.value = { ...userResult.value, roles: result.roles };
|
|
rolesStatus.value = '권한 업데이트 완료';
|
|
} catch (error) {
|
|
rolesStatus.value = '권한 업데이트 실패';
|
|
}
|
|
};
|
|
|
|
const applyBan = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
|
|
const patch = {
|
|
bannedUntil: until,
|
|
notes: banReason.value.trim() || undefined,
|
|
};
|
|
try {
|
|
const result = await adminClient.users.updateSanctions.mutate({
|
|
userId: userResult.value.id,
|
|
patch,
|
|
});
|
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
|
banStatus.value = '차단 설정 완료';
|
|
} catch (error) {
|
|
banStatus.value = '차단 설정 실패';
|
|
}
|
|
};
|
|
|
|
const clearBan = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
try {
|
|
const result = await adminClient.users.updateSanctions.mutate({
|
|
userId: userResult.value.id,
|
|
patch: { bannedUntil: null },
|
|
});
|
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
|
banStatus.value = '차단 해제 완료';
|
|
} catch (error) {
|
|
banStatus.value = '차단 해제 실패';
|
|
}
|
|
};
|
|
|
|
const resetProfileIcon = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
try {
|
|
const result = await adminClient.users.resetProfileIcon.mutate({
|
|
userId: userResult.value.id,
|
|
});
|
|
userResult.value = {
|
|
...userResult.value,
|
|
sanctions: {
|
|
...userResult.value.sanctions,
|
|
profileIconResetAt: result.profileIconResetAt,
|
|
},
|
|
};
|
|
profileIconStatus.value = '아이콘 초기화 요청 완료';
|
|
} catch (error) {
|
|
profileIconStatus.value = '아이콘 초기화 실패';
|
|
}
|
|
};
|
|
|
|
const applyRestriction = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
if (!restrictionProfile.value.trim()) {
|
|
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
|
return;
|
|
}
|
|
const features = restrictionFeatures.value
|
|
.split(',')
|
|
.map((feature) => feature.trim())
|
|
.filter(Boolean);
|
|
const restriction = {
|
|
blockedFeatures: features.length ? features : undefined,
|
|
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined,
|
|
reason: restrictionReason.value.trim() || undefined,
|
|
notes: restrictionNotes.value.trim() || undefined,
|
|
};
|
|
try {
|
|
const result = await adminClient.users.setServerRestriction.mutate({
|
|
userId: userResult.value.id,
|
|
profile: restrictionProfile.value.trim(),
|
|
restriction,
|
|
});
|
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
|
restrictionStatus.value = '서버 제재 적용 완료';
|
|
} catch (error) {
|
|
restrictionStatus.value = '서버 제재 적용 실패';
|
|
}
|
|
};
|
|
|
|
const clearRestriction = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
if (!restrictionProfile.value.trim()) {
|
|
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
|
return;
|
|
}
|
|
try {
|
|
const result = await adminClient.users.setServerRestriction.mutate({
|
|
userId: userResult.value.id,
|
|
profile: restrictionProfile.value.trim(),
|
|
restriction: null,
|
|
});
|
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
|
restrictionStatus.value = '서버 제재 해제 완료';
|
|
} catch (error) {
|
|
restrictionStatus.value = '서버 제재 해제 실패';
|
|
}
|
|
};
|
|
|
|
const forceDeleteUser = async () => {
|
|
if (!userResult.value) {
|
|
return;
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
const confirmed = window.confirm('정말로 강제 탈퇴 처리하시겠습니까?');
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
await adminClient.users.forceDelete.mutate({ userId: userResult.value.id });
|
|
userResult.value = null;
|
|
forceDeleteStatus.value = '강제 탈퇴 완료';
|
|
} catch (error) {
|
|
forceDeleteStatus.value = '강제 탈퇴 실패';
|
|
}
|
|
};
|
|
|
|
const createLocalAccount = async () => {
|
|
localAccountStatus.value = '';
|
|
localAccountResult.value = '';
|
|
if (!localAccountEnabled.value) {
|
|
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
|
|
return;
|
|
}
|
|
const username = localAccountForm.value.username.trim();
|
|
const password = localAccountForm.value.password.trim();
|
|
const displayName = localAccountForm.value.displayName.trim();
|
|
if (!username || !password) {
|
|
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
|
|
return;
|
|
}
|
|
localAccountLoading.value = true;
|
|
try {
|
|
const result = await adminClient.users.createLocal.mutate({
|
|
username,
|
|
password,
|
|
displayName: displayName || undefined,
|
|
});
|
|
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
|
|
localAccountStatus.value = '로컬 계정 생성 완료';
|
|
localAccountForm.value = {
|
|
username: result.user.username,
|
|
password: '',
|
|
displayName: '',
|
|
};
|
|
userLookupMode.value = 'username';
|
|
userLookupValue.value = result.user.username;
|
|
await lookupUser();
|
|
} catch (error) {
|
|
localAccountStatus.value = '로컬 계정 생성 실패';
|
|
} finally {
|
|
localAccountLoading.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
void loadLocalAccountStatus();
|
|
void loadNotice();
|
|
void loadProfiles();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<DefaultLayout>
|
|
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
|
|
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<div class="space-y-2">
|
|
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
|
|
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
|
|
</div>
|
|
<RouterLink
|
|
to="/admin/server-operations"
|
|
class="rounded bg-amber-500 px-4 py-2 text-center text-sm font-bold text-black hover:bg-amber-400"
|
|
>
|
|
서버 배포 · 시나리오 초기화
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
|
|
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
|
|
</div>
|
|
<div class="flex flex-col md:flex-row gap-3">
|
|
<input
|
|
v-model="sessionToken"
|
|
type="password"
|
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
|
placeholder="세션 토큰 입력"
|
|
/>
|
|
<button
|
|
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
|
|
@click="saveSessionToken"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid lg:grid-cols-2 gap-8">
|
|
<section class="space-y-6">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h3 class="text-lg font-semibold">유저 관리</h3>
|
|
<form class="space-y-3" @submit.prevent="lookupUser">
|
|
<div class="flex flex-col md:flex-row gap-2">
|
|
<select
|
|
v-model="userLookupMode"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
>
|
|
<option value="username">계정명</option>
|
|
<option value="email">이메일</option>
|
|
<option value="id">UUID</option>
|
|
</select>
|
|
<input
|
|
v-model="userLookupValue"
|
|
type="text"
|
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
|
placeholder="검색 값 입력"
|
|
/>
|
|
<button
|
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="userLoading"
|
|
>
|
|
조회
|
|
</button>
|
|
</div>
|
|
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
|
|
</form>
|
|
|
|
<div v-if="userResult" class="bg-zinc-950 border border-zinc-800 rounded p-4 space-y-2">
|
|
<div class="flex justify-between items-center">
|
|
<div class="text-sm font-semibold">{{ userResult.username }}</div>
|
|
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
|
|
</div>
|
|
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
|
|
<div class="text-xs text-zinc-400">권한: {{ userResult.roles.join(', ') || '-' }}</div>
|
|
<div class="text-xs text-zinc-500">
|
|
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
|
|
</div>
|
|
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
|
|
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
|
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
|
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
|
<span class="text-xs text-zinc-500"> ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} </span>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.</div>
|
|
<div class="grid gap-2">
|
|
<input
|
|
v-model="localAccountForm.username"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="아이디"
|
|
:disabled="!localAccountEnabled || localAccountLoading"
|
|
/>
|
|
<input
|
|
v-model="localAccountForm.password"
|
|
type="password"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="비밀번호"
|
|
:disabled="!localAccountEnabled || localAccountLoading"
|
|
/>
|
|
<input
|
|
v-model="localAccountForm.displayName"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="표시명 (선택)"
|
|
:disabled="!localAccountEnabled || localAccountLoading"
|
|
/>
|
|
<button
|
|
class="bg-cyan-600 hover:bg-cyan-500 text-black font-semibold px-4 py-2 rounded"
|
|
:disabled="!localAccountEnabled || localAccountLoading"
|
|
@click="createLocalAccount"
|
|
>
|
|
계정 생성
|
|
</button>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">{{ localAccountStatus }}</div>
|
|
<div v-if="localAccountResult" class="text-xs text-emerald-400">
|
|
{{ localAccountResult }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
|
|
<div class="flex flex-col md:flex-row gap-2">
|
|
<input
|
|
v-model="passwordInput"
|
|
type="text"
|
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="직접 지정 시 입력"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<button
|
|
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="resetUserPassword"
|
|
>
|
|
초기화
|
|
</button>
|
|
</div>
|
|
<div class="text-xs text-zinc-400">임시 비밀번호: {{ passwordResult || '-' }}</div>
|
|
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h4 class="text-base font-semibold">특수 권한 부여</h4>
|
|
<div class="flex flex-col md:flex-row gap-2">
|
|
<select
|
|
v-model="rolesMode"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
:disabled="!hasUser"
|
|
>
|
|
<option value="grant">추가</option>
|
|
<option value="revoke">제거</option>
|
|
<option value="set">덮어쓰기</option>
|
|
</select>
|
|
<input
|
|
v-model="rolesInput"
|
|
type="text"
|
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="admin, moderator 등"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<button
|
|
class="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="updateUserRoles"
|
|
>
|
|
적용
|
|
</button>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h4 class="text-base font-semibold">유저 차단</h4>
|
|
<div class="flex flex-col gap-2">
|
|
<input
|
|
v-model="banUntil"
|
|
type="datetime-local"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<input
|
|
v-model="banReason"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="사유"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="applyBan"
|
|
>
|
|
차단 설정
|
|
</button>
|
|
<button
|
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="clearBan"
|
|
>
|
|
차단 해제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">{{ banStatus }}</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
|
|
<div class="grid gap-2">
|
|
<input
|
|
v-model="restrictionProfile"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="profile:scenario"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<input
|
|
v-model="restrictionFeatures"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="login, message 등"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<input
|
|
v-model="restrictionUntil"
|
|
type="datetime-local"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<input
|
|
v-model="restrictionReason"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="사유"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<input
|
|
v-model="restrictionNotes"
|
|
type="text"
|
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="메모"
|
|
:disabled="!hasUser"
|
|
/>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="applyRestriction"
|
|
>
|
|
제재 적용
|
|
</button>
|
|
<button
|
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="clearRestriction"
|
|
>
|
|
제재 해제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">{{ restrictionStatus }}</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h4 class="text-base font-semibold">프로필 아이콘 초기화</h4>
|
|
<button
|
|
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="resetProfileIcon"
|
|
>
|
|
아이콘 초기화 요청
|
|
</button>
|
|
<div class="text-xs text-zinc-500">{{ profileIconStatus }}</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<h4 class="text-base font-semibold text-red-400">강제 탈퇴</h4>
|
|
<button
|
|
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="!hasUser"
|
|
@click="forceDeleteUser"
|
|
>
|
|
강제 탈퇴 처리
|
|
</button>
|
|
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="space-y-6">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
|
<div class="flex justify-between items-center">
|
|
<h3 class="text-lg font-semibold">서버 공지</h3>
|
|
<span class="text-xs text-zinc-500">{{ noticeStatus }}</span>
|
|
</div>
|
|
<textarea
|
|
v-model="noticeDraft"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-sm text-white min-h-[140px]"
|
|
placeholder="로비 공지 입력"
|
|
/>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="noticeLoading"
|
|
@click="saveNotice"
|
|
>
|
|
공지 저장
|
|
</button>
|
|
<button
|
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
|
|
:disabled="noticeLoading"
|
|
@click="loadNotice"
|
|
>
|
|
다시 불러오기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">서버별 관리</h3>
|
|
<button
|
|
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
|
|
:disabled="profilesLoading"
|
|
@click="loadProfiles"
|
|
>
|
|
새로고침
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="profile in profiles"
|
|
:key="profile.profileName"
|
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
|
>
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
|
<div>
|
|
<div class="text-base font-semibold">
|
|
{{ profile.profileName }} ({{ profile.profile }})
|
|
</div>
|
|
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
|
|
</div>
|
|
<div class="text-xs text-zinc-400">
|
|
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
|
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
|
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
|
|
|
|
<div class="grid md:grid-cols-2 gap-3">
|
|
<div class="space-y-2">
|
|
<label class="text-xs text-zinc-400">표시명</label>
|
|
<input
|
|
v-model="profileEdits[profile.profileName].korName"
|
|
type="text"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
<label class="text-xs text-zinc-400">색상</label>
|
|
<input
|
|
v-model="profileEdits[profile.profileName].color"
|
|
type="text"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
<label class="text-xs text-zinc-400">인게임 공지</label>
|
|
<textarea
|
|
v-model="profileEdits[profile.profileName].inGameNotice"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded p-2 text-sm text-white min-h-[80px]"
|
|
/>
|
|
<label class="text-xs text-zinc-400">프로필 이미지</label>
|
|
<input
|
|
v-model="profileEdits[profile.profileName].profileImageUrl"
|
|
type="text"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
<label class="text-xs text-zinc-400">다음 시즌 번호</label>
|
|
<input
|
|
v-model="profileEdits[profile.profileName].nextSeasonIdx"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="예: 12"
|
|
/>
|
|
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div>
|
|
<button
|
|
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
|
|
@click="updateProfileMeta(profile.profileName)"
|
|
>
|
|
메타 저장
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="text-xs text-zinc-400">특수 동작 메모</label>
|
|
<input
|
|
v-model="profileActions[profile.profileName].reason"
|
|
type="text"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="사유 / 메모"
|
|
/>
|
|
<label class="text-xs text-zinc-400">가속/연기 (분)</label>
|
|
<input
|
|
v-model="profileActions[profile.profileName].durationMinutes"
|
|
type="number"
|
|
min="1"
|
|
max="1440"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
<label class="text-xs text-zinc-400">리셋 예약</label>
|
|
<input
|
|
v-model="profileActions[profile.profileName].scheduledAt"
|
|
type="datetime-local"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
<div class="grid grid-cols-2 gap-2 pt-2">
|
|
<button
|
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'RESUME')"
|
|
>
|
|
재개
|
|
</button>
|
|
<button
|
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'PAUSE')"
|
|
>
|
|
일시정지
|
|
</button>
|
|
<button
|
|
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'STOP')"
|
|
>
|
|
중지
|
|
</button>
|
|
<button
|
|
class="bg-orange-600 hover:bg-orange-500 text-black font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'ACCELERATE')"
|
|
>
|
|
가속
|
|
</button>
|
|
<button
|
|
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'DELAY')"
|
|
>
|
|
연기
|
|
</button>
|
|
<button
|
|
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'RESET_NOW')"
|
|
>
|
|
즉시 리셋
|
|
</button>
|
|
<button
|
|
class="bg-purple-800 hover:bg-purple-700 text-white font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'RESET_SCHEDULED')"
|
|
>
|
|
리셋 예약
|
|
</button>
|
|
<button
|
|
class="bg-teal-700 hover:bg-teal-600 text-white font-semibold px-3 py-2 rounded"
|
|
@click="requestProfileAction(profile.profileName, 'OPEN_SURVEY')"
|
|
>
|
|
설문 오픈
|
|
</button>
|
|
<button
|
|
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
|
|
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
|
|
>
|
|
서버 폐쇄
|
|
</button>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">
|
|
{{ profileActionStatus[profile.profileName] }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="profileInstalls[profile.profileName]"
|
|
class="border-t border-zinc-800 pt-4 space-y-3"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-semibold">설치/리셋</h4>
|
|
<span class="text-xs text-zinc-500">{{
|
|
profileInstallStatus[profile.profileName]
|
|
}}</span>
|
|
</div>
|
|
<div class="grid lg:grid-cols-2 gap-4">
|
|
<div class="space-y-3">
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">Git ref (선택)</label>
|
|
<div class="flex gap-2">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].gitRef"
|
|
type="text"
|
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="main / v1.0.0 / abc123"
|
|
/>
|
|
<button
|
|
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-2 rounded"
|
|
:disabled="getScenarioLoading(profile.profileName)"
|
|
@click="loadScenariosForProfile(profile.profileName)"
|
|
>
|
|
불러오기
|
|
</button>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">
|
|
비워두면 현재 저장소 기준으로 불러옵니다.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">시나리오 선택</label>
|
|
<select
|
|
v-model.number="profileInstalls[profile.profileName].scenarioId"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
:disabled="getScenarioLoading(profile.profileName)"
|
|
>
|
|
<option v-if="getScenarioLoading(profile.profileName)" disabled>
|
|
불러오는 중...
|
|
</option>
|
|
<template
|
|
v-for="(items, group) in getScenarioGroups(profile.profileName)"
|
|
:key="group"
|
|
>
|
|
<optgroup :label="group">
|
|
<option
|
|
v-for="scenario in items"
|
|
:key="scenario.id"
|
|
:value="scenario.id"
|
|
>
|
|
{{ scenario.title }}
|
|
</option>
|
|
</optgroup>
|
|
</template>
|
|
</select>
|
|
<div
|
|
v-if="getScenarioStatus(profile.profileName)"
|
|
class="text-xs text-red-400"
|
|
>
|
|
{{ getScenarioStatus(profile.profileName) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">턴 시간(분)</label>
|
|
<select
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].turnTermMinutes
|
|
"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
>
|
|
<option v-for="term in turnTermOptions" :key="term" :value="term">
|
|
{{ term }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">시간 동기화</label>
|
|
<div class="flex gap-2">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].sync"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="true"
|
|
/>
|
|
Y
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].sync"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="false"
|
|
/>
|
|
N
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">NPC 상성</label>
|
|
<div class="flex gap-2">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="profileInstalls[profile.profileName].fiction"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="0"
|
|
/>
|
|
연의
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="profileInstalls[profile.profileName].fiction"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="1"
|
|
/>
|
|
가상
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">확장 NPC</label>
|
|
<div class="flex gap-2">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].extend"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="true"
|
|
/>
|
|
포함
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].extend"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="false"
|
|
/>
|
|
미포함
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">임관 모드</label>
|
|
<div class="flex gap-2">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].joinMode"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
value="full"
|
|
/>
|
|
일반
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].joinMode"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
value="onlyRandom"
|
|
/>
|
|
랜덤 임관
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">장수 임의 생성</label>
|
|
<div class="flex gap-2 flex-wrap">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].blockGeneralCreate
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="0"
|
|
/>
|
|
가능
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].blockGeneralCreate
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="2"
|
|
/>
|
|
장수명 무작위
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].blockGeneralCreate
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="1"
|
|
/>
|
|
불가
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">NPC 빙의</label>
|
|
<div class="flex gap-2 flex-wrap">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="profileInstalls[profile.profileName].npcMode"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="1"
|
|
/>
|
|
가능
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="profileInstalls[profile.profileName].npcMode"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="0"
|
|
/>
|
|
불가
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="profileInstalls[profile.profileName].npcMode"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="2"
|
|
/>
|
|
선택 생성 가능
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">이미지 표기</label>
|
|
<div class="flex gap-2 flex-wrap">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].showImgLevel
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="0"
|
|
/>
|
|
안함
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].showImgLevel
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="1"
|
|
/>
|
|
전콘
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].showImgLevel
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="2"
|
|
/>
|
|
전콘, 병종
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model.number="
|
|
profileInstalls[profile.profileName].showImgLevel
|
|
"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="3"
|
|
/>
|
|
전콘, 병종, NPC
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">토너먼트 자동 시작</label>
|
|
<div class="flex gap-2">
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].tournamentTrig"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="false"
|
|
/>
|
|
수동
|
|
</label>
|
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].tournamentTrig"
|
|
class="accent-yellow-500"
|
|
type="radio"
|
|
:value="true"
|
|
/>
|
|
자동
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">휴식 턴 자동 행동</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<label
|
|
v-for="option in autorunOptionLabels"
|
|
:key="option.key"
|
|
class="flex items-center gap-1 text-xs text-zinc-300"
|
|
>
|
|
<input
|
|
v-model="
|
|
profileInstalls[profile.profileName].autorunUserOptions[
|
|
option.key
|
|
]
|
|
"
|
|
class="accent-yellow-500"
|
|
type="checkbox"
|
|
/>
|
|
{{ option.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">유효 시간(분)</label>
|
|
<select
|
|
v-model.number="profileInstalls[profile.profileName].autorunUserMinutes"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
>
|
|
<option :value="0">꺼짐</option>
|
|
<option :value="43200">항상</option>
|
|
<option :value="10">10분</option>
|
|
<option :value="20">20분</option>
|
|
<option :value="30">30분</option>
|
|
<option :value="60">1시간</option>
|
|
<option :value="120">2시간</option>
|
|
<option :value="180">3시간</option>
|
|
<option :value="240">4시간</option>
|
|
<option :value="360">6시간</option>
|
|
<option :value="480">8시간</option>
|
|
<option :value="600">10시간</option>
|
|
<option :value="720">12시간</option>
|
|
<option :value="1440">24시간</option>
|
|
<option :value="2160">36시간</option>
|
|
<option :value="2880">48시간</option>
|
|
<option :value="3600">60시간</option>
|
|
<option :value="4320">72시간</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">오픈 예약</label>
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].openAt"
|
|
type="datetime-local"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">가오픈 예약</label>
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].preopenAt"
|
|
type="datetime-local"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<label class="text-xs text-zinc-400">설치 메모</label>
|
|
<input
|
|
v-model="profileInstalls[profile.profileName].reason"
|
|
type="text"
|
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
|
placeholder="사유/메모"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded w-full"
|
|
@click="requestInstall(profile.profileName)"
|
|
>
|
|
설치 적용
|
|
</button>
|
|
|
|
<div
|
|
v-if="getScenarioPreview(profile.profileName)"
|
|
class="bg-zinc-950 border border-zinc-800 rounded p-3 text-xs text-zinc-300 space-y-2"
|
|
>
|
|
<div class="font-semibold text-zinc-200">
|
|
{{ getScenarioPreview(profile.profileName)?.title }}
|
|
</div>
|
|
<div>
|
|
시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년
|
|
</div>
|
|
<div>
|
|
NPC: {{ getScenarioPreview(profile.profileName)?.npcCount }}명
|
|
<span v-if="getScenarioPreview(profile.profileName)?.npcExCount"
|
|
>+{{ getScenarioPreview(profile.profileName)?.npcExCount }}명</span
|
|
>
|
|
<span v-if="getScenarioPreview(profile.profileName)?.npcNeutralCount">
|
|
/ 중립
|
|
{{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
|
|
</span>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="text-zinc-400">국가</div>
|
|
<div class="space-y-1">
|
|
<div
|
|
v-for="nation in getScenarioPreview(profile.profileName)
|
|
?.nations ?? []"
|
|
:key="nation.id"
|
|
class="text-[11px]"
|
|
>
|
|
<span :style="{ color: nation.color }">{{ nation.name }}</span>
|
|
{{ nation.generals }}명
|
|
<span v-if="nation.generalsEx">(+{{ nation.generalsEx }})</span>
|
|
<span class="text-zinc-500"
|
|
>· {{ nation.cities.join(', ') }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
|
|
{{ profileActionStatus.global }}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</DefaultLayout>
|
|
</template>
|