새로운 강의는 이제 https://memi.dev 에서 진행합니다.
memi가 Vue & Firebase로 직접 만든 새로운 사이트를 소개합니다.

바로가기


NEMBV 14 Front-end finished

8 분 소요

이 강좌는 종료되었습니다.
새로운 강좌로 시작하세요~
모던웹(NEMV) 제작 강좌

이제 본격적으로 쓸만한 라이브러리들을 추가하여 화면을 구성해보도록 하겠다.

전처럼 하나 하고 리뷰하고 하나 하고 리뷰하기엔 좀 방대헤서 일단 전부 만들고 리뷰한다.

라이브러리 설치

목록

  • moment: 시간관련
  • sweetalert: alert 모양변경
  • vue-awesome: font-awesome
  • vue2-google-maps: 구글맵 사용
  • fontawesome-markers: 구글맵 마커용

설치

moment

$ npm i moment --save

sweetalert

$ npm i sweetalert --save

vue-awesome

$ npm i vue-awesome --save

vue2-google-maps

$ npm i vue2-google-maps --save

fontawesome-markers

$ npm i fontawesome-markers --save

eslint 예외처리

몇가지 나랑 안맞는 부분들을 룰에서 무시하도록 설정하였다.

eslint rule add

    'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, // 밑에 추가
    'no-underscore-dangle': ['error', { 'allow': ['_id'] }],
    'consistent-return': 0, // ['error', { 'treatUndefinedAsUnspecified': false }],
    'no-param-reassign': 0,
    // 'no-undef': 0,
    'no-undef': 0,
    'no-unused-vars': 0,
    'object-shorthand': 0,

front-end cfg file 제작

fe/static/cfg.js

module.exports = {
  path: {
    api : '/api/', // npm run build 용
    // api: 'http://localhost:3000/api/', // npm run dev 용
  },
};

빌드 별로 경로를 바꿔주면 사실 이건 안해도 되는데 아직 webpack 사용이 미숙해서 일단 이런 파일이 필요하다.. - 빌드용 process 변수로 구분할 수 있다.

설치된 라이브러리 추가

fe/src/main.js

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue';
import axios from 'axios';
import moment from 'moment';
import swal from 'sweetalert';
import Icon from 'vue-awesome/components/Icon';
import * as VueGoogleMaps from 'vue2-google-maps';
import App from './App';
import router from './router';

import '../node_modules/bootstrap/dist/css/bootstrap.min.css';
import '../node_modules/bootstrap-vue/dist/bootstrap-vue.css';
import '../node_modules/vue-awesome/icons';
import fam from '../node_modules/fontawesome-markers/fontawesome-markers.json';
import cfg from '../static/cfg';

moment.locale('ko');

if (process.env.NODE_ENV === 'development') cfg.path.api = 'http://localhost:3000/api/';

Vue.prototype.$axios = axios;
Vue.prototype.$cfg = cfg;
Vue.prototype.$moment = moment;
Vue.prototype.$swal = swal;
Vue.prototype.$fam = fam;

Vue.component('icon', Icon);

Vue.use(BootstrapVue);
Vue.use(VueGoogleMaps, {
  load: {
    key: 'AIzaSyBzlLYISGjL_ovJwAehh6ydhB56fCCpPQw',
    // OR: libraries: 'places,drawing'
    // OR: libraries: 'places,drawing,visualization'
    // (as you require)
  },
  // installComponents: true,
});

Vue.config.productionTip = true;

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  template: '<App/>',
  components: { App },
});
  • process.env.NODE_ENV 가 ‘production’ 면 npm run build ‘development’ 면 npm run dev
  • moment 표현 한글로 변경
  • 전역으로 쓰기 위한 prototype 구성
  • 하위 템플릿에서 icon 태그 쓰기 위해 등록
  • 회사 위치를 표현하기 위한 googlemap init 해당 라이브러리 예제에 있는 키인데 테스트 되길래 쓰고 있다. 가지고 있는 키가 있다면 변경해서 쓰면된다

company 화면 구성

fe/src/components/page/setting/company.vue

<template>
  <div>
    <b-row class="mb-4">
      <b-col cols="4">
        <input type="text" v-model="search" class="form-control" id="input-text" placeholder="회사 검색">
      </b-col>
      <b-col>
      </b-col>
    </b-row>
    <b-alert variant="warning" show v-if="!d.ds.length">
      <h4><icon name="info"></icon> 새로 시작하셨나 봐요 회사가 하나도 없네요</h4>
      <p>회사추가를 눌러서 시작하세요!</p>
    </b-alert>
    <b-card-group deck class="mb-3" v-else>
      <b-card no-body
              v-for="(v, i) in d.ds"
              :sub-title="v.rmk"
              :key="v._id">
        <b-card-header>
            <h4>{{ v.name }}</h4>
        </b-card-header>
        <b-card-body>
          <p class="card-text">{{v.rmk}}</p>
          <gmap-map
            :id="'map' + i.toString()"
            :center="v.pos"
            :zoom="16"
            style="height: 200px">
            <gmap-marker :position="v.pos" :icon="markerIcon"></gmap-marker>
            <gmap-circle
              :center="v.pos"
              :radius="100"
              :options="circleOptions"

            ></gmap-circle>

            <!--<gmap-marker v-for="(w, j) in v.sgr_ids" :key="w._id" v-for="(x, k) in w.sdv_ids" :key="x._id" :position="x.r.pos" :icon="markerIcon"></gmap-marker>-->
          </gmap-map>
        </b-card-body>
        <!--<b-list-group flush>-->
          <!--<b-list-group-item v-for="(w, j) in v.gr_ids" :key="w._id" class="d-flex justify-content-between align-items-center">-->
            <!--<span> {{ w.name }} </span>-->
          <!--</b-list-group-item>-->
        <!--</b-list-group>-->

        <b-list-group flush>
          <b-list-group-item v-for="(gr, j) in v.gr_ids" :key="gr._id">
            <span> {{ gr.name }} </span>
            <b-button-group class="float-right" size="sm">
              <b-btn variant="outline-warning" @click="modGr(gr)"><icon name="pencil"></icon></b-btn>
              <b-btn variant="outline-danger" @click="delGr(gr)"><icon name="trash"></icon></b-btn>
            </b-button-group>
          </b-list-group-item>
          <b-list-group-item>
            <span> 새그룹 추가 </span>
            <b-button-group class="float-right" size="sm">
              <b-btn variant="outline-success" @click="addGr(v)"><icon name="plus"></icon></b-btn>
            </b-button-group>
          </b-list-group-item>

        </b-list-group>

        <b-card-footer>
          <p>
            <small class="text-muted">{{ ago(v.ut) }}</small>
            <b-button-group class="float-right">
              <b-btn variant="outline-warning" @click="modalOpen(v)"><icon name="pencil"></icon></b-btn>
              <b-btn variant="outline-danger" @click="del(v)"><icon name="trash"></icon></b-btn>
            </b-button-group>
          </p>
        </b-card-footer>
      </b-card>
    </b-card-group>

    <b-row>
      <b-col>
        <b-btn variant="info" @click="list">새로고침</b-btn>
        <b-btn variant="success" @click="add" >회사 추가</b-btn>
      </b-col>
      <b-col>
        <b-pagination align="right" size="md" @input="list" :total-rows="d.cnt" v-model="page" :per-page="s.limit">
        </b-pagination>
      </b-col>
    </b-row>


    <b-modal ref="mdRef" :title="md.set.name">
      <b-row class="mb-2">
        <b-col>
          <b-input-group prepend="회사 이름">
            <b-form-input type="text" v-model="md.set.name"></b-form-input>
          </b-input-group>
        </b-col>
        <b-col>
          <b-input-group prepend="설명">
            <b-form-input type="text" v-model="md.set.rmk"></b-form-input>
          </b-input-group>
        </b-col>
      </b-row>
      <gmap-map
        :center="md.set.pos"
        :zoom="12"
        style="height: 200px"
        @click="mdSetPos">
        <gmap-marker :position="md.set.pos"></gmap-marker>
      </gmap-map>
      <div slot="modal-footer">
        <b-btn class="float-right" variant="primary" @click="mod(md.set)">
          저장
        </b-btn>
      </div>
    </b-modal>
  </div>
</template>

<script>

  export default {
    name: 'company',
    data() {
      return {
        page: 1,
        search: '',
        s: {
          draw: 0,
          skip: 0,
          limit: 3,
          order: 'name',
          sort: 1,
        },
        d: {
          draw: 0,
          cnt: 0,
          ds: [],
        },
        md: {
          set: {
            _id: '',
            name: '',
            rmk: '',
            pos: {
              lat: 37,
              lng: 127,
            },
          },
          // name: 'def',
        },
        markerIcon: {
          path: this.$fam.BUILDING_O,
          scale: 0.4,
          strokeWeight: 0.0,
          strokeColor: 'black',
          strokeOpacity: 1,
          fillColor: '#3276B1',
          fillOpacity: 0.9,
        },
        circleOptions: {
          strokeColor: '#1cc3ff',
          strokeOpacity: 0.8,
          strokeWeight: 2,
          fillColor: '#99fdff',
          fillOpacity: 0.35,
        },
      };
    },
    mounted() {
      this.list();
    },
    computed: {
      setSkip() {
        if (this.page <= 0) return 0;
        return (this.page - 1) * this.s.limit;
      },
    },
    methods: {
      swalSuccess(msg) {
        return this.$swal({
          icon: 'success',
          // button: false,
          title: '성공',
          text: msg,
          timer: 2000,
        });
      },
      swalWarning(msg) {
        return this.$swal({
          icon: 'warning',
          // button: false,
          title: '실패',
          text: msg,
          timer: 2000,
        });
      },
      swalError(msg) {
        return this.$swal({
          icon: 'error',
          // button: false,
          title: '에러',
          text: msg,
          timer: 2000,
        });
      },
      modalOpen(v) {
        this.md.set = v;
        this.$refs.mdRef.show();
      },
      ago(t) {
        return this.$moment(t).fromNow();
      },
      mdSetPos(m) {
        this.md.set.pos = m.latLng;
      },
      list() {
        this.$axios.get(`${this.$cfg.path.api}data/company`, {
          params: {
            draw: (this.s.draw += 1),
            search: this.search,
            skip: this.setSkip,
            limit: this.s.limit,
            order: this.s.order,
            sort: this.s.sort,
          },
        })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            this.d = res.data.d;
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('리스트를 불러올 수 없습니다.');
          });
      },
      add() {
        this.$swal({
          title: '회사 추가',
          content: 'input',
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '추가',
            },
          },
        })
          .then((res) => {
            if (!res) throw new Error('');
            return this.$axios.post(`${this.$cfg.path.api}data/company`, {
              name: res,
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('회사 추가 완료');
          })
          .then(() => {
            this.list();
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('회사 이름을 입력하세요');
          });
      },
      del(v) {
        this.$swal({
          title: '회사 삭제',
          dangerMode: true,
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '삭제',
            },
          },
        })
          .then((sv) => {
            if (!sv) throw new Error('');
            return this.$axios.delete(`${this.$cfg.path.api}data/company`, {
              params: { id: v._id },
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('회사 삭제 완료');
          })
          .then(() => {
            this.list();
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('회사 삭제 취소');
          });
      },
      mod(v) {
        this.$swal({
          title: '회사 정보 변경',
          dangerMode: true,
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '변경',
            },
          },
        })
          .then((sv) => {
            if (!sv) throw new Error('');
            return this.$axios.put(`${this.$cfg.path.api}data/company`, {
              _id: v._id,
              name: v.name,
              rmk: v.rmk,
              pos: v.pos,
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            this.$refs.mdRef.hide();
            return this.swalSuccess('회사 정보 변경 완료');
          })
          .then(() => {
            this.list();
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('회사 정보 변경 취소');
          });
      },
      addGr(cp) {
        this.$swal({
          title: '그룹 추가',
          content: {
            element: 'input',
            attributes: {
              placeholder: '선릉',
            },
          },
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '추가',
            },
          },
        })
          .then((res) => {
            if (!res) throw new Error(''); // return; // swal('취소됨');
            return this.$axios.post(`${this.$cfg.path.api}data/group`, {
              name: res,
              cp_id: cp._id,
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('그룹 추가 완료');
          })
          .then(() => {
            this.list();
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('그룹 이름을 입력하세요');
          });
      },
      modGr(gr) {
        this.$swal({
          title: '그룹 이름 변경',
          // dangerMode: true,
          content: {
            element: 'input',
            attributes: {
              placeholder: '소속1',
              value: gr.name,
            },
          },
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              className: 'red-bg',
              text: '변경',
            },
          },
        })
          .then((res) => {
            if (!res) throw new Error('');
            return this.$axios.put(`${this.$cfg.path.api}data/group`, {
              _id: gr._id,
              name: res,
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('그룹 변경 완료');
          })
          .then(() => {
            this.list();
          })
          .catch((err) => {
            if (err.message) this.swalError(err.message);
            else this.swalWarning('그룹 변경 취소');
          });
      },
      delGr(gr) {
        this.$swal({
          title: '그룹 삭제',
          dangerMode: true,
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '삭제',
            },
          },
        })
          .then((res) => {
            if (!res) throw new Error('');
            return this.$axios.delete(`${this.$cfg.path.api}data/group`, {
              params: { _id: gr._id },
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('그룹 삭제 완료');
          })
          .then(() => {
            this.list();
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('그룹 삭제 취소');
          });
      },
    },
    watch: {
      search() {
        // this.inputSync();
        this.list();
      },
    },
    destroyed() {
    },
  };
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

우선 현재 이 블로그의 liquid 문법과 vue Mustache( {{ }}) 가 겹쳐서 띄어 쓰여져있으니 카피했다면 붙혀야한다 ..

html

  • b-row, b-col: grid system의 기초인데 cols=”4” 선언으로 4/12 영역에 검색창을 만들었다.
  • b-alert: 주로 버튼등 액션있을 때 표시용, 데이터가 없을때 표시했다.
  • b-card: nobody로 주면 패딩없이 꽉꽉한 내용이 담기는데 아래 b-card-body는 nobody로 부터 자유롭다., 회사 리스트만큼 루프를 돌려서 맵과 이름을 찍었다.
  • gmap-map: google map canvas다
  • gmap-marker, gmap-circle: gmap-map 하위에서 동작한다 좌표만 넣으면 찍힌다.
  • b-list-group: flush 되어 있으면 테두리 없이 카드에 붙는다. 회사에 속한 그룹들을 루프를 돌려서 이름을 취했다.
  • b-card-footer: 하단에 회색 공간이 생긴다 회사를 수정할 수 있는 모달과 삭제할수 있는 버튼을 만들었다.
  • b-modal: modal 레퍼런스를 만들어 두고 모달을 위한 변수로 핸들링된다. 원래 따로놀아야 하는 변수지만 번지만 넘겨서 시각적인 즐거움을 줘봤다
  • b-pagination: paging을 위한 바인드 부분을 채워주면 알아서 작동한다.

script

  • data
    • page: b-pagination과 한쌍
    • search: 검색창
    • s: 서버에 페이징으로 전달할 값들
    • d: 수신된 데이터
    • md: modal에서 사용할 데이터
    • markerIcon: 기본 아이콘이 못생겨서 바꿈
    • circleOption: 옵션 안주면 검정색 원임
  • mounted: 회사 리스트를 불러오는 부분
  • computed
    • setSkip: page는 1부터 시작하지만 디비 입장에선 skip은 0부터 줘야하기 때문에 간단한 계산식
  • methods
    • swal 시리즈: 모양 좋은 알러트, 간단한 선택 및 입력창인데 매번 쓰기 지저분해서 만들어놓음
    • ago: 몇분전
    • mdSetPos: 모달 지도에서 온클릭 이벤트로 넘어온 좌표
    • list, add, mod, del: company api 에 대응하는 CRUD func.
    • addGr, modGr, delGr: group api 에 대응하는 CRUD func.

구현된 화면

db 연동

시연 영상

직접 눈으로 확인하는게 좋을 것 같아서 영상으로 만들어봤다.

전체 소스

https://github.com/fkkmemi/nembv

결론

이로서 NEMBV 1편을 마친다.

처음에 이곳에 기록하기 시작한 것은 나 자신이 까먹는 걸 방지하려 기록한 것인데..

항상 라이브러리들을 고맙게 쓰기만 했지 소스를 오픈한 적은 없는 것 같다.

약 3일동안 업무 중간중간 만들었는데 다 만들고 나니 조금 후련해졌다.

이 소스가 도움이 될 만한 사람들이 많아졌으면 좋겠다.

다음에는 한국형 게시판과 jwt 인증등을 2편에서 이어 진행해보려한다.

댓글남기기