コードリポジトリ

giteeで以前書いたもの
gitee本文のケーススタディ、ブランチ5_otherをダウンロードしてください

プロジェクトの作成

1
2
3
4
5
6
# 私は個人的にpnpmを使うのが好きです
npm create vite
pnpm create vite custom-vue-starter
# コマンドを入力するとオプションが表示されるので、vueを選択し、次にJavaScriptを選択します
cd custom-vue-starter
pnpm i

ディレクトリ構造

完全なフロントエンドプロジェクトには以下が必要です:

  • ステート管理
    グローバルに共有される状態(データ)を維持し、ページコンポーネント間でデータを共有します。Piniaを使用します。
  • ルーティング
    ルーティングによりページ間の移動が可能になります。Vue Routerを使用します。
  • スタイル
    スタイルによりページがより美しくなります。Tailwindcssを使用します。
  • ネットワークリクエスト
    フロントエンドは、ネットワークリクエストを通じてバックエンドとデータをやり取りし、機能を実現します。Axiosを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
├── dev-dist/               # 開発環境ビルド出力ディレクトリ
├── node_modules/ # Node.js依存パッケージディレクトリ
├── public/ # 静的リソースディレクトリ、ビルドツールで処理されません
├── src/
├── admin/ # バックエンド管理ページを格納
├── api/ # APIリクエストロジック
├── assets/ # 静的リソース(画像、フォントなど)
├── components/ # 共通コンポーネント
├── includes/ # インクルードファイル、外部ライブラリを格納
├── lib/ # プロジェクト内の共通リソースを格納
├── locales/ # 国際化言語パッケージ
├── mocks/ # モックデータ
├── pages/ # 普通のページコンポーネントを格納
├── router/ # ルーティング設定
├── store/ # ステート管理(Pinia)
├── styles/ # グローバルスタイルやCSSファイル
├── utils/ # ユーティリティ関数
├── App.vue # ルートコンポーネント
├── main.js # プロジェクトエントリーファイル
├── middleware.js # ミドルウェアロジック(例: ルートガード)
└── settings.js # プロジェクト設定や設定ファイル
├── .gitignore # Git無視ファイル設定
├── index.html # プロジェクトエントリーハTMLファイル
├── package.json # プロジェクト設定及び依存関係宣言
├── postcss.config.js # PostCSS設定ファイル
├── README.md # プロジェクト説明ドキュメント
├── tailwind.config.js # Tailwind CSS設定ファイル
├── vite.config.js # Viteビルドツール設定ファイル

設定

パスエイリアス

パスエイリアスを設定し、@/でsrc/ディレクトリを表します。

1
2
# nodeの型宣言、nodeの依存を使用後にエラーを防ぐ
pnpm i @types/node --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
// パスエイリアス
resolve: {
alias: {
'@':
join(__dirname, 'src'),
}
}
})

プロジェクト設定

1
2
3
4
5
export default {
routeMode: 'history', // ルートモード
BaseURL: 'http://localhost:4000', // バックエンドリクエストアドレス
timeout: 5000, // リクエストタイムアウト時間
}

PWA設定

1
2
# v2
pnpm i vite-plugin-pwa --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'
import {VitePWA} from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
// マニフェストファイルを生成
enabled: true
},
manifest: {
name: "vue-quick-start",
theme_color: '#ff5e3a',
icons: [
{
src: 'assets/logo.png',
size: '192x192',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,jpg}']
}
})
],
resolve: {
alias: {
'@':
join(__dirname, 'src'),
}
}
})

ロードプログレスバー

1
pnpm i nprogress

src/main.js

1
2
3
4
5
import 'nprogress/nprogress.css'
import progressBar from "./includes/progress-bar";
// プログレスバー
progressBar(router)
app.use(router)

includes/progress-bar

1
2
3
4
5
6
7
8
9
import NProgress from "nprogress";

export default (router) => {
router.beforeEach((to, from, next) => {
NProgress.start();
next();
});
router.afterEach(NProgress.done);
};

Tailwind

Tailwind3を使用します(もし4を使用する場合は、インストール方法に若干の違いがあります)。

1
2
pnpm install -D tailwindcss@3 postcss autoprefixer
pnpm dlx tailwindcss@3 init -p

tailwind.config.js

1
2
3
4
5
6
7
8
9
10
11
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

styles/base.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@tailwind base;
@layer base {
h1 {
@apply text-2xl;
}

h2 {
@apply text-xl;
}

h3 {
@apply text-lg;
}

h4 {
@apply text-base;
}

h5 {
@apply text-sm;
}

h6 {
@apply text-xs;
}
}

@tailwind components;
@tailwind utilities;
body {
@apply h-full w-full p-0 m-0;
}

/* デフォルトの最大幅制限を上書き */
@media (min-width: 1536px) {
.container {
max-width: 100%;
}
}

/* ページの主なコンテンツ */
.container {
width: 100%;
height: 100%;
background-color: #f5f7fb;
padding: 20px;

.container-wrapper {
width: 100%;
height: 100%;
padding: 15px 30px 0;
background-color: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
}

/* 印刷コントロールのスタイル設定 */
.plugin-download {
width: 500px !important;

a:hover {
text-decoration: underline;
}
}

src/main.jsにインポートすることを忘れないでください。

1
2
3
4
5
6
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'

createApp(App)
.mount('#app')

ルーティング router

Vue
Routerを使用してルーティングを行い、ファイルルーティングを実装します。ディレクトリ内のpage.vueファイルを自動的にスキャンし、ルートとして登録します。2つのページディレクトリがあります。adminとpagesです。adminディレクトリはバックエンド管理ページ、pagesディレクトリは通常のページコンポーネントです。

1
2
# v4
pnpm i vue-router@4

ファイルルーティング

ファイルルーティングはディレクトリ構造に基づき、自動的にスキャンしルートとして登録します。手動で一つ一つ宣言・登録する必要はありません。実装の鍵は以下の通りです。

1
import.meta.glob("xxx")

これはViteが提供するメソッドで、ファイルをスキャンして取得します。Webpackにも同様のメソッドがあります。

1
require.context()

私たちはViteを使用しているため、import.meta.globを使用します。ファイルスキャン機能を実装してみましょう。
pages/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/pages/index.js
// pagesディレクトリ下のすべてのpage.vueをスキャン
const pages = import.meta.glob('./**/page.vue')
// まずフラットな構造でスキャン
const routes = Object.keys(pages).map(key => {
const path = key
.replace(/^\.\//, '') // ./を削除
.replace(/\/?page\.vue$/, ''); // /page.vueを削除
return {
path: `/${path}`,
name: path,
component: () => pages[key](),
meta: {
title: path.split('/').pop() || 'ホーム'
},
hidden: false,
whiteList: true
};
});
// 親ノードを挿入
const allPaths = routes.map(route => route.path);
const parentRoutes = new Set();
allPaths.forEach(path => {
// /a/b/c -> /a/b
let currentPath = path.substring(0, path.lastIndexOf('/'));
console.log('currentPath', currentPath)
while (currentPath) {
// /a/bの親パス/aもルートに表示されるようにし、メニューレベル構造をサポート
if (!parentRoutes.has(currentPath)) {
routes.push({
// /a
path: currentPath,
// name: -a
name: currentPath.replace(/\//g, '-'),
meta: {title: currentPath.split('/').pop()}
});
parentRoutes.add(currentPath);
}
// /a/b -> /a
currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
}
});
console.log('scan routes', routes)
export default routes

app.vueで子ルートをレンダリング

1
2
3
4

<template>
<router-view/>
</template>

admin/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/admin/index.js
const pages = import.meta.glob('./**/page.vue')
// まずフラットな構造でスキャン
const routes = Object.keys(pages).map(key => {
const path = key
.replace(/^\.\//, '') // ./を削除
.replace(/\/?page\.vue$/, ''); // /page.vueを削除
// 親パスを抽出
// const parentPath = path.substring(0, path.lastIndexOf('/'));
return {
path: path, // 相対パスを使用し、children内で子ルートとしてネスト
// path: `/${path}`,
name: path,
component: () => pages[key](),
meta: {
title: path.split('/').pop() || 'ホーム'
},
hidden: false,
whiteList: true
};
});
// 親ノードを挿入
const allPaths = routes.map(route => route.path);
const parentRoutes = new Set();
allPaths.forEach(path => {
// /a/b/c -> /a/b
let currentPath = path.substring(0, path.lastIndexOf('/'));
console.log('currentPath', currentPath)
while (currentPath) {
// /a/bの親パス/aもルートに表示されるようにし、メニューレベル構造をサポート
if (!parentRoutes.has(currentPath)) {
routes.push({
// /a
path: currentPath,
// name: -a
name: currentPath.replace(/\//g, '-'),
meta: {title: currentPath.split('/').pop()}
});
parentRoutes.add(currentPath);
}
// /a/b -> /a
currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
}
});
console.log('scan admin routes', routes)
export default routes

admin/base.vue、まず子ルートを簡単にレンダリングし、後でコンポーネントの部分で管理システムの骨組みを完璧にします。

1
2
3
4

<template>
<router-view/>
</template>

これらのソースコードはほぼ同じで、原理はディレクトリをスキャンし、ネスト構造を処理し、スキャン処理が完了した後、route配列に格納し、このroute配列がルートの定義となり、全体のルートに統一して登録できます。
現在のルートのソースコードを見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/router/index.js
import {createRouter, createWebHashHistory, createWebHistory} from 'vue-router'
import routes from "@/pages/index.js";
import adminRoutes from '@/admin/index.js'
import settings from "../settings.js";

const router = createRouter({
// import.meta.env.BASE_URLはvite.config.jsのbase設定を指します
history: settings.routeMode === 'history'
? createWebHistory(import.meta.env.BASE_URL)
: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/about',
name: 'about',
component: () => import('@/pages/about/page.vue')
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/login/page.vue')
},
{
path: '/404',
name: '404',
component: () => import('@/pages/404.vue'),
beforeEnter: (to, from, next) => {
console.log('Router Guard')
console.log(to, from)
next()
}
},
// pages下のvueをスキャン
...routes,
// admin下のvueをスキャン
{
name: 'admin',
path: '/admin',
component: () => import('@/admin/base.vue'),
children: [
...adminRoutes
]
},
{
path: '/:catchAll(.*)*',
// 上記すべてに一致しない場合、404にリダイレクト
redirect: {name: '404'}
}
]
})
// 全体のルートガード
router.beforeEach((to, from, next) => {
console.log('Global Guard')
console.log(to, from)
// ルートメタ情報にtitleがあれば、document.titleを設定
if (to.meta && to.meta.title) {
document.title = to.meta.title
}
next()
})
export default router

src/main.jsでインポートし使用することを忘れないでください。

1
2
3
4
5
6
7
8
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'

createApp(App)
.use(router)
.mount('#app')

この時点で、pagesとadminの下にテストファイルを作成し、プロジェクトを起動してアクセスできます。

コンポーネント components

componentsディレクトリにコンポーネントを格納し、このディレクトリのindex.jsで自動的にスキャンし、vueインスタンスに登録し、グローバルで使用できるようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 変換ロジック、短い線をキャメルケースに変換し、先頭文字を大文字にする
function toPascalCase(str) {
return str
.split('-')
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join('')
}

export default {
install: (app) => {
// 同期で現在のディレクトリのすべての.vueファイルをスキャン
const components = import.meta.glob('./**/*.vue', {eager: true})
for (const path in components) {
const componentName = path
.replace(/^\.\//, '')
.replace(/\.\w+$/, '')
.split('/')
.map(toPascalCase)
.join('')
// コンポーネント定義を取得(同期)
const component = components[path].default
// グローバルコンポーネントとして登録
app.component(componentName, component)
}
}
}

ここでのinstallはvueプラグインのロジックで、vueインスタンスがuseされたときに自動的にinstallメソッドが呼び出されます。
src/main.jsで使用します。

1
2
3
4
5
6
7
8
9
10
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
import components from "@/components/index.js";

createApp(App)
.use(router)
.use(components)
.mount('#app')

テスト:

components/hello-world.vue

1
2
3
4

<template>
hello
</template>

components/mal/red.vue

1
2
3
4

<template>
malred
</template>

pages/page.vue

1
2
3
4
5

<template>
<hello-world/>
<mal-red/>
</template>

Element Plus

以前のadmin/base.vueは子ルートの内容を簡単にレンダリングしていました。ここでは、左側のメニュー、上部のナビゲーション、下部の著作権といった骨組みを追加し、これらの骨組みはコンポーネントとして、Element
Plusコンポーネントライブラリを使用して開発を簡略化します。

1
2
# v2
pnpm i element-plus @element-plus/icons-vue

src/main.jsで使用します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
import components from "@/components/index.js";
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
app.use(router)
// グローバルにElement+アイコンをインストール
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(components)
app.mount('#app')

レイアウトコンポーネント

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!--
* @Author Malred
* @Date 2025-06-01 22:28:33
* @Description
* @Path src/components/layout/aside/menu-item.vue
-->
<template>
<div :key="item.path">
<el-menu-item
v-if="!item.children || !item.children.length"
:index="item.path"
>
{{ item.meta.title || item.name }}
</el-menu-item>
<el-sub-menu v-else :index="item.path" :key="item.path">
<template #title>{{ item.meta.title || item.name }}</template>
<MenuItem v-for="child in item.children" :key="child.path" :item="child"/>
</el-sub-menu>
</div>
</template>
<script>
export default {
name: 'MenuItem',
props: ['item']
};
</script>
<style scoped>
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<!--
* @Author Malred
* @Date 2025-06-01 06:30:07
* @Description 左側のナビゲーション
* @Path src/components/layout/aside.vue
-->
<template>
<div class="aside-container">
<!-- ロゴ -->
<div class="logo-section">
<img src="../../assets/logo.png" alt="Logo" class="logo-img"/>
<span class="logo-title">Malred</span>
</div>
<!-- メニュー -->
<el-menu
:default-active="$route.path"
class="el-menu-vertical-demo"
background-color="#409EFF"
text-color="white"
active-text-color="#ffd04b"
:router="true"
>
<menu-item v-for="item in menuTree" :key="item.path" :item="item"/>
</el-menu>
</div>
</template>
<script>
import routes from '@/admin/index';
import {buildMenuTree} from '@/utils/menu';
import MenuItem from "./aside/menu-item.vue";

export default {
components: {
MenuItem
},
data() {
return {
// menuTree: buildMenuTree(routes)
menuTree: buildMenuTree(routes.map(item => ({
...item,
path: '/admin/' + item.path
})))
};
},
mounted() {
console.log(this.menuTree)
}
};
</script>
<style scoped>
.aside-container {
height: 100%;
}

.logo-section {
background-color: #4fa3fa;
display: flex;
align-items: center;
padding: 14px;
color: white;
}

.logo-img {
width: 32px;
height: 32px;
margin-right: 10px;
}

.logo-title {
font-size: 18px;
font-weight: bold;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!--
* @Author Malred
* @Date 2025-06-01 06:52:12
* @Description 上部のナビゲーション
* @Path src/components/layout/header.vue
-->
<template>
<div class="header-container">
<el-menu
mode="horizontal"
background-color="#4fa3fa"
text-color="white"
active-text-color="#ffd04b"
:router="true"
>
<el-menu-item index="/">ホーム</el-menu-item>
<el-sub-menu index="/about" popper-append-to-body>
<template #title>について</template>
<el-menu-item index="/about/team">チーム紹介</el-menu-item>
<el-menu-item index="/about/contact">お問い合わせ</el-menu-item>
</el-sub-menu>
<el-menu-item index="/services">サービス</el-menu-item>
</el-menu>
<!-- 右側のツールバー、margin-left: autoで最右に配置 -->
<div class="right-tools">
<translation-btn style="margin-right: 16px;"/>
<el-button circle style="margin-right: 8px;">
<el-icon>
<bell/>
</el-icon>
</el-button>
<el-button circle>
<el-icon>
<user/>
</el-icon>
</el-button>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.header-container {
width: 100%;
display: flex;
align-items: center;
}

.right-tools {
display: flex;
align-items: center;
margin-left: auto; /* キーポイント:div全体を最右に配置 */
}
</style>

admin/base.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!--
* @Author Malred · Wang
* @Date 2025-06-17 11:26:20
* @Description
* @Path src/admin/base.vue
-->
<template>
<el-container style="height: 100vh; width: 100vw;">
<!-- 左側のサイドバー -->
<el-aside width="200px" style="background-color: #409EFF; height: 100vh;">
<layout-aside/>
</el-aside>
<!-- 主なコンテンツ -->
<el-container style="flex: 1; min-width: 0;">
<!-- ヘッダー -->
<el-header style="background-color: #4fa3fa; height: 60px; display: flex; align-items: center;">
<layout-header/>
</el-header>
<!-- メインコンテンツエリア -->
<el-main style="margin: 0; padding: 0; height: calc(100vh - 60px);">
<router-view/>
</el-main>
<!-- フッター -->
<el-footer style="display: flex; justify-content: center; align-items: center;">
著作権所有 @Malred
</el-footer>
</el-container>
</el-container>
</template>
<script setup>
</script>
<style scoped>
.el-main {
--el-main-padding: 0px;
}
</style>

以下は、提供されたコードと説明を日本語に翻訳したものです。

ユーティリティ / メニュービルダー

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
* @Author Malred · Wang
* @Date 2025-06-17 12:52:12
* @Description
* @Path src/utils/menu.js
*/
export function buildMenuTree(routes) {
console.log('build menu routes', routes)
const tree = [];
const map = {};
// まずすべてをmapにマッピング
routes.forEach(route => {
map[route.path] = {...route, children: []};
});
// ツリー構造を構築
routes.forEach(route => {
const path = route.path;
const parentPath = path.substring(0, path.lastIndexOf('/'));
if (parentPath && map[parentPath]) {
map[parentPath].children.push(map[path]);
} else {
tree.push(map[path]);
}
});
return tree;
}

ベースコンポーネント

管理ページのコンポーネントもいくつか作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- base-operation-item.vue -->
<template>
<el-form-item :label="label">
<el-input
:modelValue="value"
@update:modelValue="$emit('update:value', $event)"
/>
</el-form-item>
</template>
<script setup>
import {defineProps, defineEmits} from 'vue';

defineProps({
label: String,
value: [String, Number]
});
const emit = defineEmits(['update:value']);
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!--
* @Author Malred
* @Date 2025-06-02 05:13:16
* @Description ポップアップコンポーネント
* @Path src/components/base/operation.vue
-->
<!-- Operation.vue -->
<template>
<el-dialog v-model="visible" :title="title" width="50%">
<el-form :model="formData" label-width="120px">
<slot></slot>
</el-form>
<template #footer>
<el-button @click="visible = false">キャンセル</el-button>
<el-button type="primary" @click="submit">送信</el-button>
</template>
</el-dialog>
</template>
<script setup>
import {ref} from 'vue';
// emitsを定義
const emit = defineEmits(['submit']);
// ポップアップ表示を制御
const visible = ref(false);
// 表示メソッド
const show = () => {
visible.value = true;
};
// 送信メソッド
const submit = () => {
emit('submit'); // submitイベントをトリガー
visible.value = false;
};
// 親コンポーネントから呼び出せるようにメソッドを公開
defineExpose({show});
import {defineProps} from 'vue';

defineProps({
title: String,
formData: {
type: Object,
default: () => ({})
}
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!--
* @Author Malred
* @Date 2025-06-02 06:18:53
* @Description 日付選択検索コンポーネント
* @Path src/components/base/table/search/date.vue
-->
<!-- BaseSearchDate.vue -->
<template>
<div class="search-item">
<label v-if="label">{{ label }}</label>
<el-date-picker
v-model="value"
type="date"
placeholder="日付を選択"
value-format="YYYY-MM-DD"
/>
</div>
</template>
<script setup>
import {defineProps, defineModel} from 'vue';

defineProps({
label: String
});
const value = defineModel();
</script>
<style scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
/*flex-wrap: wrap;*/
gap: 8px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!--
* @Author Malred
* @Date 2025-06-02 05:12:31
* @Description 普通の入力検索ボックス
* @Path src/components/base/table/search/item.vue
-->
<!-- SearchItem.vue -->
<template>
<div class="search-item">
<label v-if="label">{{ label }}</label>
<el-input style="width: 120px;" v-model="value" placeholder="入力してください"/>
</div>
</template>
<script setup>
import {defineProps, defineModel} from 'vue';

const props = defineProps({
label: String
});
const value = defineModel();
</script>
<style scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
/*flex-wrap: wrap;*/
gap: 8px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<!--
* @Author Malred
* @Date 2025-06-02 06:18:42
* @Description ドロップダウン選択検索ボックス
* @Path src/components/base/table/search/select.vue
-->
<!-- BaseSearchSelect.vue -->
<template>
<div class="search-item">
<label v-if="label">{{ label }}</label>
<el-select v-model="value" placeholder="選択してください">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</template>
<script setup>
import {defineProps, defineModel} from 'vue';

defineProps({
label: String,
options: {
type: Array,
required: true,
default: () => []
}
});
const value = defineModel();
</script>
<style scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
/*flex-wrap: wrap;*/
width: 120px;

label {
width: 56px;
}
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!--
* @Author Malred
* @Date 2025-06-02 05:10:43
* @Description テーブルヘッダーコンポーネント
* @Path src/components/base/table/header.vue
-->
<!-- base-table-header.vue -->
<template>
<div class="table-header">
<!-- スロットを使って任意の構造を挿入可能 -->
<div class="search-container">
<slot name="search"></slot>
</div>
<div class="actions">
<slot name="action">
<el-button type="primary" @click="$emit('add')">新規作成</el-button>
<el-button
type="danger"
@click="onDeleteSelected"
:disabled="deleteDisabled"
>
選択項目を削除
</el-button>
</slot>
</div>
</div>
</template>
<script setup>
defineProps({
deleteDisabled: Boolean,
onDeleteSelected: {
type: Function,
default: null
}
});
defineEmits(['add']);
</script>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center; /* 垂直方向の中央揃え */
margin-bottom: 12px;
margin-top: 8px;
flex-wrap: wrap;
gap: 12px;
}

.search-container {
display: flex;
align-items: center;
flex-grow: 1;
min-width: 200px;
gap: 8px;
}

.actions {
display: flex;
gap: 8px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!--
* @Author Malred
* @Date 2025-06-02 05:11:31
* @Description テーブルボディーコンポーネント
* @Path src/components/base/table/body.vue
-->
<template>
<el-table :data="data" border style="width: 100%" @selection-change="handleSelectionChange">
<!-- 多選列 -->
<el-table-column type="selection" width="55"></el-table-column>
<!-- 動的列 -->
<el-table-column
v-for="(col, index) in columns"
:key="index"
:prop="col.prop"
:label="col.label"
:width="col.width"
>
<template #default="scope">
<slot :name="'col-' + col.prop" :row="scope.row">
{{ scope.row[col.prop] }}
</slot>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="150">
<template #default="scope">
<slot name="operation" :row="scope.row">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">編集</el-button>
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">削除</el-button>
</slot>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import {defineProps, defineEmits, ref} from 'vue';

const props = defineProps({
data: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
onDelete: { // 削除メソッドを受け取るprop
type: Function,
default: null
}
});
const emit = defineEmits(['edit', 'delete', 'selection-change']);
const selectedRows = ref([]);
const handleSelectionChange = (rows) => {
selectedRows.value = rows;
emit('selection-change', rows);
};
const handleEdit = (row) => {
emit('edit', row);
};
const handleDelete = (row) => {
if (props.onDelete) {
props.onDelete([row]);
} else {
emit('delete', [row]);
}
};
</script>

テスト: admin/test/page.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
<!--
* @Author Malred · Wang
* @Date 2025-06-17 13:27:45
* @Description テーブルコンポーネントのテスト
* @Path src/admin/test/page.vue
-->
<template>
<div class="container">
<div class="container-wrapper space-y-4">
<base-table-header
:delete-disabled="selectedRows.length === 0"
@add="openAddDialog"
:onDeleteSelected="handleDeleteSelected"
>
<template #search>
<!-- テキスト入力 -->
<base-table-search-item label="ユーザー名" v-model="searchForm.username"/>
<!-- メール入力 -->
<base-table-search-item label="メール" v-model="searchForm.email"/>
<!-- ID入力 -->
<base-table-search-item label="ID" v-model="searchForm.id"/>
<!-- ステータス選択 -->
<base-table-search-select
label="ステータス"
v-model="searchForm.status"
:options="[
{ label: '有効', value: 'active' },
{ label: '無効', value: 'inactive' }
]"
/>
<!-- 作成日時 -->
<base-table-search-date label="作成日時" v-model="searchForm.createTime"/>
<!-- リセットボタン -->
<el-button type="info" size="small" @click="resetSearchForm">リセット</el-button>
</template>
</base-table-header>
<base-table-body
:data="tableData"
:columns="columns"
@selection-change="handleSelectionChange"
@edit="openEditDialog"
@delete="handleDelete"
:onDelete="handleDelete"
>
<!-- カスタム操作列 -->
<template #operation="props">
<el-button link type="primary" size="small" @click="openEditDialog(props.row)">編集</el-button>
<el-button link type="danger" size="small" @click="handleDelete([props.row])">削除</el-button>
</template>
</base-table-body>
<!-- ポップアップ部分 -->
<base-operation ref="operationDialog" title="ユーザー情報" @submit="submitForm">
<base-operation-item
label="ユーザー名"
:value="formData.username"
@update:value="val => formData.username = val"
/>
<base-operation-item
label="メール"
:value="formData.email"
@update:value="val => formData.email = val"
/>
</base-operation>
</div>
</div>
</template>
<script setup>
import {ref, watch} from 'vue';
// データソース
const originalData = [
{id: 1, username: 'admin', email: 'admin@example.com', status: 'active', createTime: '2025-06-01'},
{id: 2, username: 'user1', email: 'user1@example.com', status: 'inactive', createTime: '2025-06-02'},
{id: 3, username: 'test', email: 'test@example.com', status: 'active', createTime: '2025-06-03'}
];
const tableData = ref([...originalData]);
// テーブル列設定
const columns = [
{prop: 'id', label: 'ID'},
{prop: 'username', label: 'ユーザー名'},
{prop: 'email', label: 'メール'},
{prop: 'status', label: 'ステータス'},
{prop: 'createTime', label: '作成日時'},
];
// 検索フォーム
const searchForm = ref({
username: '',
email: '',
id: '',
status: '',
createTime: ''
});
// 検索フォームをリセット
const resetSearchForm = () => {
searchForm.value = {
username: '',
email: '',
id: '',
status: '',
createTime: ''
};
};
// 新規/編集フォームデータ
const formData = ref({
username: '',
email: '',
});
const operationDialog = ref(null);
// 新規ポップアップを開く
const openAddDialog = () => {
formData.value = {username: '', email: ''};
operationDialog.value.show();
};
// 編集ポップアップを開く
const openEditDialog = (row) => {
formData.value = {...row};
operationDialog.value.show();
};
// 新規/編集を送信
const submitForm = () => {
if (!formData.value.id) {
// 新規
const newRow = {
id: Date.now(),
username: formData.value.username,
email: formData.value.email
};
tableData.value.unshift(newRow);
} else {
// 編集
const index = tableData.value.findIndex(item => item.id === formData.value.id);
if (index > -1) {
tableData.value.splice(index, 1, {...formData.value});
}
}
};
// 複数選択行
const selectedRows = ref([]);
const handleSelectionChange = (rows) => {
selectedRows.value = rows;
};
// 削除
const handleDelete = (rows) => {
tableData.value = tableData.value.filter(row =>
!rows.some(r => r.id === row.id)
);
};
// 一括削除
const handleDeleteSelected = () => {
if (selectedRows.value.length === 0) return;
handleDelete(selectedRows.value);
};

function applySearch(data, search) {
const {username, email, id, status, createTime} = search;
return data.filter(item => {
return (
(!username || item.username.toLowerCase().includes(username.toLowerCase())) &&
(!email || item.email.toLowerCase().includes(email.toLowerCase())) &&
(!id || item.id.toString().includes(id.toString())) &&
(!status || item.status === status) &&
(!createTime || item.createTime === createTime)
);
});
}

// 検索の変化を監視
watch(
() => searchForm.value,
(newVal) => {
tableData.value = applySearch(originalData, newVal);
},
{deep: true}
);
</script>
<style scoped>
.search-form {
display: flex;
align-items: center; /* 垂直方向の中央揃え */
gap: 12px; /* 項目間の間隔を増やす */
}
</style>

i18n切り替えボタン

i18nは国際化の一般的なライブラリです。

1
2
# v9
pnpm i vue-i18n

src/includes/i18n.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {createI18n} from "vue-i18n";

export const messages = {};
// localesディレクトリをスキャンし、i18nの国際化言語パッケージを登録
const modules = import.meta.glob('../locales/*.json'); // 現在のディレクトリのjsonファイルのみを読み取る
for (const path in modules) {
const key = path.match(/\.\.\/locales\/([^.]+)\.json$/)?.[1];
if (key) {
const module = await modules[path]();
messages[key] = module.default; // .defaultを追加
}
console.log(key) // en
console.log(path)
}
console.log('messages', messages)
export default createI18n({
legacy: false, // Vue 3 Composition APIモード
locale: 'cn', // デフォルト言語
fallbackLocale: 'cn',
messages,
});

main.js

1
2
3
import i18n from "@/includes/i18n.js";

app.use(i18n)

src/locales/cn.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"error": {
"nofound": "ページが見つかりません!"
},
"greet": "こんにちは",
"info": {
"translate": {
"btn": {
"cn": "中国語",
"en": "英語"
}
}
}
}

src/locales/en.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"error": {
"notFound": "404 NOT FOUND!"
},
"greet": "hello",
"info": {
"translate": {
"btn": {
"cn": "chinese",
"en": "english"
}
}
}
}

言語切り替えコンポーネント src/components/translation-btn.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

<template>
<el-dropdown trigger="click" @command="handleSelect">
<el-button circle style="background-color: transparent;">
<img src="../assets/icons/components/translation.svg" alt="Language" style="width: 20px; height: 20px;"/>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="option in options" :key="option.key" :command="option.key">
{{ option.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'

const {t, locale} = useI18n()
// 動的に言語オプションを生成
const options = computed(() => [
{
label: t('info.translate.btn.en'),
key: 'en'
},
{
label: t('info.translate.btn.cn'),
key: 'cn'
}
])
// 言語を切り替え
const handleSelect = (key) => {
locale.value = String(key)
}
</script>
<style scoped>
/* オプション:ボタンスタイルを調整 */
.el-button {
border: none;
padding: 0;
width: 36px;
height: 36px;
}
</style>

共通メソッド utils

utilsフォールダに機能関数を格納し、utilsフォールダ内のjsファイルをスキャンし、それをvueインスタンスにグローバルで使用できるようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
// index.js
const modules = import.meta.glob('./*.js'); // 注意:現在のディレクトリのjsファイルのみを読み取る、再帰しない
const utils = {};
for (const path in modules) {
const moduleName = path.replace('./', '').replace('.js', '');
const module = await modules[path](); // 対応するパスのモジュールを取得
utils[moduleName] = module.default; // デフォルトエクスポートを抽出
}
export default {
install: (app) => {
app.config.globalProperties.$utils = utils;
},
};

vue3インスタンスを取得する方法

1
2
3
4
5
6
7
// vue-instance.js
import {getCurrentInstance} from "vue"

export const instance = () => getCurrentInstance()
.appContext.config.globalProperties
export const api = () => instance().$api
export const utils = () => instance().$utils
1
2
3
4
// main.js
import utils from '@/utils/index.js'

app.use(utils)

テスト

1
2
3
4
// utils/test.js
export default {
test: () => console.log('test')
}
1
2
3
4
5
6
<!--app.vue-->
<script setup>
import {utils} from "./utils/vue-instance.js";

utils().test.test()
</script>

ステート管理 store

以前vueを学んだとき、元のコンポーネント間の値の伝達は非常に面倒な場合があることがわかります。例えば、兄弟/跨階層の値の伝達など。これを解決するために、グローバルな状態管理のソリューションを使用できます。ここではpiniaを使用します。

1
2
# v3
pnpm i pinia
1
2
3
4
5
6
7
8
9
10
// index.js
import {createPinia} from 'pinia'
import {useUserStore} from './user-store.js'
// すべてのstoreを登録(オプション)
// ここではすべてのstoreが読み込まれるようにするためだけ
export {
useUserStore
}
// main.jsで使用するためのpiniaインスタンスをエクスポート
export default createPinia()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// user-store.js
import {defineStore, acceptHMRUpdate} from 'pinia'

export const useUserStore = defineStore('user', {
state: () => ({
name: '張三',
age: 25
}),
// getters: {},
actions: {
setName(newName) {
this.name = newName
}
}
})
// piniaのホットリロードをサポートし、ブラウザをリロードする必要がない
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
}
1
2
3
4
// main.js
import store from "@/store/index.js";

app.use(store)

テスト

1
2
3
4
5
6
7
8
9
10
11

<template>
<div>
<div @click="userStore.setName('malred')">{{ userStore.name }}</div>
</div>
</template>
<script setup>
import {useUserStore} from "../store/index.js";

const userStore = useUserStore()
</script>

ネットワークリクエスト API

Axios
を使用してネットワークリクエストを行います。通常、フロントエンドとバックエンドは別々のチームで開発されます。フロントエンドのリクエストインターフェースアドレスはバックエンドが提供します。もしバックエンドのインターフェースがまだ存在しない場合は、自分でダミーデータを使用するか、json-server
を使用してテストをシミュレートする必要があります。

1
2
# v1
pnpm i axios

ラップ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// request.js
import axios from 'axios'
import settings from "../settings.js";

const request = axios.create({
baseURL: settings.BaseURL, // ベースURLを設定
timeout: settings.timeout, // リクエストタイムアウト時間
})
// リクエストインターセプター
request.interceptors.request.use(
(config) => {
// リクエストを送信する前に何かを行う、例えばトークンを追加する
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => {
// リクエストエラーに対して何かを行う
return Promise.reject(error)
}
)
// レスポンスインターセプター
request.interceptors.response.use(
(response) => {
// レスポンスデータに対して処理を行う
return response.data
},
(error) => {
// レスポンスエラーに対して処理を行う
return Promise.reject(error)
}
)
export default request

Vue インスタンスにスキャンして登録

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// index.js
const modules = import.meta.glob('./**/index.js') // index.js ファイルのみをスキャン
const requests = {}
for (const path in modules) {
if (path === './index.js') continue // 自身をスキップ
const modulePath = path
.replace('./', '')
.replace('/index.js', '') // 前後の接頭辞と接尾辞を削除
const segments = modulePath.split('/') // パスの階層を分割
// モジュール名を安全に取得:もし単一のディレクトリ(例:admin/index)なら、最初のセグメントを直接取得
const moduleName = segments[segments.length - 1]
console.log('api', moduleName)
console.log('api', segments)
let currentLevel = requests
// ネスト構造を構築
// for (let i = 0; i < segments.length - 1; i++) {
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
currentLevel[segment] = currentLevel[segment] || {}
currentLevel = currentLevel[segment]
}
// モジュールをロードして対応する位置にマウント
// await を追加しないと、これらの関数が空になる
await modules[path]().then(module => {
console.log('currentLevel', currentLevel)
console.log('moduleName', moduleName)
// currentLevel[moduleName] = module.default
// その後、currentLevel に直接代入(moduleName を使用しない)
Object.assign(currentLevel, module.default)
// currentLevel = module.default
})
}
export default {
install: (app) => {
// app.config.globalProperties.$requests = requests
app.config.globalProperties.$api = requests
},
}

main.js に追加

1
2
3
4
// main.js
import api from '@/api/index.js'

app.use(api)

テスト

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// api/admin/index.js
import request from "../request.js";

export default {
// 全てをクエリ
queryAdmin(body) {
return request({url: '/api/admin', method: 'get', data: '', params: body})
},
// ID に基づいて単一をクエリ
queryAdminDetail(id) {
return request({url: '/api/admin' + id, method: 'get', data: '', params: ''})
},
// 追加
addAdmin(body) {
return request({url: '/api/admin', method: 'post', data: body, params: ''})
},
// 編集
editAdmin(body, id) {
return request({url: '/api/admin' + id, method: 'put', data: body, params: ''})
},
// 削除
deleteAdmin(ids) {
return request({url: '/api/admin' + ids.join(','), method: 'delete', data: '', params: ''})
},
}
1
2
3
4
5
6

<script setup>
import {api} from "./utils/vue-instance.js";

api().admin.queryAdmin()
</script>

次のステップ

  • json-server でインターフェースをシミュレート
  • コードジェネレーター、繰り返しのページとコードを一括生成

コミュニティ

以下のプラットフォームで私に連絡できます:


本站总访问量