chore: vue-cli to vite

This commit is contained in:
KarolChang 2022-03-29 09:55:10 +08:00
parent d89915f826
commit 3a335f578a
82 changed files with 3872 additions and 29799 deletions

View File

@ -1,5 +0,0 @@
{
"projects": {
"default": "jm-expense-2022"
}
}

View File

@ -5,8 +5,10 @@
<!-- <meta http-equiv="X-UA-Compatible" content="IE=edge"> -->
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
<!-- <link rel="icon" href="<%= BASE_URL %>favicon.ico" /> -->
<link rel="icon" href="/favicon.ico" />
<!-- <title><%= htmlWebpackPlugin.options.title %></title> -->
<title>JM Expense</title>
<script src="https://kit.fontawesome.com/ccfd93e9a7.js" crossorigin="anonymous"></script>
<script>
sessionStorage.redirect = location.href;
@ -15,12 +17,17 @@
</head>
<body>
<noscript>
<strong
<!-- <strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please
enable it to continue.</strong
> -->
<strong
>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -21,6 +21,7 @@ git commit -m 'deploy'
# if you are deploying to https://<USERNAME>.Github.io/<REPO>
# git push -f https://github.com/<USERNAME>/<REPO>.git master:gh-pages
git push origin master
git push -f https://github.com/KarolChang/jm-expense-vue-ts.git master:gh-pages
cd -

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -1,10 +0,0 @@
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
]
}
}

View File

@ -5,8 +5,10 @@
<!-- <meta http-equiv="X-UA-Compatible" content="IE=edge"> -->
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
<!-- <link rel="icon" href="<%= BASE_URL %>favicon.ico" /> -->
<link rel="icon" href="/favicon.ico" />
<!-- <title><%= htmlWebpackPlugin.options.title %></title> -->
<title>JM Expense</title>
<script src="https://kit.fontawesome.com/ccfd93e9a7.js" crossorigin="anonymous"></script>
</head>
<body>
@ -20,12 +22,17 @@
})()
</script>
<noscript>
<strong
<!-- <strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please
enable it to continue.</strong
> -->
<strong
>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

29174
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,34 +3,38 @@
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src"
},
"dependencies": {
"@vueform/multiselect": "^2.3.1",
"axios": "^0.24.0",
"bootstrap": "^5.1.3",
"bootswatch": "^5.1.3",
"core-js": "^3.6.5",
"dayjs": "^1.10.7",
"dotenv": "^10.0.0",
"esbuild": "^0.14.28",
"esbuild-darwin-64": "^0.14.28",
"firebase": "^9.6.3",
"luxon": "^1.0.0",
"pinia": "^2.0.6",
"sweetalert2": "^11.3.0",
"vue": "^3.2.27",
"vue": "^3.2.29",
"vue-class-component": "^8.0.0-0",
"vue-router": "^4.0.0-0",
"vuefire": "^2.2.5"
"vue3-date-time-picker": "^2.4.4",
"weekstart": "^1.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^1.6.1",
"vite": "^2.5.4",
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-typescript": "^7.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0",
"typescript": "~4.1.5"

View File

@ -1,25 +1,28 @@
<script setup lang="ts">
import Navbar from '@/components/Navbar.vue'
import { onLoad } from './cocos/config'
import { useRoute } from 'vue-router'
const route = useRoute()
// created
onLoad()
if (import.meta.env.NODE_ENV === 'production') {
onLoad()
}
</script>
<template>
<div>
<Navbar>
<template #main>
<router-view v-slot="{ Component }">
<transition name="fade-fast" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</template>
</Navbar>
</div>
<!-- <div> -->
<Navbar>
<template #main>
<router-view v-slot="{ Component }">
<transition name="fade-fast" mode="out-in">
<component :is="Component" :key="route.fullPath" />
</transition>
</router-view>
</template>
</Navbar>
<!-- </div> -->
</template>
<style>
@import url('https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;700&display=swap');
@ -40,6 +43,19 @@ body {
font-family: 'Zen Maru Gothic', sans-serif;
}
table {
display: block;
line-height: 25px;
}
.color-orange {
color: coral;
}
.bg-orange {
background-color: coral;
}
/* transition */
.slide-x-enter-active {
transition: transform 0.3s ease-out;
@ -101,4 +117,11 @@ body {
.slide-right-leave-to {
transform: translateX(100%);
}
th,
td,
label {
white-space: nowrap;
}
</style>
<!-- <style src="@vueform/multiselect/themes/default.css"></style> -->

View File

@ -1,6 +1,5 @@
import { apiHelper } from './helper'
import { ExpenseInput } from '../models/Expense'
import { CategoryInput } from '../models/Category'
import { ExpenseInput, CategoryInput } from '@/models'
export default {
expense: {
@ -12,6 +11,12 @@ export default {
},
create(data: ExpenseInput) {
return apiHelper.post('/expense/create', data)
},
edit(id: number, data: ExpenseInput) {
return apiHelper.put(`/expense/edit/${id}`, data)
},
delete(id: number) {
return apiHelper.delete(`/expense/delete/${id}`)
}
},
category: {

View File

@ -5,10 +5,10 @@ export const apiHelper = axios.create({
baseURL
})
// const baseURL_Line = 'https://linebot20220114.herokuapp.com/'
// export const apiHelperLine = axios.create({
// baseURL: baseURL_Line
// })
const baseURL_Line = 'http://linebot20220114.herokuapp.com'
export const apiHelperLine = axios.create({
baseURL: baseURL_Line
})
export interface Message {
type: string

7
src/apis/lineBot.ts Normal file
View File

@ -0,0 +1,7 @@
import { apiHelperLine, LineInput } from './helper'
export default {
push(data: LineInput) {
return apiHelperLine.post('/push', data)
}
}

View File

@ -1,5 +1,5 @@
import { apiHelper, LineInput } from './helper'
import { RecordInput } from '../models/Record'
import { RecordInput } from '@/models'
export default {
getAll() {
@ -14,13 +14,13 @@ export default {
edit(id: number, data: RecordInput) {
return apiHelper.put(`/record/edit/${id}`, data)
},
close(data: { records: string; totalAmount: number; recorder: string }) {
delete(id: number) {
return apiHelper.delete(`/record/delete/${id}`)
},
close(data: { records: string; totalAmount: number; UserId: number }) {
return apiHelper.put('/close', data)
},
getLogs() {
return apiHelper.get('/log/all')
},
pushLineMsg(data: LineInput) {
return apiHelper.post('/lineBot/push', data)
}
}

53
src/apis/user.ts Normal file
View File

@ -0,0 +1,53 @@
import { apiHelper } from './helper'
import { FirebaseUserInput, UserCreateInput, UserEditInput, RoleInput, PermissionInput } from '@/models'
export default {
user: {
firebase_email_register(data: FirebaseUserInput) {
return apiHelper.post('/user/register', data)
},
create(data: UserCreateInput) {
return apiHelper.post('/user/create', data)
},
edit(id: number, data: UserEditInput) {
return apiHelper.put(`/user/edit/${id}`, data)
},
getAll() {
return apiHelper.get('/user/all')
},
getUserByEmail(email: string) {
return apiHelper.get(`/user/${email}`)
}
},
role: {
getAll() {
return apiHelper.get('/role/all')
},
getOne(id: number) {
return apiHelper.get(`/role/${id}`)
},
create(data: RoleInput) {
return apiHelper.post('/role/create', data)
},
edit(id: number, data: RoleInput) {
return apiHelper.put(`/role/edit/${id}`, data)
},
delete(id: number) {
return apiHelper.delete(`/role/delete/${id}`)
}
},
permission: {
getAll() {
return apiHelper.get('/permission/all')
},
create(data: PermissionInput) {
return apiHelper.post('/permission/create', data)
},
edit(id: number, data: PermissionInput) {
return apiHelper.put(`/permission/edit/${id}`, data)
},
delete(id: number) {
return apiHelper.delete(`/permission/delete/${id}`)
}
}
}

BIN
src/assets/logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
src/assets/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -9,10 +9,10 @@ export const onLoad = async function () {
URLscheme[strs[i].split('=')[0]] = unescape(strs[i].split('=')[1])
}
}
CallParent('_alert', '我愛豬涵')
// CallParent('_alert', '我愛豬涵')
}
const CallParent = async function (method: string, ...param: any[]) {
export const CallParent = async function (method: string, ...param: any[]) {
const target: string = URLscheme['host']
if (target) {
window.parent.postMessage(

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { inject, Ref } from 'vue'
import { formatDate } from '@/utils/dateFormat'
import { DateFilterData } from '@/definition/interface'
// inject
const dateFilterData = inject<Ref<DateFilterData>>('dateFilterData')!
// methods
const format = (date: Date) => {
return formatDate(date)
}
const setStartDate = (date: Date) => {
if (date === null) {
dateFilterData.value.filter.startDate = ''
} else {
dateFilterData.value.filter.startDate = formatDate(date)
}
}
const setFinishDate = (date: Date) => {
if (date === null) {
dateFilterData.value.filter.finishDate = ''
} else {
dateFilterData.value.filter.finishDate = formatDate(date)
}
}
</script>
<template>
<div class="d-flex m-2 ms-3">
<div class="form-check me-3">
<input
class="form-check-input"
type="radio"
name="flexRadioDefault"
id="flexRadioDefault1"
@click="dateFilterData.searchMode = '月份'"
checked
/>
<label class="form-check-label" for="flexRadioDefault1">月份搜尋</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="flexRadioDefault"
id="flexRadioDefault2"
@click="dateFilterData.searchMode = '日期'"
/>
<label class="form-check-label" for="flexRadioDefault2">日期搜尋</label>
</div>
</div>
<div class="d-flex mb-3" v-if="dateFilterData.searchMode === '月份'">
<div class="ms-3 my-auto">
<select class="form-select" aria-label="Default select example" v-model="dateFilterData.filter.year">
<option v-for="n in 100" :key="n" :value="n + 2020">{{ n + 2020 }}</option>
</select>
</div>
<div class="ms-3 my-auto">
<select class="form-select" aria-label="Default select example" v-model="dateFilterData.filter.month">
<option v-for="n in 12" :key="n" :value="n">{{ n }}</option>
</select>
</div>
</div>
<div v-else class="d-flex mb-3">
<div class="ms-3 my-auto">
<Datepicker
:modelValue="dateFilterData.filter.startDate"
@update:modelValue="setStartDate"
:format="format"
:previewFormat="format"
:enableTimePicker="false"
autoApply
/>
</div>
<div class="mx-2 my-auto"></div>
<div class="my-auto">
<Datepicker
:modelValue="dateFilterData.filter.finishDate"
@update:modelValue="setFinishDate"
:format="format"
:previewFormat="format"
:enableTimePicker="false"
autoApply
/>
</div>
</div>
</template>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, inject, Ref, watch } from 'vue'
import { Category } from '@/models'
// data
const allChosen = ref<boolean>(true)
// props
const props = defineProps<{
allCategories: number[]
}>()
// inject
const categoriesByType = inject<Ref<Category[]>>('categoriesByType')!
const categoryFilters = inject<Ref<number[]>>('categoryFilters')!
// methods
const handleCheckboxClick = (categoryId: number) => {
if (!categoryFilters.value.includes(categoryId)) {
categoryFilters.value.push(categoryId)
} else {
categoryFilters.value.forEach((id: number, index) => {
if (categoryId === id) {
categoryFilters.value.splice(index, 1)
return
}
})
}
}
// watch
watch(allChosen, () => {
if (allChosen.value) {
categoryFilters.value = props.allCategories
} else {
categoryFilters.value = []
}
})
</script>
<template>
<div class="bg-secondary me-3 p-3">
<div class="form-check form-switch mb-4">
<label class="form-check-label" for="flexSwitchCheckChecked">全選</label>
<input
class="form-check-input"
type="checkbox"
role="switch"
id="flexSwitchCheckChecked"
v-model="allChosen"
/>
</div>
<div class="form-check mb-2" v-for="(category, index) in categoriesByType" :key="index">
<input
class="form-check-input"
type="checkbox"
:value="category.id"
:id="category.name"
:checked="categoryFilters.includes(category.id)"
@click="handleCheckboxClick(category.id)"
/>
<label class="form-check-label" :for="category.name">{{ category.name }}</label>
</div>
</div>
</template>

View File

@ -0,0 +1,148 @@
<script setup lang="ts">
import { ref } from 'vue'
interface GuessLog_AB {
number: number[]
status: string
}
// variable
const nums: number[] = Array.from({ length: 10 }, (_, i) => i)
const answer: number[] = []
for (let i = 0; i < 4; i++) {
const index = Math.floor(Math.random() * nums.length)
answer.push(nums[index])
nums.splice(index, 1)
}
console.log('answer', answer)
// data
const nowGuessNumberArr = ref<number[]>([])
const guessedNumber = ref<GuessLog_AB[]>([])
const isWinning = ref<boolean>(false)
// methods
const numClick = (num: number) => {
if (nowGuessNumberArr.value.length >= 4) {
return alert('數字已滿!')
}
if (nowGuessNumberArr.value.includes(num)) {
return alert('數字重複!')
}
nowGuessNumberArr.value.push(num)
}
const enterClick = () => {
console.log('enterClick')
if (nowGuessNumberArr.value.length !== 4) {
return alert('數字未達四位!')
}
let ACount = 0
let BCount = 0
nowGuessNumberArr.value.forEach((num, i) => {
if (num === answer[i]) {
ACount += 1
} else if (answer.includes(num)) {
BCount += 1
}
})
guessedNumber.value.push({
number: nowGuessNumberArr.value,
status: `${ACount ? ACount + 'A' : ''}${BCount ? BCount + 'B' : ''}`
})
//
if (ACount === 4) {
isWinning.value = true
return alert(`恭喜答對!你真幸運~\n總次數:${guessedNumber.value.length}`)
}
//
nowGuessNumberArr.value = []
}
const resetClick = () => {
nowGuessNumberArr.value = []
guessedNumber.value = []
isWinning.value = false
}
</script>
<template>
<div class="container" v-if="!isWinning">
<div class="row">
<div class="col col-sm-6">
<div class="d-flex justify-content-center mb-3">
<div class="num-block">{{ nowGuessNumberArr[0] }}</div>
<div class="num-block">{{ nowGuessNumberArr[1] }}</div>
<div class="num-block">{{ nowGuessNumberArr[2] }}</div>
<div class="num-block">{{ nowGuessNumberArr[3] }}</div>
</div>
<div class="d-flex flex-wrap justify-content-center">
<div v-for="index in 10" :key="index" @click="numClick(index - 1)" class="num-btn">
{{ index - 1 }}
</div>
</div>
<div class="d-flex justify-content-center mb-3">
<div class="btn-customer reset-btn" v-if="isWinning" @click="resetClick">RESET</div>
<template v-else>
<div class="btn-customer back-btn col-6" @click="nowGuessNumberArr.pop()">BACK</div>
<div class="btn-customer enter-btn col-6" @click="enterClick">ENTER</div>
</template>
</div>
</div>
<div class="col col-sm-6">
<h4 class="text-center">GUESS RECORDS</h4>
<ul class="list-group">
<li class="list-group-item mx-auto" v-for="(obj, index) in guessedNumber" :key="index">
<div class="d-flex align-items-center">
<div :class="'num-block bg-' + obj.status[num]" v-for="num in obj.number" :key="num">{{ num }}</div>
<h3 class="ms-2">{{ obj.status }}</h3>
</div>
</li>
</ul>
</div>
</div>
</div>
<img v-else src="https://i.imgur.com/OmkEpSv.jpg" alt="" class="img-fluid" @click="resetClick" />
</template>
<style scoped>
.num-block {
height: 50px;
width: 50px;
background-color: #d3d3d3;
border: 2px solid black;
border-radius: 8%;
margin: 0 5px;
text-align: center;
font-size: 28px;
}
.num-btn {
width: 15%;
background-color: white;
border: 2px solid black;
border-radius: 8%;
margin: 5px;
text-align: center;
font-size: 28px;
cursor: pointer;
}
.btn-customer {
width: 35%;
border: 2px solid black;
border-radius: 5%;
margin: 5px 5px;
text-align: center;
font-size: 20px;
cursor: pointer;
color: black;
}
.back-btn {
background-color: yellow;
}
.enter-btn {
background-color: greenyellow;
}
.reset-btn {
background-color: darkturquoise;
}
</style>

View File

@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref } from 'vue'
type StatusColor = 'success' | 'warning' | 'secondary' | undefined
interface GuessLog {
number: number[]
status: StatusColor[]
}
// variable
const nums: number[] = Array.from({ length: 10 }, (_, i) => i)
const answer: number[] = []
for (let i = 0; i < 4; i++) {
const index = Math.floor(Math.random() * nums.length)
answer.push(nums[index])
nums.splice(index, 1)
}
console.log('answer', answer)
// data
const nowGuessNumberArr = ref<number[]>([])
const guessedNumberStatus = ref<StatusColor[]>(Array(10))
const guessedNumber = ref<GuessLog[]>([])
const isWinning = ref<boolean>(false)
// methods
const numClick = (num: number) => {
if (nowGuessNumberArr.value.length >= 4) {
return alert('數字已滿!')
}
if (nowGuessNumberArr.value.includes(num)) {
return alert('數字重複!')
}
nowGuessNumberArr.value.push(num)
}
const enterClick = () => {
console.log('enterClick')
if (nowGuessNumberArr.value.length !== 4) {
return alert('數字未達四位!')
}
let successCount = 0
nowGuessNumberArr.value.forEach((num, i) => {
if (num === answer[i]) {
guessedNumberStatus.value[num] = 'success'
successCount += 1
} else if (answer.includes(num)) {
guessedNumberStatus.value[num] = 'warning'
} else {
guessedNumberStatus.value[num] = 'secondary'
}
})
guessedNumber.value.push({
number: nowGuessNumberArr.value,
// status: guessedNumberStatus.value
status: [...guessedNumberStatus.value]
})
//
if (successCount === 4) {
isWinning.value = true
return alert(`恭喜答對!你真幸運~\n總次數:${guessedNumber.value.length}`)
}
//
nowGuessNumberArr.value = []
}
const resetClick = () => {
nowGuessNumberArr.value = []
guessedNumber.value = []
guessedNumberStatus.value = Array(10)
isWinning.value = false
}
</script>
<template>
<div class="container" v-if="!isWinning">
<div class="row">
<div class="col col-sm-6">
<div class="d-flex justify-content-center mb-3">
<div class="num-block">{{ nowGuessNumberArr[0] }}</div>
<div class="num-block">{{ nowGuessNumberArr[1] }}</div>
<div class="num-block">{{ nowGuessNumberArr[2] }}</div>
<div class="num-block">{{ nowGuessNumberArr[3] }}</div>
</div>
<div class="d-flex flex-wrap justify-content-center">
<div
v-for="(status, index) in guessedNumberStatus"
:key="index"
@click="numClick(index)"
:class="`num-btn bg-${status ? status : 'light'}`"
>
{{ index }}
</div>
</div>
<div class="d-flex justify-content-center mb-3">
<div class="btn-customer reset-btn" v-if="isWinning" @click="resetClick">RESET</div>
<template v-else>
<div class="btn-customer back-btn col-6" @click="nowGuessNumberArr.pop()">BACK</div>
<div class="btn-customer enter-btn col-6" @click="enterClick">ENTER</div>
</template>
</div>
</div>
<div class="col col-sm-6">
<h4 class="text-center">GUESS RECORDS</h4>
<ul class="list-group">
<li class="list-group-item mx-auto" v-for="(obj, index) in guessedNumber" :key="index">
<div class="d-flex align-items-center">
<div :class="'num-block bg-' + obj.status[num]" v-for="num in obj.number" :key="num">{{ num }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<img v-else src="https://i.imgur.com/OmkEpSv.jpg" alt="" class="img-fluid" @click="resetClick" />
</template>
<style scoped>
.num-block {
height: 50px;
width: 50px;
background-color: #d3d3d3;
border: 2px solid black;
border-radius: 8%;
margin: 0 5px;
text-align: center;
font-size: 28px;
}
.num-btn {
width: 15%;
background-color: white;
border: 2px solid black;
border-radius: 8%;
margin: 5px;
text-align: center;
font-size: 28px;
cursor: pointer;
}
.btn-customer {
width: 35%;
border: 2px solid black;
border-radius: 5%;
margin: 5px 5px;
text-align: center;
font-size: 20px;
cursor: pointer;
color: black;
}
.back-btn {
background-color: yellow;
}
.enter-btn {
background-color: greenyellow;
}
.reset-btn {
background-color: darkturquoise;
}
</style>

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import { inject } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '../../utils/swal'
import expenseAPI from '../../apis/expense'
import { CategoryInput } from '../../models/Category'
import { Toast, ConfirmBox } from '@/utils/swal'
import expenseAPI from '@/apis/expense'
import { CategoryInput } from '@/models'
// props
const props = defineProps<{
refetch: () => {}
}>()
// inject
const refetchCategories = inject<() => {}>('refetchCategories')!
// methods
const btnClick = async () => {
@ -15,6 +14,17 @@ const btnClick = async () => {
const { value: formValues } = await ConfirmBox.fire({
title: '新增類別',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-name" class="col-form-label">種類</label>
</div>
<div class="col-auto my-auto">
<input class="form-check-input" type="radio" name="type" id="swal-expense" value="支出" checked>
<label class="form-check-label me-3" for="swal-expense">支出</label>
<input class="form-check-input" type="radio" name="type" id="swal-income" value="收入">
<label class="form-check-label" for="swal-income">收入</label>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-name" class="col-form-label">名稱</label>
@ -44,6 +54,7 @@ const btnClick = async () => {
</div>
`,
preConfirm: () => {
const type = (document.getElementById('swal-expense') as HTMLInputElement).checked
const name = (document.getElementById('swal-name') as HTMLInputElement).value
const icon = (document.getElementById('swal-icon') as HTMLInputElement).value
const photoUrl = (document.getElementById('swal-photoUrl') as HTMLInputElement).value
@ -56,6 +67,7 @@ const btnClick = async () => {
}
return {
input: {
type: type ? '支出' : '收入',
name,
icon: icon === '' ? null : icon,
photoUrl: photoUrl === '' ? null : photoUrl
@ -73,12 +85,13 @@ const btnClick = async () => {
const createExpense = async function (formValues: { input: CategoryInput }) {
try {
await expenseAPI.category.create(formValues.input)
console.log('formValues.input', formValues.input)
const { data } = await expenseAPI.category.create(formValues.input)
refetchCategories()
Toast.fire({
icon: 'success',
title: `成功建立類別[${formValues.input.name}]`
title: `成功建立類別[${data.data.name}]`
})
props.refetch!()
} catch (error) {
console.error('error', error)
Toast.fire({

View File

@ -1,14 +1,17 @@
<script setup lang="ts">
import { Toast, ConfirmBox } from '../../utils/swal'
import expenseAPI from '../../apis/expense'
import { Category } from '../../models/Category'
import { inject } from 'vue'
import { Toast, ConfirmBox } from '@/utils/swal'
import expenseAPI from '@/apis/expense'
import { Category } from '@/models'
// props
const props = defineProps<{
refetch: () => {}
category: Category
}>()
// inject
const refetchCategories = inject<() => {}>('refetchCategories')!
// methods
const btnClick = async () => {
try {
@ -27,12 +30,14 @@ const btnClick = async () => {
const deleteCategory = async function (id: number) {
try {
const { data } = await expenseAPI.category.delete(id)
console.log('data', data)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchCategories()
Toast.fire({
icon: 'success',
title: `成功刪除類別[${data.deletedCategory.name}]`
title: `成功刪除類別[${data.data.name}]`
})
props.refetch()
} catch (error) {
console.error('error', error)
Toast.fire({
@ -45,3 +50,9 @@ const deleteCategory = async function (id: number) {
<template>
<i class="fas fa-trash" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(226, 19, 19);
cursor: pointer;
}
</style>

View File

@ -1,15 +1,18 @@
<script setup lang="ts">
import { inject } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '../../utils/swal'
import expenseAPI from '../../apis/expense'
import { Category, CategoryInput } from '../../models/Category'
import { Toast, ConfirmBox } from '@/utils/swal'
import expenseAPI from '@/apis/expense'
import { Category, CategoryInput } from '@/models'
// props
const props = defineProps<{
refetch: () => {}
category: Category
}>()
// inject
const refetchCategories = inject<() => {}>('refetchCategories')!
// methods
const btnClick = async () => {
try {
@ -17,6 +20,21 @@ const btnClick = async () => {
const { value: formValues } = await ConfirmBox.fire({
title: '編輯類別',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-name" class="col-form-label">種類</label>
</div>
<div class="col-auto my-auto">
<input class="form-check-input" type="radio" name="type" id="swal-expense" value="支出" ${
category.type === '支出' ? 'checked' : ''
}>
<label class="form-check-label me-3" for="swal-expense">支出</label>
<input class="form-check-input" type="radio" name="type" id="swal-income" value="收入" ${
category.type === '收入' ? 'checked' : ''
}>
<label class="form-check-label" for="swal-income">收入</label>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-name" class="col-form-label">名稱</label>
@ -55,6 +73,7 @@ const btnClick = async () => {
</div>
`,
preConfirm: () => {
const type = (document.getElementById('swal-expense') as HTMLInputElement).checked
const name = (document.getElementById('swal-name') as HTMLInputElement).value
const icon = (document.getElementById('swal-icon') as HTMLInputElement).value
const photoUrl = (document.getElementById('swal-photoUrl') as HTMLInputElement).value
@ -67,6 +86,7 @@ const btnClick = async () => {
}
return {
input: {
type: type ? '支出' : '收入',
name,
icon: icon === '' ? null : icon,
photoUrl: photoUrl === '' ? null : photoUrl
@ -75,21 +95,24 @@ const btnClick = async () => {
}
})
if (formValues) {
editCategoryName(formValues)
editCategory(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const editCategoryName = async function (formValues: { input: CategoryInput }) {
const editCategory = async function (formValues: { input: CategoryInput }) {
try {
await expenseAPI.category.edit(props.category.id, formValues.input)
const { data } = await expenseAPI.category.edit(props.category.id, formValues.input)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchCategories()
Toast.fire({
icon: 'success',
title: `成功建立類別[${formValues.input.name}]`
title: `成功編輯類別[${data.data.name}]`
})
props.refetch()
} catch (error) {
console.error('error', error)
Toast.fire({

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import { inject, computed, Ref } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import expenseAPI from '@/apis/expense'
import { ExpenseInput, Category } from '@/models'
import { useStore } from '@/store/index'
const store = useStore()
// inject
const refetchExpenses = inject<() => {}>('refetchExpenses')!
const categories = inject<Ref<Category[]>>('categories')!
// computed
const selectionsHtml = computed(() => {
let html = `<select class="form-select" id="swal-categoryId">`
categories.value.forEach((category: Category) => (html += `<option value="${category.id}">${category.name}</option>`))
html += `</select>`
return html
})
// methods
const btnClick = async () => {
try {
const { value: formValues } = await ConfirmBox.fire({
title: '新增記帳',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-categoryId" class="col-form-label">類別</label>
</div>
<div class="col-auto">
${selectionsHtml.value}
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">項目</label>
</div>
<div class="col-auto">
<input type="text" id="swal-item" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-amount" class="col-form-label">金額</label>
</div>
<div class="col-auto">
<input type="number" id="swal-amount" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-note" class="col-form-label">備註</label>
</div>
<div class="col-auto">
<input type="text" id="swal-note" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-date" class="col-form-label">日期</label>
</div>
<div class="col-auto">
<input type="date" id="swal-date" class="form-control" value="${new Date().toJSON().substring(0, 10)}">
</div>
</div>
`,
preConfirm: () => {
const categoryId = (document.getElementById('swal-categoryId') as HTMLInputElement).value
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const amount = (document.getElementById('swal-amount') as HTMLInputElement).value
const date = new Date((document.getElementById('swal-date') as HTMLInputElement).value)
const note = (document.getElementById('swal-note') as HTMLInputElement).value
if (!item || !amount || !date) {
Swal.showValidationMessage('除了[備註],所有資料都是必填!')
}
return {
input: {
UserId: store.currentUser?.id,
CategoryId: Number(categoryId),
item,
amount,
note,
date
} as ExpenseInput
}
}
})
if (formValues) {
createExpense(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const createExpense = async function (formValues: { input: ExpenseInput }) {
try {
await expenseAPI.expense.create(formValues.input)
refetchExpenses()
Toast.fire({
icon: 'success',
title: '成功建立記帳!'
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '新增記帳失敗!'
})
}
}
// created
// fetchCategories()
</script>
<template>
<button type="button" class="btn btn-primary me-3" @click="btnClick">新增記帳</button>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { inject } from 'vue'
import { Toast, ConfirmBox } from '@/utils/swal'
import expenseAPI from '@/apis/expense'
import { Expense } from '@/models'
// props
const props = defineProps<{
expense: Expense
}>()
// inject
const refetchExpenses = inject<() => {}>('refetchExpenses')!
// methods
const btnClick = async () => {
try {
const { isConfirmed } = await ConfirmBox.fire({
title: `確定刪除記帳[${props.expense.item}]嘛?`,
showCancelButton: true
})
if (isConfirmed) {
deleteExpense(props.expense.id)
}
} catch (error) {
console.error('error', error)
}
}
const deleteExpense = async function (id: number) {
try {
const { data } = await expenseAPI.expense.delete(id)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchExpenses()
Toast.fire({
icon: 'success',
title: `成功刪除記帳[${data.data.item}]`
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '刪除記帳失敗!'
})
}
}
</script>
<template>
<i class="fas fa-trash" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(226, 19, 19);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed, inject, Ref } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import expenseAPI from '@/apis/expense'
import { Expense, ExpenseInput, Category } from '@/models'
import { useStore } from '@/store/index'
const store = useStore()
// props
const props = defineProps<{
expense: Expense
}>()
// inject
const refetchExpenses = inject<() => {}>('refetchExpenses')!
const categories = inject<Ref<Category[]>>('categories')!
// computed
const selectionsHtml = computed(() => {
let html = `<select class="form-select" id="swal-categoryId">`
categories.value.forEach((category: Category) => {
if (category.id === props.expense.Category.id) {
html += `<option selected value="${category.id}">${category.name}</option>`
} else {
html += `<option value="${category.id}">${category.name}</option>`
}
})
html += `</select>`
return html
})
// methods
const btnClick = async () => {
try {
const expense = props.expense
const { value: formValues } = await ConfirmBox.fire({
title: '編輯記帳',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-categoryId" class="col-form-label">類別</label>
</div>
<div class="col-auto">
${selectionsHtml.value}
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">項目</label>
</div>
<div class="col-auto">
<input value="${expense.item}" type="text" id="swal-item" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-amount" class="col-form-label">金額</label>
</div>
<div class="col-auto">
<input value="${expense.amount}" type="number" id="swal-amount" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-note" class="col-form-label">備註</label>
</div>
<div class="col-auto">
<input value="${expense.note}" type="text" id="swal-note" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-date" class="col-form-label">日期</label>
</div>
<div class="col-auto">
<input value="${new Date(expense.date)
.toJSON()
.substring(0, 10)}" type="date" id="swal-date" class="form-control">
</div>
`,
preConfirm: () => {
const categoryId = (document.getElementById('swal-categoryId') as HTMLInputElement).value
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const amount = (document.getElementById('swal-amount') as HTMLInputElement).value
const date = new Date((document.getElementById('swal-date') as HTMLInputElement).value)
const note = (document.getElementById('swal-note') as HTMLInputElement).value
if (!item || !amount || !date) {
Swal.showValidationMessage('除了[備註],所有資料都是必填!')
}
return {
input: {
UserId: store.currentUser?.id,
CategoryId: Number(categoryId),
item,
amount,
note,
date
} as ExpenseInput
}
}
})
if (formValues) {
editExpense(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const editExpense = async function (formValues: { input: ExpenseInput }) {
try {
const { data } = await expenseAPI.expense.edit(props.expense.id, formValues.input)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchExpenses()
Toast.fire({
icon: 'success',
title: `成功編輯記帳[${data.data.item}]`
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '編輯記帳失敗!'
})
}
}
</script>
<template>
<i class="fas fa-edit" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(30, 197, 57);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { inject, computed } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import userAPI from '@/apis/user'
import { PermissionInput, ActionTypePermission } from '@/models'
const actions: ActionTypePermission[] = ['查看', '新增', '編輯', '刪除', '停用', '操作']
// inject
const refetchPermissions = inject<() => {}>('refetchPermissions')!
// computed
const selectionsHtml = computed(() => {
let html = `<select class="form-select" id="swal-action">`
actions.forEach((action: ActionTypePermission) => (html += `<option value="${action}">${action}</option>`))
html += `</select>`
return html
})
// methods
const btnClick = async () => {
try {
const { value: formValues } = await ConfirmBox.fire({
title: '新增權限',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-action" class="col-form-label">ACTION</label>
</div>
<div class="col-auto">
${selectionsHtml.value}
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">ITEM</label>
</div>
<div class="col-auto">
<input type="text" id="swal-item" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-description" class="col-form-label">DESCRIPTION</label>
</div>
<div class="col-auto">
<input type="text" id="swal-description" class="form-control" >
</div>
</div>
`,
preConfirm: () => {
const action = (document.getElementById('swal-action') as HTMLInputElement).value
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const description = (document.getElementById('swal-description') as HTMLInputElement).value
if (!action || !item) {
Swal.showValidationMessage('除了[DESCRIPTION],所有欄位都是必填!')
}
return {
input: {
action,
item,
description
} as PermissionInput
}
}
})
if (formValues) {
createPermission(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const createPermission = async function (formValues: { input: PermissionInput }) {
try {
await userAPI.permission.create(formValues.input)
refetchPermissions()
Toast.fire({
icon: 'success',
title: '成功建立權限!'
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '新增權限失敗!'
})
}
}
</script>
<template>
<button type="button" class="btn btn-primary" @click="btnClick">新增權限</button>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { inject } from 'vue'
import { Toast, ConfirmBox } from '@/utils/swal'
import userAPI from '@/apis/user'
import { Permission } from '@/models'
// props
const props = defineProps<{
permission: Permission
}>()
// inject
const refetchPermissions = inject<() => {}>('refetchPermissions')!
// methods
const btnClick = async () => {
try {
const { isConfirmed } = await ConfirmBox.fire({
title: `確定刪除權限[${props.permission.action}-${props.permission.item}]嘛?`,
showCancelButton: true
})
if (isConfirmed) {
deletePermission(props.permission.id)
}
} catch (error) {
console.error('error', error)
}
}
const deletePermission = async function (id: number) {
try {
const { data } = await userAPI.permission.delete(id)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchPermissions()
Toast.fire({
icon: 'success',
title: `成功刪除權限[${data.data.action}-${data.data.item}]`
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '刪除權限失敗!'
})
}
}
</script>
<template>
<i class="fas fa-trash" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(226, 19, 19);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { inject, computed } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import userAPI from '@/apis/user'
import { Permission, PermissionInput, ActionTypePermission } from '@/models'
const actions: ActionTypePermission[] = ['查看', '新增', '編輯', '刪除', '停用', '操作']
// props
const props = defineProps<{
permission: Permission
}>()
// inject
const refetchPermissions = inject<() => {}>('refetchPermissions')!
// computed
const selectionsHtml = computed(() => {
let html = `<select class="form-select" id="swal-action">`
actions.forEach((action: ActionTypePermission) => {
if (action === props.permission.action) {
html += `<option selected value="${action}">${action}</option>`
} else {
html += `<option value="${action}">${action}</option>`
}
})
html += `</select>`
return html
})
// methods
const btnClick = async () => {
try {
const permission = props.permission
const { value: formValues } = await ConfirmBox.fire({
title: '編輯權限',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-action" class="col-form-label">ACTION</label>
</div>
<div class="col-auto">
${selectionsHtml.value}
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">ITEM</label>
</div>
<div class="col-auto">
<input value="${permission.item}" type="text" id="swal-item" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-description" class="col-form-label">DESCRIPTION</label>
</div>
<div class="col-auto">
<input value="${
permission.description === null ? '' : permission.description
}" type="text" id="swal-description" class="form-control" >
</div>
</div>
`,
preConfirm: () => {
const action = (document.getElementById('swal-action') as HTMLInputElement).value
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const description = (document.getElementById('swal-description') as HTMLInputElement).value
if (!action || !item) {
Swal.showValidationMessage('除了[DESCRIPTION],所有資料都是必填!')
}
return {
input: {
action,
item,
description
} as PermissionInput
}
}
})
if (formValues) {
editPermission(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const editPermission = async function (formValues: { input: PermissionInput }) {
try {
const { data } = await userAPI.permission.edit(props.permission.id, formValues.input)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchPermissions()
Toast.fire({
icon: 'success',
title: `成功編輯權限[${data.data.action}-${data.data.item}]`
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '編輯權限失敗!'
})
}
}
</script>
<template>
<i class="fas fa-edit" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(30, 197, 57);
cursor: pointer;
}
</style>

View File

@ -1,17 +1,22 @@
<script setup lang="ts">
import { inject } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '../../utils/swal'
import recordAPI from '../../apis/record'
import { RecordInput } from '../../models/Record'
import { useStore } from '../../store/index'
const store = useStore()
import { Toast, ConfirmBox } from '@/utils/swal'
import recordAPI from '@/apis/record'
import { pushMsgToBoth } from '@/utils/lineBotMsg'
import { RecordInput } from '@/models'
import { CallParent } from '@/cocos/config'
import { useStore } from '@/store/index'
import { useRouter } from 'vue-router'
const store = useStore()
const router = useRouter()
// inject
const refetchRecords = inject<() => {}>('refetchRecords')!
// props
const props = defineProps<{
view: 'Home' | 'Record'
refetch?: () => {}
}>()
// methods
@ -52,24 +57,13 @@ const btnClick = async () => {
<input type="date" id="swal-date" class="form-control" value="${new Date().toJSON().substring(0, 10)}">
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-recorder" class="col-form-label">紀錄者</label>
</div>
<div class="col-auto">
<select id="swal-recorder" class="form-select">
<option selected>${store.currentUser}</option>
</select>
</div>
</div>
`,
preConfirm: () => {
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const merchant = (document.getElementById('swal-merchant') as HTMLInputElement).value
const amount = (document.getElementById('swal-amount') as HTMLInputElement).value
const date = new Date((document.getElementById('swal-date') as HTMLInputElement).value)
const recorder = (document.getElementById('swal-recorder') as HTMLInputElement).value
if (!item || !merchant || !amount || !date || !recorder) {
if (!item || !merchant || !amount || !date) {
Swal.showValidationMessage('所有資料都是必填!若紀錄者為空,請登入~')
}
return {
@ -78,7 +72,7 @@ const btnClick = async () => {
merchant,
amount,
date,
recorder
UserId: store.currentUser?.id
} as RecordInput
}
}
@ -99,11 +93,15 @@ const createRecord = async function (formValues: { input: RecordInput }) {
title: '成功建立資料!'
})
if (props.view === 'Record') {
props.refetch!()
refetchRecords()
} else {
router.push({ name: 'Record' })
}
lineBotPush(formValues.input)
// lineBot push
pushMsgToBoth(
`${store.nickName}${store.icon}新增了一筆紀錄 →\n${formValues.input.merchant}-${formValues.input.item} ${formValues.input.amount}`
)
CallParent('Speak', `成功紀錄${formValues.input.amount}`)
} catch (error) {
console.error('error', error)
Toast.fire({
@ -112,26 +110,9 @@ const createRecord = async function (formValues: { input: RecordInput }) {
})
}
}
const lineBotPush = async (recordInput: RecordInput) => {
try {
const input = {
to: [process.env.VUE_APP_KAROL_USERID, process.env.VUE_APP_JIANMIAU_USERID],
messages: {
type: 'text',
text: `${store.currentUser + store.icon}新增了一筆紀錄 →\n${recordInput.merchant}-${recordInput.item} $${
recordInput.amount
}`
}
}
await recordAPI.pushLineMsg(input)
} catch (error) {
console.error('error', error)
}
}
</script>
<template>
<div>
<button type="button" class="btn btn-primary" @click="btnClick">新增資料</button>
<button type="button" class="btn btn-success" @click="btnClick">新增資料</button>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { inject } from 'vue'
import { Toast, ConfirmBox } from '@/utils/swal'
import recordAPI from '@/apis/record'
import { Record } from '@/models'
// props
const props = defineProps<{
record: Record
}>()
// inject
const refetchRecords = inject<() => {}>('refetchRecords')!
// methods
const btnClick = async () => {
try {
const { isConfirmed } = await ConfirmBox.fire({
title: `確定刪除紀錄[${props.record.item}]嘛?`,
showCancelButton: true
})
if (isConfirmed) {
deleteRecord(props.record.id)
}
} catch (error) {
console.error('error', error)
}
}
const deleteRecord = async function (id: number) {
try {
const { data } = await recordAPI.delete(id)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchRecords()
Toast.fire({
icon: 'success',
title: `成功刪除紀錄[${data.data.item}]`
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '刪除紀錄失敗!'
})
}
}
</script>
<template>
<i class="fas fa-trash" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(226, 19, 19);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { inject } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import recordAPI from '@/apis/record'
import { pushMsgToBoth } from '@/utils/lineBotMsg'
import { Record, RecordInput } from '@/models'
import { useStore } from '@/store/index'
const store = useStore()
// props
const props = defineProps<{
record: Record
}>()
// inject
const refetchRecords = inject<() => {}>('refetchRecords')!
const btnClick = async () => {
try {
const record = props.record
const { value: formValues } = await ConfirmBox.fire({
title: '資料編輯',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">項目</label>
</div>
<div class="col-auto">
<input value="${record.item}" type="text" id="swal-item" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-merchant" class="col-form-label">商家</label>
</div>
<div class="col-auto">
<input value="${record.merchant}" type="text" id="swal-merchant" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-amount" class="col-form-label">金額</label>
</div>
<div class="col-auto">
<input value="${record.amount}" type="number" id="swal-amount" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-date" class="col-form-label">日期</label>
</div>
<div class="col-auto">
<input value="${new Date(record.date)
.toISOString()
.substring(0, 10)}" type="date" id="swal-date" class="form-control" >
</div>
</div>
`,
preConfirm: () => {
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const merchant = (document.getElementById('swal-merchant') as HTMLInputElement).value
const amount = (document.getElementById('swal-amount') as HTMLInputElement).value
const date = new Date((document.getElementById('swal-date') as HTMLInputElement).value)
if (!item || !merchant || !amount || !date) {
Swal.showValidationMessage('所有資料都是必填!若編輯者為空,請登入~')
}
return {
id: record.id as number,
input: {
item,
merchant,
amount,
date,
UserId: store.currentUser?.id
} as RecordInput
}
}
})
if (formValues) {
editRecord(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const editRecord = async function (formValues: { id: number; input: RecordInput }) {
try {
await recordAPI.edit(formValues.id, formValues.input)
refetchRecords()
Toast.fire({
icon: 'success',
title: '成功編輯資料!'
})
// lineBot push
pushMsgToBoth(
`${store.nickName}${store.icon}編輯了一筆紀錄 →\n${formValues.input.merchant}-${formValues.input.item} $${formValues.input.amount}`
)
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '編輯資料失敗!'
})
}
}
</script>
<template>
<i class="fas fa-edit" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(30, 197, 57);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import { inject } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import userAPI from '@/apis/user'
import { RoleInput } from '@/models'
// inject
const refetchRoles = inject<() => {}>('refetchRoles')!
// methods
const btnClick = async () => {
try {
const { value: formValues } = await ConfirmBox.fire({
title: '新增角色',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-name" class="col-form-label">中文名稱</label>
</div>
<div class="col-auto">
<input type="text" id="swal-name" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-nameEn" class="col-form-label">英文名稱</label>
</div>
<div class="col-auto">
<input type="text" id="swal-nameEn" class="form-control" >
</div>
</div>
`,
preConfirm: () => {
const name = (document.getElementById('swal-name') as HTMLInputElement).value
const name_en = (document.getElementById('swal-nameEn') as HTMLInputElement).value
if (!name || !name_en) {
Swal.showValidationMessage('所有欄位都是必填!')
}
return {
input: {
name,
name_en
} as RoleInput
}
}
})
if (formValues) {
createExpense(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const createExpense = async function (formValues: { input: RoleInput }) {
try {
await userAPI.role.create(formValues.input)
refetchRoles()
Toast.fire({
icon: 'success',
title: '成功建立角色!'
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '新增角色失敗!'
})
}
}
</script>
<template>
<button type="button" class="btn btn-primary" @click="btnClick">新增角色</button>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { inject } from 'vue'
import { Toast, ConfirmBox } from '@/utils/swal'
import userAPI from '@/apis/user'
import { Role } from '@/models'
// props
const props = defineProps<{
role: Role
}>()
// inject
const refetchRoles = inject<() => {}>('refetchRoles')!
// methods
const btnClick = async () => {
try {
const { isConfirmed } = await ConfirmBox.fire({
title: `確定刪除角色[${props.role.name}]嘛?`,
showCancelButton: true
})
if (isConfirmed) {
deleteRole(props.role.id)
}
} catch (error) {
console.error('error', error)
}
}
const deleteRole = async function (id: number) {
try {
const { data } = await userAPI.role.delete(id)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
refetchRoles()
Toast.fire({
icon: 'success',
title: `成功刪除角色[${data.data.name}]`
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '刪除角色失敗!'
})
}
}
</script>
<template>
<i class="fas fa-trash" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(226, 19, 19);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { inject } from 'vue'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '@/utils/swal'
import userAPI from '@/apis/user'
import { Role, RoleInput } from '@/models'
// props
const props = defineProps<{
role: Role
}>()
// inject
const refetchRoles = inject<() => {}>('refetchRoles')!
const btnClick = async () => {
try {
const record = props.role
const { value: formValues } = await ConfirmBox.fire({
title: '角色編輯',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-name" class="col-form-label">中文名稱</label>
</div>
<div class="col-auto">
<input value="${record.name}" type="text" id="swal-name" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-nameEn" class="col-form-label">英文名稱</label>
</div>
<div class="col-auto">
<input value="${record.name_en}" type="text" id="swal-nameEn" class="form-control" >
</div>
</div>
`,
preConfirm: () => {
const name = (document.getElementById('swal-name') as HTMLInputElement).value
const name_en = (document.getElementById('swal-nameEn') as HTMLInputElement).value
if (!name || !name_en) {
Swal.showValidationMessage('所有資料都是必填!')
}
return {
id: record.id as number,
input: {
name,
name_en
} as RoleInput
}
}
})
if (formValues) {
editRole(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const editRole = async function (formValues: { id: number; input: RoleInput }) {
try {
await userAPI.role.edit(formValues.id, formValues.input)
refetchRoles()
Toast.fire({
icon: 'success',
title: '成功編輯角色!'
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '編輯角色失敗!'
})
}
}
</script>
<template>
<i class="fas fa-edit" @click="btnClick"></i>
</template>
<style scoped>
i:hover {
color: rgb(30, 197, 57);
cursor: pointer;
}
</style>

View File

@ -1,16 +1,25 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, provide } from 'vue'
import Sidebar from '@/components/Sidebar.vue'
import UserRP from '@/components/RightPanel/UserRP.vue'
import { useRoute } from 'vue-router'
import { useStore } from '@/store/index'
const store = useStore()
const route = useRoute()
const sidebarOpen = ref(false)
const sidebarOpen = ref<boolean>(false)
const userRPOpen = ref<boolean>(false)
// provide
provide('sidebarOpen', sidebarOpen)
provide('userRPOpen', userRPOpen)
</script>
<template>
<div class="d-flex">
<!-- 登入後 -->
<div class="d-flex" v-if="store.firebaseUser">
<transition name="slide-x">
<Sidebar v-show="sidebarOpen" @open="sidebarOpen = false" />
<Sidebar v-show="sidebarOpen" @openUserRP="userRPOpen = true" />
</transition>
<div class="m-2" style="width: 100vw">
@ -20,7 +29,14 @@ const sidebarOpen = ref(false)
</div>
<div class="m-3">
<slot name="main" />
<transition name="slide-right">
<UserRP v-show="userRPOpen" />
</transition>
</div>
</div>
</div>
<!-- 未登入 -->
<div v-else>
<slot name="main" />
</div>
</template>

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { inject } from 'vue'
import recordAPI from '@/apis/record'
import CreateRecordModalButton from '@/components/ModalButton/Record/CreateRecordModalButton.vue'
import { Toast, ConfirmBox } from '@/utils/swal'
import { pushMsgToBoth } from '@/utils/lineBotMsg'
import { useStore } from '@/store/index'
const store = useStore()
const emit = defineEmits(['closeFunction'])
// inject
const refetchRecords = inject<() => {}>('refetchRecords')!
// props
const props = defineProps<{
propData: {
isCloseStatus: boolean
closeRecords: number[]
closeRecordsAmount: number
}
}>()
// methods
const toCloseBtnClick = async () => {
const { isConfirmed } = await ConfirmBox.fire({
icon: 'info',
title: '確定結算資料?',
text: `結算金額為 $${props.propData.closeRecordsAmount} [結算者: ${store.currentUser?.displayName}]`
})
if (isConfirmed) {
closeRecord(props.propData.closeRecordsAmount)
}
}
const closeRecord = async (amount: number) => {
try {
if (store.currentUser) {
const { data } = await recordAPI.close({
records: props.propData.closeRecords.toString(),
totalAmount: amount,
UserId: store.currentUser?.id
})
emit('closeFunction', 'closeRecord')
refetchRecords()
if (data.data.recordsNotFound.length || data.data.recordsClosedBefore.length) {
Toast.fire({
icon: 'success',
title: '成功結算資料!有部分資料未能找到或是已經結算過。'
})
} else {
Toast.fire({
icon: 'success',
title: '成功結算資料!'
})
}
// lineBot push
pushMsgToBoth(`${store.nickName}${store.icon}結算紀錄 → 總金額 $${amount}`)
}
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '結算資料失敗!'
})
}
}
</script>
<template>
<div class="d-flex mb-3" style="width: 100vw">
<template v-if="!propData.isCloseStatus">
<CreateRecordModalButton view="Record" class="me-3" />
<button type="button" class="btn btn-danger" @click="emit('closeFunction', 'closeBtnClick')">開始結算</button>
</template>
<template v-else>
<div class="btn btn-info fw-bold">結算金額 ${{ propData.closeRecordsAmount }}</div>
<button type="button" class="btn btn-secondary ms-3" @click="emit('closeFunction', 'cancelBtnClick')">
取消結算
</button>
<button type="button" class="btn btn-success ms-3" @click="toCloseBtnClick">確定結算</button>
</template>
</div>
</template>

View File

@ -1,65 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import expenseAPI from '../apis/expense'
import { Category } from '../models/Category'
import CreateCategoryModalButton from '@/components/modalButton/CreateCategoryModalButton.vue'
import DeleteCategoryModalButton from '@/components/modalButton/DeleteCategoryModalButton.vue'
import EditCategoryModalButton from '@/components/modalButton/EditCategoryModalButton.vue'
// data
const isLoading = ref<boolean>(false)
const categories = ref<Category[]>([])
// methods
const fetchCategories = async () => {
try {
const { data } = await expenseAPI.category.getAll()
categories.value = data.filter((item: Category) => item.deletedAt === null)
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
// created
fetchCategories()
</script>
<template>
<div id="panel" v-if="!isLoading">
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-light" style="width: 250px; height: 100vh">
<h5 class="text-dark mb-3">
記帳類別
<CreateCategoryModalButton :refetch="fetchCategories" />
</h5>
<div class="scroll">
<ul class="list-group">
<li class="list-group-item d-flex mb-2 border" v-for="(category, index) in categories" :key="index">
<i :class="`${category.icon} fa-2x text-warning me-3`"></i>
<div class="text-nowrap">
<span>{{ category.name }}</span>
<EditCategoryModalButton :refetch="fetchCategories" :category="category" />
<DeleteCategoryModalButton class="ms-2" :refetch="fetchCategories" :category="category" />
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
#panel {
position: fixed;
top: 0;
right: 0;
}
i[class~='fa-trash']:hover {
color: rgb(226, 19, 19);
cursor: pointer;
}
.scroll {
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { inject, Ref, ref } from 'vue'
import { Category } from '@/models'
import CreateCategoryModalButton from '@/components/ModalButton/Category/CreateCategoryModalButton.vue'
import DeleteCategoryModalButton from '@/components/ModalButton/Category/DeleteCategoryModalButton.vue'
import EditCategoryModalButton from '@/components/ModalButton/Category/EditCategoryModalButton.vue'
import RightPanel from '@/components/RightPanel/RightPanel.vue'
import type { CategoryType } from '@/models'
// inject
const categories = inject<Ref<Category[]>>('categories')!
const categoryRPOpen = inject<Ref<boolean>>('categoryRPOpen')!
// data
const nowTab = ref<CategoryType>('支出')
</script>
<template>
<RightPanel @RPOpen="categoryRPOpen = false">
<template #title>
<h5 class="text-dark mb-3">
記帳類別
<CreateCategoryModalButton />
</h5>
</template>
<template #content>
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span class="nav-link" :class="{ active: nowTab === '支出' }" @click="nowTab = '支出'">支出</span>
</li>
<li class="nav-item">
<span class="nav-link" :class="{ active: nowTab === '收入' }" @click="nowTab = '收入'">收入</span>
</li>
</ul>
<div class="scroll">
<ul class="list-group">
<template v-for="(category, index) in categories" :key="index">
<li class="list-group-item d-flex mb-2 border" v-if="category.type === nowTab">
<div class="col-2">
<i :class="`${category.icon} fa-2x text-warning`"></i>
</div>
<div class="col-10">
<div class="text-nowrap text-start ms-3">
<span class="align-middle">{{ category.name }}</span>
<EditCategoryModalButton :category="category" class="align-middle" />
<DeleteCategoryModalButton class="ms-2 align-middle" :category="category" />
</div>
</div>
</li>
</template>
</ul>
</div>
</template>
</RightPanel>
</template>
<style scoped>
.scroll {
overflow-y: auto;
}
.nav-link {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
const emit = defineEmits(['RPOpen'])
</script>
<template>
<div id="panel">
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-light" style="width: 250px; height: 100vh">
<i class="fas fa-chevron-right hide-icon" @click="emit('RPOpen', false)"></i>
<slot name="title" />
<slot name="content" />
</div>
</div>
</template>
<style scoped>
#panel {
position: fixed;
top: 0;
right: 0;
z-index: 100;
}
.hide-icon {
position: absolute;
top: 22px;
left: 25px;
color: black;
}
.hide-icon:hover {
color: orange;
}
</style>

View File

@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, inject, Ref } from 'vue'
import RightPanel from '@/components/RightPanel/RightPanel.vue'
import userAPI from '@/apis/user'
import { UserEditInput } from '@/models'
import { Toast } from '@/utils/swal'
import { useStore } from '@/store/index'
const store = useStore()
// inject
const userRPOpen = inject<Ref<boolean>>('userRPOpen')!
// data
const editField = ref<'displayName' | 'photoURL' | ''>('')
const user = ref(new UserEditInput())
const isLoading = ref<boolean>(false)
// methods
const editUser = async function () {
try {
if (store.currentUser) {
isLoading.value = true
const { data } = await userAPI.user.edit(store.currentUser.id, user.value)
store.getCurrentUser(store.currentUser.email)
if (data.status !== 'success') {
throw new Error(`[SERVER ERROR] ${data.message}`)
}
editField.value = ''
Toast.fire({
icon: 'success',
title: '成功編輯個人資料!'
})
isLoading.value = false
}
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '編輯個人資料失敗!'
})
}
}
// created
if (store.currentUser) {
user.value = {
displayName: store.currentUser.displayName,
photoURL: store.currentUser.photoURL
}
}
</script>
<template>
<RightPanel @RPOpen="userRPOpen = false">
<template #title>
<h5 class="text-dark mb-3">個人資料</h5>
</template>
<template #content>
<div class="my-3">
<div class="text-dark fw-bold">
<span class="badge rounded-pill bg-success ms-2 mb-1">信箱</span>
<span class="ms-2">{{ store.currentUser?.email }}</span>
</div>
</div>
<div class="mb-3">
<div class="text-dark fw-bold">
<span class="badge rounded-pill bg-success ms-2">名稱</span>
<span class="ms-2">
<template v-if="editField === 'displayName'">
<input
type="text"
class="form-control d-inline-block"
id="displayName"
style="width: 120px"
v-model="user.displayName"
/>
<div class="spinner-border spinner-border-sm text-success ms-2" role="status" v-if="isLoading">
<span class="visually-hidden">Loading...</span>
</div>
<i v-else class="far fa-check-circle fa-lg ms-2" @click="editUser"></i>
</template>
<template v-else>
<span>{{ user.displayName }}</span>
<i class="far fa-edit ms-2" @click="editField = 'displayName'"></i>
</template>
</span>
</div>
</div>
<div class="mb-3">
<div class="text-dark fw-bold">
<span class="badge rounded-pill bg-success ms-2">大頭貼(url)</span>
<span class="ms-2">
<template v-if="editField === 'photoURL'">
<div class="d-flex">
<div class="input-group mt-2">
<textarea class="form-control" aria-label="With textarea" v-model="user.photoURL"></textarea>
</div>
<div class="spinner-border spinner-border-sm text-success ms-2 mt-4" role="status" v-if="isLoading">
<span class="visually-hidden">Loading...</span>
</div>
<i v-else class="far fa-check-circle fa-lg ms-2 mt-4" @click="editUser"></i>
</div>
</template>
<template v-else>
<img :src="user.photoURL || ''" alt="photoURL" width="55" height="55" class="rounded-circle" />
<i class="far fa-edit ms-2" @click="editField = 'photoURL'"></i>
</template>
</span>
</div>
</div>
</template>
</RightPanel>
</template>
<style scoped>
span {
float: left;
}
span[class~='badge'] {
margin-top: 0.2em;
}
i {
color: black;
}
i:hover {
color: rgb(86, 116, 42);
cursor: pointer;
}
</style>

View File

@ -1,54 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { routes } from '../router/index'
import { computed, inject, Ref } from 'vue'
import router, { routes } from '../router/index'
import { useRoute, RouteRecordRaw } from 'vue-router'
import { useStore } from '../store/index'
import Swal from 'sweetalert2'
import { ConfirmBox } from '../utils/swal'
import { Toast } from '@/utils/swal'
import { getAuth, signOut } from 'firebase/auth'
const route = useRoute()
const store = useStore()
const getUser = store.switchUser
const emit = defineEmits(['open'])
const emit = defineEmits(['openUserRP'])
// inject
const sidebarOpen = inject<Ref<boolean>>('sidebarOpen')!
// computed
const items = computed(() => {
return routes.filter((route: RouteRecordRaw) => route.meta?.show)
})
const changeItem = () => {
emit('open', false)
// methods
const logout = () => {
const auth = getAuth()
signOut(auth)
.then(() => {
store.logout()
sidebarOpen.value = false
router.push({ name: 'Login' })
Toast.fire({
icon: 'success',
title: '離開豬豬世界囉~'
})
})
.catch((error) => {
console.log('error', error)
})
}
const switchModalOpen = async () => {
const { value: user } = await ConfirmBox.fire({
title: '切換使用者',
input: 'select',
inputOptions: { 建喵: '建喵', 豬涵: '豬涵' },
inputPlaceholder: 'who are you?',
showCancelButton: true
})
if (user) {
getUser(user)
Swal.fire(`使用者已切換至 [${user}]`)
}
const openUserRP = () => {
sidebarOpen.value = false
emit('openUserRP', true)
}
</script>
<template>
<div>
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-dark" style="width: 180px; height: 100vh">
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-primary" style="width: 180px; height: 100vh">
<router-link
:to="{ name: 'Home' }"
class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none"
>
<span class="fs-4"><i class="fas fa-star"></i> JM記帳 <i class="fas fa-star"></i></span>
<div class="fs-4 mx-auto"><i class="fas fa-star"></i> JM記帳 <i class="fas fa-star"></i></div>
</router-link>
<hr />
<ul class="nav nav-pills flex-column mb-auto">
<hr class="my-1" />
<ul class="nav nav-pills mb-auto">
<li class="nav-item" v-for="(item, index) in items" :key="index">
<template v-if="item.children?.length">
<button
class="nav-link fw-bold text-white"
type="button"
data-bs-toggle="collapse"
:data-bs-target="`#collapse-${index}`"
aria-expanded="false"
:aria-controls="`#collapse-${index}`"
>
<span class="me-3">{{ item.meta?.pageTitle }}</span>
<i class="fa-solid fa-angle-down"></i>
</button>
<div class="collapse" :id="`collapse-${index}`">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li v-for="child in item.children" :key="child.name">
<router-link
:to="{ name: child.name }"
:class="'nav-link fw-bold ' + (child.name === route.name ? 'text-danger' : 'text-white')"
@click="sidebarOpen = false"
>{{ child.meta?.pageTitle }}</router-link
>
</li>
</ul>
</div>
</template>
<router-link
v-else
:to="{ name: item.name }"
:class="'nav-link ' + (item.name === route.name ? 'text-info' : 'text-white')"
@click="changeItem"
:class="'nav-link fw-bold ' + (item.name === route.name ? 'text-danger' : 'text-white')"
@click="sidebarOpen = false"
>{{ item.meta?.pageTitle }}</router-link
>
</li>
@ -63,32 +98,24 @@ const switchModalOpen = async () => {
aria-expanded="false"
>
<img
v-if="store.currentUser === '建喵'"
src="../assets/jianmiau-login.png"
alt=""
width="50"
:src="store.firebaseUser?.photoURL || '../assets/capoo.gif'"
alt="photo"
width="55"
height="55"
class="rounded-circle me-2"
/>
<img
v-else-if="store.currentUser === '豬涵'"
src="../assets/karol-login.png"
alt=""
width="50"
height="55"
class="rounded-circle me-2"
/>
<img v-else src="../assets/capoo.gif" alt="" width="50" height="55" class="rounded-circle me-2" />
<strong>{{ store.currentUser ? store.currentUser : '未登入' }}</strong>
<strong>{{ store.firebaseUser?.displayName }}</strong>
</a>
<ul class="dropdown-menu dropdown-menu-dark text-small shadow" aria-labelledby="dropdownUser1">
<!-- <li><a class="dropdown-item" href="#">New project...</a></li>
<li><a class="dropdown-item" href="#">Settings</a></li>
<li><a class="dropdown-item" href="#">Profile</a></li> -->
<!-- <li><hr class="dropdown-divider" /></li> -->
<li><a class="dropdown-item" @click="switchModalOpen">切換帳號</a></li>
<li><a class="dropdown-item" @click="openUserRP">個人資料</a></li>
<li><a class="dropdown-item" @click="logout">登出</a></li>
<!-- <li><a class="dropdown-item" @click="switchModalOpen">切換帳號</a></li> -->
</ul>
</div>
</div>
<!-- <transition name="slide-right">
<UserRP v-show="userRPOpen" />
</transition> -->
</div>
</template>

26
src/components/global.ts Normal file
View File

@ -0,0 +1,26 @@
// import Vue from 'vue'
import { createApp } from 'vue'
import App from '@/App.vue'
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
const requireComponent = require.context(
'.',
false,
/\.vue$/
//找到components文件夾下以.vue命名的文件
)
requireComponent.keys().forEach((fileName) => {
const componentConfig = requireComponent(fileName)
const componentName = capitalizeFirstLetter(
fileName.replace(/^\.\//, '').replace(/\.\w+$/, '')
//因爲得到的filename格式是: './dataList.vue', 所以這裏我們去掉頭和尾,只保留真正的文件名
)
const app = createApp(App)
app.component(componentName, componentConfig.default || componentConfig)
})

View File

@ -0,0 +1,11 @@
import type { SearchMode } from './type'
export interface DateFilterData {
searchMode: SearchMode
filter: {
year: number
month: number
startDate: string
finishDate: string
}
}

1
src/definition/type.ts Normal file
View File

@ -0,0 +1 @@
export type SearchMode = '月份' | '日期'

30
src/firebase/auth.ts Normal file
View File

@ -0,0 +1,30 @@
import { getAuth, onAuthStateChanged, User as FirebaseUser } from 'firebase/auth'
import { useStore } from '../store/index'
// export const getFirebaseUser = () => {
// onAuthStateChanged(getAuth(), (user: FirebaseUser | null) => {
// const store = useStore()
// if (user) {
// console.log('[auth] Get Firebase User', user)
// store.login(user)
// } else {
// store.logout()
// }
// })
// }
export const getFirebaseUser = () => {
const store = useStore()
return new Promise<void>((resolve) => {
onAuthStateChanged(getAuth(), async (user: FirebaseUser | null) => {
console.log(`[firebase] onAuthStateChanged`)
if (user) {
console.log('[auth] Get Firebase User', user)
store.login(user)
} else {
store.logout()
}
resolve()
})
})
}

15
src/firebase/config.ts Normal file
View File

@ -0,0 +1,15 @@
import { initializeApp } from 'firebase/app'
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTO_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
}
export const initFirebase = () => {
initializeApp(firebaseConfig)
}

View File

@ -2,9 +2,26 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import { firestorePlugin } from 'vuefire'
import { getFirebaseUser } from '@/firebase/auth'
import { getAuth, onAuthStateChanged, User as FirebaseUser } from 'firebase/auth'
import { initFirebase } from '@/firebase/config'
import Datepicker from 'vue3-date-time-picker'
import 'vue3-date-time-picker/dist/main.css'
import 'bootstrap'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootswatch/dist/minty/bootstrap.min.css'
createApp(App).use(router).use(createPinia()).mount('#app')
console.log('[main] Initialize Firebase')
initFirebase()
const startApp = onAuthStateChanged(getAuth(), async (user: FirebaseUser | null) => {
const app = createApp(App)
console.log('[main] Initialize Vue App')
app.use(createPinia())
await getFirebaseUser()
app.use(router)
app.component('Datepicker', Datepicker)
app.mount('#app')
startApp()
})

View File

@ -1,15 +1,18 @@
export type CategoryType = '支出' | '收入'
export class Category {
id!: number
name!: string
icon?: string
photoUrl?: string
deletedAt?: Date | null
icon!: string
// photoUrl?: string
type!: CategoryType
deletedAt!: Date | null
createdAt!: Date
updatedAt!: Date
}
export class CategoryInput {
name!: string
icon?: string
photoUrl?: string
type!: CategoryType
icon!: string
}

View File

@ -1,15 +1,17 @@
import { Category } from './Category'
import { User } from './User'
export class Expense {
id!: number
date!: Date
item?: string
item!: string
amount!: number
note?: string
deletedAt?: Date | null
deletedAt!: Date | null
createdAt!: Date
updatedAt!: Date
Category!: Category
User!: User
}
export class ExpenseInput {
@ -18,4 +20,5 @@ export class ExpenseInput {
amount!: string
note?: string
CategoryId!: number
UserId!: number
}

View File

@ -1,11 +0,0 @@
export interface MessageInput {
type: string
// text
text?: string
// sticker
packageId?: string
stickerId?: string
// image or video
originalContentUrl?: string
previewImageUrl?: string
}

View File

@ -1,9 +1,8 @@
export type RecorderType = '建喵' | '豬涵'
export type ActionType = '新增' | '編輯' | '結算'
import { User } from './User'
import { Record } from './Record'
type ActionType = '新增' | '編輯' | '結算'
export class Log {
recorder!: RecorderType
action!: ActionType
item?: string
merchant?: string
@ -18,4 +17,6 @@ export class Log {
RecordIds?: string
createdAt!: Date
updatedAt!: Date
User!: User
Records!: Record[]
}

20
src/models/Permission.ts Normal file
View File

@ -0,0 +1,20 @@
import { Role } from '@/models/index'
export type ActionTypePermission = '查看' | '新增' | '編輯' | '刪除' | '停用' | '操作'
export class Permission {
id!: number
action!: ActionTypePermission
item!: string
description!: string
deletedAt!: Date | null
createdAt!: Date
updatedAt!: Date
Roles!: Role[]
}
export class PermissionInput {
action!: ActionTypePermission
item!: string
description!: string
}

View File

@ -1,16 +1,17 @@
export type RecorderType = '建喵' | '豬涵'
// export type RecorderType = '建喵' | '豬涵'
import { User } from './User'
export class Record {
id!: number
date!: Date
item?: string
merchant?: string
item!: string
merchant!: string
amount!: number
recorder?: RecorderType
isClosed?: boolean
deletedAt?: Date | null
isClosed!: boolean
deletedAt!: Date | null
createdAt!: Date
updatedAt!: Date
User!: User
}
export class RecordInput {
@ -18,6 +19,5 @@ export class RecordInput {
item!: string
merchant!: string
amount!: string
recorder?: RecorderType
editor?: RecorderType
UserId!: number
}

16
src/models/Role.ts Normal file
View File

@ -0,0 +1,16 @@
import { Permission } from '@/models/index'
export class Role {
id!: number
name!: string
name_en!: string
deletedAt!: Date | null
createdAt!: Date
updatedAt!: Date
Permissions!: Permission[]
}
export class RoleInput {
name!: string
name_en!: string
}

35
src/models/User.ts Normal file
View File

@ -0,0 +1,35 @@
import { Record, Log, Expense, Role } from '@/models/index'
export class User {
id!: number
email!: string
displayName!: string
photoURL!: string
firebaseUid!: string
active!: boolean
createdAt!: Date
updatedAt!: Date
Records!: Record[]
Logs!: Log[]
Expenses!: Expense[]
Role!: Role
}
export class FirebaseUserInput {
email!: string
password!: string
displayName!: string
photoURL!: string
}
export class UserCreateInput {
email!: string
displayName!: string
photoURL!: string | null
firebaseUid!: string
}
export class UserEditInput {
displayName?: string
photoURL?: string
}

9
src/models/index.ts Normal file
View File

@ -0,0 +1,9 @@
export { Record, RecordInput } from './Record'
export { Log } from './Log'
export { Expense, ExpenseInput } from './Expense'
export { Category, CategoryInput } from './Category'
export type { CategoryType } from './Category'
export { User, FirebaseUserInput, UserCreateInput, UserEditInput } from './User'
export { Role, RoleInput } from './Role'
export { Permission, PermissionInput } from './Permission'
export type { ActionTypePermission } from './Permission'

View File

@ -1,4 +1,8 @@
import { Toast } from '@/utils/swal'
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useStore } from '../store/index'
import Default from '@/views/Default.vue'
const root = '/jm-expense-vue-ts'
export const routes: RouteRecordRaw[] = [
@ -6,6 +10,24 @@ export const routes: RouteRecordRaw[] = [
path: '/',
redirect: `${root}/`
},
{
path: `${root}/register`,
name: 'Register',
component: () => import('../views/Register.vue'),
meta: {
pageTitle: '註冊頁',
show: false
}
},
{
path: `${root}/login`,
name: 'Login',
component: () => import('../views/Login.vue'),
meta: {
pageTitle: '登入頁',
show: false
}
},
{
path: `${root}/`,
name: 'Home',
@ -51,6 +73,93 @@ export const routes: RouteRecordRaw[] = [
show: true
}
},
{
path: `${root}/tools`,
name: 'Tools',
component: () => import('../views/Tools.vue'),
meta: {
pageTitle: '小工具',
show: true
}
},
{
path: `${root}/game`,
name: 'Game',
component: () => import('../views/Game.vue'),
meta: {
pageTitle: '小遊戲',
show: true
// auth: ['root', 'admin', 'member']
}
},
{
path: `${root}/admin`,
name: 'Admin',
redirect: { name: 'Admin-Role' },
component: Default,
meta: {
pageTitle: '管理面板',
show: true
}
// children: [
// {
// path: 'role',
// name: 'Admin-Role',
// component: () => import('../views/Role.vue'),
// meta: {
// pageTitle: '角色管理',
// show: true
// }
// },
// {
// path: 'permission',
// name: 'Admin-Permission',
// component: () => import('../views/Permission.vue'),
// meta: {
// pageTitle: '權限管理',
// show: true
// }
// }
// ]
},
{
path: '/admin/role',
name: 'Admin-Role',
component: () => import('@/views/Role.vue'),
meta: {
pageTitle: '角色管理',
show: true
}
// children: [
// {
// path: ':id/access',
// name: 'Admin-Role-Access',
// component: () => import('@/views/Access.vue'),
// meta: {
// pageTitle: '角色管理 / 設置權限[角色名稱]',
// show: false
// }
// }
// ]
},
{
path: '/admin/role/:id/access',
name: 'Admin-Role-Access',
component: () => import('@/views/Access.vue'),
meta: {
pageTitle: '角色管理 / 設置權限[角色名稱]',
show: false
}
},
{
path: '/admin/permission',
name: 'Admin-Permission',
component: () => import('../views/Permission.vue'),
meta: {
pageTitle: '權限管理',
show: true
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
@ -63,4 +172,28 @@ const router = createRouter({
routes
})
router.beforeEach(async (to, from, next) => {
const store = useStore()
console.log('[router]firebaseUser', store.firebaseUser)
console.log('[router]currentUser ID', store.currentUser?.id)
// 目前不能使用 currentUser
if (!store.firebaseUser) {
if (to.name !== 'Login' && to.name !== 'Register') {
next({ name: 'Login' })
Toast.fire({
icon: 'error',
title: '請先登入'
})
} else {
next()
}
} else {
if (to.name === 'Login' || to.name === 'Register') {
next({ name: 'Home' })
} else {
next()
}
}
})
export default router

View File

@ -1,16 +1,55 @@
import { defineStore } from 'pinia'
import { User as FirebaseUser } from 'firebase/auth'
import { User } from '@/models'
import userAPI from '@/apis/user'
export const useStore = defineStore('index', {
state: () => ({
currentUser: localStorage.getItem('jm-user')
// currentUser: localStorage.getItem('jm-user')
firebaseUser: null as null | FirebaseUser,
currentUser: null as null | User
}),
getters: {
icon: (state) => (state.currentUser === '豬涵' ? '🐷' : '🐣')
nickName: (state) => {
if (state.currentUser?.email === 'super000999888@gmail.com') {
return '豬涵'
} else if (state.currentUser?.email === 'bir840124@gmail.com') {
return '建喵'
} else {
return ''
}
},
icon: (state) => {
if (state.currentUser?.email === 'super000999888@gmail.com') {
return '🐷'
} else if (state.currentUser?.email === 'bir840124@gmail.com') {
return '🐣'
} else {
return ''
}
}
},
actions: {
switchUser(user: string) {
localStorage.setItem('jm-user', user)
this.currentUser = user
// switchUser(user: string) {
// localStorage.setItem('jm-user', user)
// this.currentUser = user
// }
async getCurrentUser(email: string) {
try {
const { data } = await userAPI.user.getUserByEmail(email)
this.currentUser = data.data
console.log('[getCurrentUser] this.currentUser', this.currentUser)
} catch (error) {
console.error('error')
}
},
login(user: FirebaseUser) {
this.firebaseUser = user
this.getCurrentUser(user.email!)
},
logout() {
this.firebaseUser = null
this.currentUser = null
}
}
})

24
src/utils/dateFilter.ts Normal file
View File

@ -0,0 +1,24 @@
import { dayjs } from '@/utils/dayjs'
import { DateFilterData } from '@/definition/interface'
import { Record } from '@/models/Record'
export const dateFilter = (filterData: DateFilterData, data: any[]) => {
const { searchMode, filter } = filterData
if (searchMode === '月份') {
return data.filter((item: Record) => dayjs(item.date).isSame(`${filter.year}-${filter.month}`, 'month'))
} else {
if (!filter.startDate && !filter.finishDate) return []
if (filter.startDate && !filter.finishDate) {
return data.filter((item: Record) => dayjs(item.date).isSameOrAfter(filter.startDate))
} else if (!filter.startDate && filter.finishDate) {
return data.filter((item: Record) => dayjs(item.date).isSameOrBefore(filter.finishDate))
} else {
return data.filter(
(item: Record) =>
dayjs(item.date).isBetween(filter.startDate, filter.finishDate) ||
dayjs(item.date).isSame(dayjs(filter.startDate)) ||
dayjs(item.date).isSame(dayjs(filter.finishDate))
)
}
}
}

9
src/utils/dateFormat.ts Normal file
View File

@ -0,0 +1,9 @@
import { dayjs } from '@/utils/dayjs'
export const formatDate = (date: Date) => {
return dayjs(date).format('YYYY-MM-DD')
}
export const getDay = (date: Date) => {
return dayjs(date).locale('zh-tw').format('dddd')
}

17
src/utils/dayjs.ts Normal file
View File

@ -0,0 +1,17 @@
import dayjs from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import updateLocale from 'dayjs/plugin/updateLocale'
import 'dayjs/locale/zh-tw'
dayjs.extend(isBetween)
dayjs.extend(isSameOrAfter)
dayjs.extend(isSameOrBefore)
dayjs.extend(updateLocale)
dayjs.updateLocale('zh-tw', {
weekdays: ['(日)', '(一)', '(二)', '(三)', '(四)', '(五)', '(六)']
})
export { dayjs }

View File

@ -1,18 +0,0 @@
export const showWeekDay = (date: Date) => {
switch (new Date(date).getDay()) {
case 1:
return '一'
case 2:
return '二'
case 3:
return '三'
case 4:
return '四'
case 5:
return '五'
case 6:
return '六'
case 0:
return '日'
}
}

16
src/utils/lineBotMsg.ts Normal file
View File

@ -0,0 +1,16 @@
import lineBotAPI from '@/apis/lineBot'
export const pushMsgToBoth = async (text: string) => {
try {
const input = {
to: [import.meta.env.VITE_KAROL_USERID, import.meta.env.VITE_JIANMIAU_USERID],
messages: {
type: 'text',
text
}
}
await lineBotAPI.push(input)
} catch (error) {
console.error('error', error)
}
}

117
src/views/Access.vue Normal file
View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref } from 'vue'
import userAPI from '@/apis/user'
import { Role, Permission } from '@/models/index'
import Spinner from '@/components/Spinner.vue'
// import CreateRoleModalButton from '@/components/ModalButton/Role/CreateRoleModalButton.vue'
// import EditRoleModalButton from '@/components/ModalButton/Role/EditRoleModalButton.vue'
// import DeleteRoleModalButton from '@/components/ModalButton/Role/DeleteRoleModalButton.vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// data
const isLoading = ref<boolean>(true)
const editMode = ref<boolean>(false)
const role = ref<Role>(new Role())
const permissions = ref<Permission[]>([])
const editPermissionId = ref<number[]>([])
// methods
const fetchRole = async function () {
try {
const { data } = await userAPI.role.getOne(Number(route.params.id))
role.value = data.data
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
const fetchPermissions = async function () {
try {
const { data } = await userAPI.permission.getAll()
permissions.value = data.data
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
const startEditBtn = () => {
editMode.value = true
editPermissionId.value = role.value.Permissions.map((p) => p.id)
}
const checkboxClick = (id: number) => {
if (editMode.value) {
if (!editPermissionId.value.includes(id)) {
editPermissionId.value.push(id)
} else {
const index = editPermissionId.value.findIndex((pid) => pid === id)
editPermissionId.value.splice(index, 1)
}
}
}
const editPermissions = () => {
editMode.value = false
}
// created
fetchRole()
fetchPermissions()
// provide
// provide('refetchRoles', fetchRoles)
</script>
<template>
<div v-if="!isLoading">
{{ editPermissionId }}
<div class="d-flex my-3">
<!-- <CreateRoleModalButton /> -->
<button v-if="!editMode" @click="startEditBtn" class="btn btn-primary">開始編輯</button>
<template v-else>
<button @click="editMode = false" class="btn btn-secondary me-3">取消編輯</button>
<button @click="editPermissions" class="btn btn-warning">確定編輯</button>
</template>
</div>
<table class="table table-striped table-danger table-hover" v-if="permissions.length">
<thead>
<tr>
<th scope="col" v-if="editMode">#</th>
<th scope="col">ID</th>
<th scope="col">ACTION</th>
<th scope="col">ITEM</th>
<th scope="col">DESCRIPTION</th>
</tr>
</thead>
<tbody>
<tr v-for="(permission, index) in permissions" :key="index" @click="checkboxClick(permission.id)">
<td v-if="editMode">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value=""
id="flexCheckChecked"
:checked="editPermissionId.includes(permission.id)"
/>
<label class="form-check-label" for="flexCheckChecked"></label>
</div>
</td>
<td>{{ permission.id }}</td>
<td>{{ permission.action }}</td>
<td>{{ permission.item }}</td>
<td>{{ permission.description }}</td>
</tr>
</tbody>
</table>
<img
v-else
class="img-fluid"
src="https://stickershop.line-scdn.net/stickershop/v1/sticker/208430466/iPhone/sticker_animation@2x.png"
alt="img"
/>
</div>
<Spinner v-else />
</template>

View File

@ -1,130 +1,58 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, provide } from 'vue'
import recordAPI from '../apis/record'
import { Record } from '../models/Record'
import { showWeekDay } from '../utils/helper'
import { Record } from '@/models/index'
import { getDay } from '../utils/dateFormat'
import Spinner from '../components/Spinner.vue'
type SearchMode = '月份' | '日期'
import DateFilter from '@/components/DateFilter.vue'
import { DateFilterData } from '@/definition/interface'
import { dateFilter } from '@/utils/dateFilter'
import { dayjs } from '@/utils/dayjs'
// data
const isLoading = ref<boolean>(true)
const records = ref<Record[]>([])
const searchMode = ref<SearchMode>('月份')
const year = ref()
const month = ref()
const startDate = ref<Date | string>('')
const finishDate = ref<Date | string>('')
const dateFilterData = ref<DateFilterData>({
searchMode: '月份',
filter: {
year: 0,
month: 0,
startDate: '',
finishDate: ''
}
})
// methods
const fetchRecords = async function () {
try {
const { data } = await recordAPI.getAll()
records.value = data.filter((item: Record) => item.isClosed === true && item.deletedAt === null)
year.value = new Date(records.value[0].date).getFullYear()
month.value = new Date(records.value[0].date).getMonth() + 1
records.value = data.data.filter((item: Record) => item.isClosed === true)
dateFilterData.value.filter = {
year: dayjs(records.value[0].date).year(),
month: dayjs(records.value[0].date).month() + 1,
startDate: dayjs(records.value[0].date).startOf('month').format('YYYY-MM-DD'),
finishDate: dayjs(records.value[0].date).endOf('month').format('YYYY-MM-DD')
}
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
const radioClick = (radio: SearchMode) => {
searchMode.value = radio
}
// computed
const filteredRecord = computed(() => {
if (searchMode.value === '月份') {
return records.value?.filter(
(item: Record) =>
new Date(item.date).getFullYear() === year.value && new Date(item.date).getMonth() + 1 === month.value
)
} else {
if (!startDate.value && !finishDate.value) return []
if (startDate.value && !finishDate.value) {
return records.value?.filter(
(item: Record) => new Date(item.date).getTime() >= new Date(startDate.value).getTime()
)
} else if (!startDate.value && finishDate.value) {
return records.value?.filter(
(item: Record) => new Date(item.date).getTime() <= new Date(finishDate.value + ' 23:59:59').getTime()
)
} else {
return records.value?.filter(
(item: Record) =>
new Date(item.date).getTime() >= new Date(startDate.value).getTime() &&
new Date(item.date).getTime() <= new Date(finishDate.value + ' 23:59:59').getTime()
)
}
}
return dateFilter(dateFilterData.value, records.value)
})
// created
fetchRecords()
// provide
provide('dateFilterData', dateFilterData)
</script>
<template>
<div v-if="!isLoading">
<div class="d-flex mb-4">
<div class="m-2 me-5">
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="flexRadioDefault"
id="flexRadioDefault1"
@click="radioClick('月份')"
checked
/>
<label class="form-check-label" for="flexRadioDefault1">月份搜尋</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="flexRadioDefault"
id="flexRadioDefault2"
@click="radioClick('日期')"
/>
<label class="form-check-label" for="flexRadioDefault2">日期搜尋</label>
</div>
</div>
<template v-if="searchMode === '月份'">
<div>
<label for="year" class="form-label">西元年</label>
<select class="form-select" aria-label="Default select example" v-model="year">
<option v-for="n in 100" :key="n" :value="n + 2020">{{ n + 2020 }}</option>
</select>
</div>
<div class="ms-3">
<label for="finishDate" class="form-label">月份</label>
<select class="form-select" aria-label="Default select example" v-model="month">
<option v-for="n in 12" :key="n" :value="n">{{ n }}</option>
</select>
</div>
</template>
<template v-else>
<div>
<label for="startDate" class="form-label">開始日期</label>
<input
v-model="startDate"
type="date"
id="startDate"
class="form-control"
aria-describedby="passwordHelpInline"
/>
</div>
<div class="ms-3">
<label for="finishDate" class="form-label">結束日期</label>
<input
v-model="finishDate"
type="date"
id="finishDate"
class="form-control"
aria-describedby="passwordHelpInline"
/>
</div>
</template>
</div>
<DateFilter />
<table class="table table-striped table-info table-hover" v-if="filteredRecord.length">
<thead>
<tr>
@ -139,21 +67,11 @@ fetchRecords()
</thead>
<tbody>
<tr v-for="(record, index) in filteredRecord" :key="index">
<td>
{{ record.item }}
</td>
<td>
{{ record.merchant }}
</td>
<td>
{{ record.amount }}
</td>
<td>
{{ new Date(record.date).toLocaleDateString() + ` (${showWeekDay(record.date)})` }}
</td>
<td id="column-item">
{{ record.recorder }}
</td>
<td>{{ record.item }}</td>
<td>{{ record.merchant }}</td>
<td>{{ record.amount }}</td>
<td>{{ new Date(record.date).toLocaleDateString() + ' ' + getDay(record.date) }}</td>
<td id="column-item">{{ record.User.displayName }}</td>
<td id="column-item">{{ new Date(record.createdAt).toLocaleString() }}</td>
<td id="column-item">{{ new Date(record.updatedAt).toLocaleString() }}</td>
</tr>
@ -162,8 +80,8 @@ fetchRecords()
<img
v-else
class="img-fluid"
src="https://memeprod.sgp1.digitaloceanspaces.com/user-wtf/1581909112681.jpg"
alt=""
src="https://stickershop.line-scdn.net/stickershop/v1/sticker/208430466/iPhone/sticker_animation@2x.png"
alt="img"
/>
</div>
<Spinner v-else />

3
src/views/Default.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

View File

@ -1,175 +1,195 @@
<script setup lang="ts">
import { ref } from 'vue'
import expenseAPI from '../apis/expense'
import { Expense, ExpenseInput } from '../models/Expense'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '../utils/swal'
import { showWeekDay } from '../utils/helper'
import { ref, provide, computed, watch } from 'vue'
import expenseAPI from '@/apis/expense'
import { Expense, Category } from '@/models'
import { getDay } from '../utils/dateFormat'
import Spinner from '@/components/Spinner.vue'
import RightPanel from '@/components/RightPanel.vue'
import CategoryRP from '@/components/RightPanel/CategoryRP.vue'
import CreateExpenseModalButton from '@/components/ModalButton/Expense/CreateExpenseModalButton.vue'
import EditExpenseModalButton from '@/components/ModalButton/Expense/EditExpenseModalButton.vue'
import DeleteExpenseModalButton from '@/components/ModalButton/Expense/DeleteExpenseModalButton.vue'
import FilterBox from '@/components/FilterBox.vue'
import DateFilter from '@/components/DateFilter.vue'
import { DateFilterData } from '@/definition/interface'
import { dateFilter } from '@/utils/dateFilter'
import { dayjs } from '@/utils/dayjs'
// data
const isLoading = ref<boolean>(true)
const expenses = ref<Expense[]>([])
const rightPanelOpen = ref(false)
const expenses = ref<Expense[]>()
const categories = ref<Category[]>()
const categoryRPOpen = ref<boolean>(false)
const categoryFilters = ref<number[]>([])
const dateFilterData = ref<DateFilterData>({
searchMode: '月份',
filter: {
year: dayjs().year(),
month: dayjs().month() + 1,
startDate: dayjs().startOf('month').format('YYYY-MM-DD hh:mm:ss'),
finishDate: dayjs().endOf('month').format('YYYY-MM-DD hh:mm:ss')
}
})
const selectedType = ref<'支出' | '收入' | 'ALL'>('ALL')
// computed
const allCategories = computed<number[]>(() => {
if (selectedType.value !== 'ALL') {
return categories
.value!.filter((category: Category) => category.type === selectedType.value)
.map((item: Category) => item.id)
} else {
return categories.value!.map((item: Category) => item.id)
}
})
const categoriesByType = computed(() => {
if (selectedType.value === 'ALL') {
return categories.value
} else {
return categories.value!.filter((category: Category) => category.type === selectedType.value)
}
})
const filteredExpenses = computed(() => {
let chosenExpenses: Expense[] = []
// category filter
chosenExpenses = expenses.value!.filter((expense: Expense) => categoryFilters.value.includes(expense.Category.id))
// date filter
return dateFilter(dateFilterData.value, chosenExpenses)
})
const amountSum = computed(() => {
let income = 0
let expense = 0
filteredExpenses.value.forEach((item: Expense) => {
if (item.Category.type === '支出') {
expense += item.amount
} else {
income += item.amount
}
})
return {
income,
expense
}
})
// methods
const fetchExpenses = async function () {
try {
const { data } = await expenseAPI.expense.getAll()
expenses.value = data.filter((item: Expense) => item.deletedAt === null)
isLoading.value = false
expenses.value = data.data
} catch (error) {
console.error('error', error)
}
}
const createBtnClick = async () => {
const fetchCategories = async () => {
try {
const { value: formValues } = await ConfirmBox.fire({
title: '新增資料',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-categoryId" class="col-form-label">類別</label>
</div>
<div class="col-auto">
<input type="text" id="swal-categoryId" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">項目</label>
</div>
<div class="col-auto">
<input type="text" id="swal-item" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-amount" class="col-form-label">金額</label>
</div>
<div class="col-auto">
<input type="number" id="swal-amount" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-note" class="col-form-label">備註</label>
</div>
<div class="col-auto">
<input type="text" id="swal-note" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-date" class="col-form-label">日期</label>
</div>
<div class="col-auto">
<input type="date" id="swal-date" class="form-control" value="${new Date().toJSON().substring(0, 10)}">
</div>
</div>
`,
preConfirm: () => {
const categoryId = (document.getElementById('swal-categoryId') as HTMLInputElement).value
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const amount = (document.getElementById('swal-amount') as HTMLInputElement).value
const date = new Date((document.getElementById('swal-date') as HTMLInputElement).value)
const note = (document.getElementById('swal-note') as HTMLInputElement).value
if (!item || !amount || !date) {
Swal.showValidationMessage('除了[備註],所有資料都是必填!')
}
return {
input: {
CategoryId: Number(categoryId),
item,
amount,
note,
date
} as ExpenseInput
}
}
})
if (formValues) {
createExpense(formValues)
}
const { data } = await expenseAPI.category.getAll()
categories.value = data.data
categoryFilters.value = categories.value!.map((item: Category) => item.id)
} catch (error) {
console.error('error', error)
}
}
const createExpense = async function (formValues: { input: ExpenseInput }) {
try {
await expenseAPI.expense.create(formValues.input)
fetchExpenses()
Toast.fire({
icon: 'success',
title: '成功建立資料!'
})
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '新增資料失敗!'
})
}
}
const editBtnClick = (id: number) => {
console.log('editBtnClick', id)
}
// created
fetchExpenses()
fetchCategories()
// watch
watch(selectedType, (newVal) => {
if (newVal !== 'ALL') {
categoryFilters.value = categories
.value!.filter((category: Category) => category.type === selectedType.value)
.map((item: Category) => item.id)
} else {
categoryFilters.value = categories.value!.map((item: Category) => item.id)
}
})
// provide
provide('refetchExpenses', fetchExpenses)
provide('refetchCategories', fetchCategories)
provide('categories', categories)
provide('categoryFilters', categoryFilters)
provide('dateFilterData', dateFilterData)
provide('categoryRPOpen', categoryRPOpen)
provide('categoriesByType', categoriesByType)
</script>
<template>
<div v-if="!isLoading">
<div v-if="expenses && categories">
<!-- buttons -->
<div class="d-flex mb-3" style="width: 100vw">
<button type="button" class="btn btn-primary me-3" @click="createBtnClick">新增資料</button>
<button type="button" class="btn btn-warning" @click="rightPanelOpen = !rightPanelOpen">查看類別</button>
<CreateExpenseModalButton />
<button type="button" class="btn btn-warning text-dark" @click="categoryRPOpen = true">查看類別</button>
</div>
<!-- filter -->
<div class="d-flex mb-4" style="width: 100%">
<div class="mt-4" style="width: 50px">
<label for="type" style="float: left; font-size: 0.7em">TYPE</label>
<select class="form-select" id="type" aria-label="Default select example" v-model="selectedType">
<option selected>ALL</option>
<option>支出</option>
<option>收入</option>
</select>
</div>
<div>
<DateFilter style="width: 250px" />
</div>
</div>
<!-- sum -->
<div class="d-flex align-items-center mb-4" style="width: 100%">
<span class="badge bg-warning text-dark fs-3">收入 $ {{ amountSum.income }}</span>
<i class="fas fa-minus mx-2"></i>
<span class="badge bg-success fs-3">支出 $ {{ amountSum.expense }}</span>
<i class="fas fa-equals mx-2"></i>
<span class="badge bg-info fs-3">$ {{ amountSum.income - amountSum.expense }}</span>
</div>
<div class="d-flex align-items-start">
<FilterBox :allCategories="allCategories" />
<table class="table table-striped table-success table-hover" v-if="filteredExpenses?.length">
<thead>
<tr class="table-light">
<th scope="col">#</th>
<th scope="col">#</th>
<th scope="col">類別</th>
<th scope="col">項目</th>
<th scope="col">金額</th>
<th scope="col">備註</th>
<th scope="col">日期</th>
</tr>
</thead>
<tbody>
<template v-for="(expense, index) in filteredExpenses" :key="index">
<tr :class="expense.Category.type === '支出' ? 'table-success' : 'table-warning'">
<td>
<EditExpenseModalButton :expense="expense" />
</td>
<td>
<DeleteExpenseModalButton :expense="expense" />
</td>
<td>
<i :class="expense.Category.icon"></i>
</td>
<td>{{ expense.item }}</td>
<td>{{ expense.amount }}</td>
<td>{{ expense.note }}</td>
<td>{{ new Date(expense.date).toLocaleDateString() + ' ' + getDay(expense.date) }}</td>
</tr>
</template>
</tbody>
</table>
<img
v-else
class="img-fluid"
src="https://stickershop.line-scdn.net/stickershop/v1/sticker/208430466/iPhone/sticker_animation@2x.png"
alt="img"
/>
</div>
<table class="table table-striped table-success table-hover" v-if="expenses.length">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">類別</th>
<th scope="col">項目</th>
<th scope="col">金額</th>
<th scope="col">備註</th>
<th scope="col">日期</th>
</tr>
</thead>
<tbody>
<tr v-for="(expense, index) in expenses" :key="index">
<td>
<i class="fas fa-edit" @click="editBtnClick(expense.id)"></i>
</td>
<td>
{{ expense.Category.name }}
</td>
<td>
{{ expense.item }}
</td>
<td>
{{ expense.amount }}
</td>
<td>
{{ expense.note }}
</td>
<td>
{{ new Date(expense.date).toLocaleDateString() + ` (${showWeekDay(expense.date)})` }}
</td>
</tr>
</tbody>
</table>
<!-- <img
v-else
class="img-fluid"
src="https://memeprod.sgp1.digitaloceanspaces.com/user-wtf/1581909112681.jpg"
alt=""
/> -->
<transition name="slide-right">
<RightPanel v-show="rightPanelOpen" />
<CategoryRP v-if="categoryRPOpen" />
</transition>
</div>
<Spinner v-else />

71
src/views/Game.vue Normal file
View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from 'vue'
import NumberWordle from '@/components/Game/NumberWordle.vue'
import NumberAB from '@/components/Game/NumberAB.vue'
const tabs = ref(['猜數字(Wordle)', '猜數字(AB)'])
const nowTab = ref('猜數字(Wordle)')
</script>
<template>
<div>
<div class="d-flex justify-content-center m-2 mb-5">
<button
v-for="(tab, index) in tabs"
:key="index"
type="button"
:class="`btn btn-${nowTab !== tab ? 'outline-' : ''}danger me-2`"
@click="nowTab = tab"
>
{{ tab }}
</button>
</div>
<NumberWordle v-if="nowTab === '猜數字(Wordle)'"></NumberWordle>
<NumberAB v-if="nowTab === '猜數字(AB)'" />
</div>
</template>
<style scoped>
.num-block {
height: 50px;
width: 50px;
background-color: #d3d3d3;
border: 2px solid black;
border-radius: 8%;
margin: 0 5px;
text-align: center;
font-size: 28px;
}
.num-btn {
width: 15%;
background-color: white;
border: 2px solid black;
border-radius: 8%;
margin: 5px;
text-align: center;
font-size: 28px;
cursor: pointer;
}
.btn-customer {
width: 35%;
border: 2px solid black;
border-radius: 5%;
margin: 5px 5px;
text-align: center;
font-size: 20px;
cursor: pointer;
color: black;
}
.back-btn {
background-color: yellow;
}
.enter-btn {
background-color: greenyellow;
}
.reset-btn {
background-color: darkturquoise;
}
</style>

View File

@ -1,10 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue'
import recordAPI from '../apis/record'
import { Record } from '../models/Record'
import Spinner from '../components/Spinner.vue'
import CreateRecordModalButton from '../components/modalButton/CreateRecordModalButton.vue'
import recordAPI from '@/apis/record'
import lineBotAPI from '@/apis/lineBot'
import { Record } from '@/models'
import { CallParent } from '@/cocos/config'
// components
import Spinner from '@/components/Spinner.vue'
import CreateRecordModalButton from '@/components/ModalButton/Record/CreateRecordModalButton.vue'
// class
class MonthData {
total!: number
closedAmount!: number
@ -21,7 +25,7 @@ const lastMonthData = ref<MonthData>(new MonthData())
const fetchRecords = async function () {
try {
const { data } = await recordAPI.getAll()
records.value = data.filter((item: Record) => item.deletedAt === null)
records.value = data.data
// monthData
const nowYear = new Date().getFullYear()
const nowMonth = new Date().getMonth() + 1
@ -67,20 +71,24 @@ fetchRecords()
//
const handle = async () => {
const input = {
to: [process.env.VUE_APP_KAROL_USERID, process.env.VUE_APP_JIANMIAU_USERID],
messages: { type: 'text', text: '卡比覺得促咪!' }
try {
const input = {
to: [import.meta.env.VITE_KAROL_USERID, import.meta.env.VITE_JIANMIAU_USERID],
messages: { type: 'text', text: '卡比覺得促咪!' }
}
await lineBotAPI.push(input)
CallParent('Speak', '按我了 你是笨蛋')
} catch (error) {
console.error('error', error)
}
console.log('input', input)
await recordAPI.pushLineMsg(input)
}
</script>
<template>
<div v-if="!isLoading">
<div class="d-flex mb-3">
<div class="d-flex mb-3" style="width: 100vw">
<CreateRecordModalButton view="Home" class="me-3" />
<button type="button" class="btn btn-success me-3" @click="handle">笨蛋才按我</button>
<button type="button" class="btn btn-danger me-3" @click="handle">笨蛋才按我</button>
</div>
<div class="list-group list-group-checkable">
<label class="list-group-item py-3 mb-3">

137
src/views/Login.vue Normal file
View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import { ref } from 'vue'
// import userAPI from '../apis/user'
// import { User } from '../models/User'
import { Toast } from '@/utils/swal'
import { useRouter } from 'vue-router'
const router = useRouter()
// import { useStore } from '../store/index'
// const store = useStore()
// import {
// getAuth,
// GoogleAuthProvider,
// signInWithEmailAndPassword,
// signInWithPopup,
// User as FirebaseUser
// } from 'firebase/auth'
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth'
// import { getAuth, GoogleAuthProvider, signInWithEmailAndPassword } from 'firebase/auth'
const auth = getAuth()
// const provider = new GoogleAuthProvider()
// data
const email = ref('')
const password = ref('')
// methods
const signInEmail = () => {
if (!email.value || !password.value) {
return Toast.fire({
icon: 'warning',
title: '信箱、密碼為必填選項!'
})
}
signInWithEmailAndPassword(auth, email.value, password.value)
.then((userCredential) => {
// Signed in
const user = userCredential.user
Toast.fire({
icon: 'success',
title: `歡迎[${user.displayName}]進入豬豬世界🐷`
})
router.push({ name: 'Home' })
})
.catch((error) => {
const errorCode = error.code
const errorMessage = error.message
console.log('error', errorCode, errorMessage)
Toast.fire({
icon: 'error',
title: '豬豬世界不歡迎你 ☠️'
})
})
}
// const signInGoogle = () => {
// signInWithPopup(auth, provider)
// .then(async (result) => {
// // const credential = GoogleAuthProvider.credentialFromResult(result)
// // const token = credential?.accessToken
// const firebaseUser: FirebaseUser = result.user
// console.log('firebaseUser', firebaseUser)
// //
// const { data } = await userAPI.getAll()
// const user = data.data.find((user: User) => user.email === firebaseUser.email)
// if (!user) {
// await userAPI.create({
// email: user.email!,
// displayName: user.displayName!,
// photoURL: user.photoURL ? user.photoURL : null,
// firebaseUid: user.uid
// })
// Toast.fire({
// icon: 'success',
// title: `@GOOGLE@ [${firebaseUser.displayName}]🐷`
// })
// router.push({ name: 'Home' })
// } else {
// Toast.fire({
// icon: 'error',
// title: `${firebaseUser.email} `
// })
// }
// })
// .catch((error) => {
// console.error('error', error)
// })
// }
</script>
<template>
<div>
<button class="btn btn-info register-btn" type="button" @click="router.push({ name: 'Register' })">前往註冊</button>
<div class="m-auto" style="width: 70vw">
<form>
<img src="@/assets/logo2.png" alt="" width="150" height="150" />
<h1 class="h3 mb-3 fw-normal">臭建喵記帳 Login</h1>
<div class="form-floating">
<input type="email" class="form-control" id="email-login" v-model="email" autocomplete="on" />
<label for="email-login">信箱</label>
</div>
<div class="form-floating">
<input
type="password"
class="form-control"
id="password-login"
v-model="password"
autocomplete="on"
@keyup.enter="signInEmail"
/>
<label for="password-login">密碼</label>
</div>
<!-- <div class="checkbox mb-3">
<label> <input type="checkbox" value="remember-me" /> Remember me </label>
</div> -->
<button
class="w-100 btn btn-lg mt-3"
type="button"
@click="signInEmail"
style="color: white; background-color: coral"
>
進入豬豬世界
</button>
<!-- <button class="w-100 btn btn-lg btn-success mt-3" type="button" @click="signInGoogle">Google登入</button> -->
</form>
</div>
</div>
</template>
<style scoped>
.register-btn {
position: fixed;
top: 1em;
right: 1em;
}
</style>

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { showWeekDay } from '../utils/helper'
import recordAPI from '../apis/record'
import { Log } from '../models/Log'
import { getDay } from '../utils/dateFormat'
import recordAPI from '@/apis/record'
import { Log, Record } from '@/models'
import Swal from 'sweetalert2'
import Spinner from '../components/Spinner.vue'
import Spinner from '@/components/Spinner.vue'
const tabs = [
{ title: '總覽', btnColor: 'secondary' },
@ -12,43 +12,36 @@ const tabs = [
{ title: '編輯', btnColor: 'success' },
{ title: '結算', btnColor: 'danger' }
]
const isLoading = ref<boolean>(false)
const isLoading = ref<boolean>(true)
const logs = ref<Log[]>([])
const nowTab = ref('總覽')
const logCount = ref<any>({ 總覽: 5, 新增: 5, 編輯: 5, 結算: 5 })
const filteredLogs = computed(() => {
if (nowTab.value === '總覽') return logs.value
return logs.value.filter((log) => log.action === nowTab.value)
return logs.value.filter((log: Log) => log.action === nowTab.value)
})
const fetchLogs = async function () {
try {
const { data } = await recordAPI.getLogs()
logs.value = data
logs.value = data.data
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
const listIconClick = async (recordIds: string | undefined) => {
if (!recordIds) return
const recordIdArr = recordIds.split(',')
const recordArr = []
for (const id of recordIdArr) {
const { data } = await recordAPI.getOne(Number(id))
recordArr.push(data)
}
const listIconClick = async (records: Record[]) => {
if (!records.length) return
let html = `<table class="table"><thead><tr><th scope="col">項目</th><th scope="col">商家</th><th scope="col">金額</th><th scope="col">日期</th></tr></thead><tbody>`
for (const id of recordIdArr) {
const { data } = await recordAPI.getOne(Number(id))
html += `<tr><td>${data.item}</td><td>${data.merchant}</td><td>${data.amount}</td><td>${new Date(
data.date
for (const record of records) {
html += `<tr><td>${record.item}</td><td>${record.merchant}</td><td>${record.amount}</td><td>${new Date(
record.date
).toLocaleDateString()}</td></tr>`
}
html += `</tbody></table>${
document.documentElement.scrollWidth >= 500 ? '' : '<style>table{font-size:0.5em;}</style>'
document.documentElement.scrollWidth >= 500 ? '' : '<style>table{font-size:0.7em;}</style>'
}`
await Swal.fire({
title: '結算紀錄',
@ -61,115 +54,52 @@ fetchLogs()
</script>
<template>
<div v-if="!isLoading && logs.length">
<template v-if="logs.length">
<div id="pc">
<div class="btn-group mb-3" role="group" aria-label="Basic radio toggle button group">
<template v-for="(tab, index) in tabs" :key="index">
<input
type="radio"
class="btn-check"
name="btnradio"
:id="tab.title"
autocomplete="off"
:checked="nowTab === tab.title"
/>
<label :class="`btn btn-outline-${tab.btnColor}`" :for="tab.title" @click="nowTab = tab.title">{{
tab.title
}}</label>
</template>
</div>
<div class="row h-20 mb-3" v-for="(log, index) in filteredLogs" :key="index">
<div class="col-2 mt-4">
<div class="fw-bold">
{{ new Date(log.createdAt).toLocaleDateString() }}
<!-- {{ new Date(log.createdAt).toLocaleDateString() + ` (${showWeekDay(log.createdAt)})` }} -->
</div>
<div class="fw-bold">{{ new Date(log.createdAt).toLocaleTimeString() }}</div>
</div>
<div class="col-9">
<div class="bg-white border p-3">
<div class="d-flex">
<img
v-if="log.recorder === '建喵'"
src="../assets/jianmiau.jpeg"
class="my-auto img-thumbnail me-2"
width="60"
height="60"
/><img v-else src="../assets/karol.png" class="my-auto img-thumbnail me-2" width="60" height="60" />
<div class="text-start mt-2">
<strong class="px-1 me-1" style="background-color: yellow">{{ log.recorder }}</strong>
<strong style="color: salmon">{{ log.action }}紀錄</strong>
<i class="fas fa-chevron-right m-2"></i>
<template v-if="log.action === '新增' || log.action === '編輯'">
<strong style="color: blue">{{ log.item }}</strong> |
<strong style="color: brown">{{ log.merchant }}</strong> |
<strong style="color: orange">$ {{ log.amount }}</strong> |
<strong style="color: green">{{ new Date(log.date || '').toLocaleDateString() }}</strong>
</template>
<template v-if="log.action === '編輯'">
<br />
<h6>
原紀錄<strong style="color: blue">{{ log.itemBefore }}</strong> |
<strong style="color: brown">{{ log.merchantBefore }}</strong> |
<strong style="color: orange">$ {{ log.amountBefore }}</strong> |
<strong style="color: green">{{ new Date(log.dateBefore || '').toLocaleDateString() }}</strong>
</h6>
</template>
<template v-if="log.action === '結算'">
<span>
結算金額
<strong style="color: orange">$ {{ log.closeAmount }} </strong>
</span>
<i class="far fa-list-alt fa-lg ms-2" id="records" @click="listIconClick(log.RecordIds)"></i>
</template>
</div>
</div>
</div>
</div>
</div>
<div v-if="!isLoading">
<div id="pc" style="width: 100vw">
<div class="btn-group mb-3" role="group" aria-label="Basic radio toggle button group">
<template v-for="(tab, index) in tabs" :key="index">
<input
type="radio"
class="btn-check"
name="btnradio"
:id="tab.title"
autocomplete="off"
:checked="nowTab === tab.title"
/>
<label :class="`btn btn-outline-${tab.btnColor}`" :for="tab.title" @click="nowTab = tab.title">{{
tab.title
}}</label>
</template>
</div>
<!-- mobile -->
<div id="mobile" class="card text-center">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item" v-for="tab in tabs" :key="tab.title" @click="nowTab = tab.title">
<span class="nav-link" :class="{ active: nowTab === tab.title }">{{ tab.title }}</span>
</li>
</ul>
<div class="row h-20 mb-3" v-for="(log, index) in filteredLogs" :key="index">
<div class="col-3 mt-4">
<div class="fw-bold">
{{ new Date(log.createdAt).toLocaleDateString() + ' ' + getDay(log.createdAt) }}
</div>
<div class="fw-bold">{{ new Date(log.createdAt).toLocaleTimeString() }}</div>
</div>
<div class="card-body">
<div class="card mb-3" v-for="(log, index) in filteredLogs.slice(0, logCount[nowTab])" :key="index">
<div class="card-header">
{{
new Date(log.createdAt).toLocaleDateString() +
` (${showWeekDay(log.createdAt)}) ` +
new Date(log.createdAt).toLocaleTimeString()
}}
</div>
<div class="card-body d-flex">
<div class="col-9">
<div class="bg-white border p-3">
<div class="d-flex">
<img
v-if="log.recorder === '建喵'"
v-if="log.User.displayName === '建喵'"
src="../assets/jianmiau.jpeg"
class="my-auto img-thumbnail me-2"
width="60"
height="60"
/><img v-else src="../assets/karol.png" class="my-auto img-thumbnail me-2" width="60" height="60" />
<div class="text-start mt-2">
<strong class="px-1 me-1" style="background-color: yellow">{{ log.recorder }}</strong>
<strong class="px-1 me-1" style="background-color: yellow">{{ log.User.displayName }}</strong>
<strong style="color: salmon">{{ log.action }}紀錄</strong>
<i class="fas fa-chevron-right m-2"></i>
<template v-if="log.action === '新增' || log.action === '編輯'">
<h6>
<strong style="color: blue">{{ log.item }}</strong> |
<strong style="color: brown">{{ log.merchant }}</strong> |
<strong style="color: orange">$ {{ log.amount }}</strong> |
<strong style="color: green">{{ new Date(log.date || '').toLocaleDateString() }}</strong>
</h6>
<strong style="color: blue">{{ log.item }}</strong> |
<strong style="color: brown">{{ log.merchant }}</strong> |
<strong style="color: orange">$ {{ log.amount }}</strong> |
<strong style="color: green">{{ new Date(log.date || '').toLocaleDateString() }}</strong>
</template>
<template v-if="log.action === '編輯'">
<br />
<h6>
原紀錄<strong style="color: blue">{{ log.itemBefore }}</strong> |
<strong style="color: brown">{{ log.merchantBefore }}</strong> |
@ -178,36 +108,90 @@ fetchLogs()
</h6>
</template>
<template v-if="log.action === '結算'">
<h6>
<span>
結算金額
<strong style="color: orange">$ {{ log.closeAmount }} </strong>
</span>
<i class="far fa-list-alt fa-lg ms-2" id="records" @click="listIconClick(log.RecordIds)"></i>
</h6>
<span>
結算金額
<strong style="color: orange">$ {{ log.closeAmount }} </strong>
</span>
<i class="far fa-list-alt fa-lg ms-2" id="records" @click="listIconClick(log.Records)"></i>
</template>
</div>
</div>
</div>
<div class="mt-3">
<button
v-if="logCount[nowTab] < filteredLogs.length"
type="button"
class="btn btn-secondary"
@click="logCount[nowTab] += 5"
>
更多紀錄
</button>
</div>
</div>
</div>
</template>
<img
v-else
class="img-fluid"
src="https://memeprod.sgp1.digitaloceanspaces.com/user-wtf/1581909112681.jpg"
alt=""
/>
</div>
<!-- mobile -->
<div id="mobile" class="card text-center" style="width: 100vw">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item" v-for="tab in tabs" :key="tab.title" @click="nowTab = tab.title">
<span class="nav-link" :class="{ active: nowTab === tab.title }">{{ tab.title }}</span>
</li>
</ul>
</div>
<div class="card-body">
<div class="card mb-3" v-for="(log, index) in filteredLogs.slice(0, logCount[nowTab])" :key="index">
<div class="card-header">
{{
new Date(log.createdAt).toLocaleDateString() +
' ' +
getDay(log.createdAt) +
new Date(log.createdAt).toLocaleTimeString()
}}
</div>
<div class="card-body d-flex">
<img
v-if="log.User.displayName === '建喵'"
src="../assets/jianmiau.jpeg"
class="my-auto img-thumbnail me-2"
width="60"
height="60"
/><img v-else src="../assets/karol.png" class="my-auto img-thumbnail me-2" width="60" height="60" />
<div class="text-start mt-2">
<strong class="px-1 me-1" style="background-color: yellow">{{ log.User.displayName }}</strong>
<strong style="color: salmon">{{ log.action }}紀錄</strong>
<i class="fas fa-chevron-right m-2"></i>
<template v-if="log.action === '新增' || log.action === '編輯'">
<h6>
<strong style="color: blue">{{ log.item }}</strong> |
<strong style="color: brown">{{ log.merchant }}</strong> |
<strong style="color: orange">$ {{ log.amount }}</strong> |
<strong style="color: green">{{ new Date(log.date || '').toLocaleDateString() }}</strong>
</h6>
</template>
<template v-if="log.action === '編輯'">
<h6>
原紀錄<strong style="color: blue">{{ log.itemBefore }}</strong> |
<strong style="color: brown">{{ log.merchantBefore }}</strong> |
<strong style="color: orange">$ {{ log.amountBefore }}</strong> |
<strong style="color: green">{{ new Date(log.dateBefore || '').toLocaleDateString() }}</strong>
</h6>
</template>
<template v-if="log.action === '結算'">
<h6>
<span>
結算金額
<strong style="color: orange">$ {{ log.closeAmount }} </strong>
</span>
<i class="far fa-list-alt fa-lg ms-2" id="records" @click="listIconClick(log.Records)"></i>
</h6>
</template>
</div>
</div>
</div>
<div class="mt-3">
<button
v-if="logCount[nowTab] < filteredLogs.length"
type="button"
class="btn btn-secondary"
@click="logCount[nowTab] += 5"
>
更多紀錄
</button>
</div>
</div>
</div>
</div>
<Spinner v-else />
</template>
@ -217,6 +201,9 @@ fetchLogs()
cursor: pointer;
color: salmon;
}
li:hover {
cursor: pointer;
}
@media screen and (max-width: 499px) {
#pc {
display: none;

77
src/views/Permission.vue Normal file
View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref, provide } from 'vue'
import userAPI from '@/apis/user'
import { Permission } from '@/models/index'
import Spinner from '@/components/Spinner.vue'
import CreatePermissionModalButton from '@/components/ModalButton/Permission/CreatePermissionModalButton.vue'
import EditPermissionModalButton from '@/components/ModalButton/Permission/EditPermissionModalButton.vue'
import DeletePermissionModalButton from '@/components/ModalButton/Permission/DeletePermissionModalButton.vue'
// data
const isLoading = ref<boolean>(true)
const permissions = ref<Permission[]>([])
// methods
const fetchPermissions = async function () {
try {
const { data } = await userAPI.permission.getAll()
permissions.value = data.data
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
// created
fetchPermissions()
// provide
provide('refetchPermissions', fetchPermissions)
</script>
<template>
<div v-if="!isLoading">
<div class="d-flex my-3">
<CreatePermissionModalButton />
</div>
<table class="table table-striped table-danger table-hover" v-if="permissions.length">
<thead>
<tr>
<!-- <th scope="col">#</th> -->
<th scope="col">ID</th>
<th scope="col">ACTION</th>
<th scope="col">ITEM</th>
<th scope="col">DESCRIPTION</th>
<th scope="col">#</th>
</tr>
</thead>
<tbody>
<tr v-for="(permission, index) in permissions" :key="index">
<!-- <td>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="flexCheckChecked" checked />
<label class="form-check-label" for="flexCheckChecked"></label>
</div>
</td> -->
<td>{{ permission.id }}</td>
<td>{{ permission.action }}</td>
<td>{{ permission.item }}</td>
<td>{{ permission.description }}</td>
<td>
<EditPermissionModalButton :permission="permission" />
<DeletePermissionModalButton :permission="permission" class="ms-2" />
</td>
<!-- <td> -->
<!-- <DeletePermissionModalButton :permission="permission" /> -->
<!-- </td> -->
</tr>
</tbody>
</table>
<img
v-else
class="img-fluid"
src="https://stickershop.line-scdn.net/stickershop/v1/sticker/208430466/iPhone/sticker_animation@2x.png"
alt="img"
/>
</div>
<Spinner v-else />
</template>

View File

@ -1,243 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue'
import recordAPI from '../apis/record'
import { Record, RecordInput } from '../models/Record'
import Swal from 'sweetalert2'
import { Toast, ConfirmBox } from '../utils/swal'
import { showWeekDay } from '../utils/helper'
import Spinner from '../components/Spinner.vue'
import CreateRecordModalButton from '../components/modalButton/CreateRecordModalButton.vue'
import { ref, provide } from 'vue'
import recordAPI from '@/apis/record'
import { Record } from '@/models'
import { getDay } from '../utils/dateFormat'
import { useStore } from '../store/index'
import Spinner from '@/components/Spinner.vue'
import EditRecordModalButton from '@/components/ModalButton/Record/EditRecordModalButton.vue'
import RecordButtons from '@/components/RecordButtons.vue'
import DeleteRecordModalButton from '@/components/ModalButton/Record/DeleteRecordModalButton.vue'
const store = useStore()
// data
const isLoading = ref<boolean>(true)
const records = ref<Record[]>([])
const isCloseStatus = ref<boolean>(false)
const closeRecords = ref<number[]>([])
const closeRecordsAmount = ref<number>(0)
const propDataForRecordButtons = ref({
isCloseStatus: false,
closeRecords: [] as number[],
closeRecordsAmount: 0
})
// methods
const fetchRecords = async function () {
try {
const { data } = await recordAPI.getAll()
records.value = data.filter((item: Record) => item.isClosed === false)
records.value = data.data.filter((item: Record) => item.isClosed === false)
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
const editBtnClick = async function (id: number) {
try {
const { data } = await recordAPI.getOne(id)
const { value: formValues } = await ConfirmBox.fire({
title: '資料編輯',
html: `
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-item" class="col-form-label">項目</label>
</div>
<div class="col-auto">
<input value="${data.item}" type="text" id="swal-item" class="form-control"/>
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-merchant" class="col-form-label">商家</label>
</div>
<div class="col-auto">
<input value="${data.merchant}" type="text" id="swal-merchant" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-amount" class="col-form-label">金額</label>
</div>
<div class="col-auto">
<input value="${data.amount}" type="number" id="swal-amount" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-date" class="col-form-label">日期</label>
</div>
<div class="col-auto">
<input value="${new Date(data.date)
.toISOString()
.substring(0, 10)}" type="date" id="swal-date" class="form-control" >
</div>
</div>
<div class="d-flex mb-2">
<div class="col-auto me-3">
<label for="swal-editor" class="col-form-label">編輯者</label>
</div>
<div class="col-auto">
<select id="swal-editor" class="form-select">
<option selected>${store.currentUser}</option>
</select>
</div>
</div>
`,
preConfirm: () => {
const item = (document.getElementById('swal-item') as HTMLInputElement).value
const merchant = (document.getElementById('swal-merchant') as HTMLInputElement).value
const amount = (document.getElementById('swal-amount') as HTMLInputElement).value
const date = new Date((document.getElementById('swal-date') as HTMLInputElement).value)
const editor = (document.getElementById('swal-editor') as HTMLInputElement).value
if (!item || !merchant || !amount || !date || !editor) {
Swal.showValidationMessage('所有資料都是必填!若編輯者為空,請登入~')
}
return {
id: data.id as number,
input: {
item,
merchant,
amount,
date,
editor
} as RecordInput
}
}
})
if (formValues?.input) {
editRecord(formValues)
}
} catch (error) {
console.error('error', error)
}
}
const editRecord = async function (formValues: { id: number; input: RecordInput }) {
try {
await recordAPI.edit(formValues.id, formValues.input)
fetchRecords()
Toast.fire({
icon: 'success',
title: '成功編輯資料!'
})
// lineBot push
const input = {
to: [process.env.VUE_APP_KAROL_USERID, process.env.VUE_APP_JIANMIAU_USERID],
messages: {
type: 'text',
text: `${store.currentUser + store.icon}編輯了一筆紀錄 →\n${formValues.input.merchant}-${
formValues.input.item
} $${formValues.input.amount}`
}
}
await recordAPI.pushLineMsg(input)
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '編輯資料失敗!'
})
}
}
const closeBtnClick = () => {
if (!store.currentUser) {
Swal.fire('請先登入才能結算!', '', 'warning')
return
}
isCloseStatus.value = true
const recordIds = []
let total = 0
for (let record of records.value) {
recordIds.push(record.id)
total += record.amount
}
closeRecords.value = recordIds
closeRecordsAmount.value = total
}
const checkboxClick = (id: number, amount: number) => {
if (isCloseStatus.value) {
const index = closeRecords.value?.findIndex((recordId) => recordId === id)
console.log('checkboxClick')
if (propDataForRecordButtons.value.isCloseStatus) {
const index = propDataForRecordButtons.value.closeRecords.findIndex((recordId) => recordId === id)
if (index !== undefined) {
if (index !== -1) {
closeRecords.value?.splice(index, 1)
closeRecordsAmount.value -= amount
propDataForRecordButtons.value.closeRecords.splice(index, 1)
propDataForRecordButtons.value.closeRecordsAmount -= amount
} else {
closeRecords.value?.push(id)
closeRecordsAmount.value += amount
propDataForRecordButtons.value.closeRecords.push(id)
propDataForRecordButtons.value.closeRecordsAmount += amount
}
}
}
}
const cancelBtnClick = () => {
isCloseStatus.value = false
closeRecords.value = []
closeRecordsAmount.value = 0
}
const toCloseBtnClick = async () => {
const { isConfirmed } = await ConfirmBox.fire({
icon: 'info',
title: '確定結算資料?',
text: `結算金額為 $${closeRecordsAmount.value} [結算者: ${store.currentUser}]`
})
if (isConfirmed) {
closeRecord(closeRecordsAmount.value)
const handleCloseFunction = (func: string) => {
if (func === 'cancelBtnClick') {
propDataForRecordButtons.value.isCloseStatus = false
propDataForRecordButtons.value.closeRecords = []
propDataForRecordButtons.value.closeRecordsAmount = 0
}
}
const closeRecord = async (amount: number) => {
try {
if (store.currentUser) {
await recordAPI.close({
records: closeRecords.value.toString(),
totalAmount: amount,
recorder: store.currentUser
})
isCloseStatus.value = false
fetchRecords()
Toast.fire({
icon: 'success',
title: '成功結算資料!'
})
// lineBot push
const input = {
to: [process.env.VUE_APP_KAROL_USERID, process.env.VUE_APP_JIANMIAU_USERID],
messages: {
type: 'text',
text: `${store.currentUser + store.icon}結算紀錄 → 總金額 $${amount}`
}
}
await recordAPI.pushLineMsg(input)
if (func === 'closeBtnClick') {
propDataForRecordButtons.value.isCloseStatus = true
const recordIds = []
let total = 0
for (let record of records.value) {
recordIds.push(record.id)
total += record.amount
}
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '結算資料失敗!'
})
propDataForRecordButtons.value.closeRecords = recordIds
propDataForRecordButtons.value.closeRecordsAmount = total
}
if (func === 'closeRecord') {
propDataForRecordButtons.value.isCloseStatus = false
}
}
// created
fetchRecords()
// provide
provide('refetchRecords', fetchRecords)
</script>
<template>
<div v-if="!isLoading">
<div class="d-flex mb-3" style="width: 100vw">
<template v-if="!isCloseStatus">
<CreateRecordModalButton view="Record" :refetch="fetchRecords" class="me-3" />
<button type="button" class="btn btn-danger" @click="closeBtnClick">開始結算</button>
</template>
<template v-else>
<div class="btn btn-info fw-bold">結算金額 ${{ closeRecordsAmount }}</div>
<button type="button" class="btn btn-secondary ms-3" @click="cancelBtnClick">取消結算</button>
<button type="button" class="btn btn-success ms-3" @click="toCloseBtnClick">確定結算</button>
</template>
</div>
<table class="table table-striped table-info table-hover" v-if="records.length">
<RecordButtons :propData="propDataForRecordButtons" @closeFunction="handleCloseFunction" />
<table class="table table-info table-hover" v-if="records.length">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col"></th>
<th scope="col">項目</th>
<th scope="col">商家</th>
<th scope="col">金額</th>
@ -251,17 +95,20 @@ fetchRecords()
<tr
v-for="(record, index) in records"
:key="index"
:class="{ 'table-warning': closeRecords?.includes(record.id) }"
:class="{ 'table-success': propDataForRecordButtons.closeRecords?.includes(record.id) }"
@click="checkboxClick(record.id, record.amount)"
>
<td>
<input
v-if="isCloseStatus"
v-if="propDataForRecordButtons.isCloseStatus"
class="form-check-input"
type="checkbox"
:checked="closeRecords?.includes(record.id)"
:checked="propDataForRecordButtons.closeRecords?.includes(record.id)"
/>
<i v-else class="fas fa-edit" @click="editBtnClick(record.id)"></i>
<EditRecordModalButton v-else :record="record" />
</td>
<td>
<DeleteRecordModalButton :record="record" v-if="store.currentUser?.Role.name_en === 'root'" />
</td>
<td>
{{ record.item }}
@ -273,10 +120,10 @@ fetchRecords()
{{ record.amount }}
</td>
<td>
{{ new Date(record.date).toLocaleDateString() + ` (${showWeekDay(record.date)})` }}
{{ new Date(record.date).toLocaleDateString() + ' ' + getDay(record.date) }}
</td>
<td id="column-item">
{{ record.recorder }}
{{ record.User.displayName }}
</td>
<td id="column-item">{{ new Date(record.createdAt).toLocaleString() }}</td>
<td id="column-item">{{ new Date(record.updatedAt).toLocaleString() }}</td>
@ -294,11 +141,6 @@ fetchRecords()
</template>
<style scoped>
th,
td {
white-space: nowrap;
}
span {
margin-top: 0.1em;
}

104
src/views/Register.vue Normal file
View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import userAPI from '@/apis/user'
import { Toast } from '@/utils/swal'
import { FirebaseUserInput } from '@/models'
import { useRouter } from 'vue-router'
const router = useRouter()
// data
const user = ref(new FirebaseUserInput())
// methods
const register = async () => {
try {
const { displayName, email, password } = user.value
if (!displayName || !email || !password) {
return Toast.fire({
icon: 'warning',
title: '名稱、信箱、密碼為必填選項!'
})
}
if (password.length < 6) {
return Toast.fire({
icon: 'warning',
title: '密碼至少要6位'
})
}
// firebase
const { data } = await userAPI.user.firebase_email_register(user.value)
// DB
await userAPI.user.create({
email: user.value.email,
displayName: user.value.displayName,
photoURL: user.value.photoURL,
firebaseUid: data.data.uid
})
Toast.fire({
icon: 'success',
title: '註冊成功!'
})
router.push({ name: 'Login' })
} catch (error) {
console.error('error', error)
Toast.fire({
icon: 'error',
title: '註冊失敗!'
})
}
}
</script>
<template>
<div>
<button class="btn btn-info login-btn" type="button" @click="router.push({ name: 'Login' })">前往登入</button>
<div class="m-auto" style="width: 70vw">
<form>
<img src="@/assets/logo2.png" alt="" width="150" height="150" />
<h1 class="h3 mb-3 fw-normal">臭建喵記帳 Register</h1>
<div class="form-floating">
<input type="text" class="form-control" id="name-register" v-model="user.displayName" autocomplete="on" />
<label for="name-register">名稱</label>
</div>
<div class="form-floating">
<input type="url" class="form-control" id="photoUrl-register" v-model="user.photoURL" autocomplete="on" />
<label for="photoUrl-register">大頭貼(url)</label>
</div>
<div class="form-floating">
<input type="email" class="form-control" id="email-register" v-model="user.email" autocomplete="on" />
<label for="email-register">信箱</label>
</div>
<div class="form-floating">
<input
type="password"
class="form-control"
id="password-register"
v-model="user.password"
autocomplete="on"
/>
<label for="password-register">密碼</label>
</div>
<!-- <div class="checkbox mb-3">
<label> <input type="checkbox" value="remember-me" /> Remember me </label>
</div> -->
<button
class="w-100 btn btn-lg mt-3"
type="button"
@click="register"
style="color: white; background-color: mediumaquamarine"
>
取得進入豬豬世界的門票
</button>
</form>
</div>
</div>
</template>
<style scoped>
.login-btn {
position: fixed;
top: 1em;
right: 1em;
}
</style>

70
src/views/Role.vue Normal file
View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, provide } from 'vue'
import userAPI from '@/apis/user'
import { Role } from '@/models/index'
import Spinner from '@/components/Spinner.vue'
import CreateRoleModalButton from '@/components/ModalButton/Role/CreateRoleModalButton.vue'
import EditRoleModalButton from '@/components/ModalButton/Role/EditRoleModalButton.vue'
import DeleteRoleModalButton from '@/components/ModalButton/Role/DeleteRoleModalButton.vue'
// data
const isLoading = ref<boolean>(true)
const roles = ref<Role[]>([])
// methods
const fetchRoles = async function () {
try {
const { data } = await userAPI.role.getAll()
roles.value = data.data
isLoading.value = false
} catch (error) {
console.error('error', error)
}
}
// created
fetchRoles()
// provide
provide('refetchRoles', fetchRoles)
</script>
<template>
<div v-if="!isLoading">
<div class="d-flex my-3">
<CreateRoleModalButton />
</div>
<table class="table table-striped table-danger table-hover" v-if="roles.length">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">中文名稱</th>
<th scope="col">英文名稱</th>
<th scope="col">啟用</th>
<th scope="col">#</th>
</tr>
</thead>
<tbody>
<tr v-for="(role, index) in roles" :key="index">
<td>{{ role.id }}</td>
<td>{{ role.name }}</td>
<td>{{ role.name_en }}</td>
<td>{{ role.deletedAt === null ? 'V' : 'X' }}</td>
<td>
<router-link :to="{ name: 'Admin-Role-Access', params: { id: role.id } }">
<i class="fa-solid fa-circle-question"></i
></router-link>
<EditRoleModalButton :role="role" class="ms-2" />
<DeleteRoleModalButton :role="role" class="ms-2" />
</td>
</tr>
</tbody>
</table>
<img
v-else
class="img-fluid"
src="https://stickershop.line-scdn.net/stickershop/v1/sticker/208430466/iPhone/sticker_animation@2x.png"
alt="img"
/>
</div>
<Spinner v-else />
</template>

30
src/views/Tools.vue Normal file
View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue'
const num_1 = ref<number>()
const num_2 = ref<number>()
const plus = () => {
num_1.value = num_1.value! + num_2.value!
num_2.value = undefined
}
</script>
<template>
<div>
<div>
<div class="input-group">
<span class="input-group-text">計算機</span>
<input type="number" class="form-control" v-model="num_1" />
<input type="number" class="form-control" v-model="num_2" />
<span class="input-group-text" @click="plus">加總</span>
</div>
</div>
<!-- <div class="mt-3 p-3 bg-info">
<p>~2022/2/7</p>
<p>加總 3931</p>
</div>
<div class="mt-3">
<label for="exampleFormControlTextarea1" class="form-label">Note</label>
<textarea class="form-control" id="exampleFormControlTextarea1" rows="3"></textarea>
</div> -->
</div>
</template>

View File

@ -13,18 +13,12 @@
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"types": ["webpack-env"],
"types": ["webpack-env", "vite/client"],
"paths": {
"@/*": ["src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx"],
"exclude": ["node_modules"]
}

15
vite.config.js Normal file
View File

@ -0,0 +1,15 @@
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const path = require('path')
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})

View File

@ -1,3 +1,4 @@
module.exports = {
publicPath: process.env.NODE_ENV === 'production' ? '/jm-expense-vue-ts/' : '/'
// publicPath: process.env.NODE_ENV === 'production' ? '/jm-expense-vue-ts/' : '/'
publicPath: import.meta.env.NODE_ENV === 'production' ? '/jm-expense-vue-ts/' : '/'
}