songwritersg
12 months ago
commit
2d77ed7897
62 changed files with 16760 additions and 0 deletions
-
21back-end/app/config/config.development.js
-
21back-end/app/config/config.production.js
-
95back-end/app/core/core.js
-
13back-end/app/core/database.js
-
54back-end/app/core/global.js
-
5back-end/app/index.js
-
93back-end/app/libraries/attach.library.js
-
334back-end/app/modules/board/board.controller.js
-
144back-end/app/modules/board/board.model.js
-
15back-end/app/modules/board/board.routes.js
-
263back-end/app/modules/users/users.controller.js
-
105back-end/app/modules/users/users.model.js
-
14back-end/app/modules/users/users.routes.js
-
1667back-end/package-lock.json
-
29back-end/package.json
-
3front-end/.browserslistrc
-
17front-end/.eslintrc.js
-
24front-end/.gitignore
-
24front-end/README.md
-
5front-end/babel.config.js
-
19front-end/jsconfig.json
-
11860front-end/package-lock.json
-
35front-end/package.json
-
BINfront-end/public/favicon.ico
-
17front-end/public/index.html
-
32front-end/src/App.vue
-
BINfront-end/src/assets/logo.png
-
60front-end/src/components/HelloWorld.vue
-
28front-end/src/components/MyFormInputWrap.vue
-
41front-end/src/components/MyFormLabel.vue
-
12front-end/src/components/MyFormRow.vue
-
29front-end/src/components/MyInput.vue
-
44front-end/src/components/MyTextInput.vue
-
0front-end/src/components/WysiwygEditor.vue
-
34front-end/src/main.js
-
51front-end/src/mixins.js
-
34front-end/src/models/boardModel.js
-
82front-end/src/models/userModel.js
-
137front-end/src/plugins/axios.js
-
51front-end/src/plugins/formatter.js
-
1front-end/src/router/agreement.routes.js
-
7front-end/src/router/authorize.routes.js
-
14front-end/src/router/board.routes.js
-
40front-end/src/router/index.js
-
6front-end/src/router/my.routes.js
-
34front-end/src/store/auth/index.js
-
21front-end/src/store/index.js
-
5front-end/src/views/AboutView.vue
-
65front-end/src/views/Authorize/SignIn.vue
-
308front-end/src/views/Authorize/SignUp.vue
-
97front-end/src/views/Board/BoardPostList.vue
-
178front-end/src/views/Board/BoardPostView.vue
-
268front-end/src/views/Board/BoardPostWrite.vue
-
67front-end/src/views/Board/BoardView.vue
-
3front-end/src/views/Board/Skins/PostGallery.vue
-
70front-end/src/views/Board/Skins/PostSimpleList.vue
-
3front-end/src/views/Board/Skins/PostWebzine.vue
-
9front-end/src/views/Errors/Error404.vue
-
30front-end/src/views/Errors/Test.vue
-
18front-end/src/views/HomeView.vue
-
0front-end/src/views/My/MyPage.vue
-
4front-end/vue.config.js
@ -0,0 +1,21 @@ |
|||||
|
module.exports = { |
||||
|
appPort: 3000, // 앱 구동 포트
|
||||
|
secretKey: 'wheeparam', // 암호화를 위해 특정한 키 입력
|
||||
|
// 데이타베이스 관련 설정
|
||||
|
database: { |
||||
|
host: '', |
||||
|
username: '', |
||||
|
password: '', |
||||
|
port: 3306, |
||||
|
database: '' |
||||
|
}, |
||||
|
// CORS관련 설정
|
||||
|
cors: { |
||||
|
origin: true, |
||||
|
credentials: true |
||||
|
}, |
||||
|
jwt: { |
||||
|
accessTokenExpire: '1m', // accessToken 의 만료시간 (1분)
|
||||
|
refreshTokenExpire: '14d', // refreshToken 의 만료시간 (14일)
|
||||
|
} |
||||
|
} |
@ -0,0 +1,21 @@ |
|||||
|
module.exports = { |
||||
|
appPort: 3000, // 앱 구동 포트
|
||||
|
secretKey: 'wheeparam', // 암호화를 위해 특정한 키 입력
|
||||
|
// 데이타베이스 관련 설정
|
||||
|
database: { |
||||
|
host: '', |
||||
|
username: '', |
||||
|
password: '', |
||||
|
port: 3306, |
||||
|
database: '' |
||||
|
}, |
||||
|
// CORS관련 설정
|
||||
|
cors: { |
||||
|
origin: true, |
||||
|
credentials: true |
||||
|
}, |
||||
|
jwt: { |
||||
|
accessTokenExpire: '1m', // accessToken 의 만료시간 (1분)
|
||||
|
refreshTokenExpire: '14d', // refreshToken 의 만료시간 (14일)
|
||||
|
} |
||||
|
} |
@ -0,0 +1,95 @@ |
|||||
|
const App = { |
||||
|
express: null, |
||||
|
isDev: false, |
||||
|
config: {} |
||||
|
} |
||||
|
|
||||
|
// 주요 의존성패키지
|
||||
|
const express = require('express') |
||||
|
const bodyParser = require('body-parser') |
||||
|
const cors = require('cors') |
||||
|
const fs = require('fs') |
||||
|
const cookieParser = require('cookie-parser') |
||||
|
|
||||
|
process.env.TZ = 'Asia/Seoul'; |
||||
|
|
||||
|
App.express = express() |
||||
|
|
||||
|
// 방금 작성한 global.js 파일을 로드합니다.
|
||||
|
require('./global') |
||||
|
|
||||
|
// CookieParser, bodyParser 를 로드합니다.
|
||||
|
// 여기서 appConfig 변수는 global.js 파일에서 각 환경에 맞는 config파일을 로드하여 할당된 변수입니다.
|
||||
|
App.express.use(cookieParser(appConfig.secretKey)) |
||||
|
App.express.use(bodyParser.json()) |
||||
|
App.express.use(bodyParser.urlencoded({extended: true})) |
||||
|
App.express.use(cors(appConfig.cors)) // CORS 설정
|
||||
|
|
||||
|
/** |
||||
|
* Helper에 등록된 helper들 자동으로 불러오기 |
||||
|
*/ |
||||
|
// helpers 폴더의 파일 목록을 가져옵니다.
|
||||
|
let fileList = fs.readdirSync(root + '/helpers'); |
||||
|
// 파일들을 전부 로드합니다.
|
||||
|
fileList.forEach(async (fileName) => { |
||||
|
require(root + '/helpers/' + fileName); |
||||
|
}); |
||||
|
|
||||
|
/** |
||||
|
* 전역 Middleware |
||||
|
* ------------------------------------------------------------------------------------ |
||||
|
* 사용자 로그인 여부 체크 |
||||
|
*/ |
||||
|
const userController = loadModule('users','controller'); |
||||
|
App.express.use(userController.loginUserCheck); |
||||
|
|
||||
|
/** |
||||
|
* 모듈에 등록된 Router 들 자동으로 불러오기 |
||||
|
*/ |
||||
|
// 라우터 라이브러리를 로드하고 router 객체에 할당 해줍니다.
|
||||
|
const router = require('express').Router(); |
||||
|
|
||||
|
// modules 폴더에 등록된 디렉토리 목록을 불러옵니다.
|
||||
|
let dirList = fs.readdirSync(modulePath) |
||||
|
dirList.forEach((dir) => { |
||||
|
// 디렉토리가 맞을경우
|
||||
|
if(fs.lstatSync(modulePath + '/' + dir).isDirectory()) { |
||||
|
// 라우팅 설정파일이 존재할 경우
|
||||
|
const routePath = `${modulePath}/${dir}/${dir}.routes.js`; |
||||
|
const matchPath = `/${dir}` |
||||
|
|
||||
|
// 파일이 존재한다면 router.use로 해당 라우트를 등록해준다.
|
||||
|
if(fs.existsSync( routePath )) { |
||||
|
router.use(matchPath, require(routePath)) |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// App.express 객체에 위에 불러온 router 설정을 사용 설정해줍니다.
|
||||
|
App.express.use(router); |
||||
|
|
||||
|
/** |
||||
|
* 업로드 관련 Router 추가 |
||||
|
*/ |
||||
|
const attachLibrary = require(root + '/libraries/attach.library') |
||||
|
const path = require("path"); |
||||
|
App.express.use(attachLibrary); |
||||
|
|
||||
|
// REST API URL 중 /attaches 로 접근하는 리소스는 모두 그대로 리소스를 반환해줍니다.
|
||||
|
// 정적인 파일 (이미지, 첨부파일등)
|
||||
|
App.express.use('/attaches', express.static(path.join(root, 'data', 'uploads'))); |
||||
|
|
||||
|
/** |
||||
|
* 어플리케이션 실행 |
||||
|
* ------------------------------------------------------------------------------------ |
||||
|
* @param port 실행 포트 |
||||
|
*/ |
||||
|
App.start = () => { |
||||
|
|
||||
|
// Listen 시작
|
||||
|
App.express.listen(appConfig.appPort, '0.0.0.0', () => { |
||||
|
console.log(`[${isDev ? '개발 모드':'릴리즈 모드'}] 서버가 작동되었습니다 : port ${appConfig.appPort}`); |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
module.exports = App |
@ -0,0 +1,13 @@ |
|||||
|
const knex = require('knex')({ |
||||
|
client:'mysql2', |
||||
|
connection: { |
||||
|
host: appConfig.database.host, |
||||
|
user: appConfig.database.username, |
||||
|
password: appConfig.database.password, |
||||
|
port: appConfig.database.port, |
||||
|
database: appConfig.database.database, |
||||
|
}, |
||||
|
debug : false |
||||
|
}) |
||||
|
|
||||
|
module.exports = knex; |
@ -0,0 +1,54 @@ |
|||||
|
"use strict"; |
||||
|
|
||||
|
const path = require("path"); |
||||
|
const fs = require("fs"); |
||||
|
/** |
||||
|
* App 개발환경 정의 |
||||
|
* ------------------------------------------------------------------------------------ |
||||
|
* 정의되지 않은 경우 자동으로 false 처리 |
||||
|
* 어플리케이션을 실행할때 입력한 추가 argument를 통해 dev와 prod 상태를 구분합니다. |
||||
|
* package.json 에서 서버 구동 npm script를 작성할때 뒤에 붙인 --dev 옵션입니다. |
||||
|
*/ |
||||
|
global.isDev = ( process.argv.length > 2 && process.argv[2] === '--dev' ) |
||||
|
|
||||
|
/** |
||||
|
* App Document Root 지정 |
||||
|
* ------------------------------------------------------------------------------------ |
||||
|
* back-end 루트를 절대경로 구해와서 root 라는 글로벌 변수에 할당합니다. |
||||
|
* 우리가 주로 로드해서 사용할 모듈폴더 (modules) 역시 글로벌 변수에 할당합니다. |
||||
|
*/ |
||||
|
global.root = path.resolve(__dirname + '/../../app'); |
||||
|
global.modulePath = root + '/modules' |
||||
|
|
||||
|
/** |
||||
|
* 사용 환경에 따른 APP 개발 환경설정 불러오기 |
||||
|
* ------------------------------------------------------------------------------------ |
||||
|
* 위의 개발환경에 따라 config폴더에서 각각 다른 파일을 불러오도록 합니다. |
||||
|
* config.development.js , config.production.js 파일 두파일로 분리됩니다. |
||||
|
*/ |
||||
|
global.appConfig = require(path.resolve(root + '/config/config.' + (isDev?'development':'production') + '.js')); |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
* 모듈 불러오기 |
||||
|
* @param moduleName 모듈 이름 |
||||
|
* @param moduleType 모듈 타입 |
||||
|
* @returns {*} |
||||
|
*/ |
||||
|
global.loadModule = ( moduleName, moduleType = 'controller') => { |
||||
|
const modulePath = `${root}/modules/${moduleName}/${moduleName}.${moduleType}.js` |
||||
|
if (!fs.existsSync(modulePath)) { |
||||
|
throw Error('로드하려는 모듈이 존재하지 않습니다') |
||||
|
} |
||||
|
const t =require(modulePath); |
||||
|
|
||||
|
return t; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 데이타베이스 객체 불러오기 |
||||
|
* @returns {*} |
||||
|
*/ |
||||
|
global.database = () => { |
||||
|
return require(`${root}/core/database.js`) |
||||
|
} |
@ -0,0 +1,5 @@ |
|||||
|
// core.js 파일을 로드한다.
|
||||
|
const app = require('./core/core') |
||||
|
|
||||
|
// 웹서버를 가동한다.
|
||||
|
app.start(); |
@ -0,0 +1,93 @@ |
|||||
|
const express = require('express') |
||||
|
const path = require("path"); |
||||
|
const md5 = require("md5"); |
||||
|
const multer = require("multer"); |
||||
|
|
||||
|
const router = require('express').Router(); |
||||
|
const randomstring = require('randomstring'); |
||||
|
const fs = require("fs"); |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
* 사용자 파일 업로드 |
||||
|
*/ |
||||
|
router.post('/attaches', async (req, res) => { |
||||
|
|
||||
|
|
||||
|
// 업로드 허용 확장자
|
||||
|
const allowExt = [".csv", ".psd", ".pdf", ".ai", ".eps", ".ps", ".smi", ".xls", ".ppt", ".pptx",".gz", ".gzip", ".tar", ".tgz", ".zip", ".rar", ".bmp", ".gif", ".jpg", ".jpe",".jpeg", ".png", ".tiff", ".tif", ".txt", ".text", ".rtl", ".xml", ".xsl",".docx", ".doc", ".dot", ".dotx", ".xlsx", ".word", ".srt",".webp"]; |
||||
|
|
||||
|
// 이미지 파일 확장자
|
||||
|
const imageExt = [".gif",".jpg",".jpe",".jpeg",".png",".tiff",".tif",".webp"] |
||||
|
|
||||
|
// 업로드 경로를 계산합니다.
|
||||
|
// 업로드 경로는 app/data/uploads/년도/월 이 됩니다
|
||||
|
const yearMonth = path.posix.join((new Date().getFullYear()).toString() , (new Date().getMonth() + 1).toString()); |
||||
|
const directory = path.join('data','uploads',yearMonth); // 루트 디렉토리를 기준으로 업로드 경로
|
||||
|
const uploadPath = path.join(root, 'data', 'uploads', yearMonth); // 루트 디렉토리를 포함한 업로드 경로
|
||||
|
|
||||
|
// multer 미들웨어 설정
|
||||
|
const upload = multer({ |
||||
|
dest: uploadPath, |
||||
|
}).array('userfile'); // 파일 필드의 이름을 지정
|
||||
|
|
||||
|
// 디렉토리가 존재하지 않는다면, 디렉토리를 생성합니다.
|
||||
|
try { |
||||
|
if (!fs.existsSync(uploadPath)) { |
||||
|
// 디렉토리가 없는 경우 생성
|
||||
|
fs.mkdirSync(uploadPath, { recursive: true }); |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error('디렉토리 생성 중 에러 발생:', err); |
||||
|
return res.status(500).json({ error: 'Failed to create directory' }); |
||||
|
} |
||||
|
|
||||
|
const resultArray = []; |
||||
|
|
||||
|
// multer 미들웨어 실행
|
||||
|
upload(req, res, async (err) => { |
||||
|
if (err) { |
||||
|
console.error('파일 업로드 중 에러 발생:', err); |
||||
|
return res.status(500).json({ error: '파일 업로드 실패' }); |
||||
|
} |
||||
|
|
||||
|
req.files.map(async (file) => { |
||||
|
const ext = path.extname(file.originalname).toLowerCase(); // 파일의 원본이름에서 확장자를 구합니다.
|
||||
|
const originalName = file.originalname; // 파일의 원본명은 따로 저장해둡니다.
|
||||
|
|
||||
|
// 업로드 허용 확장자인지 체크합니다.
|
||||
|
if( allowExt.indexOf(ext) < 0) { |
||||
|
return res.status(400).json({error: '업로드가 허용되지 않는 확장자를 가진 파일이 포함되어 있습니다 : ' + ext}) |
||||
|
} |
||||
|
|
||||
|
let fileName = md5(`${Date.now()}_${originalName}`) + randomstring.generate(5) + ext; // 파일이름을 랜덤하게 변경합니다. 한글이나 유니코드등 사용할수 없는 문자를 처리하기 위함입니다.
|
||||
|
let filePath = path.join(uploadPath, fileName); // 파일이 업로드될 경로 +
|
||||
|
|
||||
|
// 혹시 동일한 파일명이 존재한다면 파일명이 겹치지 않을떄까지 파일명을 계속 변경해 봅니다.
|
||||
|
while (fs.existsSync(filePath)) { |
||||
|
fileName = md5(`${Date.now()}_${originalName}`) + randomstring.generate(5) + ext; |
||||
|
filePath = path.join(uploadPath, fileName); |
||||
|
} |
||||
|
|
||||
|
fs.renameSync( file.path, filePath ) |
||||
|
|
||||
|
// 응답결과에 추가해줍니다.
|
||||
|
resultArray.push({ |
||||
|
file_url: path.join(path.sep, 'attaches', yearMonth, fileName), |
||||
|
file_path: path.join(directory , fileName), |
||||
|
original_name: originalName, |
||||
|
isImage: imageExt.indexOf(ext) >= 0, |
||||
|
extension: ext, |
||||
|
mime: file.mimetype, |
||||
|
file_size: file.size |
||||
|
}); |
||||
|
}) |
||||
|
return res.json(resultArray); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
* 객체 내보내기 |
||||
|
*/ |
||||
|
module.exports = router; |
@ -0,0 +1,334 @@ |
|||||
|
const repl = require("repl"); |
||||
|
const boardController = {}; |
||||
|
|
||||
|
/** |
||||
|
* 게시판 정보를 가져옵니다. |
||||
|
*/ |
||||
|
boardController.getBoardInfo = async(req, res) => { |
||||
|
const boardKey = req.params?.boardKey ?? '' |
||||
|
|
||||
|
// 게시판 키가 올바르게 넘어왔는지 확인합니다.
|
||||
|
if(! boardKey) { |
||||
|
return res.status(400).json({error:'존재 하지 않거나 삭제된 게시판입니다.'}); |
||||
|
} |
||||
|
|
||||
|
// 게시판 모델 불러오기
|
||||
|
const boardModel = loadModule('board', 'model') |
||||
|
|
||||
|
// 게시판 정보 가져오기
|
||||
|
const boardInfo = await boardModel.getBoard(boardKey) |
||||
|
|
||||
|
if(!boardInfo || boardInfo === {} ) |
||||
|
{ |
||||
|
return res.status(400).json({error:'존재 하지 않거나 삭제된 게시판입니다.'}) |
||||
|
} |
||||
|
|
||||
|
return res.json({result: boardInfo}) |
||||
|
} |
||||
|
|
||||
|
boardController.getPost = async(req, res) => { |
||||
|
const postId = req.params?.postId |
||||
|
|
||||
|
const boardModel = loadModule('board', 'model') |
||||
|
const result = await boardModel.getPostOne(postId) |
||||
|
|
||||
|
if(result) { |
||||
|
// 문자열 형태로 되어있는 attach_list는 JSON형태로 변환해준다.
|
||||
|
result.attach_list = JSON.parse(result.attach_list); |
||||
|
result.is_notice = result.is_notice === 'Y' |
||||
|
} |
||||
|
|
||||
|
return res.json({result}) |
||||
|
} |
||||
|
|
||||
|
boardController.getPostList = async(req, res) => { |
||||
|
const params = {} |
||||
|
params.key = req.params?.boardKey ?? '' |
||||
|
params.page = req.query?.page ?? 1 |
||||
|
params.page_rows = req.query?.page_rows ?? 10 |
||||
|
params.searchColumn = req.query?.searchColumn ?? '' |
||||
|
params.searchQuery = req.query?.searchQuery ?? '' |
||||
|
|
||||
|
// 게시판 모델 불러오기
|
||||
|
const boardModel = loadModule('board', 'model') |
||||
|
|
||||
|
// 먼저 공지글 부터 불러온다
|
||||
|
params.isNotice = true; |
||||
|
const noticeList = await boardModel.getPost(params); |
||||
|
|
||||
|
// 일반 게시글을 불러온다.
|
||||
|
params.isNotice = false; |
||||
|
const list = await boardModel.getPost(params); |
||||
|
|
||||
|
// 반환할 객체를 반든다.
|
||||
|
const result = { |
||||
|
result: [...noticeList.result, ...list.result], // 공지사항 목록과 일반게시글 목록을 합친다.
|
||||
|
totalCount: list.totalCount |
||||
|
} |
||||
|
|
||||
|
return res.json(result) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 게시글 작성 / 수정 / 답글 처리 |
||||
|
*/ |
||||
|
boardController.writeBoardPost = async(req, res) => { |
||||
|
|
||||
|
const boardKey = req.params?.boardKey ?? "" |
||||
|
let postId = req.params?.postId ?? 0 |
||||
|
postId = postId * 1; |
||||
|
const postParentId = req.body?.parent_id * 1 ?? 0 |
||||
|
|
||||
|
// 넘어온 데이타에 따라 수정인지 신규인지 답글인지 미리 정의해둔다
|
||||
|
const isEdit = postId > 0 |
||||
|
const isReply = postId === 0 && postParentId > 0 |
||||
|
|
||||
|
// 저장할 데이타를 먼저 정리한다.
|
||||
|
const updateData = {}; |
||||
|
updateData.title = req.body?.title ?? '' |
||||
|
updateData.content = req.body?.content ?? '' |
||||
|
updateData.category = req.body?.category ?? '' |
||||
|
updateData.author_name = req.body?.author_name ?? '' |
||||
|
updateData.author_pass = req.body?.author_pass ?? '' |
||||
|
updateData.is_notice = req.body?.is_notice === true ? 'Y' : 'N' |
||||
|
updateData.updated_at = new Date() |
||||
|
updateData.updated_user = req.loginUser.id |
||||
|
updateData.updated_ip = req.loginUser.ip |
||||
|
|
||||
|
// 첨부파일 목록
|
||||
|
const attach_list = req.body?.attach_list ?? [] |
||||
|
updateData.attach_list = JSON.stringify(attach_list); |
||||
|
|
||||
|
// 게시판 모델 불러오기
|
||||
|
const boardModel = loadModule('board','model'); |
||||
|
|
||||
|
// 답글이거나, 수정일 경우 원본 글을 가져와야 한다.
|
||||
|
let refData = null; |
||||
|
|
||||
|
if(isEdit) { |
||||
|
refData = await boardModel.getPostOne(postId); |
||||
|
|
||||
|
if(! refData) { |
||||
|
return res.status(400).json({error:'수정하려는 원본글이 존재하지 않거나 이미 삭제되었습니다.'}) |
||||
|
} |
||||
|
} |
||||
|
else if (isReply) { |
||||
|
refData = await boardModel.getPostOne(postParentId); |
||||
|
|
||||
|
if(! refData) { |
||||
|
return res.status(400).json({error:'답글을 작성하려는 원본글이 존재하지 않거나 이미 삭제되었습니다.'}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 로그인된 사용자의 경우, 비밀번호와 작성자이름은 비워둔다.
|
||||
|
if(req.loginUser.id > 0) { |
||||
|
updateData.author_name = '' |
||||
|
updateData.author_pass = '' |
||||
|
} |
||||
|
// 비로그인 사용자의 경우, 이름과 비밀번호를 작성하였는지 체크한다.
|
||||
|
else { |
||||
|
if(updateData.author_name.length === 0) { |
||||
|
return res.status(400).json({error:'닉네임을 입력하셔야 합니다.'}); |
||||
|
} |
||||
|
if(updateData.author_pass.length === 0) { |
||||
|
return res.status(400).json({error:'비밀번호를 입력하셔야 합니다.'}); |
||||
|
} |
||||
|
|
||||
|
// 입력받은 비밀번호를 암호화 한다.
|
||||
|
updateData.author_pass = require('sha256')(require('md5')(appConfig.secretKey + updateData.author_pass)) |
||||
|
|
||||
|
// 수정글인 경우 입력한 비밀번호와 기존 글의 비밀번호가 동일한지 확인한다.
|
||||
|
if(isEdit) { |
||||
|
if (updateData.author_pass !== refData?.author_pass) { |
||||
|
return res.status(400).json({error:'수정하려는 글의 비밀번호가 맞지않습니다.'}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 신규등록일 경우 필수 정보를 추가해준다.
|
||||
|
if(! isEdit) { |
||||
|
updateData.board_key = boardKey; |
||||
|
updateData.type = req.body?.type ?? 'POST' // DEFAULT 로 'POST' 입력, 댓글일경우 명시해줘야함
|
||||
|
updateData.created_at = updateData.updated_at |
||||
|
updateData.created_user = updateData.updated_user |
||||
|
updateData.created_ip = updateData.updated_ip |
||||
|
|
||||
|
if(isReply) { |
||||
|
updateData.parent_id = postParentId |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 첨부된 파일중 이미지 파일이 있다면 썸네일로 지정함
|
||||
|
updateData.thumbnail = '' |
||||
|
|
||||
|
// 첨부파일 배열을 돌면서 검사
|
||||
|
for(let i=0; i<attach_list.length; i++) { |
||||
|
if(attach_list[i].isImage) { |
||||
|
// 해당 첨부파일이 이미지이면 첨부파일로 값을 넣고, for문 break;
|
||||
|
updateData.thumbnail = attach_list[i].file_url; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
updateData.attach_count = attach_list.length; |
||||
|
|
||||
|
// 데이타베이스 처리 객체
|
||||
|
const db = database() |
||||
|
|
||||
|
// 멀티뎁스 게시판을 위한 처리
|
||||
|
// 신규작성일때만 처리한다.
|
||||
|
if(! isEdit) |
||||
|
{ |
||||
|
// 부모게시글이 없다면?
|
||||
|
if(! isReply) { |
||||
|
updateData.reply = '' |
||||
|
|
||||
|
// num은 게시글의 가장 큰 num 값을 가져와 1을 더한다.
|
||||
|
updateData.num = 1 |
||||
|
await db('tbl_board_posts').max('num', {as: 'max'}) |
||||
|
.then((rows) => { |
||||
|
if(rows && rows[0]) { |
||||
|
updateData.num = (rows[0]?.max ?? 0) + 1 |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
else { |
||||
|
// num은 부모의 num을 그대로 따른다.
|
||||
|
updateData.num = refData.num |
||||
|
|
||||
|
// reply를 계산하자. reply의 컬럼 길이가 10이므로 최대 10단계까지 답변가능
|
||||
|
if(refData.reply.length >= 10) { |
||||
|
return res.status(400).json({error:'더 이상 답변할 수 없습니다. 답변은 10단계 까지만 가능합니다.'}) |
||||
|
} |
||||
|
|
||||
|
const replyLen = refData.reply.length + 1; |
||||
|
|
||||
|
const begin_reply_char = 'A'; |
||||
|
const end_reply_char = 'Z'; |
||||
|
|
||||
|
let replyChar = begin_reply_char; |
||||
|
|
||||
|
let query = `SELECT MAX(SUBSTRING(reply, ${replyLen}, 1)) AS reply FROM tbl_board_posts WHERE board_key = ? AND num = ? AND SUBSTRING(reply, ${replyLen}, 1) <> '' ` |
||||
|
let bindList = [boardKey, refData.num] |
||||
|
|
||||
|
if(replyLen >0) { |
||||
|
query += " AND reply LIKE ? " |
||||
|
bindList.push( refData.reply + '%' ); |
||||
|
} |
||||
|
|
||||
|
await db.raw(query, bindList) |
||||
|
.then(rows => { |
||||
|
if(rows && rows[0]) { |
||||
|
if(rows[0].reply === end_reply_char ) { |
||||
|
return res.status(500).json({error: '더 이상 답변할 수 없습니다. 답변은 26개까지만 가능합니다.'}) |
||||
|
} |
||||
|
else if (rows[0].reply){ |
||||
|
replyChar = String.fromCharCode(rows[0].reply.charCodeAt(0) + 1); |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 실제 DB 입력처리
|
||||
|
try { |
||||
|
if(isEdit) { |
||||
|
await db('tbl_board_posts').where('id', postId).update(updateData); |
||||
|
} |
||||
|
else { |
||||
|
await db('tbl_board_posts').insert(updateData).then(id => { |
||||
|
postId = id; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
catch { |
||||
|
return res.status(500).json({error:'DB 입력도중 오류가 발생하였습니다.'}); |
||||
|
} |
||||
|
|
||||
|
return res.status(200).json({result: {id: postId}}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 게시판 조회수 처리하기 |
||||
|
*/ |
||||
|
boardController.increasePostHit = async(req, res) => { |
||||
|
// 패러미터에서 값을 받습니다.
|
||||
|
const boardKey = req.params?.boardKey ?? '' |
||||
|
const postId = (req.params?.postId?? 0) * 1 |
||||
|
|
||||
|
console.log(boardKey, postId); |
||||
|
if(! boardKey || postId < 0) { |
||||
|
// 조회수를 올리는 작업의 경우 실패하여도 사용자에겐 보이지 않도록 silent 하게 처리해야하므로 오류일 경우에도 STATUS 200처리.
|
||||
|
return res.json({}) |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const db = database() |
||||
|
await db.raw("UPDATE tbl_board_posts SET `hit` = `hit` + 1 WHERE id = ?", [postId]); |
||||
|
} |
||||
|
catch {} |
||||
|
|
||||
|
|
||||
|
return res.json({}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 게시글 삭제하기 |
||||
|
*/ |
||||
|
boardController.deletePost = async(req, res) => { |
||||
|
// 패러미터에서 값을 받습니다.
|
||||
|
const boardKey = req.params?.boardKey ?? '' |
||||
|
const postId = (req.params?.postId?? 0) * 1 |
||||
|
|
||||
|
// 먼저 원본 게시글 데이타를 가져옵니다.
|
||||
|
const boardModel = loadModule('board', 'model'); |
||||
|
const original = boardModel.getPostOne(postId); |
||||
|
|
||||
|
// 원본글이 없거나 이미 삭제된 글의 경우 처리
|
||||
|
if(! original || original.status === 'N' ) { |
||||
|
return res.status(400).json({error: '존재하지 않거나 이미 삭제된 글입니다.'}); |
||||
|
} |
||||
|
|
||||
|
// 삭제 권한을 체크합니다.
|
||||
|
if(this.loginUser.auth < 10 ) { |
||||
|
// 관리자 권한이 아닌경우
|
||||
|
if( original.created_user === 0 && this.loginUser.id === 0 ) |
||||
|
{ |
||||
|
// 작성자도 비회원, 현재 사용자도 비회원일경우 비밀번호 체크
|
||||
|
const password = req.body?.password ?? '' |
||||
|
const encryptedPassword = require('sha256')(require('md5')(appConfig.secretKey + password)) |
||||
|
|
||||
|
// 비밀번호가 다를경우
|
||||
|
if(encryptedPassword !== original.author_pass) { |
||||
|
return res.status(400).json({error :'해당 글을 삭제할 권한이 없습니다.'}); |
||||
|
} |
||||
|
} |
||||
|
else if (original.created_user !== this.loginUser.id) |
||||
|
{ |
||||
|
// 작성자와 현재 로그인 회원이 다른경우
|
||||
|
return res.status(400).json({error :'해당 글을 삭제할 권한이 없습니다.'}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// STATUS를 N 처리해준다.
|
||||
|
try { |
||||
|
const db = database() |
||||
|
await db(boardModel.postTableName) |
||||
|
.where('id', postId) |
||||
|
.update({ |
||||
|
status: 'N', |
||||
|
updated_at: new Date(), |
||||
|
updated_user: this.loginUser.id, |
||||
|
updated_ip : this.loginUser.ip`` |
||||
|
}) |
||||
|
} |
||||
|
catch { |
||||
|
return res.status(500).json({error:'DB 에러'}); |
||||
|
} |
||||
|
|
||||
|
return res.json({}) |
||||
|
} |
||||
|
|
||||
|
module.exports = boardController |
@ -0,0 +1,144 @@ |
|||||
|
const boardModel = {}; |
||||
|
|
||||
|
boardModel.tableName = 'tbl_board' |
||||
|
boardModel.postTableName = 'tbl_board_posts' |
||||
|
|
||||
|
/** |
||||
|
* 게시판 키를 이용하여 게시판 정보를 가져옵니다. |
||||
|
*/ |
||||
|
boardModel.getBoard = async ( key )=>{ |
||||
|
// 반환할 객체 선언
|
||||
|
let result = {} |
||||
|
|
||||
|
const db = database() |
||||
|
try { |
||||
|
await db(boardModel.tableName) |
||||
|
.where('key', key) |
||||
|
.limit(1) |
||||
|
.then((rows) => { |
||||
|
if(rows.length >0 && rows[0]) |
||||
|
{ |
||||
|
result = rows[0] |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
catch { |
||||
|
result = {} |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 게시글 또는 댓글 목록 불러오기 |
||||
|
*/ |
||||
|
boardModel.getPost = async( params ) => { |
||||
|
// 게시판 고유 키
|
||||
|
const boardKey = params?.key ?? '' |
||||
|
|
||||
|
// 게시글인지, 댓글인지 여부
|
||||
|
const type = params?.type ?? 'POST' |
||||
|
const parent_id = params?.parent_id ?? 0 // 댓글일 경우 부모 게시글 PK
|
||||
|
|
||||
|
// 검색 조건
|
||||
|
const isNotice = params?.isNotice ? 'Y' : 'N' |
||||
|
const searchColumn = params?.searchColumn ?? '' |
||||
|
const searchQuery = params?.searchQuery ?? '' |
||||
|
const page = params?.page ?? 1 |
||||
|
const pageRows = params?.page_rows ?? 10 |
||||
|
const start = (page - 1) * pageRows |
||||
|
|
||||
|
const db = database() |
||||
|
const t = db(boardModel.postTableName) |
||||
|
.select(db.raw(`SQL_CALC_FOUND_ROWS ${boardModel.postTableName}.*`)) |
||||
|
.select('tbl_members.nickname AS created_user_name') |
||||
|
.leftJoin('tbl_members', `${boardModel.postTableName}.created_user`, 'tbl_members.id') |
||||
|
.where(`${boardModel.postTableName}.status`, 'Y') |
||||
|
.where(`${boardModel.postTableName}.type`, type) |
||||
|
.where(`${boardModel.postTableName}.is_notice`, isNotice) |
||||
|
|
||||
|
// 게시판 키가 있는 경우 조건에 추가한다.
|
||||
|
if(boardKey) { |
||||
|
t.where(`${boardModel.postTableName}.board_key`, boardKey) |
||||
|
} |
||||
|
|
||||
|
// 댓글 불러오기의 경우 부모 게시글 PK를 조건에 추가한다.
|
||||
|
if(type === 'COMMENT') { |
||||
|
t.where(`${boardModel.postTableName}.parent_id`, parent_id) |
||||
|
} |
||||
|
|
||||
|
// 공지글 불러오기가 아니면서, 검색어 값이 있는 경우
|
||||
|
if(isNotice !== 'Y' && searchColumn && searchQuery ) |
||||
|
{ |
||||
|
if(searchColumn === 'title') { |
||||
|
t.whereLike(`${boardModel.postTableName}.title`, searchQuery); |
||||
|
} |
||||
|
else if (searchColumn === 'author') { |
||||
|
t.where('tbl_members.nickname', searchQuery); |
||||
|
} |
||||
|
else if (searchColumn === 'title+content') { |
||||
|
t.where(function() { |
||||
|
this.where(`${boardModel.postTableName}.title`, searchQuery) |
||||
|
.orWhere(`${boardModel.postTableName}.content`, searchQuery) |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 공지가 아닐 경우 페이징 처리
|
||||
|
if(isNotice !== 'Y') |
||||
|
{ |
||||
|
t.limit(pageRows).offset(start); |
||||
|
} |
||||
|
|
||||
|
// 반환할 객체 만들기
|
||||
|
const result = { |
||||
|
result : [], |
||||
|
totalCount: 0 |
||||
|
} |
||||
|
|
||||
|
// 정렬 순서 설정
|
||||
|
t.orderBy('num','desc').orderBy('reply','asc') |
||||
|
|
||||
|
// 리스트 불러오기
|
||||
|
console.log(t.toSQL()); |
||||
|
await t.then((rows) => { |
||||
|
result.result = rows; |
||||
|
}) |
||||
|
|
||||
|
// 검색된 총 게시글수 불러오기
|
||||
|
await db.raw('SELECT FOUND_ROWS() AS `cnt`') |
||||
|
.then(res => { |
||||
|
result.totalCount = res[0][0]?.cnt * 1 ?? 0 |
||||
|
}) |
||||
|
|
||||
|
// 각 게시글의 번호를 달아준다.
|
||||
|
for(let i=0; i<result.result.length; i++) { |
||||
|
result.result[i].num = result.totalCount - start - i; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 게시글 한개 가져오기 |
||||
|
*/ |
||||
|
boardModel.getPostOne = async(postId) => { |
||||
|
let result = {} |
||||
|
|
||||
|
const db = database() |
||||
|
await db |
||||
|
.select(db.raw(`${boardModel.postTableName}.*`)) |
||||
|
.select(db.raw(`IFNULL(tbl_members.nickname, ${boardModel.postTableName}.author_name) AS created_user_name`)) |
||||
|
.from(boardModel.postTableName) |
||||
|
.leftJoin('tbl_members', `${boardModel.postTableName}.created_user`, 'tbl_members.id') |
||||
|
.where(boardModel.postTableName+'.id', postId) |
||||
|
.then((rows) => { |
||||
|
if(rows && rows[0]) { |
||||
|
result = rows[0] |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
module.exports = boardModel |
@ -0,0 +1,15 @@ |
|||||
|
const router = require('express').Router() |
||||
|
|
||||
|
// 컨트롤러 파일을 불러옵니다.
|
||||
|
const controller = loadModule('board', 'controller'); |
||||
|
|
||||
|
router.get('/:boardKey', controller.getBoardInfo) |
||||
|
router.get('/:boardKey/posts', controller.getPostList) |
||||
|
router.get('/:boardKey/posts/:postId/comments', controller.getPostList) |
||||
|
router.get('/:boardKey/posts/:postId', controller.getPost) |
||||
|
router.post('/:boardKey/posts', controller.writeBoardPost) |
||||
|
router.put('/:boardKey/posts/:postId', controller.writeBoardPost) |
||||
|
router.post('/:boardKey/posts/:postId/hit', controller.increasePostHit) |
||||
|
router.delete('/:boardKey/posts/:postId', controller.deletePost) |
||||
|
|
||||
|
module.exports = router |
@ -0,0 +1,263 @@ |
|||||
|
const jwt = require("jsonwebtoken"); |
||||
|
const usersController = {}; |
||||
|
|
||||
|
/** |
||||
|
* 핸드폰 번호인증 |
||||
|
*/ |
||||
|
usersController.phoneAuth = async(req, res) => { |
||||
|
|
||||
|
// POST 요청한 body에서 값을 받아옵니다.
|
||||
|
const phone = req.body?.phone?.replace('/-/g','') ?? ''; |
||||
|
const code = Math.floor(100000 + Math.random() * 900000) |
||||
|
|
||||
|
// 핸드폰 번호값이 없는경우
|
||||
|
if(phone.length === 0) { |
||||
|
return res.status(400).json({error:'핸드폰 번호를 입력하세요'}); |
||||
|
} |
||||
|
|
||||
|
// @TODO: 실제 SMS 발송 API를 이용한 인증번호 발송 처리
|
||||
|
|
||||
|
// 응답 데이타를 만든다
|
||||
|
const result = { |
||||
|
authCode: code |
||||
|
} |
||||
|
|
||||
|
return res.json({result}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 회원가입 처리 |
||||
|
*/ |
||||
|
usersController.userRegister = async(req, res) => { |
||||
|
// 받아온 데이타를 정리한다.
|
||||
|
let login_id = req.body?.email ?? '' |
||||
|
let login_pass = req.body?.password ?? '' |
||||
|
let login_pass_confirm = req.body?.passwordConfirm ?? '' |
||||
|
let nickname = req.body?.nickname ?? '' |
||||
|
let phone = req.body?.phone ?? '' |
||||
|
let agree_marketing = req.body?.agreeMarketing ? 'Y' : 'N' |
||||
|
let privacy_agree_at = (new Date()) |
||||
|
|
||||
|
// model 객체 불러오기
|
||||
|
const usersModel = loadModule('users', 'model'); |
||||
|
|
||||
|
// 폼검증 처리 시작
|
||||
|
if(nickname === '') { |
||||
|
return res.status(400).json({error:'[닉네임]은 필수 입력값입니다'}) |
||||
|
} |
||||
|
if(login_id === '') { |
||||
|
return res.status(400).json({error:'[이메일주소]는 필수 입력값입니다'}) |
||||
|
} |
||||
|
|
||||
|
const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; |
||||
|
|
||||
|
console.log(emailRegex.test(login_id)); |
||||
|
if(! emailRegex.test(login_id)) |
||||
|
{ |
||||
|
return res.status(400).json({error:'올바른 형식의 [이메일주소]가 아닙니다'}) |
||||
|
} |
||||
|
|
||||
|
// 이미 사용중인 이메일 주소인지 체크합니다.
|
||||
|
const check1 = await usersModel.getUser(login_id, 'login_id'); |
||||
|
if(check1 !== null) { |
||||
|
return res.status(400).json({error:'이미 가입된 [이메일주소] 입니다.'}) |
||||
|
} |
||||
|
|
||||
|
if(login_pass === '') { |
||||
|
return res.status(400).json({error:'[비밀번호]는 필수 입력값입니다'}) |
||||
|
} |
||||
|
|
||||
|
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; |
||||
|
if(! passwordRegex.test(login_pass)) { |
||||
|
return res.status(400).json({error:'[비밀번호]는 8자 이상, 하나이상의 문자,숫자 및 특수문자를 사용하셔야 합니다'}) |
||||
|
} |
||||
|
|
||||
|
if(login_pass !== login_pass_confirm) |
||||
|
{ |
||||
|
return res.status(400).json({error:'[비밀번호]와 [비밀번호 확인]이 서로 다릅니다.'}) |
||||
|
} |
||||
|
|
||||
|
const result = await usersModel.addUser({ |
||||
|
login_id, |
||||
|
login_pass, |
||||
|
phone, |
||||
|
nickname, |
||||
|
agree_marketing, |
||||
|
privacy_agree_at |
||||
|
}) |
||||
|
|
||||
|
if(result) { |
||||
|
return res.json({result: 'success'}) |
||||
|
} |
||||
|
else { |
||||
|
return res.status(500).json({result: 'fail'}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 사용자 로그인 처리 |
||||
|
*/ |
||||
|
usersController.authorize = async(req, res) => { |
||||
|
const loginId = req.body?.loginId ?? '', |
||||
|
loginPass = req.body?.loginPass ?? '' |
||||
|
|
||||
|
// 아이디와 비밀번호 폼검증
|
||||
|
if( loginId.length === 0 ) |
||||
|
return res.status(400).json({error:'[이메일주소]를 입력하세요.'}) |
||||
|
|
||||
|
if( loginPass.length === 0 ) |
||||
|
return res.status(400).json({error:'[비밀번호]를 입력하세요.'}) |
||||
|
|
||||
|
// user model 불러오기
|
||||
|
const UserModel = loadModule('users', 'model') |
||||
|
|
||||
|
// 해당하는 아이디의 사용자가 있는지 정보를 가져옵니다.
|
||||
|
let user = await UserModel.getUser( loginId, 'login_id' ) |
||||
|
|
||||
|
// 데이타베이스에 정보가 없다면
|
||||
|
if(user === false || user === null) |
||||
|
return res.status(400).json({error:'가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'}) |
||||
|
|
||||
|
// 비밀번호 체크를 위해 입력한 비밀번호를 암호화 합니다.
|
||||
|
const encryptedPassword = require('sha256')(require('md5')(appConfig.secretKey + loginPass)) |
||||
|
|
||||
|
// DB에서 가져온 암호화된 사용자 비밀번호와 사용자가 입력한 암호화된 비밀번호가 맞지 않다면
|
||||
|
// 아이디가 존재하지 않을 경우와 비밀번호가 다를 경우의 에러메시지를 동일하게 사용합니다.
|
||||
|
if(user.login_pass !== encryptedPassword) |
||||
|
return res.status(400).json({error:'가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'}) |
||||
|
|
||||
|
// 회원상태가 정상이 아닌경우
|
||||
|
if(user.status !== 'Y') |
||||
|
return res.status(400).json({error:'가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'}) |
||||
|
|
||||
|
// 모든 검증이 완료되면 토큰을 생성하여 반환한다.
|
||||
|
return await UserModel.responseToken(user) |
||||
|
.then(result => { |
||||
|
return res.json(result); |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 토큰 재생성 |
||||
|
*/ |
||||
|
usersController.refreshToken = async (req, res) => { |
||||
|
const refreshToken = req.body?.refreshToken ?? '' |
||||
|
const jwt = require('jsonwebtoken'); |
||||
|
|
||||
|
// 올바른 refreshToken 정보가 없을경우
|
||||
|
if (!refreshToken) |
||||
|
return res.status(401).json({error: '사용자 로그인 정보가 유효하지 않습니다'}); |
||||
|
|
||||
|
// verify 메서드를 이용해서 refresh token을 검증합니다.
|
||||
|
await jwt.verify(refreshToken, appConfig.secretKey, async (error, decoded) => { |
||||
|
|
||||
|
// 검증 오류 발생시 오류를 반환합니다.
|
||||
|
if (error) { |
||||
|
if (error.name === 'TokenExpiredError') { |
||||
|
return res.status(401).json({error: '사용자 로그인 정보가 유효하지 않습니다'}); |
||||
|
} |
||||
|
return res.status(401).json({error: '사용자 로그인 정보가 유효하지 않습니다',}); |
||||
|
} |
||||
|
|
||||
|
const UserModel = loadModule('users', 'model') |
||||
|
|
||||
|
// 검증 오류가 없는 경우 새로운 토큰을 발급해줍니다.
|
||||
|
let user = {}; |
||||
|
try { |
||||
|
// user model 불러오기
|
||||
|
|
||||
|
|
||||
|
// 사용자 정보 가져오기
|
||||
|
await UserModel.getUser(decoded.id, 'id') |
||||
|
.then((res) => { |
||||
|
user = res; |
||||
|
}); |
||||
|
} catch { |
||||
|
user = null; |
||||
|
} |
||||
|
|
||||
|
// 회원상태가 정상이 아닌경우
|
||||
|
if (user === {} || user === null || user.status !== 'Y') |
||||
|
return res.status(400).json({error: '가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'}); |
||||
|
|
||||
|
// 새로운 accessToken 과 refreshToken 을 발급한다.
|
||||
|
return await UserModel.responseToken(user).then((json) => { |
||||
|
return res.status(200).json(json); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 로그인한 사용자의 정보 가져오기 |
||||
|
*/ |
||||
|
usersController.getInfo = async(req, res) => { |
||||
|
// 미들웨어를 통해 헤더를 통해 전송받은 accessToken을 이용해 현재 로그인 사용자의 PK를 가져온다.
|
||||
|
const loginUserId = req.loginUser?.id ?? 0 |
||||
|
|
||||
|
// 로그인 되지 않앗거나 잘못된 PK의 경우
|
||||
|
if(loginUserId === undefined || loginUserId < 1) { |
||||
|
return res.status(400).json({error:'잘못된 접근입니다'}) |
||||
|
} |
||||
|
|
||||
|
// user model 불러오기
|
||||
|
const UserModel = loadModule('users', 'model') |
||||
|
|
||||
|
let user = {} |
||||
|
try { |
||||
|
await UserModel.getUser( loginUserId, 'id' ).then(res => {user = res}) |
||||
|
} |
||||
|
catch { |
||||
|
user = null |
||||
|
} |
||||
|
|
||||
|
// 회원상태가 정상이 아닌경우
|
||||
|
if(user === {} || user === null || user.status !== 'Y') |
||||
|
return res.status(400).json({code:'AUTH.ERR007', error:"탈퇴한 회원이거나 접근이 거부된 회원입니다."}) |
||||
|
|
||||
|
return res.json(user); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 로그인한 사용자의 정보를 체크하는 미들웨어 |
||||
|
*/ |
||||
|
usersController.loginUserCheck = async(req, res, next) => { |
||||
|
// JWT 패키지 로드
|
||||
|
const jwt = require('jsonwebtoken'); |
||||
|
const ipToInt = require('ip-to-int'); |
||||
|
|
||||
|
|
||||
|
// Default 값을 지정한다
|
||||
|
req.loginUser = { |
||||
|
id: 0, |
||||
|
ip: ipToInt(req.headers['x-forwarded-for'] || req.connection.remoteAddress).toInt() |
||||
|
} |
||||
|
|
||||
|
// 만약 토큰 재발급요청이거나, 로그인 요청의 경우 실행하지 않는다.
|
||||
|
if(req.path === '/users/authorize/token' || req.path === '/users/authorize') { |
||||
|
return next(); |
||||
|
} |
||||
|
|
||||
|
// 헤더에 포함된 accessToken 값을 가져온다.
|
||||
|
let accessToken = req.headers['Authorization'] || req.headers['authorization']; |
||||
|
// AccessToken이 없는 경우 비로그인 상태이므로 그대로 넘어간다.
|
||||
|
if(! accessToken) return next(); |
||||
|
|
||||
|
// AccessToken 값에서 "Bearer" 값을 제거한다.
|
||||
|
accessToken = accessToken.replace("Bearer ",'') |
||||
|
|
||||
|
// AccessToken을 검증한다.
|
||||
|
await jwt.verify(accessToken, appConfig.secretKey, async(error, decoded) => { |
||||
|
if (error) { |
||||
|
return res.status(401).json({error: '토큰 유효기간이 만료되었습니다.'}); |
||||
|
} |
||||
|
else { |
||||
|
// 토큰검증에 성공한경우, req.loginUser 객체의 id 값을 복호화한 회원PK값으로 변경한다.
|
||||
|
req.loginUser.id = decoded.id; |
||||
|
|
||||
|
return next(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
module.exports = usersController; |
@ -0,0 +1,105 @@ |
|||||
|
const usersModel = {}; |
||||
|
|
||||
|
usersModel.tableName = "tbl_members"; |
||||
|
/** |
||||
|
* 사용자 데이타 한 행을 가져온다. |
||||
|
*/ |
||||
|
usersModel.getUser = async(value, column="id") => { |
||||
|
// 데이타베이스 연결 객체
|
||||
|
const db = database(); |
||||
|
|
||||
|
// 반환할 객체
|
||||
|
let result = null |
||||
|
|
||||
|
try { |
||||
|
await db(usersModel.tableName) |
||||
|
.select("*") |
||||
|
.select(db.raw("INET_ATON(`loged_ip`) AS `loged_ip`")) |
||||
|
.where(column, value) |
||||
|
.limit(1) |
||||
|
.then((rows) => { |
||||
|
if(rows && rows.length > 0) { |
||||
|
result = rows[0] |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
catch (e){ |
||||
|
result = null; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 사용자 데이타 추가 |
||||
|
*/ |
||||
|
usersModel.addUser = async( data ) => { |
||||
|
|
||||
|
// 혹시 빈값이 들어가있는경우 기본값 처리
|
||||
|
data.status = data?.status ?? 'Y' |
||||
|
data.login_id = data?.login_id ?? '' |
||||
|
data.login_pass = data?.login_pass ?? '' |
||||
|
data.phone = data?.phone ?? '' |
||||
|
data.nickname = data?.nickname ?? '' |
||||
|
data.auth = data?.auth ?? 1 |
||||
|
data.created_at = data?.created_at ?? new Date() |
||||
|
data.updated_at = data?.updated_at ?? new Date() |
||||
|
data.agree_marketing = data?.agree_marketing ?? 'N' |
||||
|
data.privacy_agree_at = data?.privacy_agree_at ?? new Date() |
||||
|
|
||||
|
// 비밀번호는 암호화 처리한다.
|
||||
|
data.login_pass = require('sha256')(require('md5')(appConfig.secretKey + data.login_pass)) |
||||
|
|
||||
|
// 결과값을 반환할 flag
|
||||
|
let result = false |
||||
|
|
||||
|
// 데이타베이스 객체
|
||||
|
const db = database() |
||||
|
|
||||
|
try { |
||||
|
await db(usersModel.tableName) |
||||
|
.insert(data) |
||||
|
.then(() => { |
||||
|
result = true |
||||
|
}) |
||||
|
} |
||||
|
catch { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 토큰을 생성합니다. |
||||
|
*/ |
||||
|
usersModel.createToken = async(type, userInfo) => { |
||||
|
const jwt = require('jsonwebtoken'); |
||||
|
const expiresIn = |
||||
|
type === 'refresh' |
||||
|
? appConfig.jwt.refreshTokenExpire |
||||
|
: appConfig.jwt.accessTokenExpire |
||||
|
|
||||
|
return await jwt.sign({ |
||||
|
id: userInfo.id |
||||
|
}, appConfig.secretKey, { |
||||
|
expiresIn |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 반환용 토큰을 생성합니다. |
||||
|
*/ |
||||
|
usersModel.responseToken = async(userInfo) => { |
||||
|
let newAccessToken = '', |
||||
|
newRefreshToken = ''; |
||||
|
await usersModel.createToken('access', userInfo ).then((v) => (newAccessToken = v)); |
||||
|
await usersModel.createToken('refresh', userInfo).then((v) => (newRefreshToken = v)); |
||||
|
|
||||
|
return { |
||||
|
accessToken: newAccessToken, |
||||
|
refreshToken: newRefreshToken |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = usersModel; |
@ -0,0 +1,14 @@ |
|||||
|
const router = require('express').Router() |
||||
|
|
||||
|
// 컨트롤러 파일을 불러옵니다.
|
||||
|
const controller = loadModule('users', 'controller'); |
||||
|
|
||||
|
// 경로를 지정하고 컨트롤러와 연결합니다.
|
||||
|
router.post('/', controller.userRegister) |
||||
|
router.post('/authorize/phone', controller.phoneAuth) |
||||
|
router.get('/',controller.getInfo) |
||||
|
router.post('/authorize', controller.authorize) |
||||
|
router.post('/authorize/token', controller.refreshToken) |
||||
|
|
||||
|
// 설정한 라우트 설정을 내보냅니다.
|
||||
|
module.exports = router |
1667
back-end/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,29 @@ |
|||||
|
{ |
||||
|
"name": "back-end", |
||||
|
"version": "1.0.0", |
||||
|
"description": "", |
||||
|
"main": "index.js", |
||||
|
"scripts": { |
||||
|
"dev": "nodemon app --dev", |
||||
|
"server": "nodemon app" |
||||
|
}, |
||||
|
"author": "", |
||||
|
"license": "ISC", |
||||
|
"dependencies": { |
||||
|
"body-parser": "^1.20.2", |
||||
|
"cookie-parser": "^1.4.6", |
||||
|
"cors": "^2.8.5", |
||||
|
"express": "^4.18.2", |
||||
|
"ip-to-int": "^0.3.1", |
||||
|
"jsonwebtoken": "^9.0.0", |
||||
|
"knex": "^2.4.2", |
||||
|
"md5": "^2.3.0", |
||||
|
"multer": "^1.4.5-lts.1", |
||||
|
"mysql2": "^3.4.1", |
||||
|
"randomstring": "^1.3.0", |
||||
|
"sha256": "^0.2.0" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"nodemon": "^2.0.22" |
||||
|
} |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
> 1% |
||||
|
last 2 versions |
||||
|
not dead |
@ -0,0 +1,17 @@ |
|||||
|
module.exports = { |
||||
|
root: true, |
||||
|
env: { |
||||
|
node: true |
||||
|
}, |
||||
|
'extends': [ |
||||
|
'plugin:vue/essential', |
||||
|
'eslint:recommended' |
||||
|
], |
||||
|
parserOptions: { |
||||
|
parser: '@babel/eslint-parser' |
||||
|
}, |
||||
|
rules: { |
||||
|
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', |
||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
.DS_Store |
||||
|
node_modules |
||||
|
/dist |
||||
|
|
||||
|
# local env files |
||||
|
.env.local |
||||
|
.env.*.local |
||||
|
|
||||
|
# Log files |
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
|
pnpm-debug.log* |
||||
|
|
||||
|
# Editor directories and files |
||||
|
.idea |
||||
|
.vscode |
||||
|
*.suo |
||||
|
*.ntvs* |
||||
|
*.njsproj |
||||
|
*.sln |
||||
|
*.sw? |
||||
|
|
||||
|
/back-end/app/data* |
@ -0,0 +1,24 @@ |
|||||
|
# front-end |
||||
|
|
||||
|
## Project setup |
||||
|
``` |
||||
|
npm install |
||||
|
``` |
||||
|
|
||||
|
### Compiles and hot-reloads for development |
||||
|
``` |
||||
|
npm run serve |
||||
|
``` |
||||
|
|
||||
|
### Compiles and minifies for production |
||||
|
``` |
||||
|
npm run build |
||||
|
``` |
||||
|
|
||||
|
### Lints and fixes files |
||||
|
``` |
||||
|
npm run lint |
||||
|
``` |
||||
|
|
||||
|
### Customize configuration |
||||
|
See [Configuration Reference](https://cli.vuejs.org/config/). |
@ -0,0 +1,5 @@ |
|||||
|
module.exports = { |
||||
|
presets: [ |
||||
|
'@vue/cli-plugin-babel/preset' |
||||
|
] |
||||
|
} |
@ -0,0 +1,19 @@ |
|||||
|
{ |
||||
|
"compilerOptions": { |
||||
|
"target": "es5", |
||||
|
"module": "esnext", |
||||
|
"baseUrl": "./", |
||||
|
"moduleResolution": "node", |
||||
|
"paths": { |
||||
|
"@/*": [ |
||||
|
"src/*" |
||||
|
] |
||||
|
}, |
||||
|
"lib": [ |
||||
|
"esnext", |
||||
|
"dom", |
||||
|
"dom.iterable", |
||||
|
"scripthost" |
||||
|
] |
||||
|
} |
||||
|
} |
11860
front-end/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,35 @@ |
|||||
|
{ |
||||
|
"name": "front-end", |
||||
|
"version": "0.1.0", |
||||
|
"private": true, |
||||
|
"scripts": { |
||||
|
"serve": "vue-cli-service serve", |
||||
|
"build": "vue-cli-service build", |
||||
|
"lint": "vue-cli-service lint" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@toast-ui/vue-editor": "^3.2.3", |
||||
|
"axios": "^1.4.0", |
||||
|
"core-js": "^3.8.3", |
||||
|
"vue": "^2.6.14", |
||||
|
"vue-cookies": "^1.8.3", |
||||
|
"vue-pagination-2": "^3.1.0", |
||||
|
"vue-router": "^3.5.1", |
||||
|
"vuejs-paginate": "^2.1.0", |
||||
|
"vuex": "^3.6.2" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"@babel/core": "^7.12.16", |
||||
|
"@babel/eslint-parser": "^7.12.16", |
||||
|
"@vue/cli-plugin-babel": "~5.0.0", |
||||
|
"@vue/cli-plugin-eslint": "~5.0.0", |
||||
|
"@vue/cli-plugin-router": "~5.0.0", |
||||
|
"@vue/cli-plugin-vuex": "~5.0.0", |
||||
|
"@vue/cli-service": "~5.0.0", |
||||
|
"eslint": "^7.32.0", |
||||
|
"eslint-plugin-vue": "^8.0.3", |
||||
|
"sass": "^1.32.7", |
||||
|
"sass-loader": "^12.0.0", |
||||
|
"vue-template-compiler": "^2.6.14" |
||||
|
} |
||||
|
} |
@ -0,0 +1,17 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang=""> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> |
||||
|
<title><%= htmlWebpackPlugin.options.title %></title> |
||||
|
</head> |
||||
|
<body> |
||||
|
<noscript> |
||||
|
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
||||
|
</noscript> |
||||
|
<div id="app"></div> |
||||
|
<!-- built files will be auto injected --> |
||||
|
</body> |
||||
|
</html> |
@ -0,0 +1,32 @@ |
|||||
|
<template> |
||||
|
<div id="app"> |
||||
|
<nav> |
||||
|
<router-link to="/">Home</router-link> | |
||||
|
<router-link to="/about">About</router-link> |
||||
|
</nav> |
||||
|
<router-view/> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss"> |
||||
|
#app { |
||||
|
font-family: Avenir, Helvetica, Arial, sans-serif; |
||||
|
-webkit-font-smoothing: antialiased; |
||||
|
-moz-osx-font-smoothing: grayscale; |
||||
|
text-align: center; |
||||
|
color: #2c3e50; |
||||
|
} |
||||
|
|
||||
|
nav { |
||||
|
padding: 30px; |
||||
|
|
||||
|
a { |
||||
|
font-weight: bold; |
||||
|
color: #2c3e50; |
||||
|
|
||||
|
&.router-link-exact-active { |
||||
|
color: #42b983; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
After Width: 200 | Height: 200 | Size: 6.7 KiB |
@ -0,0 +1,60 @@ |
|||||
|
<template> |
||||
|
<div class="hello"> |
||||
|
<h1>{{ msg }}</h1> |
||||
|
<p> |
||||
|
For a guide and recipes on how to configure / customize this project,<br> |
||||
|
check out the |
||||
|
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>. |
||||
|
</p> |
||||
|
<h3>Installed CLI Plugins</h3> |
||||
|
<ul> |
||||
|
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li> |
||||
|
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li> |
||||
|
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li> |
||||
|
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li> |
||||
|
</ul> |
||||
|
<h3>Essential Links</h3> |
||||
|
<ul> |
||||
|
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li> |
||||
|
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li> |
||||
|
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li> |
||||
|
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li> |
||||
|
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li> |
||||
|
</ul> |
||||
|
<h3>Ecosystem</h3> |
||||
|
<ul> |
||||
|
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li> |
||||
|
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li> |
||||
|
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li> |
||||
|
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li> |
||||
|
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: 'HelloWorld', |
||||
|
props: { |
||||
|
msg: String |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<!-- Add "scoped" attribute to limit CSS to this component only --> |
||||
|
<style scoped lang="scss"> |
||||
|
h3 { |
||||
|
margin: 40px 0 0; |
||||
|
} |
||||
|
ul { |
||||
|
list-style-type: none; |
||||
|
padding: 0; |
||||
|
} |
||||
|
li { |
||||
|
display: inline-block; |
||||
|
margin: 0 10px; |
||||
|
} |
||||
|
a { |
||||
|
color: #42b983; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,28 @@ |
|||||
|
<template> |
||||
|
<div class="--form-wrap"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.--form-wrap { |
||||
|
flex:1; |
||||
|
padding:.375rem .75rem .375rem 0; |
||||
|
|
||||
|
.--form-label + & { |
||||
|
padding-left:.375rem; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
props: { |
||||
|
required: { |
||||
|
type: Boolean, |
||||
|
default: false, |
||||
|
required: false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,41 @@ |
|||||
|
<template> |
||||
|
<div class="--form-label"> |
||||
|
<slot /> |
||||
|
<span class="required ms-1" v-if="required">(필수입력)</span> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.--form-label { |
||||
|
width:100px; |
||||
|
flex-shrink: 0; |
||||
|
font-size:1rem; |
||||
|
font-weight:700; |
||||
|
padding-top:.725rem; |
||||
|
padding-right:.375rem; |
||||
|
|
||||
|
.required { |
||||
|
font-size:0; |
||||
|
display:inline-block; |
||||
|
vertical-align: top; |
||||
|
|
||||
|
&:before { |
||||
|
content:'*'; |
||||
|
color:red; |
||||
|
font-size:.9rem; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
props: { |
||||
|
required: { |
||||
|
type: Boolean, |
||||
|
default: false, |
||||
|
required: false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,12 @@ |
|||||
|
<template> |
||||
|
<div class="--form-row"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.--form-row { |
||||
|
display:flex; |
||||
|
width:100%; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,29 @@ |
|||||
|
<template> |
||||
|
<my-form-row> |
||||
|
<my-form-label :required="required">{{label}}</my-form-label> |
||||
|
<my-form-input-wrap> |
||||
|
<slot /> |
||||
|
</my-form-input-wrap> |
||||
|
</my-form-row> |
||||
|
</template> |
||||
|
<script> |
||||
|
import MyFormRow from "@/components/MyFormRow.vue"; |
||||
|
import MyFormLabel from "@/components/MyFormLabel.vue"; |
||||
|
import MyFormInputWrap from "@/components/MyFormInputWrap.vue"; |
||||
|
|
||||
|
export default { |
||||
|
components: {MyFormInputWrap, MyFormLabel, MyFormRow}, |
||||
|
props: { |
||||
|
required: { |
||||
|
type: Boolean, |
||||
|
default: false, |
||||
|
required: false |
||||
|
}, |
||||
|
label : { |
||||
|
type: String, |
||||
|
required: false, |
||||
|
default: '' |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,44 @@ |
|||||
|
<template> |
||||
|
<my-input :label="label" :required="required"> |
||||
|
<input v-model="inputValue" @input="onUpdate" :required="required" /> |
||||
|
</my-input> |
||||
|
</template> |
||||
|
<script> |
||||
|
import MyInput from "@/components/MyInput.vue"; |
||||
|
|
||||
|
export default { |
||||
|
components: {MyInput}, |
||||
|
props: { |
||||
|
required: { |
||||
|
type: Boolean, |
||||
|
required: false, |
||||
|
default: false, |
||||
|
}, |
||||
|
label: { |
||||
|
type: String, |
||||
|
required: false, |
||||
|
default: '' |
||||
|
}, |
||||
|
value: { |
||||
|
type: [String, Number], |
||||
|
required: false, |
||||
|
default: '' |
||||
|
} |
||||
|
}, |
||||
|
data () { |
||||
|
return { |
||||
|
inputValue: '' |
||||
|
} |
||||
|
}, |
||||
|
watch: { |
||||
|
value () { |
||||
|
this.inputValue = this.value |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
onUpdate () { |
||||
|
this.$emit('input', this.inputValue ) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,34 @@ |
|||||
|
import Vue from 'vue' |
||||
|
import App from './App.vue' |
||||
|
import router from './router' |
||||
|
import store from './store' |
||||
|
|
||||
|
Vue.config.productionTip = false |
||||
|
|
||||
|
import '@/plugins/formatter' |
||||
|
|
||||
|
// 믹스인(mixin)설정을 불러와 적용합니다.
|
||||
|
import mixins from "@/mixins"; |
||||
|
Vue.mixin(mixins); |
||||
|
|
||||
|
// Axios 모듈 로드
|
||||
|
import '@/plugins/axios' |
||||
|
|
||||
|
// Vue Cookies
|
||||
|
import VueCookies from 'vue-cookies' |
||||
|
Vue.use(VueCookies) |
||||
|
|
||||
|
|
||||
|
// 새로고침등을 했을때 로그인이 되어있는지 여부를 다시 체크
|
||||
|
import userModel from '@/models/userModel' |
||||
|
if(userModel.isLogin()) |
||||
|
{ |
||||
|
store.commit('authorize/setLogin', true) |
||||
|
userModel.requestMyInfo() |
||||
|
} |
||||
|
|
||||
|
new Vue({ |
||||
|
router, |
||||
|
store, |
||||
|
render: h => h(App) |
||||
|
}).$mount('#app') |
@ -0,0 +1,51 @@ |
|||||
|
import {mapGetters} from "vuex"; |
||||
|
|
||||
|
export default { |
||||
|
computed: { |
||||
|
...mapGetters({ |
||||
|
isLogin: 'authorize/isLogin', |
||||
|
loginUser: 'authorize/loginUser' |
||||
|
}) |
||||
|
}, |
||||
|
filters: { |
||||
|
// 숫자를 3자리마다 콤마(,)를 붙여준다.
|
||||
|
numberFormat (value) { |
||||
|
if(value === 0 || value === '0') return "0" |
||||
|
|
||||
|
const reg = /(^[+-]?\d+)(\d{3})/; |
||||
|
let n = (value + ''); |
||||
|
|
||||
|
while(reg.test(n)) { |
||||
|
n = n.replace(reg, '$1' + ',' + '$2'); |
||||
|
} |
||||
|
|
||||
|
return n |
||||
|
}, |
||||
|
// 날짜를 sns의 ~분전 형식으로 변경해준다.
|
||||
|
snsDateTime (value) { |
||||
|
if(value === null || value === '') return |
||||
|
|
||||
|
const today = new Date() |
||||
|
let date = new Date(value); |
||||
|
|
||||
|
const elapsedTime = Math.trunc((today.getTime() - date.getTime()) / 1000); |
||||
|
|
||||
|
let elapsedText = ""; |
||||
|
if (elapsedTime < 10) { |
||||
|
elapsedText = "방금 전"; |
||||
|
} else if (elapsedTime < 60) { |
||||
|
elapsedText = elapsedTime + "초 전"; |
||||
|
} else if (elapsedTime < 60 * 60) { |
||||
|
elapsedText = Math.trunc(elapsedTime / 60) + "분 전"; |
||||
|
} else if (elapsedTime < 60 * 60 * 24) { |
||||
|
elapsedText = Math.trunc(elapsedTime / 60 / 60) + "시간 전"; |
||||
|
} else if (elapsedTime < (60* 60* 24 * 10)) { |
||||
|
elapsedText = Math.trunc(elapsedTime / 60 / 60 / 24) + "일 전"; |
||||
|
} else { |
||||
|
elapsedText = value.dateFormat('yy/MM/dd') |
||||
|
} |
||||
|
|
||||
|
return elapsedText |
||||
|
}, |
||||
|
} |
||||
|
} |
@ -0,0 +1,34 @@ |
|||||
|
import vue from 'vue' |
||||
|
import axios from '@/plugins/axios' |
||||
|
|
||||
|
const exportObject = { |
||||
|
|
||||
|
/** |
||||
|
* 게시글 한개의 데이타를 가져옵니다. |
||||
|
* @param boardKey 게시판 고유 키 |
||||
|
* @param id |
||||
|
* @returns {Promise<*>} |
||||
|
*/ |
||||
|
getPost: async(boardKey, id) => { |
||||
|
return axios.get(`/board/${boardKey}/posts/${id}`) |
||||
|
}, |
||||
|
/** |
||||
|
* 게시글의 조회수를 처리한다. |
||||
|
* @param boardKey |
||||
|
* @param postId |
||||
|
*/ |
||||
|
submitHit: (boardKey, postId) => { |
||||
|
|
||||
|
// 생성된 쿠키를 체크하여, 해당 쿠키가 존재한다면 실행하지 않는다.
|
||||
|
if(vue.$cookies.get("post_hit_"+postId)) |
||||
|
return; |
||||
|
|
||||
|
axios.post(`/board/${boardKey}/posts/${postId}/hit`) |
||||
|
.then(() => { |
||||
|
vue.$cookies.set(`post_hit_${postId}`, true, 60*60*24) // 초 단위
|
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
} |
||||
|
|
||||
|
export default exportObject |
@ -0,0 +1,82 @@ |
|||||
|
import store from '@/store' |
||||
|
import axios from '@/plugins/axios' |
||||
|
|
||||
|
const exportObject = { |
||||
|
/** |
||||
|
* 사용자의 로그인 여부를 확인합니다. |
||||
|
*/ |
||||
|
isLogin: () => { |
||||
|
const accessToken = localStorage.getItem('accessToken'); |
||||
|
return !!(accessToken && accessToken !== 'undefined'); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* REST API 서버로 로그인 요청을 보냅니다. |
||||
|
*/ |
||||
|
requestLogin: async (payload) => { |
||||
|
return await axios |
||||
|
.post('/users/authorize', { |
||||
|
loginId: payload.loginId, |
||||
|
loginPass: payload.loginPass, |
||||
|
}) |
||||
|
.then(async (res) => { |
||||
|
// 정상적으로 응답을 받은경우, processLogin 함수를 실행합니다.
|
||||
|
await exportObject.processLogin(res.data) |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 로그인이 완료 된경우, 응답데이타를 이용하여 클라이언트에 토큰을 저장합니다. |
||||
|
*/ |
||||
|
processLogin: async (result) => { |
||||
|
|
||||
|
// AccessToken 과 refreshToken 발급에 성공한 경우
|
||||
|
if (result?.accessToken && result?.refreshToken) { |
||||
|
// LocalStorage에 accessToken과 refreshToken을 저장합니다.
|
||||
|
localStorage.setItem('accessToken', result?.accessToken); |
||||
|
localStorage.setItem('refreshToken', result?.refreshToken); |
||||
|
|
||||
|
// vuex 상태관리에서 현재 로그인 상태를 TRUE 로 변경합니다.
|
||||
|
store.commit('authorize/setLogin', true); |
||||
|
|
||||
|
// REST API에 내 정보를 요청합니다.
|
||||
|
await exportObject.requestMyInfo() |
||||
|
} |
||||
|
// 발급에 실패한경우, 기존에 남아있는 데이타를 삭제합니다.
|
||||
|
else { |
||||
|
// vuex 상태관리에서 현재 로그인 상태를 FALSE 로 변경합니다.
|
||||
|
store.commit('authorize/setLogin', false); |
||||
|
|
||||
|
// vuex 상태관리에서 현재 내정보를 빈값으로 로 변경합니다.
|
||||
|
store.commit('authorize/setUserInfo', null); |
||||
|
|
||||
|
// LocalStorage에 있는 데이타를 모두 삭제합니다.
|
||||
|
localStorage.removeItem('accessToken'); |
||||
|
localStorage.removeItem('refreshToken'); |
||||
|
|
||||
|
alert('사용자 로그인에 실패하였습니다.') |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 로그아웃 처리 |
||||
|
*/ |
||||
|
processLogOut: () => { |
||||
|
localStorage.removeItem('accessToken'); |
||||
|
localStorage.removeItem('refreshToken'); |
||||
|
store.commit('authorize/setLogin', false); |
||||
|
store.commit('authorize/setUserInfo', null); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* REST API로 내 정보를 가져옵니다. |
||||
|
*/ |
||||
|
requestMyInfo: async () => { |
||||
|
return await axios.get('/users').then(res => { |
||||
|
// vuex의 상태 관리에서 현재 내 정보를 처리합니다.
|
||||
|
store.commit('authorize/setUserInfo', res.data); |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default exportObject |
@ -0,0 +1,137 @@ |
|||||
|
import Vue from 'vue' |
||||
|
import $axios from 'axios' |
||||
|
import store from '@/store' |
||||
|
import userModel from '@/models/userModel' |
||||
|
|
||||
|
class AxiosExtend { |
||||
|
|
||||
|
instance = null |
||||
|
|
||||
|
// Token 만료 응답을 받을시, Token 재생성 요청을 다시 보내기 위한 flag
|
||||
|
// 토큰 재생성은 무한으로 요청하지 않고, 한번만 요청하기 위해 사용합니다.
|
||||
|
isAlreadyFetchingAccessToken = false |
||||
|
|
||||
|
//
|
||||
|
subscribers = [] |
||||
|
constructor() { |
||||
|
this.instance = $axios.create({ |
||||
|
baseURL: process.env.NODE_ENV === 'production' |
||||
|
? '릴리즈서버 REST API URI' |
||||
|
: 'http://127.0.0.1:3000', |
||||
|
timeout: 10000, |
||||
|
withCredentials: true |
||||
|
}) |
||||
|
|
||||
|
this.instance.interceptors.request.use( |
||||
|
config => { |
||||
|
|
||||
|
// 요청 헤더에 accessToken을 추가합니다.
|
||||
|
const accessToken = localStorage.getItem('accessToken') |
||||
|
|
||||
|
// 만약 accessToken이 있다면 헤더에 토큰을 추가합니다.
|
||||
|
if (accessToken) |
||||
|
config.headers.Authorization = `Bearer ${accessToken}` |
||||
|
|
||||
|
return config |
||||
|
}, |
||||
|
error => Promise.reject(error) |
||||
|
) |
||||
|
|
||||
|
// REST API와의 통신에서 에러가 발생했을때 기본 처리
|
||||
|
this.instance.interceptors.response.use( |
||||
|
response => { |
||||
|
return response; |
||||
|
}, |
||||
|
async error => { |
||||
|
const { config } = error |
||||
|
const originalRequest = config // 토큰 재발급후 원래 요청을 다시 보내기 위해 사용합니다.
|
||||
|
|
||||
|
// 응답 코드가 401일 경우에 처리합니다.
|
||||
|
if(error.response?.status === 401) |
||||
|
{ |
||||
|
// 토큰재발급 요청을 보낸적이 없을경우
|
||||
|
if(! this.isAlreadyFetchingAccessToken) { |
||||
|
this.isAlreadyFetchingAccessToken = true // 토큰 재발급요청 flag 를 TRUE로 변경해둡니다.
|
||||
|
|
||||
|
// 토큰 재발급 요청을 보냅니다.
|
||||
|
await this.instance.post('/users/authorize/token', { |
||||
|
refreshToken: localStorage.getItem('refreshToken'), |
||||
|
}).then(r => { |
||||
|
// 토큰 재발급 요청에 성공하면 flag는 다시 true로 변경해줍니다.
|
||||
|
this.isAlreadyFetchingAccessToken = false |
||||
|
|
||||
|
// LocalStorage의 값을 업데이트 해줍니다.
|
||||
|
localStorage.setItem('refreshToken', r.data.refreshToken) |
||||
|
localStorage.setItem('accessToken', r.data.accessToken) |
||||
|
|
||||
|
store.commit('authorize/setLogin', true) |
||||
|
|
||||
|
this.subscribers = this.subscribers.filter(callback => callback(r.data.accessToken)) |
||||
|
}) |
||||
|
} |
||||
|
// 토큰 재발급을 이미 요청했는데도 401 응답이라면?
|
||||
|
else { |
||||
|
// 토큰 재발급에 실패했으므로, 저장되있던 데이타를 모두 날립니다.
|
||||
|
window.localStorage.removeItem('accessToken') |
||||
|
window.localStorage.removeItem('refreshToken') |
||||
|
originalRequest.headers.Authorization = null |
||||
|
|
||||
|
store.commit('authorize/setLogin', false) |
||||
|
store.commit('authorize/setUserInfo', null) |
||||
|
} |
||||
|
|
||||
|
const retryOriginalRequest = new Promise(resolve => { |
||||
|
this.subscribers.push(accessToken => { |
||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}` |
||||
|
resolve(this.instance(originalRequest)) |
||||
|
}) |
||||
|
console.log(this.subscribers); |
||||
|
}) |
||||
|
|
||||
|
// 로그인된 상태라면 내 정보를 다시 가져옵니다.
|
||||
|
if(userModel.isLogin()) { |
||||
|
await userModel.requestMyInfo() |
||||
|
} |
||||
|
|
||||
|
return retryOriginalRequest |
||||
|
} |
||||
|
else { |
||||
|
let message |
||||
|
|
||||
|
if(error.response?.data?.error) { |
||||
|
message = error.response.data.error |
||||
|
} |
||||
|
else { |
||||
|
switch(error.response?.status) { |
||||
|
case 0 : |
||||
|
message = "REST API 서버에 접근할 수 없습니다\n서버 관리자에게 문의하세요"; |
||||
|
break; |
||||
|
case 400: |
||||
|
message = '잘못된 요청입니다.'; |
||||
|
break; |
||||
|
case 404: |
||||
|
message = '[404] REST API 요청에 실패하였습니다'; |
||||
|
break; |
||||
|
case 500: |
||||
|
message = '서버에서 처리중 오류가 발생하였습니다.' |
||||
|
break |
||||
|
default: |
||||
|
message = "잘못된 요청입니다."; |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
alert(message); |
||||
|
|
||||
|
return Promise.reject(error); |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const axios = new AxiosExtend() |
||||
|
|
||||
|
Vue.prototype.$axios = axios.instance |
||||
|
|
||||
|
export default axios.instance |
@ -0,0 +1,51 @@ |
|||||
|
/* eslint-disable */ |
||||
|
Date.prototype.dateFormat = function(f) { |
||||
|
if (!this.valueOf()) return " "; |
||||
|
if (!f) return this; |
||||
|
|
||||
|
var weekName = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"], |
||||
|
shortWeekName = ["일", "월", "화", "수", "목", "금", "토"], |
||||
|
d = this; |
||||
|
|
||||
|
return f.replace(/(yyyy|yy|MM|dd|E|hh|mm|ss|a\/p)/gi, function($1) { |
||||
|
let h = 0 |
||||
|
|
||||
|
switch ($1) { |
||||
|
case "yyyy": return d.getFullYear(); |
||||
|
case "yy": return (d.getFullYear() % 1000).zf(2); |
||||
|
case "MM": return (d.getMonth() + 1).zf(2); |
||||
|
case "dd": return d.getDate().zf(2); |
||||
|
case "E": return weekName[d.getDay()]; |
||||
|
case "e": return shortWeekName[d.getDay()]; |
||||
|
case "HH": return d.getHours().zf(2); |
||||
|
case "hh": return ((h = d.getHours() % 12) ? h : 12).zf(2); |
||||
|
case "mm": return d.getMinutes().zf(2); |
||||
|
case "ss": return d.getSeconds().zf(2); |
||||
|
case "a/p": return d.getHours() < 12 ? "오전" : "오후"; |
||||
|
default: return $1; |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
String.prototype.string = function(len){var s = '', i = 0; while (i++ < len) { s += this; } return s;}; |
||||
|
String.prototype.zf = function(len){return "0".string(len - this.length) + this;}; |
||||
|
Number.prototype.zf = function(len){return this.toString().zf(len);}; |
||||
|
String.prototype.dateFormat = function(f) { |
||||
|
var d = new Date(this); |
||||
|
return ( d == 'Invalid Date') ? '' : d.dateFormat(f); |
||||
|
} |
||||
|
|
||||
|
/********************************************************************************************************************** |
||||
|
* 숫자에 컴마를 붙여서 리턴한다 |
||||
|
* @returns {*} |
||||
|
*********************************************************************************************************************/ |
||||
|
Number.prototype.numberFormat = function(){ |
||||
|
if(this==0) return 0; |
||||
|
|
||||
|
var reg = /(^[+-]?\d+)(\d{3})/; |
||||
|
var n = (this + ''); |
||||
|
|
||||
|
while (reg.test(n)) n = n.replace(reg, '$1' + ',' + '$2'); |
||||
|
|
||||
|
return n; |
||||
|
}; |
||||
|
String.prototype.numberFormat = function() { return isNaN( parseFloat(this) ) ? "0" : (parseFloat(this)).numberFormat(); }; |
@ -0,0 +1 @@ |
|||||
|
export default [] |
@ -0,0 +1,7 @@ |
|||||
|
/** |
||||
|
* 회원 인증과 관련된 페이지 |
||||
|
*/ |
||||
|
export default [ |
||||
|
{ path: '/authorize/sign-up', name: 'SignUp', component: () => import(/* webpackChunkName: "authorize.sign-up" */ '../views/Authorize/SignUp.vue') }, |
||||
|
{ path: '/authorize/sign-in', name: 'SignIn', component: () => import(/* webpackChunkName: "authorize.sign-in" */ '../views/Authorize/SignIn.vue') }, |
||||
|
] |
@ -0,0 +1,14 @@ |
|||||
|
export default [ |
||||
|
{ |
||||
|
path: '/board/:boardKey', |
||||
|
name: 'BoardLayout', |
||||
|
component: () => import(/* webpackChunkName: "board" */ '../views/Board/BoardView.vue'), |
||||
|
children: [ |
||||
|
{path: '', name: 'PostList',component: () => import(/* webpackChunkName: "board.postList" */ '../views/Board/BoardPostList.vue') }, |
||||
|
{path: 'write', name: 'PostWrite',component: () => import(/* webpackChunkName: "board.postWrite" */ '../views/Board/BoardPostWrite.vue') }, |
||||
|
{path: ':postId', name: 'PostView',component: () => import(/* webpackChunkName: "board.postView" */ '../views/Board/BoardPostView.vue') }, |
||||
|
{path: ':postId/edit', name: 'PostEdit',component: () => import(/* webpackChunkName: "board.postEdit" */ '../views/Board/BoardPostWrite.vue') }, |
||||
|
{path: ':parentPostId/reply', name: 'PostReply',component: () => import(/* webpackChunkName: "board.postReply" */ '../views/Board/BoardPostWrite.vue') }, |
||||
|
] |
||||
|
} |
||||
|
] |
@ -0,0 +1,40 @@ |
|||||
|
import Vue from 'vue' |
||||
|
import VueRouter from 'vue-router' |
||||
|
|
||||
|
Vue.use(VueRouter) |
||||
|
|
||||
|
/** 분리되어있는 routes 파일들 **/ |
||||
|
import AuthorizeRoutes from './authorize.routes' |
||||
|
import MyPageRoutes from './my.routes' |
||||
|
import BoardRoutes from './board.routes' |
||||
|
import AgreementRoutes from './agreement.routes' |
||||
|
|
||||
|
const routes = [ |
||||
|
{ path: '/', name: 'home', component: () => import(/* webpackChunkName: "home" */ '../views/HomeView.vue') }, |
||||
|
...AuthorizeRoutes, |
||||
|
...MyPageRoutes, |
||||
|
...BoardRoutes, |
||||
|
...AgreementRoutes, |
||||
|
{ path: '*', name: 'Error404', component: () => import(/* webpackChunkName: "error404" */ '../views/Errors/Error404.vue')} |
||||
|
] |
||||
|
|
||||
|
const router = new VueRouter({ |
||||
|
mode: 'history', |
||||
|
base: process.env.BASE_URL, |
||||
|
routes |
||||
|
}) |
||||
|
|
||||
|
import userModel from '@/models/userModel' |
||||
|
router.beforeEach((to,from, next) => { |
||||
|
const isRequiredLogin = to.meta?.requiredLogin === true; |
||||
|
|
||||
|
// 로그인이 필요한 페이지인데 로그인이 되어 있지 않다면
|
||||
|
if(isRequiredLogin && ! usersModel.isLogin()) { |
||||
|
next('/authorize/sign-in') |
||||
|
} |
||||
|
else { |
||||
|
next(); |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export default router |
@ -0,0 +1,6 @@ |
|||||
|
/** |
||||
|
* 마이페이지 |
||||
|
*/ |
||||
|
export default [ |
||||
|
{ path: '/my', name: 'MyPage', component: () => import(/* webpackChunkName: "my.page" */ '../views/My/MyPage.vue'), meta: {requiredLogin: true} } |
||||
|
] |
@ -0,0 +1,34 @@ |
|||||
|
import UserModel from '@/models/userModel' |
||||
|
|
||||
|
export default { |
||||
|
namespaced: true, |
||||
|
state: () => ({ |
||||
|
isLogin: false, |
||||
|
loginUser: { |
||||
|
id: 0, |
||||
|
nickname: '', |
||||
|
auth: 0 |
||||
|
} |
||||
|
}), |
||||
|
// mutations: state를 변경하기 위해 실행되는 것으로 비동기를 해야할 경우
|
||||
|
mutations: { |
||||
|
// 사용자의 로그인 상태를 체크합니다.
|
||||
|
setLogin (state) { |
||||
|
state.isLogin = UserModel.isLogin() |
||||
|
}, |
||||
|
// 사용자의 정보를 저장합니다.
|
||||
|
setUserInfo (state, payload) { |
||||
|
state.loginUser.id = payload?.id ?? 0; |
||||
|
state.loginUser.nickname = payload?.nickname ?? ''; |
||||
|
state.loginUser.auth = payload?.auth ?? 0; |
||||
|
} |
||||
|
}, |
||||
|
getters: { |
||||
|
isLogin (state) { |
||||
|
return state.isLogin |
||||
|
}, |
||||
|
loginUser (state) { |
||||
|
return state.loginUser |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,21 @@ |
|||||
|
import Vue from 'vue' |
||||
|
import Vuex from 'vuex' |
||||
|
|
||||
|
Vue.use(Vuex) |
||||
|
|
||||
|
// 회원인증 관련 상태관리 모듈
|
||||
|
import authorizeStore from '@/store/auth' |
||||
|
|
||||
|
export default new Vuex.Store({ |
||||
|
state: { |
||||
|
}, |
||||
|
getters: { |
||||
|
}, |
||||
|
mutations: { |
||||
|
}, |
||||
|
actions: { |
||||
|
}, |
||||
|
modules: { |
||||
|
authorize: authorizeStore |
||||
|
} |
||||
|
}) |
@ -0,0 +1,5 @@ |
|||||
|
<template> |
||||
|
<div class="about"> |
||||
|
<h1>This is an about page</h1> |
||||
|
</div> |
||||
|
</template> |
@ -0,0 +1,65 @@ |
|||||
|
<template> |
||||
|
<div class="login-wrap"> |
||||
|
<form @submit.prevent="onLogin"> |
||||
|
<input v-model.trim="formData.loginId" required> |
||||
|
|
||||
|
<input :type="passwordInputType" v-model.trim="formData.loginPass" required> |
||||
|
<button type="button" @click="togglePasswordVisible">{{passwordViewButtonText}}</button> |
||||
|
|
||||
|
<button type="submit">로그인</button> |
||||
|
<router-link to="/authorize/sign-up">회원가입</router-link> |
||||
|
</form> |
||||
|
|
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import userModel from '@/models/userModel' |
||||
|
export default { |
||||
|
name: 'SignIn', |
||||
|
data () { |
||||
|
return { |
||||
|
ui: { |
||||
|
passwordVisible: false |
||||
|
}, |
||||
|
formData: { |
||||
|
loginId: '', |
||||
|
loginPass: '' |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
passwordInputType () { |
||||
|
return this.ui.passwordVisible ? 'text' : 'password' |
||||
|
}, |
||||
|
passwordViewButtonText () { |
||||
|
return this.ui.passwordVisible ? '감추기' : '보이기' |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
togglePasswordVisible () { |
||||
|
this.ui.passwordVisible=!this.ui.passwordVisible |
||||
|
}, |
||||
|
onLogin () { |
||||
|
// 폼검증 |
||||
|
if(this.formData.loginId === '') { |
||||
|
alert('아이디를 입력하세요'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if(this.formData.loginPass === '') { |
||||
|
alert('비밀번호를 입력하세요'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
userModel.requestLogin({ |
||||
|
loginId: this.formData.loginId, |
||||
|
loginPass: this.formData.loginPass |
||||
|
}).then(() => { |
||||
|
// 사용자의 로그인 처리완료시 / 페이지로 이동합니다. |
||||
|
this.$router.replace('/') |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,308 @@ |
|||||
|
<template> |
||||
|
<div class="sign-up-wrap"> |
||||
|
<h1>회원가입</h1> |
||||
|
|
||||
|
<form @submit.prevent="onSubmit"> |
||||
|
<input type="email" placeholder="이메일 주소" v-model.trim="formData.email" maxlength="64" required> |
||||
|
|
||||
|
<input type="password" placeholder="비밀번호" v-model.trim="formData.password" required> |
||||
|
|
||||
|
<input type="password" placeholder="비밀번호 확인" v-model.trim="formData.passwordConfirm" required> |
||||
|
|
||||
|
<input type="text" placeholder="닉네임" v-model.trim="formData.nickname" maxlength="15" required /> |
||||
|
|
||||
|
<input type="text" placeholder="핸드폰 번호" v-model.trim="formData.phone" maxlength="15" :disabled="phoneAuth.certificated||phoneAuth.isSend" @blur="onBlurPhoneInput" @keypress="preventNonNumericInput" required /> |
||||
|
|
||||
|
<button type="button" :disabled="phoneAuth.isSend||phoneAuth.certificated||phoneAuth.isLoading" @click="sendPhoneCode">인증번호 발송</button> |
||||
|
|
||||
|
<template v-if="phoneAuth.isSend||phoneAuth.certificated"> |
||||
|
<input type="text" placeholder="인증번호" v-model.trim="formData.phoneAuthCode" maxlength="6" required :disabled="phoneAuth.certificated"> |
||||
|
<button type="button" :disabled="phoneAuth.certificated||phoneAuth.isLoading" @click="checkPhoneAuth">인증 {{phoneAuth.certificated?'완료':'확인'}}</button> |
||||
|
<p v-if="phoneAuth.isSend">인증 유효시간:{{phoneAuth.remainTime}}초</p> |
||||
|
</template> |
||||
|
|
||||
|
<div> |
||||
|
<input id="agree_site" type="checkbox" v-model="formData.agreeSite"> |
||||
|
<label for="agree_site">[필수] 사이트 이용약관에 동의합니다.</label> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<input id="agree_privacy" type="checkbox" v-model="formData.agreePrivacy"> |
||||
|
<label for="agree_privacy">[필수] 개인정보 처리방침에 동의합니다.</label> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<input id="agree_marketing" type="checkbox" v-model="formData.agreeMarketing"> |
||||
|
<label for="agree_marketing">[선택] 마케팅정보 수신에 동의합니다.</label> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<input id="agree_all" type="checkbox" v-model="agreeAll"> |
||||
|
<label for="agree_all">필수 약관에 모두 동의합니다.</label> |
||||
|
</div> |
||||
|
|
||||
|
|
||||
|
<button type="submit">회원가입</button> |
||||
|
</form> |
||||
|
|
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.sign-up-wrap { |
||||
|
max-width:600px; |
||||
|
margin:0 auto; |
||||
|
|
||||
|
input[type="email"], |
||||
|
input[type="text"], |
||||
|
input[type="password"] { |
||||
|
display:block; |
||||
|
width:100%; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: 'SignUp', |
||||
|
data () { |
||||
|
return { |
||||
|
// 휴대폰 인증과 관련된 객체 |
||||
|
phoneAuth: { |
||||
|
certificated: false, // 휴대폰 번호 인증이 모두 완료되었는지 여부 |
||||
|
isSend: false, // 인증번호가 발송된 상태인지 여부 |
||||
|
remainTime: 0, // 인증번호 유효 시간 |
||||
|
remainTimeInterval: null, // 남은 시간 체크를 위한 setInterval 객체 |
||||
|
code: '', // REST API에서 응답받은 인증번호를 저장하는 변수 |
||||
|
isLoading: false // REST API와 통신중인 상태인지에 대한 flag |
||||
|
}, |
||||
|
// 회원가입을 위해 입력한 정보를 담는 객체 |
||||
|
formData: { |
||||
|
email:'', // 이메일 주소 |
||||
|
password:'', // 비밀번호 |
||||
|
passwordConfirm:'', // 비밀번호 확인 |
||||
|
nickname:'', // 닉네임 |
||||
|
phone: '', // 핸드폰 번호 |
||||
|
phoneAuthCode: '', // 인증번호 |
||||
|
agreeSite: false, // 사이트 이용약관 동의 여부 |
||||
|
agreePrivacy: false, // 개인정보 처리방침 동의 여부 |
||||
|
agreeMarketing: false // 마케팅 정보 수신 동의 여부 |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
/** |
||||
|
* 필수항목 전체 동의하기 |
||||
|
*/ |
||||
|
agreeAll: { |
||||
|
get () { |
||||
|
return this.formData.agreeSite && this.formData.agreePrivacy |
||||
|
}, |
||||
|
set (value) { |
||||
|
this.formData.agreeSite = value |
||||
|
this.formData.agreePrivacy = value |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
/** |
||||
|
* 입력박스에서 키를 입력할시 숫자만 입력가능하도록 한다. |
||||
|
*/ |
||||
|
preventNonNumericInput (e) { |
||||
|
if (e.key !== 'Backspace' && e.key !== 'Delete' && e.key !== '-' && (e.key < '0' || e.key > '9')) { |
||||
|
e.preventDefault(); |
||||
|
} |
||||
|
}, |
||||
|
/** |
||||
|
* 올바른 휴대폰 번호인지 검증한뒤, 올바른 휴대폰 번호라면 자동으로 하이픈을 추가한다. |
||||
|
* @param value |
||||
|
* @returns {*|string} |
||||
|
*/ |
||||
|
validatePhoneNumber(value) { |
||||
|
const phoneRegex = /^(01[016789]{1}|02|0[3-9]{1}[0-9]{1})([0-9]{3,4})([0-9]{4})$/; |
||||
|
const phoneWithHypenRegex = /^(01[016789]{1}|02|0[3-9]{1}[0-9]{1})-?([0-9]{3,4})-?([0-9]{4})$/; |
||||
|
// 입력한 핸드폰 번호에서 하이픈(-)을 제거한다. |
||||
|
var transNum = value.replace(/\s/gi, '').replace(/-/gi,''); |
||||
|
|
||||
|
// 하이픈 제거후 글자수가 11글자 또는 10글자여야 한다. |
||||
|
if(transNum.length === 11 || transNum.length === 10) { |
||||
|
// 정규표현식을 이용하여 올바른 휴대폰 번호인지 검증한다. |
||||
|
if( phoneRegex.test(transNum) ) { |
||||
|
// 휴대폰 번호 규칙에 따라 하이픈을 다시 추가한다. |
||||
|
transNum = transNum.replace(phoneWithHypenRegex, '$1-$2-$3'); |
||||
|
return transNum |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return '' |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 휴대폰번호 입력 Input 에서 Blur(포커스를 잃을떄) 발생하는 이벤트 |
||||
|
*/ |
||||
|
onBlurPhoneInput () { |
||||
|
if(this.formData.phone === '') return; |
||||
|
|
||||
|
const transNum = this.validatePhoneNumber(this.formData.phone); |
||||
|
if(transNum === '') { |
||||
|
alert('올바른 형식의 휴대폰 번호가 아닙니다.'); |
||||
|
this.formData.phone = '' |
||||
|
} |
||||
|
else { |
||||
|
this.formData.phone = transNum |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 휴대폰 인증번호 발송하기 |
||||
|
*/ |
||||
|
sendPhoneCode () { |
||||
|
// 현재 인증상태를 초기화 한다. |
||||
|
this.phoneAuth.certificated = false; |
||||
|
|
||||
|
// 이미 인증번호 발송을 실행한 상태라면 중복 실행 방지 |
||||
|
if(this.phoneAuth.isLoading) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 휴대폰번호 검증처리 |
||||
|
if(this.formData.phone.length === 0) { |
||||
|
alert('올바른 형식의 휴대폰 번호가 아닙니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 휴대폰 번호 발송중으로 flag 처리 |
||||
|
this.phoneAuth.isLoading = true; |
||||
|
this.$axios.post('/users/authorize/phone', { |
||||
|
phone: this.formData.phone |
||||
|
}).then(res => { |
||||
|
this.formData.phoneAuthCode = '' |
||||
|
this.phoneAuth.code = res.data.result.authCode; // REST API로 받은 인증코드를 담아둔다. |
||||
|
this.phoneAuth.isSend = true // 인증코드 발송여부를 true로 변경 |
||||
|
this.phoneAuth.remainTime = 180 // 남은 입력시간 180초로 설정 |
||||
|
|
||||
|
// 1초마다 실행되도록 setInterval설정. |
||||
|
// 남은시간을 처리하고, 입력시간을 초과하면 인증번호 발송상태를 초기화 한다. |
||||
|
this.phoneAuth.remainTimeInterval = setInterval(()=> { |
||||
|
if(this.phoneAuth.remainTime <= 1) { |
||||
|
this.resetPhoneAuth() |
||||
|
} |
||||
|
this.phoneAuth.remainTime--; |
||||
|
}, 1000) |
||||
|
}).finally(()=> { |
||||
|
// 휴대폰번호 발송중 flag 처리 |
||||
|
this.phoneAuth.isLoading = false; |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 인증번호 발송상태 초기화 |
||||
|
*/ |
||||
|
resetPhoneAuth () { |
||||
|
// 입력시간 setInterval을 해제한다. |
||||
|
if(this.phoneAuth.remainTimeInterval !== null) { |
||||
|
clearInterval(this.phoneAuth.remainTimeInterval); |
||||
|
this.phoneAuth.remainTimeInterval = null; |
||||
|
} |
||||
|
|
||||
|
// 입력시간과, 핸드폰인증상태, 인증번호 발송상태 flag를 초기화한다. |
||||
|
this.phoneAuth.remainTime = 0 |
||||
|
this.phoneAuth.isSend = false; |
||||
|
this.phoneAuth.certificated = false; |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 입력한 인증번호가 백엔드에서 받은 인증번호와 동일한지 체크한다. |
||||
|
*/ |
||||
|
checkPhoneAuth () { |
||||
|
if(this.formData.phoneAuthCode.toString() === this.phoneAuth.code.toString()) { |
||||
|
this.resetPhoneAuth(); |
||||
|
this.phoneAuth.certificated = true |
||||
|
} |
||||
|
else { |
||||
|
alert('인증번호가 맞지 않습니다.'); |
||||
|
} |
||||
|
}, |
||||
|
/** |
||||
|
* 회원가입 폼검증 및 폼전송 |
||||
|
*/ |
||||
|
onSubmit () { |
||||
|
// 아이디 검증 |
||||
|
if(this.formData.email === '') |
||||
|
{ |
||||
|
alert('이메일 주소는 필수입력입니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; |
||||
|
if(! emailRegex.test(this.formData.email)) |
||||
|
{ |
||||
|
alert('올바른 형식의 이메일 주소가 아닙니다'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 닉네임 검증 |
||||
|
if(this.formData.nickname === '') |
||||
|
{ |
||||
|
alert('닉네임은 필수입력입니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if(this.formData.nickname.length < 2 || this.formData.nickname.length > 15) |
||||
|
{ |
||||
|
alert("닉네임은 2자리 이상, 15자리까지 입력가능 합니다."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 비밀번호 검증 |
||||
|
if(this.formData.password === '') { |
||||
|
alert('비밀번호는 필수입력입니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; |
||||
|
if(! passwordRegex.test(this.formData.password)) { |
||||
|
alert('비밀번호는 8자 이상, 하나이상의 문자,숫자 및 특수문자를 사용하셔야 합니다'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 비밀번호 확인 검증 |
||||
|
if(this.formData.password !== this.formData.passwordConfirm) { |
||||
|
alert('비밀번호와 비밀번호 확인이 서로 다릅니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 휴대폰 인증 확인 |
||||
|
if(! this.phoneAuth.certificated || this.formData.phone === '') { |
||||
|
alert('휴대폰 번호 인증을 완료하셔야 합니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 필수동의항목 확인 |
||||
|
if(! this.formData.agreeSite) { |
||||
|
alert("사이트 이용약관에 동의하셔야 합니다."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if(! this.formData.agreePrivacy) { |
||||
|
alert("개인정보 처리방침에 동의하셔야 합니다."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.$axios.post('/users', { |
||||
|
email: this.formData.email, |
||||
|
password: this.formData.password, |
||||
|
passwordConfirm: this.formData.passwordConfirm, |
||||
|
nickname: this.formData.nickname, |
||||
|
phone: this.formData.phone, |
||||
|
agreeSite: this.formData.agreeSite, |
||||
|
agreePrivacy: this.formData.agreePrivacy, |
||||
|
agreeMarketing: this.formData.agreeMarketing |
||||
|
}).then(() => { |
||||
|
alert("회원 등록이 완료되었습니다."); |
||||
|
this.$router.replace('/authorize/sign-in') |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,97 @@ |
|||||
|
<template> |
||||
|
<component |
||||
|
:is="boardInfo.type" |
||||
|
@onPageChange="onPageChange" |
||||
|
@onGetList="getPostList" |
||||
|
@onFilterUpdated="onFilterUpdated" |
||||
|
@onSearch="onSearch" |
||||
|
/> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import SimpleListSkin from './Skins/PostSimpleList.vue' |
||||
|
import GallerySkin from './Skins/PostGallery.vue' |
||||
|
import WebZineSkin from './Skins/PostWebzine.vue' |
||||
|
|
||||
|
export default { |
||||
|
components: { |
||||
|
'LIST' : SimpleListSkin, |
||||
|
'GALLERY' : GallerySkin, |
||||
|
'WEBZINE' : WebZineSkin |
||||
|
}, |
||||
|
computed: { |
||||
|
boardKey () { |
||||
|
return this.$parent.boardKey |
||||
|
}, |
||||
|
boardInfo () { |
||||
|
// 부모컴포넌트의 boardInfo 데이타를 가져옵니다. |
||||
|
return this.$parent.boardInfo |
||||
|
}, |
||||
|
totalPages () { |
||||
|
return this.boardInfo.page_rows === 0 |
||||
|
? 1 |
||||
|
: Math.ceil( this.listData.totalCount / this.boardInfo.page_rows ) |
||||
|
} |
||||
|
}, |
||||
|
data () { |
||||
|
return { |
||||
|
// 게시글 목록을 저장하는 객체 |
||||
|
listData: { |
||||
|
result: [], // 게시글 목록 |
||||
|
page: 1, // 현재 페이지 |
||||
|
totalCount: 0 // 총 게시글 수 |
||||
|
}, |
||||
|
// 게시판 검색에 사용되는 객체 |
||||
|
filters: { |
||||
|
type: 'title', // 검색구분 |
||||
|
q: '' // 검색어 |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
onPageChange(page) { |
||||
|
this.listData.page = page |
||||
|
}, |
||||
|
// 게시글 목록 불러오기 |
||||
|
getPostList () { |
||||
|
// 게시글 불러오기시 GET 패러미터를 정리해준다. |
||||
|
const formData = {}; |
||||
|
formData.searchColumn = this.filters.type |
||||
|
formData.searchQuery = this.filters.q |
||||
|
formData.page = this.listData.page |
||||
|
formData.page_rows = this.boardInfo.page_rows |
||||
|
|
||||
|
console.log(this.boardInfo); |
||||
|
|
||||
|
// 게시글 목록 불러오기 REST API 요청 |
||||
|
this.$axios.get(`/board/${this.boardKey}/posts`, { |
||||
|
params: formData |
||||
|
}) |
||||
|
.then((res) => { |
||||
|
this.listData.result = res.data.result |
||||
|
this.listData.totalCount = res.data.totalCount |
||||
|
}) |
||||
|
}, |
||||
|
onFilterUpdated ( filters ) { |
||||
|
this.filters.type = filters.type |
||||
|
this.filters.q = filters.q |
||||
|
}, |
||||
|
onSearch () { |
||||
|
this.listData.page = 1 |
||||
|
this.getPostList() |
||||
|
} |
||||
|
}, |
||||
|
mounted () { |
||||
|
this.$nextTick(() => { |
||||
|
this.getPostList(); |
||||
|
}) |
||||
|
}, |
||||
|
watch: { |
||||
|
// 게시판 고유키가 변경될경우 게시글 목록도 다시 불러옵니다. |
||||
|
boardKey () { |
||||
|
this.getPostList(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
</script> |
@ -0,0 +1,178 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<h2>{{boardInfo.title}}</h2> |
||||
|
<article> |
||||
|
<header> |
||||
|
<h4>{{postInfo.title}}</h4> |
||||
|
<dl> |
||||
|
<dt>작성자</dt> |
||||
|
<dd>{{postInfo.created_user_name}}</dd> |
||||
|
</dl> |
||||
|
<dl> |
||||
|
<dt>작성일시</dt> |
||||
|
<dd>{{postInfo.created_at | snsDateTime}}</dd> |
||||
|
</dl> |
||||
|
<dl> |
||||
|
<dt>조회수</dt> |
||||
|
<dd>{{postInfo.hit | numberFormat}}</dd> |
||||
|
</dl> |
||||
|
</header> |
||||
|
|
||||
|
<div v-if="postInfo.status === 'Y'"> |
||||
|
<div v-html="postInfo.content"></div> |
||||
|
</div> |
||||
|
<div v-else> |
||||
|
<p>해당 글은 블라인드 처리되어 내용을 볼 수 없습니다.</p> |
||||
|
</div> |
||||
|
</article> |
||||
|
|
||||
|
<template v-if="isAuthor"> |
||||
|
<router-link :to="`/board/${boardKey}/${postId}/edit`">수정</router-link> |
||||
|
<button type="button" @click="deletePost">수정</button> |
||||
|
</template> |
||||
|
<router-link :to="`/board/${boardKey}/${postId}/reply`">답글달기</router-link> |
||||
|
|
||||
|
<div class="popup" v-if="ui.deleteDialogView"> |
||||
|
<div class="popup-window"> |
||||
|
<form @submit.prevent="deletePost"> |
||||
|
<template v-if="postInfo.created_user===0 && loginUser.auth < 10"> |
||||
|
<my-text-input type="password" label="비밀번호" v-model="ui.deletePassword" /> |
||||
|
</template> |
||||
|
|
||||
|
<button type="submit">삭제하기</button> |
||||
|
<button type="button" @click="ui.deleteDialogView=false">취소</button> |
||||
|
</form> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
.popup { |
||||
|
position:fixed; |
||||
|
top:0; |
||||
|
left:0; |
||||
|
width:100%; |
||||
|
height:100%; |
||||
|
z-index: 10000; |
||||
|
|
||||
|
&:before { |
||||
|
content:''; |
||||
|
position:absolute; |
||||
|
top:0; |
||||
|
left:0; |
||||
|
width:100%; |
||||
|
height:100%; |
||||
|
z-index: 10001; |
||||
|
background:rgba(0,0,0, 0.4); |
||||
|
} |
||||
|
.popup-window { |
||||
|
position:absolute; |
||||
|
top:50%; |
||||
|
left:50%; |
||||
|
width:400px; |
||||
|
height:400px; |
||||
|
z-index:10002; |
||||
|
transform:translate(-50%, -50%); |
||||
|
background-color:#fff; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
import boardModel from "@/models/boardModel"; |
||||
|
import MyTextInput from "@/components/MyTextInput.vue"; |
||||
|
import {del} from "vue"; |
||||
|
|
||||
|
export default { |
||||
|
components: {MyTextInput}, |
||||
|
data () { |
||||
|
return { |
||||
|
postInfo: {}, |
||||
|
ui: { |
||||
|
deleteDialogView: false, |
||||
|
deletePassword: '' |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
boardKey() { |
||||
|
return this.$route.params?.boardKey ?? '' |
||||
|
}, |
||||
|
postId () { |
||||
|
return this.$route.params?.postId ?? 0; |
||||
|
}, |
||||
|
boardInfo () { |
||||
|
return this.$parent.boardInfo |
||||
|
}, |
||||
|
// 수정/삭제권한이 있는지 여부를 리턴 |
||||
|
isAuthor () { |
||||
|
// 관리자 권한인 경우 무조건 true |
||||
|
if(this.loginUser.auth >= 10) |
||||
|
return true; |
||||
|
|
||||
|
// 현재 로그인이 안되있으면서, 작성자도 비회원일경우 true |
||||
|
if(this.boardInfo.created_user === 0 && this.loginUser.id === 0 ) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// 작성자 PK와 현재 로그인한 사용자 PK가 같을때 true |
||||
|
if(this.boardInfo.created_user === this.loginUser.id) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
}, |
||||
|
mounted () { |
||||
|
this.$nextTick(() => { |
||||
|
this.getPostInfo() |
||||
|
}) |
||||
|
}, |
||||
|
watch: { |
||||
|
postId () { |
||||
|
this.getPostInfo() |
||||
|
}, |
||||
|
boardKey () { |
||||
|
this.getPostInfo() |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
del, |
||||
|
deletePost () { |
||||
|
// 만약 삭제확인 창이 열려있지 않은 상태라면 삭제 확인 창을 연다 |
||||
|
if(!this.ui.deleteDialogView) { |
||||
|
this.ui.deleteDialogView = true; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 삭제확인 창이 열려있는 상태에서 이창이 호출되었다는 것은 삭제확인을 눌렀다는것으로 보로 실제 삭제 명령을 REST API로 전달한다. |
||||
|
// 단, 비회원 작성글인 경우, 비밀번호 입력이 되었는지 체크해야한다. |
||||
|
if( this.boardInfo.created_user === 0 && this.loginUser.auth < 10 ) { |
||||
|
if(this.ui.deletePassword === '') { |
||||
|
alert('글 작성시 입력한 비밀번호를 입력하세요'); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// REST API에 전송 |
||||
|
this.$axios.delete(`/board/${this.boardKey}/posts/${this.postId}`, { |
||||
|
password: this.ui.deletePassword |
||||
|
}) |
||||
|
.then(() => { |
||||
|
alert('글이 삭제되었습니다.'); |
||||
|
this.$router.replace(`/board/${this.boardKey}`); |
||||
|
}) |
||||
|
}, |
||||
|
getPostInfo () { |
||||
|
boardModel.getPost(this.boardKey, this.postId) |
||||
|
.then(res => { |
||||
|
this.postInfo = res.data.result |
||||
|
|
||||
|
// 조회수 처리하기 |
||||
|
boardModel.submitHit(this.boardKey, this.postId) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,268 @@ |
|||||
|
<template> |
||||
|
<form @submit.prevent="onSubmit"> |
||||
|
<my-text-input label="제목" v-model="formData.title" required /> |
||||
|
|
||||
|
<!-- S: 비회원일 경우 닉네임과 비밀번호를 수동으로 입력합니다. --> |
||||
|
<template v-if="loginUser.id <= 0"> |
||||
|
<my-text-input label="닉네임" v-model.trim="formData.author_name" required /> |
||||
|
|
||||
|
<my-text-input type="password" label="비밀번호" v-model.trim="formData.author_pass" required /> |
||||
|
</template> |
||||
|
<!-- E: 비회원일 경우 닉네임과 비밀번호를 수동으로 입력합니다. --> |
||||
|
|
||||
|
<!-- S: 로그인한 사용자의 auth 값이 10 이상일 경우 공지사항 체크박스 보이기 --> |
||||
|
<my-input label="공지사항"> |
||||
|
<input type="checkbox" v-model="formData.is_notice" id="is_notice"> |
||||
|
<label for="is_notice">공지사항</label> |
||||
|
</my-input> |
||||
|
<!-- E : 로그인한 사용자의 auth 값이 10 이상일 경우 공지사항 체크박스 보이기 --> |
||||
|
|
||||
|
<my-input label="글내용"> |
||||
|
<editor |
||||
|
height="auto" |
||||
|
ref="editor" |
||||
|
v-model="formData.content" |
||||
|
initialEditType="wysiwyg" |
||||
|
:options="editorOption" |
||||
|
@change="onEditorChange" |
||||
|
/> |
||||
|
</my-input> |
||||
|
|
||||
|
<my-input label="첨부파일"> |
||||
|
<input type="file" id="fileInput" ref="fileInputRef" style="display:none" multiple @change="onFileInputChange" /> |
||||
|
|
||||
|
<ul> |
||||
|
<li v-for="(attach,index) in formData.attach_list" :key="`attach-${index}`"> |
||||
|
<a :href="attach.file_url" target="_blank">{{attach.original_name}}</a> |
||||
|
<button type="button" @click="removeAttach(index)">파일삭제</button> |
||||
|
</li> |
||||
|
</ul> |
||||
|
<span v-if="ui.isUploading">파일을 업로드 중입니다.</span> |
||||
|
<label v-else for="fileInput">+ 파일 추가</label> |
||||
|
</my-input> |
||||
|
|
||||
|
<button type="submit">글 작성하기</button> |
||||
|
</form> |
||||
|
|
||||
|
</template> |
||||
|
<script> |
||||
|
import MyTextInput from "@/components/MyTextInput.vue"; |
||||
|
import MyInput from "@/components/MyInput.vue"; |
||||
|
import boardModel from '@/models/boardModel' |
||||
|
|
||||
|
// TOAST UI 로드 |
||||
|
import '@toast-ui/editor/dist/toastui-editor.css'; |
||||
|
import { Editor } from '@toast-ui/vue-editor'; |
||||
|
import '@toast-ui/editor/dist/i18n/ko-kr'; |
||||
|
|
||||
|
export default { |
||||
|
components: {MyTextInput, MyInput, Editor}, |
||||
|
data () { |
||||
|
return { |
||||
|
|
||||
|
ui: { |
||||
|
isEditorChanging: false, |
||||
|
isUploading: false |
||||
|
}, |
||||
|
formData: { |
||||
|
title: '', |
||||
|
is_notice: false, |
||||
|
author_name: '', |
||||
|
author_pass: '', |
||||
|
content:'', |
||||
|
attach_list: [] |
||||
|
}, |
||||
|
editorOption: { |
||||
|
language: 'ko-KR', |
||||
|
hideModeSwitch: true, |
||||
|
initialEditType:'wysiwyg', |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
boardKey() { |
||||
|
return this.$route.params?.boardKey ?? '' |
||||
|
}, |
||||
|
// 글 수정일 경우 postId 값 |
||||
|
postId () { |
||||
|
return this.$route.params?.postId ?? 0; |
||||
|
}, |
||||
|
// 답글일 경우 부모글의 postId값 |
||||
|
postParentId () { |
||||
|
return this.$route.params?.parentPostId ?? 0; |
||||
|
}, |
||||
|
// postId 값의 존재여부에 따라 수정인지, 신규작성인지 구분 |
||||
|
isEdit () { |
||||
|
return this.postId > 0 |
||||
|
}, |
||||
|
// 신규 작성이면서 postParentId 값이 있다면 답글작성으로 구분 |
||||
|
isReply () { |
||||
|
return !this.isEdit && this.postParentId > 0 |
||||
|
} |
||||
|
}, |
||||
|
mounted () { |
||||
|
// Computed가 모두 계산된 이후에 작동시키기 위해 $nextTick 메서드 사용 |
||||
|
this.$nextTick(() => { |
||||
|
// 글이 수정모드 일경우 원본 데이타를 가져옴 |
||||
|
if(this.isEdit) { |
||||
|
// 원본글의 정보를 가져옴 |
||||
|
boardModel.getPostInfo(this.postId) |
||||
|
.then((res) => { |
||||
|
for(let key in res.data.result) { |
||||
|
// 기존 formData 객체에 등록되있는 값을 불러온 값으로 대체합니다. |
||||
|
if(this.formData[key] !== 'undefined') { |
||||
|
this.formData[key] = res.data.result[key] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 수정일 경우 비회원 비밀번호는 다시 입력해야 하므로 빈값을 넣어준다. |
||||
|
this.formData.author_pass = '' |
||||
|
}) |
||||
|
// 원본글이 없을경우, 예외처리 |
||||
|
.catch(() => { |
||||
|
alert('수정하려는 글의 정보를 불러올 수 없습니다. 존재하지 않거나, 이미 삭제되었습니다.'); |
||||
|
this.$router.back(); |
||||
|
}) |
||||
|
} |
||||
|
// 답글 작성일 경우 부모글의 데이타를 가져옴 |
||||
|
else if(this.isReply) { |
||||
|
// 부모글의 정보를 가져옴 |
||||
|
boardModel.getPostInfo(this.postParentId) |
||||
|
.then((res) => { |
||||
|
this.formData.title = "[RE] " + res.data.result.title |
||||
|
}) |
||||
|
// 부모글이 없을 경우 예외처리 |
||||
|
.catch(() => { |
||||
|
alert('답글을 달려는 글의 정보를 불러올 수 없습니다. 존재하지 않거나, 이미 삭제되었습니다.'); |
||||
|
//this.$router.back(); |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
}, |
||||
|
methods: { |
||||
|
// 에디터 컴포넌트의 값이 변하면, vue 인스턴스의 formData.content 값도 같이 변경해 줍니다. |
||||
|
onEditorChange() { |
||||
|
if(this.ui.isEditorChanging) return; |
||||
|
|
||||
|
this.ui.isEditorChanging = true; |
||||
|
this.formData.content = this.$refs.editor.invoke('getHTML'); |
||||
|
this.ui.isEditorChanging = false; |
||||
|
}, |
||||
|
// 첨부된 파일을 삭제합니다. |
||||
|
removeAttach (index) { |
||||
|
if(this.formData.attach_list.length > index + 1) { |
||||
|
alert('해당 파일이 존재하지 않거나 이미 삭제되었습니다.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 서버에서 파일 삭제 처리 |
||||
|
this.$axios.delete('/attaches', { |
||||
|
params: { |
||||
|
file: this.formData.attach_list[index].file_path |
||||
|
} |
||||
|
}).finally(() => { |
||||
|
// 첨부파일 목록 배열에서 삭제 |
||||
|
// 서버에서 삭제에 실패한다 하더라도, 현재 게시글의 첨부파일 목록에서는 삭제해야 하므로 |
||||
|
// finally 콜백 안에 작성 |
||||
|
this.formData.attach_list.splice(index, 1); |
||||
|
}) |
||||
|
}, |
||||
|
// 첨부파일 업로드 처리 |
||||
|
async onFileInputChange () { |
||||
|
const files = this.$refs.fileInputRef.files |
||||
|
|
||||
|
// 선택된 파일이 있는경우에만 실행한다. |
||||
|
if(files && files.length > 0) |
||||
|
{ |
||||
|
// 파일은 binary 형태로 업로드 되기때문에 새 FormData 객체 생성 |
||||
|
const formData = new FormData(); |
||||
|
|
||||
|
// 선택한 파일들을 FormData 객체에 배열형태로 추가 |
||||
|
for(let i=0; i<files.length; i++) { |
||||
|
formData.append('userfile', files[i]); |
||||
|
} |
||||
|
|
||||
|
// 업로드 중에 또 파일 추가하는것을 막기위해 플래그를 사용 |
||||
|
this.ui.isUploading = true; |
||||
|
|
||||
|
// POST로 파일 전송 |
||||
|
await this.$axios.post(`/attaches`, formData, { |
||||
|
headers: { |
||||
|
"Content-Type": "multipart/form-data" // 바이너리 전송을 위해 multipart/form-data 로 선언 |
||||
|
} |
||||
|
}).then((res) => { |
||||
|
// 업로드가 완료되면, 첨부파일 목록에 해당 파일을 추가한다. |
||||
|
for(let i=0; i<res.data.length; i++ ) { |
||||
|
this.formData.attach_list.push(res.data[i]) |
||||
|
} |
||||
|
|
||||
|
// 기존 파일인풋은 비워준다. |
||||
|
this.$refs.fileInputRef.value = '' |
||||
|
}).finally(() => { |
||||
|
// 모두 완료된 뒤에 업로드중 flag를 false 처리 |
||||
|
this.ui.isUploading = false; |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
// 글작성 처리 |
||||
|
onSubmit () { |
||||
|
if(this.formData.title.length === 0) { |
||||
|
alert('글 제목은 필수로 입력하셔야 합니다.') |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if(this.formData.content.length === 0) { |
||||
|
alert('글 내용은 필수로 입력하셔야 합니다.') |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 비회원일 경우 이름과 비밀번호 입력확인 |
||||
|
if(this.loginUser.id <= 0) |
||||
|
{ |
||||
|
if(this.formData.author_name === '') { |
||||
|
alert('닉네임은 필수로 입력하셔야 합니다.') |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if(this.formData.author_pass === '') { |
||||
|
alert('비밀번호는 필수로 입력하셔야 합니다.') |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; |
||||
|
if(! passwordRegex.test(this.formData.author_pass)) { |
||||
|
alert('비밀번호는 8자 이상, 하나이상의 문자,숫자 및 특수문자를 사용하셔야 합니다'); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 게시글 신규 작성이냐, 수정이냐에 따라 URL이 달라진다. (답글의 경우 로직상으론 신규 등록이다.) |
||||
|
const requestUri = `/board/${this.boardKey}/posts` + ( this.isEdit ? `/${this.postId}` : '' ); |
||||
|
|
||||
|
// 넘겨줄 데이타를 정리한다. |
||||
|
const formData = this.formData |
||||
|
|
||||
|
// 답글의 경우 추가 패러미터를 집어넣는다. |
||||
|
if(this.isReply) { |
||||
|
formData.parent_id = this.postParentId * 1 |
||||
|
} |
||||
|
|
||||
|
this.$axios({ |
||||
|
method: this.isEdit ? 'PUT' : 'POST', |
||||
|
url: requestUri, |
||||
|
data: formData |
||||
|
}).then((res) => { |
||||
|
const postId = res.data.result?.id ?? 0 |
||||
|
|
||||
|
// 작성후 글 고유 번호가 제대로 응답한 경우 작성한 글로 이동 |
||||
|
if(postId > 0) { |
||||
|
this.$router.replace(`/board/${this.boardKey}/${postId}`); |
||||
|
} else { |
||||
|
this.$router.replace(`/board/${this.boardKey}`); |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,67 @@ |
|||||
|
<template> |
||||
|
<article> |
||||
|
<header> |
||||
|
{{boardInfo.title}} 게시판 상단 부분 입니다. |
||||
|
</header> |
||||
|
|
||||
|
<!-- S: 이부분이 각각 목록/내용보기/작성하기 컴포넌트가 렌더링 됩니다 --> |
||||
|
<router-view v-if="isLoaded" /> |
||||
|
<!-- E: 이부분이 각각 목록/내용보기/작성하기 컴포넌트가 렌더링 됩니다 --> |
||||
|
|
||||
|
<footer> |
||||
|
{{boardInfo.title}} 게시판 하단 부분 입니다. |
||||
|
</footer> |
||||
|
</article> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
computed: { |
||||
|
boardKey () { |
||||
|
return this.$route.params.boardKey |
||||
|
} |
||||
|
}, |
||||
|
data () { |
||||
|
return { |
||||
|
isLoaded: false, |
||||
|
boardInfo: { |
||||
|
title: '', |
||||
|
type: 'LIST', |
||||
|
auth_list: 0, |
||||
|
auth_view: 0, |
||||
|
auth_write: 0, |
||||
|
auth_comment: 0, |
||||
|
page_rows: 0, |
||||
|
category_info: [] |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
async getBoardInfo () { |
||||
|
// REST API를 이용하여 게시판 정보를 가져옵니다. |
||||
|
await this.$axios |
||||
|
.get(`/board/${this.boardKey}`) |
||||
|
.then((result) => { |
||||
|
// 응답받은 result 를 이용하여 데이타 값을 가져옵니다. |
||||
|
for(let key in result.data.result) { |
||||
|
// 해당 키값을 가진 속성이 선언되어 있다면 |
||||
|
if(typeof this.boardInfo[key] !== 'undefined') { |
||||
|
this.boardInfo[key] = result.data.result[key]; |
||||
|
} |
||||
|
} |
||||
|
this.isLoaded = true; |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
mounted () { |
||||
|
// 마운트되면 게시판의 정보를 불러옵니다. |
||||
|
this.getBoardInfo(); |
||||
|
}, |
||||
|
watch: { |
||||
|
boardKey () { |
||||
|
// boardKey 값이 변경된다면 게시판 정보를 새로 불러옵니다. |
||||
|
this.getBoardInfo() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,3 @@ |
|||||
|
<template> |
||||
|
<div>갤러리 리스트 스킨입니다.</div> |
||||
|
</template> |
@ -0,0 +1,70 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<table> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>#</th> |
||||
|
<th>제목</th> |
||||
|
<th>작성자</th> |
||||
|
<th>작성일시</th> |
||||
|
<th>조회수</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<template v-if="listData.result.length === 0"> |
||||
|
<tr> |
||||
|
<td colspan="5">등록된 글이 없습니다.</td> |
||||
|
</tr> |
||||
|
</template> |
||||
|
<template v-else> |
||||
|
<tr v-for="row in listData.result" :key="row.id"> |
||||
|
<td> |
||||
|
<span v-if="row.is_notice==='Y'">[공지]</span> |
||||
|
<span>{{row.num | numberFormat}}</span> |
||||
|
</td> |
||||
|
<td><router-link :to="`/board/${row.board_key}/${row.id}`">{{row.title}}</router-link></td> |
||||
|
<td>{{row.created_user_name}}</td> |
||||
|
<td>{{row.created_at | snsDateTime}}</td> |
||||
|
<td>{{row.hit | numberFormat}}</td> |
||||
|
</tr> |
||||
|
</template> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
|
||||
|
<paginate |
||||
|
v-model="listData.page" |
||||
|
:page-count="totalPages" |
||||
|
:click-handler="getList" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import Paginate from 'vuejs-paginate'; |
||||
|
|
||||
|
export default { |
||||
|
name:'SimpleListSkin', |
||||
|
components: {Paginate }, |
||||
|
computed: { |
||||
|
totalPages () { |
||||
|
return this.$parent.totalPages; |
||||
|
}, |
||||
|
boardKey () { |
||||
|
return this.$parent.boardKey |
||||
|
}, |
||||
|
boardInfo () { |
||||
|
return this.$parent.boardInfo |
||||
|
}, |
||||
|
listData () { |
||||
|
return this.$parent.listData |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
getList () { |
||||
|
this.$emit('onGetList') |
||||
|
} |
||||
|
}, |
||||
|
filters: { |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,3 @@ |
|||||
|
<template> |
||||
|
<div>웹진 리스트 스킨입니다.</div> |
||||
|
</template> |
@ -0,0 +1,9 @@ |
|||||
|
<template> |
||||
|
<div>404 Not Found</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: "404ErrorPage" |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,30 @@ |
|||||
|
<<template> |
||||
|
<ul> |
||||
|
<select v-model="category"> |
||||
|
<option value="1">1</option> |
||||
|
<option value="2">2</option> |
||||
|
<option value="3">3</option> |
||||
|
</select> |
||||
|
<li v-for="item in computedList">{{item.value}}</li> |
||||
|
</ul> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
data () { |
||||
|
return { |
||||
|
category: '1', |
||||
|
list: [ |
||||
|
{category: '1', value:'햄버거'}, |
||||
|
{category: '1', value:'콜라'}, |
||||
|
{category: '2', value:'사이다'} |
||||
|
] |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
computedList() { |
||||
|
return this.list.filter(item => item.category === this.category) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script>> |
@ -0,0 +1,18 @@ |
|||||
|
<template> |
||||
|
<div class="home"> |
||||
|
<img alt="Vue logo" src="../assets/logo.png"> |
||||
|
<HelloWorld msg="Welcome to Your Vue.js App"/> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
// @ is an alias to /src |
||||
|
import HelloWorld from '@/components/HelloWorld.vue' |
||||
|
|
||||
|
export default { |
||||
|
name: 'HomeView', |
||||
|
components: { |
||||
|
HelloWorld |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,4 @@ |
|||||
|
const { defineConfig } = require('@vue/cli-service') |
||||
|
module.exports = defineConfig({ |
||||
|
transpileDependencies: true |
||||
|
}) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue