This commit is contained in:
2026-04-07 14:50:23 +09:00
commit b4e485502b
4778 changed files with 2017091 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,157 @@
<template>
<div
v-if="
(attachments.length > 0 && readOnlyFlag == true) ||
readOnlyFlag == false
"
class="mt-3 sm:col-span-2"
>
<dt class="text-sm font-medium text-gray-500">
<p v-if="readOnlyFlag" class="font-medium">첨부된 파일</p>
<a
v-else
href="javascript:void(0)"
class="font-medium text-blue-600 hover:text-blue-500"
@click="addFiles()"
>
파일 첨부
</a>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<ul
role="list"
class="border border-gray-200 rounded-md divide-y divide-gray-200"
>
<li
v-for="attachment in attachments"
:key="attachment.name"
class="pl-3 pr-4 py-3 flex items-center justify-between text-sm"
>
<div class="w-0 flex-1 flex items-center">
<PaperClipIcon
class="flex-shrink-0 h-5 w-5 text-gray-400"
aria-hidden="true"
/>
<span class="ml-2 flex-1 w-0 truncate">
{{ attachment.name }} {{ ', ' }}
{{ _utils.formatBytes(attachment.size, 2) }}
</span>
</div>
<div v-if="readOnlyFlag" class="ml-4 flex-shrink-0">
<a
:href="
_crossCtl.config['API_BASE_URL'].replace(
'/api/',
''
) + attachment.localUrl
"
:download="attachment.name"
target="_blank"
class="font-medium text-blue-600 hover:text-blue-500"
>
다운로드
</a>
</div>
<div v-else class="ml-4 flex-shrink-0">
<a
href="javascript:void(0)"
class="font-medium text-red-600 hover:text-red-500"
@click="rmvFile(attachment)"
>
삭제
</a>
</div>
</li>
</ul>
</dd>
</div>
</template>
<script setup lang="ts">
import { stringLiteral } from '@babel/types';
import { PaperClipIcon } from '@heroicons/vue/24/solid';
const props = defineProps({
attachments: {
type: Array<{ name: ''; localUrl: ''; size: 0; type: 'text/html' }>,
default: [],
},
readOnlyFlag: { type: Boolean, required: true },
updateAttachments: {
type: Function,
required: false,
default: () => {
void 0;
},
},
boardId: { type: String, required: false, default: null },
secureEnabled: { type: Boolean, required: false, default: false },
});
// console.log('huk props = ', props);
const currentDomain = ref('');
if (process.client) {
currentDomain.value = _utils.getDomain(window.location.href);
console.log('currentDomain.value=', currentDomain.value);
} else {
console.log('server?');
}
function addFiles() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('multiple', 'multiple');
input.click();
// Listen upload local image and save to server
input.onchange = () => {
if (input.files.length > 0) {
console.log('we got file(s) : ', input.files);
uploadFiles(input.files);
}
};
}
async function uploadFiles(files) {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('upload-file', files[i], files[i].name);
}
formData.append('target', 'just');
if (props.boardId != null) {
formData.append('attachedTo', props.boardId);
}
formData.append('secureEnabled', props.secureEnabled.toString());
console.log('formData=', formData);
const responseJson = await _crossCtl.doUpload('just', formData);
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
props.updateAttachments([
...props.attachments,
...responseJson['files'],
]);
} else {
alert('upload error : ' + responseJson['responseMessage']);
}
}
function rmvFile(target) {
const tmpAry = [];
for (let i = 0; i < props.attachments.length; i++) {
if (props.attachments[i] != target) {
tmpAry.push(props.attachments[i]);
}
}
props.updateAttachments(tmpAry);
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div>
<span
v-if="photoUrl == ''"
class="inline-block rounded-full overflow-hidden bg-gray-100"
:style="'height:' + photoSize + 'rem; width:' + photoSize + 'rem'"
>
<svg
class="h-full w-full text-gray-300"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
</span>
<img
v-else
class="'inline-block rounded-full border'"
:style="'height:' + photoSize + 'rem; width:' + photoSize + 'rem'"
:src="photoUrl"
:alt="imageAlt"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
imageSize: { type: Number, default: 12 },
imageAlt: { type: String, default: '' },
imageUrl: { type: String, default: '' },
});
const photoSize = ref(props.imageSize);
const photoUrl = ref(props.imageUrl);
const imageAlt = ref(props.imageAlt);
</script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,139 @@
<template>
<div class="flex flex-col">
<div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div
class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8"
>
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
v-for="(heading, index) in headings"
:key="index"
:width="
heading['widthRatio'] != ''
? heading['widthRatio'] + '%'
: '100%'
"
:class="
index == 0
? 'whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6 md:pl-0'
: 'whitespace-nowrap py-3.5 px-3 text-left text-sm font-semibold text-gray-900'
"
scope="col"
>
{{ heading['title'] }}
</th>
<th
v-for="(action, index) in actions"
:key="index + headings.length"
:width="actions.length * 1"
class="relative py-3.5 pl-3 pr-4 sm:pr-6 md:pr-0"
scope="col"
>
<span class="sr-only">{{ action }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(item, itemIndex) in data" :key="itemIndex">
<td
v-for="(heading, index) in headings"
:key="index"
:class="
index == 0
? 'max-w-0 whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 md:pl-0'
: 'w-full whitespace-nowrap py-4 px-3 text-sm text-gray-500'
"
>
<div v-if="index == 0" class="">
<a
href="javascript:void(0)"
class="hover:bg-gray-50"
@click="
doAction('보기', item[actionKey])
"
><div class="truncate">
{{
columnFilter
? columnFilter(
heading['key'],
item[heading['key']]
)
: item[heading['key']]
}}
</div>
</a>
</div>
<div v-else>
{{
columnFilter
? columnFilter(
heading['key'],
item[heading['key']]
)
: item[heading['key']]
}}
</div>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 md:pr-0"
>
<a
v-for="(action, index) in actions"
:key="index"
href="javascript:void(0)"
:class="index != 0 ? 'ml-3' : ''"
class="text-indigo-600 hover:text-indigo-900"
@click="doAction(action, item[actionKey])"
>{{ action
}}<span class="sr-only"
>, {{ item['serial'] }}</span
></a
>
</td>
</tr>
<tr v-if="data.length == 0">
<td
:colspan="headings.length"
class="whitespace-nowrap py-4 px-3 text-sm text-gray-500"
>
{{ 'No Data to display...' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const people = [
{
name: 'Lindsay Walton',
title: 'Front-end Developer',
email: 'lindsay.walton@example.com 아주 긴 아지 긴 아주 긴 이런 저런 긴 길 저런 길 갈 골 갈',
role: 'Member',
},
// More people...
];
const props = defineProps({
headings: { type: Array<object>, required: true },
actions: { type: Array<string>, default: [] },
data: { type: Array<object>, required: true },
actionKey: { type: String, default: 'serial' },
columnFilter: {
type: Function,
default: (key: string, val: string) => val,
},
doAction: {
type: Function,
default: (key: string) => {
return key;
},
},
});
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div
class="px-4 py-5 sm:px-6 flex justify-between items-center flex-wrap sm:flex-nowrap"
>
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ title }}
</h3>
</div>
<div>
<div class="flex items-start">
<div
class="flex-shrink-0 inline-flex rounded-full border-2 border-white"
>
<BaseAvater1
:image-size="2"
:image-url="profileUrl"
:image-alt="name + ' 프로필 사진'"
/>
</div>
<div class="ml-4">
<div class="text-base font-medium">{{ name }}</div>
<div class="text-base text-xs">{{ created }}</div>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-4 py-5 sm:px-6 min-h-[15rem]">
<div
ref="myCoolDiv"
class="prose mt-3 space-y-0 max-w-none"
v-html="content"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps({
name: { type: String, required: true },
profileUrl: { type: String, required: true },
title: { type: String, required: true },
content: { type: String, required: true },
flags: { type: Array<string>, default: [] },
attachments: { type: Array<string>, default: [] },
hitCount: { type: Number, required: true },
likeCount: { type: Number, required: true },
dislikeCount: { type: Number, required: true },
commentCount: { type: Number, required: true },
reportCount: { type: Number, required: true },
status: { type: Number, required: true },
updated: { type: String, required: true },
created: { type: String, required: true },
});
const myCoolDiv = ref(null);
watch(myCoolDiv, () => {
if (myCoolDiv.value.childNodes[0].tagName == 'IFRAME') {
myCoolDiv.value.childNodes[0].classList.add('xl:w-[1243px]');
myCoolDiv.value.childNodes[0].classList.add('xl:h-[621.5px]');
myCoolDiv.value.childNodes[0].classList.add('lg:w-[1243px]');
myCoolDiv.value.childNodes[0].classList.add('lg:h-[621.5px]');
myCoolDiv.value.childNodes[0].classList.add('md:w-[900px]');
myCoolDiv.value.childNodes[0].classList.add('md:h-[400px]');
myCoolDiv.value.childNodes[0].classList.add('w-[310px]');
myCoolDiv.value.childNodes[0].classList.add('h-[250px]');
}
});
</script>

View File

@@ -0,0 +1,372 @@
<template>
<!-- Comments-->
<section aria-labelledby="notes-title">
<div
class="border border-gray-300 shadow sm:rounded-lg sm:overflow-hidden"
>
<div class="divide-y divide-gray-200">
<div class="px-4 py-5 sm:px-6">
<h2
id="notes-title"
class="text-lg font-medium text-gray-900"
>
{{ title }}
</h2>
</div>
<div class="px-4 py-6 sm:px-6">
<ul role="list" class="space-y-8">
<li
v-for="commentItem in listData"
:key="commentItem.serial"
>
<div class="flex space-x-3">
<div class="flex-shrink-0 items-center">
<BaseAvater1
:image-size="2"
:image-url="commentItem.profile_url"
/>
</div>
<div>
<div class="text-sm">
<a
:href="
'/user/profile/' +
commentItem.pid
"
class="font-medium text-gray-900"
>{{ commentItem.nick }}</a
>
</div>
<div
class="mt-1 text-sm text-gray-700 break-all"
>
<p>{{ commentItem.comment }}</p>
</div>
<div class="mt-2 text-sm space-x-2">
<span
class="text-gray-500 font-medium"
>{{
$dayjs(
commentItem.created
).fromNow()
}}</span
>
{{ ' ' }}
<span
v-if="!readOnlyFlag"
class="text-gray-500 font-medium"
>&middot;</span
>
{{ ' ' }}
<span
v-if="commentItem.like_count != 0"
class="text-gray-500 font-medium"
>{{
'좋아요 ' +
_utils.formatNumberInBytesStyle(
commentItem.like_count,
0
) +
'개'
}}</span
>
<span
v-if="
commentItem.dislike_count != 0
"
class="text-gray-500 font-medium"
>{{
'싫어요 ' +
_utils.formatNumberInBytesStyle(
commentItem.dislike_count,
0
) +
'개'
}}</span
>
<span
v-if="commentItem.report_count != 0"
class="text-gray-500 font-medium"
>{{
'신고 ' +
_utils.formatNumberInBytesStyle(
commentItem.report_count,
0
) +
'개'
}}</span
>
<button
v-if="
commentItem.myFlag == true &&
!readOnlyFlag
"
type="button"
class="text-gray-900 font-medium"
@click="
doAction(
'delete',
commentItem.cid
)
"
>
삭제
</button>
<button
v-if="!readOnlyFlag"
type="button"
class="text-gray-900 font-medium"
@click="
doAction(
'like',
commentItem.cid
)
"
>
좋아요
</button>
<button
v-if="!readOnlyFlag"
type="button"
class="text-gray-900 font-medium"
@click="
doAction(
'dislike',
commentItem.cid
)
"
>
싫어요
</button>
<button
v-if="!readOnlyFlag"
type="button"
class="text-gray-900 font-medium"
@click="
doAction(
'report',
commentItem.cid
)
"
>
신고
</button>
<button
v-if="!readOnlyFlag"
type="button"
class="text-gray-900 font-medium"
@click="
doAction(
'cancel',
commentItem.cid
)
"
>
신고 취소
</button>
</div>
</div>
</div>
</li>
<li v-if="listData.length == 0">
<div>등록된 댓글이 없습니다.</div>
</li>
</ul>
</div>
<BasePagination1
class="mb-5 px-4 sm:px-6"
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
<div v-if="!readOnlyFlag" class="bg-gray-50 px-4 py-6 sm:px-6">
<div class="flex space-x-3">
<div class="flex-shrink-0">
<BaseUserProfileImage :image-size="2" />
</div>
<div class="min-w-0 flex-1">
<form>
<div>
<label for="comment" class="sr-only">{{
title
}}</label>
<textarea
id="comment"
v-model="comment"
style="resize: none"
name="comment"
rows="3"
class="shadow-sm block w-full focus:ring-blue-500 focus:border-blue-500 sm:text-sm border border-gray-300 rounded-md"
placeholder="댓글 내용을 입력하세요."
/>
</div>
<div class="mt-3 flex items-center justify-between">
<a
href="#"
class="group inline-flex items-start text-sm space-x-2 text-gray-500 hover:text-gray-900"
>
<QuestionMarkCircleIcon
class="flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span>
모욕적인 표현이 포함된 댓글은 등록이
거부될 수 있습니다.
</span>
</a>
<button
type="button"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="addComment()"
>
댓글 등록
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { QuestionMarkCircleIcon } from '@heroicons/vue/24/solid';
const comment = ref('');
const props = defineProps({
tid: { type: String, required: true },
title: { type: String, default: '댓글' },
readOnlyFlag: { type: Boolean, required: true },
});
// console.log('huk props = ', props);
const title = ref(props.title);
console.log('title = ', title.value);
const listData = ref([]);
const readOnlyFlag = ref(props.readOnlyFlag);
const totalPageCount = ref(1);
const currentPageNumber = ref(Number.MAX_SAFE_INTEGER);
const pageSize = ref(3);
const recordsTotal = ref(0);
function pageMove(targetPageIdex) {
// console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function addComment() {
if (comment.value.trim() != '') {
const responseJson = await _crossCtl.doComm('insert', 'comment', {
hero: props.tid,
comment: comment.value,
for: 'board',
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
console.log('responseJson=', responseJson);
comment.value = '';
refresh();
}
}
}
async function refresh() {
if (props.tid != '') {
const responseJson = await _crossCtl.doComm('list', 'comment:active', {
hero: props.tid,
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
console.log('responseJson=', responseJson);
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
}
if (props.tid != '' && _crossCtl.isAuthenticated) {
const responseJson = await _crossCtl.doComm('list', 'like', {
hero: props.tid,
start: 0,
length: -1,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
console.log('like responseJson=', responseJson);
refresh();
}
}
async function doAction(tag, target) {
console.log('in doAction(), tag =', tag, ', target =', target);
let tmpResponseJson = null;
switch (tag) {
case 'like':
case 'dislike':
tmpResponseJson = await _crossCtl.doComm('update', 'like', {
domain: props.tid,
hero: target,
for: 'comment',
tag: tag,
});
refresh();
break;
case 'report':
tmpResponseJson = await _crossCtl.doComm('update', 'report', {
domain: props.tid,
hero: target,
for: 'comment',
tag: tag,
});
refresh();
break;
case 'cancel':
tmpResponseJson = await _crossCtl.doComm('update', 'report', {
domain: props.tid,
hero: target,
for: 'comment',
tag: tag,
});
refresh();
break;
case 'delete':
tmpResponseJson = await _crossCtl.doComm('delete', 'comment', {
hero: target,
tid: props.tid,
from: 'board',
});
refresh();
break;
}
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<dt class="text-lg leading-6 font-medium text-gray-900">
{{ item.question }}
</dt>
<dd
class="mt-2 text-base text-gray-500"
style="
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
"
v-html="item.answer.replace(/(?:\r\n|\r|\n)/g, '<br />')"
></dd>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
item: { type: Object, required: true },
});
</script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="pb-8 px-0 sm:px-0 lg:px-0">
<div
class="-mx-4 mt-8 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:-mx-6 md:mx-0 md:rounded-lg"
>
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th
v-for="(heading, index) in headings"
:key="index"
scope="col"
:class="heading['class']"
>
{{ heading['title'] }}
</th>
<th
v-for="(action, index) in actions"
:key="index + headings.length"
scope="col"
class="relative py-3.5 pl-3 pr-4 sm:pr-6"
>
<span class="sr-only">{{ action }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-if="data.length == 0">
<td>
<div class="py-3.5 pl-3 pr-4 sm:pr-6">
{{ noDataMessage }}
</div>
</td>
</tr>
<tr v-for="(item, itemIndex) in data" :key="itemIndex">
<td
v-for="(heading, index) in headings"
:key="index"
:class="heading['subClass']"
>
{{
columnFilter
? columnFilter(
heading['key'],
item[heading['key']]
)
: item[heading['key']]
}}
<dl
v-for="(hItem, hItemIndex) in heading[
'hiddenInfo'
]['dts']"
:key="hItemIndex"
:class="heading['hiddenInfo']['headClass']"
>
<dt
:class="
heading['hiddenInfo']['dts'][
hItemIndex
]['class']
"
>
{{
heading['hiddenInfo']['dts'][
hItemIndex
]['title']
}}
</dt>
<dd
:class="
heading['hiddenInfo']['dds'][
hItemIndex
]['class']
"
>
{{
columnFilter
? columnFilter(
heading['hiddenInfo']['dds'][
hItemIndex
]['key'],
item[
heading['hiddenInfo'][
'dds'
][hItemIndex]['key']
]
)
: item[
heading['hiddenInfo']['dds'][
hItemIndex
]['key']
]
}}
</dd>
</dl>
</td>
<td
class="py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<a
v-for="(action, index) in actions"
:key="index"
href="javascript:void(0)"
class="text-indigo-600 hover:text-indigo-900"
@click="doAction(action, item[actionKey])"
>{{ action
}}<span class="sr-only"
>, {{ item['serial'] }}</span
></a
>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
headings: { type: Array<object>, required: true },
actions: { type: Array<string>, default: [] },
keys: { type: Array<string>, default: [] },
data: { type: Array<object>, required: true },
actionKey: { type: String, default: 'serial' },
columnFilter: {
type: Function,
default: (key: string, val: string) => val,
},
doAction: {
type: Function,
default: (key: string) => {
return key;
},
},
noDataMessage: { type: String, default: '검색된 데이터가 없습니다.' },
});
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div class="flex flex-col overflow-hidden rounded-lg shadow-lg">
<div
v-if="isLoading"
class="flex flex-1 flex-col justify-between bg-white p-6"
>
<div class="flex-1 relative">
<div class="flex border-b-2 border-indigo-500 pb-2">
<p class="text-sm font-medium text-indigo-600">
<a
href="javascript:void(0)"
@click="pageMove(pageTitle[0].id, '')"
>{{
pageTitle[0]?.title ?? pageTitle[0]?.title
}}&nbsp;</a
>
</p>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6 text-indigo-500 absolute right-0 cursor-pointer"
@click="pageMove(pageTitle[0].id, '')"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v12m6-6H6"
/>
</svg>
</div>
<a
v-for="list in dataList"
:key="list.serial"
class="mt-2 block"
>
<p
class="mt-3 text-base text-gray-500 cursor-pointer"
@click="pageMove(pageTitle[0].id, list)"
>
{{
list.title.length >= 20
? list.title.substr(0, 20) + '...'
: list.title.substr(0, 20)
}}
</p>
</a>
</div>
</div>
<div v-else class="flex flex-1 flex-col justify-between bg-white p-6">
<div class="flex-1 relative">
<div class="flex pb-2 bg-sky-100 animate-pulse rounded-lg">
<p class="text-sm font-medium text-gray-600">
<a href="javascript:void(0)">&nbsp;</a>
</p>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="hidden w-6 h-6 text-indigo-500 absolute right-0 cursor-pointer"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v12m6-6H6"
/>
</svg>
</div>
<a class="mt-2 block">
<p
class="mt-3 bg-slate-100 animate-pulse rounded-lg cursor-pointer w-3/4"
>
&nbsp;
</p>
<p
class="mt-3 bg-slate-100 animate-pulse rounded-lg cursor-pointer w-full"
>
&nbsp;
</p>
<p
class="mt-3 bg-slate-100 animate-pulse rounded-lg cursor-pointer w-2/3"
>
&nbsp;
</p>
<p
class="mt-3 bg-slate-100 animate-pulse rounded-lg cursor-pointer w-3/4"
>
&nbsp;
</p>
<p
class="mt-3 bg-slate-100 animate-pulse rounded-lg cursor-pointer w-3/4"
>
&nbsp;
</p>
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
dataList: { type: Object, required: true },
pageTitle: { type: Object, required: true },
isLoading: { type: Boolean, default: false },
});
const bordPage = ['preach', 'programme', 'youtube'];
function pageMove(pageName, detail) {
if (pageName === 'notice') {
navigateTo('/support/' + pageName);
} else if (bordPage.includes(pageName) == true) {
if (detail == '') {
navigateTo('/board/' + pageName + '/list');
} else {
navigateTo('/board/' + pageName + '/view/' + detail.cid);
}
}
}
</script>

View File

@@ -0,0 +1,131 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<TransitionRoot as="template" :show="open">
<Dialog as="div" class="relative z-10" @close="open = false">
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
/>
<div class="fixed z-10 inset-0 overflow-y-auto">
<div
class="flex items-end sm:items-center justify-center min-h-full p-4 text-center sm:p-0"
>
<DialogPanel
class="relative bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
:class="
currentModalInfo['type'] == 'ok'
? 'mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10'
: currentModalInfo['type'] ==
'error'
? 'mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10'
: 'mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-gray-100 sm:mx-0 sm:h-10 sm:w-10'
"
>
<CheckIcon
v-if="currentModalInfo['type'] == 'ok'"
class="h-6 w-6 text-green-600"
aria-hidden="true"
/>
<ExclamationTriangleIcon
v-else-if="
currentModalInfo['type'] == 'error'
"
class="h-6 w-6 text-red-600"
aria-hidden="true"
/>
<ExclamationCircleIcon
v-else
class="h-6 w-6 text-gray-600"
aria-hidden="true"
/>
</div>
<div
class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"
>
<DialogTitle
as="h3"
class="text-lg leading-6 font-medium text-gray-900"
>
{{ currentModalInfo['title'] }}
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500">
{{ currentModalInfo['message'] }}
</p>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
type="button"
:class="
currentModalInfo['type'] == 'ok'
? 'w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm'
: currentModalInfo['type'] == 'error'
? 'w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm'
: 'w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:ml-3 sm:w-auto sm:text-sm'
"
@click="
_crossCtl.onModalClosed(
currentModalInfo.serial,
0
)
"
>
{{ currentModalInfo['btnTexts'][0] }}
</button>
<button
v-if="currentModalInfo['btnCount'] > 1"
ref="cancelButtonRef"
type="button"
:class="
currentModalInfo['type'] == 'ok'
? 'mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm'
: currentModalInfo['type'] == 'error'
? 'mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm'
: 'mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm'
"
@click="
_crossCtl.onModalClosed(
currentModalInfo.serial,
1
)
"
>
{{ currentModalInfo['btnTexts'][1] }}
</button>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionRoot,
} from '@headlessui/vue';
import {
CheckIcon,
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from '@heroicons/vue/24/outline';
const currentModalInfo = _crossCtl.currentModalInfo;
const open = computed(() => {
return currentModalInfo.value['serial'] !== -1;
});
</script>

View File

@@ -0,0 +1,222 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto bg-white">
<div class="flex-shrink-0 flex items-center px-4">
<a href="javascript:void(0)" @click="router.push('/')">
<!--{{ siteName }}-->
<img
src="/kiso_ci_1.png"
alt="KISO CI"
width="500"
height="600"
/>
</a>
</div>
<nav class="flex-1 mt-5 px-2 space-y-1 bg-white" aria-label="Sidebar">
<template v-for="item in currentMenu['main']" :key="item.idx">
<div v-if="!item.subs">
<a
href="javascript:void(0)"
:class="[
item['current']
? 'bg-gray-100 text-gray-900'
: 'bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-900',
'group w-full flex items-center pl-2 py-2 text-sm font-medium rounded-md',
]"
@click="handleItemClick(item)"
>
<component
:is="item['icon']"
:class="[
item['current']
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'mr-3 flex-shrink-0 h-6 w-6',
]"
aria-hidden="true"
/>
{{ item['title'] }}
</a>
</div>
<Disclosure
v-else
v-slot="{ open, close }"
as="div"
class="space-y-1"
>
<DisclosureButton
:class="[
item['current']
? 'bg-gray-100 text-gray-900'
: 'bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-900',
'group w-full flex items-center pl-2 pr-1 py-2 text-left text-sm font-medium rounded-md',
]"
>
<component
:is="item['icon']"
class="mr-3 flex-shrink-0 h-6 w-6 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span class="flex-1">
{{ item.title }}
</span>
<svg
:class="[
open
? 'text-gray-400 rotate-90'
: 'text-gray-300',
'ml-3 flex-shrink-0 h-5 w-5 transform group-hover:text-gray-400 transition-colors ease-in-out duration-150',
]"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path d="M6 6L14 10L6 14V6Z" fill="currentColor" />
</svg>
</DisclosureButton>
<DisclosurePanel class="space-y-1">
<a
v-for="subItem in item.subs"
:key="subItem['idx']"
href="javascript:void(0)"
:class="[
subItem['current']
? 'bg-gray-100 text-gray-900'
: 'bg-white text-gray-600 hover:text-gray-900 hover:bg-gray-50',
'group w-full flex items-center pl-10 pr-2 py-2 text-sm font-medium rounded-md ',
]"
@click="handleSubItemClick(subItem)"
>
{{ subItem['title'] }}
</a>
</DisclosurePanel>
<button
v-show="false"
:ref="(el) => (elements[item.idx] = el)"
:data-id="item.idx"
@click="doClose(close)"
></button>
<DisclosureStateEmitter
:show="open"
@show="hideOther(item.idx)"
/>
</Disclosure>
</template>
</nav>
</div>
<div class="flex-shrink-0 flex bg-white p-4">
<a href="#" class="flex-shrink-0 group block">
<div class="flex items-center">
<div class="space-y-1">
<h3
id="projects-headline"
class="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
></h3>
<div
class="space-y-1"
role="group"
aria-labelledby="projects-headline"
>
<a
href="javascript:void(0)"
class="group flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50"
@click="doSignInAndOut()"
>
<span class="truncate">
{{ isAuthenticated ? '로그아웃' : '로그인' }}
</span>
</a>
<a
v-for="item in currentMenu['sub']"
:key="item.idx"
href="javascript:void(0)"
class="group flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50"
@click="handleItemClick(item)"
>
<span class="truncate">
{{ item.title }}
</span>
</a>
</div>
</div>
</div>
</a>
</div>
</template>
<script setup lang="ts">
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
const props = defineProps({
onMove: {
type: Function,
required: true,
},
});
const isAuthenticated = _crossCtl.isAuthenticated;
const siteName = ref(_siteConfig.siteName);
const router = useRouter();
const currentMenu = _crossCtl.menu;
const elements = ref([]);
async function doSignInAndOut() {
if (_crossCtl.isAuthenticated.value) {
const response = await _crossCtl.doComm('signout', '', {});
console.log('response=', response);
if (response['responseCode'] == 200) {
_crossCtl.setUserInfo({
isAdmin: false,
isApproved: false,
isAuthenticated: false,
isHighLeveled: false,
isOp: false,
isSuperOp: false,
});
_crossCtl.setUserProfile({});
isAuthenticated.value = false;
window.location.href = '/';
} else {
alert(response['responseMessage']);
}
} else {
console.log("go user login");
navigateTo({
path: '/user/signin',
});
}
}
function handleItemClick(item) {
hideOther(Number.MAX_SAFE_INTEGER);
_crossCtl.moveToMenuItem(item);
props.onMove();
}
function handleSubItemClick(item) {
_crossCtl.moveToMenuItem(item);
props.onMove();
}
function hideOther(id) {
let targetEl = null;
const items = elements.value.filter((elm) => {
if (elm.getAttribute('data-id') == id.toString()) {
targetEl = elm;
}
return elm.getAttribute('data-id') !== id.toString();
// return true;
});
items.forEach((elm) => elm.click());
// targetEl.click();
}
function doClose(close) {
close();
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div
class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border-2 m-5"
>
<div class="ml-4">
<dt class="mt-3 text-lg leading-6 font-medium text-gray-900">
{{ item.title }}
</dt>
<dd
class="mt-9 text-base text-gray-500"
v-html="
_utils
.escapeHtml(item.detail)
.replace(/(?:\r\n|\r|\n)/g, '<br />')
"
></dd>
</div>
<p class="text-right text-xs">
{{ $customFormat(item.created) }}
</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
item: { type: Object, required: true },
});
</script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,112 @@
<template>
<footer
class="footer px-10 py-4 bg-base-200 text-base-content border-base-300"
>
<div class="items-center grid-flow-col">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
class="fill-current"
>
<path
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"
></path>
</svg>
<p>{{ copyrightName }} <br />{{ siteSlogan }}</p>
</div>
<div class="md:place-self-center md:justify-self-end">
<div class="grid grid-flow-col gap-4">
<a v-for="item in snsLinks" :key="item.tag" :href="item.url">
<component
:is="socialLogs[item.tag].icon"
v-if="socialLogs[item.tag] != undefined"
class="h-6 w-6"
aria-hidden="true"
/>
<span v-else class="">{{ item.tag }}</span>
</a>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const copyrightName = ref(_siteConfig.copyrightName);
const siteSlogan = ref(_siteConfig.siteSlogan);
const snsLinks = ref(_siteConfig.snsLinks);
const socialLogs = {
facebook: {
name: 'Facebook',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z',
'clip-rule': 'evenodd',
}),
]),
}),
},
instagram: {
name: 'Instagram',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z',
'clip-rule': 'evenodd',
}),
]),
}),
},
twitter: {
name: 'Twitter',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
d: 'M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84',
}),
]),
}),
},
gitHub: {
name: 'GitHub',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z',
'clip-rule': 'evenodd',
}),
]),
}),
},
dribbble: {
name: 'Dribbble',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10c5.51 0 10-4.48 10-10S17.51 2 12 2zm6.605 4.61a8.502 8.502 0 011.93 5.314c-.281-.054-3.101-.629-5.943-.271-.065-.141-.12-.293-.184-.445a25.416 25.416 0 00-.564-1.236c3.145-1.28 4.577-3.124 4.761-3.362zM12 3.475c2.17 0 4.154.813 5.662 2.148-.152.216-1.443 1.941-4.48 3.08-1.399-2.57-2.95-4.675-3.189-5A8.687 8.687 0 0112 3.475zm-3.633.803a53.896 53.896 0 013.167 4.935c-3.992 1.063-7.517 1.04-7.896 1.04a8.581 8.581 0 014.729-5.975zM3.453 12.01v-.26c.37.01 4.512.065 8.775-1.215.25.477.477.965.694 1.453-.109.033-.228.065-.336.098-4.404 1.42-6.747 5.303-6.942 5.629a8.522 8.522 0 01-2.19-5.705zM12 20.547a8.482 8.482 0 01-5.239-1.8c.152-.315 1.888-3.656 6.703-5.337.022-.01.033-.01.054-.022a35.318 35.318 0 011.823 6.475 8.4 8.4 0 01-3.341.684zm4.761-1.465c-.086-.52-.542-3.015-1.659-6.084 2.679-.423 5.022.271 5.314.369a8.468 8.468 0 01-3.655 5.715z',
'clip-rule': 'evenodd',
}),
]),
}),
},
};
</script>

View File

@@ -0,0 +1,289 @@
<template>
<div
class="pt-5 px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"
>
<div class="flex-1 flex justify-between sm:hidden">
<a
href="#"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:text-gray-500"
>
Previous
</a>
<a
href="#"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:text-gray-500"
>
Next
</a>
</div>
<div
class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"
>
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium">{{ showingFrom }}</span>
to
<span class="font-medium">{{ showingTo }}</span>
of
<span class="font-medium">{{ recordsTotal }}</span>
results
</p>
</div>
<div>
<nav
class="relative z-0 inline-flex shadow-sm -space-x-px"
aria-label="Pagination"
>
<a
href="javascript:void(0)"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click="gotoPage('first', 0)"
>
<span class="sr-only">First</span>
<!-- Heroicon name: chevron-double-left -->
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
></path>
</svg>
</a>
<a
href="javascript:void(0)"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click="gotoPage('prev', 0)"
>
<span class="sr-only">Previous</span>
<!-- Heroicon name: chevron-left -->
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</a>
<a
v-for="(item, index) in currentSlots"
:key="index"
href="javascript:void(0)"
:class="
currentSlots[index] == currentPageNumber
? 'bg-gray-200'
: 'bg-white'
"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
@click="gotoPage(currentSlots[index], index)"
>
{{ currentSlots[index] }}
</a>
<a
href="javascript:void(0)"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click="gotoPage('next', 0)"
>
<span class="sr-only">Next</span>
<!-- Heroicon name: chevron-right -->
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</a>
<a
href="javascript:void(0)"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click="gotoPage('last', 0)"
>
<span class="sr-only">Last</span>
<!-- Heroicon name: chevron-double-right -->
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
></path>
</svg>
</a>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/vue/solid/index.js';
import { computed } from 'vue';
const props = defineProps({
totalPageCount: { type: Number, default: 1 },
currentPageNumber: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
recordsTotal: { type: Number, default: 0 },
pageMove: { type: Function, required: true },
});
const pagenationSize = 5;
//console.log(props);
const showingFrom = computed(() => {
const result = 1 + (props.currentPageNumber - 1) * props.pageSize;
return result;
});
//console.log(showingFrom.value);
const showingTo = computed(() => {
let result = props.currentPageNumber * props.pageSize;
if (result > props.recordsTotal) {
result = props.recordsTotal;
}
return result;
});
const currentSlots = computed(() => {
const result: string[] = [];
if (props.totalPageCount > pagenationSize) {
if (props.currentPageNumber < Math.ceil(pagenationSize / 2)) {
for (let i = 1; i < pagenationSize; i++) {
// good for 3, 4, ....
result.push(i.toString());
}
result.push('...');
} else if (
props.currentPageNumber >
props.totalPageCount - pagenationSize / 2
) {
result.push('...');
for (
let i = props.totalPageCount - pagenationSize + 1;
i <= props.totalPageCount;
i++
) {
// good for 3, 4, ....
result.push(i.toString());
}
} else {
result.push('...');
for (
let i =
props.currentPageNumber - Math.floor(pagenationSize / 2);
i < props.currentPageNumber + Math.ceil(pagenationSize / 2);
i++
) {
// good for 3, 4, ....
result.push(i.toString());
}
result.push('...');
}
} else {
for (let i = 1; i <= props.totalPageCount; i++) {
result.push(i.toString());
}
}
//console.log('result = ', result);
return result;
});
function gotoPage(target, opt) {
//console.log('gotoPage, target=', target);
let targetPageIndex = props.currentPageNumber;
switch (target) {
case 'first':
targetPageIndex = 1;
break;
case 'prev':
if (props.currentPageNumber > 1) {
targetPageIndex = props.currentPageNumber - 1;
} else {
targetPageIndex = props.currentPageNumber;
}
break;
case 'next':
if (props.totalPageCount > props.currentPageNumber) {
targetPageIndex = props.currentPageNumber + 1;
} else {
targetPageIndex = props.currentPageNumber;
}
break;
case 'last':
targetPageIndex = props.totalPageCount;
break;
case '...':
if (opt == 0) {
if (props.currentPageNumber - pagenationSize > 0) {
targetPageIndex = props.currentPageNumber - pagenationSize;
} else {
targetPageIndex = 1;
}
} else {
if (
props.currentPageNumber + pagenationSize <
props.totalPageCount
) {
targetPageIndex = props.currentPageNumber + pagenationSize;
} else {
targetPageIndex = props.totalPageCount;
}
}
break;
default:
const tmpPageIdx = parseInt(target);
if (tmpPageIdx >= 1 && tmpPageIdx <= props.totalPageCount) {
targetPageIndex = tmpPageIdx;
} else {
targetPageIndex = props.currentPageNumber;
}
}
//console.log('final targetPageIdex = ', targetPageIndex);
// console.log('huk', this);
// this.$parent.pageMove(targetPageIndex);
// $emit('pageMove', targetPageIndex);
props.pageMove(targetPageIndex);
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="">
<div class="card border overflow-x-auto">
<table class="table w-full">
<!-- head -->
<thead>
<tr>
<th
v-for="(heading, index) in headings"
:key="index"
:width="
heading['widthRatio'] != ''
? heading['widthRatio'] + '%'
: '100%'
"
scope="col"
>
{{ heading['title'] }}
</th>
<th
v-for="(action, index) in actions"
:key="index + headings.length"
:width="actions.length * 1"
scope="col"
>
<span class="sr-only">{{ action }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, itemIndex) in data" :key="itemIndex">
<td v-for="(heading, index) in headings" :key="index">
{{
columnFilter
? columnFilter(
heading['key'],
item[heading['key']]
)
: item[heading['key']]
}}
</td>
<td>
<button
v-for="(action, index) in actions"
:key="index"
class="btn btn-sm btn-primary"
:class="index != 0 ? 'ml-1' : ''"
@click="doAction(action, item[actionKey])"
>
{{ action
}}<span class="sr-only"
>, {{ item['serial'] }}</span
>
</button>
</td>
</tr>
<tr v-if="data.length == 0">
<td>{{ 'No Data to display...' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
headings: { type: Array<object>, required: true },
actions: { type: Array<string>, default: [] },
data: { type: Array<object>, required: true },
actionKey: { type: String, default: 'serial' },
columnFilter: {
type: Function,
default: (key: string, val: string) => val,
},
doAction: {
type: Function,
default: (key: string) => {
return key;
},
},
});
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="flex flex-col">
<div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div
class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8"
>
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
v-for="(heading, index) in headings"
:key="index"
:width="
heading['widthRatio'] != ''
? heading['widthRatio'] + '%'
: '100%'
"
:class="
index == 0
? 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6 md:pl-0'
: 'py-3.5 px-3 text-left text-sm font-semibold text-gray-900'
"
scope="col"
>
{{ heading['title'] }}
</th>
<th
v-for="(action, index) in actions"
:key="index + headings.length"
:width="actions.length * 1"
class="relative py-3.5 pl-3 pr-4 sm:pr-6 md:pr-0"
scope="col"
>
<span class="sr-only">{{ action }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(item, itemIndex) in data" :key="itemIndex">
<td
v-for="(heading, index) in headings"
:key="index"
:class="
index == 0
? 'whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 md:pl-0'
: 'whitespace-nowrap py-4 px-3 text-sm text-gray-500'
"
>
{{
columnFilter
? columnFilter(
heading['key'],
item[heading['key']]
)
: item[heading['key']]
}}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 md:pr-0"
>
<a
v-for="(action, index) in actions"
:key="index"
href="javascript:void(0)"
:class="index != 0 ? 'ml-3' : ''"
class="text-indigo-600 hover:text-indigo-900"
@click="doAction(action, item[actionKey])"
>{{ action
}}<span class="sr-only"
>, {{ item['serial'] }}</span
></a
>
</td>
</tr>
<tr v-if="data.length == 0">
<td
:colspan="headings.length"
class="whitespace-nowrap py-4 px-3 text-sm text-gray-500"
>
{{ noDataMessage }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
headings: { type: Array<object>, required: true },
actions: { type: Array<string>, default: [] },
data: { type: Array<object>, required: true },
actionKey: { type: String, default: 'serial' },
columnFilter: {
type: Function,
default: (key: string, val: string) => val,
},
doAction: {
type: Function,
default: (key: string) => {
return key;
},
},
noDataMessage: { type: String, default: '조회된 데이터가 없습니다.' },
});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<BaseAvater1
:image-size="photoSize"
:image-url="photoUrl"
:image-alt="photoAlt"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
imageSize: { type: Number, default: 12 },
});
const photoSize = ref(props.imageSize);
const photoUrl = _crossCtl.profileUrlRef;
const photoAlt = ref(
_crossCtl.isAuthenticated
? _crossCtl.userInfo['userInfo']['userName'] + '의 프로필 사진'
: ''
);
</script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,22 @@
<template>
<div v-show="false"></div>
</template>
<script>
export default {
props: {
show: {
type: Boolean,
},
},
emits: ['show', 'hide'],
watch: {
show(show) {
if (show) {
this.$emit('show');
} else {
this.$emit('hide');
}
},
},
};
</script>

View File

@@ -0,0 +1,106 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<footer class="bg-white">
<div
class="mx-auto max-w-7xl py-12 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8"
>
<div class="flex justify-center space-x-6 md:order-2">
<a
v-for="item in snsLinks"
:key="item.tag"
:href="item.url"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">{{ item.tag }}</span>
<component
:is="socialLogos[item.tag].icon"
v-if="socialLogos[item.tag] != undefined"
class="h-6 w-6"
aria-hidden="true"
/>
</a>
</div>
<div class="mt-8 md:order-1 md:mt-0">
<p class="text-center text-base text-gray-400">
{{ copyrightName }}
</p>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const copyrightName = ref(_siteConfig.copyrightName);
const snsLinks = ref(_siteConfig.snsLinks);
const socialLogos = {
facebook: {
name: 'Facebook',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z',
'clip-rule': 'evenodd',
}),
]),
}),
},
instagram: {
name: 'Instagram',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z',
'clip-rule': 'evenodd',
}),
]),
}),
},
twitter: {
name: 'Twitter',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
d: 'M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84',
}),
]),
}),
},
github: {
name: 'GitHub',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z',
'clip-rule': 'evenodd',
}),
]),
}),
},
dribbble: {
name: 'Dribbble',
href: '#',
icon: defineComponent({
render: () =>
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
h('path', {
'fill-rule': 'evenodd',
d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10c5.51 0 10-4.48 10-10S17.51 2 12 2zm6.605 4.61a8.502 8.502 0 011.93 5.314c-.281-.054-3.101-.629-5.943-.271-.065-.141-.12-.293-.184-.445a25.416 25.416 0 00-.564-1.236c3.145-1.28 4.577-3.124 4.761-3.362zM12 3.475c2.17 0 4.154.813 5.662 2.148-.152.216-1.443 1.941-4.48 3.08-1.399-2.57-2.95-4.675-3.189-5A8.687 8.687 0 0112 3.475zm-3.633.803a53.896 53.896 0 013.167 4.935c-3.992 1.063-7.517 1.04-7.896 1.04a8.581 8.581 0 014.729-5.975zM3.453 12.01v-.26c.37.01 4.512.065 8.775-1.215.25.477.477.965.694 1.453-.109.033-.228.065-.336.098-4.404 1.42-6.747 5.303-6.942 5.629a8.522 8.522 0 01-2.19-5.705zM12 20.547a8.482 8.482 0 01-5.239-1.8c.152-.315 1.888-3.656 6.703-5.337.022-.01.033-.01.054-.022a35.318 35.318 0 011.823 6.475 8.4 8.4 0 01-3.341.684zm4.761-1.465c-.086-.52-.542-3.015-1.659-6.084 2.679-.423 5.022.271 5.314.369a8.468 8.468 0 01-3.655 5.715z',
'clip-rule': 'evenodd',
}),
]),
}),
},
};
</script>

View File

@@ -0,0 +1,476 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<Disclosure v-slot="{ open }" as="nav" class="bg-white shadow">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<a
href="javascript:void(0)"
@click="handleItemClick($event, null)"
>
<img
class="block h-8 w-auto lg:hidden"
:src="siteLogoUrl"
alt="site logo image"
/>
<img
class="hidden h-8 w-auto lg:block"
:src="siteLogoUrl"
alt="site logo image"
/>
</a>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<!-- Current: "border-indigo-500 text-gray-900", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" -->
<template
v-for="item in currentMenu['main']"
:key="item.idx"
>
<a
v-if="item.subs == undefined"
href="javascript:void(0)"
:class="
isCurrentMenu(item)
? 'inline-flex items-center border-b-2 border-indigo-500 px-1 pt-1 text-sm font-medium text-gray-900'
: 'inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700'
"
@click="handleItemClick($event, item)"
>{{ item['title'] }}</a
>
<div
v-else
:class="
isCurrentMenu(item)
? 'inline-flex items-center border-b-2 border-indigo-500 px-1 pt-1 text-sm font-medium text-gray-900'
: 'inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700'
"
>
<!-- dropdown -->
<Menu as="div" class="relative ml-3">
<div>
<MenuButton class="flex">
{{ item['title'] }}
<svg
x-state:on="Item active"
x-state:off="Item inactive"
class="h-5 w-5 group-hover:text-gray-500 text-gray-400"
x-bind:class="{ 'text-gray-600': flyoutMenuOpen, 'text-gray-400': !flyoutMenuOpen }"
x-description="Heroicon name: chevron-down"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItem
v-for="subItem in item['subs']"
v-slot="{ active }"
:key="subItem.idx"
>
<a
href="javascript:void(0)"
:class="[
active
? 'bg-gray-100'
: '',
'block px-4 py-2 text-sm text-gray-700',
]"
@click="
handleItemClick(
$event,
subItem
)
"
>{{ subItem.title }}</a
>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<template
v-for="item in currentMenu['sub']"
:key="item.idx"
>
<a
v-if="item.subs == undefined"
href="javascript:void(0)"
:class="
isCurrentMenu(item)
? 'inline-flex items-center border-b-2 border-indigo-500 px-1 pt-1 text-sm font-medium text-gray-900'
: 'inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700'
"
@click="handleItemClick($event, item)"
>{{ item['title'] }}</a
>
<div
v-else
:class="
isCurrentMenu(item)
? 'inline-flex items-center border-b-2 border-indigo-500 px-1 pt-1 text-sm font-medium text-gray-900'
: 'inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700'
"
>
<!-- dropdown -->
<Menu as="div" class="relative ml-3">
<div>
<MenuButton class="flex">
{{ item['title'] }}
<svg
x-state:on="Item active"
x-state:off="Item inactive"
class="h-5 w-5 group-hover:text-gray-500 text-gray-400"
x-bind:class="{ 'text-gray-600': flyoutMenuOpen, 'text-gray-400': !flyoutMenuOpen }"
x-description="Heroicon name: chevron-down"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItem
v-for="subItem in item['subs']"
v-slot="{ active }"
:key="subItem.idx"
>
<a
href="javascript:void(0)"
:class="[
active
? 'bg-gray-100'
: '',
'block px-4 py-2 text-sm text-gray-700',
]"
@click="
handleItemClick(
$event,
subItem
)
"
>{{ subItem.title }}</a
>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</template>
</div>
</div>
<div class="hidden sm:ml-6 sm:flex sm:items-center">
<button
v-if="false"
type="button"
class="rounded-full bg-white p-1 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span class="sr-only">View notifications</span>
<BellIcon class="h-6 w-6" aria-hidden="true" />
</button>
<!-- Profile dropdown -->
<Menu as="div" class="relative ml-3">
<div>
<MenuButton
class="flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span class="sr-only">Open user menu</span>
<BaseUserProfileImage :image-size="2" />
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItem
v-if="isAuthenticated"
v-slot="{ active }"
>
<a
href="javascript:void(0)"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
@click="moveToPath('/user/info')"
>설정</a
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
href="javascript:void(0)"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
@click="doSignInAndOut($event)"
>{{
isAuthenticated == true
? '로그아웃'
: '로그인'
}}</a
>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
<div class="-mr-2 flex items-center sm:hidden">
<!-- Mobile menu button -->
<DisclosureButton
class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
>
<span class="sr-only">Open main menu</span>
<Bars3Icon
v-if="!open"
class="block h-6 w-6"
aria-hidden="true"
/>
<XMarkIcon
v-else
class="block h-6 w-6"
aria-hidden="true"
/>
</DisclosureButton>
</div>
</div>
</div>
<DisclosurePanel class="sm:hidden">
<div class="space-y-1 pt-2 pb-3">
<!-- Current: "bg-indigo-50 border-indigo-500 text-indigo-700", Default: "border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700" -->
<template v-for="item in currentMenu['main']" :key="item.idx">
<DisclosureButton
v-if="item.path != undefined"
as="a"
href="javascript:void(0)"
:class="
currentRoutePath == item.path
? 'block border-l-4 border-indigo-500 bg-indigo-50 py-2 pl-3 pr-4 text-base font-medium text-indigo-700'
: 'block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
"
@click="handleItemClick($event, item)"
>{{ item['title'] }}</DisclosureButton
>
<a
v-else
href="javascript:void(0)"
:class="
currentRoutePath == item.path
? 'block border-l-4 border-indigo-500 bg-indigo-50 py-2 pl-3 pr-4 text-base font-medium text-indigo-700'
: 'block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
"
>{{ item['title'] }}</a
>
<template v-for="subItem in item.subs" :key="subItem.idx">
<DisclosureButton
v-if="subItem.path != undefined"
as="a"
href="javascript:void(0)"
:class="
currentRoutePath == subItem.path
? 'block border-l-4 border-indigo-500 bg-indigo-50 py-2 pl-3 pr-4 text-base font-medium text-indigo-700'
: 'block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
"
class="ml-4"
@click="handleItemClick($event, subItem)"
>{{ subItem['title'] }}</DisclosureButton
>
<a
v-else
href="javascript:void(0)"
:class="
currentRoutePath == subItem.path
? 'block border-l-4 border-indigo-500 bg-indigo-50 py-2 pl-3 pr-4 text-base font-medium text-indigo-700'
: 'block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
"
>{{ subItem['title'] }}</a
>
</template>
</template>
<template v-for="item in currentMenu['sub']" :key="item.idx">
<DisclosureButton
as="a"
href="javascript:void(0)"
:class="
currentRoutePath == item.path
? 'block border-l-4 border-indigo-500 bg-indigo-50 py-2 pl-3 pr-4 text-base font-medium text-indigo-700'
: 'block border-l-4 border-transparent py-2 pl-3 pr-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
"
@click="handleItemClick($event, item)"
>{{ item['title'] }}</DisclosureButton
>
</template>
</div>
<div class="border-t border-gray-200 pt-4 pb-3">
<div class="flex items-center px-4">
<div class="flex-shrink-0">
<BaseUserProfileImage :image-size="3" />
</div>
<div v-if="false" class="ml-3">
<div class="text-base font-medium text-gray-800">
Tom Cook
</div>
<div class="text-sm font-medium text-gray-500">
tom@example.com
</div>
</div>
<button
v-if="false"
type="button"
class="ml-auto flex-shrink-0 rounded-full bg-white p-1 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span class="sr-only">View notifications</span>
<BellIcon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div class="mt-3 space-y-1">
<DisclosureButton
v-if="isAuthenticated"
as="a"
href="javascript:void(0)"
class="block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800"
@click="moveToPath('/user/info')"
>설정</DisclosureButton
>
<DisclosureButton
as="a"
href="javascript:void(0)"
class="block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800"
@click="doSignInAndOut($event)"
>{{
isAuthenticated == true ? '로그아웃' : '로그인'
}}</DisclosureButton
>
</div>
</div>
</DisclosurePanel>
</Disclosure>
</template>
<script setup lang="ts">
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from '@headlessui/vue';
import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/vue/24/outline';
const route = useRoute();
const currentRoutePath = ref(route.path);
const siteLogoUrl = ref(_siteConfig.siteLogoUrl);
const currentMenu = ref(_crossCtl.menu);
const isAuthenticated = ref(_crossCtl.isAuthenticated);
function isCurrentMenu(menuItem) {
let finalResult = false;
if (menuItem.path == currentRoutePath.value) {
finalResult = true;
} else {
if (menuItem['subs'] != undefined) {
for (let i = 0; i < menuItem['subs'].length; i++) {
if (menuItem['subs'][i].path == currentRoutePath.value) {
finalResult = true;
break;
}
}
}
}
return finalResult;
}
function handleItemClick(e, item) {
if (item == null) {
navigateTo('/');
currentRoutePath.value = '/';
} else {
_crossCtl.moveToMenuItem(item);
currentRoutePath.value = item.path;
}
}
function moveToPath(path) {
navigateTo(path);
currentRoutePath.value = path;
}
async function doSignInAndOut(e) {
e.target.blur();
if (_crossCtl.isAuthenticated.value) {
const response = await _crossCtl.doComm('signout', '', {});
console.log('response=', response);
if (response['responseCode'] == 200) {
_crossCtl.setUserInfo({
isAdmin: false,
isApproved: false,
isAuthenticated: false,
isHighLeveled: false,
isOp: false,
isSuperOp: false,
});
_crossCtl.setUserProfile({});
isAuthenticated.value = false;
window.location.href = '/';
} else {
alert(response['responseMessage']);
}
} else {
navigateTo({
path: '/user/signin',
});
}
}
</script>

View File

@@ -0,0 +1,27 @@
import { defineComponent, h, PropType } from 'vue';
import VueJsonPretty from 'vue-json-pretty';
export default defineComponent({
name: 'VueJsonPretty',
components: {
VueJsonPretty,
},
props: {
path: {
type: String,
default: 'res',
},
data: {
type: Object,
default: () => {},
},
},
setup(props) {
return () =>
h(VueJsonPretty, {
path: props.path,
data: props.data,
});
},
});

View File

@@ -0,0 +1,130 @@
<template>
<!--
This example requires updating your template:
```
<html class="h-full bg-gray-100">
<body class="h-full">
```
-->
<div>
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog
as="div"
class="relative z-40 md:hidden bg-white"
@close="sidebarOpen = false"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-600 bg-opacity-75" />
</TransitionChild>
<div class="fixed inset-0 flex z-40">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel
class="relative flex-1 flex flex-col max-w-xs w-full"
>
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
class="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
@click="sidebarOpen = false"
>
<span class="sr-only"
>Close sidebar</span
>
<XMarkIcon
class="h-6 w-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</TransitionChild>
<BaseNavSideBar1 :on-move="onMoveHandler" />
</DialogPanel>
</TransitionChild>
<div class="flex-shrink-0 w-14" aria-hidden="true">
<!-- Force sidebar to shrink to fit close icon -->
</div>
</div>
</Dialog>
</TransitionRoot>
<!-- Static sidebar for desktop -->
<div class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div class="flex-1 flex flex-col min-h-0 bg-white">
<BaseNavSideBar1 :on-move="onMoveHandler" />
</div>
</div>
<div class="md:pl-64 flex flex-col flex-1">
<div
class="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-100"
>
<button
type="button"
class="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900"
@click="sidebarOpen = true"
>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
<main class="flex-1">
<!-- Replace with your content -->
<div class="mt-5 sm:px-3 lg:px-5"><slot /></div>
<!-- /End replace -->
</main>
<BaseModal1 />
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
import { Bars3Icon, XMarkIcon } from '@heroicons/vue/24/outline';
useHead({
htmlAttrs: {
class: 'h-full bg-gray-50',
},
bodyAttrs: {
class: 'h-full',
},
});
const sidebarOpen = ref(false);
function onMoveHandler() {
sidebarOpen.value = false;
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="flex flex-col min-h-screen">
<TopNavBar1 class="mb-3" />
<slot class="bg-gray-50 mt-5 sm:px-3 lg:px-5" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<BaseModal1 />
<Footer1 class="mt-3" />
</div>
</template>
<script setup lang="ts">
import TopNavBar1 from '../components/TopNavBar1.vue';
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex flex-col min-h-screen">
<TopNavBar1 class="mb-3" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<slot class="bg-gray-50 mt-5 sm:px-3 lg:px-5" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<BaseModal1 />
<Footer1 class="mt-3" />
</div>
</template>
<script setup lang="ts">
import TopNavBar1 from '../components/TopNavBar1.vue';
</script>

View File

@@ -0,0 +1,20 @@
<template>
<body class="flex flex-col min-h-screen">
<header>
<TopNavBar1 class="mb-3" />
</header>
<main>
<slot class="bg-gray-50 mt-5 sm:px-3 lg:px-5" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<BaseModal1 />
<Footer1 class="mt-3" />
</main>
<footer class="sticky top-[100vh]"><StickyFooter /></footer>
</body>
</template>
<script setup lang="ts">
import TopNavBar1 from '../components/TopNavBar1.vue';
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col min-h-screen">
<TopNavBar1 class="mb-3" />
<slot class="bg-gray-50 mt-5 sm:px-3 lg:px-5" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<BaseModal1 />
<Footer1 class="mt-3" />
<div class="mt-3 bg-yellow-50"></div>
<StickyFooter />
</div>
</template>
<script setup lang="ts">
import TopNavBar1 from '../components/TopNavBar1.vue';
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex flex-col min-h-screen">
<TopNavBar1 class="mb-3" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<slot class="bg-gray-50 mt-5 sm:px-3 lg:px-5" />
<!-- It is cushion -->
<div class="flex-grow justify-center"></div>
<BaseModal1 />
<Footer1 class="mt-3" />
<div class="mt-3 bg-yellow-50"></div>
<StickyFooter />
</div>
</template>
<script setup lang="ts">
import TopNavBar1 from '../components/TopNavBar1.vue';
</script>

View File

@@ -0,0 +1,8 @@
<template>
<div class="items-center justify-center h-screen">
<slot />
<BaseModal1 />
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,27 @@
<template>
<NuxtLayout :name="layout">
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
import { _siteConfig } from '@/config/site';
const layout = _siteConfig.siteLayout;
/*
let modalOpendedFlag = false;
if (modalOpendedFlag == false) {
_crossCtl.openModal(
'info',
'베타 서비스 종료',
'지금은 정식 서비스 오픈 준비중입니다. 2023년 5월 10일까지의 베타 서비스를 마치고 2023년 6월중에 정식 서비스 오픈을 위해 지금은 서비스 준비중입니다. 베타 서비스 기간에 생성된 계정이나 API 키는 현재 사용하실 수 없습니다. 확인 버튼을 누르시면 보다 상세한 안내 페이지로 이동합니다.',
['확인'],
(btnIdx) => {
modalOpendedFlag = true;
navigateTo('/doc/intermissions');
}
);
}
*/
</script>

View File

@@ -0,0 +1,7 @@
<template>
<div>
<slot />
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,35 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const responseJson = await _crossCtl.doComm('select', 'user', {});
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
_crossCtl.setAuthInfo(responseJson['data'][0]);
_crossCtl.setUserInfo(responseJson['data'][0]);
if (_crossCtl.isAuthenticated.value) {
const tmpProfile = responseJson['data'][0].userInfo.profile;
// console.log('huk', tmpProfile);
const profile = {
email: tmpProfile.infos.email,
displayName: tmpProfile.display_name,
photoUrl: tmpProfile.photo_url,
phone: tmpProfile.infos.phone ? tmpProfile.infos.phone : '',
memo: tmpProfile.infos.memo ? tmpProfile.infos.memo : '',
};
_crossCtl.setUserProfile(profile);
} else {
_crossCtl.setUserProfile({});
}
return null;
} else {
console.log('from = ', from, ', to = ', to);
_crossCtl.setUserProfile({});
if (to.fullPath != '/') {
return throwError('#' + responseJson['responseCode']);
} else {
console.log('skip for google...');
}
}
});

View File

@@ -0,0 +1,12 @@
import { _crossCtl } from '@/base/src/crossCtl';
export default defineNuxtRouteMiddleware((to, from) => {
if (_crossCtl.userInfo['isAuthenticated'] == false) {
alert('로그인이 필요합니다.');
return navigateTo('/user/signin');
} else if (_crossCtl.userInfo['isAdmin'] == false) {
return throwError('$401');
} else {
return null;
}
});

View File

@@ -0,0 +1,16 @@
import { _crossCtl } from '@/base/src/crossCtl';
export default defineNuxtRouteMiddleware((to, from) => {
if (_crossCtl.userInfo['isAuthenticated'] == false) {
alert('로그인이 필요합니다.');
return navigateTo('/user/signin');
} else if (
_crossCtl.userInfo['isOp'] == false &&
_crossCtl.userInfo['isSuperOp'] == false &&
_crossCtl.userInfo['isAdmin'] == false
) {
return throwError('$401');
} else {
return null;
}
});

View File

@@ -0,0 +1,15 @@
import { _crossCtl } from '@/base/src/crossCtl';
export default defineNuxtRouteMiddleware((to, from) => {
if (_crossCtl.userInfo['isAuthenticated'] == false) {
alert('로그인이 필요합니다.');
return navigateTo('/user/signin');
} else if (
_crossCtl.userInfo['isSuperOp'] == false &&
_crossCtl.userInfo['isAdmin'] == false
) {
return throwError('$401');
} else {
return null;
}
});

View File

@@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware((to, from) => {
if (_crossCtl.userInfo['isAuthenticated'] == false) {
alert('로그인이 필요합니다.');
return navigateTo('/user/signin');
} else {
return null;
}
});

View File

@@ -0,0 +1,23 @@
import { defineNuxtConfig } from 'nuxt';
export default defineNuxtConfig({
ssr: false,
autoImports: {
dirs: ['src'],
},
build: {
transpile: ['@fawmi/vue-google-maps', '@headlessui/vue', 'chart.js'],
plugins: [],
},
runtimeConfig: {
public: {
API_BASE_URL: process.env.API_BASE_URL,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
},
},
css: [
'vue-json-pretty/lib/styles.css',
'@vueup/vue-quill/dist/vue-quill.snow.css',
],
});

View File

@@ -0,0 +1,504 @@
<template>
<div>
<!-- Page head goes here -->
<div class="px-4 py-5 sm:px-6">
<div
class="flex justify-between items-center flex-wrap sm:flex-nowrap"
>
<div class="">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ pageTitle }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ pageDescription }}
</p>
</div>
<div class="flex-shrink-0">
<button
v-for="(headingAction, index) in headingActions"
:key="headingAction"
type="button"
:class="index > 0 ? 'ml-3' : ''"
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doHeadingAction(headingAction)"
>
{{ headingAction }}
</button>
</div>
</div>
</div>
<div class="px-4 sm:px-6">
<!-- Content goes here -->
<div class="">
<div
class="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"
>
<div class="sm:col-span-1">
<label
for="boardId"
class="block text-sm font-medium text-gray-700"
>
게시판 아이디
</label>
<div class="mt-1">
<input
id="boardId"
v-model="boardId"
type="text"
name="boardId"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div class="sm:col-span-5">
<label
for="title"
class="block text-sm font-medium text-gray-700"
>
게시판 제목
</label>
<div class="mt-1">
<input
id="title"
v-model="title"
type="text"
name="title"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div class="col-span-6">
<label
for="description"
class="block text-sm font-medium text-gray-700"
>
게시판 설명
</label>
<div class="mt-1">
<textarea
id="description"
v-model="description"
name="description"
rows="3"
style="resize: none"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
placeholder="게시판에 대한 간단한 설명을 입력하세요."
/>
</div>
<p class="flex mt-2 text-sm text-gray-500">
<QuestionMarkCircleIcon
class="mr-1 flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
사용자들에게 표시되는 내용이니 신중하게 입력하세요.
빈칸으로 그냥 수도 있습니다.
</p>
</div>
<div class="sm:col-span-3">
<label
for="readLevelMin"
class="block text-sm font-medium text-gray-700"
>
읽기제한
</label>
<div class="mt-1">
<select
id="readLevelMin"
v-model="readLevelMin"
name="readLevelMin"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
>
<option value="5">관리자 이상</option>
<option value="4">자격 사용자 이상</option>
<option value="0">로그인 사용자 이상</option>
<option value="-1">익명 사용자 이상</option>
</select>
</div>
</div>
<div class="sm:col-span-3">
<label
for="writeLevelMin"
class="block text-sm font-medium text-gray-700"
>
쓰기제한
</label>
<div class="mt-1">
<select
id="writeLevelMin"
v-model="writeLevelMin"
name="writeLevelMin"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
>
<option value="5">관리자 이상</option>
<option value="4">자격 사용자 이상</option>
<option value="0">로그인 사용자 이상</option>
</select>
</div>
</div>
<div class="sm:col-span-6">
<fieldset>
<legend class="sr-only">기타 옵션</legend>
<div
class="block text-sm font-medium text-gray-700"
aria-hidden="true"
>
기타 옵션
</div>
<div class="mt-4 space-y-4">
<div class="relative flex items-start">
<div class="flex h-5 items-center">
<input
id="commentEnabled"
v-model="commentEnabled"
name="commentEnabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</div>
<div class="ml-3 text-sm">
<label
for="commentEnabled"
class="font-medium text-gray-700"
>댓글 기능</label
>
<p class="text-gray-500">
쓰기 권한이 있는 사용자들은 댓글을
보고 있고, 읽기 권한이 있는
사용자들은 있습니다.
</p>
</div>
</div>
<div class="relative flex items-start">
<div class="flex h-5 items-center">
<input
id="attachmentEnabled"
v-model="attachmentEnabled"
name="attachmentEnabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</div>
<div class="ml-3 text-sm">
<label
for="attachmentEnabled"
class="font-medium text-gray-700"
>파일 첨부</label
>
<p class="text-gray-500">
쓰기 권한이 있는 사용자들은 파일을
첨부할 있습니다.
</p>
</div>
</div>
<div class="relative flex items-start">
<div class="flex h-5 items-center">
<input
id="agoEnabled"
v-model="agoEnabled"
name="agoEnabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</div>
<div class="ml-3 text-sm">
<label
for="agoEnabled"
class="font-medium text-gray-700"
>작성일 축약 표시</label
>
<p class="text-gray-500">
작성일을 날짜와 시간 모두 표시하지
않고 짧게 표시합니다.
</p>
</div>
</div>
</div>
</fieldset>
</div>
<div class="col-span-6">
<label
for="memo"
class="block text-sm font-medium text-gray-700"
>
관리자 메모
</label>
<div class="mt-1">
<textarea
id="memo"
v-model="memo"
name="memo"
rows="3"
style="resize: none"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
placeholder=""
/>
</div>
<p class="flex mt-2 text-sm text-gray-500">
<QuestionMarkCircleIcon
class="mr-1 flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
게시판에 대해서 관리 목적상 필요한 간단한 메모를
저장할 있습니다. 관리자에게만 표시됩니다.
</p>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto sm:px-4 lg:px-4">
<!-- Footer Buttons goes here -->
<div class="mt-5 flex justify-between items-center flex-wrap">
<div class="ml-4 mt-4">
<button
v-if="currnetMode != 'new'"
class="ml-3 inline-flex items-center rounded-md border border-transparent bg-red-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
@click="doFooterAction(status == 0 ? '삭제' : '복구')"
>
{{ status == 0 ? '삭제' : '복구' }}
</button>
</div>
<div class="ml-4 mt-4 flex-shrink-0">
<button
class="inline-flex items-center rounded-md border border-transparent bg-indigo-100 px-3 py-2 text-sm font-medium leading-4 text-indigo-700 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doFooterAction('닫기')"
>
{{ '닫기' }}
</button>
<button
class="ml-3 inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doFooterAction('저장')"
>
{{ '저장' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { QuestionMarkCircleIcon } from '@heroicons/vue/24/solid';
definePageMeta({
middleware: 'check-auth-admin',
});
const router = useRouter();
const pageTitle = '새로운 게시판 생성';
const pageDescription = '새로운 게시판 생성 정보를 입력하세요.';
// 해당 페이지 우측 상단에 표시될 액션 버튼들
const headingActions = [];
const bid = ref('');
const boardId = ref('');
const title = ref('');
const description = ref('');
const readLevelMin = ref(5);
const writeLevelMin = ref(5);
const commentEnabled = ref(false);
const attachmentEnabled = ref(false);
const agoEnabled = ref(false);
const memo = ref('');
const status = ref(0);
function doHeadingAction(tag) {
console.log('on doHeadingAction(), tag=', tag);
switch (tag) {
default:
alert('unhandled heading action. tag = ' + tag);
}
}
function doFooterAction(tag) {
console.log('111 on doFooterAction(), tag=', tag);
switch (tag) {
case '저장':
updateContent();
break;
case '닫기':
router.back();
break;
case '삭제':
deleteContent();
break;
case '복구':
reviveContent();
break;
default:
alert('unhandled footer action. tag = ' + tag);
}
}
async function deleteContent() {
const responseJson = await _crossCtl.doComm('delete', 'board:info', {
hero: bid.value,
});
if (responseJson['responseCode'] == 200) {
status.value = 4;
alert(responseJson['responseMessage']);
} else {
// router.back();
alert(responseJson['responseMessage']);
}
}
function reviveContent() {
status.value = 0;
updateContent();
}
async function updateContent() {
if (boardId.value.trim() == '') {
alert('게시판 아이디를 입력해 주세요.');
return;
}
if (title.value.trim() == '') {
alert('게시판 제목을 입력해 주세요.');
return;
}
const responseJson = await _crossCtl.doComm(
bid.value == '' ? 'insert' : 'update',
'board:info',
bid.value == ''
? {
boardId: boardId.value,
title: title.value,
description: description.value,
readLevelMin: readLevelMin.value,
writeLevelMin: writeLevelMin.value,
commentEnabled: commentEnabled.value,
attachmentEnabled: attachmentEnabled.value,
agoEnabled: agoEnabled.value,
memo: memo.value,
}
: {
hero: bid.value,
boardId: boardId.value,
title: title.value,
description: description.value,
readLevelMin: readLevelMin.value,
writeLevelMin: writeLevelMin.value,
commentEnabled: commentEnabled.value,
attachmentEnabled: attachmentEnabled.value,
agoEnabled: agoEnabled.value,
memo: memo.value,
status: status.value,
}
);
console.log('huk responseJson = ', responseJson);
if (responseJson['responseCode'] != 200) {
if (responseJson['responseMessage'].startsWith('ER_DUP_ENTRY: ')) {
alert('게시판 아이디가 이미 존재하고 있습니다.');
} else {
alert(responseJson['responseMessage']);
}
} else {
// router.back();
alert(responseJson['responseMessage']);
}
}
const route = useRoute();
const currnetMode = ref('');
if (route.params.mode instanceof Array) {
console.log('huk 1');
if (route.params.mode.length != 1) {
console.log('huk 2');
throwError('$404');
} else {
console.log('huk 3');
currnetMode.value = route.params.mode[0];
switch (currnetMode.value) {
case 'new':
case 'edit':
console.log('huk 4');
break;
default:
throwError('$404');
console.log('huk 5');
}
}
} else {
if (route.params.mode == '' && route.params._bid == 'new') {
currnetMode.value = 'new';
} else {
throwError('$404');
}
}
console.log('huk 7');
if (currnetMode.value == '') {
console.log('missing params...');
} else {
console.log('mode = ', currnetMode.value);
if (currnetMode.value == 'edit') {
const responseJson = await _crossCtl.doComm(
'select',
'board:info:all',
{
hero: route.params._bid,
}
);
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
if (responseJson['data'].length != 1) {
alert(
'게시판 아이디가 잘못되었습니다. 이전 화면으로 돌아 갑니다.'
);
navigateTo('/admin/board/list');
} else {
const targetData = responseJson['data'][0];
bid.value = targetData['bid'];
boardId.value = targetData['id'];
title.value = targetData['title'];
description.value = targetData['description'];
readLevelMin.value = targetData['read_level_min'];
writeLevelMin.value = targetData['write_level_min'];
commentEnabled.value = targetData['comment_enabled'] == 1;
attachmentEnabled.value = targetData['attachment_enabled'] == 1;
agoEnabled.value = targetData['ago_enabled'] == 1;
memo.value = targetData['memo'];
status.value = targetData['status'];
}
} else {
alert(responseJson['responseMessage']);
}
}
}
// const targetBoardInfo = await _crossCtl.getBoardInfo(route);
// console.log('huk targetBoardInfo = ', targetBoardInfo);
console.log('huk params = ', route.params);
</script>

View File

@@ -0,0 +1,233 @@
<template>
<div>
<!-- Page head goes here -->
<div class="px-3 py-5">
<div
class="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap"
>
<div class="ml-4 mt-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ pageTitle }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ pageDescription }}
</p>
</div>
<div class="ml-4 mt-4 flex-shrink-0">
<button
v-for="(headingAction, index) in headingActions"
:key="headingAction"
type="button"
:class="index > 0 ? 'ml-3' : ''"
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doHeadingAction(headingAction)"
>
{{ headingAction }}
</button>
</div>
</div>
</div>
<div class="max-w mx-auto px-3">
<!-- Content goes here -->
<BaseTable2
:headings="listHeadings"
:actions="listActions"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'check-auth-user',
});
const route = useRoute();
const listMode = ref(route.query.mode ? route.query.mode : '');
console.log('listMode.value=', listMode.value);
const pageTitle = ref(
listMode.value == 'trashcan'
? '게시판 관리 - 삭제 게시판 리스트'
: '게시판 관리 - 리스트'
);
const pageDescription = ref('게시판 관리 리스트 페이지 입니다.');
// 해당 페이지 우측 상단에 표시될 액션 버튼들
const headingActions = ['게시판 생성', '리스트 모드'];
// 리스트 쓰는 경에만 해당. 안되는 경우 모두 지울것.
const listSource = 'list';
const listTarget = ref('');
const listActions = ['보기', '수정'];
const actionKey = 'id';
const listHeadings = [
{
title: '아이디',
widthRatio: '10',
key: 'id',
},
{
title: '제목',
widthRatio: '25',
key: 'title',
},
{
title: '설명',
widthRatio: '40',
key: 'description',
},
{
title: '권한',
widthRatio: '10',
key: 'level_min',
},
{
title: '수정일',
widthRatio: '15',
key: 'updated',
},
];
const listData = ref([]);
const totalPageCount = ref(1);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
return $dayjs(val).format('YYYY/MM/DD A h:mm:ss');
// return $dayjs(val).format('YY/MM/DD');
} else if (key == 'level_min')
switch (val) {
case -1:
return '익명 사용자 이상';
break;
case 0:
return '로그인 사용자 이상';
break;
case 4:
return '확인 사용자 이상';
break;
case 5:
return '관리자 이상';
break;
default:
return val;
break;
}
else {
return val;
}
}
const router = useRouter();
async function doHeadingAction(tag) {
console.log('on doHeadingAction(), tag=', tag);
switch (tag) {
case '게시판 생성':
navigateTo('/admin/board/new');
break;
case '리스트 모드':
console.log('listMode.value=', '[' + listMode.value + ']');
if (listMode.value == 'trashcan') {
console.log('huk 1');
pageTitle.value = '게시판 관리 - 리스트';
await navigateTo('/admin/board/list', { replace: true });
listMode.value = '';
} else {
console.log('huk 2');
pageTitle.value = '게시판 관리 - 삭제 리스트';
await navigateTo('/admin/board/list?mode=trashcan', {
replace: true,
});
listMode.value = 'trashcan';
}
pageMove(1);
break;
default:
alert('unhandled heading action. tag = ' + tag);
}
// alert('headingAction : ' + tag);
}
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
// alert('doAction : ' + tag + ', target = ' + target);
switch (tag) {
case '보기':
navigateTo('/board/' + target + '/list');
break;
case '수정':
navigateTo('/admin/board/edit/' + target);
break;
}
}
function pageMove(targetPageIdex) {
// console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
const responseJson = await _crossCtl.doComm(
listSource,
listMode.value == 'trashcan'
? 'admin:board:info:deactivated'
: 'admin:board:info:active',
{
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
}
);
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
console.log('responseJson=', responseJson);
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
refresh();
</script>

View File

@@ -0,0 +1,495 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="pb-8 px-4 sm:px-6 lg:px-8">
<br />
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ pageTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ pageDescription }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="$router.push(activeListPath)"
>
활성 항목 리스트
</button>
</div>
<div
v-if="currentTarget == 'inquiry'"
class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"
>
<label for="mobile-search-candidate" class="sr-only"
>Search</label
>
<label for="desktop-search-candidate" class="sr-only"
>Search</label
>
<div class="flex rounded-md shadow-sm">
<div class="relative flex-grow focus-within:z-10">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<MagnifyingGlassCircleIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
<input
id="mobile-search-candidate"
v-model="searchKeyword"
type="text"
name="mobile-search-candidate"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:hidden border-gray-300"
placeholder=""
/>
<input
id="desktop-search-candidate"
v-model="searchKeyword"
type="text"
name="desktop-search-candidate"
class="hidden focus:ring-indigo-500 focus:border-indigo-500 w-full rounded-none rounded-l-md pl-10 sm:block sm:text-sm border-gray-300"
placeholder=""
/>
</div>
<button
type="button"
class="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
@click="doAction('search', searchKeyword)"
>
<span class="ml-2">검색</span>
</button>
</div>
</div>
</div>
</div>
<BaseList1
:headings="listHeadings"
:actions="listActions"
:keys="listKeys"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
MagnifyingGlassCircleIcon,
} from '@heroicons/vue/24/solid';
const route = useRoute();
definePageMeta({
middleware: 'check-auth-admin',
});
const searchKeyword = ref('');
const currentTarget = ref('notice');
const pageTitle = ref('제목');
const pageDescription = ref('설명');
const listActions = ref(['상세보기']);
const actionKey = ref('serial');
const listKeys = ref(['serial', 'uid', 'name', 'domain', 'email', 'role']);
const listHeadings = ref([]);
const doActionTargetName = 'admin-support-target-edit';
let listSource = 'list';
let listTarget = 'dummy';
let activeListPath = '/admin/key/deleted';
let makeNewTargetPath = 'admin-support-notice-new';
const inquiryListOptionTags = ['대기중', '검토중', '답변완료', '삭제'];
if (route.params.target instanceof Array) {
if (route.params.target.length != 1) {
throwError('$404');
} else {
if (
route.params.target[0] != 'notice' &&
route.params.target[0] != 'faq' &&
route.params.target[0] != 'inquiry'
) {
throwError('$404');
} else {
currentTarget.value = route.params.target[0];
console.log('currentTarget.value=', currentTarget.value);
switch (route.params.target[0]) {
case 'notice':
pageTitle.value = '삭제된 공지사항';
pageDescription.value =
'삭제된 공지사항을 보고 복구 합니다.';
listHeadings.value = [
{
title: '일련번호',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'serial',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '제목',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'title',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
listActions.value = ['상세보기'];
actionKey.value = 'serial';
listKeys.value = [
'serial',
'title',
'status',
'updated',
'created',
];
makeNewTargetPath = '/admin/support/notice/new';
listSource = 'list';
listTarget = 'notice:deleted';
activeListPath =
'/admin/support/' + route.params.target[0] + '/list';
break;
case 'faq':
pageTitle.value = '삭제된 자주 묻는 질문';
pageDescription.value = '삭제된 FAQ를 보고 복구 합니다.';
listHeadings.value = [
{
title: '일련번호',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'serial',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '질문',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'question',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
listActions.value = ['상세보기'];
actionKey.value = 'serial';
listKeys.value = [
'serial',
'question',
'status',
'updated',
'created',
];
makeNewTargetPath = '/admin/support/faq/new';
listSource = 'list';
listTarget = 'faq:deleted';
activeListPath =
'/admin/support/' + route.params.target[0] + '/list';
break;
case 'inquiry':
pageTitle.value = '처리 완료된 1:1 문의';
pageDescription.value =
'처리 완료된 1:1 문의 내용을 확인할 수 있습니다.';
listHeadings.value = [
{
title: '일련번호',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'serial',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '아이디',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'name',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '제목',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'title',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
listActions.value = ['상세보기'];
actionKey.value = 'serial';
listKeys.value = [
'serial',
'name',
'title',
'status',
'updated',
'created',
];
makeNewTargetPath = '/admin/support/inquiry/new';
listSource = 'list';
listTarget = 'admin:inquiry:done';
activeListPath =
'/admin/support/' + route.params.target[0] + '/list';
break;
}
}
}
} else {
throwError('$404');
}
const listData = ref([]);
const totalPageCount = ref(0);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
// return $dayjs(val).format('YY/MM/DD A h:mm:ss');
return $dayjs(val).format('YY/MM/DD');
} else if (key == 'status') {
if (currentTarget.value == 'inquiry') {
return inquiryListOptionTags[val];
} else {
return val;
}
} else {
return val;
}
}
const router = useRouter();
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
navigateTo('/admin/support/' + currentTarget.value + '/edit/' + target);
/*
router.push({
name: doActionTargetName,
params: { hero: target, target: [currentTarget.value] },
});
*/
}
function makeNewOne() {
/*
router.push({
path: makeNewTargetPath,
params: {},
});
*/
navigateTo({ path: makeNewTargetPath, params: {} });
}
function pageMove(targetPageIdex) {
console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
if (process.client) {
const responseJson = await _crossCtl.doComm(listSource, listTarget, {
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
}
refresh();
</script>

View File

@@ -0,0 +1,760 @@
<template>
<form @submit.prevent="doUpdate">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ newTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ newDescription }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<img
v-if="inPregressFlag"
width="32"
src="/loading-load-2.gif"
/>
</div>
</div>
<div class="mt-2"></div>
<div v-if="currentTarget == 'inquiry'">
<form action="#" class="relative">
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="title" class="sr-only">Title</label>
<input
id="title"
v-model="targetTitle"
disabled
type="text"
name="title"
class="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 focus:ring-0"
placeholder="Title"
/>
<label for="description" class="sr-only">Description</label>
<textarea
id="description"
v-model="targetContent"
rows="8"
disabled
name="description"
class="block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder="Write a description..."
/>
<!-- Spacer element to match the height of the toolbar -->
<div aria-hidden="true">
<div class="py-2">
<div class="h-9" />
</div>
<div class="h-px" />
<div class="py-2">
<div class="py-px">
<div class="h-9" />
</div>
</div>
</div>
</div>
<div class="absolute bottom-0 inset-x-px">
<!-- Actions: These are just examples to demonstrate the concept, replace/wire these up however makes sense for your project. -->
<div
class="flex flex-nowrap justify-end py-2 px-2 space-x-2 sm:px-3"
>
<Listbox
v-model="labelled"
as="div"
class="flex-shrink-0"
>
<ListboxLabel class="sr-only">
Add a label
</ListboxLabel>
<div class="relative">
<ListboxButton
disabled
class="relative inline-flex items-center rounded-full py-2 px-2 bg-gray-50 text-sm font-medium text-gray-500 whitespace-nowrap hover:bg-gray-100 sm:px-3"
>
<TagIcon
:class="[
labelled.value === null
? 'text-gray-300'
: 'text-gray-500',
'flex-shrink-0 h-5 w-5 sm:-ml-1',
]"
aria-hidden="true"
/>
<span
:class="[
labelled.value === null
? ''
: 'text-gray-900',
'hidden truncate sm:ml-2 sm:block',
]"
>{{
labelled.value === null
? 'Label'
: labelled.name
}}</span
>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute right-0 z-10 mt-1 w-52 bg-white shadow max-h-56 rounded-lg py-3 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="label in labels"
:key="label.value"
v-slot="{ active }"
as="template"
:value="label"
>
<li
:class="[
active
? 'bg-gray-100'
: 'bg-white',
'cursor-default select-none relative py-2 px-3',
]"
>
<div class="flex items-center">
<span
class="block font-medium truncate"
>
{{ label.name }}
</span>
</div>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<base-attachment-ctl1
:attachments="targetAttachmentFrom"
:read-only-flag="true"
/>
</div>
</form>
<p class="mt-5 text-sm text-gray-700">답변을 작성 하세요.</p>
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="answer" class="sr-only">answer</label>
<textarea
id="answer"
v-model="targetAnswer"
:disabled="!(targetStatus == 0 || targetStatus == 1)"
rows="8"
name="answer"
class="m-1 mt-2 block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder=""
/>
</div>
<base-attachment-ctl1
:attachments="targetAttachmentTo"
:read-only-flag="false"
:update-attachments="updateAttachments"
/>
</div>
<div v-else>
<TabGroup v-slot="{ selectedIndex }">
<TabList class="flex items-center">
<Tab v-slot="{ selected }" as="template">
<button
:class="[
selected
? 'text-gray-900 bg-gray-100 hover:bg-gray-200'
: 'text-gray-500 hover:text-gray-900 bg-white hover:bg-gray-100',
'px-3 py-1.5 border border-transparent text-sm font-medium rounded-md',
]"
>
입력
</button>
</Tab>
<Tab v-slot="{ selected }" as="template">
<button
:class="[
selected
? 'text-gray-900 bg-gray-100 hover:bg-gray-200'
: 'text-gray-500 hover:text-gray-900 bg-white hover:bg-gray-100',
'ml-2 px-3 py-1.5 border border-transparent text-sm font-medium rounded-md',
]"
>
미리보기
</button>
</Tab>
<!-- These buttons are here simply as examples and don't actually do anything. -->
<div v-if="actionTarget == 'notice'">
<div
v-if="selectedIndex === 0"
class="ml-auto flex items-center space-x-5"
>
<Listbox
v-model="labelled"
as="div"
class="flex-shrink-0"
>
<ListboxLabel class="sr-only">
Add a label
</ListboxLabel>
<div class="relative">
<ListboxButton
class="relative inline-flex items-center rounded-full py-2 px-2 bg-gray-50 text-sm font-medium text-gray-500 whitespace-nowrap hover:bg-gray-100 sm:px-3"
>
<TagIcon
:class="[
labelled.value === null
? 'text-gray-300'
: 'text-gray-500',
'flex-shrink-0 h-5 w-5 sm:-ml-1',
]"
aria-hidden="true"
/>
<span
:class="[
labelled.value === null
? ''
: 'text-gray-900',
'hidden truncate sm:ml-2 sm:block',
]"
>{{
labelled.value === null
? '라벨'
: labelled.name
}}</span
>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute right-0 z-10 mt-1 w-52 bg-white shadow max-h-56 rounded-lg py-3 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="label in labels"
:key="label.value"
v-slot="{ active }"
as="template"
:value="label"
>
<li
:class="[
active
? 'bg-gray-100'
: 'bg-white',
'cursor-default select-none relative py-2 px-3',
]"
>
<div
class="flex items-center"
>
<span
class="block font-medium truncate"
>
{{ label.name }}
</span>
</div>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
</div>
</TabList>
<TabPanels class="mt-2">
<TabPanel class="p-0.5 -m-0.5 rounded-lg">
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="title" class="sr-only">제목</label>
<input
id="title"
v-model="targetTitle"
type="text"
name="title"
class="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 focus:ring-0"
placeholder="제목"
/>
<label for="content" class="sr-only">내용</label>
<textarea
id="content"
v-model="targetContent"
rows="20"
name="content"
class="block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder="내용..."
/>
</div>
</TabPanel>
<TabPanel class="p-0.5 -m-0.5 rounded-lg">
<div class="border-b">
<div
class="mx-px mt-px px-3 pt-2 pb-12 text-sm leading-5 text-gray-800"
>
<div v-if="currentTarget == 'notice'">
<BaseNoticeItem1 :item="previewItem" />
</div>
<div v-else>
<BaseFaqItem1 :item="previewItem" />
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
<div class="mt-2 flex justify-between">
<div>
<button
v-if="currentTarget != 'inquiry'"
type="button"
:class="
targetStatus == 0
? 'inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
: 'inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
"
@click="doToggle"
>
{{ targetStatus == 0 ? '삭제' : '복구' }}
</button>
</div>
<div>
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{{
targetStatus == 0 || targetStatus == 1 ? '저장' : '확인'
}}
</button>
</div>
</div>
</form>
</template>
<script setup>
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/vue';
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from '@headlessui/vue';
import { TagIcon, PaperClipIcon } from '@heroicons/vue/24/solid';
const route = useRoute();
definePageMeta({
middleware: 'check-auth-admin',
});
let labels = [
{ name: '라벨 없음', value: null },
{ name: '공지', value: 'notice' },
{ name: '이벤트', value: 'event' },
// More items...
];
const labelled = ref(labels[0]);
const newTitle = ref('');
const newDescription = ref('');
const contentTitle = ref('');
const contentMessageGuide = ref('');
const currentTarget = ref('notice');
let actionTarget = 'notice';
const inPregressFlag = ref(false);
const targetTitle = ref('');
const targetContent = ref('');
const targetAttachmentFrom = ref([]);
const targetAnswer = ref('');
const targetAttachmentTo = ref([]);
const targetStatus = ref(0);
const previewItem = ref({ title: '', detail: '', created: '' });
let targetCreated = '';
function updateAttachments(newAttachments) {
console.log('newAttachments=', newAttachments);
targetAttachmentTo.value = newAttachments;
}
watch(targetTitle, (newValue, oldValue) => {
previewItem.value =
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
};
});
watch(targetContent, (newValue, oldValue) => {
previewItem.value =
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
};
});
watch(labelled, (newValue, oldValue) => {
previewItem.value =
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
};
});
async function doToggle() {
if (targetStatus.value == 0) {
const responseJson = await _crossCtl.doComm('delete', actionTarget, {
hero: route.params.hero,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
targetStatus.value = 4;
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
} else {
targetStatus.value = 0;
/*
const responseJson = await _crossCtl.doComm(
'update',
actionTarget,
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
attachmentTo: targetAttachmentTo.value,
status: targetStatus.value,
created: targetCreated,
}
);
*/
const responseJson = await _crossCtl.doComm(
'update',
actionTarget == 'inquiry' ? 'inquiry:admin' : actionTarget,
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
}
: actionTarget == 'faq'
? {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
}
: {
hero: route.params.hero,
answer: targetAnswer.value,
attachmentTo: targetAttachmentTo.value,
memo: '',
status: 2,
}
);
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
targetStatus.value = 0;
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
}
console.log('huk route.params.target=', route.params.target);
if (route.params.target instanceof Array) {
if (route.params.target.length != 1) {
throwError('$404');
} else {
if (
route.params.target[0] != 'notice' &&
route.params.target[0] != 'faq' &&
route.params.target[0] != 'inquiry'
) {
throwError('$404');
} else {
currentTarget.value = route.params.target[0];
actionTarget = route.params.target[0];
switch (route.params.target[0]) {
case 'notice':
newTitle.value = '공지 수정';
newDescription.value =
'본문 html태그는 변환되어 저장되고 표시되므로 미리보기를 통해 의도한 대로 보여지는지 미리 확인해 주세요.';
contentTitle.value = '공지 제목';
contentMessageGuide.value = '공지 내용';
break;
case 'faq':
newTitle.value = 'FAQ 수정';
newDescription.value =
'본문 html태그는 변환되어 저장되고 표시되므로 미리보기를 통해 의도한 대로 보여지는지 미리 확인해 주세요.';
contentTitle.value = '질문';
contentMessageGuide.value = '답변';
break;
case 'inquiry':
newTitle.value = '1:1 문의 처리';
newDescription.value =
'1:1 문의에 답을 입력하면 상태가 즉시 답변 완료로 변하지만, 내용은 추가 수정할 있습니다.';
contentTitle.value = '질문';
contentMessageGuide.value = '답변';
labels = [
{ name: '라벨 없음', value: null },
{ name: '사이트 이용', value: 'site' },
{ name: 'API 문의', value: 'api' },
{ name: '기타', value: 'etc' },
// More items...
];
break;
}
console.log('route.params=', route.params);
const responseJson = await _crossCtl.doComm(
'select',
currentTarget.value,
{
hero: route.params.hero,
}
);
console.log('huk responseJson = ', responseJson);
if (responseJson['responseCode'] == 200) {
console.log(responseJson['data']);
if (actionTarget == 'notice') {
targetTitle.value = responseJson['data'][0]['title'];
targetContent.value = responseJson['data'][0]['detail'];
const tmpFlags =
responseJson['data'][0]['flags'] != null
? responseJson['data'][0]['flags']
: '[]';
const flags = JSON.parse(tmpFlags);
for (let i = 0; i < flags.length; i++) {
const flag = flags[i];
for (let j = 0; j < labels.length; j++) {
if (flag == labels[j]['value']) {
labelled.value = labels[j];
}
}
}
} else if (actionTarget == 'inquiry') {
targetTitle.value = responseJson['data'][0]['title'];
targetContent.value = responseJson['data'][0]['question'];
targetAttachmentFrom.value = JSON.parse(
responseJson['data'][0]['attachment_from']
);
targetAnswer.value = responseJson['data'][0]['answer'];
targetAttachmentTo.value = JSON.parse(
responseJson['data'][0]['attachment_to']
);
const tmpFlags =
responseJson['data'][0]['flags'] != null
? responseJson['data'][0]['flags']
: '[]';
const flags = JSON.parse(tmpFlags);
for (let i = 0; i < flags.length; i++) {
const flag = flags[i];
for (let j = 0; j < labels.length; j++) {
if (flag == labels[j]['value']) {
labelled.value = labels[j];
}
}
}
} else {
targetTitle.value = responseJson['data'][0]['question'];
targetContent.value = responseJson['data'][0]['answer'];
}
targetCreated = responseJson['data'][0]['created'];
targetStatus.value = responseJson['data'][0]['status'];
} else {
alert(responseJson['responseMessage']);
}
}
}
} else {
throwError('$404');
}
const router = useRouter();
async function doCancel() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
router.back();
}
async function doUpdate() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
if (targetTitle.value == '' || targetContent.value == '') {
alert('내용을 입력하셔야 합니다. ');
return;
}
if (
actionTarget == 'inquiry' &&
(targetAnswer.value == '' || targetAnswer.value == null)
) {
alert('답변 내용을 입력하셔야 합니다. ');
return;
}
console.log('huk actionTarget = ', actionTarget);
console.log('huk targetAnswer.value = ', targetAnswer.value);
if (targetStatus.value == 2) {
router.back();
return;
}
inPregressFlag.value = true;
const responseJson = await _crossCtl.doComm(
'update',
actionTarget == 'inquiry' ? 'inquiry:admin' : actionTarget,
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
}
: actionTarget == 'faq'
? {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
}
: {
hero: route.params.hero,
answer: targetAnswer.value,
attachmentTo: targetAttachmentTo.value,
memo: '',
status: 2,
}
);
inPregressFlag.value = false;
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
router.back();
} else {
alert('오류 : ' + responseJson['responseMessage']);
}
}
</script>

View File

@@ -0,0 +1,543 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="pb-8 px-4 sm:px-6 lg:px-8">
<br />
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ pageTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ pageDescription }}
</p>
</div>
<div v-if="currentTarget == 'inquiry'" class="ml-3">
<select
id="targetLevel"
v-model="targetLevel"
name="targetLevel"
class="block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
@change="onChangeLevel($event)"
>
<option value="all">전체</option>
<option value="wait">대기중</option>
<option value="done">답변완료</option>
</select>
</div>
<div class="mt-4 sm:mt-0 sm:ml-0 sm:flex-none">
<div
v-if="currentTarget == 'inquiry'"
class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"
>
<label for="mobile-search-candidate" class="sr-only"
>Search</label
>
<label for="desktop-search-candidate" class="sr-only"
>Search</label
>
<div class="flex rounded-md shadow-sm">
<div class="relative flex-grow focus-within:z-10">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<MagnifyingGlassCircleIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
<input
id="mobile-search-candidate"
v-model="searchKeyword"
type="text"
name="mobile-search-candidate"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:hidden border-gray-300"
placeholder=""
@keydown.enter.prevent="onEnterHandler()"
/>
<input
id="desktop-search-candidate"
v-model="searchKeyword"
type="text"
name="desktop-search-candidate"
class="hidden focus:ring-indigo-500 focus:border-indigo-500 w-full rounded-none rounded-l-md pl-10 sm:block sm:text-sm border-gray-300"
placeholder=""
@keydown.enter.prevent="onEnterHandler()"
/>
</div>
<button
type="button"
class="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
@click="doAction('search', searchKeyword)"
>
<span class="ml-2">검색</span>
</button>
</div>
</div>
<button
v-if="currentTarget != 'inquiry'"
type="button"
class="inline-flex mr-3 items-center justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:w-auto"
@click="$router.push(deletedListPath)"
>
삭제 항목 리스트
</button>
<button
v-if="currentTarget == 'notice' || currentTarget == 'faq'"
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="makeNewOne"
>
항목 작성
</button>
</div>
</div>
<BaseList1
:headings="listHeadings"
:actions="listActions"
:keys="listKeys"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
MagnifyingGlassCircleIcon,
} from '@heroicons/vue/24/solid';
import { hueRotate } from 'tailwindcss/defaultTheme';
const route = useRoute();
definePageMeta({
middleware: 'check-auth-admin',
});
function onEnterHandler() {
doAction('search', searchKeyword.value);
}
const targetLevel = ref('all');
function onChangeLevel(e) {
console.log('targetLevel.value=', targetLevel.value);
listTarget = 'admin:inquiry:' + targetLevel.value;
refresh();
}
const inquiryListOptionTags = ['대기중', '검토중', '답변완료', '삭제'];
const inquiryListOption = ref(0);
const searchKeyword = ref('');
const currentTarget = ref('notice');
const pageTitle = ref('제목');
const pageDescription = ref('설명');
const listActions = ref(['상세보기']);
const actionKey = ref('serial');
const listKeys = ref(['serial', 'uid', 'name', 'domain', 'email', 'role']);
const listHeadings = ref([]);
const doActionTargetName = 'admin-support-target-edit';
let listSource = 'list';
let listTarget = 'admin:users:level:all';
let deletedListPath = '/admin/key/deleted';
let makeNewTargetPath = 'admin-support-notice-new';
if (route.params.target instanceof Array) {
if (route.params.target.length != 1) {
throwError('$404');
} else {
if (
route.params.target[0] != 'notice' &&
route.params.target[0] != 'faq' &&
route.params.target[0] != 'inquiry'
) {
throwError('$404');
} else {
currentTarget.value = route.params.target[0];
switch (route.params.target[0]) {
case 'notice':
pageTitle.value = '공지사항';
pageDescription.value =
'공지사항을 작성하거나 수정, 삭제 합니다.';
listHeadings.value = [
{
title: '일련번호',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'serial',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '제목',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'title',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
listActions.value = ['상세보기'];
actionKey.value = 'serial';
listKeys.value = [
'serial',
'title',
'status',
'updated',
'created',
];
makeNewTargetPath = '/admin/support/notice/new';
listSource = 'list';
listTarget = 'notice:active';
deletedListPath =
'/admin/support/' + route.params.target[0] + '/deleted';
break;
case 'faq':
pageTitle.value = '자주 묻는 질문';
pageDescription.value =
'FAQ를 작성하거나 수정, 삭제 합니다.';
listHeadings.value = [
{
title: '일련번호',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'serial',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '질문',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'question',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
listActions.value = ['상세보기'];
actionKey.value = 'serial';
listKeys.value = [
'serial',
'question',
'status',
'updated',
'created',
];
makeNewTargetPath = '/admin/support/faq/new';
listSource = 'list';
listTarget = 'faq:active';
deletedListPath =
'/admin/support/' + route.params.target[0] + '/deleted';
break;
case 'inquiry':
pageTitle.value = '1:1 문의';
pageDescription.value =
'응답하지 않은 1:1 문의 내용을 보고 회신합니다.';
listHeadings.value = [
{
title: '일련번호',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'serial',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '아이디',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'name',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '제목',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'title',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
listActions.value = ['상세보기'];
actionKey.value = 'serial';
listKeys.value = [
'serial',
'name',
'title',
'status',
'updated',
'created',
];
makeNewTargetPath = '/admin/support/inquiry/new';
listSource = 'list';
listTarget = 'admin:inquiry:all';
deletedListPath =
'/admin/support/' + route.params.target[0] + '/deleted';
break;
}
}
}
} else {
throwError('$404');
}
const listData = ref([]);
const totalPageCount = ref(0);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
// return $dayjs(val).format('YY/MM/DD A h:mm:ss');
return $dayjs(val).format('YY/MM/DD');
} else if (key == 'status') {
if (currentTarget.value == 'inquiry') {
return inquiryListOptionTags[val];
} else {
return val;
}
} else {
return val;
}
}
const router = useRouter();
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
if (tag == 'search') {
currentPageNumber.value = 1;
refresh();
} else {
navigateTo('/admin/support/' + currentTarget.value + '/edit/' + target);
}
/*
router.push({
name: doActionTargetName,
params: { hero: target, target: [currentTarget.value] },
});
*/
}
function makeNewOne() {
/*
router.push({
path: makeNewTargetPath,
params: {},
});
*/
navigateTo({ path: makeNewTargetPath, params: {} });
}
function pageMove(targetPageIdex) {
console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
if (process.client) {
const responseJson = await _crossCtl.doComm(listSource, listTarget, {
hero: searchKeyword.value,
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
}
refresh();
</script>

View File

@@ -0,0 +1,409 @@
<!--
This example requires Tailwind CSS v2.0+
This example requires some changes to your config:
```
// tailwind.config.js
module.exports = {
// ...
plugins: [
// ...
require('@tailwindcss/forms'),
],
}
```
-->
<template>
<form @submit.prevent="doCreate">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ newTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ newDescription }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<img
v-if="inPregressFlag"
width="32"
src="/loading-load-2.gif"
/>
</div>
</div>
<div class="mt-2"></div>
<TabGroup v-slot="{ selectedIndex }">
<TabList class="flex items-center">
<Tab v-slot="{ selected }" as="template">
<button
:class="[
selected
? 'text-gray-900 bg-gray-100 hover:bg-gray-200'
: 'text-gray-500 hover:text-gray-900 bg-white hover:bg-gray-100',
'px-3 py-1.5 border border-transparent text-sm font-medium rounded-md',
]"
>
입력
</button>
</Tab>
<Tab v-slot="{ selected }" as="template">
<button
:class="[
selected
? 'text-gray-900 bg-gray-100 hover:bg-gray-200'
: 'text-gray-500 hover:text-gray-900 bg-white hover:bg-gray-100',
'ml-2 px-3 py-1.5 border border-transparent text-sm font-medium rounded-md',
]"
>
미리보기
</button>
</Tab>
<!-- These buttons are here simply as examples and don't actually do anything. -->
<div
v-if="selectedIndex === 0"
class="ml-auto flex items-center space-x-5"
>
<div v-if="currentTarget == 'notice'">
<Listbox
v-model="labelled"
as="div"
class="flex-shrink-0"
>
<ListboxLabel class="sr-only">
Add a label
</ListboxLabel>
<div class="relative">
<ListboxButton
class="relative inline-flex items-center rounded-full py-2 px-2 bg-gray-50 text-sm font-medium text-gray-500 whitespace-nowrap hover:bg-gray-100 sm:px-3"
>
<TagIcon
:class="[
labelled.value === null
? 'text-gray-300'
: 'text-gray-500',
'flex-shrink-0 h-5 w-5 sm:-ml-1',
]"
aria-hidden="true"
/>
<span
:class="[
labelled.value === null
? ''
: 'text-gray-900',
'hidden truncate sm:ml-2 sm:block',
]"
>{{
labelled.value === null
? '라벨'
: labelled.name
}}</span
>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute right-0 z-10 mt-1 w-52 bg-white shadow max-h-56 rounded-lg py-3 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="label in labels"
:key="label.value"
v-slot="{ active }"
as="template"
:value="label"
>
<li
:class="[
active
? 'bg-gray-100'
: 'bg-white',
'cursor-default select-none relative py-2 px-3',
]"
>
<div class="flex items-center">
<span
class="block font-medium truncate"
>
{{ label.name }}
</span>
</div>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
</div>
</TabList>
<TabPanels class="mt-2">
<TabPanel class="p-0.5 -m-0.5 rounded-lg">
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="title" class="sr-only">제목</label>
<input
id="title"
v-model="targetTitle"
type="text"
name="title"
class="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 focus:ring-0"
placeholder="제목"
/>
<label for="content" class="sr-only">내용</label>
<textarea
id="content"
v-model="targetContent"
rows="20"
name="content"
class="block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder="내용..."
/>
</div>
</TabPanel>
<TabPanel class="p-0.5 -m-0.5 rounded-lg">
<div class="border-b">
<div
class="mx-px mt-px px-3 pt-2 pb-12 text-sm leading-5 text-gray-800"
>
<div v-if="currentTarget == 'notice'">
<BaseNoticeItem1 :item="previewItem" />
</div>
<div v-else>
<BaseFaqItem1 :item="previewItem" />
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
<div class="mt-2 flex justify-end">
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
저장
</button>
</div>
</form>
</template>
<script setup>
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/vue';
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from '@headlessui/vue';
import { TagIcon } from '@heroicons/vue/24/solid';
const route = useRoute();
definePageMeta({
middleware: 'check-auth-admin',
});
const labels = [
{ name: '라벨 없음', value: null },
{ name: '공지', value: 'notice' },
{ name: '이벤트', value: 'event' },
// More items...
];
const labelled = ref(labels[0]);
const newTitle = ref('');
const newDescription = ref('');
const contentTitle = ref('');
const contentMessageGuide = ref('');
const currentTarget = ref('notice');
let actionTarget = 'notice';
const inPregressFlag = ref(false);
const targetTitle = ref('');
const targetContent = ref('');
const targetStatus = ref(0);
const today = new Date();
const targetCreated = today.toISOString();
// console.log('targetCreated=', targetCreated);
const previewItem = ref({ title: '', detail: '', created: targetCreated });
watch(targetTitle, (newValue, oldValue) => {
previewItem.value =
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
};
});
watch(targetContent, (newValue, oldValue) => {
previewItem.value =
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
};
});
watch(labelled, (newValue, oldValue) => {
previewItem.value =
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
};
});
if (route.params.target instanceof Array) {
if (route.params.target.length != 1) {
throwError('$404');
} else {
if (
route.params.target[0] != 'notice' &&
route.params.target[0] != 'faq'
) {
throwError('$404');
} else {
currentTarget.value = route.params.target[0];
actionTarget = route.params.target[0];
switch (route.params.target[0]) {
case 'notice':
newTitle.value = ' 공지 작성';
newDescription.value =
'본문 html태그는 변환되어 저장되고 표시되므로 미리보기를 통해 의도한 대로 보여지는지 미리 확인해 주세요.';
contentTitle.value = '공지 제목';
contentMessageGuide.value = '공지 내용';
break;
case 'faq':
newTitle.value = ' FAQ 작성';
newDescription.value =
'본문 html태그는 변환되어 저장되고 표시되므로 미리보기를 통해 의도한 대로 보여지는지 미리 확인해 주세요.';
contentTitle.value = '질문';
contentMessageGuide.value = '답변';
break;
}
}
}
} else {
throwError('$404');
}
const router = useRouter();
async function doCancel() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
router.back();
}
async function doCreate() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
if (targetTitle.value == '' || targetContent.value == '') {
alert('내용을 입력하셔야 합니다. ');
return;
}
inPregressFlag.value = true;
const responseJson = await _crossCtl.doComm(
'insert',
actionTarget,
actionTarget == 'notice'
? {
hero: route.params.hero,
title: targetTitle.value,
detail: targetContent.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
created: targetCreated,
}
: {
hero: route.params.hero,
question: targetTitle.value,
answer: targetContent.value,
status: targetStatus.value,
created: targetCreated,
}
);
inPregressFlag.value = false;
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert('오류 : ' + responseJson['responseMessage']);
}
}
</script>

View File

@@ -0,0 +1,58 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="bg-white">
<div class="max-w-7xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:px-8">
<div class="text-center">
<h2
class="text-base font-semibold text-indigo-600 tracking-wide uppercase"
>
어드민 / 고객 지원
</h2>
<p
class="mt-1 text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl"
>
고객 지원 기능 리스트
</p>
<p class="max-w-xl mt-5 mx-auto text-xl text-gray-500">
어드민이 사용할 있는 고개 지원 기능 리스트
</p>
<br />
있는 페이지 :
<a
href="javascript:void(0)"
@click="$router.push('/admin/support/notice/list')"
>
공지 리스트
</a>
,
<a
href="javascript:void(0)"
@click="$router.push('/admin/support/faq/list')"
>
자주 묻는 질문 리스트
</a>
,
<a
href="javascript:void(0)"
@click="$router.push('/admin/support/inquiry/list')"
>
1:1 문의 리스트
</a>
,
<a href="javascript:void(0)" @click="$router.back()">
이전 페이지
</a>
,
<a href="javascript:void(0)" @click="$router.push('/')"> </a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['check-auth-admin'],
});
</script>

View File

@@ -0,0 +1,816 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="divide-y divide-gray-200">
<form class="lg:col-span-9" @submit.prevent="doUpdateInfo">
<!-- Profile section -->
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h2 class="text-lg leading-6 font-medium text-gray-900">
사용자 정보 확인, 변경
</h2>
<p class="mt-1 text-sm text-gray-500">
일부 정보는 다른 사용자들에게 보여질 있으니
신중하게 입력해 주세요.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
class="mr-2 inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="
navigateTo(
'/admin/statistics/byterm/word?uid=' + hero
)
"
>
단어 통계
</button>
<button
type="button"
class="mr-2 inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="
navigateTo(
'/admin/statistics/byterm/usage?uid=' + hero
)
"
>
사용 통계
</button>
<button
type="button"
class="mr-2 inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="navigateTo('/admin/key/list?uid=' + hero)"
>
보유
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="doHistory"
>
유저 로그
</button>
</div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>이메일</label
>
<input
id="email"
v-model="email"
disabled
type="text"
name="email"
autocomplete="email"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>가입일</label
>
<input
id="created"
v-model="created"
disabled
type="text"
name="created"
autocomplete="created"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="username"
class="block text-sm font-medium text-gray-700"
>
사용자 이름
</label>
<div class="mt-1 rounded-md shadow-sm flex">
<input
id="username"
v-model="displayName"
type="text"
name="username"
autocomplete="username"
class="focus:ring-sky-500 focus:border-sky-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
/>
</div>
</div>
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-700"
>
전화번호
</label>
<div class="mt-1 rounded-md shadow-sm flex">
<input
id="phone"
v-model="phone"
type="text"
name="phone"
autocomplete="phone"
class="focus:ring-sky-500 focus:border-sky-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
/>
</div>
</div>
<div>
<label
for="about"
class="block text-sm font-medium text-gray-700"
>
간단한 소개
</label>
<div class="mt-1">
<textarea
id="about"
v-model="memo"
name="about"
rows="3"
class="shadow-sm focus:ring-sky-500 focus:border-sky-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
/>
</div>
<p class="mt-2 text-sm text-gray-500">
관리자나 다른 사용자가 당신을 식별할 있도록
간단한 소개를 입력해 주세요.
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700"
>
프로필 사진
</label>
<div class="mt-2 flex items-center space-x-5">
<span
v-if="photoUrl == ''"
class="inline-block h-12 w-12 rounded-full overflow-hidden bg-gray-100"
>
<svg
class="h-full w-full text-gray-300"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
</span>
<img
v-else
class="inline-block h-12 w-12 rounded-full border"
:src="photoUrl"
alt=""
/>
</div>
<div class="col-span-2 sm:col-span-2 pt-3">
<div class="mt-1 flex rounded-md shadow-sm">
<div
class="relative flex items-stretch flex-grow focus-within:z-10"
>
<input
id="photoUrl"
v-model="photoUrl"
type="text"
name="photoUrl"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md sm:text-sm border-gray-300"
placeholder="http://"
/>
</div>
</div>
<p class="mt-2 text-sm text-gray-500">
프로필로 사용하실 이미지의 주소를
입력하세요.
</p>
</div>
</div>
<div>
<div
class="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
@dragover.prevent
@dragenter.prevent
@drop.prevent="
filesChange(
'upload-file',
$event.dataTransfer.files
)
"
>
<div class="space-y-1 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<div class="flex text-sm text-gray-600">
<label
for="file-upload"
class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>
<span
><a
href="javascript:void(0)"
@click="
$refs.input_file.click()
"
>여기</a
></span
>
<input
ref="input_file"
type="file"
name="upload-file"
accept=".jpg,.jpeg,.png"
hidden
@change="
filesChange(
$event.target.name,
$event.target.files
)
"
/>
</label>
<p class="pl-1">
눌러 업로드 하시거나 마우스로
이곳에 끌어 놓아 주세요.
</p>
</div>
<p class="text-xs text-gray-500">
PNG, JPG 최대 1MB
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="button"
class="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
저장
</button>
</div>
</div>
</form>
<form class="lg:col-span-9" @submit.prevent="doUpdateLevel">
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
자격 변경
</h2>
<p class="mt-1 text-sm text-gray-500">
사용자의 자격을 변경합니다.
</p>
</div>
<RadioGroup v-model="selectedMailingLists">
<div
class="mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-4 sm:gap-x-4"
>
<RadioGroupOption
v-for="mailingList in mailingLists"
:key="mailingList.level"
v-slot="{ checked, active }"
as="template"
:value="mailingList"
>
<div
:class="[
checked
? 'border-transparent'
: 'border-gray-300',
active
? 'border-indigo-500 ring-2 ring-indigo-500'
: '',
'relative bg-white border rounded-lg shadow-sm p-4 flex cursor-pointer focus:outline-none',
]"
>
<span class="flex-1 flex">
<span class="flex flex-col">
<RadioGroupLabel
as="span"
class="block text-sm font-medium text-gray-900"
>
{{ mailingList.title }}
</RadioGroupLabel>
<RadioGroupDescription
as="span"
class="mt-1 flex items-center text-sm text-gray-500"
>
{{ mailingList.description }}
</RadioGroupDescription>
<RadioGroupDescription
as="span"
class="mt-6 text-sm font-medium text-gray-900"
>
{{ mailingList.users }}
</RadioGroupDescription>
</span>
</span>
<CheckCircleIcon
:class="[
!checked ? 'invisible' : '',
'h-5 w-5 text-indigo-600',
]"
aria-hidden="true"
/>
<span
:class="[
active ? 'border' : 'border-2',
checked
? 'border-indigo-500'
: 'border-transparent',
'absolute -inset-px rounded-lg pointer-events-none',
]"
aria-hidden="true"
/>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
현재 자격 저장
</button>
</div>
</div>
</form>
<form class="lg:col-span-9" @submit.prevent="doUpdateLimitCount">
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
API 갯수 제한
</h2>
<p class="mt-1 text-sm text-gray-500">
쵀대 몇개의 API 키를 생성할 있는지를 설정합니다.
</p>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="limitCount"
class="block text-sm font-medium text-gray-700"
>최대 API 갯수 제한</label
>
<input
id="limitCount"
v-model="limitCount"
type="number"
name="limitCount"
autocomplete="limitCount"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
제한 숫자 저장
</button>
</div>
</div>
</form>
<form class="lg:col-span-9" @submit.prevent="doUpdatePassword">
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
비밀번호 변경
</h2>
<p class="mt-1 text-sm text-gray-500">
새로운 비밀번호를 두번 정확하게 입력해 주셔야 합니다.
</p>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="password"
class="block text-sm font-medium text-gray-700"
>새로운 비밀번호</label
>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="password"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6">
<label
for="password2"
class="block text-sm font-medium text-gray-700"
>비밀번호 확인</label
>
<input
id="password2"
v-model="password2"
type="password"
name="password2"
autocomplete="password2"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
비밀번호 저장
</button>
</div>
</div>
</form>
<form class="lg:col-span-9" @submit.prevent="doWithdrawal">
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
회원 탈퇴 처리
</h2>
<p class="mt-1 text-sm text-gray-500">
탈퇴 처리를 하시면 계정은 즉시 탈퇴처리 되며 일정기간
동일한 이메일로 재가입 없습니다.
</p>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
탈퇴 처리
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import {
RadioGroup,
RadioGroupDescription,
RadioGroupLabel,
RadioGroupOption,
} from '@headlessui/vue';
import { CheckCircleIcon } from '@heroicons/vue/24/solid/index.js';
definePageMeta({
middleware: 'check-auth-admin',
});
/*
const mailingLists = [
{
level: 0,
title: '일반 회원',
description: '서비스에 가입한 상태이나 아무런 권한이 없습니다.',
users: '표기 : user',
},
{
level: 5,
title: '어드민',
description: '모든 기능을 사용할 수 있는 전체 서비스 관리자입니다.',
users: '표기 : admin',
},
];
*/
const mailingLists = [
{
level: 0,
title: '일반 회원',
description: '서비스에 가입한 상태이나 아무런 권한이 없습니다.',
users: '표기 : user',
},
{
level: 3,
title: '회원사 운영자',
description: '사이트 가입 후 어드민이 승인한 사용자입니다.',
users: '표기 : op',
},
{
level: 4,
title: '수퍼 운영자',
description: '필터 단어 추가 권한이 부여되는 운영자 입니다.',
users: '표기 : super',
},
{
level: 5,
title: '어드민',
description: '모든 기능을 사용할 수 있는 전체 서비스 관리자입니다.',
users: '표기 : admin',
},
];
const selectedMailingLists = ref(mailingLists[0]);
const router = useRouter();
const route = useRoute();
const uid = ref('');
const email = ref('');
const displayName = ref('');
const photoUrl = ref('');
const phone = ref('');
const memo = ref('');
const created = ref('');
const password = ref('');
const password2 = ref('');
const limitCount = ref(5);
// email: '1@1', displayName: '1@1', phone: '', memo: ''
let userInfo = {};
function doHistory() {
navigateTo(
'/admin/user/' + route.params.uid + '/history/' + displayName.value
);
/*
router.push({
name: 'admin-user-history',
params: { hero: uid.value, name: displayName.value },
});
*/
}
async function doUpdateLimitCount() {
if (isNaN(limitCount.value)) {
alert('숫자만 입력할 수 있습니다. ');
return false;
} else if (
limitCount.value < 0 ||
limitCount.value > Number.MAX_SAFE_INTEGER
) {
alert(
'입력 가능 범위 내에서 선택해 주세요. 0 ~ ' +
Number.MAX_SAFE_INTEGER
);
return false;
}
const responseJson = await _crossCtl.doComm('update', 'admin:limitCount', {
limitCount: limitCount.value,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
async function doUpdateInfo() {
const responseJson = await _crossCtl.doComm('update', 'admin:profile', {
hero: hero,
displayName: displayName.value,
photoUrl: photoUrl.value,
infos: {
email: email.value,
phone: phone.value,
memo: memo.value,
},
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
async function doUpdateLevel() {
const responseJson = await _crossCtl.doComm('update', 'admin:level', {
hero: hero,
level: selectedMailingLists.value['level'],
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
async function doUpdatePassword() {
if (password.value == '') {
alert('변경할 비밀번호는 빈칸이면 안됩니다.');
} else if (password.value != password2.value) {
alert('변경할 비밀번호가 확인 입력과 일치하지 않습니다.');
} else {
const responseJson = await _crossCtl.doComm(
'update',
'admin:password',
{
hero: hero,
password_new: password.value,
password_again: password2.value,
}
);
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
}
async function doCancel() {
router.back();
}
async function doWithdrawal() {
if (window.confirm('이 회원의 탈퇴 처리를 하시겠습니까?')) {
const responseJson = await _crossCtl.doComm(
'update',
'admin:withdrawal',
{
hero: hero,
}
);
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
router.back();
} else {
alert(responseJson['responseMessage']);
}
}
}
async function filesChange(fieldName, fileList) {
// handle file changes
const formData = new FormData();
if (!fileList.length) return;
// append the files to FormData
Array.from(Array(fileList.length).keys()).map((x) => {
formData.append(fieldName, fileList[x], fileList[x].name);
});
// save it
console.log('formData=', formData);
formData.append('target', 'just');
const responseJson = await _crossCtl.doUpload('just', formData);
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
photoUrl.value =
_crossCtl.config['API_BASE_URL'].replace('/api/', '') +
responseJson['files'][0]['localUrl'];
} else {
alert(responseJson['responseMessage']);
}
}
const hero = route.params.uid as string;
console.log('hero=', hero);
const responseJson = await _crossCtl.doComm('select', 'admin:user:byid', {
hero: hero,
});
console.log('responseJson=', responseJson);
const { $customFormat } = useNuxtApp();
if (responseJson['responseMessage'] == 'ok') {
userInfo = responseJson['data'][0];
const tmpUserInfo = JSON.parse(userInfo['infos']);
limitCount.value = userInfo['limit_count'];
if (tmpUserInfo != null) {
uid.value = hero;
email.value = tmpUserInfo['email'];
displayName.value = userInfo['display_name'];
photoUrl.value = userInfo['photo_url'];
phone.value = tmpUserInfo['phone'];
memo.value = tmpUserInfo['memo'];
created.value = $customFormat(userInfo['created']);
} else {
email.value = 'NaN';
displayName.value = userInfo['display_name'];
photoUrl.value = userInfo['photo_url'];
phone.value = 'NaN';
memo.value = 'NaN';
}
switch (userInfo['user_level']) {
/*
case 0:
case 1:
case 2:
selectedMailingLists.value = mailingLists[0];
break;
case 3:
selectedMailingLists.value = mailingLists[0];
break;
case 4:
selectedMailingLists.value = mailingLists[0];
break;
case 5:
selectedMailingLists.value = mailingLists[1];
break;
*/
case 0:
case 1:
case 2:
selectedMailingLists.value = mailingLists[0];
break;
case 3:
selectedMailingLists.value = mailingLists[1];
break;
case 4:
selectedMailingLists.value = mailingLists[2];
break;
case 5:
selectedMailingLists.value = mailingLists[3];
break;
}
console.log('selectedMailingLists.value=', selectedMailingLists.value);
} else {
alert(responseJson['responseMessage']);
}
</script>

View File

@@ -0,0 +1,266 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="pb-8 px-4 sm:px-6 lg:px-8">
<br />
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
사용자 로그 보기
</h1>
<p class="mt-2 text-sm text-gray-700">
전체 사용자 로그를 보거나 특정 사용자 로그만을 검색해
있습니다.
</p>
</div>
<!--
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<label for="mobile-search-candidate" class="sr-only"
>Search</label
>
<label for="desktop-search-candidate" class="sr-only"
>Search</label
>
<div class="flex rounded-md shadow-sm">
<div class="relative flex-grow focus-within:z-10">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<MagnifyingGlassCircleIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
<input
id="mobile-search-candidate"
v-model="searchKeyword"
type="text"
name="mobile-search-candidate"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:hidden border-gray-300"
placeholder=""
/>
<input
id="desktop-search-candidate"
v-model="searchKeyword"
type="text"
name="desktop-search-candidate"
class="hidden focus:ring-indigo-500 focus:border-indigo-500 w-full rounded-none rounded-l-md pl-10 sm:block sm:text-sm border-gray-300"
placeholder=""
/>
</div>
<button
type="button"
class="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
@click="doAction('search', searchKeyword)"
>
<span class="ml-2">검색</span>
</button>
</div>
</div>
-->
</div>
<BaseList1
:headings="listHeadings"
:actions="listActions"
:keys="listKeys"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
<div class="p-0 rounded-bl-2xl rounded-br-2xl md:px-0">
<a
href="javascript:void(0)"
class="text-base font-medium text-indigo-700 hover:text-indigo-600"
@click="$router.back()"
>&larr;이전 화면으로<span aria-hidden="true"> </span
></a>
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
MagnifyingGlassCircleIcon,
} from '@heroicons/vue/24/solid';
import consolaGlobalInstance from 'consola';
const { $dayjs } = useNuxtApp();
const router = useRouter();
definePageMeta({
middleware: 'check-auth-admin',
});
const route = useRoute();
console.log('route.params=', route.params);
const targetName = route.params.hero;
const hero = route.params.uid;
console.log('targetName=', targetName);
console.log('hero=', hero);
let listTarget = 'log:user:active';
const listHeadings = [
{
title: '누가',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'uid',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '언제',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '무엇을',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'tag',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
const listActions = ['상세보기'];
const actionKey = 'serial';
const listKeys = [
'serial',
'raw',
'level',
'comment',
'status',
'updated',
'created',
];
const listData = ref([]);
const totalPageCount = ref(0);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
return $dayjs(val).format('YY/MM/DD A h:mm:ss');
// return $dayjs(val).format('YY/MM/DD');
} else if (key == 'uid') {
return '' + targetName + '';
} else if (key == 'status') {
let statusTag = '정상';
switch (val) {
case 0:
statusTag = '정상등록';
break;
case 4:
statusTag = '삭제됨';
break;
default:
statusTag = val;
}
return statusTag;
} else if (key == 'level') {
let levelTag = 'mid';
switch (val) {
case 10:
levelTag = 'high';
break;
case 50:
levelTag = 'mid';
break;
case 100:
levelTag = 'low';
break;
default:
levelTag = val;
}
return levelTag;
} else {
return val;
}
}
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
if (tag == '상세보기') {
navigateTo('/admin/user/' + hero + '/history/detail/' + target);
} else if (tag == 'search') {
console.log('search for ', target);
if (target == '') {
listTarget = 'log:user:active';
} else {
listTarget = 'log:user';
// hero = target;
}
refresh();
}
}
function pageMove(targetPageIdex) {
console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
const responseJson = await _crossCtl.doComm('list', listTarget, {
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
hero: hero,
});
console.log('listTarget=', listTarget);
console.log('hero=', hero);
console.log('responseJson=', responseJson);
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
if (hero != undefined) {
doAction('search', hero);
} else {
refresh();
}
</script>

View File

@@ -0,0 +1,232 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="divide-y divide-gray-200">
<form class="lg:col-span-9">
<!-- Profile section -->
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h2 class="text-lg leading-6 font-medium text-gray-900">
로그 상세 보기
</h2>
<p class="mt-1 text-sm text-gray-500"></p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"></div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>이메일</label
>
<input
id="email"
v-model="email"
disabled
type="text"
name="email"
autocomplete="email"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6"></div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="username"
class="block text-sm font-medium text-gray-700"
>
사용자 이름
</label>
<div class="mt-1 rounded-md shadow-sm flex">
<input
id="username"
v-model="displayName"
disabled
type="text"
name="username"
autocomplete="username"
class="focus:ring-sky-500 focus:border-sky-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
/>
</div>
</div>
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-700"
>
전화번호
</label>
<div class="mt-1 rounded-md shadow-sm flex">
<input
id="phone"
v-model="phone"
disabled
type="text"
name="phone"
autocomplete="phone"
class="focus:ring-sky-500 focus:border-sky-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
/>
</div>
</div>
<div>
<label
for="about"
class="block text-sm font-medium text-gray-700"
>
간단한 소개
</label>
<div class="mt-1">
<textarea
id="about"
v-model="memo"
disabled
name="about"
rows="3"
class="shadow-sm focus:ring-sky-500 focus:border-sky-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
/>
</div>
<p class="mt-2 text-sm text-gray-500">
로그 참고 사항으로 수정할 없습니다.
</p>
</div>
</div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>로그 태그</label
>
<input
id="logTag"
v-model="logTag"
disabled
type="text"
name="logTag"
autocomplete="logTag"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6"></div>
</div>
<div class="mt-6">
<div>
<label
for="about"
class="block text-sm font-medium text-gray-700"
>
부가 정보
</label>
<div class="mt-1">
<vue-json-pretty
class="bg-white shadow-sm focus:ring-sky-500 focus:border-sky-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
:path="'root'"
:data="logMemo"
>
</vue-json-pretty>
</div>
<p class="mt-2 text-sm text-gray-500"></p>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="button"
class="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
@click="doCancel"
>
이전 화면으로
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'check-auth-admin',
});
const router = useRouter();
const route = useRoute();
const email = ref('');
const displayName = ref('');
const photoUrl = ref('');
const phone = ref('');
const memo = ref('');
const logTag = ref('');
const logMemo = ref('');
let logInfo = {};
let userInfo = {};
async function doCancel() {
router.back();
}
console.log('route.params=', route.params);
const hero = route.params.hero;
const uid = route.params.uid;
console.log('hero=', hero);
let responseJson = await _crossCtl.doComm('select', 'admin:user:byid', {
hero: uid,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
userInfo = responseJson['data'][0];
const tmpUserInfo = _utils.safeJSON(userInfo['infos']);
if (tmpUserInfo != null) {
email.value = tmpUserInfo['email'];
displayName.value = userInfo['display_name'];
photoUrl.value = userInfo['photo_url'];
phone.value = tmpUserInfo['phone'];
memo.value = tmpUserInfo['memo'];
} else {
email.value = 'NaN';
displayName.value = userInfo['display_name'];
photoUrl.value = userInfo['photo_url'];
phone.value = 'NaN';
memo.value = 'NaN';
}
responseJson = await _crossCtl.doComm('select', 'log:user', {
hero: hero,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
logInfo = responseJson['data'][0];
const tmpLogMemo = _utils.safeJSON(logInfo['memo']);
console.log('logInfo = ', logInfo);
console.log('tmpLogMemo = ', tmpLogMemo);
logTag.value = logInfo['tag'];
logMemo.value = tmpLogMemo;
} else {
alert(responseJson['responseMessage']);
}
} else {
alert(responseJson['responseMessage']);
}
</script>

View File

@@ -0,0 +1,156 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="pb-8 px-4 sm:px-6 lg:px-8">
<br />
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
사용자 리스트
</h1>
<p class="mt-2 text-sm text-gray-700">
전체 등록 사용자 리스트를 확인할 있습니다.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"></div>
</div>
<BaseList1
:headings="listHeadings"
:actions="listActions"
:keys="listKeys"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</template>
<script setup lang="ts">
const router = useRouter();
definePageMeta({
middleware: 'check-auth-admin',
});
const listHeadings = [
{
title: '이름',
class: 'py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6',
key: 'name',
hiddenInfo: {
headClass: 'font-normal lg:hidden',
dts: [
{ class: 'sr-only', title: '소속' },
{ class: 'sr-only sm:hidden', title: '이메일' },
],
dds: [
{ class: 'mt-1 truncate text-gray-700', key: 'domain' },
{
class: 'mt-1 truncate text-gray-500 sm:hidden',
key: 'email',
},
],
},
subClass:
'w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6',
},
{
title: '소속',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'domain',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '이메일',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'email',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '역할',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'role',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
];
const listActions = ['상세보기'];
const actionKey = 'uid';
const listKeys = ['serial', 'uid', 'name', 'domain', 'email', 'role'];
const listData = ref([]);
const totalPageCount = ref(0);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
// return $dayjs(val).format('YY/MM/DD A h:mm:ss');
return $dayjs(val).format('YY/MM/DD');
} else {
return val;
}
}
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
navigateTo('/admin/user/' + target + '/edit');
}
function pageMove(targetPageIdex) {
console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
if (process.client) {
const responseJson = await _crossCtl.doComm(
'list',
'admin:users:level:all',
{
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
}
);
console.log('responseJson=', responseJson);
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
refresh();
</script>

View File

@@ -0,0 +1,298 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="divide-y divide-gray-200">
<form class="lg:col-span-9" @submit.prevent="doUpdate">
<!-- Profile section -->
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h2 class="text-lg leading-6 font-medium text-gray-900">
가입 허가 항목 수정
</h2>
<p class="mt-1 text-sm text-gray-500">
이미 가입된 사용자에게는 적용되지 않으며, 항목이
저장된 상태에서 가입시에만 적용됩니다.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"></div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="uid"
class="block text-sm font-medium text-gray-700"
>이메일</label
>
<input
id="uid"
v-model="uid"
disabled
type="text"
name="uid"
autocomplete="uid"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6">
<label
for="uid"
class="block text-sm font-medium text-gray-700"
>생성일</label
>
<input
id="created"
v-model="created"
disabled
type="text"
name="created"
autocomplete="created"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="memo"
class="block text-sm font-medium text-gray-700"
>
운영자 메모
</label>
<div class="mt-1">
<textarea
id="memo"
v-model="memo"
name="memo"
rows="3"
class="shadow-sm focus:ring-sky-500 focus:border-sky-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
/>
</div>
<p class="mt-2 text-sm text-gray-500">
관리자에게만 보여지는 메모 항목 입니다. 관리
편의를 위한 내용을 기입해 주세요.
</p>
</div>
</div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="level"
class="block text-sm font-medium text-gray-700"
>
자격 설정
</label>
<div class="mt-1">
<RadioGroup v-model="selectedUserLevelInfo">
<div
class="mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-4 sm:gap-x-4"
>
<RadioGroupOption
v-for="userLevel in userLevels"
:key="userLevel.level"
v-slot="{ checked, active }"
as="template"
:value="userLevel"
>
<div
:class="[
checked
? 'border-transparent'
: 'border-gray-300',
active
? 'border-indigo-500 ring-2 ring-indigo-500'
: '',
'relative bg-white border rounded-lg shadow-sm p-4 flex cursor-pointer focus:outline-none',
]"
>
<span class="flex-1 flex">
<span class="flex flex-col">
<RadioGroupLabel
as="span"
class="block text-sm font-medium text-gray-900"
>
{{
userLevel.title
}}
</RadioGroupLabel>
<RadioGroupDescription
as="span"
class="mt-1 flex items-center text-sm text-gray-500"
>
{{
userLevel.description
}}
</RadioGroupDescription>
<RadioGroupDescription
as="span"
class="mt-6 text-sm font-medium text-gray-900"
>
{{
userLevel.users
}}
</RadioGroupDescription>
</span>
</span>
<CheckCircleIcon
:class="[
!checked
? 'invisible'
: '',
'h-5 w-5 text-indigo-600',
]"
aria-hidden="true"
/>
<span
:class="[
active
? 'border'
: 'border-2',
checked
? 'border-indigo-500'
: 'border-transparent',
'absolute -inset-px rounded-lg pointer-events-none',
]"
aria-hidden="true"
/>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</div>
<p class="mt-2 text-sm text-gray-500">
해당 계정이 가입하면 자동으로 보여될 사용자
자격을 설정합니다.
</p>
</div>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="button"
class="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
@click="doCancel"
>
닫기
</button>
<button
type="button"
class="ml-5 bg-red-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
@click="doToggle"
>
{{ status == 0 ? '삭제' : '복구' }}
</button>
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
저장
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import {
RadioGroup,
RadioGroupDescription,
RadioGroupLabel,
RadioGroupOption,
} from '@headlessui/vue';
import { CheckCircleIcon } from '@heroicons/vue/24/solid/index.js';
import { stat } from 'fs/promises';
definePageMeta({
middleware: 'check-auth-admin',
});
const userLevels = _crossCtl.siteConfig.userLevels;
const selectedUserLevelInfo = ref(_crossCtl.siteConfig.userLevels[0]);
const router = useRouter();
const route = useRoute();
const serial = ref(0);
const uid = ref('');
const level = ref(0);
const memo = ref('');
const status = ref(0);
const created = ref('');
// email: '1@1', displayName: '1@1', phone: '', memo: ''
let userInfo = {};
function doToggle() {
status.value = status.value == 0 ? 4 : 0;
doUpdate();
}
async function doUpdate() {
const responseJson = await _crossCtl.doComm('update', 'admin:white', {
hero: serial.value,
uid: uid.value,
level: selectedUserLevelInfo.value['level'],
memo: memo.value,
status: status.value,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
async function doCancel() {
router.back();
}
const hero = route.params.hero as string;
console.log('hero=', hero);
const responseJson = await _crossCtl.doComm('select', 'admin:white', {
hero: hero,
});
console.log('responseJson=', responseJson);
const { $customFormat } = useNuxtApp();
if (responseJson['responseMessage'] == 'ok') {
userInfo = responseJson['data'][0];
serial.value = userInfo['serial'];
uid.value = userInfo['uid'];
level.value = userInfo['level'];
memo.value = userInfo['memo'];
status.value = userInfo['status'];
created.value = $customFormat(userInfo['created']);
for (let i = 0; i < _crossCtl.siteConfig.userLevels.length; i++) {
const tmpLevelInfo = _crossCtl.siteConfig.userLevels[i];
if (tmpLevelInfo.level == level.value) {
selectedUserLevelInfo.value = tmpLevelInfo;
break;
}
}
console.log('selectedUserLevelInfo.value=', selectedUserLevelInfo.value);
} else {
alert(responseJson['responseMessage']);
}
</script>

View File

@@ -0,0 +1,154 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="pb-8 px-4 sm:px-6 lg:px-8">
<br />
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
가입 허가 리스트
</h1>
<p class="mt-2 text-sm text-gray-700">
가입 사전 허가 리스트를 확인할 있습니다.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="navigateTo('/admin/user/white/new')"
>
계정 추가
</button>
</div>
</div>
<BaseTable2
:headings="listHeadings"
:actions="listActions"
:keys="listKeys"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</template>
<script setup lang="ts">
const router = useRouter();
definePageMeta({
middleware: 'check-auth-admin',
});
const listHeadings = [
{
title: '아이디',
widthRatio: '100',
key: 'uid',
},
{
title: '역할',
widthRatio: '',
key: 'level',
},
{
title: '상태',
widthRatio: '',
key: 'status',
},
{
title: '생성일',
widthRatio: '',
key: 'created',
},
];
const listActions = ['상세보기'];
const actionKey = 'uid';
const listKeys = ['serial', 'uid', 'name', 'domain', 'email', 'role'];
const listData = ref([]);
const totalPageCount = ref(0);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
return $dayjs(val).format('YY/MM/DD A h:mm:ss');
// return $dayjs(val).format('YY/MM/DD');
}
if (key == 'status') {
if (val == 0) {
return '정상';
} else if (val == 4) {
return '삭제';
} else {
return 'unknown(' + val + ')';
}
// return $dayjs(val).format('YY/MM/DD');
}
if (key == 'level') {
switch (val) {
case 0:
case 1:
case 2:
case 3:
case 4:
case 5:
return _crossCtl.siteConfig.userLevelInfo[val]['title'];
break;
default:
return 'unknown(' + val + ')';
}
} else {
return val;
}
}
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
navigateTo('/admin/user/white/edit/' + target);
}
function pageMove(targetPageIdex) {
console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
if (process.client) {
const responseJson = await _crossCtl.doComm('list', 'admin:white', {
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
});
console.log('responseJson=', responseJson);
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
refresh();
</script>

View File

@@ -0,0 +1,244 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="divide-y divide-gray-200">
<form class="lg:col-span-9" @submit.prevent="doInsert">
<!-- Profile section -->
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h2 class="text-lg leading-6 font-medium text-gray-900">
가입 허가 항목 생성
</h2>
<p class="mt-1 text-sm text-gray-500">
이미 가입된 사용자에게는 적용되지 않으며, 항목이
저장된 상태 이후 가입시에만 적용됩니다.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"></div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="uid"
class="block text-sm font-medium text-gray-700"
>이메일</label
>
<input
id="uid"
v-model="uid"
type="text"
name="uid"
autocomplete="uid"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="memo"
class="block text-sm font-medium text-gray-700"
>
운영자 메모
</label>
<div class="mt-1">
<textarea
id="memo"
v-model="memo"
name="memo"
rows="3"
class="shadow-sm focus:ring-sky-500 focus:border-sky-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
/>
</div>
<p class="mt-2 text-sm text-gray-500">
관리자에게만 보여지는 메모 항목 입니다. 관리
편의를 위한 내용을 기입해 주세요.
</p>
</div>
</div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="level"
class="block text-sm font-medium text-gray-700"
>
자격 설정
</label>
<div class="mt-1">
<RadioGroup v-model="selectedUserLevelInfo">
<div
class="mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-4 sm:gap-x-4"
>
<RadioGroupOption
v-for="userLevel in userLevels"
:key="userLevel.level"
v-slot="{ checked, active }"
as="template"
:value="userLevel"
>
<div
:class="[
checked
? 'border-transparent'
: 'border-gray-300',
active
? 'border-indigo-500 ring-2 ring-indigo-500'
: '',
'relative bg-white border rounded-lg shadow-sm p-4 flex cursor-pointer focus:outline-none',
]"
>
<span class="flex-1 flex">
<span class="flex flex-col">
<RadioGroupLabel
as="span"
class="block text-sm font-medium text-gray-900"
>
{{
userLevel.title
}}
</RadioGroupLabel>
<RadioGroupDescription
as="span"
class="mt-1 flex items-center text-sm text-gray-500"
>
{{
userLevel.description
}}
</RadioGroupDescription>
<RadioGroupDescription
as="span"
class="mt-6 text-sm font-medium text-gray-900"
>
{{
userLevel.users
}}
</RadioGroupDescription>
</span>
</span>
<CheckCircleIcon
:class="[
!checked
? 'invisible'
: '',
'h-5 w-5 text-indigo-600',
]"
aria-hidden="true"
/>
<span
:class="[
active
? 'border'
: 'border-2',
checked
? 'border-indigo-500'
: 'border-transparent',
'absolute -inset-px rounded-lg pointer-events-none',
]"
aria-hidden="true"
/>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</div>
<p class="mt-2 text-sm text-gray-500">
해당 계정이 가입하면 자동으로 보여될 사용자
자격을 설정합니다.
</p>
</div>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="button"
class="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
저장
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import {
RadioGroup,
RadioGroupDescription,
RadioGroupLabel,
RadioGroupOption,
} from '@headlessui/vue';
import { CheckCircleIcon } from '@heroicons/vue/24/solid/index.js';
definePageMeta({
middleware: 'check-auth-admin',
});
const userLevels = _crossCtl.siteConfig.userLevels;
const selectedUserLevelInfo = ref(_crossCtl.siteConfig.userLevels[0]);
const router = useRouter();
const uid = ref('');
const level = ref(3);
const memo = ref('');
// email: '1@1', displayName: '1@1', phone: '', memo: ''
for (let i = 0; i < _crossCtl.siteConfig.userLevels.length; i++) {
const tmpLevelInfo = _crossCtl.siteConfig.userLevels[i];
if (tmpLevelInfo.level == level.value) {
selectedUserLevelInfo.value = tmpLevelInfo;
break;
}
}
async function doInsert() {
const tmpUID = uid.value;
console.log('tmpUID = ', tmpUID);
if (tmpUID.trim() == '') {
alert('이메일 주소를 아이디로 입력해 주세요.');
return;
}
const responseJson = await _crossCtl.doComm('insert', 'admin:white', {
uid: uid.value,
level: selectedUserLevelInfo.value['level'],
memo: memo.value,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
router.back();
} else {
if (responseJson['responseMessage'].startsWith('ER_DUP_ENTRY:')) {
alert('이미 동일 아이디가 설정되어 있습니다.');
} else {
alert(responseJson['responseMessage']);
}
}
}
async function doCancel() {
router.back();
}
</script>

View File

@@ -0,0 +1,332 @@
<template>
<div>
<!-- Page head goes here -->
<div class="px-4 py-5 sm:px-6">
<div
class="flex justify-between items-center flex-wrap sm:flex-nowrap"
>
<div class="">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ pageTitle }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ pageDescription }}
</p>
</div>
<div class="flex-shrink-0">
<button
v-for="(headingAction, index) in headingActions"
:key="headingAction"
type="button"
:class="index > 0 ? 'ml-3' : ''"
class="btn btn-sm btn-primary"
@click="doHeadingAction(headingAction)"
>
{{ headingAction }}
</button>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto sm:px-4 lg:px-4">
<!-- Content goes here -->
<input
id="title"
v-model="title"
type="text"
name="title"
placeholder="제목을 입력하세요."
class="mb-3 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
<quill-editor
v-model:content="content"
class="min-h-[30rem]"
:modules="modules"
:toolbar="toolbarOptions"
placeholder="본문을 입력하세요."
@ready="onEditorReady($event)"
/>
<base-attachment-ctl1
v-if="attachmentEnabled"
:attachments="attachments"
:read-only-flag="false"
:update-attachments="updateAttachments"
/>
<div class="mt-5 flex justify-between items-center flex-wrap">
<div class="ml-4 mt-4"></div>
<div class="ml-4 mt-4 flex-shrink-0">
<button
type="button"
class="inline-flex items-center rounded-md border border-transparent bg-red-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
@click="doFooterAction('취소')"
>
{{ '취소' }}
</button>
<button
type="button"
class="ml-3 inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doFooterAction('저장')"
>
{{ '저장' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import BlotFormatter from 'quill-blot-formatter/dist/BlotFormatter';
definePageMeta({
// middleware: 'check-auth-admin',
});
const route = useRoute();
const bid = ref('');
let cid: string | string[] = '';
bid.value = route.params.boardId[0];
cid = route.params['_cid'];
const modules = {
name: 'blotFormatter',
module: BlotFormatter,
options: {},
};
const pageTitle = cid == undefined ? '광장 - 새글 작성' : '광장 - 글 수정';
const pageDescription =
cid == undefined ? '새로운 글을 작성 합니다.' : '내 글을 수정합니다.';
// 해당 페이지 우측 상단에 표시될 액션 버튼들
const headingActions = [];
const title = ref('');
const content = ref({ ops: [] });
const attachments = ref([]);
const status = ref(0);
function updateAttachments(newAttachments) {
console.log('newAttachments=', newAttachments);
attachments.value = newAttachments;
}
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
// [{ header: 1 }, { header: 2 }], // custom button values
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }], // superscript/subscript
[{ indent: '-1' }, { indent: '+1' }], // outdent/indent
// [{ direction: 'rtl' }], // text direction
// [{ size: ['small', false, 'large', 'huge'] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ font: [] }],
[{ align: [] }],
['link', 'video', 'image'],
['clean'], // remove formatting button
];
let quill = null;
function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.click();
// Listen upload local image and save to server
input.onchange = () => {
const file = input.files[0];
// file type is only image.
if (/^image\//.test(file.type)) {
console.warn('upload images...');
saveToServer(file);
} else {
console.warn('You could only upload images.');
}
};
}
function selectLocalFiles() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.click();
// Listen upload local image and save to server
input.onchange = () => {
console.log('we got file(s) : ', input.files);
};
}
async function saveToServer(file: File) {
const formData = new FormData();
formData.append('upload-file', file, file.name);
// save it
formData.append('target', 'just');
console.log('formData=', formData);
const responseJson = await _crossCtl.doUpload('just', formData);
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
insertToEditor(
_crossCtl.config['API_BASE_URL'].replace('/api/', '') +
responseJson['files'][0]['localUrl']
);
} else {
}
}
function insertToEditor(url: string) {
// push image url to rich editor.
const range = quill.getSelection();
quill.insertEmbed(range.index, 'image', url);
}
function onEditorReady(e) {
console.log('onEditorReady() e = ', e);
quill = e;
// quill editor add image handler
e.getModule('toolbar').addHandler('image', () => {
selectLocalImage();
});
e.getModule('toolbar').addHandler('attachment', () => {
selectLocalFiles();
});
if (cid != undefined) {
loadContent();
}
}
const attachmentEnabled = ref(false);
async function loadContent() {
const responseJson = await _crossCtl.doComm('select', 'board', {
boardId: bid.value,
hero: cid,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
console.log('huk responseJson=', responseJson);
const tmpDatas = responseJson['data'];
console.log('huk tmpDatas=', tmpDatas);
if (tmpDatas.length == 1) {
attachmentEnabled.value =
responseJson['metaData']['attachmentEnabled'];
title.value = tmpDatas[0]['title'];
// content.value = tmpDatas[0]['content'];
quill.root.innerHTML = tmpDatas[0]['content'];
attachments.value = JSON.parse(tmpDatas[0]['attachments']);
status.value = tmpDatas[0]['status'];
// $dayjs(val).format('YY/MM/DD A h:mm:ss')
} else {
alert('bad count. count = ' + tmpDatas.length);
}
}
}
/*
const attachments = [
{ name: 'resume_front_end_developer.pdf', href: '#' },
{ name: 'coverletter_front_end_developer.pdf', href: '#' },
];
*/
const router = useRouter();
function doHeadingAction(tag) {
console.log('on doHeadingAction(), tag=', tag);
}
function doFooterAction(tag) {
console.log('on doFooterAction(), tag=', tag);
switch (tag) {
case '저장':
// console.log('quill.root.innerHTML=', quill.root.innerHTML);
// console.log('content=', content.value);
updateContent();
break;
case '취소':
// router.back();
navigateTo('/board/' + bid.value + '/list');
break;
}
}
async function updateContent() {
if (title.value.trim() == '') {
alert('제목을 입력해 주세요.');
return;
}
let emptyContent = true;
console.log('content.value = ', content.value);
console.log('quill.root.innerHTML = ', quill.root.innerHTML);
for (let i = 0; i < content.value['ops'].length; i++) {
console.log("content.value['ops'][i] = ", content.value['ops'][i]);
if (typeof content.value['ops'][i]['insert'] == 'string') {
if (content.value['ops'][i]['insert'].trim() != '') {
emptyContent = false;
break;
}
} else {
emptyContent = false;
break;
}
}
if (emptyContent == true) {
alert('본문을 입력해 주세요.');
return;
}
const responseJson = await _crossCtl.doComm(
cid == undefined ? 'insert' : 'update',
'board',
{
boardId: bid.value,
hero: cid,
title: title.value,
content: quill.root.innerHTML,
attachments: attachments.value,
status: status.value,
}
);
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
// router.back();
navigateTo('/board/' + bid.value + '/list');
}
}
// refresh();
</script>

View File

@@ -0,0 +1,216 @@
<template>
<div>
<!-- Page head goes here -->
<div class="px-3 py-5">
<div
class="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap"
>
<div class="ml-4 mt-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ pageTitle }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ pageDescription }}
</p>
</div>
<div class="ml-4 mt-4 flex-shrink-0">
<button
v-for="(headingAction, index) in headingActions"
:key="headingAction"
type="button"
:class="index > 0 ? 'ml-3' : ''"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="doHeadingAction(headingAction)"
>
{{ headingAction }}
</button>
</div>
</div>
</div>
<div class="max-w mx-auto px-3">
<!-- Content goes here -->
<BaseBoardList1
:headings="listHeadings"
:actions="listActions"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
// middleware: 'check-auth-user',
});
const pageTitle = ref('');
const pageDescription = ref('');
// 해당 페이지 우측 상단에 표시될 액션 버튼들
const headingActions = ['글쓰기'];
// 리스트 쓰는 경에만 해당. 안되는 경우 모두 지울것.
const listSource = 'list';
const listTarget = 'board';
const listActions = [];
const actionKey = 'cid';
const listHeadings = [
{
title: '제목',
widthRatio: '100',
key: 'title',
},
{
title: '글쓴이',
widthRatio: '0',
key: 'name',
},
{
title: '날짜',
widthRatio: '0',
key: 'created',
},
{
title: '조회',
widthRatio: '0',
key: 'hit_count',
},
{
title: '댓글',
widthRatio: '0',
key: 'comment_count',
},
];
const listData = ref([]);
const boardMeta = ref({});
const totalPageCount = ref(1);
const route = useRoute();
const currentPageNumber = ref(route.query.page ? Number(route.query.page) : 1);
const pageSize = ref(3);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
if (boardMeta.value['ago_enabled'] == 1) {
return $dayjs(val).fromNow();
} else {
return $dayjs(val).format('YYYY/MM/DD A h:mm:ss');
}
// return $dayjs(val).format('YY/MM/DD');
} else {
return val;
}
}
const router = useRouter();
function doHeadingAction(tag) {
// console.log('on doHeadingAction(), tag=', tag);
switch (tag) {
case '글쓰기':
navigateTo('/board/' + currnetBoardId.value + '/new');
break;
}
// alert('headingAction : ' + tag);
}
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
// alert('doAction : ' + tag + ', target = ' + target);
switch (tag) {
case '보기':
navigateTo('/board/' + currnetBoardId.value + '/view/' + target);
break;
}
}
function pageMove(targetPageIdex) {
// console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
navigateTo(
'/board/' + currnetBoardId.value + '/list?page=' + targetPageIdex
);
refresh();
}
async function refresh() {
const responseJson = await _crossCtl.doComm(listSource, listTarget, {
hero: currnetBoardId.value,
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
});
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] != 200) {
if (responseJson['responseMessage'] == 'Unauthorized') {
alert(
'이 게시판을 볼 수 있는 권한이 없습니다. 확인을 누르면 서비스 메인 화면으로 이동합니다. '
);
navigateTo('/', { replace: true });
} else {
alert(responseJson['responseMessage']);
}
} else {
boardMeta.value = responseJson['metaData'];
pageTitle.value = responseJson['metaData']['title'];
pageDescription.value = responseJson['metaData']['description'];
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
// refresh();
console.log('huk params = ', route.params);
const currnetBoardId = ref('');
if (route.params.boardId instanceof Array) {
if (route.params.boardId.length != 1) {
throwError('$404');
} else {
console.log('huk 3');
currnetBoardId.value = route.params.boardId[0];
}
} else {
throwError('$404');
}
refresh();
</script>

View File

@@ -0,0 +1,310 @@
<template>
<div>
<!-- Page head goes here -->
<div class="px-4 py-5 sm:px-6">
<div
class="flex justify-between items-center flex-wrap sm:flex-nowrap"
>
<div class="">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ pageTitle }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ pageDescription }}
</p>
</div>
<div class="flex-shrink-0">
<button
v-for="(headingAction, index) in headingActions"
:key="headingAction"
type="button"
:class="index > 0 ? 'ml-3' : ''"
class="btn btn-sm btn-primary"
@click="doHeadingAction(headingAction)"
>
{{ headingAction }}
</button>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto sm:px-4 lg:px-4">
<!-- Content goes here -->
<input
id="title"
v-model="title"
type="text"
name="title"
placeholder="제목을 입력하세요."
class="mb-3 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
<quill-editor
v-model:content="content"
class="min-h-[30rem]"
:modules="modules"
:toolbar="toolbarOptions"
placeholder="본문을 입력하세요."
@ready="onEditorReady($event)"
/>
<base-attachment-ctl1
v-if="attachmentEnabled"
:attachments="attachments"
:read-only-flag="false"
:update-attachments="updateAttachments"
:board-id="bid"
:secure-enabled="false"
/>
<div class="mt-5 flex justify-between items-center flex-wrap">
<div class="ml-4 mt-4"></div>
<div class="ml-4 mt-4 flex-shrink-0">
<button
type="button"
class="mr-3 inline-flex items-center rounded-md border border-transparent bg-red-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
@click="doFooterAction('취소')"
>
{{ '취소' }}
</button>
<button
type="button"
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doFooterAction('저장')"
>
{{ '저장' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import BlotFormatter from 'quill-blot-formatter/dist/BlotFormatter';
definePageMeta({
// middleware: 'check-auth-user',
});
const route = useRoute();
console.log('huk params = ', route.params);
const bid = ref('');
if (route.params.boardId instanceof Array) {
if (route.params.boardId.length != 1) {
throwError('$404');
} else {
console.log('huk 3');
bid.value = route.params.boardId[0];
}
} else {
throwError('$404');
}
console.log('huk bid = ', bid.value);
const modules = {
name: 'blotFormatter',
module: BlotFormatter,
options: {},
};
const responseJson = await _crossCtl.doComm('select', 'board:info', {
hero: bid.value,
});
console.log('responseJson = ', responseJson);
const boardInfo = ref({});
const attachmentEnabled = ref(false);
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
if (responseJson['data'].length > 0) {
boardInfo.value = responseJson['data'][0];
attachmentEnabled.value =
responseJson['data'][0]['attachment_enabled'] == 1;
console.log('attachmentEnabled.value=', attachmentEnabled.value);
}
}
const pageTitle = boardInfo.value['title'] + ' - ' + '새글 작성';
const pageDescription = '새로운 글을 작성 합니다.';
// 해당 페이지 우측 상단에 표시될 액션 버튼들
const headingActions = [];
const title = ref('');
const content = ref({ ops: [] });
const attachments = ref([]);
const status = ref(0);
function updateAttachments(newAttachments) {
console.log('newAttachments=', newAttachments);
attachments.value = newAttachments;
}
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
// [{ header: 1 }, { header: 2 }], // custom button values
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }], // superscript/subscript
[{ indent: '-1' }, { indent: '+1' }], // outdent/indent
// [{ direction: 'rtl' }], // text direction
// [{ size: ['small', false, 'large', 'huge'] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ font: [] }],
[{ align: [] }],
['link', 'video', 'image'],
['clean'], // remove formatting button
];
let quill = null;
function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.click();
// Listen upload local image and save to server
input.onchange = () => {
const file = input.files[0];
// file type is only image.
if (/^image\//.test(file.type)) {
console.warn('upload images...');
saveToServer(file);
} else {
console.warn('You could only upload images.');
}
};
}
function selectLocalFiles() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.click();
// Listen upload local image and save to server
input.onchange = () => {
console.log('we got file(s) : ', input.files);
};
}
async function saveToServer(file: File) {
const formData = new FormData();
formData.append('upload-file', file, file.name);
// save it
formData.append('target', 'just');
formData.append('attachedTo', bid.value);
console.log('formData=', formData);
const responseJson = await _crossCtl.doUpload('just', formData);
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
insertToEditor(
// _crossCtl.config['API_BASE_URL'].replace('/api/', '') +
responseJson['files'][0]['localUrl']
);
} else {
}
}
function insertToEditor(url: string) {
// push image url to rich editor.
const range = quill.getSelection();
quill.insertEmbed(range.index, 'image', url);
}
function onEditorReady(e) {
console.log('onEditorReady() e = ', e);
quill = e;
// quill editor add image handler
e.getModule('toolbar').addHandler('image', () => {
selectLocalImage();
});
}
function doFooterAction(tag) {
console.log('on doFooterAction(), tag=', tag);
switch (tag) {
case '저장':
// console.log('quill.root.innerHTML=', quill.root.innerHTML);
// console.log('content=', content.value);
updateContent();
break;
case '취소':
// router.back();
navigateTo('/board/' + bid.value + '/list', { replace: true });
break;
}
}
async function updateContent() {
if (title.value.trim() == '') {
alert('제목을 입력해 주세요.');
return;
}
let emptyContent = true;
console.log('content.value = ', content.value);
console.log('quill.root.innerHTML = ', quill.root.innerHTML);
for (let i = 0; i < content.value['ops'].length; i++) {
console.log("content.value['ops'][i] = ", content.value['ops'][i]);
if (typeof content.value['ops'][i]['insert'] == 'string') {
if (content.value['ops'][i]['insert'].trim() != '') {
emptyContent = false;
break;
}
} else {
emptyContent = false;
break;
}
}
if (emptyContent == true) {
alert('본문을 입력해 주세요.');
return;
}
const responseJson = await _crossCtl.doComm('insert', 'board', {
boardId: bid.value,
title: title.value,
content: quill.root.innerHTML,
attachments: attachments.value,
status: status.value,
});
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
// router.back();
navigateTo('/board/' + bid.value + '/list', { replace: true });
}
}
// refresh();
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div>
<!-- Page head goes here -->
<div class="max-w mx-auto px-3">
<!-- Content goes here -->
<BaseBoardView1
:name="name"
:profile-url="profileUrl"
:title="title"
:content="content"
:flags="flags"
:attachments="attachments"
:hit-count="hitCount"
:like-count="likeCount"
:dislike-count="dislikeCount"
:comment-count="commentCount"
:report-count="reportCount"
:status="status"
:updated="updated"
:created="created"
/>
<BaseAttachmentCtl1
v-if="attachmentEnabled"
:attachments="attachments"
:read-only-flag="true"
/>
<div class="mt-5 flex justify-between items-center flex-wrap">
<div class="ml-4 mt-4"></div>
<div class="ml-4 mt-4 flex-shrink-0">
<button
type="button"
class="mr-3 inline-flex items-center rounded-md border border-transparent bg-indigo-100 px-3 py-2 text-sm font-medium leading-4 text-indigo-700 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doFooterAction('닫기')"
>
{{ '닫기' }}
</button>
<button
v-if="myFlag"
type="button"
class="mr-3 inline-flex items-center rounded-md border border-transparent bg-red-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
@click="doFooterAction('삭제')"
>
{{ '삭제' }}
</button>
<button
v-if="myFlag"
type="button"
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="doFooterAction('수정')"
>
{{ '수정' }}
</button>
</div>
</div>
<BaseCommentCtl1
v-if="commentEnabled"
class="mt-5"
:tid="commentTargetId"
:read-only-flag="!_crossCtl.isAuthenticated"
/>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
// middleware: 'check-auth-user',
});
const route = useRoute();
console.log('huk params = ', route.params);
const bid = ref('');
let cid: string | string[] = '';
bid.value = route.params.boardId[0];
cid = route.params['_cid'];
console.log('huk 7');
const commentTargetId = ref(cid.toString());
const name = ref('');
const profileUrl = ref('');
const title = ref('');
const content = ref('');
const flags = ref([]);
const attachments = ref([]);
const hitCount = ref(0);
const likeCount = ref(0);
const dislikeCount = ref(0);
const commentCount = ref(0);
const reportCount = ref(0);
const status = ref(0);
const updated = ref('');
const created = ref('');
const myFlag = ref(false);
const commentEnabled = ref(false);
const attachmentEnabled = ref(false);
const { $dayjs } = useNuxtApp();
function doFooterAction(tag) {
console.log('on doFooterAction(), tag=', tag);
switch (tag) {
case '수정':
_crossCtl.openModal(
'confirm',
'수정 확인',
'정말로 수정하시겠습니까?',
['수정', '취소'],
(serial, btnIdx) => {
console.log('btnIdx=', btnIdx);
if (btnIdx == 0) {
navigateTo('/board/' + bid.value + '/edit/' + cid);
}
}
);
break;
case '삭제':
doDelete();
break;
case '닫기':
// router.back();
navigateTo('/board/' + bid.value + '/list', { replace: true });
break;
}
}
async function doDelete() {
_crossCtl.openModal(
'confirm',
'삭제 확인',
'정말로 삭제하시겠습니까?',
['삭제', '취소'],
async (serial, btnIdx) => {
console.log('btnIdx=', btnIdx);
if (btnIdx == 0) {
const responseJson = await _crossCtl.doComm('delete', 'board', {
boardId: bid.value,
hero: cid,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
// router.back();
navigateTo('/board/' + bid.value + '/list', {
replace: true,
});
}
}
}
);
}
const responseJson = await _crossCtl.doComm('select', 'board', {
boardId: bid.value,
hero: cid,
});
console.log('huk responseJson=', responseJson);
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
const tmpDatas = responseJson['data'];
console.log('huk tmpDatas=', tmpDatas);
if (tmpDatas.length == 1) {
commentEnabled.value = responseJson['metaData']['commentEnabled'];
attachmentEnabled.value = responseJson['metaData']['attachmentEnabled'];
name.value = tmpDatas[0]['name'];
profileUrl.value = tmpDatas[0]['profile_url'];
title.value = tmpDatas[0]['title'];
content.value = tmpDatas[0]['content'];
flags.value = JSON.parse(tmpDatas[0]['flags']);
attachments.value = JSON.parse(tmpDatas[0]['attachments']);
hitCount.value = tmpDatas[0]['hit_count'];
likeCount.value = tmpDatas[0]['like_count'];
dislikeCount.value = tmpDatas[0]['dislike_count'];
commentCount.value = tmpDatas[0]['comment_count'];
reportCount.value = tmpDatas[0]['report_count'];
status.value = tmpDatas[0]['status'];
updated.value = $dayjs(tmpDatas[0]['updated']).format(
'YY/MM/DD A h:mm:ss'
);
created.value = $dayjs(tmpDatas[0]['created']).format(
'YY/MM/DD A h:mm:ss'
);
myFlag.value = tmpDatas[0]['myFlag'];
// $dayjs(val).format('YY/MM/DD A h:mm:ss')
} else {
console.log('bad count. count = ' + tmpDatas.length);
}
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div class="prose p-0 max-w-none">
<ContentRenderer :value="targetData" />
</div>
</template>
<script setup lang="ts">
const route = useRoute();
let targetData = ref();
let target = '';
const { path } = useRoute();
if (route.params.target instanceof Array) {
switch (route.params.target[0]) {
case 'kss':
case 'api_doc':
case 'bill':
case 'guide':
// case 'privacy':
// case 'stipulation':
case 'contract':
case 'certification':
case 'manual':
target = route.params.target[0];
break;
}
if (target != '') {
const { data } = await useAsyncData(`content-${path}`, () => {
return queryContent().where({ _path: path }).findOne();
});
/*
const { data } = await useAsyncData('home', () =>
queryContent('/doc/' + target).findOne()
);
*/
targetData = data;
} else {
throwError('$404');
}
} else {
throwError('$404');
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div>
<div class="mt-5 sm:px-3 lg:px-5">구글 </div>
<div class="mt-5">
<GMapMap
:center="center"
:zoom="15"
:options="{
zoomControl: true,
mapTypeControl: false,
scaleControl: false,
streetViewControl: false,
rotateControl: false,
fullscreenControl: true,
}"
style="width: 100%; height: 500px; margin: auto"
>
<GMapMarker
v-for="(marker, index) in markers"
:key="index"
:position="marker.position"
:clickable="true"
:draggable="false"
@click="openMarker(marker.id)"
>
<GMapInfoWindow
:closeclick="true"
:opened="openedMarkerID === marker.id"
@closeclick="openMarker(null)"
>
<div>{{ marker.description }}</div>
</GMapInfoWindow>
</GMapMarker>
</GMapMap>
</div>
</div>
</template>
<script setup lang="ts">
const openedMarkerID = ref(null);
const center = { lat: 37.5488, lng: 127.0440105738147 };
const markers = [
{
description: 'Inspond Co., Ltd.',
title: '인스폰드',
label: ['라벨'],
id: '1',
position: {
lat: 37.5488,
lng: 127.0440105738147,
},
},
];
function openMarker(id) {
console.log('huk open, id = ', id);
openedMarkerID.value = id;
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="bg-gray-50">
<div class="max-w-7xl mx-auto py-6 space-y-6 sm:px-6 lg:px-8">
<div class="bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6">
<div class="bg-white">
<div
class="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:py-20 lg:px-8"
>
<div class="lg:grid lg:grid-cols-3 lg:gap-8">
<div>
<h2
class="text-3xl font-extrabold text-gray-900"
>
자주 묻는 질문과 답변
</h2>
<p class="mt-4 text-base text-gray-500">
궁금하신 내용이 이곳에 없다면
<a
href="javascript:void(0)"
class="font-medium text-indigo-600 hover:text-indigo-500"
@click="
$router.push('/support/inquiry')
"
>일대일 문의</a
>
메뉴를 통해 질문을 남기실 있습니다.
</p>
</div>
<div class="mt-12 lg:mt-0 lg:col-span-2">
<dl class="space-y-12">
<div v-if="listData.length == 0">
<dt
class="text-lg leading-6 font-medium text-gray-900"
>
아직 등록된 자주 묻는 질문과 답변이
없나요?
</dt>
<dd
class="mt-2 text-base text-gray-500"
>
. 아직은 등록된 내용이 없습니다.
</dd>
</div>
<div
v-for="item in listData"
v-else
:key="item.serial"
>
<BaseFaqItem1 :item="item" />
</div>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/*
definePageMeta({
middleware: 'check-auth-user',
});
*/
const listData = ref();
const inLoadingFlag = ref(false);
const statusMessage = ref('데이터를 읽어 오는 중...');
inLoadingFlag.value = true;
const responseJson = await _crossCtl.doComm('list', 'faq:active', {});
inLoadingFlag.value = false;
if (responseJson['responseCode'] == 200) {
listData.value = responseJson['data'];
} else {
statusMessage.value = responseJson['responseMessage'];
}
</script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,185 @@
<template>
<div class="pb-8 px-4 sm:px-6 lg:px-8">
<br />
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ pageTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ pageDescription }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
@click="makeNewOne"
>
문의 작성
</button>
</div>
</div>
<BaseList1
:headings="listHeadings"
:actions="listActions"
:keys="listKeys"
:data="listData"
:action-key="actionKey"
:column-filter="columnFilter"
:do-action="doAction"
/>
<BasePagination1
:total-page-count="totalPageCount"
:current-page-number="currentPageNumber"
:page-size="pageSize"
:records-total="recordsTotal"
:page-move="pageMove"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
definePageMeta({
middleware: 'check-auth-user',
});
const inquiryListOptionTags = ['대기중', '검토중', '답변완료', '삭제'];
const inquiryListOption = ref(0);
const pageTitle = '1:1 문의';
const pageDescription =
'나의 1:1 문의 내역을 확인하고 새로운 문의를 작성할 수 있습니다.';
const listSource = 'list';
const listTarget = 'inquiry:all';
const deletedListName = 'support-inquiry-deleted';
const makeNewTargetName = 'support-inquiry-new';
const listActions = ['상세보기'];
const actionKey = 'serial';
const listKeys = ['serial', 'name', 'title', 'status', 'updated'];
const listHeadings = [
{
title: '제목',
widthRatio: '',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 lg:table-cell',
key: 'title',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 lg:table-cell',
},
{
title: '상태',
widthRatio: '10',
class: 'px-3 py-3.5 text-left text-sm font-semibold text-gray-900',
key: 'status',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'px-3 py-4 text-sm text-gray-500',
},
{
title: '수정일',
widthRatio: '15',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'updated',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
{
title: '작성일',
widthRatio: '15',
class: 'hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 sm:table-cell',
key: 'created',
hiddenInfo: {
headClass: '',
dts: [],
dds: [],
},
subClass: 'hidden px-3 py-4 text-sm text-gray-500 sm:table-cell',
},
];
const doActionTargetName = 'support-inquiry-view';
const listData = ref([]);
const totalPageCount = ref(0);
const currentPageNumber = ref(1);
const pageSize = ref(10);
const recordsTotal = ref(0);
// const order = [{ column: 'serial', dir: 'desc' }];
// const columns = { serial: { data: 'serial' } };
const { $dayjs } = useNuxtApp();
function columnFilter(key, val) {
// console.log("columnFilter(), key = ", key, ", val = ", val);
if (key == 'updated' || key == 'created') {
// return $dayjs(val).format('YY/MM/DD A h:mm:ss');
return $dayjs(val).format('YY/MM/DD');
} else if (key == 'status') {
return inquiryListOptionTags[val];
} else {
return val;
}
}
const router = useRouter();
function doAction(tag, target) {
console.log('on doAction(), tag=', tag, ', target=', target);
navigateTo('/support/inquiry/view/' + target);
}
function makeNewOne() {
router.push({
name: makeNewTargetName,
params: {},
});
}
function pageMove(targetPageIdex) {
console.log('on pageMove(), targetPageIdex=', targetPageIdex);
currentPageNumber.value = targetPageIdex;
refresh();
}
async function refresh() {
const responseJson = await _crossCtl.doComm(listSource, listTarget, {
hero: inquiryListOption.value,
start: (currentPageNumber.value - 1) * pageSize.value,
length: pageSize.value,
});
if (responseJson['responseCode'] != 200) {
alert(responseJson['responseMessage']);
} else {
currentPageNumber.value = responseJson['currentPageNumber'];
totalPageCount.value = responseJson['totalPageCount'];
pageSize.value = parseInt(responseJson['pageSize']);
recordsTotal.value = responseJson['recordsTotal'];
listData.value = responseJson['data'];
}
}
refresh();
</script>

View File

@@ -0,0 +1,244 @@
<template>
<form @submit.prevent="doCreate">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ newTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ newDescription }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<img
v-if="inPregressFlag"
width="32"
src="/loading-load-2.gif"
/>
</div>
</div>
<div class="mt-2"></div>
<form action="#" class="relative">
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="title" class="sr-only">Title</label>
<input
id="title"
v-model="targetTitle"
type="text"
name="title"
class="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 focus:ring-0"
placeholder="제목"
/>
<label for="description" class="sr-only">Description</label>
<textarea
id="description"
v-model="targetContent"
rows="12"
name="description"
class="block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder="내용..."
/>
<!-- Spacer element to match the height of the toolbar -->
<div aria-hidden="true">
<div class="py-2">
<div class="h-9" />
</div>
<div class="h-px" />
<div class="py-2">
<div class="py-px">
<div class="h-9" />
</div>
</div>
</div>
</div>
<div class="absolute bottom-0 inset-x-px">
<!-- Actions: These are just examples to demonstrate the concept, replace/wire these up however makes sense for your project. -->
<div
class="flex flex-nowrap justify-end py-2 px-2 space-x-2 sm:px-3"
>
<Listbox v-model="labelled" as="div" class="flex-shrink-0">
<ListboxLabel class="sr-only">
Add a label
</ListboxLabel>
<div class="relative">
<ListboxButton
class="relative inline-flex items-center rounded-full py-2 px-2 bg-gray-50 text-sm font-medium text-gray-500 whitespace-nowrap hover:bg-gray-100 sm:px-3"
>
<TagIcon
:class="[
labelled.value === null
? 'text-gray-300'
: 'text-gray-500',
'flex-shrink-0 h-5 w-5 sm:-ml-1',
]"
aria-hidden="true"
/>
<span
:class="[
labelled.value === null
? ''
: 'text-gray-900',
'hidden truncate sm:ml-2 sm:block',
]"
>{{
labelled.value === null
? 'Label'
: labelled.name
}}</span
>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute right-0 z-10 mt-1 w-52 bg-white shadow max-h-56 rounded-lg py-3 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="label in labels"
:key="label.value"
v-slot="{ active }"
as="template"
:value="label"
>
<li
:class="[
active
? 'bg-gray-100'
: 'bg-white',
'cursor-default select-none relative py-2 px-3',
]"
>
<div class="flex items-center">
<span
class="block font-medium truncate"
>
{{ label.name }}
</span>
</div>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<base-attachment-ctl1
:attachments="attachments"
:read-only-flag="false"
:update-attachments="updateAttachments"
:secure-enabled="true"
/>
</div>
</form>
<div class="mt-2 flex justify-end">
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
저장
</button>
</div>
</form>
</template>
<script setup>
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from '@headlessui/vue';
import { PaperClipIcon, TagIcon } from '@heroicons/vue/24/solid';
const route = useRoute();
definePageMeta({
middleware: 'check-auth-user',
});
const labels = [
{ name: '라벨 없음', value: null },
{ name: '사이트 이용', value: 'site' },
{ name: 'API 문의', value: 'api' },
{ name: '기타', value: 'etc' },
// More items...
];
const labelled = ref(labels[0]);
const newTitle = '1:1 문의 작성';
const newDescription =
'문의 사항을 적어 주시고 필요한 경우 파일도 첨부하실 수 있습니다.';
const inPregressFlag = ref(false);
const targetTitle = ref('');
const targetContent = ref('');
const attachments = ref([]);
const targetStatus = ref(0);
const actionTarget = 'inquiry';
const router = useRouter();
function updateAttachments(newAttachments) {
console.log('newAttachments=', newAttachments);
attachments.value = newAttachments;
}
async function doCancel() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
router.back();
}
async function doCreate() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
if (targetTitle.value == '' || targetContent.value == '') {
alert('내용을 입력하셔야 합니다. ');
return;
}
inPregressFlag.value = true;
const responseJson = await _crossCtl.doComm('insert', actionTarget, {
title: targetTitle.value,
question: targetContent.value,
attachmentFrom: attachments.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
});
inPregressFlag.value = false;
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
router.back();
} else {
alert('오류 : ' + responseJson['responseMessage']);
}
}
</script>

View File

@@ -0,0 +1,384 @@
<template>
<form @submit.prevent="doUpdate">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{{ newTitle }}
</h1>
<p class="mt-2 text-sm text-gray-700">
{{ newDescription }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<img
v-if="inPregressFlag"
width="32"
src="/loading-load-2.gif"
/>
</div>
</div>
<div class="mt-2"></div>
<div>
<form action="#" class="relative">
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="title" class="sr-only">Title</label>
<input
id="title"
v-model="targetTitle"
:disabled="targetStatus != 0"
type="text"
name="title"
class="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 focus:ring-0"
placeholder="Title"
/>
<label for="description" class="sr-only">Description</label>
<textarea
id="description"
v-model="targetContent"
rows="8"
:disabled="targetStatus != 0"
name="description"
class="block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder="Write a description..."
/>
<!-- Spacer element to match the height of the toolbar -->
<div aria-hidden="true">
<div class="py-2">
<div class="h-9" />
</div>
<div class="h-px" />
<div class="py-2">
<div class="py-px">
<div class="h-9" />
</div>
</div>
</div>
</div>
<div class="absolute bottom-0 inset-x-px">
<!-- Actions: These are just examples to demonstrate the concept, replace/wire these up however makes sense for your project. -->
<div
class="flex flex-nowrap justify-end py-2 px-2 space-x-2 sm:px-3"
>
<Listbox
v-model="labelled"
as="div"
class="flex-shrink-0"
>
<ListboxLabel class="sr-only">
Add a label
</ListboxLabel>
<div class="relative">
<ListboxButton
:disabled="targetStatus != 0"
class="relative inline-flex items-center rounded-full py-2 px-2 bg-gray-50 text-sm font-medium text-gray-500 whitespace-nowrap hover:bg-gray-100 sm:px-3"
>
<TagIcon
:class="[
labelled.value === null
? 'text-gray-300'
: 'text-gray-500',
'flex-shrink-0 h-5 w-5 sm:-ml-1',
]"
aria-hidden="true"
/>
<span
:class="[
labelled.value === null
? ''
: 'text-gray-900',
'hidden truncate sm:ml-2 sm:block',
]"
>{{
labelled.value === null
? 'Label'
: labelled.name
}}</span
>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute right-0 z-10 mt-1 w-52 bg-white shadow max-h-56 rounded-lg py-3 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="label in labels"
:key="label.value"
v-slot="{ active }"
as="template"
:value="label"
>
<li
:class="[
active
? 'bg-gray-100'
: 'bg-white',
'cursor-default select-none relative py-2 px-3',
]"
>
<div class="flex items-center">
<span
class="block font-medium truncate"
>
{{ label.name }}
</span>
</div>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<base-attachment-ctl1
:attachments="targetAttachmentFrom"
:read-only-flag="targetStatus != 0"
:update-attachments="updateAttachments"
/>
</div>
</form>
<div v-if="targetStatus == 2">
<p class="mt-5 text-sm text-gray-700">답변</p>
<div
class="border border-gray-300 rounded-lg shadow-sm overflow-hidden focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<label for="answer" class="sr-only">answer</label>
<textarea
id="answer"
v-model="targetAnswer"
disabled
rows="8"
name="answer"
class="block w-full border-0 py-0 resize-none placeholder-gray-500 focus:ring-0 sm:text-sm"
placeholder=""
/>
</div>
<base-attachment-ctl1
:attachments="targetAttachmentTo"
:read-only-flag="true"
/>
</div>
</div>
<div class="mt-2 flex justify-between">
<div>
<button
v-if="currentTarget != 'inquiry'"
type="button"
:class="
targetStatus == 0
? 'inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
: 'inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
"
@click="doToggle"
>
{{ targetStatus == 0 ? '삭제' : '복구' }}
</button>
</div>
<div>
<button
v-if="targetStatus == 0"
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{{ targetStatus == 0 ? '수정' : '확인' }}
</button>
</div>
</div>
</form>
</template>
<script setup>
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from '@headlessui/vue';
import { TagIcon } from '@heroicons/vue/24/solid';
const route = useRoute();
definePageMeta({
middleware: 'check-auth-user',
});
let labels = [
{ name: '라벨 없음', value: null },
{ name: '공지', value: 'notice' },
{ name: '이벤트', value: 'event' },
// More items...
];
const labelled = ref(labels[0]);
const newTitle = ref('');
const newDescription = ref('');
const contentTitle = ref('');
const contentMessageGuide = ref('');
const currentTarget = ref('inquiry');
const actionTarget = 'inquiry';
const inPregressFlag = ref(false);
const targetTitle = ref('');
const targetContent = ref('');
const targetAttachmentFrom = ref([]);
const targetAnswer = ref('');
const targetAttachmentTo = ref([]);
const targetStatus = ref(0);
const inquiryListOptionTags = ['대기중', '검토중', '답변완료', '삭제'];
let targetCreated = '';
async function doToggle() {
if (targetStatus.value == 0) {
const responseJson = await _crossCtl.doComm('delete', actionTarget, {
hero: route.params.hero,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
targetStatus.value = 4;
alert('ok');
router.back();
} else {
alert(responseJson['responseMessage']);
}
} else {
router.back();
}
}
newTitle.value = '1:1 문의 처리';
newDescription.value =
'1:1 문의에 답을 입력하면 상태가 즉시 답변 완료로 변하지만, 내용은 추가 수정할 수 있습니다.';
contentTitle.value = '질문';
contentMessageGuide.value = '답변';
labels = [
{ name: '라벨 없음', value: null },
{ name: '사이트 이용', value: 'site' },
{ name: 'API 문의', value: 'api' },
{ name: '기타', value: 'etc' },
// More items...
];
console.log('route.params.hero=', route.params.hero);
const responseJson = await _crossCtl.doComm('select', currentTarget.value, {
hero: route.params.hero,
});
if (responseJson['responseCode'] == 200) {
console.log(responseJson['data']);
targetTitle.value = responseJson['data'][0]['title'];
targetContent.value = responseJson['data'][0]['question'];
targetAttachmentFrom.value = JSON.parse(
responseJson['data'][0]['attachment_from']
);
console.log('huk targetAttachmentFrom.value = ', targetAttachmentFrom);
targetAnswer.value = responseJson['data'][0]['answer'];
targetAttachmentTo.value = JSON.parse(
responseJson['data'][0]['attachment_to']
);
const tmpFlags =
responseJson['data'][0]['flags'] != null
? responseJson['data'][0]['flags']
: '[]';
const flags = JSON.parse(tmpFlags);
for (let i = 0; i < flags.length; i++) {
const flag = flags[i];
for (let j = 0; j < labels.length; j++) {
if (flag == labels[j]['value']) {
labelled.value = labels[j];
}
}
}
targetCreated = responseJson['data'][0]['created'];
targetStatus.value = responseJson['data'][0]['status'];
} else {
alert(responseJson['responseMessage']);
}
// console.log('huk route.params.target=', route.params.target);
const router = useRouter();
async function doCancel() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
router.back();
}
function updateAttachments(newAttachments) {
console.log('newAttachments=', newAttachments);
targetAttachmentFrom.value = newAttachments;
}
async function doUpdate() {
if (inPregressFlag.value == true) {
alert('이전 동작이 아직 진행중입니다.');
return;
}
if (targetTitle.value == '' || targetContent.value == '') {
alert('내용을 입력하셔야 합니다. ');
return;
}
if (targetStatus.value != 0) {
router.back();
return;
}
inPregressFlag.value = true;
const responseJson = await _crossCtl.doComm('update', actionTarget, {
hero: route.params.hero,
title: targetTitle.value,
question: targetContent.value,
attachmentFrom: targetAttachmentFrom.value,
flags: labelled.value['value']
? JSON.stringify([labelled.value['value']])
: JSON.stringify([]),
status: targetStatus.value,
});
inPregressFlag.value = false;
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
router.back();
} else {
alert('오류 : ' + responseJson['responseMessage']);
}
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<ul v-if="inLoadingFlag">
<li>
<!-- This example requires Tailwind CSS v2.0+ -->
<div class="rounded-md bg-green-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<!-- Heroicon name: check-circle -->
<svg
class="h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
{{ statusMessage }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5"></div>
</div>
</div>
</div>
</li>
</ul>
<ul v-else-if="statusMessage != null">
<li>
<div class="rounded-md bg-red-50 p-4 m-5">
<div class="flex">
<div class="flex-shrink-0">
<!-- Heroicon name: x-circle -->
<svg
class="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
오류가 발생하였습니다.
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<li>
{{ statusMessage }}
</li>
</ul>
</div>
</div>
</div>
</div>
</li>
</ul>
<div v-else>
<ul v-if="listData.length > 0">
<li v-for="noticeItem in listData" :key="noticeItem.serial">
<BaseNoticeItem1 :item="noticeItem" />
</li>
</ul>
<div v-else>
<div class="rounded-md bg-red-50 p-4 m-5">
<div class="flex">
<div class="flex-shrink-0">
<!-- Heroicon name: x-circle -->
<svg
class="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
등록된 공지가 없습니다.
</h3>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'check-auth-user',
});
const listData = ref();
const inLoadingFlag = ref(true);
const statusMessage = ref('데이터를 읽어 오는 중...');
inLoadingFlag.value = true;
const responseJson = await _crossCtl.doComm('local/list', 'notice', {});
inLoadingFlag.value = false;
if (responseJson['responseCode'] == 200) {
listData.value = responseJson['data'];
statusMessage.value = null;
} else {
statusMessage.value = responseJson['responseMessage'];
}
</script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,451 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="divide-y divide-gray-200">
<form class="lg:col-span-9" @submit.prevent="doUpdateInfo">
<!-- Profile section -->
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
사용자 정보 확인, 변경
</h2>
<p class="mt-1 text-sm text-gray-500">
일부 정보는 다른 사용자들에게 보여질 있으니 신중하게
입력해 주세요.
</p>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>이메일</label
>
<input
id="email"
v-model="email"
disabled
type="text"
name="email"
autocomplete="email"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6"></div>
</div>
<div class="mt-6 flex flex-col lg:flex-row">
<div class="flex-grow space-y-6">
<div>
<label
for="username"
class="block text-sm font-medium text-gray-700"
>
사용자 이름
</label>
<div class="mt-1 rounded-md shadow-sm flex">
<input
id="username"
v-model="displayName"
type="text"
name="username"
autocomplete="username"
class="focus:ring-sky-500 focus:border-sky-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
/>
</div>
</div>
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-700"
>
전화번호
</label>
<div class="mt-1 rounded-md shadow-sm flex">
<input
id="phone"
v-model="phone"
type="text"
name="phone"
autocomplete="phone"
class="focus:ring-sky-500 focus:border-sky-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
/>
</div>
</div>
<div>
<label
for="about"
class="block text-sm font-medium text-gray-700"
>
간단한 소개
</label>
<div class="mt-1">
<textarea
id="about"
v-model="memo"
name="about"
rows="3"
class="shadow-sm focus:ring-sky-500 focus:border-sky-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md"
/>
</div>
<p class="mt-2 text-sm text-gray-500">
관리자나 다른 사용자가 당신을 식별할 있도록
간단한 소개를 입력해 주세요.
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700"
>
프로필 사진
</label>
<div class="mt-2 flex items-center space-x-5">
<span
v-if="photoUrl == ''"
class="inline-block h-12 w-12 rounded-full overflow-hidden bg-gray-100"
>
<svg
class="h-full w-full text-gray-300"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
</span>
<img
v-else
class="inline-block h-12 w-12 rounded-full border"
:src="photoUrl"
alt=""
/>
</div>
<div class="col-span-2 sm:col-span-2 pt-3">
<div class="mt-1 flex rounded-md shadow-sm">
<div
class="relative flex items-stretch flex-grow focus-within:z-10"
>
<input
id="photoUrl"
v-model="photoUrl"
type="text"
name="photoUrl"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md sm:text-sm border-gray-300"
placeholder="http://"
/>
</div>
</div>
<p class="mt-2 text-sm text-gray-500">
프로필로 사용하실 이미지의 주소를
입력하세요.
</p>
</div>
</div>
<div>
<div
class="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
@dragover.prevent
@dragenter.prevent
@drop.prevent="
filesChange(
'upload-file',
$event.dataTransfer.files
)
"
>
<div class="space-y-1 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<div class="flex text-sm text-gray-600">
<label
for="file-upload"
class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>
<span
><a
href="javascript:void(0)"
@click="
$refs.input_file.click()
"
>여기</a
></span
>
<input
ref="input_file"
type="file"
name="upload-file"
accept=".jpg,.jpeg,.png"
hidden
@change="
filesChange(
$event.target.name,
$event.target.files
)
"
/>
</label>
<p class="pl-1">
눌러 업로드 하시거나 마우스로
이곳에 끌어 놓아 주세요.
</p>
</div>
<p class="text-xs text-gray-500">
PNG, JPG 최대 1MB
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="button"
class="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
@click="doCancel"
>
취소
</button>
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
저장
</button>
</div>
</div>
</form>
<form class="lg:col-span-9" @submit.prevent="doUpdatePassword">
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
비밀번호 변경
</h2>
<p class="mt-1 text-sm text-gray-500">
이전 비밀번호와 새로운 비밀번호를 두번 정확하게 입력해
주셔야 합니다.
</p>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="passwordCurrent"
class="block text-sm font-medium text-gray-700"
>현재 비밀번호</label
>
<input
id="passwordCurrent"
v-model="passwordCurrent"
type="password"
name="passwordCurrent"
autocomplete="passwordCurrent"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6"></div>
</div>
<div class="mt-6 grid grid-cols-12 gap-6">
<div class="col-span-12 sm:col-span-6">
<label
for="password"
class="block text-sm font-medium text-gray-700"
>새로운 비밀번호</label
>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="password"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
<div class="col-span-12 sm:col-span-6">
<label
for="password2"
class="block text-sm font-medium text-gray-700"
>비밀번호 확인</label
>
<input
id="password2"
v-model="password2"
type="password"
name="password2"
autocomplete="password2"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
비밀번호 저장
</button>
</div>
</div>
</form>
<form class="lg:col-span-9" @submit.prevent="doWithdrawal">
<div class="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 class="text-lg leading-6 font-medium text-gray-900">
회원 탈퇴
</h2>
<p class="mt-1 text-sm text-gray-500">
탈퇴 신청을 하시면 계정은 탈퇴처리 되며 일정기간 동일한
이메일로 재가입을 하실 없습니다.
</p>
</div>
</div>
<!-- Privacy section -->
<div class="pt-0">
<div class="mt-4 py-4 px-4 flex justify-end sm:px-6">
<button
type="submit"
class="ml-5 bg-sky-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-sky-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"
>
회원 탈퇴
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'check-auth-user',
});
const router = useRouter();
const email = ref(_crossCtl.userProfile['email']);
const displayName = ref(_crossCtl.userProfile['displayName']);
const photoUrl = ref(_crossCtl.userProfile['photoUrl']);
const phone = ref(_crossCtl.userProfile['phone']);
const memo = ref(_crossCtl.userProfile['memo']);
const passwordCurrent = ref('');
const password = ref('');
const password2 = ref('');
// email: '1@1', displayName: '1@1', phone: '', memo: ''
async function doUpdateInfo() {
const responseJson = await _crossCtl.doComm('update', 'profile', {
displayName: displayName.value,
photoUrl: photoUrl.value,
infos: {
email: email.value,
phone: phone.value,
memo: memo.value,
},
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
async function doUpdatePassword() {
const responseJson = await _crossCtl.doComm('update', 'password', {
password_current: passwordCurrent.value,
password_new: password.value,
password_again: password2.value,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
alert('ok');
} else {
alert(responseJson['responseMessage']);
}
}
async function doCancel() {
router.back();
}
async function doWithdrawal() {
_crossCtl.openModal(
'confirm',
'탈퇴 확인',
'정말로 탈퇴하시겠습니까?',
['탈퇴', '취소'],
(serial, btnIdx) => {
console.log('btnIdx=', btnIdx);
if (btnIdx == 0) {
router.push('/user/withdrawal');
}
}
);
/*
if (window.confirm('탈퇴하시겠습니까?')) {
router.push('/user/withdrawal');
}
*/
}
async function filesChange(fieldName, fileList) {
// handle file changes
const formData = new FormData();
if (!fileList.length) return;
// append the files to FormData
Array.from(Array(fileList.length).keys()).map((x) => {
formData.append(fieldName, fileList[x], fileList[x].name);
});
// save it
console.log('formData=', formData);
formData.append('target', 'just');
const responseJson = await _crossCtl.doUpload('just', formData);
console.log('responseJson=', responseJson);
if (responseJson['responseCode'] == 200) {
photoUrl.value =
_crossCtl.config['API_BASE_URL'].replace('/api/', '') +
responseJson['files'][0]['localUrl'];
} else {
alert(responseJson['responseMessage']);
}
}
</script>

View File

@@ -0,0 +1,149 @@
<!--
This example requires Tailwind CSS v2.0+
This example requires some changes to your config:
```
// tailwind.config.js
module.exports = {
// ...
plugins: [
// ...
require('@tailwindcss/forms'),
],
}
```
-->
<template>
<!--
This example requires updating your template:
```
<html class="h-full bg-gray-50">
<body class="h-full">
```
-->
<div
class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-md w-full space-y-8">
<div>
<h2
class="mt-6 text-center text-3xl font-extrabold text-gray-900"
>
비밀번호 찾기
</h2>
<!--
<p class="mt-2 text-center text-sm text-gray-600">
계정이 없으신 경우
{{ ' ' }}
<a
href="javascript:void()"
class="font-medium text-indigo-600 hover:text-indigo-500"
@click="navigateTo('/user/signup')"
>
이곳에서 회원가입
</a>
</p>
-->
<p class="mt-2 text-center text-sm text-gray-600">
계정이 있는 경우
{{ ' ' }}
<a
href="javascript:void()"
class="font-medium text-indigo-600 hover:text-indigo-500"
@click="navigateTo('/user/signin')"
>
이곳에서 로그인
</a>
</p>
</div>
<form class="mt-8 space-y-6" @submit.prevent="passwordReset">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email-address" class="sr-only"
>이메일 주소</label
>
<input
id="email-address"
v-model="email"
name="email"
type="email"
autocomplete="email"
required=""
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="이메일 주소"
/>
</div>
</div>
<div class="flex items-center justify-center">
<p class="mt-2 text-center text-sm text-gray-600">
입력하신 이메일 주소로 비밀번호 리셋 링크를 보내
드립니다.
</p>
</div>
<div>
<button
type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span
class="absolute left-0 inset-y-0 flex items-center pl-3"
>
<LockClosedIcon
class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400"
aria-hidden="true"
/>
</span>
링크 요청
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/24/solid/index.js';
definePageMeta({
layout: 'center',
});
useHead({
htmlAttrs: {
class: 'h-full bg-white',
},
bodyAttrs: {
class: 'h-full',
},
});
const email = ref('');
async function passwordReset() {
const responseJson = await _crossCtl.doComm('reset', '', {
userName: email.value,
});
_utils.log('debug', 'responseJson=', responseJson);
switch (responseJson['responseMessage']) {
case 'no user found':
alert('no user found');
break;
case 'ok':
alert(
'비밀번호 복구 메일을 발송하였습니다. 잠시 후에 이메일 수신함을 확인해 주세요.'
);
// window.location.replace('/');
break;
default:
alert(responseJson['responseMessage']);
break;
}
}
</script>

View File

@@ -0,0 +1,163 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<section class="bg-white overflow-hidden">
<div
class="relative max-w-7xl mx-auto pt-20 pb-12 px-4 sm:px-6 lg:px-8 lg:py-20"
>
<svg
class="absolute top-full left-0 transform translate-x-80 -translate-y-24 lg:hidden"
width="784"
height="404"
fill="none"
viewBox="0 0 784 404"
aria-hidden="true"
>
<defs>
<pattern
id="e56e3f81-d9c1-4b83-a3ba-0d0ac8c32f32"
x="0"
y="0"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width="4"
height="4"
class="text-gray-200"
fill="currentColor"
/>
</pattern>
</defs>
<rect
width="784"
height="404"
fill="url(#e56e3f81-d9c1-4b83-a3ba-0d0ac8c32f32)"
/>
</svg>
<svg
class="hidden lg:block absolute right-full top-1/2 transform translate-x-1/2 -translate-y-1/2"
width="404"
height="784"
fill="none"
viewBox="0 0 404 784"
aria-hidden="true"
>
<defs>
<pattern
id="56409614-3d62-4985-9a10-7ca758a8f4f0"
x="0"
y="0"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width="4"
height="4"
class="text-gray-200"
fill="currentColor"
/>
</pattern>
</defs>
<rect
width="404"
height="784"
fill="url(#56409614-3d62-4985-9a10-7ca758a8f4f0)"
/>
</svg>
<div class="relative lg:flex lg:items-center">
<div class="w-64 h-64">
<BaseAvater1 :image-size="64" :image-url="photoUrl" />
</div>
<div class="relative lg:ml-10">
<svg
class="absolute top-0 left-0 transform -translate-x-8 -translate-y-24 h-36 w-36 text-indigo-200 opacity-50"
stroke="currentColor"
fill="none"
viewBox="0 0 144 144"
aria-hidden="true"
>
<path
stroke-width="2"
d="M41.485 15C17.753 31.753 1 59.208 1 89.455c0 24.664 14.891 39.09 32.109 39.09 16.287 0 28.386-13.03 28.386-28.387 0-15.356-10.703-26.524-24.663-26.524-2.792 0-6.515.465-7.446.93 2.327-15.821 17.218-34.435 32.11-43.742L41.485 15zm80.04 0c-23.268 16.753-40.02 44.208-40.02 74.455 0 24.664 14.891 39.09 32.109 39.09 15.822 0 28.386-13.03 28.386-28.387 0-15.356-11.168-26.524-25.129-26.524-2.792 0-6.049.465-6.98.93 2.327-15.821 16.753-34.435 31.644-43.742L121.525 15z"
/>
</svg>
<blockquote class="relative">
<div
class="text-2xl leading-9 font-medium text-gray-900"
>
<p>
{{ memo }}
</p>
</div>
<footer class="mt-8">
<div class="flex">
<div class="flex-shrink-0 lg:hidden">
<img
class="h-12 w-12 rounded-full"
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt=""
/>
</div>
<div class="ml-4 lg:ml-0">
<div
class="text-base font-medium text-gray-900"
>
{{ displayName }}
</div>
<div
class="text-base font-medium text-indigo-600"
>
{{ email }}
</div>
</div>
</div>
</footer>
</blockquote>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'check-auth-user',
});
const route = useRoute();
const displayName = ref(_crossCtl.userProfile['displayName']);
const email = ref('');
const photoUrl = ref(_crossCtl.userProfile['photoUrl']);
const memo = ref(_crossCtl.userProfile['memo']);
let userInfo = {};
const hero = route.params.pid;
console.log('hero=', hero);
const responseJson = await _crossCtl.doComm('select', 'profile', {
hero: hero,
});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
userInfo = responseJson['data'][0];
const tmpSubInfos = userInfo.infos;
email.value = tmpSubInfos['email'];
displayName.value = userInfo['display_name'];
photoUrl.value = userInfo['photo_url'];
memo.value = tmpSubInfos['memo'];
} else {
alert(responseJson['responseMessage']);
}
</script>

View File

@@ -0,0 +1,153 @@
<!--
This example requires Tailwind CSS v2.0+
This example requires some changes to your config:
```
// tailwind.config.js
module.exports = {
// ...
plugins: [
// ...
require('@tailwindcss/forms'),
],
}
```
-->
<template>
<!--
This example requires updating your template:
```
<html class="h-full bg-gray-50">
<body class="h-full">
```
-->
<div
class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-md w-full space-y-8">
<div>
<h2
class="mt-6 text-center text-3xl font-extrabold text-gray-900"
>
비밀번호 변경
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
계정이 있는 경우
{{ ' ' }}
<a
href="javascript:void()"
class="font-medium text-indigo-600 hover:text-indigo-500"
@click="navigateTo('/user/signin')"
>
이곳에서 로그인
</a>
</p>
</div>
<form class="mt-8 space-y-6" @submit.prevent="doReset">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="password" class="sr-only"
> 로그인 비밀번호</label
>
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required=""
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="비밀번호"
/>
</div>
<div>
<label for="password2" class="sr-only"
>비밀번호 확인</label
>
<input
id="password2"
v-model="password2"
name="password2"
type="password"
autocomplete="current-password2"
required=""
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="비밀번호 확인"
/>
</div>
</div>
<div>
<button
type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span
class="absolute left-0 inset-y-0 flex items-center pl-3"
>
<LockClosedIcon
class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400"
aria-hidden="true"
/>
</span>
변경 신청
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/24/solid/index.js';
definePageMeta({
layout: 'center',
});
useHead({
htmlAttrs: {
class: 'h-full bg-white',
},
bodyAttrs: {
class: 'h-full',
},
});
const route = useRoute();
console.log('huk query = ', route.query);
const hero = ref(route.query.key);
const password = ref('');
const password2 = ref('');
async function doReset() {
if (password.value != password2.value) {
alert('비밀번호가 일치하지 않습니다.');
return;
}
const responseJson = await _crossCtl.doComm('update', 'password:reset', {
hero: hero.value,
passwordNew: password.value,
passwordAgain: password.value,
});
_utils.log('debug', 'responseJson=', responseJson);
switch (responseJson['responseMessage']) {
case 'Expired reset request':
alert('이미 사용된 비밀번호 리셋 링크입니다.');
break;
case 'ok':
alert('새로운 비밀번호가 설정되었습니다.');
break;
default:
alert(responseJson['responseMessage']);
break;
}
}
</script>

View File

@@ -0,0 +1,219 @@
<template>
<div
class="bg-white min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-md w-full space-y-8">
<div>
<img
src="/kiso_ci_1.png"
alt="KISO CI"
width="500"
height="600"
/>
<!--
<h2
class="mt-6 text-center text-3xl font-extrabold text-gray-900"
>
로그인
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
계정이 없으신 경우
{{ ' ' }}
<a
href="/user/signup"
class="font-medium text-indigo-600 hover:text-indigo-500"
>
이곳에서 회원가입
</a>
</p>
-->
</div>
<form class="mt-8 space-y-6" @submit.prevent="signin">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email-address" class="sr-only"
>이메일 주소</label
>
<input
id="email-address"
v-model="email"
name="email"
type="email"
autocomplete="email"
required="true"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="이메일 주소"
/>
</div>
<div>
<label for="password" class="sr-only"
>로그인 비밀번호</label
>
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required="true"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="비밀번호"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMeFlag"
name="remember-me"
type="checkbox"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label
for="remember-me"
class="ml-2 block text-sm text-gray-900"
>
아이디 저장
</label>
</div>
<div class="text-sm">
<a
href="javascript:void()"
class="font-medium text-indigo-600 hover:text-indigo-500"
@click="navigateTo('/user/password-reset')"
>
비밀번호 찾기
</a>
</div>
</div>
<div>
<button
type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span
class="absolute left-0 inset-y-0 flex items-center pl-3"
>
<LockClosedIcon
class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400"
aria-hidden="true"
/>
</span>
로그인
</button>
<!--
<button
type="button"
class="mt-3 group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="navigateTo('/user/signup')"
>
<span
class="absolute left-0 inset-y-0 flex items-center pl-3"
>
</span>
신규 가입
</button>
-->
</div>
</form>
</div>
</div>
</template>
<!--
This example requires updating your template:
```
<html class="h-full bg-gray-50">
<body class="h-full">
```
-->
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/24/solid/index.js';
definePageMeta({
layout: 'center',
});
useHead({
htmlAttrs: {
class: 'h-full bg-white',
},
bodyAttrs: {
class: 'h-full',
},
});
const email = ref('');
const password = ref('');
const rememberMeFlag = ref(false);
const cookieNameEmail = '/user/signin:email';
const cachedEmail = _utils.getCookie(cookieNameEmail);
console.log('cachedEmail=', cachedEmail);
if (cachedEmail != null) {
email.value = cachedEmail;
rememberMeFlag.value = true;
}
async function signin() {
// alert(email.value);
const normalizedInfo = {
provider: 'id/password',
id: email.value,
name: email.value,
email: email.value,
photo: '',
roleTag: '',
};
const infoString = JSON.stringify(normalizedInfo);
const responseJson = await _crossCtl.doComm('signin', '', {
type: 0,
id: email.value,
token: password.value,
info: infoString,
});
_utils.log('debug', 'responseJson=', responseJson);
switch (responseJson['responseMessage']) {
case 'wrong password count limit exceeded : 5':
alert(
'비밀번호가 다섯번 틀려 로그인 할 수 없습니다. 비밀번호 찾기를 시도해 보세요.'
);
break;
case 'no user found':
alert('아이디를 확인해 주세요.');
break;
case 'bad password':
alert('비밀번호가 일치하지 않습니다.');
break;
case 'ok':
// alert('login ok');
if (rememberMeFlag.value == true) {
_utils.setCookie('/user/signin:email', email.value, 10000);
} else {
_utils.rmvCookie('/user/signin:email');
}
window.location.replace('/');
break;
default:
alert(responseJson['responseMessage']);
break;
}
}
</script>

View File

@@ -0,0 +1,192 @@
<!--
This example requires Tailwind CSS v2.0+
This example requires some changes to your config:
```
// tailwind.config.js
module.exports = {
// ...
plugins: [
// ...
require('@tailwindcss/forms'),
],
}
```
-->
<template>
<!--
This example requires updating your template:
```
<html class="h-full bg-gray-50">
<body class="h-full">
```
-->
<div
class="bg-white min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-md w-full space-y-8">
<div>
<h2
class="mt-6 text-center text-3xl font-extrabold text-gray-900"
>
계정등록
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
계정이 있는 경우
{{ ' ' }}
<a
href="javascript:void()"
class="font-medium text-indigo-600 hover:text-indigo-500"
@click="navigateTo('/user/signin')"
>
이곳에서 로그인
</a>
</p>
</div>
<form class="mt-8 space-y-6" @submit.prevent="signup">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email-address" class="sr-only"
>이메일 주소</label
>
<input
id="email-address"
v-model="email"
name="email"
type="email"
autocomplete="email"
required=""
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="이메일 주소"
/>
</div>
<div>
<label for="password" class="sr-only"
>로그인 비밀번호</label
>
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required=""
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="비밀번호"
/>
</div>
<div>
<label for="password2" class="sr-only"
>비밀번호 확인</label
>
<input
id="password2"
v-model="password2"
name="password2"
type="password"
autocomplete="current-password2"
required=""
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="비밀번호 확인"
/>
</div>
</div>
<div>
<button
type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span
class="absolute left-0 inset-y-0 flex items-center pl-3"
>
<LockClosedIcon
class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400"
aria-hidden="true"
/>
</span>
계정등록
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/24/solid/index.js';
definePageMeta({
layout: 'center',
});
useHead({
htmlAttrs: {
class: 'h-full bg-white',
},
bodyAttrs: {
class: 'h-full',
},
});
const email = ref('');
const password = ref('');
const password2 = ref('');
async function signup() {
if (password.value != password2.value) {
alert('비밀번호가 일치하지 않습니다.');
return;
}
const responseJson = await _crossCtl.doComm('signup', '', {
userName: email.value,
password: password.value,
});
_utils.log('debug', 'responseJson=', responseJson);
switch (responseJson['responseMessage']) {
case 'not in a white list':
alert(
'사전 승인이 필요합니다. 확인을 누르시면 안내 페이지로 이동합니다.'
);
navigateTo('/doc/contract');
break;
case 'ok':
alert('가입 완료. 확인을 누르시면 로그인 화면으로 이동합니다.');
navigateTo('/user/signin');
break;
default:
alert(responseJson['responseMessage']);
break;
}
}
console.log(
'huk _crossCtl.isSignUpInfoNoticed = ',
_crossCtl.isSignUpInfoNoticed
);
if (_crossCtl.isSignUpInfoNoticed == false) {
_crossCtl.isSignUpInfoNoticed = true;
console.log('open modal');
_crossCtl.openModal(
'info',
'계정등록 안내',
'본 서비스의 회원 가입은 정식 계약 신청 후 서버에 등록된 이메일에 대해서만 가능합니다.' +
"만약 정식 서비스 계약을 체결하지 않으셨다면 먼저 '서비스 이용 절차' 부분의 내용을 참고해 주시기 바랍니다.",
['확인', '서비스 이용 절차 확인'],
(serial, btnIdx) => {
console.log('btnIdx=', btnIdx);
if (btnIdx == 1) {
// navigateTo('/#service_use_agreement');
window.location.href = '/#service-use-agreement';
}
}
);
}
</script>

View File

@@ -0,0 +1,64 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<div class="bg-white">
<div
class="max-w-7xl mx-auto text-center py-12 px-4 sm:px-6 lg:py-16 lg:px-8"
>
<h2
class="text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl"
>
<span class="block">회원 탈퇴</span>
<span class="block">탈퇴하시겠습니까?</span>
</h2>
<p class="mt-4 text-lg leading-6 text-indigo-200">
탈퇴 후에는 일정 기간 동일 이메일로 가입이 불가합니다.
</p>
<div class="mt-8 flex justify-center">
<div class="inline-flex rounded-md shadow">
<a
href="javascript:void(0)"
class="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
@click="doWithdrawal"
>
탈퇴
</a>
</div>
<div class="ml-3 inline-flex">
<a
href="javascript:void(0)"
class="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
@click="$router.push('/')"
>
취소
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'check-auth-user',
layout: 'center',
});
useHead({
htmlAttrs: {
class: 'h-full bg-white',
},
bodyAttrs: {
class: 'h-full',
},
});
async function doWithdrawal() {
const responseJson = await _crossCtl.doComm('withdrawal', '', {});
console.log('responseJson=', responseJson);
if (responseJson['responseMessage'] == 'ok') {
window.location.replace('/');
} else {
alert(responseJson['responseMessage']);
}
}
</script>

View File

@@ -0,0 +1,38 @@
export default defineNuxtPlugin((nuxtApp) => {
// _utils.log('defineNuxtPlugin() of !.ts executed...');
_crossCtl.setConfig(useRuntimeConfig().public);
/*
nuxtApp.hook('app:created', () => {
_utils.log('nuxtApp.hook, app:created');
});
nuxtApp.hook('app:beforeMount', () => {
_utils.log('nuxtApp.hook, app:beforeMount');
});
nuxtApp.hook('app:mounted', () => {
_utils.log('nuxtApp.hook, app:mounted');
});
nuxtApp.hook('vue:error', (..._args) => {
console.log('vue:error');
// if (process.client) {
// console.log(..._args)
// }
});
nuxtApp.hook('app:error', (..._args) => {
console.log('app:error');
// if (process.client) {
// console.log(..._args)
// }
});
nuxtApp.vueApp.config.errorHandler = (..._args) => {
console.log('global error handler');
// if (process.client) {
// console.log(..._args);
// }
return true;
};
*/
});

View File

@@ -0,0 +1,44 @@
export default defineNuxtPlugin((/* nuxtApp */) => {
// _utils.log('defineNuxtPlugin() of customDateTimeFormat.ts executed...');
return {
provide: {
customFormat: (rawtime) => {
const date: any = new Date(rawtime);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
let hh = date.getHours();
const mm = date.getMinutes();
const AmOrPm = hh <= 12 ? '오전' : '오후';
hh = hh % 12 || 12;
const diffInSec = Math.floor((Date.now() - date) / 1000);
if (diffInSec < 30) {
return '조금 전';
}
if (diffInSec < 59) {
return diffInSec + '초 전';
}
const diffInMin = Math.floor(diffInSec / 60);
if (diffInMin < 59) {
return diffInMin + '분 전';
}
return (
year +
'년 ' +
month +
'월' +
' ' +
day +
'일, ' +
AmOrPm +
' ' +
hh +
':' +
mm
);
},
},
};
});

View File

@@ -0,0 +1,17 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime.js';
import 'dayjs/locale/ko';
dayjs.locale('ko');
dayjs.extend(relativeTime);
export default defineNuxtPlugin((/* nuxtApp */) => {
// _utils.log('defineNuxtPlugin() of dayjs.ts executed...');
return {
provide: {
dayjs: (rawDateTime) => {
return dayjs(rawDateTime);
},
},
};
});

View File

@@ -0,0 +1,9 @@
import { defineNuxtPlugin } from '#app';
import { QuillEditor } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('QuillEditor', QuillEditor);
});

View File

@@ -0,0 +1,10 @@
import VueGoogleMaps from '@fawmi/vue-google-maps';
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig().public;
nuxtApp.vueApp.use(VueGoogleMaps, {
load: {
key: config.GOOGLE_MAPS_API_KEY,
},
});
});

View File

@@ -0,0 +1,12 @@
import VueJsonPretty from 'vue-json-pretty';
export default defineNuxtPlugin((/* nuxtApp */) => {
// _utils.log('defineNuxtPlugin() of dayjs.ts executed...');
return {
provide: {
vueJsonPretty: () => {
return new VueJsonPretty();
},
},
};
});

View File

@@ -0,0 +1,473 @@
import { _utils } from '@/base/src/utils';
import { _siteConfig } from '@/config/site';
import { ref } from '#imports';
import {
CheckIcon,
ShieldExclamationIcon,
ExclamationCircleIcon,
} from '@heroicons/vue/24/outline/index.js';
class CrossCtl {
// constructor() {}
siteConfig = _siteConfig;
userProfile = {};
userInfo = {};
isAuthenticated = ref(false);
isSignUpInfoNoticed = false;
profileUrlRef = ref('');
menu = ref();
// const config = useRuntimeConfig();
config = {
// API_BASE_URL: 'https://dev.twf.today/api/',
};
modalSerial = 0;
getEmptyModalInfo() {
return {
serial: -1,
icon: null,
classIcon: '',
classBg: '',
classBtnBg: '',
title: '',
message: '',
btnCount: 0,
btnTexts: [''],
onCloseCb: null,
};
}
currentModalInfo = ref(this.getEmptyModalInfo());
modalQueue = [];
onModalClosed(serial, btnIdx) {
if (this.currentModalInfo.value != null) {
if (this.currentModalInfo.value['serial'] == serial) {
this.currentModalInfo.value['onCloseCb'](serial, btnIdx);
this.modalQueue.shift();
if (this.modalQueue.length > 0) {
this.currentModalInfo.value = this.modalQueue[0];
} else {
this.currentModalInfo.value = this.getEmptyModalInfo();
}
} else {
console.log(
`modla serial missmatch current = ${this.currentModalInfo.value['serial']}, received = ${serial}`
);
}
} else {
console.log(`modla callback missing. received serial = ${serial}`);
}
}
getIconByTag(tag) {
switch (tag) {
case 'ok':
return {
icon: CheckIcon,
class: 'h-6 w-6 text-green-600',
bg: 'mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10',
btn: 'w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm',
};
break;
case 'error':
return {
icon: ShieldExclamationIcon,
class: 'h-6 w-6 text-red-600',
bg: 'mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10',
btn: 'w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm',
};
break;
default:
return {
icon: ExclamationCircleIcon,
class: 'h-6 w-6 text-gray-600',
bg: 'bg-gray-100',
btn: 'mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm',
};
break;
}
}
openModal(icon, title, message, btnTexts, onCloseCb) {
const newModalInfo = {
serial: this.modalSerial++,
type: icon,
icon: this.getIconByTag(icon)['icon'],
classIcon: this.getIconByTag(icon)['class'],
classBg: this.getIconByTag(icon)['bg'],
classBtnBg: this.getIconByTag(icon)['btn'],
title: title,
message: message,
btnCount: btnTexts.length,
btnTexts: btnTexts,
onCloseCb: onCloseCb,
};
this.modalQueue.push(newModalInfo);
if (this.currentModalInfo.value['serial'] == -1) {
this.currentModalInfo.value = newModalInfo;
}
console.log('newModalInfo=', newModalInfo);
return newModalInfo.serial;
}
setConfig(_config) {
this.config = _config;
this.router = useRouter();
_utils.log('config captured...', this.config);
}
setUserInfo(info) {
this.userInfo = info;
// this.userInfo['userInfo']['userName']
// console.log('huk info = ', info);
if (!info.isAuthenticated) {
this.profileUrlRef.value = '';
} else {
this.profileUrlRef.value = info['userInfo']['profileUrl'];
}
}
setAuthInfo(infos) {
_utils.log('authInfo=', infos);
this.isAuthenticated.value = infos.isAuthenticated;
}
setUserProfile(profile) {
this.userProfile = profile;
if (
this.userInfo['isAuthenticated'] == true &&
this.userInfo['isAdmin'] == true
) {
this.menu.value = _siteConfig.menus['admin'];
} else if (
this.userInfo['isAuthenticated'] == true &&
this.userInfo['isAdmin'] == false &&
this.userInfo['isSuperOp'] == true &&
this.userInfo['isOp'] == false
) {
this.menu.value = _siteConfig.menus['super_op'];
} else if (
this.userInfo['isAuthenticated'] == true &&
this.userInfo['isAdmin'] == false &&
this.userInfo['isSuperOp'] == false &&
this.userInfo['isOp'] == true
) {
this.menu.value = _siteConfig.menus['op'];
} else if (
this.userInfo['isAuthenticated'] == true &&
this.userInfo['isAdmin'] == false &&
this.userInfo['isSuperOp'] == false &&
this.userInfo['isOp'] == false
) {
this.menu.value = _siteConfig.menus['user'];
} else {
this.menu.value = _siteConfig.menus['anonym'];
}
}
getPrifileImageUrl() {
if (this.isAuthenticated.value == false) {
return '';
} else {
return this.userInfo['profileUrl'];
}
}
async doComm(cmd: string, target: string, params: any) {
let fetchOptions = {};
const newParams = { ...params };
let queryStr = '';
if (target != '') {
newParams['target'] = target;
}
switch (cmd) {
case 'signout':
case 'local/list':
case 'local/select':
case 'list':
fetchOptions = {
method: 'GET',
credentials: 'include',
params: newParams,
};
queryStr = '?' + new URLSearchParams(newParams).toString();
break;
default:
fetchOptions = {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newParams),
};
}
//console.log('fetchOptions=', fetchOptions);
fetchOptions = {
...fetchOptions,
async onRequestError({ request, options, error }) {
// Log error
console.log('[fetch request error]', request, error);
},
async onResponseError({ request, response, options }) {
// Log error
console.log(
'[fetch response error]',
request,
response.status,
response.body
);
},
};
const fetchUrl = this.config['API_BASE_URL'] + cmd + queryStr;
//console.log('fetchUrl=', fetchUrl);
const data = await $fetch(fetchUrl, fetchOptions).catch(
async (error) => {
console.log('doComm error=', error);
return {
header: 'pONd-vERsion4',
responseCode: 503,
responseMessage: 'Network Error.',
};
}
);
// console.log('data=', data);
return data;
// 서버가 내려간 경우
// FetchError: Failed to fetch ()
// 서버 주소가 틀린 경우
//
}
async doUpload(target: string, params: any) {
let fetchOptions = {};
const newParams = { ...params };
const queryStr = '';
if (target != '') {
newParams['target'] = target;
}
fetchOptions = {
method: 'POST',
credentials: 'include',
body: params,
};
//console.log('fetchOptions=', fetchOptions);
fetchOptions = {
...fetchOptions,
async onRequestError({ request, options, error }) {
// Log error
console.log('[fetch request error]', request, error);
},
async onResponseError({ request, response, options }) {
// Log error
console.log(
'[fetch response error]',
request,
response.status,
response.body
);
},
};
const fetchUrl = this.config['API_BASE_URL'] + 'upload' + queryStr;
//console.log('fetchUrl=', fetchUrl);
const data = await $fetch(fetchUrl, fetchOptions).catch(
async (error) => {
console.log('doComm error=', error);
return {
header: 'pONd-vERsion4',
responseCode: 503,
responseMessage: 'Network Error.',
};
}
);
// console.log('data=', data);
return data;
// 서버가 내려간 경우
// FetchError: Failed to fetch ()
// 서버 주소가 틀린 경우
//
}
async doFilterRaw(key: string, params: any) {
let fetchOptions = {};
fetchOptions = {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-api-key': key,
},
body: new URLSearchParams(params),
};
fetchOptions = {
...fetchOptions,
async onRequestError({ request, options, error }) {
// Log error
console.log('[fetch request error]', request, error);
},
async onResponseError({ request, response, options }) {
// Log error
console.log(
'[fetch response error]',
request,
response.status,
response.body
);
},
};
const fetchUrl = this.config['API_BASE_URL'] + 'v1/filter';
console.log('fetchUrl=', fetchUrl);
const data = await $fetch(fetchUrl, fetchOptions).catch(
async (error) => {
console.log('doComm error=', error);
return {
header: 'pONd-vERsion4',
responseCode: 503,
responseMessage: 'Network Error.',
};
}
);
// console.log('data=', data);
return data;
}
async doFilter(keyTail: string, params: any) {
return this.doFilterRaw(
'04f4909fd242509fa03e9648236d98d8' + keyTail,
params
);
}
lastMenuItem = null;
router = null;
moveToMenuItem(item) {
if (this.lastMenuItem != null) {
this.lastMenuItem['current'] = false;
}
this.lastMenuItem = item;
item.current = true;
if (item.path != '') {
/*
this.router.push({
path: item.path,
params: {},
});
*/
// navigateTo(item.path);
if (item.path.startsWith('/')) {
navigateTo(item.path);
} else {
window.open(item.path);
}
} else {
alert('더미 메뉴');
}
return true;
}
boardInfoCacheTTL = 60 * 24 * 1; // 1 minute
boardInfoPool = {};
async getBoardInfo(routeInstance) {
const currentNow: number = Date.now();
let targetId = null;
if (routeInstance.params.boardId instanceof Array) {
if (routeInstance.params.boardId.length != 1) {
return throwError('$404');
} else {
targetId = routeInstance.params.boardId[0];
}
} else {
return throwError('$404');
}
console.log('try to get boardInfo from cache for ', targetId);
let targetInfo = this.boardInfoPool[targetId];
if (targetInfo != undefined) {
const targetCacheAge: number = parseInt(
(currentNow - parseInt(targetInfo['created'])) / (1000 * 60)
);
if (true) {
// if (targetCacheAge > this.boardInfoCacheTTL) {
targetInfo = undefined;
} else {
console.log('cache hit for ', targetId);
}
}
if (targetInfo == undefined) {
console.log('try to get boardInfo from server for ', targetId);
const responseJson = await this.doComm('select', 'board:info', {
hero: targetId,
});
if (responseJson['responseCode'] == 200) {
this.boardInfoPool[targetId] = {
created: Date.now(),
info: responseJson['data'][0],
};
targetInfo = this.boardInfoPool[targetId];
} else if (responseJson['responseCode'] == 404) {
return throwError('$404');
} else if (responseJson['responseCode'] == 401) {
return throwError('$401');
} else {
return throwError('$' + responseJson['responseCode']);
}
}
console.log('do some gatekeeping works for id ', targetId);
return targetInfo;
}
}
export const _crossCtl = new CrossCtl();

View File

@@ -0,0 +1,288 @@
function dateFormat(date, fstr, utc) {
utc = utc ? 'getUTC' : 'get';
return fstr.replace(/%[YmdHMS]/g, function (m) {
switch (m) {
case '%Y':
return date[utc + 'FullYear'](); // no leading zeros required
case '%m':
m = 1 + date[utc + 'Month']();
break;
case '%d':
m = date[utc + 'Date']();
break;
case '%H':
m = date[utc + 'Hours']();
break;
case '%M':
m = date[utc + 'Minutes']();
break;
case '%S':
m = date[utc + 'Seconds']();
break;
default:
return m.slice(1); // unknown code, remove %
}
// add leading zero if required
return ('0' + m).slice(-2);
});
}
function getNow() {
return dateFormat(new Date(), '%Y-%m-%d %H:%M:%S', false);
}
/*
if (process.server) {
log('debug', 'utils in server side');
} else {
log('debug', 'utils in client side');
function getDomain(url) {
let domain = url.replace(/(https?:\/\/)?(www.)?/i, '');
if (domain.indexOf('/') !== -1) {
domain = domain.split('/')[0].toLowerCase();
}
if (domain.indexOf(':') != -1) {
const tmpAry = domain.split(':');
domain = tmpAry[0];
}
return domain;
}
const currentHost = window.location.host.toLowerCase();
const currentProtocol = window.location.protocol;
const currentDomain = getDomain(window.location.href);
console.log('currentHost = ' + currentHost);
console.log('currentProtocol = ' + currentProtocol);
console.log('currentDomain = ' + currentDomain);
}
*/
class Utils {
// constructor() {}
getDomain(url) {
let domain = url.replace(/(https?:\/\/)?(www.)?/i, '');
if (domain.indexOf('/') !== -1) {
domain = domain.split('/')[0].toLowerCase();
}
if (domain.indexOf(':') != -1) {
const tmpAry = domain.split(':');
domain = tmpAry[0];
}
return domain;
}
tracer() {
try {
throw new Error();
} catch (e) {
const tmpAry = e.stack.toString().split('\n');
// console.log('tmpAry=', tmpAry);
let locTag = 'unidentified';
const callerInfo = tmpAry[2];
if (callerInfo.indexOf('/_nuxt/') != -1) {
const tmpSubAry = tmpAry[2].split('/_nuxt/');
if (tmpSubAry[tmpSubAry.length - 1].toString().endsWith(')')) {
locTag = tmpSubAry[tmpSubAry.length - 1]
.toString()
.substring(
0,
tmpSubAry[tmpSubAry.length - 1].toString().length -
1
);
} else {
locTag = tmpSubAry[tmpSubAry.length - 1];
}
} else {
const tmpSubAry = tmpAry[2].split('/');
locTag = tmpSubAry[tmpSubAry.length - 1];
}
if (process.server) {
console.log(
`trace called from ${locTag}, NODE_ENV = ${process.env.NODE_ENV}, server side`
);
} else if (process.client) {
console.log(
`trace called from ${locTag}, NODE_ENV = ${process.env.NODE_ENV}, client side`
);
} else {
console.log(
`trace called from ${locTag}, NODE_ENV = ${process.env.NODE_ENV}, unknown side`
);
}
}
}
log(...args) {
let waste = 'DEBUG';
// console.log(`args.length = ${args.length}`)
if (args.length > 1) {
waste = args[0];
waste = waste.toLowerCase();
// console.log("waste = ", waste)
switch (waste) {
case 'trace':
case 'debug':
case 'info':
case 'warn':
case 'error':
case 'fatal':
waste = args.shift();
waste = waste.toUpperCase();
console.log(`[${getNow()}] ${waste} :`, ...args);
break;
default:
console.log(`[${getNow()}] DEBUG :`, ...args);
break;
}
} else {
console.log(`[${getNow()}] DEBUG :`, ...args);
}
}
escapeHtml(source: string) {
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
return String(source).replace(/[&<>"'`=\/]/g, function (s) {
return entityMap[s];
});
}
formatNumberInBytesStyle(bytes, decimals) {
if (bytes === 0) return '0';
let sign = '';
if (bytes < 0) {
sign = '-';
bytes = Math.abs(bytes);
}
const k = 1024;
const dm = decimals || 2;
const sizes = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
// log('formatBytes(), bytes, decimals, k, dm, i =', bytes, decimals, k, dm, i)
return (
sign +
parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) +
' ' +
sizes[i]
);
}
formatBytes(bytes, decimals) {
if (bytes === 0) return '0 Bytes';
let sign = '';
if (bytes < 0) {
sign = '-';
bytes = Math.abs(bytes);
}
const k = 1024;
const dm = decimals || 2;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
// log('formatBytes(), bytes, decimals, k, dm, i =', bytes, decimals, k, dm, i)
return (
sign +
parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) +
' ' +
sizes[i]
);
}
formatNumberWithComma(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
setCookie(name, value, days) {
let expires = '';
if (days) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
expires = '; expires=' + date.toUTCString();
}
document.cookie = name + '=' + (value || '') + expires + '; path=/';
}
getCookie(name) {
const nameEQ = name + '=';
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0)
return c.substring(nameEQ.length, c.length);
}
return null;
}
rmvCookie(name) {
document.cookie = name + '=; Max-Age=-99999999;';
}
getDateTimeTag(base) {
let resultTag = '';
switch (base) {
case 'y':
resultTag = dateFormat(new Date(), '%Y', false);
break;
case 'm':
resultTag = dateFormat(new Date(), '%Y%m', false);
break;
case 'd':
resultTag = dateFormat(new Date(), '%Y%m%d', false);
break;
case 'h':
resultTag = dateFormat(new Date(), '%Y%m%d%H', false);
break;
case 'M':
resultTag = dateFormat(new Date(), '%Y%m%d%H%M', false);
break;
case 'boom':
resultTag = dateFormat(new Date(), '%Y%m%d%H%M', false);
resultTag = resultTag.substring(0, 11);
break;
default:
resultTag = dateFormat(new Date(), '%Y-%m-%d %H:%M:%S', false);
}
return resultTag;
}
safeJSON(data) {
let paramJSON;
try {
if (data == null) {
paramJSON = {};
} else {
paramJSON = JSON.parse(data);
}
} catch (e) {
paramJSON = { _raw: data };
this.log('error', 'JSON parse failed data : ' + `[${data}]`);
this.log('error', 'JSON parse failed err : ' + e);
}
return paramJSON;
}
}
export const _utils = new Utils();