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,20 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 4
# I recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -0,0 +1,4 @@
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
.output

View File

@@ -0,0 +1,33 @@
module.exports = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
},
extends: [
// "plugin:vue/strongly-recommended",
// 'eslint:recommended',
'plugin:vue/vue3-recommended',
'@vue/typescript/recommended',
'prettier',
],
plugins: ['@typescript-eslint', 'prettier'],
rules: {
'prettier/prettier': ['error', { endOfLine: 'auto' }],
// not needed for vue 3
'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': 'off',
},
env: {
browser: true,
es6: true,
node: true,
},
overrides: [
{
files: ['layouts/default.vue', 'error.vue', 'pages/index.vue'],
rules: { 'vue/multi-word-component-names': 'off' },
},
],
};

21
inspond-nuxt-safekiso/.gitattributes vendored Normal file
View File

@@ -0,0 +1,21 @@
# Auto detect text files and perform LF normalization
* text=auto
*.cs text diff=csharp
*.java text diff=java
*.html text diff=html
*.css text
*.js text
*.sql text
*.csproj text merge=union
*.sln text merge=union eol=crlf
*.docx diff=astextplain
*.DOCX diff=astextplain
# absolute paths are ok, as are globs
/**/postinst* text eol-lf
# paths that don't start with / are treated relative to the .gitattributes folder
relative/path/*.txt text eol-lf

8
inspond-nuxt-safekiso/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80
}

View File

@@ -0,0 +1,42 @@
# Nuxt 3 Minimal Starter
Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
# npm
npm install
# pnpm
pnpm install --shamefully-hoist
```
## Development Server
Start the development server on http://localhost:3000
```bash
npm run dev
```
## Production
Build the application for production:
```bash
npm run build
```
Locally preview production build:
```bash
npm run preview
```
Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information.

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();

View File

@@ -0,0 +1,153 @@
<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>
<tfoot>
<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"
>
{{
index == 0
? '합계'
: calcSum(heading['key'])
}}
</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>
</tfoot>
</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: '조회된 데이터가 없습니다.' },
});
function calcSum(tag) {
let tmpSum = 0;
for (let i = 0; i < props.data.length; i++) {
tmpSum += props.data[i][tag];
}
return tmpSum;
}
</script>

View File

@@ -0,0 +1,96 @@
<template>
<section
class="block fixed bottom-0 inset-x-0 z-50 shadow-lg text-gray-800 bg-gray-700 dark:bg-dark backdrop-blur-lg bg-opacity-30 dark:bg-opacity-30 dark:text-gray-400 border-t-2 border-royal/20"
>
<div id="tabs" class="flex justify-between">
<a
href="#"
class="w-full focus:text-royal hover:text-royal justify-center inline-block text-center pt-2 pb-1 hover:bg-white"
activeClass="dark:text-gray-100 text-black"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 inline-block mb-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
<span class="tab block text-xs">Home</span>
</a>
<a
href="#"
class="w-full focus:text-royal hover:text-royal justify-center inline-block text-center pt-2 pb-1 hover:bg-white"
activeClass="dark:text-gray-100 text-black"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 inline-block mb-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
<span class="tab block text-xs">Categories</span>
</a>
<a
href="#"
class="w-full focus:text-royal hover:text-royal justify-center inline-block text-center pt-2 pb-1 hover:bg-white"
activeClass="dark:text-gray-100 text-black"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 inline-block mb-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="tab block text-xs">Gallery</span>
</a>
<a
href="#"
class="w-full focus:text-royal hover:text-royal justify-center inline-block text-center pt-2 pb-1 hover:bg-white"
activeClass="dark:text-gray-100 text-black"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 inline-block mb-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span class="tab block text-xs">About</span>
</a>
</div>
</section>
</template>
<script setup lang="ts"></script>
<style lang="css" scoped></style>

View File

@@ -0,0 +1,75 @@
import { defineComponent, h, PropType } from 'vue';
import { Bar } from 'vue-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
Plugin,
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale
);
export default defineComponent({
name: 'BarChart',
components: {
Bar,
},
props: {
chartId: {
type: String,
default: 'bar-chart',
},
chartData: {
type: Object,
},
chartOptions: {
type: Object,
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object as PropType<Partial<CSSStyleDeclaration>>,
default: () => {},
},
plugins: {
type: Array as PropType<Plugin<'bar'>[]>,
default: () => [],
},
},
setup(props) {
return () =>
h(Bar, {
chartData: props.chartData,
chartOptions: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
});
},
});

View File

@@ -0,0 +1,76 @@
import { defineComponent, h, PropType } from 'vue';
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
Plugin,
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale
);
export default defineComponent({
name: 'LineChart',
components: {
Line,
},
props: {
chartId: {
type: String,
default: 'line-chart',
},
chartData: {
type: Object,
},
chartOptions: {
type: Object,
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object as PropType<Partial<CSSStyleDeclaration>>,
default: () => {},
},
plugins: {
type: Array as PropType<Plugin<'line'>[]>,
default: () => [],
},
},
setup(props) {
return () =>
h(Line, {
chartData: props.chartData,
chartOptions: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
});
},
});

View File

@@ -0,0 +1,66 @@
import { defineComponent, h, PropType } from 'vue';
import { Pie } from 'vue-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
ArcElement,
CategoryScale,
Plugin,
} from 'chart.js';
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale);
export default defineComponent({
name: 'PieChart',
components: {
Pie,
},
props: {
chartId: {
type: String,
default: 'pie-chart',
},
chartData: {
type: Object,
},
chartOptions: {
type: Object,
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object as PropType<Partial<CSSStyleDeclaration>>,
default: () => {},
},
plugins: {
type: Array as PropType<Plugin<'pie'>[]>,
default: () => [],
},
},
setup(props) {
return () =>
h(Pie, {
chartData: props.chartData,
chartOptions: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
});
},
});

View File

@@ -0,0 +1,619 @@
import { _utils } from '@/base/src/utils';
import {
InformationCircleIcon,
NewspaperIcon,
} from '@heroicons/vue/24/outline';
class siteConfig {
constructor() {
_utils.log('siteConfig Instance created...');
for (const property in this.menuItems) {
this.menuItems[property]['current'] = false;
}
}
// siteLayout = 'top-navbar-and-footer';
// siteLayout = 'center';
// siteLayout = 'raw';
siteLayout = 'side-navbar-and-footer';
siteName = 'KISO Safeguard System';
copyrightName = '(사)한국인터넷자율정책기구';
snsLinks = [
{ tag: 'facebook', url: 'https://facebook.com/kiso.cast' },
{ tag: 'twitter', url: 'https://twitter.com/kiso_cast' },
{
tag: 'youtube',
url: 'https://www.youtube.com/channel/UCa4qy69Aqr4JqrMeBeUEtuA',
},
];
siteSlogan = '바른 인터넷 사용 문화';
siteLogoUrl = '/inspond_logo_in_blue_bg_white.png';
menuItems = {
support_stipulation: {
title: '서비스 이용약관',
icon: NewspaperIcon,
routeName: 'docs-stipulation',
path: '/docs/stipulation/',
},
support_privacy: {
title: '개인정보 보호',
icon: NewspaperIcon,
routeName: 'docs-privacy',
path: '/docs/privacy',
},
// 이상 익명 사용자 권한
support_notice: {
title: '공지',
icon: NewspaperIcon,
routeName: 'support-notice',
path: '/support/notice',
},
support_faq: {
title: 'FAQ',
icon: NewspaperIcon,
routeName: 'support-faq',
path: '/support/faq',
},
support_inquiry: {
title: '1:1 문의',
icon: NewspaperIcon,
routeName: 'support-inquiry',
path: '/support/inquiry',
},
user_info: {
title: '유저 정보',
icon: NewspaperIcon,
routeName: 'user-info',
path: '/user/info',
},
// 이상 로그인 사용자 권한
doc_manual: {
title: '어드민 기능 안내',
icon: NewspaperIcon,
routeName: 'doc-manual',
path: '/doc/manual',
},
doc_guide: {
title: '서비스 도입 안내',
icon: NewspaperIcon,
routeName: 'doc-guide',
path: '/doc/guide',
},
doc_document: {
title: 'API 연동 안내',
icon: NewspaperIcon,
routeName: 'doc-api_doc',
path: '/doc/api_doc',
},
key_list: {
title: 'API 키 리스트',
icon: NewspaperIcon,
routeName: 'key-list',
path: '/key/list',
},
statistics: {
title: 'API 사용량 통계',
icon: NewspaperIcon,
routeName: 'statistics',
path: '/statistics',
},
// 이상 오퍼레이터 이상 사용 권한
filter: {
title: '필터 테스트',
icon: NewspaperIcon,
routeName: 'filter',
path: '/filter',
},
board_filter: {
title: '필터 관리 게시판',
icon: NewspaperIcon,
routeName: 'board-filter',
path: '/board/filter/list',
},
// 이상 수퍼 오퍼레이터 이상 사용 권한
admin_dashboard: {
title: '서비스 대시보드',
icon: NewspaperIcon,
routeName: 'admin-dashboard',
path: '/admin/dashboard',
},
admin_notice: {
title: '공지 관리',
icon: NewspaperIcon,
routeName: 'admin-support-notice-list',
path: '/admin/support/notice/list',
},
admin_faq: {
title: '자주 묻는 질문 관리',
icon: NewspaperIcon,
routeName: 'admin-support-faq-list',
path: '/admin/support/faq/list',
},
admin_inquiry: {
title: '1:1 문의 관리',
icon: NewspaperIcon,
routeName: 'admin-support-inquiry-list',
path: '/admin/support/inquiry/list',
},
admin_user_list: {
title: '사용자 관리',
icon: NewspaperIcon,
routeName: 'admin-user-list',
path: '/admin/user/list',
},
admin_white_list: {
title: '가입 허가 관리',
icon: NewspaperIcon,
routeName: 'admin-user-white-list',
path: '/admin/user/white/list',
},
admin_board: {
title: '게시판 관리',
icon: NewspaperIcon,
routeName: 'admin-board-list',
path: '/admin/board/list',
},
admin_key_list: {
title: '전체 API 키 관리',
icon: NewspaperIcon,
routeName: 'admin-key-list',
path: '/admin/key/list',
},
admin_filter: {
title: '필터 관리 (admin)',
icon: NewspaperIcon,
routeName: 'admin-filter',
path: '/admin/filter',
},
admin_statistics: {
title: '사용량 통계 (admin)',
icon: NewspaperIcon,
routeName: 'admin-statistics',
path: '/admin/statistics',
},
admin_lab: {
title: '연구실',
icon: NewspaperIcon,
routeName: 'admin-lab',
path: '/admin/lab',
},
};
menuIdx = 0;
menus = {
anonym: {
main: [],
sub: [
{
...this.menuItems['support_stipulation'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_privacy'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_faq'],
idx: this.menuIdx++,
},
],
},
user: {
main: [
{
title: '고객 지원',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['support_notice'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_inquiry'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: '유저',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['user_info'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
],
sub: [
{
...this.menuItems['support_stipulation'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_privacy'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_faq'],
idx: this.menuIdx++,
},
],
},
op: {
main: [
{
title: '고객 지원',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['support_notice'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_inquiry'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: '유저',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['user_info'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: 'API',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['doc_guide'],
idx: this.menuIdx++,
},
{
...this.menuItems['doc_document'],
idx: this.menuIdx++,
},
{
...this.menuItems['doc_manual'],
idx: this.menuIdx++,
},
{
...this.menuItems['key_list'],
idx: this.menuIdx++,
},
{
...this.menuItems['statistics'],
idx: this.menuIdx++,
},
{
...this.menuItems['filter'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
],
sub: [
{
...this.menuItems['support_stipulation'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_privacy'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_faq'],
idx: this.menuIdx++,
},
],
},
super_op: {
main: [
{
title: '고객 지원',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['support_notice'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_inquiry'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: '유저',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['user_info'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: 'API',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['doc_guide'],
idx: this.menuIdx++,
},
{
...this.menuItems['doc_document'],
idx: this.menuIdx++,
},
{
...this.menuItems['doc_manual'],
idx: this.menuIdx++,
},
{
...this.menuItems['key_list'],
idx: this.menuIdx++,
},
{
...this.menuItems['statistics'],
idx: this.menuIdx++,
},
{
...this.menuItems['filter'],
idx: this.menuIdx++,
},
{
...this.menuItems['board_filter'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
],
sub: [
{
...this.menuItems['support_stipulation'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_privacy'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_faq'],
idx: this.menuIdx++,
},
],
},
admin: {
main: [
{
title: '고객 지원',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['support_notice'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_inquiry'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: '유저',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['user_info'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: 'API',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['doc_guide'],
idx: this.menuIdx++,
},
{
...this.menuItems['doc_document'],
idx: this.menuIdx++,
},
{
...this.menuItems['doc_manual'],
idx: this.menuIdx++,
},
{
...this.menuItems['key_list'],
idx: this.menuIdx++,
},
{
...this.menuItems['statistics'],
idx: this.menuIdx++,
},
{
...this.menuItems['filter'],
idx: this.menuIdx++,
},
{
...this.menuItems['board_filter'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_filter'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_key_list'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_dashboard'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_lab'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_statistics'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
{
title: '어드민',
icon: InformationCircleIcon,
subs: [
{
...this.menuItems['admin_notice'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_faq'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_inquiry'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_user_list'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_white_list'],
idx: this.menuIdx++,
},
{
...this.menuItems['admin_board'],
idx: this.menuIdx++,
},
],
idx: this.menuIdx++,
},
],
sub: [
{
...this.menuItems['support_stipulation'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_privacy'],
idx: this.menuIdx++,
},
{
...this.menuItems['support_faq'],
idx: this.menuIdx++,
},
],
},
};
userLevelInfo = {
'0': {
level: 0,
title: '일반 회원',
description: '서비스에 가입한 상태이나 아무런 권한이 없습니다.',
users: '표기 : user',
},
'1': {
level: 1,
title: '일반 회원',
description: '서비스에 가입한 상태이나 아무런 권한이 없습니다.',
users: '표기 : user',
},
'2': {
level: 2,
title: '일반 회원',
description: '서비스에 가입한 상태이나 아무런 권한이 없습니다.',
users: '표기 : user',
},
'3': {
level: 3,
title: '회원사 운영자',
description: '사이트 가입 후 어드민이 승인한 사용자입니다.',
users: '표기 : op',
},
'4': {
level: 4,
title: '수퍼 운영자',
description: '필터 단어 추가 권한이 부여되는 운영자 입니다.',
users: '표기 : super',
},
'5': {
level: 5,
title: '어드민',
description: '모든 기능을 사용할 수 있는 전체 서비스 관리자입니다.',
users: '표기 : admin',
},
};
userLevels = [
this.userLevelInfo['0'],
this.userLevelInfo['3'],
this.userLevelInfo['4'],
this.userLevelInfo['5'],
];
}
export const _siteConfig = new siteConfig();

View File

@@ -0,0 +1,230 @@
**(사)한국인터넷자율정책기구 KSS(KISO Safeguard System)API 서비스**
## API 연동 안내
### 개요
KISO Safeguard System API 서비스는 제시된 문자열 중에 미리 준비된 욕설·비속어 DB에 포함된
표현이 있는지를 빠르게 검사하고 그 결과를 돌려 주는 API 서비스 입니다.
- 사전 비교 방식 (2024년 4월 현재 80만여 표현)
- 아호코라식(Aho-Corasick) 알고리즘으로 빠른 검색
### 호출 주소
```log
https://www.safekiso.com/api/v1/filter
\___/ \_______________/ \____/ \____/
| | | |
protocol domain version api name
```
### API 호출 파라메타
콘텐츠 타입은 application/x-www-form-urlencoded, 메쏘드는 POST로 호출 합니다.
| location | name | mandatory | Description |
|-------------|-------------|-------------|-------------|
| header | x-api-key | `required` | API 키 |
| body | text | `required` | 필터 검사 대상이 되는 문자열 |
| body | mode | `required` | 필터 모드. quick인 경우 첫번째 매칭이 발견되면 더 이상 검색하지 않고 결과값을 리턴. normal인 경우 전체를 검사하고 그 결과를 리턴. filter인 경우 전체를 검사하고 그 결과로 필터된 단어를 필터 마스크(*)로 대체한 대체 텍스트까지 결과에 포함함. |
| body | callback | `optional` | 비동기 처리 요청 응답 수신 url. 여기에 값이 설정되면 요청은 작업 큐에 저장된 후 처리 완료 후 해당 주소에 대한 호출로 결과를 리턴합니다. |
| body | checksum | `optional` | API 키를 클라이언트에서 사용하려는 경우 만료 시간을 설정하고 검증하기 위한 체크값 |
| body | ts | `optional` | 체크값 생성 근거가 되는 타임 스템프값 |
### API 호출 결과값
API 서버가 어떤 방식으로든 동작하고 있다면 서버는 HTTP(S) 호출에 대한 응답으로 반드시 200 [HTTP/1.1 - RFC2616에 명세된](https://www.ietf.org/rfc/rfc2616.txt)으로 응답합니다. 이 경우 표준 응답값과 별개로 본 서비스에서는 아래와 같은 Status.Code의 값을 이용하여 서비스의 상태와 호출 결과값을 표시합니다. 만약 표준 호출 응답이 200이 아닌 경우라면 본 서비스 자체가 아니라 로드 밸런서, 게이트 웨이 등 여러가지 레이어에서 서비스 제공이 불가능하여 응답을 보낸 것으로 판단할 수 있습니다. 이 경우에는 그 컨벤션에 맞춰 대응하시면 됩니다.
| Status.Code | Description |
|-------------|-------------|
| `2000 OK` | 모든 처리가 완료되어 정상적으로 결과를 되돌리는 경우 이 코드를 사용하게 됩니다. |
| `2020 Accepted` | 호출 파라메타중 callback에 값이 설정된 경우 실제 요청에 대한 처리를 하지 않고 일단 작업큐에 저장한 후에 이 응답이 발생합니다. 이 응답은 지정된 callback 주소를 호출할때에 응답에 함께 포함된 TrackingId의 값을 참조하여 매치할 수 있습니다. |
| `4000 Bad Request` | 어떤 이유로 지금 수신한 호출에 정상 처리가 불가능합니다. 보통의 경우 필요한 파라메타가 누락된 경우 또는 해당 파라메타에 부적절한 값이 들어가 있는 경우, 부가적인 체크섬 체크 옵션이 켜져있으나 체크에 실패한 경우 입니다. 구체적인 이유는 Status.Description의 메세지를 참고하여 조건을 수정한 후에 다시 호출해야 합니다.|
| `4010 Unauthorized` | 요청을 인증할 API 키 값이 없는 경우 발생하는 오류 입니다. `4030 Forbidden` 오류와 다른 경우임에 주의해 주세요.|
| `4030 Forbidden` | 서버에서 요청에 API 키값을 인식하였으나 해당 키가 적절한 권한을 가지지 않았다고 판정한 경우 발생합니다. 보안상의 이유로 4040과 같은 코드를 사용하지는 않는다는 점을 참고해 주세요. |
| `4290 Too Many Requests` | 아이피를 기준으로 특정 클라이언트가 너무 많은 요청을 단위시간 안에 보낸 경우에 이 응답이 리턴됩니다. 서버측에서 사용자들의 입력을 처리하는 경우에는 이 값에 주의해서 미리 큰 값으로 설정해 주어야 하며, 관리자에게 문의하여 처리가 가능합니다. |
| `5000 Internal Server Error` | 서버측의 문제로 요청에 대한 처리가 불가능한 경우 오류가 발생하였음을 알리기 위해 본 코드를 사용합니다. Description에서 보다 상세한 오류 메세지를 확인할 수 있으며 보통의 경우 이 오류는 잠시 후에 다시 시도하는 것으로 극복 가능합니다. |
| `5030 Service Unavailable` | 현재 서비스가 점검중이므로 서비스 응답할 수 없는 경우 발생합니다. |
### !!! 주의 !!!
여기에 명세된 Status.Code는 http의 호출 결과 상태 코드(http status code)와 다릅니다. 그 값을 기반으로 뒤에 0을 하나 더 붙여 만들어 진 것들로 의미를 연상하기 쉽도록 만들어 진 것이나 그대로 같은 의미가 아니고 둘은 서로 다른 값이니 참고하여 주세요. 예를 들어 Status.Code의 값이 4290인 경우에도 http status의 값은 200이 오는 것이 정상입니다. 이런 설정을 가진 이유는, API 서비스를 호출하는 과정에서 서버가 반응하지 않는 경우에는 http status의 값으로 상황을 판단하고 일단 호출 결과 http status의 값이 200이라면 이는 API 서버가 작성한 결과값이 도착했음을 의미한다고 봐도 좋습니다. http status의 값이 429가 오는 경우에는 KSS를 호스팅 하는 인프라에서 자체적으로 판단하여 호출수가 너무 많다고 판정한 경우입니다.
### API 사용예
몇가지 방법으로 예를 보이면 아래와 같습니다.
cURL
```sh
curl --location --request POST 'https://www.safekiso.com/api/v1/filter' \
--header 'x-api-key: <YOUR-API-KEY-HERE>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'text=검사할 문장을 이곳에 넣어 주세요.' \
--data-urlencode 'mode=filter' \
```
JavaScript - Fetch
```sh
var myHeaders = new Headers();
myHeaders.append("x-api-key", "<YOUR-API-KEY-HERE>");
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
var urlencoded = new URLSearchParams();
urlencoded.append("text", "검사할 문장을 이곳에 넣어 주세요.");
urlencoded.append("mode", "filter");
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: urlencoded,
redirect: 'follow'
};
fetch("https://www.safekiso.com/api/v1/filter", requestOptions)
.then(response => response.text())
.then(result => console.log(result))
.catch(error => console.log('error', error));
```
NodeJs - Native
```sh
var https = require('follow-redirects').https;
var fs = require('fs');
var qs = require('querystring');
var options = {
'method': 'POST',
'hostname': 'www.safekiso.com',
'path': '/api/v1/filter',
'headers': {
'x-api-key': '<YOUR-API-KEY-HERE>',
'Content-Type': 'application/x-www-form-urlencoded',
},
'maxRedirects': 20
};
var req = https.request(options, function (res) {
var chunks = [];
res.on("data", function (chunk) {
chunks.push(chunk);
});
res.on("end", function (chunk) {
var body = Buffer.concat(chunks);
console.log(body.toString());
});
res.on("error", function (error) {
console.error(error);
});
});
var postData = qs.stringify({
'text': '검사할 문장을 이곳에 넣어 주세요.',
'mode': 'filter',
});
req.write(postData);
req.end();
```
### 초당 호출수 과다 오류 발생하는 경우
응답 코드 4290이 발생하는 경우에는 [netsafe@kiso.or.kr](mailto:netsafe@kiso.or.kr)로 연락을 주셔서 키 마다 설정되는 초당 최대 호출수 설정을 변경해 주셔야 합니다. 베타 서비스 시점에서 각 API 키의 초당 최대 호출수 제한은 100으로 설정되어 있으나, 이는 사전 예고 없이 조정된 후 공지될 수 있으니 참고해 주세요. 만약 기본 값보다 더 많은 초당 호출수가 필요한 경우에는 별도의 협약이 필요하니 [netsafe@kiso.or.kr](mailto:netsafe@kiso.or.kr)로 메일 주시기 바랍니다.
### 비동기 방식으로 서비스를 호출하는 경우
callback에 콜백할 주소를 지정하면 KSS에서는 우선 다음과 같은 응답이 도착 합니다.
```
{
"TrackingId": "7408a8f2-5843-59f5-8352-9ef2f80d89ec",
"Status": {
"Code": 2020,
"Message": "Accepted",
"Description": "Filter work created with trackingId 7408a8f2-5843-59f5-8352-9ef2f80d89ec"
}
}
```
실제 주어진 문장에 대한 필터 작업이 끝난 후에는 지정된 웹 주소로 다음과 같은 코드가 동작 합니다.
```
let options = {
uri: req.body.callback,
method: 'POST',
body: response,
json: true,
};
request.post(options, function (error, response, body) {
localHandler.handleApiFinalResponse(req, response, {
error: error,
response: response,
body: body,
});
});
```
응답(response)의 예는 아래와 같습니다.
```
{
"TrackingId": "7408a8f2-5843-59f5-8352-9ef2f80d89ec",
"Status": {
"Code": 2000,
"Message": "OK",
"Description": ""
},
"Detected": [
[
3,
"개자식"
]
],
"Filtered": "이런 ***을 봤나.",
"Elapsed": "0 s, 10.448 ms"
}
```
### 클라이언트측에서 API 키를 사용하려는 경우
이 경우 먼저 [netsafe@kiso.or.kr](mailto:netsafe@kiso.or.kr) 메일로 사용하실 키를 알려 주시고 저희가 기반 설정을 마친 후에 구체적인 검증값 생성 방법과 테스트를 도와 드립니다. 이 방식에 대한 상세한 안내는 보안과 관련이 있으므로 부득이 메일로 개별 안내 하는 것에 대해 양해 부탁 드립니다.
### 기타 API 서비스 이용 안내
<a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/guide" >서비스 이용 안내</a> 를 참고해 주세요.
### License
Copyright &copy; 2023 [(사)한국인터넷자율정책기구](https://www.kiso.or.kr/)

View File

@@ -0,0 +1,23 @@
**(사)한국인터넷자율정책기구 KSS(KISO Safeguard System)API 서비스**
## 서비스 이용 요금
### 기본 설정
KISO Safeguard System API 서비스는 KISO 회원사, 관공서, 언론사에는 무료로 제공되며 그 외에는 월 6만원의 사용료가 부과되고 사용량의 제한은 다음과 같습니다.
| 항목 | 제한 값 |
|--------------------|-------------|
| 초당 호출 수 | 100회 (하나의 API 키 마다 제한) |
| 일 최대 호출 수 | 8,640,000회 |
| 계정 당 키 생성 제한 | 5개 |
단, 이 내용은 내부 사정에 따라 향후 변경될 수 있음을 알려 드립니다.
계정당 키 생성 제한이나 초당 호출 제한 등은 시스템을 함께 사용하는 다른 고객사에 문제가 생기지 않도록 하기 위한 최소한의 제한이며 실제로 허용된 최대 사용량을 사용하는 경우에는 전체 시스템의 안정성을 위하여 별도의 협약이 필요할 수 있습니다.
또한 위 사용량 제한보다 더 많은 호출이 필요한 경우에는 별도의 논의와 협의를 거쳐 서비스를 제공하는 방법이 마련되어 있으니 문의 바랍니다.
### License
(사)한국인터넷자율정책기구

View File

@@ -0,0 +1,93 @@
**(사)한국인터넷자율정책기구 KSS(KISO Safeguard System)API 서비스**
## 인증 로고 안내
![KSS API 인증 로고](/logos/kss_certification_logo_box_korean.png)
KSS API를 이용하는 서비스에서 필터된 결과를 사용자에게 표시할 때에 혹은 필터가 될 것임을 안내하는 경우에 활용하실 수 있도록 인증 로고를 준비 하였습니다. 인증 로고는 다음과 같은 효과를 기대하고 만들어 졌습니다.
- 사용자 입력에 대한 표준적인 대응을 구현하였음을 사전에 표시하여 서비스 신뢰도 향상
- 사용자들에게 필터 DB의 관리 주체를 명시함으로써 운영의 편의성과 객관성을 확보
### 준비된 내용
- 로고의 제작 의도 [1](/logos/description/kss_certification_logo_01.jpg){:target="_blank"} [2](/logos/description/kss_certification_logo_02.jpg){:target="_blank"} [3](/logos/description/kss_certification_logo_03.jpg){:target="_blank"} [4](/logos/description/kss_certification_logo_04.jpg){:target="_blank"}
- 직사각 기본 로고 : [영문 컬러 버전](/logos/kss_certification_logo_box_english.png){:target="_blank"}, [영문 흑백 버전](/logos/kss_certification_logo_box_english_g.png){:target="_blank"}, [한글 컬러 버전](/logos/kss_certification_logo_box_korean.png){:target="_blank"}, [한글 흑백 버전](/logos/kss_certification_logo_box_korean_g.png){:target="_blank"} (png 포맷)
- 간단 심볼 : [영문 컬러 버전](/logos/kss_certification_logo_symbol.png){:target="_blank"}, [영문 흑백 버전](/logos/kss_certification_logo_symbol_g.png){:target="_blank"} (png 포맷)
- 정사각 로고 : [영문 컬러 버전](/logos/kss_certification_logo_variation_english.png){:target="_blank"}, [영문 흑백 버전](/logos/kss_certification_logo_variation_english_g.png){:target="_blank"}, [한글 컬러 버전](/logos/kss_certification_logo_variation_korean.png){:target="_blank"}, [한글 흑백 버전](/logos/kss_certification_logo_variation_korean_g.png){:target="_blank"} (png 포맷)
### 사용 예
인증 로고는 위에 링크로부터 직접 다운로드 받아서 활용하실 수 도 있고, 아래와 같이 HTML태그로 KSS 서버로부터 불러 사용하실 수도 있습니다.
아래는 인증 로고를 클릭 하는 경우 설명 페이지로 이동하는 태그까지 포함한 예 입니다.
``` html
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_box_english.png" width="243" height="66" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
```
위와 같은 코드는 아래와 같은 결과로 표시 됩니다.
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_box_english.png" width="243" height="66" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
아래 코드는 영문 흑백 버전의 경우를 보여 줍니다.
``` html
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_box_english_g.png" width="243" height="66" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
```
위와 같은 코드는 아래와 같은 결과로 표시 됩니다.
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_box_english_g.png" width="243" height="66" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
아래 코드는 심볼만을 표시하는 경우를 보여 줍니다.
``` html
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_symbol.png" width="62" height="72" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
```
위와 같은 코드는 아래와 같은 결과로 표시 됩니다.
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_symbol.png" width="62" height="72" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
아래 코드는 사각형 영역의 우측 하단에 로고를 표시하는 경우를 보여 줍니다.
``` html
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img src="https://www.safekiso.com/logos/kss_certification_logo_variation_english.png" width="143" height="105" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
```
위와 같은 코드는 아래와 같은 결과로 표시 됩니다.
<a href="https://www.safekiso.com/doc/kss" rel="nofollow" title="KSS API Site Certification Logo"><img align="right" src="https://www.safekiso.com/logos/kss_certification_logo_variation_english.png" width="143" height="105" title="KSS API로 보호되고 있는 사이트 입니다." alt="KSS API Site Certification Logo"/></a>
<br/><br/>
<br/><br/>
### 주의 사항
인증 로고를 서비스에 활용하시는 경우 KSS 사이트로부터 직접 링크하는 경우 파일의 위치가 변경되는 경우 본 서비스에 이미지가 제대로 표시되지 않는 등 부작용이 발생할 수 있으므로 가급적이면 직접 링크가 아닌 다운로드 하여 안전한 위치에서 표시, 사용 하시는 것을 권장 합니다.
### 연락처
서비스 사용 신청은 [netsafe@kiso.or.kr](mailto:netsafe@kiso.or.kr) 주소로 간단한 업체 소개와 사용 용도 등을 적어 보내 주세요. 사용 신청 전, 사용 중에 기술적인 문의 등에 대해서도 같은 메일로 문의해 주시면 답변을 받아 보실 수 있습니다.
### License
Copyright &copy; 2023 [(사)한국인터넷자율정책기구](https://www.kiso.or.kr/)

View File

@@ -0,0 +1,30 @@
**(사)한국인터넷자율정책기구 KSS(KISO Safeguard System)API 서비스**
## 환영합니다!
### 서비스 이용 신청 안내
KISO 이용자보호시스템(KSS, KISO Safeguard System) API는 국내 대표 포털 네이버와 카카오로부터 제공받은 욕설·비속어 DB를 활용해 개발되었습니다.
해당 API는 약 60만 건의 욕설·비속어 DB를 활용해 입력된 표현 중 사전에 포함된 단어가 있는지 검사하고 그 결과를 필터링해 주는 서비스를 제공합니다.
본 서비스의 목적은 다양한 인터넷 서비스에서 사업자가 별도의 욕설·비속어 DB구축 및 유지 보수의 부담이 없이 욕설·비속어 표현에 대한 서비스 관리 및 운영에 도움을 드리는 것입니다.
본 서비스를 이용하기 위해서는 먼저 한국인터넷자율정책기구의 사전 승인이 필요합니다.
아래 이메일 주소로 간단한 소개와 서비스 사용 목적 등을 알려 주시면 내부 검토 후 이후 절차를 안내 해 드리겠습니다.
문의 : netsafe@kiso.or.kr
### 추가 정보
KSS에 대해 조금 더 알고 싶으시다면 아래 링크를 참고해 주세요
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/">KSS 소개</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/guide">서비스 도입 안내</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/api_doc">API 연동 안내</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/certification">인증 로고 안내</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/docs/stipulation">서비스 이용 약관</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/docs/privacy">개인정보 보호 정책</a>

View File

@@ -0,0 +1,100 @@
**(사)한국인터넷자율정책기구 KSS(KISO Safeguard System)API 서비스**
## 서비스 도입 안내
### 첫번째 고려 사항
모든 IT 서비스들은 24시간 365일 정상 동작하는 것을 목표로 하지만 때로는 그렇지 못한 경우가 있습니다. KSS API 서비스를 여러분의 서비스에 이용하시려는 경우 가장 먼저 생각해야 하는 것은 언제라도 본 서비스가 동작하지 않을 수 있다는 점입니다. KSS의 모든 기술 스텝들은 최선을 다하여 시스템을 모니터링 하고 유지 하겠지만, 돌발 상황이 발생하여 시스템이 동작을 멈추는 경우라도 여러분의 서비스를 이용하는 사용자들에게는 불친절한 모습을 보이지 않도록 최대한 세심하게 도입 설계를 해 주세요. 짧게 말씀 드리자면, KSS가 응답하지 않는다고 해서 여러분의 서비스가 멈추도록 해서는 안된다는 의미 입니다. 이하의 내용은 그와 관련된 몇 가지 안내와 의견입니다.
### 서비스 가입 계정의 선택
이 문서를 둘러 보시고 서비스 이용을 해 보려 하시는 경우 가입에 사용하는 이메일은 담당자 개인 메일이 아닌 조직의 역할 이메일((예를 들면 admin@ 혹은 infra@와 같은)을 사용하시는 것을 권합니다. 그리고 로그인에 사용하는 메일 계정으로 수신되는 메일을 자주 모니터링 하지 않는 경우에는 서비스로부터의 중요한 공지 사항등을 수신할 수 있도록 서비스 내에 개인 정보 수정 항목에서 이메일 주소를 늘 모니터링 되는 이 메일 주소로 변경해 주세요.
### 인증 로고 활용의 검토
KSS를 활용하여 얻을 수 있는 장점으로 표준화된 필터와 기준의 적용을 사용자들에게 안내 하여 서비스에 대한 신뢰를 높일 수 있다는 점이 가장 크다고 생각합니다. 여러분이 본 서비스를 활용하시려는 경우에는
<a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/certification" >인증 로고 안내</a>
의 내용도 참고해 주세요.
### 가장 일반적인 사용의 예
웹 페이지에서 DB에 게시물을 작성하는 경우를 예로 들어 보겠습니다. 사용자측에서 작성된 내용을 DB에 저장해 달라는 요청이 들어오면 서버에서는 먼저 해당 계정이 적절한 권한을 가지고 있는지 등을 검사하고 만약 조건에 맞지 않는 요청이라면 오류 메세지를 리턴합니다. 이 과정(여러가지 권한 검사)에 KSS API 호출 과정도 추가하여 만약 검사 결과가 적절치 못하다면 이런 표현은 달리 바꿔 달라는 오류 메세지로 사용자에게 되돌려 줄 수 있습니다. 만약 특정 시점에 KSS API가 정상 동작하지 않는다면(타임아웃이 될 동안 응답이 오지 않거나 서버가 아예 응답이 없거나 등) 그 사실을 그대로 사용자에게 오류 메세지로 표시하는 결과를 되돌릴 수 있습니다.
>본문 검사 과정에서 오류가 발생하였습니다. 잠시 후에 다시 시도해 주세요.
이 경우, KSS API가 정상 동작하지 않는다면 게시물의 작성이나 댓글 등이 모두 멈춘다는 단점은 있으나 구현이 가장 단순하고 사용자에게도 혼란이 가장 적은 일반적인 적용 예라 할 수 있습니다.
### 실시간 채팅, 메세징에서 사용하는 경우
실시간 채팅이나 메세지도 결국은 중계를 위해 서버를 거치게 되는데 이 과정에서 메세지를 보낸 사용자의 자격이나 계정 등에 대해 기본적인 검사를 하게 됩니다. 이 부분에 필터 API의 호출 부분을 넣어 그 결과에 따라 필터를 적용한 메세지로 바꿔 전달하거나 혹은 전송 요청을 한 사용자에게 전송 거부의 오류 메세지를 되돌려 줄 수 있습니다. 예를 든다면
>이런 개자식을 봤나.
이런 입력은
>이런 ***을 봤나.
혹은 오류 메세지로 전송을 거부할 수 있습니다.
>부적절한 표현들이 포함되어 있습니다. '개자식'
이 경우 서버측의 회신 메세지 예는 아래와 같고, 응답시간은 33밀리초 입니다.
```
{
"TrackingId": "7408a8f2-5843-59f5-8352-9ef2f80d89ec",
"Status": {
"Code": 2000,
"Message": "OK",
"Description": ""
},
"Detected": [
[
3,
"개자식"
]
],
"Filtered": "이런 ***을 봤나.",
"Elapsed": "0 s, 10.448 ms"
}
```
### API 키를 클라이언트에서 사용하는 경우
일반적인 상황이라면 API 키는 서버측에서 사용하고 사용자측, 클라이언트측에 노출되지 않도로고 하는 것이 최선입니다. 하지만 부득이하게 클라이언트측에서 사용해야 하는 경우라면 일정 시간마다 해시값을 발생시키고 이를 API 키 호출에 추가하여 서버측에서 이 값을 검사하고 만약 복사된 키가 계속 사용된다면 오류를 되돌리도록 할 수 있습니다. 구체적인 해시 키 사용과 설정의 방법은
<a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/api_doc" >API 연동 안내</a>
문서를 참고해 주세요. 단, 이 기능은 바로 사용하실 수 없으며 저희 기술팀에 문의하여 키를 이 방식 처리에 대응하도록 설정해야 합니다. 관련된 문의는 [netsafe@kiso.or.kr](mailto:netsafe@kiso.or.kr)로 메일 주세요.
### KSS가 멈춘 동안 작성된 필터 되지 않은 컨텐츠의 처리
사용자가 클라이언트측에서 무언가를 입력하고 전송 버튼을 누른 경우, 게시물의 댓글을 예로 들어 보겠습니다. 댓글은 우선 서버에서 KSS API 서비스를 호출하여 부적절한 표현이 포함되어 있는지 검사하고 만약 검사 결과가 좋지 않다면 사용자에게 이 내용은 DB에 입력할 수 없다는 오류 메세지를 출력하는 것이 일반적인 흐름입니다. 그런데 만약 KSS API가 응답이 없다면 어떻게 할까요? 어떤 댓글은 모욕적인 표현이 없는 상태로 저장되었지만 어떤 운 좋은 댓글들은 모욕적인 표현들이 그대로 포함된 채로 게시될 것입니다. 이렇게 만에 하나 발생할 수 있는 간헐적인 장애로 모욕적인 표현의 필터가 동작하거나 하지 않을 수 있는 불일치가 서비스 품질에 심각한 영향을 준다면 조금 작업이 복잡해 지긴 하지만 안전 장치를 할 수 있습니다.
게시물의 댓글 DB에 현재 이 댓글이 필터가 완료된 상태인지 필터 전인지를 표시하는 플래그와 DB에 저장된 시간(이 값은 보통 이미 있겠지요)을 저장합니다. 그리고 욕설·비속어 필터가 통과되지 않은 댓글은 '처리중'이라는 텍스트가 대신 표출되도록 해 둡니다. 댓글을 저장할 때 KSS API를 비동기 방식으로 호출할 수 있으며 처리가 끝난 후 호출될 콜백 주소를 지정할 수 있습니다. 이곳에서 해당 댓글의 검사 결과가 깨끗하다면 필터 완료로 마크하고 사용자들에게 댓글은 정상 표출될 것입니다. 만약 특정 시점에 KSS API의 동작이 실패하여 일정 시간(보통 5초 이내)이 지났음에도 필터가 완료 마크되지 않은 항목들은 한번 더 KSS 검사를 동기 방식으로 요청하여 직접 그 결과를 처리할 수 있습니다. 이런 시나리오에 필요한 세부적인 API 호출 파라메타와 예시는 <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/api_doc" >API 연동 안내</a> 문서를 참고해 주세요.
### DB를 직접 활용하거나 인공지능을 이용해 욕설·비속어을 검출하는 방식에 대한 의견
KSS API 서비스는 국내 대형 포털에서 오랜 기간 고객 응대를 하며 축적된 대량의 욕설·비속어 단어 DB를 기반으로 합니다. 어떤 경우에, 사업적인 판단에 의해, 여러분의 서비스에 자체적인 욕설·비속어 DB를 구축하고 유지하는 것에 대해 검토하실 수 있습니다. 저희가 본 서비스를 구축하게 된 계기도 바로 그 지점에 대한 고민이었습니다. 우선 현실적으로 한글로 된 적절한 DB를 최초 획득하는 것이 쉽지 않습니다. 어찌 하여 꽤 괜찮은 DB를 구했다 하더라도 새로운 표현이 계속 만들어 지기 때문에 결국은 이 시스템으로 일손을 던다기 보다는 그 DB를 계속 최신으로 유지하기 위한 노력이 시스템 구축의 비용보다 장기적으로는 더 많이 든다고 생각하게 되었습니다. DB 비교 방식의 장점은 특정 이슈에 즉각적으로 대응할 수 있다는 것입니다. 우리는 그 장점과 거대한 DB를 항상 최신으로 유지하며 표준적인 가이드를 제시하는 역할을 누군가 해 준다면 회원사들이 조금 더 서비스 본연에만 집중할 수 있지 않을까 생각 했습니다. 인공지능으로 전혀 새로운, 욕설·비속어을 발견하는 것이 가능은 합니다. 하지만 결국 사용자의 컴플레인으로 그 판정들을 다시 리뷰하고 그 결과를 인공지능 엔진에 적용하는 작업이 간단하지 않습니다. 결국 욕설·비속어에 대한 필터링은 고객 응대 이슈와 끊임 없이 맞물리게 되므로, 저희가 표준화된 가이드를 제공하고 회원사들이 그것을 이용하는 것이 각자 역할의 분담이 될 것이라고 생각 합니다.
### 개인 정보와 관련된 이슈
KSS API 서비스를 사용하기 위해서는 이메일 주소로 가입이 필요하며 저희가 수집하는 개인정보는 이메일 주소 뿐입니다. 나머지 항목들은 필수적인 항목이 아니며, 운영자가 사용자를 식별하기 위해 메모의 형식으로 별도 보관하는 항목들은 다음과 같습니다.
> 업체명, 담당자 이름, 직함
API 서비스를 호출하게 되면 서버는 주어진 문장을 검사하여 필터 결과만을 리턴하며 후에 통계를 생성할 목적으로 다음과 같은 내용의 로그를 남깁니다.
```
{
TrackingId: 호출 고유 아이디,
uid: API 키 소유자 내부 아이디(이메일 주소 아님),
key: API 키,
hit: 검출된 단어 수,
words: 검출된 단어들,
size: 전송된 원문의 크기,
referrer: 웹 요청 리퍼럴,
ip: 웹 요청 아이피,
}
```
### 기타 문의
본 서비스와 관련해 궁금하신 점이 있다면 [netsafe@kiso.or.kr](mailto:netsafe@kiso.or.kr)로 메일 주세요.

View File

@@ -0,0 +1,36 @@
**(사)한국인터넷자율정책기구 KSS(KISO Safeguard System)API 서비스**
## 웹 어드민 기능 안내
KSS API를 사용하는데 필요한 어드민 기능을 제공 합니다. 아래와 같은 기능들을 이용하실 수 있습니다.
### 서비스 로그인 관련 기능
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/user/info">회원 정보 수정 / 회원 탈퇴</a>
- 회원 정보를 수정하거나 탈퇴 기능을 수행할 수 있습니다.
### API 키 관리 관련 기능
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/key/list">API 키 리스트</a>
- 내가 만든 API 키 리스트를 볼 수 있습니다. 리스트에 원하는 항목의 <span class="font-semibold text-indigo-600 hover:text-indigo-500">상세 보기</span> 링크를 선택 하시면 상세 정보를 보고 관리할 수 있습니다.
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/key/deleted">삭제된 API 키 리스트</a>
- 삭제된 키 리스트를 볼 수 있고 복구할 수도 있습니다. 리스트에 원하는 항목의 <span class="font-semibold text-indigo-600 hover:text-indigo-500">상세 보기</span> 링크를 선택 하세요.
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/key/new">새로운 키 생성</a>
- 키의 이름은 내가 보유한 여러 키를 식별하기 위해 입력하는 것입니다.
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/filter">필터 테스트</a>
- 어떤 문장을 입력하고 필터를 수행하여 어떤 결과가 나오는지를 미리 볼 수 있습니다.
### API 키 사용 통계 관련 기능
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/statistics">API 사용량 통계</a>
- 내가 보유한 모든 키의 사용량을 합한 통계를 볼 수 있습니다. 개별 키의 사용량은 개별 키 항목의 <span class="font-semibold text-indigo-600 hover:text-indigo-500">상세 보기</span> 링크를 선택 하신 후 우측 상단 버튼을 선택하면 보실 수 있습니다.
### 기타 기능
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/support/notice">공지 사항</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/support/faq">자주 묻는 질문과 답변</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/support/inquiry">1:1 문의</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/doc/certification">인증 로고 안내</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/docs/stipulation">서비스 이용 약관</a>
- <a class="font-semibold text-indigo-600 hover:text-indigo-500" href="/docs/privacy">개인정보 보호 정책</a>

View File

@@ -0,0 +1,2 @@
API_BASE_URL: https://www.safekiso.com/api/
GOOGLE_MAPS_API_KEY: AIzaSyDJQXJrwk2oHsNdR_QBxv9ltk3JRt4wfrc

View File

@@ -0,0 +1,120 @@
<!-- This example requires Tailwind CSS v2.0+ -->
<template>
<!--
This example requires updating your template:
```
<html class="h-full">
<body class="h-full">
```
-->
<div class="flex items-center justify-center h-screen">
<main class="sm:flex">
<p class="text-4xl font-extrabold text-indigo-600 sm:text-5xl">
{{ filteredCode }}
</p>
<div class="sm:ml-6">
<div class="sm:border-l sm:border-gray-200 sm:pl-6">
<h1
class="text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl"
>
{{ filteredTitle }}
</h1>
<p class="mt-1 text-base text-gray-500">
{{ filteredMessage }}
</p>
</div>
<div
class="mt-10 flex space-x-3 sm:border-l sm:border-transparent sm:pl-6"
>
<a
href="javascript:void(0)"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="handleError('/')"
>
Go back home
</a>
<a
v-if="isAuthenticated"
href="javascript:void(0)"
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="handleError('/support/inquiry')"
>
Contact support
</a>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
error: Object,
});
const filteredCode = ref(0);
const filteredTitle = ref('');
const filteredMessage = ref('');
const isAuthenticated = ref(_crossCtl.isAuthenticated);
switch (props.error.toString()) {
case 'Error: $400':
filteredCode.value = 400;
filteredTitle.value = 'Bad Request';
filteredMessage.value =
'조건을 만족하지 않아 처리할 수 없습니다. 잠시 후 다시 시도해 주세요.';
break;
case 'Error: #401':
case 'Error: $401':
filteredCode.value = 401;
filteredTitle.value = 'Unauthorized';
filteredMessage.value = '로그인이 필요합니다.';
break;
case 'Error: #403':
case 'Error: $403':
filteredCode.value = 403;
filteredTitle.value = 'Forbidden';
filteredMessage.value = '권한이 필요 합니다.';
break;
case 'Error: #404':
case 'Error: $404':
filteredCode.value = 404;
filteredTitle.value = 'Not Found';
filteredMessage.value =
'없는 페이지 주소입니다. 잠시 후 다시 시도해 주세요.';
break;
case 'Error: #503':
filteredCode.value = 503;
filteredTitle.value = 'Network Error';
filteredMessage.value =
'서버와 통신할 수 없습니다. 인터넷 연결을 확인하시고 잠시 후 다시 시도해 주세요.';
break;
}
if (props.error.message.startsWith('Page not found: ')) {
filteredCode.value = 404;
filteredTitle.value = 'Not Found';
filteredMessage.value =
'없는 페이지 주소입니다. 잠시 후 다시 시도해 주세요.';
} else if (props.error.toString().startsWith('FetchError: ')) {
filteredCode.value = 503;
filteredTitle.value = 'Network Error';
filteredMessage.value =
'서버와 통신할 수 없습니다. 인터넷 연결을 확인하시고 잠시 후 다시 시도해 주세요.';
}
if (filteredTitle.value == '' && props.error.errorCode == undefined) {
console.log('unhandled error=', props.error);
filteredCode.value = 0;
filteredTitle.value = 'Unhandled Error';
filteredMessage.value = props.error.toString();
}
const handleError = (nextPath) => clearError({ redirect: nextPath });
</script>

View File

@@ -0,0 +1,39 @@
import { defineNuxtConfig } from 'nuxt';
// https://v3.nuxtjs.org/docs/directory-structure/nuxt.config
export default defineNuxtConfig({
ssr: false,
extends: ['./base'],
autoImports: {
dirs: ['config'],
},
meta: {
meta: [
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
],
link: [{ rel: 'stylesheet', href: 'https://rsms.me/inter/inter.css' }],
script: [],
},
css: ['~/base/assets/css/tailwind.pcss'],
build: {
postcss: {
postcssOptions: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
},
transpile: [
'@fawmi/vue-google-maps',
'@headlessui/vue',
'chart.js',
'@vuepic/vue-datepicker',
],
},
modules: ['@nuxt/content'],
});

22527
inspond-nuxt-safekiso/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"private": true,
"scripts": {
"build": "cross-env NITRO_PRESET=node nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"lint": "eslint --ext .ts,.vue",
"lint:fix": "eslint --ext .ts,.vue --fix",
"deploy:dev": "tar -czf client.tgz .output/ && aws s3 mv client.tgz s3://bucket-g9bbf2/pond_v2:safekiso/dist/dev/ --profile safekiso",
"deploy:mng": "tar -czf client.tgz .output/ && aws s3 mv client.tgz s3://bucket-g9bbf2/pond_v2:safekiso/dist/mng/ --profile safekiso",
"deploy:prod": "tar -czf client.tgz .output/ && aws s3 mv client.tgz s3://bucket-g9bbf2/pond_v2:safekiso/dist/prod/ --profile safekiso"
},
"devDependencies": {
"@headlessui/vue": "^1.6.5",
"@heroicons/vue": "^2.0.10",
"@nuxt/content": "^2.0.1",
"@nuxtjs/moment": "^1.6.1",
"@nuxtjs/tailwindcss": "^5.3.1",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/line-clamp": "^0.4.0",
"@tailwindcss/typography": "^0.5.4",
"@typescript-eslint/eslint-plugin": "^5.29.0",
"@typescript-eslint/parser": "^5.29.0",
"@vue/eslint-config-typescript": "^11.0.0",
"autoprefixer": "^10.4.7",
"cross-env": "^7.0.3",
"eslint": "^8.18.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^9.1.1",
"nuxt": "3.0.0-rc.4",
"postcss": "^8.4.14",
"tailwindcss": "^3.1.4",
"vue-eslint-parser": "^9.0.3"
},
"dependencies": {
"@fawmi/vue-google-maps": "^0.9.72",
"@vuepic/vue-datepicker": "^3.4.4",
"@vueup/vue-quill": "^1.0.0-beta.9",
"chart.js": "^3.8.0",
"dayjs": "^1.11.3",
"quill-blot-formatter": "^1.0.5",
"vue-chartjs": "^4.1.1",
"vue-json-pretty": "^2.1.1"
}
}

Some files were not shown because too many files have changed in this diff Show More