feat:移植工程

This commit is contained in:
2025-06-10 16:20:21 +08:00
commit 829df0b1d1
356 changed files with 43395 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_HOST=43.225.157.27

5
.eslintrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
extends: [
'./node_modules/vue3-eslint'
]
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

6
Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM docker-0.unsee.tech/nginx:1.27.0
COPY dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' > /etc/timezone
RUN chmod -R 755 /usr/share/nginx/html/

18
README.md Normal file
View File

@@ -0,0 +1,18 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Sweet Hut</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

106
nginx.conf Normal file
View File

@@ -0,0 +1,106 @@
##
# You should look at the following URL's in order to grasp a solid understanding
# of Nginx configuration files in order to fully unleash the power of Nginx.
# https://www.nginx.com/resources/wiki/start/
# https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/
# https://wiki.debian.org/Nginx/DirectoryStructure
#
# In most cases, administrators will remove this file from sites-enabled/ and
# leave it as reference inside of sites-available where it will continue to be
# updated by the nginx packaging team.
#
# This file will automatically load configuration files provided by other
# applications, such as Drupal or Wordpress. These applications will be made
# available underneath a path with that package name, such as /drupal8.
#
# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples.
##
# Default server configuration
#
server {
listen 80 default_server;
listen [::]:80 default_server;
# SSL configuration
#
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
#
# Note: You should disable gzip for SSL traffic.
# See: https://bugs.debian.org/773332
#
# Read up on ssl_ciphers to ensure a secure configuration.
# See: https://bugs.debian.org/765782
#
# Self signed certs generated by the ssl-cert package
# Don't use them in a production server!
#
# include snippets/snakeoil.conf;
#root /var/www/html;
root /usr/share/nginx/html;
# Add index.php to the list if you are using PHP
#index index.html index.htm index.nginx-debian.html;
index index.html;
server_name _;
location /admin-service/ {
proxy_pass http://admin-service:8080/;
}
location /home-service/ {
proxy_pass http://home-service:8081/;
}
location /blog-service/ {
proxy_pass http://blog-service:8082/;
}
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
# pass PHP scripts to FastCGI server
#
#location ~ \.php$ {
# include snippets/fastcgi-php.conf;
#
# # With php-fpm (or other unix sockets):
# fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
# # With php-cgi (or other tcp sockets):
# fastcgi_pass 127.0.0.1:9000;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
# Virtual Host configuration for example.com
#
# You can move that to a different file under sites-available/ and symlink that
# to sites-enabled/ to enable it.
#
#server {
# listen 80;
# listen [::]:80;
#
# server_name example.com;
#
# root /var/www/example.com;
# index index.html;
#
# location / {
# try_files $uri $uri/ =404;
# }
#}

11712
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "sweet-hut-ui",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@fullcalendar/vue3": "^6.1.17",
"@popperjs/core": "^2.11.8",
"@tsparticles/slim": "^3.8.1",
"@tsparticles/vue3": "^3.0.1",
"@vueuse/core": "^13.1.0",
"animate.css": "^4.1.1",
"dayjs": "^1.11.13",
"echarts-liquidfill": "^3.1.0",
"element-china-area-data": "^6.1.0",
"element-plus": "~2.9.7",
"file-icons-vue": "^1.3.4",
"font-awesome": "^4.7.0",
"lunar-javascript": "^1.7.2",
"md-editor-v3": "^4.21.3",
"pinia": "^2.3.1",
"v-calendar": "^3.1.2",
"vue": "3.5.12",
"vue3-common": "git+https://gitee.com/Cxx0822/vue3-common",
"vue3-eslint": "git+https://gitee.com/Cxx0822/vue3-eslint"
},
"devDependencies": {
"@types/node": "^18.19.86",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-vue": "5.2.1",
"autoprefixer": "^10.4.21",
"eslint": "^7.32.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.2.0",
"eslint-plugin-vue": "^8.7.1",
"postcss": "^8.5.3",
"sass": "^1.86.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vfonts": "^0.0.3",
"vite": "5.2.0",
"vite-plugin-mock": "^3.0.2",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^1.8.27"
}
}

7127
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

28
src/App.vue Normal file
View File

@@ -0,0 +1,28 @@
<template>
<app-loading ref="loadingRef"/>
<router-view />
</template>
<script setup lang="ts">
import AppLoading from '@/components/AppLoading.vue'
import { useAppStore } from '@/store/app.ts'
import { useRouter } from 'vue-router'
const appStore = useAppStore()
const router = useRouter()
appStore.initApp()
router.beforeEach((to, from, next) => {
appStore.isLoading = true
next()
})
router.afterEach(() => {
appStore.isLoading = false
})
</script>
<style lang="scss">
@use '@/styles/index.module.scss';
</style>

29
src/apis/account.ts Normal file
View File

@@ -0,0 +1,29 @@
import { cloudService } from './index'
import { IAccount } from '@/types/auth.ts'
export const queryAccountApi = (): Promise<IAccount[]> =>
cloudService({
url: '/admin-service/account',
method: 'get'
})
export const registerAccountApi = (account: IAccount): Promise<boolean> =>
cloudService({
url: '/admin-service/account',
method: 'post',
data: account
})
export const deleteAccountApi = (id: number): Promise<boolean> =>
cloudService({
url: `/admin-service/account/${id}`,
method: 'delete'
})
export const updateAccountPasswordApi = (id: number,
oldPassword: string, newPassword: string): Promise<boolean> =>
cloudService({
url: `/admin-service/account/password/${id}`,
method: 'put',
params: { oldPassword, newPassword }
})

36
src/apis/anniversary.ts Normal file
View File

@@ -0,0 +1,36 @@
import { cloudService } from './index'
import { IAnniversary } from '@/types/anniversary'
import { IStatsChart } from 'vue3-common/types'
export const addAnniversaryApi = (anniversary: IAnniversary): Promise<boolean> =>
cloudService({
url: '/home-service/anniversary',
method: 'post',
data: anniversary
})
export const updateAnniversaryApi = (id: number, anniversary: IAnniversary): Promise<boolean> =>
cloudService({
url: `/home-service/anniversary/${id}`,
method: 'put',
data: anniversary
})
export const deleteAnniversaryApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/anniversary/${id}`,
method: 'delete'
})
export const queryAnniversaryApi = (category: string): Promise<IAnniversary[]> =>
cloudService({
url: '/home-service/anniversary',
method: 'get',
params: { category }
})
export const queryAnniversaryCategoryApi = (): Promise<IStatsChart[]> =>
cloudService({
url: '/home-service/anniversary/category',
method: 'get'
})

35
src/apis/asset.ts Normal file
View File

@@ -0,0 +1,35 @@
import { cloudService } from './index'
import { IAssetRecord } from '@/types/asset'
export const addAssetApi = (asset: IAssetRecord): Promise<boolean> =>
cloudService({
url: '/home-service/asset',
method: 'post',
data: asset
})
export const updateAssetApi = (id: number, asset: IAssetRecord): Promise<boolean> =>
cloudService({
url: `/home-service/asset/${id}`,
method: 'put',
data: asset
})
export const deleteAssetApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/asset/${id}`,
method: 'delete'
})
export const queryAssetApi = (category: string): Promise<IAssetRecord[]> =>
cloudService({
url: '/home-service/asset',
method: 'get',
params: { category }
})
export const queryAssetCategoryApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/asset/category',
method: 'get'
})

109
src/apis/bill.ts Normal file
View File

@@ -0,0 +1,109 @@
import { cloudService } from './index'
import {
IBillBook,
IBillCategory,
IBillPay,
IBillQuery,
IBillRecord,
IBillSummaryStats
} from '@/types/bill'
export const addBillRecordApi = (billRecord: IBillRecord): Promise<boolean> =>
cloudService({
url: '/home-service/bill',
method: 'post',
data: billRecord
})
export const updateBillRecordApi = (id: number, billRecord: IBillRecord): Promise<boolean> =>
cloudService({
url: `/home-service/bill/${id}`,
method: 'put',
data: billRecord
})
export const deleteBillRecordApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/bill/${id}`,
method: 'DELETE'
})
export const queryBillRecordApi = (id: number): Promise<IBillRecord> =>
cloudService({
url: `/home-service/bill/${id}`,
method: 'get'
})
export const queryBillSummaryApi = (query: IBillQuery): Promise<IBillRecord[]> =>
cloudService({
url: '/home-service/bill/summary',
method: 'get',
params: query
})
export const queryBillStatsApi = (type: string, query: IBillQuery): Promise<any> =>
cloudService({
url: `/home-service/bill/stats/${type}`,
method: 'get',
params: query
})
export const queryBillPayApi = (): Promise<IBillPay[]> =>
cloudService({
url: '/home-service/bill/pay',
method: 'get'
})
export const queryBillBookApi = (): Promise<IBillBook[]> =>
cloudService({
url: '/home-service/bill/book',
method: 'get'
})
export const queryBillCategoryApi = (): Promise<IBillCategory[]> =>
cloudService({
url: '/home-service/bill/category',
method: 'get'
})
export const addBillPayApi = (billPay: IBillPay): Promise<boolean> =>
cloudService({
url: '/admin-service/bill-manage/pay',
method: 'post',
data: billPay
})
export const updateBillPayApi = (id: number, billPay: IBillPay): Promise<any> =>
cloudService({
url: `/admin-service/bill-manage/pay/${id}`,
method: 'put',
data: billPay
})
export const addBillBookApi = (billBook: IBillBook): Promise<any> =>
cloudService({
url: '/admin-service/bill-manage/book',
method: 'post',
data: billBook
})
export const updateBillBookApi = (id: number, billBook: IBillBook): Promise<any> =>
cloudService({
url: `/admin-service/bill-manage/book/${id}`,
method: 'put',
data: billBook
})
export const addBillCategoryApi = (billCategory: IBillCategory): Promise<any> =>
cloudService({
url: '/admin-service/bill-manage/category',
method: 'post',
data: billCategory
})
export const updateBillCategoryApi = (id: number, billCategory: IBillCategory): Promise<any> =>
cloudService({
url: `/admin-service/bill-manage/category/${id}`,
method: 'put',
data: billCategory
})

91
src/apis/blog.ts Normal file
View File

@@ -0,0 +1,91 @@
import { cloudService } from './index'
import { IBlog, IBlogCategory, IBlogStats, INewBlog, IQueryBlog } from '@/types/blog'
import qs from 'qs'
export const queryBlogApi = (currentPage: number, pageSize: number): Promise<any> =>
cloudService({
url: '/blog-service/blog/page',
method: 'get',
params: { currentPage, pageSize }
})
export const queryBlogByConditionApi = (query: IQueryBlog): Promise<IBlog[]> =>
cloudService({
url: '/blog-service/blog/condition',
method: 'get',
params: query,
paramsSerializer: (params) => {
return qs.stringify(params, { arrayFormat: 'indices' })
}
})
export const queryBlogByIdApi = (id: number): Promise<IBlog> =>
cloudService({
url: `/blog-service/blog/content/${id}`,
method: 'get'
})
export const queryBlogCategoryApi = (): Promise<IBlogCategory[]> =>
cloudService({
url: '/blog-service/blog/category',
method: 'get'
})
export const queryBlogStatsApi = (): Promise<IBlogStats> =>
cloudService({
url: '/blog-service/blog/stats',
method: 'get'
})
export const queryLatestBlogApi = (): Promise<IBlog[]> =>
cloudService({
url: '/blog-service/blog/latest',
method: 'get'
})
export const queryAdminBlogApi = (currentPage: number, pageSize: number): Promise<any> =>
cloudService({
url: '/admin-service/blog-manage/page',
method: 'get',
params: { currentPage, pageSize }
})
export const queryAdminBlogByIdApi = (id: number): Promise<IBlog> =>
cloudService({
url: `/admin-service/blog-manage/content/${id}`,
method: 'get'
})
export const queryAdminBlogCategoryApi = (): Promise<IBlogCategory[]> =>
cloudService({
url: '/admin-service/blog-manage/category',
method: 'get'
})
export const addBlogApi = (blog: INewBlog): Promise<boolean> =>
cloudService({
url: '/admin-service/blog-manage',
method: 'post',
data: blog
})
export const updateBlogApi = (id: number, blog: INewBlog): Promise<boolean> =>
cloudService({
url: `/admin-service/blog-manage/${id}`,
method: 'put',
data: blog
})
export const deleteBlogApi = (id: number): Promise<boolean> =>
cloudService({
url: `/admin-service/blog-manage/${id}`,
method: 'DELETE'
})
export const queryBlogVisitApi = (currentPage: number, pageSize: number): Promise<any> =>
cloudService({
url: '/admin-service/blog-manage/visit',
method: 'get',
params: { currentPage, pageSize }
})

49
src/apis/budget.ts Normal file
View File

@@ -0,0 +1,49 @@
import { cloudService } from './index'
import { IBudgetInfo } from '@/types/budget'
export const addBudgetApi = (budget: IBudgetInfo): Promise<any> =>
cloudService({
url: '/home-service/budget',
method: 'post',
data: budget
})
export const updateBudgetApi = (id: number, budget: IBudgetInfo): Promise<any> =>
cloudService({
url: `/home-service/budget/${id}`,
method: 'put',
data: budget
})
export const deleteBudgetApi = (id: number): Promise<any> =>
cloudService({
url: `/home-service/budget/${id}`,
method: 'DELETE'
})
export const queryBudgetApi = (category: string): Promise<any> =>
cloudService({
url: '/home-service/budget',
method: 'get',
params: { category }
})
export const queryBudgetCategory = (): Promise<any> =>
cloudService({
url: '/home-service/budget/category',
method: 'get'
})
export const queryBudgetRankStats = (category: string): Promise<any> =>
cloudService({
url: '/home-service/budget/stats/rank',
method: 'get',
params: { category }
})
export const queryBudgetProjectStats = (category: string): Promise<any> =>
cloudService({
url: '/home-service/budget/stats/project',
method: 'get',
params: { category }
})

49
src/apis/file.ts Normal file
View File

@@ -0,0 +1,49 @@
import { cloudService } from './index'
import { IFileInfo } from '@/types/file'
export const uploadFileApi = (form: FormData, path: string, md5: string): Promise<any> =>
cloudService({
url: '/admin-service/file',
headers: {
'Content-Type': 'multipart/form-data'
},
method: 'post',
params: { path, md5 },
data: form
})
/**
* 上传图片到Hello图床
* @param form 表单
* @param token token
*/
export const uploadHelloImageApi = (form: FormData, token: string): Promise<any> =>
cloudService({
url: '/hello-service/upload',
headers: {
Authorization: token,
'Content-Type': 'multipart/form-data'
},
method: 'post',
data: form
})
/**
* 获取Hello图床相册
* @param token token
*/
export const getHelloAlbumsApi = (token: string): Promise<any> =>
cloudService({
url: '/hello-service/albums',
headers: {
Authorization: token
},
method: 'get'
})
export const getFolderInfoApi = (folderName: string): Promise<IFileInfo[]> =>
cloudService({
url: '/admin-service/file/catalog',
method: 'get',
params: { folderName }
})

81
src/apis/food.ts Normal file
View File

@@ -0,0 +1,81 @@
import { cloudService } from './index'
import { IFoodQuery, IFoodRecipe, IFoodRecord, IFoodStats } from '@/types/food'
export const addFoodRecipeApi = (foodRecipe: IFoodRecipe): Promise<boolean> =>
cloudService({
url: '/home-service/food/recipe',
method: 'post',
data: foodRecipe
})
export const updateFoodRecipeApi = (id: number, foodRecipe: IFoodRecipe): Promise<boolean> =>
cloudService({
url: `/home-service/food/recipe/${id}`,
method: 'put',
data: foodRecipe
})
export const deleteFoodRecipeApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/food/recipe/${id}`,
method: 'DELETE'
})
export const queryFoodRecipeByIdApi = (id: number): Promise<any> =>
cloudService({
url: `/home-service/food/recipe/${id}`,
method: 'get'
})
export const queryFoodRecipeApi = (currentPage: number, pageSize: number,
query: IFoodQuery): Promise<any> =>
cloudService({
url: '/home-service/food/recipe/page',
method: 'get',
params: { currentPage, pageSize, ...query }
})
export const queryFoodNameListApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/food/recipe/name',
method: 'get'
})
export const addFoodRecordApi = (foodRecord: IFoodRecord): Promise<boolean> =>
cloudService({
url: '/home-service/food/record',
method: 'post',
data: foodRecord
})
export const updateFoodRecordApi = (id: number, foodRecord: IFoodRecord): Promise<boolean> =>
cloudService({
url: `/home-service/food/record/${id}`,
method: 'put',
data: foodRecord
})
export const deleteFoodRecordApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/food/record/${id}`,
method: 'DELETE'
})
export const queryFoodRecordApi = (startDate: string, endDate: string): Promise<IFoodRecord[]> =>
cloudService({
url: '/home-service/food/record',
method: 'get',
params: { startDate, endDate }
})
export const queryFoodCategoryApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/food/category',
method: 'get'
})
export const queryFoodStatsApi = (): Promise<IFoodStats> =>
cloudService({
url: '/home-service/food/stats',
method: 'get'
})

3
src/apis/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import createService from 'vue3-common/utils/axiosUtil'
export const cloudService = createService()

36
src/apis/journal.ts Normal file
View File

@@ -0,0 +1,36 @@
import { cloudService } from './index'
import { IStatsChart } from 'vue3-common/types'
import { IJournal, IJournalQuery } from '@/types/journal.ts'
export const addJournalApi = (journal: IJournal): Promise<boolean> =>
cloudService({
url: '/home-service/journal',
method: 'post',
data: journal
})
export const updateJournalApi = (id: number, journal: IJournal): Promise<boolean> =>
cloudService({
url: `/home-service/journal/${id}`,
method: 'put',
data: journal
})
export const deleteJournalApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/journal/${id}`,
method: 'delete'
})
export const queryJournalApi = (query: IJournalQuery): Promise<IJournal[]> =>
cloudService({
url: '/home-service/journal',
method: 'get',
params: { ...query }
})
export const queryJournalCategoryApi = (): Promise<IStatsChart[]> =>
cloudService({
url: '/home-service/journal/category',
method: 'get'
})

29
src/apis/kpi.ts Normal file
View File

@@ -0,0 +1,29 @@
import { cloudService } from './index'
import { IKpi, IKpiQuery } from '@/types/kpi'
export const addKpiApi = (kpi: IKpi): Promise<boolean> =>
cloudService({
url: '/home-service/kpi',
method: 'post',
data: kpi
})
export const updateKpiApi = (id: number, kpi: IKpi): Promise<boolean> =>
cloudService({
url: `/home-service/kpi/${id}`,
method: 'put',
data: kpi
})
export const deleteKpiApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/kpi/${id}`,
method: 'delete'
})
export const queryKpiApi = (query: IKpiQuery): Promise<IKpi[]> =>
cloudService({
url: '/home-service/kpi',
method: 'get',
params: { ...query }
})

35
src/apis/ledger.ts Normal file
View File

@@ -0,0 +1,35 @@
import { cloudService } from './index'
import { ILedgerRecord } from '@/types/ledger'
export const addLedgerApi = (ledger: ILedgerRecord): Promise<boolean> =>
cloudService({
url: '/home-service/ledger',
method: 'post',
data: ledger
})
export const updateLedgerApi = (id: number, ledger: ILedgerRecord): Promise<boolean> =>
cloudService({
url: `/home-service/ledger/${id}`,
method: 'put',
data: ledger
})
export const deleteLedgerApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/ledger/${id}`,
method: 'delete'
})
export const queryLedgerApi = (book: string): Promise<ILedgerRecord[]> =>
cloudService({
url: '/home-service/ledger',
method: 'get',
params: { book }
})
export const queryLedgerBookApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/ledger/book',
method: 'get'
})

37
src/apis/period.ts Normal file
View File

@@ -0,0 +1,37 @@
import { cloudService } from './index'
import { IPeriodDay, IPeriodSetting } from '@/types/period.ts'
export const updatePeriodDayApi = (id: number, periodDay: IPeriodDay): Promise<boolean> =>
cloudService({
url: `/home-service/period/day/${id}`,
method: 'put',
data: periodDay
})
export const queryPeriodDayApi = (month: string): Promise<IPeriodDay[]> =>
cloudService({
url: '/home-service/period/day',
method: 'get',
params: { month }
})
export const getMenstruateFirstDateApi = (month: string): Promise<string> =>
cloudService({
url: '/home-service/period/menstruate',
method: 'get',
params: { month }
})
export const updatePeriodSettingApi = (id: number, setting: IPeriodSetting): Promise<boolean> =>
cloudService({
url: `/home-service/period/setting/${id}`,
method: 'put',
data: setting
})
export const queryPeriodSettingApi = (): Promise<IPeriodSetting> =>
cloudService({
url: '/home-service/period/setting',
method: 'get'
})

42
src/apis/plan.ts Normal file
View File

@@ -0,0 +1,42 @@
import { cloudService } from './index'
import { IPlanInfo, IPlanQuery } from '@/types/plan.ts'
export const addPlanApi = (plan: IPlanInfo): Promise<any> =>
cloudService({
url: '/home-service/plan',
method: 'post',
data: plan
})
export const updatePlanApi = (id: number, plan: IPlanInfo): Promise<any> =>
cloudService({
url: `/home-service/plan/${id}`,
method: 'put',
data: plan
})
export const setPlanStatusApi = (id: number, status: boolean): Promise<any> =>
cloudService({
url: `/home-service/plan/${id}/status`,
method: 'put',
params: { status }
})
export const deletePlanApi = (id: number): Promise<any> =>
cloudService({
url: `/home-service/plan/${id}`,
method: 'delete'
})
export const queryPlanApi = (query: IPlanQuery): Promise<any> =>
cloudService({
url: '/home-service/plan',
method: 'get',
params: query
})
export const queryPlanStatsApi = (): Promise<any> =>
cloudService({
url: '/home-service/plan/stats',
method: 'get'
})

41
src/apis/product.ts Normal file
View File

@@ -0,0 +1,41 @@
import { cloudService } from './index'
import { IProduct, IQueryProduct } from '@/types/product.ts'
export const addProductApi = (product: IProduct): Promise<boolean> =>
cloudService({
url: '/home-service/product',
method: 'post',
data: product
})
export const updateProductApi = (id: number, product: IProduct): Promise<boolean> =>
cloudService({
url: `/home-service/product/${id}`,
method: 'put',
data: product
})
export const deleteProductApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/product/${id}`,
method: 'delete'
})
export const queryProductApi = (id: number): Promise<IProduct> =>
cloudService({
url: `/home-service/product/${id}`,
method: 'get'
})
export const queryProductListApi = (query: IQueryProduct): Promise<IProduct[]> =>
cloudService({
url: '/home-service/product',
method: 'get',
params: query
})
export const queryProductCategoryApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/product/category',
method: 'get'
})

8
src/apis/quartz.ts Normal file
View File

@@ -0,0 +1,8 @@
import { cloudService } from './index'
import { IQuartzInfo } from '@/types/quartz'
export const queryQuartzApi = (): Promise<IQuartzInfo[]> =>
cloudService({
url: '/home-service/quartz',
method: 'get'
})

10
src/apis/session.ts Normal file
View File

@@ -0,0 +1,10 @@
import { cloudService } from './index'
import { ISession } from '@/types/auth'
export const loginHomeApi = (name: string, password: string): Promise<ISession> =>
cloudService({
url: '/home-service/session',
method: 'post',
params: { name, password }
})

82
src/apis/travel.ts Normal file
View File

@@ -0,0 +1,82 @@
import { cloudService } from './index'
import { ITravelQuery, ITravelRecord, ITravelSpot, ITravelStats } from '@/types/travel'
export const addTravelSpotApi = (travelSpot: ITravelSpot): Promise<boolean> =>
cloudService({
url: '/home-service/travel/spot',
method: 'post',
data: travelSpot
})
export const updateTravelSpotApi = (id: number, travelSpot: ITravelSpot): Promise<boolean> =>
cloudService({
url: `/home-service/travel/spot/${id}`,
method: 'put',
data: travelSpot
})
export const deleteTravelSpotApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/travel/spot/${id}`,
method: 'DELETE'
})
export const queryTravelSpotApi = (id: number): Promise<ITravelSpot> =>
cloudService({
url: `/home-service/travel/spot/${id}`,
method: 'get'
})
export const queryTravelSpotListApi = (currentPage: number, pageSize: number,
query: ITravelQuery): Promise<any> =>
cloudService({
url: '/home-service/travel/spot/page',
method: 'get',
params: { currentPage, pageSize, ...query }
})
export const queryTravelSpotNameListApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/travel/spot/name',
method: 'get'
})
export const addTravelRecordApi = (travelRecord: ITravelRecord): Promise<boolean> =>
cloudService({
url: '/home-service/travel/record',
method: 'post',
data: travelRecord
})
export const updateTravelRecordApi = (id: number, travelRecord: ITravelRecord): Promise<boolean> =>
cloudService({
url: `/home-service/travel/record/${id}`,
method: 'put',
data: travelRecord
})
export const deleteTravelRecordApi = (id: number): Promise<boolean> =>
cloudService({
url: `/home-service/travel/record/${id}`,
method: 'DELETE'
})
export const queryTravelRecordApi = (startDate: string, endDate: string):
Promise<ITravelRecord[]> =>
cloudService({
url: '/home-service/travel/record',
method: 'get',
params: { startDate, endDate }
})
export const queryTravelCategoryApi = (): Promise<string[]> =>
cloudService({
url: '/home-service/travel/category',
method: 'get'
})
export const queryTravelStatsApi = (): Promise<ITravelStats> =>
cloudService({
url: '/home-service/travel/stats',
method: 'get'
})

31
src/apis/travelRecord.ts Normal file
View File

@@ -0,0 +1,31 @@
import { cloudService } from './index'
export const getAllTravelRecord = (): any =>
cloudService({
url: '/travelRecord/getAllTravelRecord',
method: 'get'
})
export const getAllTravelRecordByPage = (currentPage: number, pageSize: number): any =>
cloudService({
url: '/travelRecord/getAllTravelRecordByPage',
method: 'get',
params: { currentPage, pageSize }
})
export const addTravelRecord = (travelJournal): any =>
cloudService({
url: '/travelRecord/addTravelRecord',
method: 'post',
data: travelJournal
})
export const addTravelRecordImage = (imageList, date, place): any =>
cloudService({
url: '/travelRecord/addTravelRecordImage',
method: 'post',
headers: { 'Content-Type': 'multipart/form-data' },
params: { date, place },
data: imageList
})

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

@@ -0,0 +1,15 @@
import { cloudService } from './index'
import { IUser } from '@/types/auth.ts'
export const queryUserApi = (id: number): Promise<IUser> =>
cloudService({
url: `/admin-service/user/${id}`,
method: 'get'
})
export const updateUserApi = (id: number, user: IUser): Promise<boolean> =>
cloudService({
url: `/admin-service/user/${id}`,
method: 'put',
data: user
})

BIN
src/assets/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

BIN
src/assets/logo/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

View File

@@ -0,0 +1,20 @@
<template>
<div v-if="appStore.isLoading" id="app-loading">
<div class="cube-container">
<div class="cube cube1"></div>
<div class="cube cube2"></div>
<div class="cube cube3"></div>
</div>
<p class="loading-text">正在加载中...</p>
</div>
</template>
<script setup>
import { useAppStore } from '@/store/app.ts'
const appStore = useAppStore()
</script>
<style lang="scss">
@use "@/styles/app.loading.module.scss";
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="blog-archive blog-section">
<h4 class="module-title">文章归档</h4>
<el-date-picker
v-model="currentYear"
type="year" placeholder="请选择年份"
style="width: 100px"
@change="changeDateEvent"
/>
<span class="total">共计{{ blogStore.blogList.length }}篇文章</span>
<el-timeline class="time-line">
<el-timeline-item v-for="(value, index) in blogStore.blogList" :key="index">
<div class="item">
<span class="date">{{ value.createTime }}</span>
<span class="title" @click="viewBlogClick(index)">{{ value.title }}</span>
</div>
</el-timeline-item>
</el-timeline>
</div>
</template>
<script setup lang="ts">
import { useBlogStore } from '@/store/blog.ts'
import { useRouter } from 'vue-router'
import { onMounted, ref } from 'vue'
const blogStore = useBlogStore()
const router = useRouter()
const currentYear = ref(new Date())
onMounted(() => {
blogStore.queryBlogByCondition({
year: (new Date()).getFullYear()
})
})
/**
* 选择年份事件
* @param value 年份
*/
const changeDateEvent = async (value: Date) => {
await blogStore.queryBlogByCondition({
year: value.getFullYear()
})
}
/**
* 点击查看博客按钮
* @param index 索引
*/
const viewBlogClick = async (index: number) => {
const blogId = blogStore.blogList[index].id
await router.push({ name: 'BlogDetail', params: { id: blogId } })
}
</script>
<style scoped lang="scss">
@use "@/styles/blog/blog.archive.module.scss";
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="blog-category blog-section">
<h4 class="module-title">文章分类</h4>
<span class="total">目前共计{{ blogStore.blogCategoryList.length }}个分类</span>
<div class="category-list">
<div v-for="(item, index) in blogStore.blogCategoryList"
:key="index" class="item" @click="viewBlogCategoryClick(item.name)">
<span class="label">{{ item.name }}</span>
<span class="value">{{ `(${item.count})` }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useBlogStore } from '@/store/blog.ts'
import { useRouter } from 'vue-router'
import { onMounted } from 'vue'
const blogStore = useBlogStore()
const router = useRouter()
onMounted(() => {
blogStore.queryBlogCategory()
})
/**
* 点击查看博客分类信息
* @param name 类别名称
*/
const viewBlogCategoryClick = async (name: string) => {
await router.push({ name: 'BlogCategoryName', params: { name } })
}
</script>
<style scoped lang="scss">
@use "@/styles/blog/blog.category.module.scss";
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="blog-category-detail blog-section">
<span class="name">{{ route.params?.name }}</span>
<div class="category-detail-list">
<div v-for="(item, index) in blogStore.blogList"
:key="index" class="item" @click="viewBlogClick(index)">
<span class="number">{{ index+1 }}</span>
<span class="date">{{ item?.createTime }}</span>
<span class="title">{{ item.title }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useBlogStore } from '@/store/blog.ts'
import { useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { onMounted } from 'vue'
const blogStore = useBlogStore()
const router = useRouter()
const route = useRoute()
onMounted(async () => {
const categoryName = route.params.name as string
await blogStore.queryBlogByCondition({
category: categoryName
})
})
const viewBlogClick = async (index: number) => {
const blogId = blogStore.blogList[index].id
await router.push({ name: 'BlogDetailId', params: { id: blogId } })
}
</script>
<style scoped lang="scss">
@use "@/styles/blog/blog.category.detail.module.scss";
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="blog-detail blog-section" id="blog-section">
<div class="blog-title">
<span class="title">{{ blogStore.currentBlog.title }}</span>
<blog-label :basic-blog="getBasicBlog(blogStore.currentBlog)"/>
</div>
<div class="blog-md">
<MdPreview editorId="blog-id" v-model="blogStore.currentBlog.content" />
</div>
<div class="blog-copyright">
<span>
<strong>本文作者: </strong> Cxx
</span>
<span>
<strong>本文链接: </strong>
<a href="https://www.microsoft.com/">{{ currentUrl.href }}</a>
</span>
<span>
<strong>版权声明: </strong>
本博客所有文章除特别声明外均采用
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">©BY-NC-SA</a>
许可协议转载请注明出处
</span>
</div>
<div class="blog-ending">
<span>-------------本文结束感谢您的阅读给个五星好评吧~~-------------</span>
</div>
</div>
</template>
<script setup lang="ts">
import BlogLabel from '@/components/Blog/Content/BlogLabel.vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useBlogStore } from '@/store/blog.ts'
import { getBasicBlog } from '@/utils/blog/blogUtil.ts'
import { useRoute } from 'vue-router'
import { onMounted } from 'vue'
const blogStore = useBlogStore()
const route = useRoute()
const currentUrl = new URL(window.location.href)
onMounted(async () => {
await blogStore.queryBlogById(route.params.id as number)
})
</script>
<style scoped lang="scss">
@use "@/styles/blog/blog.detail.module.scss";
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="label-list">
<div class="container">
<div class="top-value item" v-if="props.basicBlog?.topValue > 1">
<i class="fa-solid fa-thumbtack"></i>
</div>
<div v-if="props.basicBlog?.topValue > 1" class="item">|</div>
<div class="great item" v-if="props.basicBlog?.isGreat">
<i class="fa-regular fa-newspaper"></i>
</div>
<div v-if="props.basicBlog?.isGreat" class="item">|</div>
<div class="item">
<i class="fa-regular fa-calendar"></i>
<span class="date">{{ formatDate(props.basicBlog.createTime) }}</span>
</div>
<div class="item">|</div>
<div class="item">
<i class="fa-regular fa-calendar-check"></i>
<span class="date">{{ formatDate(props.basicBlog.createTime) }}</span>
</div>
<div class="item">|</div>
<div class="item">
<i class="fa-regular fa-folder"></i>
<span class="category" @click="viewBlogCategoryClick">
{{ props.basicBlog.category }}
</span>
</div>
<div class="item">|</div>
<div class="item">
<i class="fa-solid fa-eye"></i>
<span>{{ props.basicBlog.visitCount }} </span>
</div>
</div>
<div class="container">
<div class="item">
<i class="fa-regular fa-file-word"></i>
<span>{{ props.basicBlog.wordCount }} </span>
</div>
<div class="item">|</div>
<div class="item">
<i class="fa-regular fa-clock"></i>
<span>{{ props.basicBlog.readDuration.toFixed(0) }} 分钟</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, PropType } from 'vue'
import { IBasicBlog } from '@/types/blog.ts'
import { useBlogStore } from '@/store/blog.ts'
import { useRouter } from 'vue-router'
import { formatDate } from 'vue3-common/utils/dateUtil'
const blogStore = useBlogStore()
const router = useRouter()
const props = defineProps({
basicBlog: {
required: true,
type: Object as PropType<IBasicBlog>
}
})
/**
* 点击查看博客分类按钮
*/
const viewBlogCategoryClick = async () => {
const category = props.basicBlog?.category as string
await blogStore.queryBlogByCondition({
category
})
await router.push({ name: 'CategoriesBlog', params: { name: category } })
}
</script>
<style scoped lang="scss">
@use "@/styles/blog/blog.label.module.scss";
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div class="blog-content-list blog-section">
<div v-if="blogStore.blogList.length === 0"
class="blog-content blog-content-empty" >
</div>
<div class="blog-content blog-content-summary"
v-for="(item, index) in blogStore.blogList" :key="index">
<span class="title">{{ item.title }}</span>
<blog-label :basic-blog="getBasicBlog(item)"/>
<p class="content">
{{ item.summary }}
</p>
<el-button type="primary" @click="readBlogClick(index)">阅读全文 »</el-button>
<hr/>
</div>
<el-pagination
v-model:current-page="blogStore.pageInfo.currentPage"
:page-size="blogStore.pageInfo.pageSize"
:total="blogStore.pageInfo.totalSize"
layout="total, prev, pager, next"
@current-change="changePageClick"
/>
</div>
</template>
<script setup lang="ts">
import BlogLabel from '@/components/Blog/Content/BlogLabel.vue'
import { useRouter } from 'vue-router'
import { onMounted } from 'vue'
import { useBlogStore } from '@/store/blog.ts'
import { getBasicBlog } from '@/utils/blog/blogUtil.ts'
const router = useRouter()
const blogStore = useBlogStore()
onMounted(async () => {
await blogStore.queryBlog()
})
/**
* 点击阅读博客按钮
* @param index 索引
*/
const readBlogClick = async (index: number) => {
const blogId = blogStore.blogList[index].id
await router.push({ name: 'BlogDetailId', params: { id: blogId } })
}
/**
* 点击切换博客页数按钮
* @param page 页码
* @param pageSize 每页大小
*/
const changePageClick = async (page: number, pageSize: number) => {
blogStore.pageInfo.currentPage = page
blogStore.pageInfo.pageSize = pageSize
await blogStore.queryBlog()
}
</script>
<style lang="scss">
@use "@/styles/blog/blog.overview.module.scss";
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="anniversary-card card-item"
@click="selectDateCardClick">
<div class="left-container">
<span class="title">{{ props.dateItem.title }}</span>
<div class="content">
<span class="date">{{ props.dateItem.date }}</span>
<el-tag v-if="props.dateItem.category"
type="primary">{{ props.dateItem.category }}
</el-tag>
</div>
</div>
<div class="right-container"
:style="{'background-color': props.dateItem?.type === 'countUp'
? 'var(--el-color-success)'
: 'var(--el-color-primary)'}">
<span class="title">{{ props.dateItem.type === 'countUp' ? '已经' : '还有' }}</span>
<div class="date">
<span class="value">{{ getAnniversaryRemainDays(props.dateItem) }}</span>
<span class="unit"></span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineProps, PropType } from 'vue'
import { useRouter } from 'vue-router'
import { useAnniversaryStore } from '@/store/anniversary.ts'
import { IAnniversary } from '@/types/anniversary.ts'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { getAnniversaryRemainDays } from '@/utils/home/anniversaryUtil'
const router = useRouter()
const anniversaryStore = useAnniversaryStore()
const props = defineProps({
dateItem: {
required: true,
type: Object as PropType<IAnniversary>
}
})
const selectDateCardClick = async () => {
anniversaryStore.anniversaryApiType = 'UPDATE'
if (isMobile() && props.dateItem?.category !== '节假日') {
anniversaryStore.currentAnniversary = props.dateItem as IAnniversary
const { id } = anniversaryStore.currentAnniversary
await router.push({ name: 'MobileHomeAnniversaryDetailId', params: { id } })
}
}
</script>
<style scoped lang="scss">
.anniversary-card {
position: relative;
padding-left: 5px;
display: grid;
grid-template-columns: 1fr 100px;
.left-container {
padding: 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 5px;
.title {
font-weight: bold;
font-size: 1.2rem;
}
.content {
display: flex;
align-items: center;
gap: 5px;
.date {
color: #696969;
}
}
}
.right-container {
padding: 10px;
border-radius: 0 10px 10px 0;
color: #FFFFFF;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 5px;
.title {
font-size: 1.2rem;
}
.date {
.value {
font-size: 1.8rem;
margin-right: 5px;
}
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div class="date-list">
<!-- <el-empty v-if="planStore.planList.length === 0" />-->
<el-scrollbar>
<date-card v-for="(item, index) in 4"
:key="index" :plan="item">
</date-card>
</el-scrollbar>
</div>
</template>
<script lang="ts" setup>
import DateCard from '@/components/Home/Anniversary/AnniversaryCard.vue'
</script>
<style lang="scss">
@use "@/styles/home.variable.module.scss";
.date-list {
height: calc(100vh - 120px);
.el-scrollbar {
.el-scrollbar__view {
display: flex;
flex-direction: column;
gap: 2vh;
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div v-if="isMobile()" class="mobile-common-query card-item">
<div class="query-item">
<span>分类</span>
<el-segmented v-model="anniversaryStore.queryInfo.category"
:options="queryCategoryList"
@change="anniversaryStore.queryAnniversaryList()"/>
</div>
</div>
<div v-else class="common-query">
<el-select v-model="anniversaryStore.queryInfo.category"
placeholder="请选择纪念日类别" clearable
@change="anniversaryStore.queryAnniversaryList()"
style="width: 180px">
<el-option
v-for="(item, index) in queryCategoryList"
:key="index" :label="item.label" :value="item.value"
/>
</el-select>
<el-button :icon="Refresh" circle @click="refreshClick"/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAnniversaryStore } from '@/store/anniversary.ts'
import { Refresh } from '@element-plus/icons-vue'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { ICategory } from '@/types'
const anniversaryStore = useAnniversaryStore()
const queryCategoryList = ref([] as ICategory[])
onMounted(async () => {
await anniversaryStore.queryAnniversaryCategory()
initCategoryList()
})
const refreshClick = async () => {
anniversaryStore.queryInfo.category = ''
await anniversaryStore.queryAnniversaryList()
}
const initCategoryList = () => {
queryCategoryList.value = []
queryCategoryList.value.push({
label: '全部',
value: ''
})
anniversaryStore.categoryStats.forEach((item) => {
queryCategoryList.value.push({
label: item.name,
value: item.name
})
})
}
</script>

View File

@@ -0,0 +1,62 @@
<template>
<el-badge :value="props.count" class="category-card"
:style="{'background-color': props.categoryItem.color}"
@click="selectCategoryClick">
<span class="title">{{ props.categoryItem.title }}</span>
<span class="sub-title">{{ props.categoryItem.subTitle }}</span>
</el-badge>
</template>
<script setup lang="ts">
import { defineProps, PropType } from 'vue'
import { IAnniversaryCategory } from '@/types/anniversary'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { useAnniversaryStore } from '@/store/anniversary'
import { useRouter } from 'vue-router'
const props = defineProps({
categoryItem: {
required: true,
type: Object as PropType<IAnniversaryCategory>
},
count: {
required: true,
type: Number
}
})
const router = useRouter()
const anniversaryStore = useAnniversaryStore()
const selectCategoryClick = () => {
if (isMobile()) {
anniversaryStore.queryInfo.category = props.categoryItem?.name as string
router.push({ name: 'MobileHomeAnniversaryList' })
}
}
</script>
<style scoped lang="scss">
.category-card {
height: 90px;
width: 100%;
// width: 160px;
border-radius: 10px;
background-color: #FFFFFF;
text-align: center;
display: flex;
flex-direction: column;
justify-content: space-around;
color: #FFFFFF;
.title {
font-weight: bold;
font-size: 1.5rem;
}
.sub-title {
font-size: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="asset-card card-item">
<div class="asset__header">
<span>{{ props.item.category }}</span>
<span>{{ formatAmount(props.item.totalAmount) }}</span>
</div>
<div class="asset__body">
<div v-for="(asset, index) in props.item.itemList"
:key="index" class="asset-item" @click="editClick(asset)">
<svg-icon :name="getAssetIcon(asset.name)" />
<div class="asset-name">
<span>{{ asset.name }}</span>
<span v-if="asset.remark">({{ asset.remark }})</span>
</div>
<div class="asset-amount">
<div class="item" style="color: #4169E1;">
<i class="fa-solid fa-flag"></i>
<span>{{ asset.owner }}</span>
</div>
<div class="item" style="color: #DAA520;">
<i class="fa-solid fa-cny"></i>
<span>{{ formatAmount(asset.amount) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SvgIcon } from 'vue3-common'
import { defineProps, PropType } from 'vue'
import { IAsset, IAssetItem } from '@/types/asset'
import { formatAmount } from 'vue3-common/utils/numberUtil'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { useAssetStore } from '@/store/asset'
import { useRouter } from 'vue-router'
const props = defineProps({
item: {
required: true,
type: Object as PropType<IAsset>
}
})
const assetStore = useAssetStore()
const router = useRouter()
const getAssetIcon = (name: string) => {
if (name.includes('银行')) {
return 'asset-银行'
} else {
return `asset-${name}`
}
}
const editClick = async (asset: IAssetItem) => {
assetStore.assetApiType = 'UPDATE'
assetStore.currentAssetRecord = assetStore.assetRecordList.find((item) => item.id === asset.id)
if (isMobile()) {
await router.push({ name: 'MobileHomeAssetDetailId', params: { id: asset.id } })
} else {
assetStore.assetDialog = {
title: '编辑资产',
visible: true,
data: deepCopyObject(assetStore.currentAssetRecord)
}
}
}
</script>
<style lang="scss">
.asset-card {
padding: 10px;
display: grid;
grid-template-rows: 25px 1fr;
align-items: center;
gap: 15px;
.asset__header {
padding-bottom: 5px;
border-bottom: 1px solid var(--el-color-primary);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.2rem;
}
.asset__body {
display: flex;
flex-direction: column;
gap: 20px;
.asset-item {
display: grid;
grid-template-columns: 20px 1fr 150px;
align-items: center;
gap: 5px;
color: #4F4F4F;
.asset-amount {
justify-self: right;
display: flex;
align-items: center;
gap: 10px;
.item {
display: flex;
align-items: center;
gap: 5px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div v-if="isMobile()" class="mobile-common-query card-item">
<div class="query-item">
<span>分类</span>
<el-segmented v-model="assetStore.queryInfo.category"
:options="queryCategoryList"
@change="assetStore.refreshInfo()"/>
</div>
</div>
<div v-else class="common-query">
<el-select v-model="assetStore.queryInfo.category"
placeholder="请选择纪念日类别" clearable
@change="assetStore.refreshInfo()"
style="width: 180px">
<el-option
v-for="(item, index) in queryCategoryList"
:key="index" :label="item.label" :value="item.value"
/>
</el-select>
<el-button :icon="Refresh" circle @click="refreshClick"/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAssetStore } from '@/store/asset.ts'
import { Refresh } from '@element-plus/icons-vue'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { ICategory } from '@/types'
const assetStore = useAssetStore()
const queryCategoryList = ref([] as ICategory[])
onMounted(async () => {
await assetStore.queryAssetCategory()
initCategoryList()
})
const refreshClick = async () => {
assetStore.queryInfo.category = ''
await assetStore.queryAssetList()
}
const initCategoryList = () => {
queryCategoryList.value = []
queryCategoryList.value.push({
label: '全部',
value: ''
})
assetStore.categoryList.forEach((item) => {
queryCategoryList.value.push({
label: item,
value: item
})
})
}
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div v-loading="billStore.isLoading"
element-loading-text="正在加载中..."
class="bill-daily">
<v-calendar ref="calendarRef"
:attributes='dailyInfo.attrs' :is-dark="appStore.isDark"
@dayclick="dateClick" @did-move="changeMonthClick"
class="bill-calendar card-item"/>
<bill-daily-item :date="dailyInfo.calendarDate"/>
</div>
</template>
<script setup lang="ts">
import BillDailyItem from '@/components/Home/Bill/BillDailyItem.vue'
import { reactive, watch, ref, onMounted } from 'vue'
import { useBillStore } from '@/store/bill.ts'
import { formatDate } from 'vue3-common/utils/dateUtil'
import { CalendarDay, Page } from 'v-calendar/dist/types/src/utils/page'
import { getCalendarDateList } from '@/utils/home/billUtil.ts'
import { useAppStore } from '@/store/app'
const calendarRef = ref()
const billStore = useBillStore()
const appStore = useAppStore()
onMounted(() => {
const { billMonth } = billStore.billQueryForm
calendarRef.value.move(new Date(billMonth))
})
watch(() => billStore.isRefresh, () => {
updateInfo()
})
const dailyInfo = reactive({
calendarDate: '',
attrs: [] as any[]
})
const updateInfo = () => {
getCalendarMoveDate()
getCalendarAttrDates()
}
const getCalendarMoveDate = () => {
const billLength = billStore.billRecordList.length
if (billLength > 0) {
// 如果包含今日日期 则显示今日 否则显示当月最近的日期
const today = formatDate(new Date())
if (billStore.billRecordList.some((item) => item.date === today)) {
dailyInfo.calendarDate = today
} else {
dailyInfo.calendarDate = formatDate(billStore.billRecordList[0].date)
}
} else {
dailyInfo.calendarDate = ''
}
}
const getCalendarAttrDates = () => {
dailyInfo.attrs = []
dailyInfo.attrs.push({
key: 'today',
highlight: true,
dates: new Date()
})
dailyInfo.attrs.push({
dot: 'red',
dates: getCalendarDateList(billStore.billRecordList, 'expensive')
})
dailyInfo.attrs.push({
dot: 'green',
dates: getCalendarDateList(billStore.billRecordList, 'income')
})
}
/**
* 选择日期事件
* @param day 日期
*/
const dateClick = (day: CalendarDay) => {
dailyInfo.calendarDate = formatDate(day.date)
}
/**
* 选择月份事件
* @param value 值
*/
const changeMonthClick = async (value: Page[]) => {
billStore.billQueryForm.billMonth = value[0].id
await billStore.refreshInfo()
}
</script>
<style lang="scss">
.bill-daily {
height: 100%;
display: flex;
flex-direction: column;
gap: 1vh;
.bill-calendar {
width: 100%;
justify-self: center;
align-self: center;
}
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="bill-chart"
v-loading="billStore.isLoading"
element-loading-text="正在加载中...">
<div class="chart-container card-item">
<div class="content">
<span class="title">📊收支统计</span>
</div>
<div class="chart" ref="billDailyChartRef"/>
</div>
<div class="chart-container card-item">
<div class="content">
<span class="title">📊类别统计</span>
</div>
<div class="chart" ref="billCategoryChartRef"/>
</div>
<div class="chart-container card-item">
<div class="content">
<span class="title">📊账本统计</span>
</div>
<div class="chart" ref="billBookChartRef"/>
</div>
<div v-if="isMobile()" class="chart-container card-item">
<div class="content">
<span class="title">📊排行榜</span>
</div>
<div class="chart rank-chart">
<bill-rank />
</div>
</div>
<div v-else class="chart-container card-item">
<div class="content">
<span class="title">📊排行榜</span>
</div>
<div class="chart" ref="billRankChartRef"/>
</div>
</div>
</template>
<script setup lang="ts">
import BillRank from '@/components/Home/Bill/BillRank.vue'
import { onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { lineChartOptions, barChartOptions, pieChartOptions } from 'vue3-common/utils/eChartsUtil'
import { useBillStore } from '@/store/bill'
import { useAppStore } from '@/store/app.ts'
import type { IStatsChart } from 'vue3-common/types'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const billStore = useBillStore()
const appStore = useAppStore()
const billDailyChartRef = ref<HTMLElement>()
const billBookChartRef = ref<HTMLElement>()
const billCategoryChartRef = ref<HTMLElement>()
const billRankChartRef = ref<HTMLElement>()
let billDailyChart: echarts.ECharts
let billBookChart: echarts.ECharts
let billCategoryChart: echarts.ECharts
let billRankChart: echarts.ECharts
watch(() => billStore.isRefresh, () => {
updateChart()
})
onMounted(() => {
billDailyChart = echarts.init(billDailyChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
billBookChart = echarts.init(billBookChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
billCategoryChart = echarts.init(billCategoryChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
if (!isMobile()) {
billRankChart = echarts.init(billRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
}
})
const updateChart = () => {
const MAX_CATEGORY_COUNT = 5
const MAX_RANK_COUNT = 10
// 收支统计图
const dailyXAxis = billStore.billDateStatsList.map((item) => item.name)
const dailyYAxis = billStore.billDateStatsList.map((item) => item.value)
billDailyChart.setOption(lineChartOptions('日期', dailyXAxis, '金额(元)', dailyYAxis))
billDailyChart.setOption({
series: [{
itemStyle: {
color: billStore.billType === 'expensive' ? '#CA6C65' : '#6FBB69'
},
markPoint: {
label: {
color: '#FFFFFF'
}
}
}]
})
// 账本统计图
const bookXAxis = billStore.billBookStatsList.map((item) => item.name)
const bookYAxis = billStore.billBookStatsList.map((item) => item.value)
billBookChart.setOption(barChartOptions('账本', bookXAxis, '金额(元)', bookYAxis))
billBookChart.setOption({
series: [{
itemStyle: {
color: billStore.billType === 'expensive' ? '#CA6C65' : '#6FBB69'
},
markPoint: {
label: {
color: '#FFFFFF'
}
}
}]
})
// 类别统计图
const billCategoryList = billStore.billCategoryStatsList.slice(0, MAX_CATEGORY_COUNT)
billCategoryChart.setOption(pieChartOptions('rank', '元', billCategoryList))
if (isMobile()) {
billCategoryChart.setOption({
series: [{
radius: ['20%', '50%'],
label: {
formatter: '{b}: {d}%'
}
}]
})
}
// 排行榜统计图
const billRankList: IStatsChart[] = []
const resultList = billStore.billRecordList.filter((item) => item.type === billStore.billType)
resultList.sort((a, b) => b.amount - a.amount).forEach((item) => {
billRankList.push({
name: item.content,
value: item.amount
})
})
if (!isMobile()) {
billRankChart.setOption(pieChartOptions('rank', '元', billRankList.slice(0, MAX_RANK_COUNT)))
}
}
</script>

View File

@@ -0,0 +1,134 @@
<template>
<el-empty v-if="dailyItemInfo.itemList.length === 0"/>
<div v-else class="bill-daily-item card-item">
<daily-summary :date="dailyItemInfo.date">
<template #extra>
<span>收入{{ formatAmount(dailyItemInfo.totalIncome, true) }}</span>
<span>支出{{ formatAmount(dailyItemInfo.totalExpensive, true) }}</span>
</template>
</daily-summary>
<div class="daily-list">
<daily-card v-for="(item, index) in dailyItemInfo.itemList"
:key="index" @click="selectBillRecordClick(item)"
:title="item.category" :content="item.content">
<template #icon>
<i :class="getCategoryByName(billStore.billCategoryList, item.category).icon"
:style="{color: getCategoryByName(billStore.billCategoryList, item.category).color}">
</i>
</template>
<template #content>
<span v-if="item.location" class="location">
<i class="fa-solid fa-location-dot"></i>
{{ item.location }}
</span>
</template>
<template #extra>
<span>{{ formatAmount(item.amount, true) }}</span>
<span>{{ item.payAccount }}</span>
</template>
</daily-card>
</div>
</div>
</template>
<script setup lang="ts">
import DailySummary from '@/components/Home/DailySummary.vue'
import DailyCard from '@/components/Home/DailyCard.vue'
import { defineProps, onMounted, reactive, watch } from 'vue'
import { useBillStore } from '@/store/bill.ts'
import { useRouter } from 'vue-router'
import { formatChineseDate } from 'vue3-common/utils/dateUtil'
import { IBillRecord } from '@/types/bill.ts'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { formatAmount } from 'vue3-common/utils/numberUtil'
import { getCalendarDailyCount, getCategoryByName } from '@/utils/home/billUtil.ts'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const billStore = useBillStore()
const router = useRouter()
const props = defineProps({
date: {
required: true,
type: String
}
})
watch(() => props.date, () => {
getBillDailyByDate(props.date as string)
})
watch(() => billStore.isRefresh, () => {
getBillDailyByDate(props.date as string)
})
onMounted(() => {
getBillDailyByDate(props.date as string)
})
const dailyItemInfo = reactive({
date: '',
totalExpensive: 0,
totalIncome: 0,
itemList: [] as IBillRecord[]
})
/**
* 根据日期获取当天的收支记录
* @param date
*/
const getBillDailyByDate = (date: string) => {
if (date) {
dailyItemInfo.date = formatChineseDate(date, 'MM月DD日 dddd')
const calendarDaily = getCalendarDailyCount(billStore.billRecordList, date)
dailyItemInfo.itemList = calendarDaily.dailyRecordList
dailyItemInfo.totalIncome = calendarDaily.totalIncome
dailyItemInfo.totalExpensive = calendarDaily.totalExpensive
} else {
dailyItemInfo.itemList = []
}
}
/**
* 点击账单记录按钮
* @param billRecord 账单记录
*/
const selectBillRecordClick = async (billRecord: IBillRecord) => {
await billStore.queryBillRecord(billRecord.id as number)
billStore.billApiType = 'UPDATE'
if (isMobile()) {
await router.push({
name: 'MobileHomeBillDetailId',
params: { id: billStore.currentBillRecord.id }
})
} else {
billStore.billDialog = {
title: '编辑账单',
visible: true,
data: deepCopyObject(billStore.currentBillRecord)
}
}
}
</script>
<style lang="scss">
.bill-daily-item {
display: flex;
flex-direction: column;
gap: 1vh;
padding: 1vh 0.5vw;
.daily-list {
display: flex;
flex-direction: column;
gap: 1vh;
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div v-loading="billStore.isLoading"
element-loading-text="正在加载中..."
class="bill-daily-list">
<el-date-picker v-model="billStore.billQueryForm.billYear"
type="year" :editable="false" style="width: 120px;"
@change="billStore.refreshInfo()"/>
<el-empty v-if="billDailyInfo.dateList.length === 0"/>
<el-scrollbar>
<bill-daily-item v-for="(item, index) in billDailyInfo.dateList"
:key="index" :date="item"/>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import BillDailyItem from '@/components/Home/Bill/BillDailyItem.vue'
import { reactive, watch } from 'vue'
import { useBillStore } from '@/store/bill.ts'
const billStore = useBillStore()
watch(() => billStore.isRefresh, () => {
updateInfo()
})
const billDailyInfo = reactive({
dateList: [] as string[]
})
const updateInfo = () => {
const dateList = billStore.billRecordList.map((item) => item.date)
billDailyInfo.dateList = [...new Set(dateList)] as string[]
}
</script>
<style lang="scss">
.bill-daily-list {
height: calc(100vh - 120px);
overflow: auto;
display: flex;
flex-direction: column;
gap: 1vh;
.el-date-editor {
align-self: center;
}
.el-scrollbar {
.el-scrollbar__view {
display: flex;
flex-direction: column;
gap: 2vh;
}
}
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<el-dialog v-model="billStore.billDialog.visible"
title="账单内容" center class="bill-dialog"
@open="openDialogEvent">
<el-form ref="formRef" :model="billStore.billDialog.data"
:rules="rules" label-width="80px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="账本类型" prop="bookName">
<el-radio-group v-model="billStore.billDialog.data.bookName"
@change="changeBillBookNameEvent">
<el-radio v-for="(item, index) in billStore.billBookList"
:value="item.name" :key="index">
<template #default>
<i :class="item.icon" :style="{color: item.color}"/>
<span style="margin-left: 5px;">{{ item.name }}</span>
</template>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="账单类别" prop="type">
<el-radio-group v-model="billStore.billDialog.data.type"
@change="changeBillCategoryEvent">
<el-radio v-for="(item, index) in billInfo.billTypeList"
:value="item" :key="index">
{{ getBillTypeName(item) }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="账单分类" prop="category">
<el-radio-group v-model="billStore.billDialog.data.category">
<el-radio v-for="(item, index) in billInfo.billCategoryList"
:value="item.name" :key="index">
<template #default>
<i :class="item.icon" :style="{color: item.color}"/>
<span style="margin-left: 5px;">{{ item.name }}</span>
</template>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="账单时间" prop="date">
<el-date-picker
v-model="billStore.billDialog.data.date"
type="date" :editable="false" placeholder="请选择账单时间"
value-format="YYYY-MM-DD"/>
</el-form-item>
<el-form-item label="账单地点" prop="location">
<el-input v-model="billStore.billDialog.data.location" autocomplete="off"
placeholder="请输入账单地点" clearable
show-word-limit maxlength="10">
<template #prefix>
<el-icon class="el-input__icon"><location/></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="账单账户" prop="payAccount">
<el-radio-group v-model="billStore.billDialog.data.payAccount">
<el-radio v-for="(item, index) in billStore.billPayList"
:value="item.name" :key="index">
<template #default>
<i :class="item.icon" :style="{color: item.color}"/>
<span style="margin-left: 5px;">{{ item.name }}</span>
</template>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="账单金额" prop="amount">
<amount-input v-model="billStore.billDialog.data.amount"/>
</el-form-item>
<el-form-item label="账单内容" prop="content">
<el-input v-model="billStore.billDialog.data.content" autocomplete="off"
placeholder="请输入账单内容" clearable
show-word-limit maxlength="10">
<template #prefix>
<el-icon class="el-input__icon"><document/></el-icon>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账单备注">
<el-input v-model="billStore.billDialog.data.remark" type="textarea"
placeholder="请输入账单备注" clearable
:rows="5" show-word-limit maxlength="200">
</el-input>
</el-form-item>
<el-form-item label="账单图片">
<upload-image v-model="billStore.billDialog.data.imageList"
:service-url="fileServiceUrl"
:service-file-root-path="serviceFileRootPath"
:image-path="billStore.billImagePath"/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">
{{ billStore.billApiType === 'UPDATE' ? '编辑' : '新增' }}
</el-button>
<el-button v-if="billStore.billApiType === 'UPDATE'"
type="danger" @click="deleteClick">
删除
</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { UploadImage, AmountInput } from 'vue3-common'
import { useBillStore } from '@/store/bill'
import { Location, Document } from '@element-plus/icons-vue'
import { FormRules } from 'element-plus'
import { billRecordRules } from '@/utils/element/elRules'
import { nextTick, reactive, ref } from 'vue'
import { IBillCategory } from '@/types/bill'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
import { fileServiceUrl, serviceFileRootPath } from '@/utils'
const formRef = ref()
const rules = reactive<FormRules>(billRecordRules)
const billStore = useBillStore()
const billInfo = reactive({
billTypeList: [] as string[],
billCategoryList: [] as IBillCategory[]
})
/**
* 打开对话框事件
*/
const openDialogEvent = async () => {
// 如果是新增账单 默认选择第一个账本
if (billStore.billApiType !== 'UPDATE') {
billStore.billDialog.data.bookName = billStore.billBookList[0].name
}
getBillTypeByBookName(billStore.billDialog.data.bookName)
// 如果是新增账单 默认选择一个类别
if (billStore.billApiType !== 'UPDATE') {
billStore.billDialog.data.type = billInfo.billTypeList[0]
}
getBillCategory()
// 如果是新增账单 默认选择一个分类
if (billStore.billApiType !== 'UPDATE') {
billStore.billDialog.data.category = billInfo.billCategoryList[0].name
billStore.billDialog.data.payAccount = billStore.billPayList[0].name
}
await nextTick(() => {
formRef.value.clearValidate()
})
}
/**
* 获取账单类型名称
* @param type 账单类型
*/
const getBillTypeName = (type: string) => {
return type === 'expensive' ? '支出' : '收入'
}
/**
* 选择账本事件
* @param value 账本名称
*/
const changeBillBookNameEvent = (value: string) => {
getBillTypeByBookName(value)
billStore.billDialog.data.type = billInfo.billTypeList[0]
getBillCategory()
billStore.billDialog.data.category = billInfo.billCategoryList[0].name
}
/**
* 选择账单类别事件
*/
const changeBillCategoryEvent = () => {
getBillCategory()
billStore.billDialog.data.category = billInfo.billCategoryList[0].name
}
/**
* 通过账本获取账单类型
* @param bookName 账本
*/
const getBillTypeByBookName = (bookName: string) => {
billInfo.billTypeList = []
billStore.billCategoryList.forEach((item) => {
if (item.bookName === bookName && !billInfo.billTypeList.includes(item.type)) {
billInfo.billTypeList.push(item.type)
}
})
}
/**
* 获取账单类别
*/
const getBillCategory = () => {
billInfo.billCategoryList = billStore.billCategoryList.filter((value) => {
return value.bookName === billStore.billDialog.data.bookName
&& value.type === billStore.billDialog.data.type
})
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value.validate().then(() => {
billStore.handleBillApi(billStore.billDialog.data)
})
}
const cancelClick = () => {
billStore.billDialog.visible = false
}
const deleteClick = () => {
commonElMessageBox('是否确认删除该账单内容?').then(() => {
billStore.billApiType = 'DELETE'
billStore.handleBillApi(billStore.billDialog.data)
})
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="common-query">
<el-select v-model="billStore.billQueryForm.billBook"
@change="billStore.refreshInfo()" style="width: 120px">
<el-option
v-for="(item, index) in billQuery.billBookList"
:key="index" :label="item.label" :value="item.name"
/>
</el-select>
<el-select v-model="billStore.billDateType"
@change="billStore.refreshInfo()"
style="width: 120px;align-self: center;">
<el-option v-for="(item, index) in dateTypeOptions" :key="index"
:label="item.label" :value="item.value"/>
</el-select>
<el-switch
v-model="billStore.billType"
inline-prompt
active-value="income" inactive-value="expensive"
active-text="收入账单" inactive-text="支出账单"
@change="billStore.refreshInfo()"
style="--el-switch-on-color: #6FBB69; --el-switch-off-color: #CA6C65;"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { useBillStore } from '@/store/bill.ts'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { IBillBook } from '@/types/bill.ts'
const billStore = useBillStore()
onMounted(async () => {
await billStore.initBillInfo()
billQuery.billBookList = deepCopyObject(billStore.billBookList)
billQuery.billBookList.push({
color: '',
icon: '',
id: 0,
label: '全部账本',
name: 'all'
})
})
const dateTypeOptions = [{
label: '按月选择',
value: 'month'
},
{
label: '按年选择',
value: 'year'
}]
const billQuery = reactive({
query: '',
billBookList: [] as IBillBook[]
})
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div v-for="(item, index) in billRankList.slice(0, MAX_RANK_COUNT)"
:key="index" class="rank-item">
<span class="rank-index">{{ index+1 }}.</span>
<span class="rank-content">{{ item.content }}</span>
<span class="rank-amount">{{ item.amount }}</span>
</div>
</template>
<script setup lang="ts">
import { useBillStore } from '@/store/bill'
import { getBillSortByAmount } from '@/utils/home/billUtil.ts'
import { computed } from 'vue'
const billStore = useBillStore()
const MAX_RANK_COUNT = 10
const billRankList = computed(() => {
return getBillSortByAmount(billStore.billRecordList, billStore.billType)
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="common-statistics">
<stats-card
title="当前支出" color="#CA6C65" unit="元" icon="fa-solid fa-sack-xmark"
:value="formatAmount(billStats.expensive)" />
<stats-card
title="当前收入" color="#6FBB69" unit="元" icon="fa-solid fa-sack-dollar"
:value="formatAmount(billStats.income)" />
<stats-card
title="当前结余" color="#E4BF62" unit="元" icon="fa-solid fa-coins"
:value="formatAmount(billStats.balance)" />
<stats-card
title="支出占比" color="#5470C6" unit="%" icon="fa-solid fa-chart-pie"
:value="(billStats.rate * 100).toFixed(2)" />
</div>
</template>
<script setup lang="ts">
import StatsCard from '@/components/Home/StatsCard.vue'
import { useBillStore } from '@/store/bill'
import { ref, watch } from 'vue'
import { getBillSummaryStats } from '@/utils/home/billUtil'
import { IBillSummaryStats } from '@/types/bill'
import { formatAmount } from 'vue3-common/utils/numberUtil'
const billStore = useBillStore()
watch(() => billStore.isRefresh, () => {
updateStats()
})
const billStats = ref<IBillSummaryStats>({
balance: 0,
expensive: 0,
income: 0,
rate: 0
})
const updateStats = () => {
billStats.value = getBillSummaryStats(billStore.billRecordList)
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div class="budget-chart">
<div class="budget-rank-chart card-item" ref="budgetRankChartRef"/>
<div class="budget-project-chart card-item" ref="budgetProjectChartRef"/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { useBudgetStore } from '@/store/budget'
import { barChartOptions, pieChartOptions } from 'vue3-common/utils/eChartsUtil'
const budgetStore = useBudgetStore()
const budgetRankChartRef = ref<HTMLElement>()
const budgetProjectChartRef = ref<HTMLElement>()
let budgetRankChart: echarts.ECharts
let budgetProjectChart: echarts.ECharts
watch(() => budgetStore.isRefresh, () => {
updateChart()
})
onMounted(() => {
budgetRankChart = echarts.init(budgetRankChartRef.value as HTMLElement)
budgetProjectChart = echarts.init(budgetProjectChartRef.value as HTMLElement)
})
const updateChart = () => {
// 预算排行统计图
const rankNameList = budgetStore.budgetRankStatsList.map((item) => item.name)
const rankValueList = budgetStore.budgetRankStatsList.map((item) => item.value)
budgetRankChart.setOption(barChartOptions('📊预算排行', '项目', rankNameList, '金额', rankValueList))
// 预算项目统计图
budgetProjectChart.setOption(pieChartOptions('📊预算项目', '项目', '', budgetStore.budgetProjectStatsList))
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<el-dialog v-model="budgetStore.budgetDialog.visible"
:title="budgetStore.budgetDialog.title"
center @open="openDialogEvent"
width="50%" class="budget-dialog">
<el-form ref="formRef" :model="budgetStore.budgetDialog.data"
:rules="rules" label-width="100px">
<el-form-item label="预算类别" prop="category">
<el-select
v-model="budgetStore.budgetDialog.data.category"
allow-create filterable placeholder="请输入或选择预算类别">
<el-option
v-for="(item, index) in budgetStore.budgetCategoryList"
:key="index" :label="item" :value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="预算项目" prop="project">
<el-input v-model="budgetStore.budgetDialog.data.project" autocomplete="off"
placeholder="请输入预算项目" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<el-form-item label="预算内容" prop="content">
<el-input v-model="budgetStore.budgetDialog.data.content" autocomplete="off"
placeholder="请输入预算内容" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<el-form-item label="供应商" prop="vendor">
<el-input v-model="budgetStore.budgetDialog.data.vendor" autocomplete="off"
placeholder="请输入预算供应商" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<el-form-item label="预算支出" prop="budget">
<el-input
v-model="budgetStore.budgetDialog.data.budget"
placeholder="请输入预算支出" clearable
:formatter="(value: string) => `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: string) => value.replace(/\$\s?|(,*)/g, '')"
/>
</el-form-item>
<el-form-item label="实际支出" prop="actual">
<el-input
v-model="budgetStore.budgetDialog.data.actual"
placeholder="请输入实际支出" clearable
:formatter="(value: string) => `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: string) => value.replace(/\$\s?|(,*)/g, '')"
/>
</el-form-item>
<el-form-item label="支付时间">
<el-date-picker
v-model="budgetStore.budgetDialog.data.payTime"
type="datetime" placeholder="请选择支付时间"
value-format="YYYY-MM-DD HH:mm:ss"/>
</el-form-item>
<el-form-item label="备注" prop="remake">
<el-input v-model="budgetStore.budgetDialog.data.remake" autocomplete="off"
placeholder="请输入备注" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">确认</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { useBudgetStore } from '@/store/budget'
import { FormRules } from 'element-plus'
import { budgetRules } from '@/utils/element/elRules'
import { nextTick, reactive, ref } from 'vue'
const formRef = ref()
const rules = reactive<FormRules>(budgetRules)
const budgetStore = useBudgetStore()
/**
* 打开对话框事件
*/
const openDialogEvent = async () => {
await nextTick(() => {
formRef.value.clearValidate()
})
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value.validate().then(async () => {
await budgetStore.handleBlogApi(budgetStore.budgetDialog.data.id)
budgetStore.queryInfo.category = budgetStore.budgetDialog.data.category
})
}
const cancelClick = () => {
budgetStore.budgetDialog.visible = false
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div class="budget-query common-query">
<el-switch
v-model="budgetStore.showDataType"
inline-prompt
style="--el-switch-on-color: #CA6C65; --el-switch-off-color: #009FA8"
active-value="CHART" inactive-value="DATA"
active-text="图表统计" inactive-text="数据显示"
@change="changeShowDataTypeEvent"
/>
<el-select
v-model="budgetStore.queryInfo.category"
placeholder="请选择类别" style="width: 120px"
@focus="focusCategoryEvent" @change="changeCategoryEvent">
<el-option
v-for="(item, index) in budgetStore.budgetCategoryList"
:key="index" :label="item" :value="item"
/>
</el-select>
<el-button v-if="budgetStore.showDataType === 'DATA'"
type="primary" @click="addBudgeClick">新增预算
</el-button>
</div>
</template>
<script lang="ts" setup>
import { useBudgetStore } from '@/store/budget'
const budgetStore = useBudgetStore()
const focusCategoryEvent = async () => {
await budgetStore.queryBudgetCategory()
}
const changeCategoryEvent = async () => {
await budgetStore.refreshInfo()
}
const addBudgeClick = () => {
budgetStore.budgetApiType = 'ADD'
budgetStore.budgetDialog = {
title: '新增预算',
visible: true,
data: budgetStore.getEmptyBudget()
}
budgetStore.budgetDialog.data.category = budgetStore.queryInfo.category
}
const changeShowDataTypeEvent = async () => {
await budgetStore.refreshInfo()
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<el-table :data="budgetStore.budgetList"
border stripe show-summary
:span-method="spanMethod" :summary-method="summaryMethod"
:header-cell-style="tableHeaderStyle()"
:row-style="tableRowStyle()" :cell-style="tableCellStyle()"
class="budget-table card-item">
<el-table-column type="index" align="center" width="80"/>
<el-table-column prop="project" label="项目" align="center" min-width="5%"/>
<el-table-column prop="content" label="内容" align="center" min-width="10%"/>
<el-table-column prop="vendor" label="供应商" align="center" min-width="10%"/>
<el-table-column prop="budget" label="预算支出(元)" align="center" min-width="10%">
<template #default="scope">
{{ formatAmount(scope.row.budget) }}
</template>
</el-table-column>
<el-table-column prop="actual" label="实际支出(元)" align="center" min-width="10%">
<template #default="scope">
{{ formatAmount(scope.row.actual) }}
</template>
</el-table-column>
<el-table-column prop="save" label="节省预算(元)" align="center" min-width="10%">
<template #default="scope">
{{ formatAmount(scope.row.budget - scope.row.actual) }}
</template>
</el-table-column>
<el-table-column prop="percent" label="支出占比" align="center" min-width="10%">
<template #default="scope">
{{ `${((scope.row.actual / totalActual) * 100).toFixed(2)}%` }}
</template>
</el-table-column>
<el-table-column prop="remake" label="备注" align="center" min-width="10%"/>
<el-table-column fixed="right" label="操作" align="center" width="250">
<template #default="scope">
<el-button type="primary" @click="editBudget(scope.row)">编辑</el-button>
<el-button type="primary" @click="editBudget(scope.row)">账单</el-button>
<el-button type="danger" @click="deleteBudget(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts" setup>
import { useBudgetStore } from '@/store/budget'
import { IBudgetInfo } from '@/types/budget.ts'
import { computed } from 'vue'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { formatAmount } from 'vue3-common/utils/numberUtil'
import { getRowspan, getSummaryData } from 'vue3-common/utils/elUtil'
import { commonElMessageBox, tableHeaderStyle, tableCellStyle, tableRowStyle } from 'vue3-common/utils/elUtil'
const budgetStore = useBudgetStore()
const spanIndex = 1
const summaryColumnList = ['budget', 'actual', 'save']
const totalActual = computed(() => {
return budgetStore.budgetList.reduce((sum, item: IBudgetInfo) => {
return sum + Number(item.actual || 0)
}, 0)
})
const spanMethod = (data: { row: any; column: any; rowIndex: number; columnIndex: number }) => {
// 需要合并的列索引
if (data.columnIndex === spanIndex) {
return getRowspan(budgetStore.budgetList, data.column.property, data.rowIndex, data.row)
}
}
const summaryMethod = (data: { columns: any[]; data: any[] }) => {
const sums = getSummaryData(data.columns, data.data, summaryColumnList)
// 格式化输出
sums[6] = formatAmount(parseFloat(sums[4] as string) - parseFloat(sums[5] as string))
sums[4] = formatAmount(parseFloat(sums[4] as string))
sums[5] = formatAmount(parseFloat(sums[5] as string))
sums[7] = '100%'
return sums
}
const editBudget = (budget: IBudgetInfo) => {
budgetStore.budgetApiType = 'UPDATE'
budgetStore.budgetDialog = {
title: '编辑预算',
visible: true,
data: deepCopyObject(budget)
}
}
const deleteBudget = (budget: IBudgetInfo) => {
budgetStore.budgetApiType = 'DELETE'
commonElMessageBox('是否删除该预算?').then(() => {
budgetStore.handleBlogApi(budget.id as number)
})
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="daily-card record-card-item">
<div class="card-item__icon">
<slot name="icon"></slot>
</div>
<div class="card-item__body">
<span class="card-item__title">
{{ props.title }}
</span>
<div class="card-item__content">
<span>{{ props.content }}</span>
<slot name="content"></slot>
</div>
</div>
<div class="card-item__extra">
<slot name="extra"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
required: true
}
})
</script>
<style lang="scss">
.daily-card {
padding: 0 5px;
height: 60px;
display: grid;
grid-template-columns: 40px 1fr 100px;
justify-items: center;
align-items: center;
gap: 5px;
.card-item__icon {
.iconfont {
font-size: 28px;
}
.el-avatar {
background: #009FA8;
font-size: 1.2rem;
}
}
.card-item__body {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
justify-self: left;
.card-item__title {
font-size: 1rem;
font-weight: bold;
}
.card-item__content {
display: flex;
gap: 5px;
font-size: 0.9rem;
color: #A9A9A9;
}
}
.card-item__extra {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
justify-self: flex-end;
}
.card-item__extra span:nth-child(1) {
font-size: 1rem;
}
.card-item__extra span:nth-child(2) {
font-size: 0.9rem;
color: #A9A9A9;
}
hr {
height: 1px;
background-color: #cccccc;
border: none;
}
}
.daily-card:hover {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="daily-summary">
<span class="date">{{ props.date }}</span>
<div class="daily-summary__extra">
<slot name="extra"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
date: {
type: String,
required: true
}
})
</script>
<style lang="scss">
.daily-summary {
padding: 0 5px;
display: flex;
justify-content: space-between;
color: gray;
font-size: 0.9rem;
font-weight: bold;
.daily-summary__extra {
display: flex;
gap: 5px;
}
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="info-item card-item">
<div class="title-info">
<svg-icon name="home-basic"/>
<span class="sub-title">基础信息</span>
</div>
<div class="info food-basic-info">
<el-tag v-if="foodStore.currentFoodRecipe.category">
{{ foodStore.currentFoodRecipe.category }}
</el-tag>
</div>
</div>
</template>
<script setup lang="ts">
import { SvgIcon } from 'vue3-common'
import { useFoodStore } from '@/store/food.ts'
const foodStore = useFoodStore()
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="info-item card-item">
<div class="title-info">
<svg-icon name="home-material"/>
<span class="sub-title">食材信息</span>
</div>
<div class="info material-info">
<div class="material-item">
<span class="material-title">主料</span>
<div v-if="getFoodMaterial('主料').length > 0" class="material-content">
<el-tag v-for="(item, index) in getFoodMaterial('主料')"
:key="index">{{ `${item.name} ${item.amount}` }}
</el-tag>
</div>
<div v-else class="material-content">
<el-tag></el-tag>
</div>
</div>
<div class="material-item">
<span class="material-title">配料</span>
<div v-if="getFoodMaterial('辅料').length > 0" class="material-content">
<el-tag v-for="(item, index) in getFoodMaterial('辅料')"
:key="index">{{ `${item.name} ${item.amount}` }}
</el-tag>
</div>
<div v-else class="material-content">
<el-tag></el-tag>
</div>
</div>
<div class="material-item">
<span class="material-title">辅料</span>
<div v-if="getFoodMaterial('调料').length > 0" class="material-content">
<el-tag v-for="(item, index) in getFoodMaterial('调料')"
:key="index">{{ `${item.name} ${item.amount}` }}
</el-tag>
</div>
<div v-else class="material-content">
<el-tag></el-tag>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food.ts'
import { SvgIcon } from 'vue3-common'
const foodStore = useFoodStore()
const getFoodMaterial = (type: string) => {
return foodStore.currentFoodRecipe.materialList.filter((item) => {
return item.type === type
})
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div class="info-item card-item">
<div class="title-info">
<svg-icon name="home-others"/>
<span class="sub-title">其他信息</span>
</div>
<div class="others-info">
<span class="others-title">备注</span>
<span v-if="foodStore.currentFoodRecipe.remark">
{{ foodStore.currentFoodRecipe.remark }}
</span>
<span v-else>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food.ts'
import { SvgIcon } from 'vue3-common'
const foodStore = useFoodStore()
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="info-item">
<div class="title-info">
<svg-icon name="home-work"/>
<span class="sub-title">成果展示</span>
</div>
<div v-if="foodStore.currentFoodRecipe.recordList.length > 0" class="info record-info">
<el-timeline>
<el-timeline-item v-for="(item, index) in foodStore.currentFoodRecipe.recordList"
:key="index" :timestamp="item.date" placement="top">
<el-card class="record-card">
<span class="record-content">完成人{{ item.person }}</span>
<el-image v-if="item.imageUrl"
:src="serviceFileRootPath + item.imageUrl"
:preview-src-list="[serviceFileRootPath + item.imageUrl]"
fit="contain" alt="暂无图片" class="record-image"/>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
<el-empty v-else description="暂无成果"/>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food.ts'
import { serviceFileRootPath } from '@/utils'
import { SvgIcon } from 'vue3-common'
const foodStore = useFoodStore()
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div class="info-item card-item">
<div class="title-info">
<svg-icon name="home-step"/>
<span class="sub-title">菜谱信息</span>
</div>
<div class="step-info">
<div v-for="(item, index) in foodStore.currentFoodRecipe.stepList"
:key="index" class="step-container">
<span class="step-number">{{ index + 1 }}</span>
<div class="step-body">
<span class="step-content">{{ item.content }}</span>
<img v-if="item.imageUrl"
:src="serviceFileRootPath + item.imageUrl" alt="暂无图片" class="step-image"/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food.ts'
import { serviceFileRootPath } from '@/utils'
import { SvgIcon } from 'vue3-common'
const foodStore = useFoodStore()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="food-daily">
<v-calendar ref="calendarRef" :attributes='dailyInfo.attrs'
@dayclick="dateClick" @did-move="changeMonthClick"
:is-dark="appStore.isDark"
class="food-calendar card-item"/>
<food-daily-item :date="dailyInfo.calendarDate"/>
</div>
</template>
<script setup lang="ts">
import FoodDailyItem from '@/components/Home/Food/FoodDailyItem.vue'
import { reactive, watch, ref, onMounted } from 'vue'
import { useFoodStore } from '@/store/food.ts'
import { formatDate } from 'vue3-common/utils/dateUtil'
import { CalendarDay } from 'v-calendar/dist/types/src/utils/page'
import { useAppStore } from '@/store/app'
const calendarRef = ref()
const foodStore = useFoodStore()
const appStore = useAppStore()
onMounted(() => {
const { month } = foodStore.queryRecord
calendarRef.value.move(new Date(month))
})
watch(() => foodStore.isRefresh, () => {
updateInfo()
})
const dailyInfo = reactive({
calendarDate: '',
attrs: [] as any[]
})
const updateInfo = () => {
getCalendarMoveDate()
getCalendarAttrDates()
}
const getCalendarMoveDate = () => {
const foodLength = foodStore.foodRecordList.length
if (foodLength > 0) {
const today = formatDate(new Date())
if (foodStore.foodRecordList.some((item) => item.date === today)) {
dailyInfo.calendarDate = today
} else {
dailyInfo.calendarDate = formatDate(foodStore.foodRecordList[foodLength - 1].date)
}
} else {
dailyInfo.calendarDate = ''
}
}
const getCalendarAttrDates = () => {
dailyInfo.attrs = []
dailyInfo.attrs.push({
key: 'today',
highlight: true,
dates: new Date()
})
const dateList = foodStore.foodRecordList.map((item) => item.date)
dailyInfo.attrs.push({
dot: 'red',
dates: [...new Set(dateList)]
})
}
/**
* 选择日期事件
* @param day 日期
*/
const dateClick = (day: CalendarDay) => {
dailyInfo.calendarDate = formatDate(day.date)
}
/**
* 选择月份事件
*/
const changeMonthClick = async (value) => {
foodStore.queryRecord.month = value[0].id
await foodStore.refreshInfo()
}
</script>
<style lang="scss">
.food-daily {
height: 100%;
display: flex;
flex-direction: column;
gap: 1vh;
.food-calendar {
width: 100%;
justify-self: center;
align-self: center;
}
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div class="life-record-card card-item">
<el-carousel v-if="props.foodItem?.recordList.length > 0"
arrow="always" @change="changeFoodEvent">
<el-carousel-item v-for="(item, index) in getFoodRecord(props.foodItem?.recordList)"
:key="index">
<el-image :src="serviceFileRootPath + item.imageUrl"
fit="contain" alt="暂无成果">
<template #error>
<div class="image-slot">
<span>暂无图片</span>
</div>
</template>
</el-image>
</el-carousel-item>
</el-carousel>
<el-empty v-else description="暂无成果" />
<div class="record-info">
<div class="top-container">
<div class="info-list">
<div class="info-item">
<span class="title">{{ props.foodItem.name }}</span>
</div>
</div>
<div class="button-info">
<el-button v-if="!isMobile()"
type="primary" :icon="Edit" circle :size="'small'"
@click="editFoodRecipeClick"/>
<el-button type="primary" :icon="MoreFilled" circle :size="'small'"
@click="viewFoodRecipeClick()" />
<el-button type="danger" :icon="DeleteFilled" circle :size="'small'"
@click="deleteFoodRecipeClick()" />
</div>
</div>
<div class="bottom-container">
<div class="info-list">
<div class="info-item category-item">
<i class="fa-solid fa-utensils"></i>
{{ props.foodItem.category }}
</div>
</div>
<div v-if="props.foodItem?.recordList.length > 0" class="info-list">
<div class="info-item">
<i class="fa-solid fa-flag"></i>
{{ props.foodItem.recordList[foodCardInfo.currentFoodIndex].person}}
</div>
<div class="info-item">
<i class="fa-regular fa-calendar-check"></i>
{{ formatDate(props.foodItem.recordList[foodCardInfo.currentFoodIndex].date) }}
</div>
</div>
<div v-else>
<span>暂无成果</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Edit, MoreFilled, DeleteFilled } from '@element-plus/icons-vue'
import { useFoodStore } from '@/store/food'
import { defineProps, onMounted, PropType, reactive } from 'vue'
import { IFoodRecipe, IFoodRecord } from '@/types/food.ts'
import { useRouter } from 'vue-router'
import { formatDate } from 'vue3-common/utils/dateUtil'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
import { serviceFileRootPath } from '@/utils'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const router = useRouter()
const foodStore = useFoodStore()
const foodCardInfo = reactive({
recommendRate: 0,
currentFoodIndex: 0
})
const props = defineProps({
foodItem: {
required: true,
type: Object as PropType<IFoodRecipe>
}
})
onMounted(() => {
foodCardInfo.recommendRate = props.foodItem?.recommendRate as number
})
const getFoodRecord = (recordList: IFoodRecord[]) => {
return recordList.filter((item) => item.imageUrl)
}
const editFoodRecipeClick = async () => {
const id = props.foodItem?.id
foodStore.foodRecipeApiType = 'UPDATE'
await foodStore.queryFoodRecipe(id as number)
foodStore.foodRecipeDialog = {
title: '编辑菜谱',
visible: true,
data: deepCopyObject(foodStore.currentFoodRecipe)
}
}
const viewFoodRecipeClick = async () => {
if (isMobile()) {
await router.push({ name: 'MobileHomeFoodDetailId', params: { id: props.foodItem?.id } })
} else {
await router.push({ name: 'HomeLifeFoodId', params: { id: props.foodItem?.id } })
}
}
const deleteFoodRecipeClick = () => {
foodStore.foodRecipeApiType = 'DELETE'
commonElMessageBox(`请确认是否删除该菜谱: ${props.foodItem?.name}?`).then(() => {
foodStore.handleFoodRecipeApi(props.foodItem?.id as number)
})
}
const changeFoodEvent = (current: number) => {
foodCardInfo.currentFoodIndex = current
}
</script>
<style lang="scss">
@use "@/styles/home/life.record.module.scss";
</style>

View File

@@ -0,0 +1,117 @@
<template>
<el-empty v-if="dailyItemInfo.itemList.length === 0" />
<div v-else class="food-daily-item card-item">
<daily-summary :date="dailyItemInfo.date"/>
<div class="daily-list">
<daily-card v-for="(item, index) in dailyItemInfo.itemList"
:key="index" @click="selectFoodRecordClick(item)"
:title="item.name" :content="item.category">
<template #icon>
<el-avatar>{{ item.category.charAt(0) }}</el-avatar>
</template>
<template #extra>
<span>{{ item.person }}</span>
<span>{{ item.date }}</span>
</template>
</daily-card>
</div>
</div>
</template>
<script setup lang="ts">
import DailySummary from '@/components/Home/DailySummary.vue'
import DailyCard from '@/components/Home/DailyCard.vue'
import { defineProps, onMounted, reactive, watch } from 'vue'
import { useFoodStore } from '@/store/food.ts'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/store/app.ts'
import { formatChineseDate } from 'vue3-common/utils/dateUtil'
import { IFoodRecord } from '@/types/food.ts'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const foodStore = useFoodStore()
const appStore = useAppStore()
const router = useRouter()
const props = defineProps({
date: {
required: true,
type: String
}
})
watch(() => props.date, () => {
getFoodDailyByDate(props.date as string)
})
watch(() => foodStore.isRefresh, () => {
getFoodDailyByDate(props.date as string)
})
onMounted(() => {
getFoodDailyByDate(props.date as string)
})
const dailyItemInfo = reactive({
date: '',
itemList: [] as IFoodRecord[]
})
/**
* 根据日期获取当天的美食记录
* @param date
*/
const getFoodDailyByDate = (date: string) => {
if (date) {
dailyItemInfo.date = formatChineseDate(date, 'MM月DD日 dddd')
dailyItemInfo.itemList = []
foodStore.foodRecordList.forEach((item) => {
if (item.date === date) {
dailyItemInfo.itemList.push(item)
}
})
} else {
dailyItemInfo.itemList = []
}
}
/**
* 点击美食记录按钮
* @param foodRecord 美食记录
*/
const selectFoodRecordClick = async (foodRecord: IFoodRecord) => {
foodStore.foodRecordApiType = 'UPDATE'
if (isMobile()) {
appStore.isShowMobileBack = true
foodStore.currentFoodRecord = foodRecord
await router.push({ name: 'MobileHomeFoodRecordForm' })
} else {
foodStore.foodRecordDialog = {
title: '编辑记录',
visible: true,
data: deepCopyObject(foodRecord)
}
}
}
</script>
<style lang="scss">
.food-daily-item {
display: flex;
flex-direction: column;
gap: 1vh;
padding: 1vh 0.5vw;
.daily-list {
display: flex;
flex-direction: column;
gap: 1vh;
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="food-daily-list">
<el-date-picker v-model="foodStore.queryRecord.year"
type="year" :editable="false" style="width: 120px;"
@change="foodStore.refreshInfo()"/>
<el-empty v-if="foodDailyInfo.dateList.length === 0"/>
<el-scrollbar>
<food-daily-item v-for="(item, index) in foodDailyInfo.dateList"
:key="index" :date="item"/>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import FoodDailyItem from '@/components/Home/Food/FoodDailyItem.vue'
import { reactive, watch } from 'vue'
import { useFoodStore } from '@/store/food.ts'
const foodStore = useFoodStore()
watch(() => foodStore.isRefresh, () => {
updateInfo()
})
const foodDailyInfo = reactive({
dateList: [] as string[]
})
const updateInfo = () => {
const dateList = foodStore.foodRecordList.map((item) => item.date)
foodDailyInfo.dateList = [...new Set(dateList)] as string[]
}
</script>
<style lang="scss">
.food-daily-list {
height: calc(100vh - 120px);
overflow: auto;
display: flex;
flex-direction: column;
gap: 1vh;
.el-date-editor {
align-self: center;
}
.el-scrollbar {
.el-scrollbar__view {
display: flex;
flex-direction: column;
gap: 2vh;
}
}
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<el-dialog v-model="dialogData.visible" :title="dialogData.title"
center @open="openDialogEvent" class="food-dialog">
<el-form ref="formRef" :model="dialogData.data"
:rules=rules label-width="80px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="美食名称" prop="name">
<el-input v-model="dialogData.data.name" autocomplete="off"
placeholder="请输入美食名称" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<el-form-item label="美食菜系" prop="category">
<el-select v-model="dialogData.data.category">
<el-option v-for="(item, index) in foodCategoryList"
:key="index" :label="item" :value="item"/>
</el-select>
</el-form-item>
<el-form-item label="推荐指数" prop="recommendRate">
<el-rate v-model="dialogData.data.recommendRate"
:colors="foodStore.rateColorList"/>
</el-form-item>
<el-form-item label="食材">
<food-material-form />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="步骤">
<food-step-form />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="dialogData.data.remark" autocomplete="off"
placeholder="请输入备注" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import FoodMaterialForm from '@/components/Home/Food/Form/FoodMaterialForm.vue'
import FoodStepForm from '@/components/Home/Food/Form/FoodStepForm.vue'
import { useFoodStore } from '@/store/food'
import { FormInstance, FormRules } from 'element-plus'
import { computed, nextTick, ref, reactive } from 'vue'
import { foodRules } from '@/utils/element/elRules'
const rules = reactive<FormRules>(foodRules)
const formRef = ref<FormInstance>()
const foodStore = useFoodStore()
const foodCategoryList = ['家常菜', '淮扬菜', '鲁菜', '川菜', '粤菜', '闽菜', '浙菜', '湘菜', '徽菜', '其他']
const dialogData = computed(() => foodStore.foodRecipeDialog)
/**
* 打开对话框事件
*/
const openDialogEvent = async () => {
await nextTick(() => {
formRef.value?.clearValidate()
})
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value?.validate().then(() => {
foodStore.handleFoodRecipeApi()
})
}
const cancelClick = () => {
dialogData.value.visible = false
}
</script>
<style lang="scss">
.food-dialog {
width: 75%;
.el-dialog__body {
height: 65vh;
.el-button.is-text {
padding: 0;
}
.material-table {
height: 45vh;
}
.food-step-table {
height: 55vh;
}
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div v-if="isMobile()" class="mobile-common-query card-item">
<div class="query-item">
<span>分类</span>
<el-segmented v-model="foodStore.queryRecipe.category"
:options="queryCategoryList"
@change="queryFoodSegmented"/>
</div>
</div>
<div v-else class="common-query">
<el-select v-model="foodStore.queryDateType"
@change="foodStore.refreshInfo()"
style="width: 120px;align-self: center;">
<el-option v-for="(item, index) in dateTypeOptions" :key="index"
:label="item.label" :value="item.value"/>
</el-select>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { onMounted, ref } from 'vue'
import { ICategory } from '@/types'
const foodStore = useFoodStore()
const queryCategoryList = ref([] as ICategory[])
onMounted(async () => {
await foodStore.queryFoodCategory()
initCategoryList()
})
const dateTypeOptions = [{
label: '按月选择',
value: 'month'
},
{
label: '按年选择',
value: 'year'
}]
const initCategoryList = () => {
queryCategoryList.value = []
queryCategoryList.value.push({
label: '全部',
value: ''
})
foodStore.foodCategoryList.forEach((item) => {
queryCategoryList.value.push({
label: item,
value: item
})
})
}
const queryFoodSegmented = async () => {
foodStore.pageInfo = {
currentPage: 1,
pageSize: 3,
totalSize: 1
}
foodStore.foodRecipeList = []
await foodStore.queryMobileFoodRecipeList()
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="food-list card-item">
<el-empty v-if="foodStore.foodRecipeList.length === 0" description="暂无美食记录"/>
<div v-else class="list">
<food-card v-for="(item, index) in foodStore.foodRecipeList"
:key="index" :food-item="item"/>
</div>
<el-pagination
v-model:current-page="foodStore.pageInfo.currentPage"
:page-size="foodStore.pageInfo.pageSize"
layout="total, prev, pager, next"
:total="foodStore.pageInfo.totalSize"
@change="changePageEvent"
/>
<el-button v-if="isLargeScreen()"
type="primary" :icon="Plus" circle
@click="addFoodRecipeClick" class="add-new" />
</div>
</template>
<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue'
import FoodCard from '@/components/Home/Food/FoodCard.vue'
import { useFoodStore } from '@/store/food.ts'
import { isLargeScreen } from '@/utils'
const foodStore = useFoodStore()
const addFoodRecipeClick = () => {
foodStore.foodRecipeApiType = 'ADD'
foodStore.foodRecipeDialog = {
title: '新增菜谱',
visible: true,
data: foodStore.getEmptyFoodRecipe()
}
}
const changePageEvent = (value: number) => {
foodStore.pageInfo.currentPage = value
foodStore.queryFoodRecipeList()
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="common-statistics">
<stats-card
title="菜谱总数" color="#CA6C65" unit="个" icon="iconfont icon-caipu"
:value="foodStore.foodStats.recipeCount" />
<stats-card
title="菜谱类别" color="#6FBB69" unit="种" icon="iconfont icon-leibie"
:value="foodStore.foodStats.categoryCount" />
<stats-card
title="做菜次数" color="#E4BF62" unit="次" icon="iconfont icon-cishu"
:value="foodStore.foodStats.workCount" />
<stats-card
title="平均次数" color="#5470C6" unit="次/月" icon="iconfont icon-pinlv"
:value="getAverageCount()" />
</div>
</template>
<script setup lang="ts">
import StatsCard from '@/components/Home/StatsCard.vue'
import { onMounted } from 'vue'
import { useFoodStore } from '@/store/food'
const foodStore = useFoodStore()
onMounted(async () => {
await foodStore.queryFoodStats()
})
const getAverageCount = () => {
if (foodStore.queryDateType === 'month') {
return foodStore.foodRecordList.length
} else {
const result = {}
// 遍历数据,按月统计出现次数
foodStore.foodRecordList.forEach((item) => {
const date = new Date(item.date)
const month = String(date.getMonth() + 1)
if (result[month]) {
result[month].count += 1
} else {
result[month] = { count: 1 }
}
})
return foodStore.foodRecordList.length / Object.keys(result).length
}
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<el-dialog v-model="foodStore.foodMaterialDialog.visible"
:title="foodStore.foodMaterialDialog.title"
center @open="openDialogEvent"
class="food-material-dialog">
<el-form ref="formRef" :model="foodStore.foodMaterialDialog.data"
:rules="rules">
<el-form-item label="类型" prop="type">
<el-select
v-model="foodStore.foodMaterialDialog.data.type"
placeholder="请选择类型">
<el-option
v-for="(item, index) in foodMaterialTypeList"
:key="index" :label="item" :value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="foodStore.foodMaterialDialog.data.name" autocomplete="off"
placeholder="请输入名称" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<el-form-item label="分量" prop="amount">
<el-input v-model="foodStore.foodMaterialDialog.data.amount" autocomplete="off"
placeholder="请输入分量1斤/2勺/适量)" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { useFoodStore } from '@/store/food'
import { nextTick, reactive, ref } from 'vue'
import { FormRules } from 'element-plus'
import { foodMaterialRules } from '@/utils/element/elRules'
const rules = reactive<FormRules>(foodMaterialRules)
const formRef = ref()
const foodStore = useFoodStore()
const foodMaterialTypeList = ['主料', '辅料', '调料']
/**
* 打开对话框事件
*/
const openDialogEvent = () => {
nextTick(() => {
formRef.value?.clearValidate()
})
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value.validate().then(async () => {
if (foodStore.foodMaterialApiType === 'UPDATE') {
const { editFoodMaterialIndex: index } = foodStore
foodStore.foodRecipeDialog.data.materialList[index] = foodStore.foodMaterialDialog.data
} else {
foodStore.foodRecipeDialog.data.materialList.push(foodStore.foodMaterialDialog.data)
}
foodStore.foodMaterialDialog.visible = false
})
}
const cancelClick = () => {
foodStore.foodMaterialDialog.visible = false
}
</script>
<style lang="scss">
.food-material-dialog {
width: 30%;
.el-dialog__body {
// height: 50vh;
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<el-table :data="foodStore.foodRecipeDialog.data.materialList"
border stripe class="material-table">
<el-table-column type="index" align="center" width="90">
<template #header>
<el-button type="primary" @click="handleAddClick">新增</el-button>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" align="center" min-width="10%"/>
<el-table-column prop="name" label="名称" align="center" min-width="20%"/>
<el-table-column prop="amount" label="分量" align="center" min-width="20%"/>
<el-table-column label="操作" align="center" width="100">
<template #default="scope">
<el-button type="primary" text
@click="handleEditClick(scope.$index, scope.row)">编辑</el-button>
<el-button type="danger" text
@click="handleDeleteClick(scope.$index, scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food'
import { IFoodMaterial } from '@/types/food'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
const foodStore = useFoodStore()
const handleAddClick = () => {
foodStore.foodMaterialApiType = 'ADD'
foodStore.foodMaterialDialog = {
title: '新增食材',
visible: true,
data: foodStore.getEmptyFoodMaterial()
}
}
const handleEditClick = (index: number, material: IFoodMaterial) => {
foodStore.editFoodMaterialIndex = index
foodStore.foodMaterialApiType = 'UPDATE'
foodStore.foodMaterialDialog = {
title: '编辑食材',
visible: true,
data: deepCopyObject(material)
}
}
const handleDeleteClick = (index: number, material: IFoodMaterial) => {
foodStore.foodMaterialApiType = 'DELETE'
commonElMessageBox(`确认删除该食材: ${material.name}?`).then(() => {
foodStore.foodRecipeDialog.data.materialList.splice(index, 1)
})
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<el-dialog v-model="dialogData.visible" :title="dialogData.title"
center @open="openDialogEvent" class="food-record-dialog">
<el-form ref="formRef" :model="dialogData.data"
:rules="rules" label-width="80">
<el-form-item label="菜谱名称" prop="name">
<el-select
v-model="dialogData.data.name"
@focus="foodStore.queryFoodNameList()"
:disabled="foodStore.foodRecordApiType === 'UPDATE'"
placeholder="请选择菜谱名称">
<el-option
v-for="(item, index) in foodStore.foodNameList"
:key="index" :label="item" :value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="完成时间" prop="date">
<el-date-picker
v-model="dialogData.data.date"
type="date" :editable="false" placeholder="请选择完成时间"
value-format="YYYY-MM-DD"/>
</el-form-item>
<el-form-item label="完成人" prop="person">
<el-select
v-model="dialogData.data.person"
placeholder="请选择完成人">
<el-option
v-for="(item, index) in ['哥哥', '宝宝']"
:key="index" :label="item" :value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="图片">
<upload-image v-model="dialogData.data.imageUrl"
:service-url="fileServiceUrl"
:image-limit="1"
:service-file-root-path="serviceFileRootPath"
:image-path="foodStore.foodImagePath"/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">保存</el-button>
<el-button v-if="foodStore.foodRecordApiType === 'UPDATE'"
type="danger" @click="deleteClick">删除</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { UploadImage } from 'vue3-common'
import { useFoodStore } from '@/store/food'
import { computed, ref, reactive, nextTick } from 'vue'
import { FormInstance, FormRules } from 'element-plus'
import { foodRecordRules } from '@/utils/element/elRules'
import { fileServiceUrl, serviceFileRootPath } from '@/utils'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
const rules = reactive<FormRules>(foodRecordRules)
const formRef = ref<FormInstance>()
const foodStore = useFoodStore()
const dialogData = computed(() => foodStore.foodRecordDialog)
/**
* 打开对话框事件
*/
const openDialogEvent = async () => {
await nextTick(() => {
formRef.value?.clearValidate()
})
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value?.validate().then(() => {
foodStore.handleFoodRecordApi(foodStore.foodRecordDialog.data)
})
}
const cancelClick = () => {
dialogData.value.visible = false
}
const deleteClick = () => {
commonElMessageBox('是否确认删除该记录?').then(() => {
foodStore.foodRecordApiType = 'DELETE'
foodStore.handleFoodRecordApi(dialogData.value.data)
})
}
</script>
<style lang="scss">
.food-record-dialog {
max-width: 600px;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<el-dialog v-model="foodStore.foodStepDialog.visible"
:title="foodStore.foodStepDialog.title"
center @open="openDialogEvent"
class="food-step-dialog">
<el-form ref="formRef" :model="foodStore.foodStepDialog.data"
:rules="rules">
<el-form-item label="内容" prop="content">
<el-input v-model="foodStore.foodStepDialog.data.content" autocomplete="off"
placeholder="请输入内容" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<el-form-item label="图片">
<upload-image v-model="foodStore.foodStepDialog.data.imageUrl"
:service-url="fileServiceUrl"
:image-limit="1"
:service-file-root-path="serviceFileRootPath"
:image-path="foodStore.foodImagePath"/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { UploadImage } from 'vue3-common'
import { useFoodStore } from '@/store/food'
import { ref, reactive, nextTick } from 'vue'
import { FormRules } from 'element-plus'
import { foodStepRules } from '@/utils/element/elRules'
import { fileServiceUrl, serviceFileRootPath } from '@/utils'
const rules = reactive<FormRules>(foodStepRules)
const formRef = ref()
const foodStore = useFoodStore()
/**
* 打开对话框事件
*/
const openDialogEvent = () => {
nextTick(() => {
formRef.value?.clearValidate()
})
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value.validate()
.then(async () => {
if (foodStore.foodStepApiType === 'UPDATE') {
const { editFoodStepIndex: index } = foodStore
foodStore.foodRecipeDialog.data.stepList[index] = foodStore.foodStepDialog.data
} else {
foodStore.foodRecipeDialog.data.stepList.push(foodStore.foodStepDialog.data)
}
foodStore.foodStepDialog.visible = false
})
}
const cancelClick = () => {
foodStore.foodStepDialog.visible = false
}
</script>
<style lang="scss">
.food-step-dialog {
width: 30%;
.el-dialog__body {
// height: 50vh;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<el-table :data="foodStore.foodRecipeDialog.data.stepList"
border class="food-step-table">
<el-table-column type="index" align="center" width="90">
<template #header>
<el-button type="primary" @click="handleAddClick">新增</el-button>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" align="center" min-width="20%" />
<el-table-column prop="imageUrl" label="图片" align="center" min-width="20%">
<template #default="scope">
<el-image v-if="scope.row.imageUrl" :src="serviceFileRootPath + scope.row.imageUrl" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100">
<template #default="scope">
<el-button type="primary" text
@click="handleEditClick(scope.$index, scope.row)">编辑</el-button>
<el-button type="danger" text
@click="handleDeleteClick(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food'
import { IFoodStep } from '@/types/food'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
import { serviceFileRootPath } from '@/utils'
const foodStore = useFoodStore()
const handleAddClick = () => {
foodStore.foodStepApiType = 'ADD'
foodStore.foodStepImageList = []
foodStore.foodStepDialog = {
title: '新增步骤',
visible: true,
data: foodStore.getEmptyFoodStep()
}
}
const handleEditClick = (index: number, step: IFoodStep) => {
foodStore.editFoodStepIndex = index
foodStore.foodStepApiType = 'UPDATE'
foodStore.foodStepDialog = {
title: '编辑步骤',
visible: true,
data: deepCopyObject(step)
}
}
const handleDeleteClick = (index: number) => {
commonElMessageBox('确认删除该步骤?').then(() => {
foodStore.foodStepApiType = 'DELETE'
foodStore.foodRecipeDialog.data.stepList.splice(index, 1)
foodStore.foodRecipeDialog.data.stepList.forEach((item) => {
if (item.sort > index) {
item.sort -= 1
}
})
})
}
</script>
<style lang="scss">
.food-step-table {
.el-image {
width: 100%;
height: 100%;
max-height: 200px;
max-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div class="query-input-box">
<input v-model="query" type="text"
@blur="blurQueryEvent" @keyup.enter="enterQueryEvent"
placeholder="搜索" class="input-text"/>
<span class="input-icon"><i class="fa fa-search" aria-hidden="true"></i></span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const query = ref<string>('')
const enterQueryEvent = () => {
console.log(query.value)
}
const blurQueryEvent = () => {
query.value = ''
}
</script>
<style scoped lang="scss">
$theme-color: #009FA8;
$query-input-width: 250px;
.query-input-box {
position: relative;
display: inline-block;
.input-text {
padding: 0 0 0 40px;
width: 120px;
height: 40px;
font-size: 16px;
border: 1px solid $theme-color;
border-radius: 15px;
background: #FFFFFF;
transition: width .5s;
transition-delay: .1s;
}
.input-text:focus {
width: $query-input-width;
outline: none;
box-shadow: none;
}
.input-text:focus+.span {
background-color: $theme-color;
color: #fff;
}
.input-icon {
position: absolute;
top: 5px;
left: 5px;
width: 30px;
height: 30px;
line-height: 30px;
padding: 0;
text-align: center;
background: #F5F5F5;
border-radius: 50%;
font-size: 15px;
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="journal-daily">
<v-calendar ref="calendarRef" :attributes='dailyInfo.attrs'
@dayclick="dateClick" @did-move="changeMonthClick"
:is-dark="appStore.isDark"
class="journal-calendar card-item"/>
<journal-card v-if="journalStore.currentJournal.id !== 0"
:item="journalStore.currentJournal"/>
<el-empty v-else description="今日暂无日记" />
</div>
</template>
<script setup lang="ts">
import JournalCard from '@/components/Home/Journal/JournalCard.vue'
import { reactive, watch, ref } from 'vue'
import { formatDate, getStartEndDate } from 'vue3-common/utils/dateUtil'
import { CalendarDay } from 'v-calendar/dist/types/src/utils/page'
import { useAppStore } from '@/store/app'
import { useJournalStore } from '@/store/journal.ts'
import { IJournal } from '@/types/journal.ts'
const calendarRef = ref()
const appStore = useAppStore()
const journalStore = useJournalStore()
watch(() => journalStore.isRefresh, () => {
updateInfo()
})
const dailyInfo = reactive({
calendarDate: '',
attrs: [] as any[]
})
const updateInfo = () => {
getCalendarMoveDate()
getCalendarAttrDates()
}
const getCalendarMoveDate = () => {
const recordLength = journalStore.journalList.length
if (recordLength > 0) {
journalStore.currentJournal = getJournalDay(formatDate(new Date()))
}
}
const getCalendarAttrDates = () => {
dailyInfo.attrs = []
dailyInfo.attrs.push({
key: 'today',
highlight: true,
dates: new Date()
})
const dateList = journalStore.journalList.map((item) => item.date)
dailyInfo.attrs.push({
dot: 'red',
dates: [...new Set(dateList)]
})
}
/**
* 选择日期事件
* @param day 日期
*/
const dateClick = (day: CalendarDay) => {
journalStore.currentJournal = getJournalDay(formatDate(day.date))
}
const getJournalDay = (date: string): IJournal => {
return journalStore.journalList.find((item) => formatDate(item.date) === date)
|| journalStore.getEmptyJournal()
}
/**
* 选择月份事件
*/
const changeMonthClick = async (value) => {
const month = value[0].id
journalStore.queryInfo.startDate = getStartEndDate(month, 'month')[0]
journalStore.queryInfo.endDate = getStartEndDate(month, 'month')[1]
await journalStore.refreshInfo()
}
</script>
<style lang="scss">
.journal-daily {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 1vh;
.journal-calendar {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<div class="journal-note card-item">
<div class="journal-head" @click="editClick">
<div class="head-left">
<span class="time">
{{ formatDate(props.item.date, 'HH:mm') }}
</span>
<span v-if="props.item?.location" class="location">
<i class="fa-solid fa-location-dot"></i>
{{ props.item.location }}
</span>
<el-tag v-if="props.item?.category" type="primary">
{{ props.item.category }}
</el-tag>
<span v-if="props.item?.mood" class="emoji">
{{ props.item.mood }}
</span>
</div>
<svgIcon v-if="props.item?.weather" :name="`weather-${props.item?.weather}`"/>
</div>
<!-- 内容区域 -->
<div class="journal-body">
<!-- 可折叠文本 -->
<div
ref="textContent"
class="text-content"
:class="{ 'collapsed': !expanded }"
@click="toggleExpand">
{{ props.item.content }}
</div>
<!-- 单媒体内容图片优先于视频 -->
<el-image
v-if="props.item?.imageUrl"
:src="serviceFileRootPath + props.item?.imageUrl"
:preview-src-list="[serviceFileRootPath + props.item?.imageUrl]"
fit="contain"
class="media-content"/>
<video
v-if="props.item?.videoUrl"
controls
class="media-content">
<source :src="serviceFileRootPath + props.item?.videoUrl" type="video/mp4">
</video>
<!-- 展开/收起按钮 -->
<el-button
v-if="showToggle"
text class="toggle-btn"
@click.stop="toggleExpand">
<el-icon>
<ArrowUp v-if="expanded" />
<ArrowDown v-else />
</el-icon>
{{ expanded ? '收起内容' : `展开全文(${overflowLines}行)` }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, PropType, defineProps } from 'vue'
import { ElTag, ElButton, ElImage } from 'element-plus'
import { SvgIcon } from 'vue3-common'
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { IJournal } from '@/types/journal.ts'
import { formatDate } from 'vue3-common/utils/dateUtil'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { useJournalStore } from '@/store/journal.ts'
import { useAppStore } from '@/store/app.ts'
import { useRouter } from 'vue-router'
import { serviceFileRootPath } from '@/utils'
const props = defineProps({
item: {
required: true,
type: Object as PropType<IJournal>
}
})
const journalStore = useJournalStore()
const appStore = useAppStore()
const router = useRouter()
onMounted(() => {
if (overflowLines.value <= 0) expanded.value = true
})
// 文本展开逻辑
const expanded = ref(false)
const textContent = ref<HTMLElement>()
const maxLines = 3
const overflowLines = computed(() => {
if (!textContent.value) return 0
const lineHeight = parseInt(getComputedStyle(textContent.value).lineHeight, 10)
return Math.round((textContent.value.scrollHeight - textContent.value.clientHeight) / lineHeight)
})
const showToggle = computed(() => overflowLines.value > 0)
const toggleExpand = () => expanded.value = !expanded.value
const editClick = async () => {
const id = props.item?.id
journalStore.journalApiType = 'UPDATE'
journalStore.currentJournal = props.item
if (isMobile()) {
appStore.isShowMobileBack = true
await router.push({ name: 'MobileHomeJournalDetailId', params: { id } })
} else {
journalStore.journalDialog = {
title: '编辑日记',
visible: true,
data: deepCopyObject(journalStore.currentJournal)
}
}
}
</script>
<style lang="scss">
.journal-note {
width: 100%;
overflow: auto;
.journal-head {
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
.head-left {
display: flex;
align-items: center;
gap: 8px;
color: darkgrey;
font-size: 1rem;
}
}
.journal-body {
padding: 10px;
.text-content {
white-space: pre-line;
word-break: break-word;
line-height: 2;
cursor: pointer;
overflow: hidden;
&.collapsed {
display: -webkit-box;
-webkit-line-clamp: v-bind(maxLines);
-webkit-box-orient: vertical;
}
}
.media-content {
width: 100%;
// height: 300px;
}
.toggle-btn {
padding: 0;
color: #999;
font-size: small;
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="ledger-card card-item">
<div class="ledger__header">
<span>{{ props.item.category }}</span>
<span>{{ formatAmount(props.item.totalAmount) }}</span>
</div>
<div class="ledger__body">
<div v-for="(legerItem, index) in props.item.itemList"
:key="index" class="ledger-item" @click="editClick(legerItem)">
<div class="ledger-name">
<span>{{ legerItem.content }}</span>
<span v-if="legerItem.remark">({{ legerItem.remark }})</span>
</div>
<span>{{ legerItem.price }}</span>
<div class="ledger-unit">
<span>{{ legerItem.number }}</span>
<span>({{ legerItem.unit }})</span>
</div>
<div class="ledger-amount">
<div class="item" style="color: #4169E1;">
<i class="fa-solid fa-flag"></i>
<span>{{ legerItem.payer }}</span>
</div>
<div class="item" style="color: #DAA520;">
<i class="fa-solid fa-cny"></i>
<span>{{ formatAmount(legerItem.totalPrice) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, PropType } from 'vue'
import { formatAmount } from 'vue3-common/utils/numberUtil'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { useLedgerStore } from '@/store/ledger'
import { useRouter } from 'vue-router'
import { ILedger, ILegerItem } from '@/types/ledger'
const props = defineProps({
item: {
required: true,
type: Object as PropType<ILedger>
}
})
const ledgerStore = useLedgerStore()
const router = useRouter()
const editClick = async (leger: ILegerItem) => {
ledgerStore.ledgerApiType = 'UPDATE'
ledgerStore.currentLedgerRecord = ledgerStore.ledgerRecordList.find((item) => item.id === leger.id)
if (isMobile()) {
await router.push({ name: 'MobileHomeLedgerDetailId', params: { id: leger.id } })
} else {
ledgerStore.ledgerDialog = {
title: '编辑账本内容',
visible: true,
data: deepCopyObject(ledgerStore.currentLedgerRecord)
}
}
}
</script>
<style lang="scss">
.ledger-card {
padding: 10px;
display: grid;
grid-template-rows: 25px 1fr;
align-items: center;
gap: 15px;
.ledger__header {
padding-bottom: 5px;
border-bottom: 1px solid var(--el-color-primary);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.2rem;
}
.ledger__body {
display: flex;
flex-direction: column;
gap: 20px;
.ledger-item {
display: grid;
grid-template-columns: 130px 60px 60px 1fr;
gap: 5px;
color: #4F4F4F;
.ledger-unit {
display: flex;
align-items: center;
gap: 2px;
}
.ledger-amount {
justify-self: right;
display: flex;
align-items: center;
gap: 10px;
.item {
display: flex;
align-items: center;
gap: 5px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="mobile-common-query card-item">
<div class="query-item">
<span>分类</span>
<el-segmented v-model="ledgerStore.queryInfo.book"
:options="queryCategoryList"
@change="ledgerStore.refreshInfo()"/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useLedgerStore } from '@/store/ledger.ts'
import { ICategory } from '@/types'
const ledgerStore = useLedgerStore()
const queryCategoryList = ref([] as ICategory[])
onMounted(async () => {
await ledgerStore.queryLedgerBook()
initCategoryList()
await ledgerStore.queryLedgerList()
})
const initCategoryList = () => {
queryCategoryList.value = []
if (ledgerStore.ledgerBookList.length > 0) {
ledgerStore.queryInfo.book = ledgerStore.ledgerBookList[0]
}
ledgerStore.ledgerBookList.forEach((item) => {
queryCategoryList.value.push({
label: item,
value: item
})
})
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div class="common-login">
<el-form ref="loginFormRef" :model="authStore.loginForm"
:rules="rules" class="login-form"
autocomplete="on" label-position="left"
@submit.native.prevent>
<!-- 账户名表单选项 -->
<el-form-item prop="username" class="form-item">
<el-input
v-model="authStore.loginForm.username"
placeholder="请输入账号" clearable
>
<template #prefix>
<i class="fa-solid fa-user"></i>
</template>
</el-input>
</el-form-item>
<!-- password表单选项 -->
<el-form-item prop="password" class="form-item">
<el-input
v-model="authStore.loginForm.password"
placeholder="请输入密码" type="password"
show-password clearable
@keyup.enter.native="handleLogin()"
>
<template #prefix>
<i class="fa-solid fa-lock"></i>
</template>
</el-input>
</el-form-item>
<el-form-item>
<login-auth />
</el-form-item>
<el-form-item class="login-option">
<el-checkbox v-model="authStore.loginForm.isRemember">记住密码</el-checkbox>
<el-link type="primary">忘记密码</el-link>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;"
:disabled="!authStore.isVerified"
:loading="authStore.loginLoading"
@click.prevent="handleLogin()">登录
</el-button>
<div class="login-type">
<el-button @click="authStore.changeLoginType('phone')">手机号登录</el-button>
<el-button>扫码登录</el-button>
</div>
<div class="login-others-type">
<el-divider content-position="center">其他登录方式</el-divider>
<div class="type-list">
<i class="fa-brands fa-weixin" style="color: #3CB034"></i>
<i class="fa-brands fa-qq" style="color: #29A4D9"></i>
<i class="fa-solid fa-envelope" style="color: #4971B8"
@click="authStore.changeLoginType('email')"></i>
<i class="fa-brands fa-github" style="color: #555555"></i>
</div>
</div>
<div class="login-register">
<span>还没有账户</span><el-link type="primary">创建账户</el-link>
</div>
</div>
</template>
<script setup lang="ts">
import LoginAuth from '@/components/Home/Login/LoginAuth.vue'
import { useAuthStore } from '@/store/auth'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { ElMessage, FormRules } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { loginRules } from '@/utils/element/elRules'
import { isMobile } from 'vue3-common/utils/layoutUtil'
import { getLocalStorage, setLocalStorage } from 'vue3-common/utils/storageUtil'
const rules = reactive<FormRules>(loginRules)
const authStore = useAuthStore()
const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref()
onMounted(() => {
const storageLoginForm = getLocalStorage('loginForm')
if (storageLoginForm) {
authStore.loginForm = JSON.parse(storageLoginForm)
}
})
const handleLogin = async () => {
loginFormRef.value.validate(async (valid: any) => {
if (valid) {
authStore.loginLoading = true
try {
await authStore.handleLogin('common')
userStore.handleSession(authStore.homeSession)
// 2. 存储账户密码到localStorage中
if (authStore.loginForm.isRemember) {
setLocalStorage('loginForm', JSON.stringify(authStore.loginForm))
}
if (isMobile()) {
await router.push('/mobile-home')
} else {
await router.push('/home')
}
} finally {
authStore.loginLoading = false
}
} else {
ElMessage.error('请输入正确的信息')
return false
}
})
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="email-login">
<el-form ref="loginFormRef" :model="authStore.emailLoginForm"
:rules="rules" class="login-form"
autocomplete="on" label-position="left"
@submit.native.prevent>
<el-form-item prop="email" class="form-item">
<el-input
v-model="authStore.emailLoginForm.email"
placeholder="请输入邮箱地址" clearable
>
<template #prefix>
<i class="fa-solid fa-envelope"></i>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code" class="code-item">
<el-input
v-model="authStore.emailLoginForm.code"
placeholder="请输入验证码" type="password"
show-password clearable
>
<template #prefix>
<i class="fa-solid fa-shield-halved"></i>
</template>
</el-input>
<el-button type="primary"
:disabled="authStore.isGettingCode"
@click="authStore.getAuthCode('email')">
{{ authStore.isGettingCode ? authStore.getCodeCountdown + 's' : '获取验证码' }}
</el-button>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;"
:loading="authStore.loginLoading"
@click.prevent="handleLogin()">登录
</el-button>
<el-button style="width: 100%;margin-left: 0;"
@click="authStore.changeLoginType('common')">取消
</el-button>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/store/auth'
import { useRouter } from 'vue-router'
import { ElMessage, FormRules } from 'element-plus'
import { reactive, ref } from 'vue'
import { loginRules } from '@/utils/element/elRules'
const rules = reactive<FormRules>(loginRules)
const authStore = useAuthStore()
const router = useRouter()
const loginFormRef = ref()
const handleLogin = async () => {
loginFormRef.value.validate(async (valid: any) => {
if (valid) {
authStore.loginLoading = true
try {
await authStore.handleLogin('email')
await router.push('/home')
} finally {
authStore.loginLoading = false
}
} else {
ElMessage.error('请输入正确的信息')
return false
}
})
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<div class="login-auth">
<div class="slider-container"
@mousedown="startDrag" @mousemove="handleDrag"
@mouseup="endDrag" @mouseleave="endDrag"
@touchstart="startDrag" @touchmove="handleDrag" @touchend="endDrag">
<div ref="draggableRef" class="draggable"
:style="{ left: `${loginAuth.dragLeft}px` }">
<i v-if="authStore.isVerified" class="fas fa-check"></i>
<i v-else class="fas fa-arrow-right"></i>
</div>
<div :style="{ width: loginAuth.progressWidth + '%' }" class="slider-progress"></div>
<div class="slider-message" :style="{color: authStore.isVerified ? '#FFFFFF' : '#696969'}">
<span v-if="authStore.isVerified">验证成功</span>
<span v-else-if="loginAuth.isDragging">拖动中...</span>
<span v-else>向右拖动滑块 完成验证</span>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useAuthStore } from '@/store/auth'
const draggableRef = ref(null)
const authStore = useAuthStore()
const loginAuth = reactive({
isDragging: false,
initialX: 0,
offsetX: 0,
dragLeft: 0,
progressWidth: 0,
targetWidth: 100,
// 记录拖动的开始时间
startTime: 0,
// 记录拖动过程中的位置
positions: [],
// 记录拖动过程中的时间戳
timestamps: []
})
const startDrag = (event) => {
// 验证成功后禁止拖动
if (authStore.isVerified) return
loginAuth.isDragging = true
const touch = event.changedTouches ? event.changedTouches[0] : event
loginAuth.initialX = touch.clientX
loginAuth.offsetX = draggableRef.value?.offsetLeft
loginAuth.startTime = Date.now()
loginAuth.positions = []
loginAuth.timestamps = []
loginAuth.positions.push(loginAuth.initialX)
loginAuth.timestamps.push(loginAuth.startTime)
}
const handleDrag = (event) => {
if (loginAuth.isDragging && !authStore.isVerified) {
const touch = event.changedTouches ? event.changedTouches[0] : event
const dx = touch.clientX - loginAuth.initialX
const newLeft = loginAuth.offsetX + dx
const sliderWidth = draggableRef.value?.parentNode.offsetWidth
const handleWidth = draggableRef.value?.offsetWidth
if (newLeft < 0) {
loginAuth.dragLeft = 0
loginAuth.progressWidth = 0
} else if (newLeft > sliderWidth - handleWidth) {
loginAuth.dragLeft = sliderWidth - handleWidth
loginAuth.progressWidth = loginAuth.targetWidth
} else {
loginAuth.dragLeft = newLeft
loginAuth.progressWidth = (newLeft / (sliderWidth - handleWidth)) * loginAuth.targetWidth
}
loginAuth.positions.push(touch.clientX)
loginAuth.timestamps.push(Date.now())
}
}
const endDrag = () => {
loginAuth.isDragging = false
if (loginAuth.progressWidth === loginAuth.targetWidth && !checkIsHuman()) {
authStore.isVerified = true
} else {
loginAuth.dragLeft = 0
loginAuth.progressWidth = 0
}
}
const checkIsHuman = () => {
const endTime = Date.now()
const dragDuration = endTime - loginAuth.startTime
const speedChanges = []
for (let i = 1; i < loginAuth.positions.length; i++) {
const distance = loginAuth.positions[i] - loginAuth.positions[i - 1]
const time = loginAuth.timestamps[i] - loginAuth.timestamps[i - 1]
const speed = time > 0 ? distance / time : 0
speedChanges.push(speed)
}
// 计算速度变化的标准差
const meanSpeed = speedChanges.reduce((sum, speed) => sum + speed, 0) / speedChanges.length
const variance = speedChanges.reduce((sum, speed) => sum + Math.pow(speed - meanSpeed, 2), 0) / speedChanges.length
const stdDevSpeed = Math.sqrt(variance)
// 检查是否有停顿
let hasPause = false
for (let i = 1; i < loginAuth.timestamps.length; i++) {
if (loginAuth.timestamps[i] - loginAuth.timestamps[i - 1] > 200) {
hasPause = true
break
}
}
// 综合判断是否为机器操作
return dragDuration >= 100 && stdDevSpeed >= 0.1 && hasPause
}
</script>
<style lang="scss">
.login-auth {
width: 100%;
.slider-container {
position: relative;
height: 32px;
background-color: #E7E9E8;
border: 1px solid #E7E9E8;
border-radius: 5px;
overflow: hidden;
.draggable {
position: absolute;
top: 0;
left: 0;
width: 35px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--el-color-primary);
color: #FFFFFF;
cursor: pointer;
z-index: 3;
border-left: 1px solid #E7E9E8;
border-radius: 3px;
}
.slider-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--el-color-primary);
z-index: 1;
}
.slider-message {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 2;
user-select: none;
span {
transition: opacity 0.3s ease;
opacity: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div class="phone-login">
<el-form ref="loginFormRef" :model="authStore.phoneLoginForm"
:rules="rules" class="login-form"
autocomplete="on" label-position="left"
@submit.native.prevent>
<el-form-item prop="phoneNumber" class="form-item">
<el-input
v-model="authStore.phoneLoginForm.phoneNumber"
placeholder="请输入手机号" clearable
>
<template #prefix>
<i class="fa-solid fa-mobile-screen"></i>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code" class="code-item">
<el-input
v-model="authStore.phoneLoginForm.code"
placeholder="请输入验证码" type="password"
show-password clearable
>
<template #prefix>
<i class="fa-solid fa-shield-halved"></i>
</template>
</el-input>
<el-button type="primary"
:disabled="authStore.isGettingCode"
@click="authStore.getAuthCode('phone')">
{{ authStore.isGettingCode ? authStore.getCodeCountdown + 's' : '获取验证码' }}
</el-button>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;"
:loading="authStore.loginLoading"
@click.prevent="handleLogin()">登录
</el-button>
<el-button style="width: 100%;margin-left: 0;"
@click="authStore.changeLoginType('common')">取消
</el-button>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/store/auth'
import { useRouter } from 'vue-router'
import { ElMessage, FormRules } from 'element-plus'
import { reactive, ref } from 'vue'
import { loginRules } from '@/utils/element/elRules'
const rules = reactive<FormRules>(loginRules)
const authStore = useAuthStore()
const router = useRouter()
const loginFormRef = ref()
const handleLogin = async () => {
loginFormRef.value.validate(async (valid: any) => {
if (valid) {
authStore.loginLoading = true
try {
await authStore.handleLogin('phone')
await router.push('/home')
} finally {
authStore.loginLoading = false
}
} else {
ElMessage.error('请输入正确的信息')
return false
}
})
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div class="milestone-container">
<h2>里程碑事件</h2>
<div class="timeline">
<ul>
<li>
<div class="bullet pink"></div>
<div class="time">2024-09-02</div>
<div class="desc">
<span class="title">今天订婚啦</span>
<br/>
<span class="sub-title">终于订婚咯终于订婚咯终于订婚咯终于订婚咯</span>
</div>
</li>
<li>
<div class="bullet green"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">终于订婚咯终于订婚咯终于订婚咯</span>
</div>
</li>
<li>
<div class="bullet orange"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">SubTitle</span>
</div>
</li>
<li>
<div class="bullet green"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">SubTitle</span>
</div>
</li>
<li>
<div class="bullet orange"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">SubTitle</span>
</div>
</li>
<li>
<div class="bullet green"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">SubTitle</span>
</div>
</li>
<li>
<div class="bullet orange"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">SubTitle</span>
</div>
</li>
<li>
<div class="bullet green"></div>
<span class="time">2024-09-02</span>
<div class="desc">
<span class="title">Title</span>
<br/>
<span class="sub-title">SubTitle</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.milestone-container {
padding: 1vh 0.5vw;
border: solid 2px sandybrown;
border-radius: 0.5vw;
overflow: auto;
h2 {
margin: 1vh 0;
text-align: center;
}
.timeline {
ul {
list-style: none;
position: relative;
display: flex;
flex-direction: column;
gap: 2vh;
&::before {
content: '';
height: 100%;
width: 0.2vw;
background-color: lightgray;
position: absolute;
left: 2.45vw;
}
li {
margin: 1vh 0;
display: flex;
align-items: center;
gap: 0.5vw;
}
}
.bullet {
width: 0.5vw;
height: 0.5vw;
border-radius: 50%;
background: white;
z-index: 1;
}
.bullet:nth-child(2n) {
border: 4px solid orange;
}
.bullet:nth-child(2n+1) {
border: 4px solid green;
}
.time {
width: 30%;
font-size: 0.8vw;
}
.desc {
width: 60%;
.title {
font-size: 1vw;
font-family: '华为行楷',serif;
}
.sub-title {
color: gray;
}
}
span {
font-size: 0.8vw;
}
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<router-link :to="props.router" class="module-card">
<i :class="props.icon" :style="{color: props.color}"></i>
<div class="content">
<span class="title">{{ props.title }}</span>
<span class="summary">{{ props.subTitle }}</span>
</div>
</router-link>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
icon: {
type: String,
required: true
},
color: {
type: String,
required: true
},
title: {
type: String,
required: true
},
subTitle: {
type: String,
required: true
},
router: {
type: String,
required: true
}
})
</script>
<style lang="scss" scoped>
$module-card-height: 80px;
.module-card {
background-color: var(--color-card-bg);
border: 1px solid var(--el-color-primary);
border-radius: 10px;
padding: 10px;
height: $module-card-height;
display: grid;
grid-template-columns: 45px 1fr;
gap: 10px;
.iconfont {
font-size: 40px;
justify-self: center;
align-self: center;
}
.content {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 5px;
.title {
font-size: 1.2rem;
}
.summary {
font-size: 1rem;
color: darkgrey;
}
}
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<el-form :model="periodStore.currentPeriodDay" class="period-form card-item">
<el-form-item>
<template #label>
<i class="fa-regular fa-calendar-check"></i>
<span>日期</span>
</template>
<el-date-picker v-model="periodStore.currentPeriodDay.date"
type="date" :disabled="true"
style="width: 130px;"/>
</el-form-item>
<el-form-item>
<template #label>
<i class="fa-solid fa-pills"></i>
<span>吃药</span>
</template>
<el-switch
v-model="periodStore.currentPeriodDay.isDrug"
inline-prompt
style="--el-switch-on-color: #DE3163; --el-switch-off-color: #DED2D5"
active-text="" inactive-text=""
/>
</el-form-item>
<el-form-item>
<template #label>
<i class="fa-solid fa-droplet"></i>
<span>月经</span>
</template>
<el-switch
v-model="periodStore.currentPeriodDay.isMenstruate"
inline-prompt
style="--el-switch-on-color: #DE3163; --el-switch-off-color: #DED2D5"
active-text="" inactive-text=""
@change="changeHasPeriodEvent"
/>
</el-form-item>
<el-form-item>
<template #label>
<i class="fa-solid fa-glass-water-droplet"></i>
<span>流量</span>
</template>
<el-select v-model="periodStore.currentPeriodDay.flowLevel"
placeholder="请选择流量" style="width: 130px;"
:disabled="!periodStore.currentPeriodDay.isMenstruate">
<el-option v-for="(item, index) in flowLevelList" :key="index"
:label="item" :value="item"/>
</el-select>
</el-form-item>
<el-form-item label="症状">
<template #label>
<i class="fa-solid fa-disease"></i>
<span>症状</span>
</template>
<el-select v-model="periodStore.currentPeriodDay.crampLevel"
placeholder="请选择症状" style="width: 130px;"
:disabled="!periodStore.currentPeriodDay.isMenstruate">
<el-option v-for="(item, index) in crampLevel" :key="index"
:label="item" :value="item"/>
</el-select>
</el-form-item>
<el-form-item label="爱爱">
<template #label>
<i class="fa-solid fa-shield-heart"></i>
<span>爱爱</span>
</template>
<el-select v-model="periodStore.currentPeriodDay.sexActivity"
placeholder="请选择爱爱" style="width: 130px;"
:disabled="periodStore.currentPeriodDay.isMenstruate">
<el-option v-for="(item, index) in sexActivity" :key="index"
:label="item.label" :value="item.value"/>
</el-select>
</el-form-item>
<el-form-item style="align-self: center;">
<el-button type="primary" @click="onSubmit"
:disabled="isBeforeDate(formatDate(new Date()), periodStore.selectDate)">
确认
</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { usePeriodStore } from '@/store/period.ts'
import { formatDate, isBeforeDate } from 'vue3-common/utils/dateUtil'
const periodStore = usePeriodStore()
const flowLevelList = ['非常少量', '少量', '中量', '大量', '非常大量']
const crampLevel = ['完全不痛', '轻微痛', '比较痛', '非常痛', '通到极致']
const sexActivity = [{
value: '',
label: '无'
},
{
value: '有措施',
label: '有措施'
},
{
value: '无措施',
label: '无措施'
}]
const changeHasPeriodEvent = (value: boolean) => {
if (value) {
periodStore.currentPeriodDay.sexActivity = ''
} else {
periodStore.currentPeriodDay.flowLevel = ''
periodStore.currentPeriodDay.crampLevel = ''
}
}
const onSubmit = async () => {
await periodStore.updatePeriodDay()
periodStore.currentPeriodDay = periodStore.getPeriodDay(periodStore.selectDate)
}
</script>
<style lang="scss">
.period-form {
padding: 10px;
flex-grow: 1;
display: flex;
flex-direction: column;
.el-form-item {
justify-content: space-between;
.el-form-item__label {
display: grid;
grid-template-columns: 20px 1fr;
align-items: center;
gap: 5px;
i {
font-size: 1.2rem;
color: #DE3163;
}
}
.el-form-item__content {
flex: none;
}
}
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="period-legend card-item">
<div class="legend-item">
<div class="circle menstrual"></div>
<span>月经期</span>
</div>
<div class="legend-item">
<div class="circle ovulation"></div>
<span>排卵日</span>
</div>
<div class="legend-item">
<div class="circle with-protect"></div>
<span>爱爱日</span>
</div>
<div class="legend-item">
<div class="small-circle with-drug"></div>
<span>吃药</span>
</div>
<div class="legend-item">
<div class="small-circle safe"></div>
<span>安全期</span>
</div>
<div class="legend-item">
<div class="small-circle fertile"></div>
<span>易孕期</span>
</div>
</div>
</template>
<script lang="ts" setup>
</script>
<style lang="scss">
.period-legend {
padding: 10px;
display: flex;
gap: 5px;
.legend-item {
display: flex;
align-items: center;
gap: 5px;
.circle {
width: 16px;
height: 16px;
border-radius: 50%;
}
.small-circle {
width: 6px;
height: 6px;
border-radius: 50%;
}
}
.ovulation {
background-color: #9333EA;
}
.menstrual {
background-color: #DC2626;
}
.with-protect {
background-color: #16A34A;
}
.with-drug {
background-color: #DC2626;
}
.safe {
background-color: #16A34A;
}
.fertile {
background-color: #9333EA;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div id="planCalendar"/>
</template>
<script lang="ts" setup>
import { onMounted, watch } from 'vue'
import { Calendar, DateSelectArg, EventClickArg } from '@fullcalendar/core'
import { initCommonCalendar, initMobileCalendar } from '@/utils/home/planUtil'
import { usePlanStore } from '@/store/plan.ts'
import { isMobile } from 'vue3-common/utils/layoutUtil'
let calendar: Calendar | undefined
const planStore = usePlanStore()
onMounted(() => {
initCalendar()
})
watch(() => planStore.isRefresh, () => {
getCalendarEvent()
})
/**
* 初始化日程
*/
const initCalendar = async () => {
const calendarEl = document.getElementById('planCalendar') as HTMLElement
if (isMobile()) {
calendar = initMobileCalendar(calendarEl, selectDateEvent, eventClickEvent, updateDateEvent)
} else {
calendar = initCommonCalendar(calendarEl, selectDateEvent, eventClickEvent, updateDateEvent)
}
calendar.render()
}
/**
* 获取排班事件
*/
const getCalendarEvent = () => {
if (calendar) {
calendar.removeAllEvents()
planStore.planList.forEach((item) => {
if (item.date) {
calendar?.addEvent({
start: `${item.date} ${item.startTime}`,
end: `${item.date} ${item.endTime}`,
title: item.title
})
}
})
calendar.refetchEvents()
}
}
/**
* 更新日期事件 重新获取内容
*/
const updateDateEvent = async (dateInfo) => {
}
/**
* 选择日期事件
* @param dateSelect 日期选择
*/
const selectDateEvent = (dateSelect: DateSelectArg) => {
}
/**
* 点击日程事件
* @param eventClick 点击事件
*/
const eventClickEvent = (eventClick: EventClickArg) => {
// const anniversary = formatDate(eventClick.event.start as Anniversary)
// const beginTime = formatDate(eventClick.event.start as Anniversary, 'HH:mm')
// const endTime = formatDate(eventClick.event.end as Anniversary, 'HH:mm')
}
</script>
<style lang="scss">
@import "@/styles/home.variable.module.scss";
$cell-height: 3.8vh;
$time-slot-width: 70px;
$month-cell-height: 120px;
#planCalendar {
height: 100%;
background-color: var(--color-card-bg);
border-radius: 15px;
// box-shadow: 3px 3px 3px #CFCFCF;
padding: 10px;
// 标题区域
.fc-header-toolbar {
// margin-bottom: 4vh;
// 按钮区域
.fc-button-group, .fc-toolbar-chunk {
.fc-button-primary {
background-color: var(--color-record-card-bg);
border: none;
color: var(--color-text);
}
.fc-button-active {
background-color: $theme-color;
color: #FFFFFF;
}
}
}
// 通用内容区域
.fc-view {
border: none;
height: calc(100vh - 260px);
overflow: auto;
// 表格边框线
table, td, th {
// border-top: 0;
// border-left: 0;
// border-right: 0;
// border-bottom: 0;
}
// 主体区域
.fc-scrollgrid {
table-layout: auto;
text-align: center;
// 左侧时间线
col {
width: $time-slot-width !important;
text-align: center;
}
// 表头区域
.fc-col-header {
color: $theme-color;
height: 50px;
font-size: 1.4rem;
background-color: var(--color-record-card-bg);
th {
vertical-align: middle;
}
// 表格边框线
table, td, th {
border: none;
}
// 内容
.fc-col-header-cell-cushion {
// 超出2行省略号
white-space: pre-wrap;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
// 内容区域
.fc-timegrid-body {
// 单元格高度
tr {
height: $cell-height;
}
// 表格行
.fc-timegrid-slots {
// label(时间轴)
.fc-timegrid-slot-label {
font-size: 1.2rem;
.fc-timegrid-slot-label-frame {
text-align: center;
}
}
}
}
// 事件方块内容
.fc-event {
span {
color: #FFFFFF;
// 最多显示几个字符
width: 6em;
overflow: hidden;
// 省略号
text-overflow: ellipsis;
// 不换行
white-space: nowrap;
}
// 时间
.fc-event-time {
color: #FFFFFF;
}
.fc-event-time::after {
content: none;
}
// 标题
.fc-event-title {
color: #FFFFFF;
// 最多显示几个字符
width: 7em;
overflow: hidden;
// 省略号
text-overflow: ellipsis;
// 不换行
white-space: nowrap;
}
// 方块内容
.fc-event-main-frame {
justify-items: center;
align-items: center;
.fc-event-time {
font-size: 1rem;
}
.fc-event-title {
font-size: 1.2rem;
}
}
}
}
}
// 悬浮框
.fc-popover {
.fc-popover-body {
.fc-event {
.fc-event-time {
color: #FFFFFF;
}
.fc-event-title {
color: #FFFFFF;
font-size: 1rem;
}
}
.fc-event:hover {
background-color: #009FA8;
}
}
}
// 月视图
.fc-dayGridMonth-view {
// 主体区域
.fc-scrollgrid {
.fc-daygrid-body {
// 单元格高度
td {
// height: 6vh;
}
// 月视图中的每日事件方块
.fc-daygrid-day-frame {
.fc-daygrid-day-events {
.fc-daygrid-event-harness {
.fc-event {
background-color: #009FA8;
}
.fc-event:hover {
// background-color: #009FA8;
}
}
}
}
}
}
}
}
@media (max-width: 1200px) {
#planCalendar {
.fc-header-toolbar {
.fc-toolbar-title {
font-size: 1.2rem;
}
}
// 通用内容区域
.fc-view {
// 主体区域
.fc-scrollgrid {
// 表头区域
.fc-col-header {
font-size: 1rem;
}
// 内容区域
.fc-timegrid-body {
// 表格行
.fc-timegrid-slots {
// label(时间轴)
.fc-timegrid-slot-label {
font-size: 1rem;
}
}
}
// 事件方块内容
.fc-event {
// 方块内容
.fc-event-main-frame {
.fc-event-time {
font-size: 0.8rem;
}
.fc-event-title {
font-size: 0.8rem;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,218 @@
<template>
<div class="plan-card card-item" :class="{ 'plan-complete': props.plan.completed }">
<div v-if="props.plan?.completed"
class="ribbon" style="background-color: #80c480">
<span>已完成</span>
</div>
<div v-else-if="props.plan?.isTop"
class="ribbon" style="background-color: #D14D49">
<span>置顶</span>
</div>
<div v-else-if="props.plan?.priority === 0"
class="ribbon" style="background-color: #D14D49">
<span></span>
</div>
<div v-else-if="props.plan?.priority === 1"
class="ribbon" style="background-color: #009FA8">
<span></span>
</div>
<div class="plan-check">
<el-checkbox v-model="props.plan.completed"
:true-value="1" :false-value="0"
@change="changeCompleteEvent"/>
</div>
<div class="plan-body" @click="editPlanClick">
<span class="plan-title">{{ props.plan.title }}</span>
<span v-if="props.plan?.content" class="plan-content">
{{ props.plan.content }}
</span>
<div v-if="props.plan?.date" class="plan-time">
<div class="time"
:style="{'color': isBeforeToday(props.plan.date) ? '#FF0000' : '#009FA8'}">
<span v-if="props.plan.date">
{{ props.plan.date }}
</span>
<span v-if="props.plan.startTime">
{{ props.plan.startTime }}
</span>
<span v-if="props.plan.endTime">
~ {{ props.plan.endTime }}
</span>
</div>
<el-icon v-if=" props.plan?.alterTime">
<el-tooltip effect="dark" :content="props.plan.alterTime" placement="bottom">
<AlarmClock/>
</el-tooltip>
</el-icon>
<div v-if=" props.plan?.date" class="remain">
<el-tooltip effect="dark" :content="getRemainDays(props.plan.date)" placement="bottom">
<el-icon><Clock/></el-icon>
</el-tooltip>
</div>
</div>
<div v-if=" props.plan?.tag" class="plan-tag">
<el-tag>{{ props.plan.tag }}</el-tag>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineProps, PropType } from 'vue'
import { AlarmClock, Clock } from '@element-plus/icons-vue'
import { IPlanInfo } from '@/types/plan.ts'
import { getRemainDays, isBeforeToday } from '@/utils/home/planUtil'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { usePlanStore } from '@/store/plan'
import { useRouter } from 'vue-router'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const planStore = usePlanStore()
const router = useRouter()
const props = defineProps({
plan: {
required: true,
type: Object as PropType<IPlanInfo>
}
})
const editPlanClick = async () => {
planStore.planApiType = 'UPDATE'
if (isMobile()) {
planStore.currentPlan = props.plan as IPlanInfo
await router.push({ name: 'MobileHomePlanDetailId', params: { id: planStore.currentPlan.id } })
} else {
planStore.planDialog = {
title: '编辑计划',
visible: true,
data: deepCopyObject(props.plan)
}
}
}
const changeCompleteEvent = (value: boolean) => {
if (value) {
commonElMessageBox('请确认已经完成该计划?').then(() => {
planStore.setPlanStatus(props.plan?.id as number, value)
}).catch(() => {
planStore.refreshInfo()
})
} else {
planStore.setPlanStatus(props.plan?.id as number, value)
}
}
</script>
<style lang="scss">
$plan-card-height: 70px;
.plan-complete {
background-color: #808080;
opacity: 0.5;
}
.plan-card {
position: relative;
min-height: $plan-card-height;
padding: 10px;
display: flex;
overflow: hidden;
.plan-check {
align-self: center;
width: 30px;
.el-checkbox {
.el-checkbox__inner {
height: 20px;
width: 20px;
}
.el-checkbox__inner:after {
height: 10px;
width: 6px;
}
// transform: scale(1.5);
}
}
.plan-body {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1vh;
.plan-title {
font-weight: bold;
font-size: large;
}
.plan-content {
font-size: small;
color: darkgrey;
}
.plan-time {
display: flex;
align-items: center;
gap: 10px;
.time {
display: flex;
gap: 5px;
font-size: small;
}
.remain {
display: flex;
align-items: center;
gap: 5px;
span {
font-size: small;
color: darkgrey;
}
}
}
.plan-tag {
display: flex;
align-items: center;
gap: 10px;
}
}
.ribbon {
overflow: hidden;
white-space: nowrap;
position: absolute;
right: -45px;
top: 10px;
transform: rotate(45deg);
box-shadow: 3px 3px 3px #CFCFCF;
span {
color: #FFFFFF;
display: block;
font-weight: bold;
padding: 5px 50px;
text-align: center;
text-shadow: 0 0 5px #444;
}
}
}
.plan-card:hover {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<el-dialog v-model="dialogData.visible" :title="dialogData.title"
center @open="openDialogEvent"
class="plan-dialog">
<el-form ref="formRef" :model="dialogData.data"
:rules="rules" label-width="80px" class="plan-form">
<el-form-item label="计划标题" prop="title">
<el-input v-model="dialogData.data.title" autocomplete="off"
placeholder="请输入标题" clearable
show-word-limit maxlength="10">
</el-input>
</el-form-item>
<el-form-item label="计划内容">
<el-input v-model="dialogData.data.content" autocomplete="off"
placeholder="请输入标题" :rows="2" type="textarea" clearable
show-word-limit maxlength="20">
</el-input>
</el-form-item>
<div style="display: flex;justify-content: space-between;">
<el-form-item label="开始日期">
<el-date-picker v-model="dialogData.data.date"
type="date" placeholder="请选择日期" :editable="false"
value-format="YYYY-MM-DD" style="width: 150px;"
:disabled-date="disabledDate" @change="changeDateEvent"/>
</el-form-item>
<el-form-item label="开始时间">
<el-time-picker v-model="dialogData.data.startTime"
placeholder="请选择开始时间" :editable="false"
format="HH:mm" value-format="HH:mm" style="width: 150px;"
:disabled="dialogData.data.date === null"
@change="changeStartTimeEvent"/>
</el-form-item>
<el-form-item label="结束时间">
<el-time-picker v-model="dialogData.data.endTime"
placeholder="请选择结束时间" :editable="false"
:disabled="dialogData.data.startTime === null"
format="HH:mm" value-format="HH:mm" style="width: 150px;"/>
</el-form-item>
</div>
<div style="display: flex;gap: 40px;">
<el-form-item label="提醒时间">
<el-date-picker v-model="dialogData.data.alterTime"
type="datetime" placeholder="请选择提醒时间" :editable="false"
format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DD HH:mm:ss"
@change="changeAlterTimeEvent"
:disabled-date="disabledDate" style="width: 200px;"/>
</el-form-item>
<el-form-item label="邮箱地址" prop="email">
<el-input v-model="dialogData.data.email"
placeholder="请输入邮箱地址" clearable
:disabled="dialogData.data.alterTime === null"
style="width: 200px;">
<template #prefix>
<i class="fa-solid fa-envelope"></i>
</template>
</el-input>
</el-form-item>
</div>
<div style="display: flex;justify-content: space-between;">
<el-form-item label="类别">
<el-input v-model="dialogData.data.tag" autocomplete="off"
placeholder="请输入类别" clearable
show-word-limit maxlength="10" style="width: 150px;">
</el-input>
</el-form-item>
<el-form-item label="置顶">
<el-switch v-model="dialogData.data.isTop"
:active-value="1" :inactive-value="0"
@change="changeTopEvent"
/>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="dialogData.data.priority"
placeholder="请选择优先级" style="width: 150px;"
:disabled="!!dialogData.data.isTop">
<el-option v-for="(item, index) in priorityOptionList"
:key="index" :label="item.label" :value="item.value"
/>
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelClick">取消</el-button>
<el-button type="primary" @click="confirmClick">确认</el-button>
<el-button v-if="planStore.planApiType === 'UPDATE'"
type="danger" @click="deleteClick">删除</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ElMessage, FormInstance, FormRules } from 'element-plus'
import { planRules } from '@/utils/element/elRules'
import { computed, nextTick, reactive, ref } from 'vue'
import { isBeforeTime } from '@/utils/home/planUtil'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
import { usePlanStore } from '@/store/plan.ts'
import { useUserStore } from '@/store/user.ts'
const formRef = ref<FormInstance>()
const rules = reactive<FormRules>(planRules)
const planStore = usePlanStore()
const userStore = useUserStore()
const dialogData = computed(() => planStore.planDialog)
const priorityOptionList = [{
value: 0,
label: '高'
},
{
value: 1,
label: '中'
},
{
value: 2,
label: '低'
}]
const disabledDate = (time: Date) => {
return time.getTime() < Date.now() - 8.64e7
}
/**
* 打开对话框事件
*/
const openDialogEvent = async () => {
await nextTick(() => {
formRef.value?.clearValidate()
})
}
const changeDateEvent = (value: string) => {
if (!value) {
dialogData.value.data.startTime = null
dialogData.value.data.endTime = null
}
}
const changeStartTimeEvent = (value: string) => {
if (!value) {
dialogData.value.data.endTime = null
}
}
const changeAlterTimeEvent = (value: string) => {
if (value) {
dialogData.value.data.email = userStore.userInfo.email
}
}
const changeTopEvent = (value: number) => {
if (value) {
dialogData.value.data.priority = 0
}
}
const validatePlan = (): boolean => {
const { startTime, endTime } = dialogData.value.data
if (startTime && endTime) {
if (!isBeforeTime(startTime, endTime)) {
ElMessage.error('请确认结束时间大于开始时间')
return false
}
}
return true
}
/**
* 点击确认按钮
*/
const confirmClick = () => {
formRef.value?.validate().then(async () => {
if (validatePlan()) {
await planStore.handlePlanApi(dialogData.value.data)
}
})
}
const deleteClick = () => {
commonElMessageBox('是否确认删除该计划内容?').then(() => {
planStore.planApiType = 'DELETE'
planStore.handlePlanApi(dialogData.value.data)
})
}
const cancelClick = () => {
planStore.planDialog.visible = false
}
</script>
<style lang="scss">
.plan-dialog {
width:40%;
min-width: 800px;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<div class="plan-list">
<el-empty v-if="planStore.planList.length === 0" />
<el-scrollbar v-else>
<plan-card v-for="(item, index) in planStore.planList"
:key="index" :plan="item">
</plan-card>
</el-scrollbar>
</div>
</template>
<script lang="ts" setup>
import PlanCard from '@/components/Home/Plan/PlanCard.vue'
import { usePlanStore } from '@/store/plan.ts'
const planStore = usePlanStore()
</script>

Some files were not shown because too many files have changed in this diff Show More