20200107のvue.jsに関する記事は16件です。

VueとFlaskでITイベント情報を地図表示するウェブアプリを作った�

はじめに

ネットサーフィンをしていると、ITイベントポータルサイトのconnpassがイベント情報を提供するWebAPIを配信している事を知り、開催地などを地図上で表示するアプリがあれば便利なのではないかと思い制作、年始の3日間を生贄に捧げ完成。

成果物

Signpost for connpass
GitHub
スクリーンショット 2020-01-07 21.57.23.png

いわゆるSPAアプリケーションで、非同期通信で得たレスポンスを動的に地図表示します。

フレームワーク・ライブラリと選定理由

  • Flask:後述
  • Vue.js (Vue CLI):勉強中であるため、Reactよりしっくり来るため
  • Mapbox GL JS (+VueMapbox):多数の地物を表示するため動作パフォーマンスを重視した
  • BootstrapVue:神
  • OpenStreetMap(背景地図):国土地理院の基本地図よりも建物名などが見やすいから

各種ポイント

CORS制限

CORSとは、オリジン間リソース共有(Cross-Origin Resource Sharing)のこと。
簡単には、「許可を得ていない他人のウェブサイトのデータを非同期通信で持ってくるのはダメよ」、と理解している。
今回のケースでは、connpassAPIのレスポンスは、CORS制限のためフロント側で取得する事が出来ない。一度自らのサーバで受けておけばCORS制限には引っかからない。という訳で、当初はfirebaseのhostingやfunctionで対応しようと考えていたが、connpassAPIにアクセスしレスポンスを受け取るサーバをFlaskで構築する事とした。Flaskはとてもシンプルなので、今回のようになんでも良いからサーバが必要な場合に最適だと思うし、個人的に好きだから採用。

FlaskとVue.jsの環境構築

Vue.jsはnode.jsと組み合わせて使う事が多いと思います。色々調べた結果、Flaskと共存して開発出来る構成に至りました。
スクリーンショット 2020-01-07 21.51.25.png

ルートにPythonスクリプトやherokuサーバ用ファイルを配置し、フロント側はvueディレクトリに納めてあります。以下のとおり設定する事で、Flaskは、vue/distをtemplateフォルダとして参照するようになります。

from flask import Flask, render_template, request, jsonify, make_response, send_file, redirect, url_for

app = Flask(__name__, static_folder='./vue/dist/static', template_folder='./vue/dist')


#以下ルーティング…

APIサーバの構築

connpassAPIの仕様に沿ってアクセスし、得たレスポンスをフロントへ返すAPIサーバを以下のようにつくりました。

import urllib.request, urllib.parse
import json
@app.route('/api/')
def getApi():
    keyword_or = request.args.get('keyword_or')
    ym = request.args.get('ym')
    ymd = request.args.get('ymd')
    owner_nickname = request.args.get('owner_nickname')
    start = request.args.get('start')
    order = request.args.get('order')
    count = request.args.get('count')
    all_params = {
        "keyword_or": keyword_or,
        "ym":ym,
        "ymd":ymd,
        "owner_nickname":owner_nickname,
        "start":start,
        "order":order,
        "count":100
    }

    params = {}
    #値がNoneとなっている要素はパラメータから削除する
    for key in all_params:
        if all_params[key] != None:
            params[key] = all_params[key]


    p = urllib.parse.urlencode(params)
    url = "https://connpass.com/api/v1/event/?" + p

    with urllib.request.urlopen(url) as res:
        html = res.read().decode().replace(r"\n","")
        jsonData = json.loads(html)
        return jsonify(jsonData)

connpassAPIと同じパラメータを受け、そのままconnpassAPIに投げ、レスポンスをデコードし、jsonとして返しています。完全なる中継サーバという事です。

参考サイト

Vue.js(vue-cli)とFlaskを使って簡易アプリを作成する【後半 - サーバーサイド編】
BootstrapVue
VueMapbox

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

VueとFlaskでITイベント情報を地図表示するウェブアプリを作った

はじめに

ネットサーフィンをしていると、ITイベントポータルサイトのconnpassがイベント情報を提供するWebAPIを配信している事を知り、開催地などを地図上で表示するアプリがあれば便利なのではないかと思い制作、年始の3日間を生贄に捧げ完成。

成果物

Signpost for connpass
GitHub
スクリーンショット 2020-01-07 21.57.23.png

いわゆるSPAアプリケーションで、非同期通信で得たレスポンスを動的に地図表示します。

フレームワーク・ライブラリと選定理由

  • Flask:後述
  • Vue.js (Vue CLI):勉強中であるため、Reactよりしっくり来るため
  • Mapbox GL JS (+VueMapbox):多数の地物を表示するため動作パフォーマンスを重視した
  • BootstrapVue:神
  • OpenStreetMap(背景地図):国土地理院の基本地図よりも建物名などが見やすいから

各種ポイント

CORS制限

CORSとは、オリジン間リソース共有(Cross-Origin Resource Sharing)のこと。
簡単には、「許可を得ていない他人のウェブサイトのデータを非同期通信で持ってくるのはダメよ」、と理解している。
今回のケースでは、connpassAPIのレスポンスは、CORS制限のためフロント側で取得する事が出来ない。一度自らのサーバで受けておけばCORS制限には引っかからない。という訳で、当初はfirebaseのhostingやfunctionで対応しようと考えていたが、connpassAPIにアクセスしレスポンスを受け取るサーバをFlaskで構築する事とした。Flaskはとてもシンプルなので、今回のようになんでも良いからサーバが必要な場合に最適だと思うし、個人的に好きだから採用。

FlaskとVue.jsの環境構築

Vue.jsはnode.jsと組み合わせて使う事が多いと思います。色々調べた結果、Flaskと共存して開発出来る構成に至りました。
スクリーンショット 2020-01-07 21.51.25.png

ルートにPythonスクリプトやherokuサーバ用ファイルを配置し、フロント側はvueディレクトリに納めてあります。以下のとおり設定する事で、Flaskは、vue/distをtemplateフォルダとして参照するようになります。

from flask import Flask, render_template, request, jsonify, make_response, send_file, redirect, url_for

app = Flask(__name__, static_folder='./vue/dist/static', template_folder='./vue/dist')


#以下ルーティング…

APIサーバの構築

connpassAPIの仕様に沿ってアクセスし、得たレスポンスをフロントへ返すAPIサーバを以下のようにつくりました。

import urllib.request, urllib.parse
import json
@app.route('/api/')
def getApi():
    keyword_or = request.args.get('keyword_or')
    ym = request.args.get('ym')
    ymd = request.args.get('ymd')
    owner_nickname = request.args.get('owner_nickname')
    start = request.args.get('start')
    order = request.args.get('order')
    count = request.args.get('count')
    all_params = {
        "keyword_or": keyword_or,
        "ym":ym,
        "ymd":ymd,
        "owner_nickname":owner_nickname,
        "start":start,
        "order":order,
        "count":100
    }

    params = {}
    #値がNoneとなっている要素はパラメータから削除する
    for key in all_params:
        if all_params[key] != None:
            params[key] = all_params[key]


    p = urllib.parse.urlencode(params)
    url = "https://connpass.com/api/v1/event/?" + p

    with urllib.request.urlopen(url) as res:
        html = res.read().decode().replace(r"\n","")
        jsonData = json.loads(html)
        return jsonify(jsonData)

connpassAPIと同じパラメータを受け、そのままconnpassAPIに投げ、レスポンスをデコードし、jsonとして返しています。完全なる中継サーバという事です。

参考サイト

Vue.js(vue-cli)とFlaskを使って簡易アプリを作成する【後半 - サーバーサイド編】
BootstrapVue
VueMapbox

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでAPI呼び出し結果の件数分、動的にコンポーネントを生成する

Vue.jsでAPI呼び出し結果のJSONデータ件数分コンポーネントを動的に生成するには、
API呼び出し結果をセットしてv-forディレクティブを使えば実現可能。

API呼び出し結果
[
    {id:1, name:"foo"},
    {id:2, name:"bar"}
]
index.html
<div id="example">
    <foo-component v-for="val of values" :val="val"></foo-component>
</div>
index.js
<script>
    const component = Vue.extend({
        template:
            '<div>' +
                '<input type="checkbox" name="hoge" :value="val.id">' +
                '<label>{{val.name}}</label>' +
            '</div>',
        props: ['val']
    })

    new Vue({
        el: '#example',
        data() {
            return {
                values: null
            }
        },
        mounted() {
            axios
                .get("http://localhost")
                .then(response => {
                    this.values = response.data
                })
        },
        components: {
            'foo-component': component
        }
    })
</script>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWSでのデプロイ手順①ネットワーク環境設定

初心者には難関である、AWSを使用したデプロイ手順を書いてみます

AWSはUIもよく変化するので、
現在のもの(2019/10)で書いています

※アカウントがない人はまず新規で申し込みしておいてください

今回内容
STEP1 ネットワーク環境設定
1.VPCの作成
2.サブネットの作成
3.インターネットゲートウェイの作成
4.ルートテーブルの作成
5.サブネットとの紐付け
6.セキュリティグループの作成

STEP2 EC2の設定
1.EC2にてインスタンスの作成
2.Elastic IPの割り当て
3.インスタンスにSSHでログイン

次回内容
STEP3以降
AWSでデプロイするまでの手順②サーバー(EC2インスタンス)環境設定

※ここでいうサーバーとはAWS EC2インスタンス (Amazon Linux) のこととする

STEP1 ネットワーク環境設定

1.VPCの作成

https://aws.amazon.com/
Amazon Web Servicesでサインインします

ヘッダーにあるリージョンを東京に設定し、
「VPC」を検索し「VPC」にいきます

スクリーンショット 2019-10-01 10.13.39.png

サイドメニューのVPCより[VPCの作成]を押下します
スクリーンショット 2019-09-29 20.26.18.png

作成画面になるので
今回は下のように設定し、[作成]を押下します

・ネームタグ       :testVPC
・IPv4 CIDRブロック:10.0.0.0/16
・IPv4 CIDRブロック:ブロックなし
・テナンシー       :デフォルト

スクリーンショット 2019-10-01 10.18.41.png

2.サブネットの作成

サイドメニューのサブネットより[サブネットの作成]を押下します
スクリーンショット 2019-09-29 20.30.39.png

作成画面になるので
下のように設定し、[作成]を押下します

・ネームタグ:testSubnet
・VPC:先ほど作成したものを選択
・アベイラビリティゾーン:ap-northeast-1a
・CIDRブロック:10.0.0.0/24

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f34633731633536382d346231352d653262622d636665612d6333373631643964363634642e706e67.png

3.インターネットゲートウェイの作成

サイドメニューのインターネットゲートウェイより
[インターネットゲートウェイの作成]を押下します

スクリーンショット 2019-09-29 20.34.02.png

作成画面になるので
下のように設定し、[作成]を押下します

名前タグ:testGateway

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f30303636333333392d383839352d643333632d343637332d6362376530643365353366632e706e67.png

[アクション]を押下し、[VPCにアタッチ]を押下します
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f63333666633763662d313638622d626136392d306135652d3365326235653836303433642e706e67.png

下のように設定し、[アタッチ]を押下する

VPC:先ほど作成したものを選択

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f66303032333238392d653838612d326264652d306337632d3661323536666230373264312e706e67.png

4.ルートテーブルの作成

サイドメニューのルートテーブルより[ルートテーブルの作成]を押下

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f37616534643065332d323862302d613333372d346132632d6630623434383936643238352e706e67.png

作成画面になるので
下のように設定し、[作成]を押下します

名前タグ:testTable
VPC:先ほど作成したものを選択

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f32623430326161332d633761652d666461622d616163622d6130326265323933383230652e706e67.png

作成したルートテーブルを選択した状態で、
下のタブ「ルート」>「ルートの編集」を押下、[ルートの追加]を押下

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f35336331383337372d353137662d613265342d313139352d3263613730396130306661632e706e67.png

下のように設定し[ルールの保存]を押下します

送信先:0.0.0.0/0
ターゲット:Internet Gatewayを選択し、先ほど作成したゲートウェイIDを選択

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f63626462313066362d656534352d616239312d613164612d6534363733636333313136662e706e67.png

5.サブネットとの紐付け

→左側の「サブネット」を選択します
→関連付けを変更するサブネットを選択します
→画面下のルートテーブルを選択します
→[ルートテーブルの関連付けの編集]を押下します

スクリーンショット 2019-10-01 10.35.17.png

→先ほど作成したルートテーブルIDを選択します
→[保存]を押下します

スクリーンショット 2019-10-01 10.39.06.png

6.セキュリティグループの作成

サイドメニューの
セキュリティ>セキュリティグループより>[セキュリティグループの作成]を押下します
スクリーンショット 2019-09-29 21.07.37.png

下のように設定し、[作成]を押下する

セキュリティグループ名:testSecurityGroup
説明:そのセキュリティグループの説明
VPC:先ほど作成したものを選択

スクリーンショット 2019-10-01 10.43.36.png

画面下のインバウンドのルールの[ルールの編集]を押下し、[ルールの追加]を押下、
下のように設定し、[ルールの保存]を押下する

タイプ:SSH
ソース:マイIP

スクリーンショット 2019-09-30 21.23.54.png

STEP2 EC2の設定

AWSでDBを利用したい場合、

・EC2にてインスタンスの作成
・RDSを利用する
※ ただRDSを使用すると料金が掛かるので、
使用しない場合はサーバーに直接データベースを作成してください。

この2種類の方法があります。
今回は
EC2にてインスタンスの作成(サーバーに直接データベースを作成)
をメインに行なっていきます

1.EC2にてインスタンスの作成

インスタンスは起動のままだと
課金請求されるので、使わない場合は
停止状態にすることをお勧めします

AWS マネジメントコンソールにて"EC2"を検索しアクセスする

スクリーンショット 2019-09-26 23.58.51.png

サイドメニューのインスタンスより[インスタンスの作成]を押下する

スクリーンショット 2019-09-27 0.02.03.png

今回はこちらをクリックします
スクリーンショット 2019-09-27 0.06.56.png

[次の手順: インスタンスの詳細の設定]をクリックする

スクリーンショット 2019-09-27 0.08.21.png

下を設定し,[次の手順: ストレージの追加]を押下する

ネットワーク:先ほど作成したVPCを選択
サブネット:先ほど作成したサブネットを選択
自動割り当てパブリック IP:有効
他はデフォルトのまま

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f30353438646266632d333631362d396662392d386166662d6265383133353536653934632e706e67.png

デフォルトのままで,[次の手順: タグの追加]を押下する

タグを追加する

[タグの追加]を押下する
スクリーンショット 2019-09-30 16.46.54.png

キー:Name
値:testInstance

で[次の手順: セキュリティグループの設定]を押下する

セキュリティグループの設定する

セキュリティグループの割り当て:既存を
選択し、先ほど作成したセキュリティグループを選択し、
[確認と作成]を押下する
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f39616532356237392d396565342d363531342d396237392d6237333732613434373030322e706e67.png

一覧画面にて、
最後に[起動]を押下します

下ような表示になり、
新しいキーペアを作成を選択、
キーペア名を入力し、
[キーペアのダウンロード]を押下します
(一度作成されたファイルは再度ダウンロードができなくなるので注意)
スクリーンショット 2019-09-30 19.51.28.png

ダウンロードしたら[インスタンスの作成]を押下します

しばらくするとインスタンスが作成されます!!

2.Elastic IPの割り当て

サイドメニューの
ネットワーク&セキュリティ>
Elastic IP[新しいアドレスの割り当て]を押下する

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f36323739383338622d633864352d643366312d383066342d6639613635663234633238612e706e67.png

[割り当て]を押下します
スクリーンショット 2019-09-30 17.06.31.png

[閉じる]で戻り

[アクション]>アドレスの関連付けを押下します
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3335353531372f34643235323034332d663531632d653261362d396330362d3462626139363734326635662e706e67.png

インスタンスを先ほど作成したものを選択し、[関連付け]を押下する
スクリーンショット 2019-09-30 17.09.45.png

3.インスタンスにSSHでログイン

各種インストール

python

$ brew install python

pip(pythonのパッケージ管理システム)

$ easy_install pip

awscli(awsをPCのコンソール上から扱うためのもの)

$ pip install awscli

できない場合はこちら
https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/install-macos.html

ec2-userでインスタンスにログインする

AWSでは、
EC2インスタンスにログインできるユーザーとして、
デフォルトでec2-userという名のユーザーが用意されています
こちらではまずec2-userでログインします

ターミナルで以下を入れていきます
(※testKey.pemは、先ほどSTEP2でダウンロードしたキーです)

$ mv Downloads/testKey.pem .ssh/
(#作成した公開鍵をsshフォルダに移動)

$ cd .ssh/
(#ディレクトリをsshに移動)

$ chmod 600 testKey.pem
(#公開鍵に600番で定義されたアクセス権を付与する)

$ ssh -i testKey.pem ec2-user@(@以降のURLは、作成したEC2インスタンスと紐付けたElastic IPを使用してください。)
(#公開鍵を利用してec2-userとしてログイン)

例:
($ ssh -i testKey.pem ec2-user@13.112.140.56)

yes/noを聞かれるので

yesでEnterを押下
スクリーンショット 2019-09-30 21.49.54.png

無事にログインできました!!!


ssh port 22 Operation timed out
エラーになる場合はこちら
https://qiita.com/yokoto/items/338bd80262d9eefb152e

https://qiita.com/minicoopers0716/items/cac50f29ef79a03f1d8d

ユーザー作成(EC2上での操作)

上記方法でインスタンスにログインしている状態で

[ec2-user|~]$ sudo adduser testuser
(#新規ユーザー名の登録)
[ec2-user|~]$ sudo passwd testuser
(#新規ユーザー名のパスワード登録)


パスワードを登録します


vimでユーザーに権限を追加する記述する

$ sudo visudo

vimモードになります

## Allows people in group wheel to run all commands
# %wheel        ALL=(ALL)       ALL

## Same thing without a password
# %wheel  ALL=(ALL)       NOPASSWD: ALL

## Allows members of the users group to mount and unmount the
## cdrom as root
# %users  ALL=/sbin/mount /mnt/cdrom, /sbin/umount /mnt/cdrom

## Allows members of the users group to shutdown this system
# %users  localhost=/sbin/shutdown -h now

検索モードにし下の
"wheel"を探します
(キーボードで"/wheel"を入力し押下、"N"を押下すると次にいけます)

## Same thing without a password
# %wheel  ALL=(ALL)       NOPASSWD: ALL

キーボードの「i」を押下し、編集モードで

# %wheel ALL=(ALL) NOPASSWD: ALL
のコメントアウトを外します

## Same thing without a password
%wheel  ALL=(ALL)       NOPASSWD: ALL

さらに

その下に、作成したユーザーに権限を追加する記述
testuser   ALL=(ALL)       ALL  を追加する
## Allow root to run any commands anywhere
root    ALL=(ALL)       ALL
testuser   ALL=(ALL)       ALL

キーボードの「esc」を押下します
「:wq」を入力しEnterで保存します

こちらでユーザーの切り替えを行ってください。

[ec2-user|~]$ sudo su - testuser
(#ユーザー名の切り替え)
[testuser@ ~]

無事に[ec2-user|がtestuser(作成したユーザー名)と切り替わればOKです。

exit

を二回いれて
で一度ログアウトする

追加ユーザ用キーペアを作成

こちらはローカルでの作業です

$ cd .ssh
[.ssh]$ ssh-keygen -t rsa
(#公開鍵を作成)
-----------------------------
Enter file in which to save the key ():
(#ここでファイルの名前を記述して、エンターを押す)
test_key_rsa 

Enter passphrase (empty for no passphrase): 
(#何もせずそのままエンター)
Enter same passphrase again: 
(#何もせずそのままエンター)
-----------------------------

[.ssh]$ ls
#「test_key_rsa」と「test_key_rsa.pub」が生成されたことを確認
[.ssh]$ vi config
(#VIMを起動し、設定ファイルを編集する)
-----------------------------

キーボードの「i」を押下し、編集モードで

# 以下を追記
Host test_key_rsa
  Hostname 前出のElastic IP (#自分の設定に合わせて)
  Port 22
  User testuser (#先ほどのユーザー名)
  IdentityFile ~/.ssh/test_key_rsa (#秘密鍵の設定)
* ()部分は削除する。
-----------------------------
キーボードの「esc」を押下します
「:wq」で保存します

[.ssh]$ cat test_key_rsa.pub (#鍵の中身をターミナル上に出力)

★ ssh-rsa~~~~localまでをコピーしておく

サーバー側作業

続いてサーバーでの作業です
ec2-userでログインします

$ cd .ssh
$ ssh -i testKey.pem ec2-user@(@以降のURLは、作成したEC2インスタンスと紐付けたElastic IPを使用してください。)
[ec2-user|~]$ sudo su - testuser
[testuser@ ~]$ mkdir .ssh
[testuser@ ~]$ chmod 700 .ssh
[testuser@ ~]$ cd .ssh
[testuser@ |.ssh~]$ vi authorized_keys

(vimがオープンするので、「i」を押し、
先ほど ★ で、コピーしたssh-rsaをペーストする)
キーボードの「esc」を押下します
「:wq」で保存します

[testuser@ |.ssh~]$ chmod 600 authorized_keys

$ exit
もう一度
$ exit
ログアウト
[~]$ ssh test_key_rsa

ログインできれば、無事ユーザー設定は終了です。

なお、時間が経つとローカルからログインできなくなることがあるので、その場合は、
セキュリティグループ>インバウンド>編集で
SSHのソースで
マイIPを選択し[保存]すると繋がるようになります

続きはこちら
AWSでデプロイするまでの手順②サーバー(EC2インスタンス)環境設定

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

{(name)}ってなんやねん!分割代入とは

Javascript(Vue.js)を書くなかで分割代入なんやねん!となったので、記事に残します。
※今回はVue.jsでコード書いてます。Javascriptの機能なので生Javascriptでも同じことができます。

変数に仮引数ではなく実引数を代入している・・・?

分割代入は関数の変数の部分に({実引数})という書き方をする。
以下のmyFuncNewに注目すると、nameというプロパティをもつオブジェクトをmyFuncNewに渡すという意味になる。

{{myFunc(items.name)}}//りんご
{{myFuncNew(items)}}//りんご

省略

data(){
  return{
    items:{
      name:'りんご',
      price:'100',
    },
  }
},
methods:{
  //通常の書き方
  myFunc(name){
    return name;
  },
 //分割代入での書き方
  myFuncNew({name}){
    return name;
  },
}

分割代入は関数の変数の部分に({実引数})という書き方をすると書いた通り、
呼び出し側で{}内を変えると動かなくなる。
nameからnewnameに変えてみた。

{{myFunc(newname)}}//りんご
{{myFuncNew(newname)}}//※表示されない※

省略

data(){
  return{
    items:{
      name:'りんご',
      price:'100',
    },
  }
},
methods:{
  myFunc(name){
    return name;
  },
  myFuncNew({name}){
    return name;
  },
}

表示されなくなった。

今回はオブジェクトを対象とした分割代入について簡単に記しましたが、もっと奥が深い内容なので勉強することにします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

({実引数})ってなんやねん!分割代入とは

Javascript(Vue.js)を書くなかで分割代入なんやねん!となったので、記事に残します。
※今回はVue.jsでコード書いてます。Javascriptの機能なので生Javascriptでも同じことができます。

()の中に{}があるぞ・・・?しかも{}の中に仮引数ではなく実引数が書かれている・・・?

分割代入は関数に渡す変数の部分に({実引数})という書き方をする。
以下のmyFuncNewに注目すると、nameというプロパティをもつオブジェクトをmyFuncNewに渡すという意味になる。

{{myFunc(items.name)}}//りんご
{{myFuncNew(items)}}//りんご

省略

data(){
  return{
    items:{
      name:'りんご',
      price:'100',
    },
  }
},
methods:{
  //通常の書き方
  myFunc(name){
    return name;
  },
 //分割代入での書き方
  myFuncNew({name}){
    return name;
  },
}

分割代入は関数の変数の部分に({実引数})という書き方をすると書いた通り、
呼び出し側で{}内を変えると動かなくなる。
nameからnewnameに変えてみた。

{{myFunc(newname)}}//りんご
{{myFuncNew(newname)}}//※表示されない※

省略

data(){
  return{
    items:{
      name:'りんご',
      price:'100',
    },
  }
},
methods:{
  myFunc(name){
    return name;
  },
  myFuncNew({name}){
    return name;
  },
}

表示されなくなった。

まとめ

呼び出される時に呼び出し元のオブジェクトが渡されるというところがポイントです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

分割代入

Javascript(Vue.js)を書くなかで分割代入なんやねん!となったので、記事に残します。
※今回はVue.jsでコード書いてます。Javascriptの機能なので生Javascriptでも同じことができます。

変数に仮引数ではなく実引数を代入している・・・?

分割代入は関数の変数の部分に({実引数})という書き方をする。
以下のmyFuncNewに注目すると、nameというプロパティをもつオブジェクトをmyFuncNewの引数とするという意味になる。

{{myFunc(items.name)}}//りんご
{{myFuncNew(items)}}//りんご

省略

data(){
  return{
    items:{
      name:'りんご',
      price:'100',
    },
  }
},
methods:{
  //通常の書き方
  myFunc(name){
    return name;
  },
 //分割代入での書き方
  myFuncNew({name}){
    return name;
  },
}

分割代入は関数の変数の部分に({実引数})という書き方をすると書いた通り、
呼び出し側で{}内を変えると動かなくなる。
nameからnewnameに変えてみた。

{{myFunc(newname)}}//りんご
{{myFuncNew(newname)}}//※表示されない※

省略

data(){
  return{
    items:{
      name:'りんご',
      price:'100',
    },
  }
},
methods:{
  myFunc(name){
    return name;
  },
  myFuncNew({name}){
    return name;
  },
}

表示されなくなった。

今回はオブジェクトを対象とした分割代入について簡単に記しましたが、もっと奥が深い内容なので勉強することにします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ユーザーごとに表示する画面を切り替えるアクセス制御の実装(Google Apps Script + Vue.js + Spreadsheet)

はじめに

Google Apps ScriptのHTMLサービスを利用して簡易なワークフローツールを開発したい。

実現したいこと

アクセスしたユーザーの権限に応じて、表示する画面を切り替える。
Aというユーザーがアクセスした場合には入力フォームが表示されるが、
Bというユーザーがアクセスした場合には権限がありませんというメッセージを表示して登録できないようにする。

実装方針

[Google Apps Script]
- スプレッドシートにメールアドレスをキーにした権限テーブルを作成。
- HTMLにアクセスした際にその権限テーブル(スプレッドシート)を読み込んで、アクセスしたユーザーのアクセス権限を確認。
[Vue.js]
- ユーザーのアクセス権限に応じて表示するテンプレートを切り替える。
- アクセス権限あり:入力フォームを表示
- アクセス権限なし:アクセス権限がありませんというメッセージのみ表示し、入力フォームを表示しない。

Spreadsheet(権限テーブル)

ID Role
Login User Email allow

Google Apps Script

vue.js

コード.js
function doGet() {
  var html = HtmlService.createTemplateFromFile("index").evaluate().addMetaTag('viewport','width=device-width,initial-scale=1,minimal-ui');
  return html;
}


function getUser() {
  var email = Session.getActiveUser().getEmail();
  var mapData = getAccessList();

  return filterdData = mapData.filter(function(item, index){
    if (item.ID == email ) return true;
  });
}


function getAccessList(){
  var spreadSheetID = "yourSpreadsheetID";
  var sheetName = "yourSheetName";

  var res = SpreadsheetApp.openById(spreadSheetID)
    .getSheetByName(sheetName).getDataRange().getValues();

  var keys = res.splice(0, 1)[0];

  return res.map(function(row) {
    var obj = {}
    row.map(function(item, index) {
      obj[keys[index]] = item;
    });
    return obj;
  });
}

vue.js

vue.html
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>

<script>  
  var vm = new Vue({
    el: '#app',
    data: {
      changeTemplate:'loading',
    },
    computed: {         
    },
    watch: {
    },
    methods:{
      checkRole: function(loginUser){
        if (loginUser[0].Role == 'allow') {
          this.changeTemplate = 'allowAccess';          
        }else{
          this.changeTemplate = 'accessDeny';
        }
      }
    },
    created: function(){
      google.script.run
        .withSuccessHandler(this.checkRole).getUser();
    },
  })
</script>

vue.js

index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>

  </head>
  <body>

  <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
    <a class="navbar-brand" href="#">Navbar</a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>

  <div class="collapse navbar-collapse" id="navbarsExampleDefault">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item active">
        <a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="#">Link</a>
      </li>
      <li class="nav-item">
        <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
      </li>
      <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown</a>
        <div class="dropdown-menu" aria-labelledby="dropdown01">
          <a class="dropdown-item" href="#">Action</a>
          <a class="dropdown-item" href="#">Another action</a>
          <a class="dropdown-item" href="#">Something else here</a>
        </div>
      </li>
    </ul>
    <form class="form-inline my-2 my-lg-0">
      <input class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search">
      <button class="btn btn-secondary my-2 my-sm-0" type="submit">Search</button>
    </form>
  </div>
  </nav>

  <main role="main" class="container">
  <div id="app">

    <template v-if="changeTemplate == 'loading'">
      <div class="col-sm-12">
        <ul class="list">
          <li>アクセス権の有無を確認しています。</li>
        </ul>
      </div>
    </template>


    <template v-if="changeTemplate == 'allowAccess'">

      <div class="col-sm-12">
        <ul class="list">
          <li>Hello, world!</li>
          <li>アクセスが許可されました。</li>
        </ul>  
      </div>
    </template>


    <template v-if="changeTemplate == 'accessDeny'">
      <div class="col-sm-12">
        <ul class="list">
          <li>アクセスが拒否されました。</li>
        </ul>
      </div>
    </template>


  </div><!-- /.vue.el.app -->
  </main><!-- /.container -->

  <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
  <?!= HtmlService.createHtmlOutputFromFile('vue').getContent(); ?>
  </body>
</html>

vue.js

css.html
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<style>
  body {
    padding-top: 5rem;
  }
  .starter-template {
    padding: 3rem 1.5rem;
    text-align: center;
  }     
  .bd-placeholder-img {
    font-size: 1.125rem;
    text-anchor: middle;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
  }
  @media (min-width: 768px) {
    .bd-placeholder-img-lg {
    font-size: 3.5rem;
     }
  }
</style>

vue.js

js.jtml
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuexのstateをcomposition-apiでReactiveに使う

Vue.js版のReact HooksにあたるComposition APIとVuexを組み合わせる方法を紹介します。

vuejs/composition-api: Vue2 plugin for the Composition API.
https://github.com/vuejs/composition-api

こちらの方の投稿でVuexのstateそのものをreactiveにする方法を紹介されていますが、今回はVuexはそのままにしておく方法です。

Nuxt.js + Composition APIでVuexのStateをReactiveに使う方法
https://qiita.com/tubone/items/f5c7e8e79e21b051eec4

Composition APIによるカウンターの実装

単純なカウンターを例にComposition APIで実装すると以下のようになるかと思います。

export default createComponent({
  setup() {
    const state = reactive({
      count: 0,
    })
    return {
      state,
      handleIncrementButtonClick() {
        state.count++
      }
    }
  }
})

これを使って説明を進めていきます。

Vuex Storeのwatch関数を使って変更を監視する

さてVuex Storeはそれ自体でリアクティブな仕組みを持っていますが、Composition API(というかVueコンポーネントそのもの)とは別のライフサイクルなのでそれぞれが勝手に連携することはありません。

そこでStoreのwatch関数を使うことで対象の変更を監視することができます。
(Vuex側にも同じcountというstateが存在しそれを更新するactionがあることにします)

export default createComponent({
  setup(props: Props, context: SetupContext) {
    const state = reactive({
      count: 0,
    });

    // setup関数内はcreatedフックと同じタイミングで動く
    const unwatch = context.root.$store.watch<number>(vuexState => {
      // ここでreturnした値が監視対象になる
      return vuexState.count;
    }, (newVal: number) => {
      // Vuexのstateが更新されるとこの関数が呼ばれるのでreactiveの値にセットして通知
      state.count = newVal;
    });

    // コンポーネントが消されるときに監視を止める
    onUnmounted(() => {
      unwatch();
    });

    return {
      state,
      handleIncrementButtonClick() {
        // ここは普通にactionの呼び出し
        context.root.$store.dispatch('incrementCount');
      }
    }
  }
});

Storeの参照と監視を一般化する

このままでは使い勝手が悪いので一般化した関数にして楽に取れるようにします。
一旦これでnamespacedでないstateであれば参照が可能です。
contextを引数に渡さないようにできないもんですかね。

function vuexStateRef<T>(context: SetupContext, key: string): Ref<T> {
  // 最後に as Ref<T> をしておかないと data.value の更新の部分で型エラーが起きました
  const data = ref<T>(context.root.$store.state[key] as T) as Ref<T>;

  const unwatch = context.root.$store.watch<T>((vuexState: any) => {
    // 階層化された値の監視には対応してない
    return vuexState[key] as T;
  }, (newVal: T) => {
    // refのvalueを更新すればコンポーネントが反応する
    data.value = newVal;
  });

  onUnmounted(() => {
    unwatch();
  });

  // Refを返す
  return data;
}

setup関数で使うときはこうします。

export default createComponent({
  setup(props: Props, context: SetupContext) {
    const state = reactive({
      count: vuexStateRef<number>(context, 'count'),
    });

    return {
      state,
      handleIncrementButtonClick() {
        context.root.$store.dispatch('incrementCount')
      }
    };
  }
})

かなり見通しが良いですね。

まとめ

そのうち公式からVuexとの連携部分もリリースされるかと思いますが、Composition APIの勉強ついでに自作してみました。
actionsの連携部分で型定義も勝手に引き継げるようになるとかなりいいんですが、この辺りのアップデートに期待ですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vuetify-material-dashboardをPWA対応してみた

はじめに

スクリーンショット 2020-01-07 13.56.12.png

vuetify-material-dashboardはvue+vuetifyベースで作成された管理画面用のフレームワークです。
このフレームワークが、初期状態だとPWA対応していなかったため、pwa化した時の手順です。

PWA化に必要なモジュール追加

  • vuetify-masterial-dashbordのpackage.jsonがあるディレクトリで下記を実行
yarn add --dev @vue/cli-plugin-pwa
yarn add register-service-worker

ソースコード修正

  • src/router/index.jssrc/router/router.js の変更
  • buildしたファイルを読み込んだ際に、redirectされた/dashbordを読み込めなかったためです。 (redirectを書き直せば、問題なさそうですが、一旦以下の対応です。)

index.js

変更前
routes: paths.map(path => route(path.path, path.view, path.name)).concat([
  { path: '*', redirect: '/dashboard' }
]),
変更後
routes: paths.map(path => route(path.path, path.view, path.name)),

paths.js

変更前
  path: '/dashboard',
  // Relative to /src/views
変更後
  path: '/',
  name: 'Dashboard',

ファイルの追加

public/img/icons/

  • icons配下にはアイコン画像が複数枚(iOSやAndoroid用にサイズ違いで)
  • manifest.jsonにアイコン画像のパスを指定するので、それに合わせて適当に用意する。

manifest.json

public/manifest.json
{
    "name": "vuetify-materila-dashbord",
    "short_name": "vm-bord",
    "icons": [
      {
        "src": "./img/icons/ic_launcher.png",
        "sizes": "144x144",
        "type": "image/png"
      },
      {
        "src": "./img/icons/Icon-App-20x20@3x.png",
        "sizes": "60x60",
        "type": "image/png"
      },
      {
        "src": "./img/icons/Icon-App-29x29@3x.png",
        "sizes": "87x87",
        "type": "image/png"
      },
      {
        "src": "./img/icons/Icon-App-40x40@3x.png",
        "sizes": "120x120",
        "type": "image/png"
      },
      {
        "src": "./img/icons/Icon-App-60x60@3x.png",
        "sizes": "180x180",
        "type": "image/png"
      }
    ],
    "start_url": "./index.html",
    "display": "standalone",
    "background_color": "#FFFFFF",
    "theme_color": "#4e342e"
  }

robots.txt

public/robots.txt
User-agent: *
Disallow:

registerServiceWorker.js

src/registerServiceWorker.js
/* eslint-disable no-console */

import { register } from 'register-service-worker'

if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready () {
      console.log(
        'App is being served from cache by a service worker.\n' +
        'For more details, visit https://goo.gl/AFskqB'
      )
    },
    registered () {
      console.log('Service worker has been registered.')
    },
    cached () {
      console.log('Content has been cached for offline use.')
    },
    updatefound () {
      console.log('New content is downloading.')
    },
    updated () {
      console.log('New content is available please refresh.')
    },
    offline () {
      console.log('No internet connection found. App is running in offline mode.')
    },
    error (error) {
      console.error('Error during service worker registration:', error)
    }
  })
}

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuetifyのv-messages

Vuetifyにはv-messagesというメッセージ表示のコンポーネントがあります。
v-text-fieldなどでerror-messagessuccess-messagesを設定するとv-messagesコンポーネントを使って表示されます。
しかし、v-messages自体は公式サイトのドキュメントには説明がありません。
通常は他のコンポーネントに含まれる形で使われるからでしょう。
それでも、v-messages単体で使いたい場合もあります。
今回はv-messages単体での使い方をまとめました。

使用するVuetifyは、
v2.2.1
です。

https://vuetifyjs.com/en/getting-started/quick-start

$ vue create my-app
$ cd my-app/
$ vue add vuetify
$ npm run serve

スクリーンショット 2020-01-07 11.58.09.png

src/components/HelloWorld.vueを書き換えます。
v-messagespropsvalueがあります。
重要な点は、valueに渡されるデータは文字列の配列です:bell:
参照 (node_modules/vuetify/src/components/VMessages/VMessages.ts

src/components/HelloWorld.vue
<template>
  <v-container>
    <v-row>
      <v-col>
        <v-img
          :src="require('../assets/logo.svg')"
          class="my-3"
          contain
          height="200"
        ></v-img>
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <v-messages :value="messages"></v-messages>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
export default {
  name: 'HelloWorld',

  data: () => ({
    messages: [
      'Hello!!',
      'Good Bye!!',
      'Good Night.'
    ]
  }),
};
</script>
$ npm run serve

スクリーンショット 2020-01-07 12.18.17.png

Have a good Vuetify life:beer:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt.js に後から E2E テスト (puppeteer, jest-puppeteer) を入れる

Nuxt.js でも E2E テストを導入したいですね。
E2E テストを書いて、既存機能の修正時にデグレないようにしましょう。

今回は E2E テストのために、Headless Chrome API の puppeteer を入れてみた記事です。

Puppeteer 導入

$ yarn add -D puppeteer jest-puppeteer

Puppeteer の設定ファイルを追加

Nuxt.js プロジェクトのルート直下に jest-puppeteer.config.js を追加

module.exports = {
  launch: {
    headless: true,
    slowMo: 250
  },
  server: {
    command: 'yarn run testServer',
    port: 3000,
    launchTimeout: 50000
  }
}

jest.e2e.config.js を追加

module.exports = {
  verbose: true,
  preset: 'jest-puppeteer'
}

E2E テストを追加

E2E テストのファイルを作成し、最低限のテスト(トップページに文字が表示されていること)を追加します。

test/e2e/index.spec.js

describe('Index page', () => {
  let page;

  beforeAll(async () => {
    jest.setTimeout(50000)
    page = await browser.newPage()
    await page.goto('http://127.0.0.1:3000')
  });

  afterAll(async () => {
    await page.close()
  });

  it ('Display catch copy ', async () => {
    let text = await page.evaluate(() => document.body.textContent)

    await expect(text).toContain('トップページです');
  })
});

package.json にスクリプトを追加

...
"test": "jest --config jest.config.js ./test/components",
"test:e2e": "jest --config jest.e2e.config.js --runInBand ./test/e2e",
"testServer": "nuxt build && nuxt start --port 3000"

puppeteer 用に testServer スクリプトを用意します。
また、コンポーネントのテスト、E2E 用のテストそれぞれで実行するディレクトリも指定します。

Circle CI 上でテストを実行する

version: 2
jobs:
  build:
    docker:
      - image: circleci/node:10-browsers
    steps:
      ...
      - run: yarn run test:e2e

[nodejs version]-browsers のイメージを使い、テスト実行の run を追加します。

その他参考記事

jest-puppeteerを使ったテストを試す

最後に

決済などサービスのクリティカルな動作に対して、 E2E テストを書いていきましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue.js this が使えない場合 例えば html2canvasにて

vue.jsを使っていると時々、thisが使えないときがある。
入れ子になっているときとかね。

その時は、 this を let vc に格納して、利用する。

<script>

    document.getElementById('preview').style.display = "block";

    //画像が生成されたあとに動作させることができない。

    let vc = this;

    html2canvas(document.querySelector("#preview_inner")).then(function(canvas){

        var result = document.querySelector("#result");
        result.innerHTML = '';
        result.appendChild(canvas);



        // document.getElementById('preview').style.display = "none";
        // document.getElementById('result').style.display = "none";
        //
        //

        var dataURI = canvas.toDataURL('image/png');

        let dataform = new FormData();

        const message_tmp = document.getElementsByName("message");
        dataform.append('body',message_tmp[0].value);
        dataform.append('img',dataURI);

        message_tmp[0].value = "";

        vc.$message({
            message: '質問を送信しました',
            type: 'success',//or 'warning'
            customClass: 'message-margin'
        });


        axios.post('/question/add/', dataform).then(e => {


            vc.dialogVisible = false;
            vc.message = '';
            vc.clearStorage();
            vc.$router.push('/question/'+e.data, () => {}, () => {});


        }).catch((error) => {
            console.log(error);
            console.log("エラー");
        });

    });

</script>


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

サーバーサイドエンジニアがフロントエンドに挑戦

初投稿なので、ひとまず軽く自己紹介がてら…

投稿時で大体7年弱のエンジニア経験があり、内訳は
3年ほど大手企業の子会社に当たるシステム開発会社で正社員として、システム開発に従事。
その後半年弱、自己学習などもかねて自分でアプリ開発に挑戦してみるなどした後に転職し、1年半ほど小規模なソフトハウスで正社員として、システム開発に従事。
計4年半近くの正社員としての経験を積み独立してフリーランスに転身しました。
現在2期目になり、まもなく3期目を迎えようとしています。

おおまかなスキルセットですが
バックエンドはJavaでの経験が主で、6年程度。正社員では主にエンプラ系の業務システムに携わっていましたが、フリーになってからはWebサービスなどを中心に参画しています。FWはSpring系が一番経験として多くなります。
また、独学と実務経験1年弱ではありますがRubyの経験もあります。こちらはRuby on Railsを取り扱っておりました。

また、フロントエンドですが過去の経験はテンプレートエンジン(Thymeleafなど)やJSPなどを使っての開発が多く、ここ最近React.jsやVue.jsを使う機会も増えてきました。

Javaの技術や、これから使うことの増えそうなReact、Vue、Nuxt辺りの技術を中心に発信できればと思います。
フロント関連は正直まだやりながらなので、間違った認識などもあるかと思いますがその際はご指摘いただければと思います。
また、あまり文章書くのが得意でもないので読みにくいなどありましたらご指摘いただけますと修正いたします。
よろしくお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

npm run serveの速度改善

npm run serveが遅い

Vue.jsで書いたプログラムを確認しようとした時に、
npm run serveをしますが、動作が遅いし、他のブラウジングも同時に遅くなってしまう現象が発生しました。

原因

ESETなどのセキュリティソフトが影響しているみたい。

回避方法

Vue.jsのプロジェクトルートに、vue.config.jsというファイルを作成し、配置する。中身はこんな感じ。

module.exports = {
  devServer: {
    port: 8009,
  },
};

これで
npm run serveすると、
指定した通り、8009ポートで立ち上げている。

 2020-01-07 8.47.20.jpg

参考サイト

https://blog.kobasato.net/entry/vue-cli-yarn-serve-npm-run-serve-issue/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js入門としてWEBブラウザで使える会社の歓送迎会用費用計算機を作った。

はじめに

会社の歓送迎会の幹事。会社員ならば必ずと言っていいほどやらされる面倒くさい仕事。
2019年12月、ちょうど参加者20人を超える歓送迎会の幹事をやることになった。そこには歓送迎者に加え部長、課長、平社員勢揃いで、全員から同じ参加費を取るわけにもいかないので傾斜計算をする必要があった。

そして、いざ計算する時にやっぱり思う、「めちゃくちゃ面倒くさい...」
ゲスト3人タダで、部長が6000円で課長3人が5000円で平社員20人が4000円で…え?●●さんやっぱ出れない?→再計算が始まる。悪夢。

できたもの

さっそくできたサイトは以下。着想からリリースまで1週間、15時間ぐらい。
歓送迎会の会費計算 | Cocktail -f liquor
スクショ.png

使ったもの

簡単に傾斜計算をするためのWEBサービスを入門書だけ読んだVue.jsで作る事にした。
読んだ本→動かして学ぶ!Vue.js開発入門 (NEXT ONE)

既に自分はカクテルレシピ検索サイト「Cocktail -f liquor」を持っていたので、そこに機能として追加した。
レシピ検索サイト自体はPaaSにheroku、フレームワークにRails、CSSフレームワークにUIKitを使っているため、PCスマホどちらでもWEBブラウザ上で利用可能にした。

傾斜計算をするにあたって考えたこと

歓送迎会の傾斜計算をする場合に必要なものを考えた。いわゆる画面設計。

  • 参加する全体の人数と一人分のコース料金のための予約情報の入力欄
  • ゲスト、部長、課長、平社員といった参加費用の異なるグループごとの人数と参加費用の入力欄
  • 全グループ参加費の合計と人数の合計
  • 全グループの情報と予約情報が一致しているかの判定結果
  • 残りの参加費を自動的に計算できるようにする機能(これが結構考えるの面倒だった)
  • 結果を残しておけるように結果コピー機能

Vue.js部分

自身のスキルが入門書レベルということもあり、コンポーネントは使わない非常にシンプルな構成でVue.jsは記述した。
大枠としては以下の通りで、filter,data,computed,watch,methodsの基本構成で作成することができた(全体のソースコードは最後に記述)。今回はお金と人数を扱うこともあり、Vue.filterを使用して20,000のようにコンマを自動的につけれるようにした。

<script>
    Vue.filter('number_format', function (value) {
            ...
        }); 
    new Vue({
        el: "#app",
        data: {
            price: null,
            ...
        },
        computed :{
            ...
        },
        watch :{
            ...
        },
        methods :{
            ...
        }
    })
</script>

少し止まった部分

各グループ情報ごとのグループ参加費合計(グループ人数×グループごとの参加費)を出す必要がある。グループごとの情報は連想配列を配列で保持しており、watchで各グループ情報を監視する際に連想配列の中身をwatchする場合はdeepを指定する必要があった。

参考
[Vue]watchフックで連想配列を監視する場合、ディープウォッチャーにしておく必要がある件 - Qiita

data: {
    calcs: [{c_name:"",c_price:null,c_person:null,c_total:0,c_auto:false}],
},
watch :{
    calcs: {
        handler: function(val){
            for (var i = 0; i < this.calcs.length; i++ ){
                this.calcs[i].c_total = this.calcs[i].c_price * this.calcs[i].c_person;
            }
        },
        deep: true
    }
},

最後に

歓送迎会用の費用計算機の感想・意見を募集中です。
Twitter @anemoi42 までリプライまたはDMを送っていただけると助かります。

以上、ありがとうございました。

ソースコード

<% @page_title = '歓送迎会の会費計算' %>
<% content_for :header do %>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script>
<% end %>

<body>
    <div class='uk-card uk-card-default uk-card-small uk-card-body uk-margin-small'>
        <h3 class="box8 uk-margin-remove">歓送迎会の会費計算</h3>
        <div class="uk-margin-small">
        参加費用に傾斜をつける歓送迎会の会費シミュレーションが可能です。コース料金、全体参加人数を入力後、支払う金額ごとのグループを作成して最終的に参加費用と人数が予約と一致しているか確認できます。ゲストは無料、一般社員は3000円、課長は5000円、部長は6000円といった際の計算が簡単にできます。
        </div>

    </div>
    <div id="app">
        <div class='uk-card uk-card-default uk-card-small uk-card-body uk-margin-small'>
            <div class='uk-h4 uk-margin-small'>予約情報</div>
            <div>
                全体の参加費合計を出すために、一人分のコース料金と全体の参加人数を入力して下さい。
            </div>
            <hr>
            <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                 <div>1人分のコース料金:</div>
                 <div>
                    <input v-model.number="price" type="number" placeholder="例)5000" class="uk-input uk-form-width-medium" v-on:blur="complementPriceReserve"></div>
                 <div>
                    <input v-model="tax" type="checkbox" class="uk-checkbox"> 税別(10%)
                </div>
            </div>
            <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                <div>
                    参加人数:
                </div>
                <div>
                    <input v-model.number="person" type="number" placeholder="例)10" class="uk-input uk-form-width-small" v-on:blur="complementPersonReserve"></div>
            </div>
            <hr>
            <div class='uk-h4 uk-margin-small uk-align-right'>予約上の参加費合計: {{ total_price|number_format }}円</div>

        </div>

        <div class="tmpcalc uk-card uk-card-default uk-card-small uk-card-body uk-margin-small">
            <div class='uk-h4 uk-margin-small'>グループ情報</div>
            支払う金額ごとのグループを作成して下さい。
            <hr>
            <div v-for="(calc,c) in calcs">
                <div class="tmpcalc uk-card uk-card-group uk-card-small uk-card-body uk-margin-small">

                    <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                        <div>
                        グループ名(任意):
                        </div>
                        <div>
                            <input v-model.number="calc.c_name" type="text" placeholder="例)ゲスト、平社員、課長" class="uk-input uk-form-width-auto">
                        </div>
                    </div>
                    <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' > 
                        <div>
                            グループ人数:
                        </div>
                        <div>
                            <input v-model.number="calc.c_person" type="number" placeholder="例)5" v-on:blur="complementPerson(c)" class="uk-input uk-form-width-small"></div>
                    </div>
                    <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                        <div>
                            グループ参加費(1人分):
                        </div>
                        <div>
                            <input v-model.number="calc.c_price" type="number" placeholder="例)4000" v-on:blur="complementPrice(c)" class="uk-input uk-form-width-medium"></div>

                        <div>
                            <button v-on:click="autoCalculate(c)" class="uk-button uk-button-default" uk-tooltip="title: 予約の参加費合計と一致するようにこのグループの参加費を計算します。自動計算をするには事前に他のグループ情報と、このグループの人数を入力して下さい。; pos: right">自動計算</button>
                        </div>

                    </div>

                    <hr>

                    <div class='uk-h4 uk-margin-small uk-text-right'>グループ参加費合計: {{ calc.c_total|number_format }}円</div>
                    <div class='uk-flex uk-flex-center' >                        
                        <div class='uk-padding-small uk-padding-remove-vertical'>
                            <button v-on:click="addList(c)" class="uk-button uk-button-default" uk-tooltip="title: グループを追加します。; pos: bottom"><span uk-icon="icon: plus-circle; ratio: 1.0" type="button" class="cursor_to_point"></span> 追加</button>
                        </div>
                        <div class='uk-padding-small uk-padding-remove-vertical'>
                            <button v-show="minus_circle" v-on:click="delList(c)" class="uk-button uk-button-default " uk-tooltip="title: このグループを削除します。; pos: bottom"><span uk-icon="icon: minus-circle; ratio: 1.0" type="button" class="cursor_to_point"></span> 削除</button>  
                        </div>                  
                    </div>

                </div>
            </div>

            <hr>
            <div class='uk-h4 uk-margin-small ' align="right">全グループ参加費合計: {{calc_total_price|number_format}} 円</div>
            <div class='uk-h4 uk-margin-small ' align="right">全グループ参加人数合計: {{calc_total_person|number_format}} 人</div>

            <div v-if="sub_total_price > 0">
                <div class="uk-alert-warning uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{sub_total_price|number_format}}円</font> 多く回収しています。
                    端数の場合は幹事が貰ってしまいましょう。
                </div>
            </div>
            <div v-else-if="sub_total_price < 0">
                <div class="uk-alert-danger uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{-sub_total_price|number_format}}円</font> 少なく回収しています。
                </div>
            </div>
            <div v-else>
                <div class="uk-alert-success uk-text-center" uk-alert>
                    予約上の参加費合計と全グループ参加費合計は一致しています。
                </div>
            </div>

            <div v-if="sub_total_person > 0">
                <div class="uk-alert-warning uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{sub_total_person|number_format}}人</font> 多く参加しています。
                </div>
            </div>
            <div v-else-if="sub_total_person < 0">
                <div class="uk-alert-danger uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{-sub_total_person|number_format}}人</font> 少なく参加しています。
                </div>
            </div>
            <div v-else>
                <div class="uk-alert-success uk-text-center" uk-alert>
                    予約上の参加人数と全グループ参加人数は一致しています。
                </div>
            </div>
            <textarea id="copyTarget" class="uk-textarea" v-model="outputText"  rows="5" placeholder="Textarea" readonly></textarea>
            <div class='uk-flex uk-flex-center uk-margin-small' >   
            <button v-on:click="copyResult" class="uk-button uk-button-default " uk-tooltip="title: 結果をクリップボードにコピーします; pos: bottom" ><span uk-icon="icon: copy; ratio: 1.0" type="button" class="cursor_to_point"></span> 結果をコピー</button> 
            </div>
        </div>

    </div>
    <div class="uk-flex uk-flex-center uk-margin">
        <a class="uk-button uk-button-secondary" href="#topanchor" uk-scroll>ページトップへ</a>
    </div>

    <script>
        Vue.filter('number_format', function (value) {
            return addComma(value);
        }); 
        var addComma = function(value){
             if (! value) { return 0; }
            return value.toString().replace( /([0-9]+?)(?=(?:[0-9]{3})+$)/g , '$1,' );
        }
        new Vue({
            el: "#app",
            data: {
                price: null,
                person: null,
                tax: false,
                calcs: [{c_name:"",c_price:null,c_person:null,c_total:0,c_auto:false}],
            },
            computed :{
                total_price: function(){
                    var total = this.price * this.person;
                    if (this.tax){
                        total *= 1.1;
                    }
                    return Math.round(total);
                },
                calc_total_price: function(){
                    var tmp_total = 0;
                    for (var i = 0; i < this.calcs.length; i++ ){
                        tmp_total += this.calcs[i].c_total;
                    }
                    return tmp_total;   
                },
                calc_total_person: function(){
                    var tmp_person = 0;
                    for (var i = 0; i < this.calcs.length; i++ ){
                        tmp_person += this.calcs[i].c_person;
                    }
                    return tmp_person;  
                },
                sub_total_price: function(){
                    return this.calc_total_price - this.total_price;
                },
                sub_total_person: function(){
                    return this.calc_total_person - this.person;
                },
                minus_circle: function(){
                    if (this.calcs.length > 1){
                        return true;
                    }else{
                        return false;
                    }
                },
                outputText: function(){
                    var resultText = "";
                    resultText += "◆予約情報◆\n";
                    resultText += "1人分のコース料金:" + addComma(this.price) + "\n";
                    resultText += "参加人数:" + addComma(this.person) + "\n";
                    resultText += "予約上の参加費合計:" + addComma(this.total_price) + "\n";
                    resultText += "--------------------\n";
                    resultText += "◆グループ情報◆\n";
                    for (var i = 0; i < this.calcs.length; i++ ){
                        if(this.calcs[i].c_name == ""){
                            resultText += "◇グループ" + (i+1) + "\n";
                        }else{
                            resultText += "" + this.calcs[i].c_name + "\n";
                        }
                        resultText += "参加費:" +addComma(this.calcs[i].c_price) + "";
                        resultText += "人数:" +addComma(this.calcs[i].c_person) + "";
                        resultText += "合計:" +addComma(this.calcs[i].c_total) + "\n";
                    }
                    resultText += "--------------------\n";
                    resultText += "全グループ参加費合計:" + addComma(this.calc_total_price) + "\n";
                    resultText += "全グループ参加人数合計:" + addComma(this.calc_total_person) + "\n";
                    if(this.sub_total_price > 0){
                        resultText += "※予約に対して " + addComma(this.sub_total_price) + "円 多く回収\n";
                    }else if(this.sub_total_price < 0){
                        resultText += "※予約に対して " + addComma(-this.sub_total_price) + "円 少なく回収\n";
                    }
                    if(this.sub_total_person > 0){
                        resultText += "※予約に対して " + addComma(this.sub_total_person) + "人 多く参加\n";
                    }else if(this.sub_total_person < 0){
                        resultText += "※予約に対して " + addComma(-this.sub_total_person) + "人 少なく参加\n";
                    }
                    return resultText;
                }
            },
            watch :{
                calcs: {
                    handler: function(val){
                        for (var i = 0; i < this.calcs.length; i++ ){
                            this.calcs[i].c_total = this.calcs[i].c_price * this.calcs[i].c_person;
                        }
                    },
                    deep: true
                }
            },
            methods :{
                addList: function(c){   
                    //残りの人数を計算
                    var left_person = this.person - this.calc_total_person;
                    if (left_person < 0){
                        left_person = 0;
                    }
                    this.calcs.splice(c+1,0,{c_name:"",c_price:0,c_person:left_person,c_total:0});
                },
                delList: function(c){
                    if(this.calcs.length > 1){
                        this.calcs.splice(c,1);
                    }
                },
                autoCalculate: function(target){
                    var left_person = 0;
                    var left_price = 0;
                    var conf_price = 0;
                    for (var i = 0; i < this.calcs.length; i++ ){
                        //自動計算対象外の人数と確定金額を取得
                        if (!this.calcs[i].c_person){
                            this.calcs[i].c_person = 1;
                        }
                        if (!this.calcs[i].c_price){
                            this.calcs[i].c_price = 0;
                        }
                        if (i == target){                               
                            left_person += this.calcs[i].c_person;
                        } else {
                            conf_price += this.calcs[i].c_price * this.calcs[i].c_person;
                        }
                    }
                    //残額計算
                    left_price = (this.total_price - conf_price) / left_person;
                    //空欄につめる
                    this.calcs[target].c_price = Math.round(left_price);
                },
                complementPriceReserve: function(){
                    if(!this.price){
                        this.price = 0;
                    }else if (this.price < 0){
                        this.price = 0;
                    }
                },
                complementPersonReserve: function(){
                    if(!this.person){
                        this.person = 0;
                    }else if (this.person < 0){
                        this.person = 0;
                    }
                },
                complementPerson: function(target){
                    if(!this.calcs[target].c_person){
                        this.calcs[target].c_person = 0;
                    }else if (this.calcs[target].c_person < 0){
                        this.calcs[target].c_person = 0;
                    }
                },
                complementPrice: function(target){
                    if(!this.calcs[target].c_price){
                        this.calcs[target].c_price = 0;
                    }else if (this.calcs[target].c_price < 0){
                        this.calcs[target].c_price = 0;
                    }
                },
                copyResult: function(){
                    var copyTarget = document.getElementById("copyTarget");
                    copyTarget.select();
                    document.execCommand("Copy");
                }
            }
        })

    </script>

</body>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む