初始化项目文件

This commit is contained in:
2025-07-11 16:54:11 +08:00
parent 6bffd582a0
commit 39fedaac16
213 changed files with 16944 additions and 0 deletions

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

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

0
web_vue/README.md Normal file
View File

13
web_vue/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ylsa</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2661
web_vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
web_vue/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "web_vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/node": "^20.14.10",
"@vitejs/plugin-vue": "^5.2.1",
"animate.css": "^4.1.1",
"axios": "1.8.2",
"bootstrap-icons": "^1.11.3",
"echarts": "^5.5.1",
"element-plus": "^2.9.0",
"hls.js": "^1.5.20",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"luxon": "^3.5.0",
"md-editor-v3": "^4.21.1",
"vue": "^3.4.21",
"vue-request": "^2.0.4",
"vue-router": "^4.4.0",
"vue-video-player": "^6.0.0"
},
"devDependencies": {
"@types/luxon": "^3.4.2",
"typescript": "^5.2.2",
"vite": "6.2.3",
"vue-tsc": "^2.0.6"
}
}

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Docker" role="img"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%"
fill="#fff"/><path stroke="#066da5" stroke-width="38" d="M296 226h42m-92 0h42m-91 0h42m-91 0h41m-91 0h42m8-46h41m8 0h42m7 0h42m-42-46h42"/><path fill="#066da5" d="m472 228s-18-17-55-11c-4-29-35-46-35-46s-29 35-8 74c-6 3-16 7-31 7H68c-5 19-5 145 133 145 99 0 173-46 208-130 52 4 63-39 63-39"/></svg>

After

Width:  |  Height:  |  Size: 431 B

BIN
web_vue/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><title>file_type_nginx</title><path d="M15.948,2h.065a10.418,10.418,0,0,1,.972.528Q22.414,5.65,27.843,8.774a.792.792,0,0,1,.414.788c-.008,4.389,0,8.777-.005,13.164a.813.813,0,0,1-.356.507q-5.773,3.324-11.547,6.644a.587.587,0,0,1-.657.037Q9.912,26.6,4.143,23.274a.7.7,0,0,1-.4-.666q0-6.582,0-13.163a.693.693,0,0,1,.387-.67Q9.552,5.657,14.974,2.535c.322-.184.638-.379.974-.535" style="fill:#019639"/><path d="M8.767,10.538q0,5.429,0,10.859a1.509,1.509,0,0,0,.427,1.087,1.647,1.647,0,0,0,2.06.206,1.564,1.564,0,0,0,.685-1.293c0-2.62-.005-5.24,0-7.86q3.583,4.29,7.181,8.568a2.833,2.833,0,0,0,2.6.782,1.561,1.561,0,0,0,1.251-1.371q.008-5.541,0-11.081a1.582,1.582,0,0,0-3.152,0c0,2.662-.016,5.321,0,7.982-2.346-2.766-4.663-5.556-7-8.332A2.817,2.817,0,0,0,10.17,9.033,1.579,1.579,0,0,0,8.767,10.538Z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 879 B

1
web_vue/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

69
web_vue/src/App.vue Normal file
View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import YlLayout from "@/components/yl-layout/layout.vue";
import YlLoginPage from "@/pages/login/index.vue";
import { message, userinfo } from "@/store/store.ts";
import { getCookie } from "@/utils/cookie.ts";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { watch } from "vue";
import _ from "lodash";
import { updateMenu } from "@/utils/menu.ts";
// 初始化websocket
let ws;
if (!ws) {
ws = import.meta.env.PROD
? new WebSocket(`wss://${location.hostname}/ws`)
: new WebSocket(`ws://localhost:8080/ws`);
}
ws.onopen = () => console.log("websocket已连接");
ws.onmessage = (e) => (message.online = Number(e.data));
// 用户自动登录
const { runAsync: autoLogin } = useRequest(() => myRequest(`/api/user/auto`), {
manual: true,
});
// 获取用户信息
const { data: getUserinfo } = useRequest(
() => myRequest(`/api/user`, userinfo.token, {}, "get"),
{ refreshDeps: [() => userinfo.token], ready: () => Boolean(userinfo.token) },
);
// 初始化菜单信息
updateMenu();
// 刷新页面保存登录状态
if (getCookie("is_login") && getCookie("token")) {
// cookie存在登录信息
userinfo.login = true;
userinfo.token = getCookie("token");
} else if (getCookie("deviceId")) {
// cookie不存在登录信息设置了自动登录
userinfo.login = true;
autoLogin()
.then(() => {
userinfo.login = true;
userinfo.token = getCookie("token");
})
.catch(() => {
userinfo.login = false;
userinfo.token = "";
});
} else {
userinfo.login = false;
userinfo.token = "";
}
// 获取用户信息后更新状态
watch(getUserinfo, () => {
userinfo.username = _.get(getUserinfo, "value.data.username", "");
userinfo.userinfo = _.get(getUserinfo, "value.data", {});
userinfo.type = _.get(getUserinfo, "value.data.type", "") || "user";
});
</script>
<template>
<div>
<yl-login-page v-if="!userinfo.login" />
<yl-layout v-if="userinfo.login"><router-view></router-view> </yl-layout>
</div>
</template>
<style scoped></style>

350
web_vue/src/assets/css/normalize.css vendored Normal file
View File

@ -0,0 +1,350 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
text-size-adjust: 100%;
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
font-size: 16px;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
/*text-decoration: underline dotted; !* 2 *!*/
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@ -0,0 +1,44 @@
body {
font-family: '微软雅黑', serif;
font-weight: normal;
}
.flex {
display: flex;
align-items: center;
}
.around {
justify-content: space-around;
}
.between {
justify-content: space-between;
}
.menu {
height: calc(100vh - 100px);
overflow-x: hidden;
overflow-y: auto;
}
.link:hover {
cursor: pointer;
}
.space-inline-5 {
width: 5rem;
display: inline-block;
}
input {
padding: 0 5px !important;
}
.el-main {
padding: 5px !important;
}
.cursor-pointer {
cursor: pointer;
}
.center {
text-align: center;
}
.btn-action-div {
padding: 1rem 2rem;
}
div[aria-hidden='true'] {
display: none !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import * as echarts from "echarts";
const chartData = defineProps<{
xData: Array<string>;
yData: Array<{ name: string; value: Array<number> }>;
title?: string;
per?: boolean;
clickable?: boolean;
smooth?: boolean;
spanLimit?: number;
setIndex?: (e: number) => void;
defaultIndex?: boolean;
}>();
const chart = ref();
let myChart: echarts.EChartsType;
const setOption = () => {
const option = {
title: {
text: chartData.title,
},
tooltip: {
trigger: "axis",
},
grid: {
left: "10%",
right: "5%",
top: "10%",
bottom: "10%",
},
xAxis: {
type: "category",
boundaryGap: false,
data: chartData.xData,
},
yAxis: {
type: "value",
max: chartData.per ? 100 : "dataMax",
axisLabel: {
formatter: chartData.per ? "{value}%" : "{value}",
},
},
series: chartData.yData.map((e) => {
return {
name: e.name,
data: e.value,
smooth: chartData.smooth,
type: "line",
};
}),
dataZoom: {
type: "inside",
maxValueSpan: chartData.spanLimit || chartData.xData.length,
minValueSpan: 5,
startValue:
chartData.spanLimit && chartData.xData.length > chartData.spanLimit
? chartData.xData.length - 1 - chartData.spanLimit
: 0,
},
};
myChart.setOption(option);
};
const init = () => {
// const myChart = echarts.init(chart.value);
window.onresize = () => myChart.resize();
setOption();
if (chartData.clickable) {
myChart.off("click");
myChart.getZr().on("click", function (params) {
const pointInPixel = [params.offsetX, params.offsetY];
if (myChart && myChart.containPixel("grid", pointInPixel)) {
let xIndex = myChart.convertFromPixel({ seriesIndex: 0 }, [
params.offsetX,
params.offsetY,
])[0];
if (chartData.setIndex) {
chartData.setIndex(xIndex);
}
}
});
if (!chartData.defaultIndex) {
chartData.setIndex?.(chartData.xData.length - 1);
}
}
};
onMounted(() => {
myChart = echarts.init(chart.value);
init();
});
watch(
() => chartData.xData,
() => setOption(),
);
</script>
<template>
<div ref="chart" style="width: 100%; height: 100%" v-if="xData.length"></div>
<el-empty v-else description="暂无数据"></el-empty>
</template>
<style scoped></style>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{
className?: string;
}>();
</script>
<template>
<div :class="`btn-action-div ${className}`">
<slot></slot>
</div>
<el-divider style="margin: 0" />
</template>
<style scoped></style>

View File

@ -0,0 +1,110 @@
<template>
<div class="video-container" @keyup.space="togglePlay">
<video class="video-element" ref="videoElement" controls></video>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import Hls from "hls.js";
const props = defineProps({
src: {
type: String,
required: true,
},
autoplay: {
type: Boolean,
default: false,
},
});
const videoElement = ref(null);
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const progress = ref(0);
const volume = ref(1);
const hls = ref(null);
const initPlayer = () => {
if (Hls.isSupported()) {
hls.value = new Hls();
hls.value.loadSource(props.src);
hls.value.attachMedia(videoElement.value);
hls.value.on(Hls.Events.MANIFEST_PARSED, () => {
console.log("HLS 流已解析");
});
hls.value.on(Hls.Events.ERROR, (event, data) => {
console.error("HLS Error:", data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error("网络错误,尝试重新加载");
hls.value.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error("媒体错误,尝试恢复");
hls.value.recoverMediaError();
break;
default:
hls.value.destroy();
break;
}
}
});
} else if (videoElement.value.canPlayType("application/vnd.apple.mpegurl")) {
// Safari 原生支持
videoElement.value.src = props.src;
}
};
const togglePlay = () => {
isPlaying.value ? videoElement.value.pause() : videoElement.value.play();
};
onMounted(() => {
initPlayer();
videoElement.value.addEventListener("loadedmetadata", () => {
duration.value = videoElement.value.duration;
});
videoElement.value.addEventListener("timeupdate", () => {
currentTime.value = videoElement.value.currentTime;
progress.value = (currentTime.value / duration.value) * 100;
});
videoElement.value.addEventListener("play", () => {
isPlaying.value = true;
});
videoElement.value.addEventListener("pause", () => {
isPlaying.value = false;
});
videoElement.value.addEventListener("volumechange", () => {
volume.value = videoElement.value.volume;
});
});
onBeforeUnmount(() => {
if (hls.value) {
hls.value.destroy();
}
});
</script>
<style scoped>
.video-container {
margin: 0 auto;
height: 100%;
}
.video-element {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { iconSelector } from "@/store/store.ts";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { ref, watch } from "vue";
import { Close } from "@element-plus/icons-vue";
const filter = ref("");
const { data: icons, loading } = useRequest(() => myRequest(`/api/sys/icon`), {
ready: () => !Boolean(iconSelector.iconList.length),
});
watch(icons, () => {
iconSelector.iconList = icons.value.data.map((e: { icon: string }) => e.icon);
});
defineEmits<{ (e: "update", value: string): void }>();
</script>
<template>
<el-dialog
v-model="iconSelector.visible"
append-to-body
:show-close="false"
style="max-width: 60vw; max-height: 60vh; overflow-y: auto"
>
<template #header>
<el-input
placeholder="输入名称筛选,双击左键选择图标"
clearable
v-model="filter"
style="
position: sticky;
top: 10px;
background: #fff;
width: calc(100% - 5rem);
"
/>
<el-icon><Close @click="iconSelector.visible = false" /></el-icon>
</template>
<div v-loading="loading">
<el-icon
v-for="item in iconSelector.iconList.filter(
(e: string) => e.indexOf(filter) > -1,
)"
size="32"
style="padding: 1rem"
:class="{ selected: iconSelector.selected === item }"
><el-tooltip :content="item" placement="top"
><i
:class="{ [item]: true }"
@click="
iconSelector.selected !== item
? (iconSelector.selected = item)
: (iconSelector.selected = '')
"
@dblclick="
() => {
iconSelector.selected = item;
$emit('update', iconSelector.selected);
iconSelector.visible = false;
}
" /></el-tooltip
></el-icon>
</div>
</el-dialog>
</template>
<style scoped>
.selected {
background-color: #409eff50;
}
:global(.el-icon:hover) {
cursor: pointer;
}
:global(.el-dialog__header) {
position: sticky;
top: 0;
z-index: 9;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import _ from "lodash";
import { SwitchButton } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import { ref } from "vue";
import YlUserinfo from "@/components/yl-layout/yl-userinfo.vue";
import router from "@/router/router.ts";
import { updateMenu } from "@/utils/menu.ts";
const show = ref(false);
const { data: weather } = useRequest(() =>
myRequest(`/api/weather`, userinfo.token),
);
const { runAsync: logout } = useRequest(
() => myRequest(`/api/user/login`, userinfo.token, {}, "delete"),
{ manual: true },
);
const { runAsync: update } = useRequest(
(param: userinfo) => myRequest(`/api/user`, userinfo.token, param, "put"),
{ manual: true },
);
const toLogout = () => {
logout()
.then((res) => {
if (res.code === 200) {
userinfo.login = false;
userinfo.token = "";
router.push("/");
updateMenu();
}
})
.catch((err) => {
ElMessage.error(formatError(err));
userinfo.login = false;
userinfo.token = "";
});
};
const toUpdateUserinfo = (data: userinfo) => {
update(data)
.then(() => {
userinfo.userinfo = { ...data };
show.value = false;
})
.catch((res) => ElMessage.error(formatError(res)));
};
</script>
<template>
<div class="flex between header" style="height: 100%">
<div class="logo">
<el-image src="/static/image/logo.png" class="rotate" />
</div>
<div class="content">
<el-carousel
height="60px"
autoplay
:interval="6000"
indicator-position="none"
direction="vertical"
:pause-on-hover="false"
>
<el-carousel-item
v-for="(item, key) in weather?.data.split(';')"
:key="key"
>
<span>{{ key ? "明" : "今" }}日天气{{ item }}</span>
</el-carousel-item>
</el-carousel>
</div>
<div class="user flex around">
<el-avatar
class="cursor-pointer"
:src="
_.get(userinfo, 'userinfo.avatar', '') || '/static/image/avatar.png'
"
@click="show = true"
/>
<el-tooltip content="注销登录" placement="bottom" effect="light">
<el-icon size="20" color="red" class="cursor-pointer" @click="toLogout">
<SwitchButton
/></el-icon>
</el-tooltip>
</div>
</div>
<el-dialog title="修改信息" v-model="show" width="40rem">
<YlUserinfo @update="toUpdateUserinfo" :userinfo="userinfo.userinfo" />
</el-dialog>
</template>
<style scoped>
.header {
background: linear-gradient(
90deg,
#cc6aa555,
#108ee955,
#2dcca755,
#cc6aa555,
#108ee955
);
background-size: 400% 400%;
overflow: hidden;
animation: bg-pan-left 8s infinite both;
padding-right: 3rem;
}
@keyframes bg-pan-left {
0% {
background-position: 0 0;
}
100% {
background-position: 100% 100%;
}
}
.logo {
width: 60px;
}
.rotate {
animation: rotate-cw 3s infinite linear both;
}
@keyframes rotate-cw {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.content {
width: calc(100vw - 200px);
text-align: center;
line-height: 60px;
}
.user {
width: 7rem;
}
</style>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import YlMenu from "@/components/yl-layout/menu.vue";
import YlHeader from "@/components/yl-layout/header.vue";
</script>
<template>
<div class="common-layout">
<el-container>
<el-header style="padding: 0">
<yl-header></yl-header>
</el-header>
<el-container>
<el-aside style="background-color: antiquewhite; width: fit-content">
<yl-menu></yl-menu>
</el-aside>
<el-container>
<el-main style="background-color: beige; height: calc(100vh - 90px)"
><el-scrollbar height="calc(100vh - 100px)" class="main-div"
><div style="height: calc(100vh - 100px)"><slot></slot></div
></el-scrollbar>
</el-main>
<el-footer class="flex around footer" height="30px">@ ylsa</el-footer>
</el-container>
</el-container>
</el-container>
</div>
</template>
<style scoped>
.common-layout {
height: 100vh;
}
.footer {
font-size: small;
font-weight: lighter;
}
.main-div {
box-sizing: border-box;
background-color: #fff;
height: 100%;
border-radius: 7px;
overflow: auto;
}
</style>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref } from "vue";
import { menuData } from "@/store/store.ts";
import router from "@/router/router.ts";
const isCollapsed = ref(true);
</script>
<template>
<el-menu
class="yl-layout-menu"
:collapse="isCollapsed"
style="height: 100%"
:default-active="menuData.default || 'home'"
@dblclick="isCollapsed = !isCollapsed"
>
<template v-for="item in menuData.data.filter((e) => !e.route_only)">
<el-sub-menu
v-if="item.detail && item.detail.length > 0"
:index="item.menu_id"
>
<template #title>
<el-icon><i :class="item.icon" /></el-icon>
<span style="min-width: 6rem">{{ item.name }}</span>
</template>
<el-menu-item
v-for="m in item.detail"
:index="m.menu_id"
@click="router.push(m.path)"
>
<el-icon> <i :class="m.icon" /></el-icon>
<span style="min-width: 6rem">{{ m.name }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item
v-else
:index="item.menu_id"
@click="router.push(item.path)"
>
<el-icon> <i :class="item.icon" /></el-icon>
<span style="min-width: 6rem">{{ item.name }}</span>
</el-menu-item>
</template>
</el-menu>
</template>
<style scoped>
.yl-layout-menu {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import { userinfo } from "@/store/store.ts";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { ref, watch } from "vue";
import { UploadProps } from "element-plus";
const props = defineProps<{ userinfo: userinfo; admin?: boolean }>();
const emit = defineEmits<{ update: [value: userinfo] }>();
const locationOption = ref<{ label: string; value: string }[]>([]);
const tmpUserinfo = ref<userinfo>({ ...props.userinfo });
const tmpUserType = ref<string[]>([]);
const { runAsync, loading } = useRequest(
(param: string) => myRequest(`/api/weather/location?location=${param}`),
{ manual: true },
);
const { data: sys } = useRequest(
() => myRequest(`/admin/sys`, userinfo.token),
{ ready: () => props.admin },
);
const getLocation = (query: string) => {
runAsync(query).then((res) => {
locationOption.value = res.data
? Object.keys(res.data).map((key) => ({
label: res.data[key],
value: key,
}))
: [];
});
};
const afterAvatarUpload: UploadProps["onSuccess"] = (res) => {
tmpUserinfo.value.avatar = res.data;
};
const toSave = () => emit("update", tmpUserinfo.value);
watch(props, () => (tmpUserinfo.value = props.userinfo));
watch(sys, () => {
if (sys) {
const d = sys.value.data.filter(
(e: { name: string }) => e.name === "user_type",
);
console.log(d);
if (d.length) {
tmpUserType.value = d[0].data.map((e: { value: string }) => e.value);
}
}
});
</script>
<template>
<el-form label-width="100px" class="userinfo-edit">
<el-form-item label="用户名">
<el-input v-model="tmpUserinfo.username" disabled />
</el-form-item>
<el-form-item label="昵称">
<el-input
v-model="tmpUserinfo.nickname"
placeholder="填写呢称"
clearable
/>
</el-form-item>
<el-form-item label="手机号码">
<el-input
v-model="tmpUserinfo.mobile"
placeholder="填写手机号码"
clearable
/>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="tmpUserinfo.email" placeholder="填写邮箱" clearable />
</el-form-item>
<el-form-item label="用户类型" v-if="props.admin">
<el-select v-model="tmpUserinfo.type" placeholder="选择用户类型">
<el-option
v-for="item in tmpUserType"
:label="item"
:value="item"
:key="item"
/>
</el-select>
</el-form-item>
<el-form-item label="所在地">
<el-select
v-model="tmpUserinfo.location"
clearable
filterable
remote
placeholder="选择所在地"
:loading="loading"
:remote-method="getLocation"
>
<el-option
v-for="item in locationOption.map((e) => ({
value: Object.keys(e)[0],
label: Object.values(e)[0],
}))"
:key="item.value"
:label="item.label"
:value="item.label"
/>
</el-select>
</el-form-item>
<el-form-item label="头像"
><el-upload
:show-file-list="false"
action="/api/file/static/avatar"
:on-success="afterAvatarUpload"
:headers="{ Authorization: `Bearer ${userinfo.token}` }"
>
<el-avatar
:src="tmpUserinfo.avatar"
v-if="tmpUserinfo.avatar" /><el-avatar
src="/src/assets/image/avatar.png"
v-else /></el-upload
></el-form-item>
<el-form-item>
<el-button type="primary" @click="toSave">保存</el-button>
</el-form-item>
</el-form>
</template>
<style scoped>
.userinfo-edit {
padding-right: 5rem;
}
</style>

24
web_vue/src/main.ts Normal file
View File

@ -0,0 +1,24 @@
import { createApp } from "vue";
import "./assets/css/normalize.css";
import "./assets/css/style.css";
import ElementPlus from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import "element-plus/dist/index.css";
import "animate.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import App from "./App.vue";
import router from "@/router/router.ts";
import { config } from "md-editor-v3";
config({
markdownItPlugins(plugins) {
return plugins.map((item) => {
if (item.type === "taskList") {
return { ...item, options: { ...item.options, enabled: true } };
}
return item;
});
},
});
createApp(App).use(ElementPlus, { locale: zhCn }).use(router).mount("#app");

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { Check, Close } from "@element-plus/icons-vue";
import YlLine from "@/components/charts/yl-line.vue";
import { ref } from "vue";
import router from "@/router/router.ts";
import { account } from "@/store/store.ts";
defineProps<{
data?: accountBillData[];
loading: boolean;
detail?: boolean;
}>();
const currentIndex = ref(-1);
</script>
<template>
<div v-loading="loading">
<div class="bill-chart">
<yl-line
v-if="data"
:x-data="data.map((e) => e.date)"
:y-data="[{ name: '', value: data.map((e) => e.balance) }]"
:set-index="(e) => (currentIndex = e)"
:span-limit="12"
clickable
smooth
/>
<el-empty v-else description="暂无数据" />
</div>
</div>
<div class="center">
显示余额:<el-switch v-model="account.show">
<template #active-action><Check /></template>
<template #inactive-action><Close /></template>
</el-switch>
</div>
<div class="center" v-if="data?.[currentIndex]">
<div>
{{ data[currentIndex].duration || data[currentIndex].date }} :
<el-button
link
v-if="detail && data[currentIndex].duration"
@click="router.push(`/bill/${data[currentIndex].duration}`)"
>{{
data[currentIndex].changes === 0
? "无变动"
: data[currentIndex].changes > 0
? `收入 ${account.show ? data[currentIndex].changes : "*****"}`
: `支出 ${account.show ? -data[currentIndex].changes : "*****"}`
}}</el-button
><span v-else-if="data[currentIndex].duration">{{
data[currentIndex].changes === 0
? "无变动"
: data[currentIndex].changes > 0
? `收入 ${account.show ? data[currentIndex].changes : "*****"}`
: `支出 ${account.show ? -data[currentIndex].changes : "*****"}`
}}</span
><span v-else>---------</span>
</div>
<el-table :data="data[currentIndex].detail">
<el-table-column prop="card" label="项目" align="center" />
<el-table-column prop="balance" label="余额" align="center"
><template #default="scope">
{{ account.show ? scope.row.balance : "*****" }}
</template></el-table-column
>
<el-table-column prop="changes" label="收支" align="center">
<template #default="scope">
{{
scope.row.changes === 0
? "-----"
: scope.row.changes > 0
? `收入 ${account.show ? scope.row.changes : "*****"}`
: `支出 ${account.show ? -scope.row.changes : "*****"}`
}}
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.bill-chart {
padding: 1rem 1rem;
height: 20rem;
}
</style>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import BillComponent from "@/pages/account-bill/bill-component.vue";
const { data, loading } = useRequest<{ data?: accountBillData[] }>(
() => myRequest(`/api/yeb_log`, userinfo.token),
{
refreshDeps: [() => userinfo.token],
ready: () => Boolean(userinfo.token),
},
);
</script>
<template>
<BillComponent :loading="loading" :data="data?.data" detail />
</template>
<style scoped></style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { useRoute } from "vue-router";
import BillComponent from "@/pages/account-bill/bill-component.vue";
import router from "@/router/router.ts";
import YlActionDiv from "@/components/yl-action-div.vue";
const route = useRoute();
const { data, loading } = useRequest<{ data?: accountBillData[] }>(
() =>
myRequest(
`/api/yeb_log/detail?start=${(route.params.date as string).split("~")[0]}&end=${(route.params.date as string).split("~")[1]}`,
userinfo.token,
),
{
refreshDeps: [() => userinfo.token],
ready: () => Boolean(userinfo.token),
},
);
</script>
<template>
<yl-action-div>
<el-button link @click="router.push('/bill')">返回月视图</el-button>
</yl-action-div>
<BillComponent :loading="loading" :data="data?.data" />
</template>
<style scoped></style>

View File

@ -0,0 +1,262 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { ref } from "vue";
import _ from "lodash";
import { account, userinfo } from "@/store/store.ts";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import { Check, Close } from "@element-plus/icons-vue";
import { sum } from "lodash-es";
import YlActionDiv from "@/components/yl-action-div.vue";
const editData = ref({ card: "", type: false, balance: 0 });
const dialog = ref(false);
const page = ref(1);
const pageSize = ref(10);
const editYe = ref(false);
// const show = ref(false);
const { data, loading, run } = useRequest<{
data: { balance: number; type: boolean; card: string }[];
}>(() => myRequest(`/api/yeb`, userinfo.token));
const { runAsync: updateCard } = useRequest(
() =>
myRequest(
`/api/yeb`,
userinfo.token,
{ ...editData.value, balance: Number(editData.value.balance) },
"put",
),
{ manual: true },
);
const { runAsync: deleteCard } = useRequest(
(card: string) =>
myRequest(`/api/yeb`, userinfo.token, { card: card }, "delete"),
{ manual: true },
);
const { runAsync: addCard, loading: adding } = useRequest(
() =>
myRequest(
`/api/yeb`,
userinfo.token,
{ ...editData.value, balance: Number(editData.value.balance) },
"post",
),
{ manual: true },
);
const addCardBtn = () => {
editData.value = { card: "", type: false, balance: 0 };
dialog.value = true;
};
const addCardFunc = () => {
if (
!editData.value.card ||
(!editData.value.balance && editData.value.balance !== 0)
) {
ElMessage.error("参数格式错误");
return;
}
addCard()
.then((res) => {
editData.value = { card: "", type: false, balance: 0 };
ElMessage.success(res.data);
run();
dialog.value = false;
})
.catch((err) => ElMessage.error(formatError(err)));
};
const updateCardFunc = () => {
updateCard()
.then((res) => {
ElMessage.success(res.data);
run();
editData.value = { card: "", type: false, balance: 0 };
})
.catch((err) => ElMessage.error(formatError(err)));
};
const deleteCardFunc = (e: string) => {
deleteCard(e)
.then((res) => {
ElMessage.success(res.data);
run();
})
.catch((err) => ElMessage.error(formatError(err)));
};
const updateType = (
t: boolean,
d: { card: string; type: boolean; balance: number },
) => {
editData.value = { ...d, type: t };
updateCard()
.then((res) => {
ElMessage.success(res.data);
run();
editData.value = { card: "", type: false, balance: 0 };
})
.catch((err) => ElMessage.error(formatError(err)));
};
</script>
<template>
<yl-action-div>
<el-button link @click="addCardBtn">新增项目</el-button>
</yl-action-div>
<el-table
stripe
v-loading="loading"
:data="
_.get(data, 'data', []).filter(
(_: {}, index: number) =>
index >= (page - 1) * pageSize && index < page * pageSize,
)
"
>
<el-table-column prop="card" label="项目" align="center" />
<el-table-column prop="balance" label="余额(双击修改)" align="center">
<template #default="scope"
><div
v-if="scope.row.card !== editData.card"
class="balance-div"
:style="`color:${scope.row.type ? 'red' : ''}`"
@dblclick="
() => {
editYe = true;
editData = {
card: scope.row.card,
type: scope.row.type,
balance: scope.row.balance,
};
}
"
>
{{ account.show ? scope.row.balance : "******" }}
</div>
<div v-else-if="editYe" class="flex between">
<el-input
v-model="editData.balance"
type="number"
placeholder="输入当前余额"
:min="0"
:max="99999999"
style="width: calc(100% - 50px)"
>
<template #prefix></template>
</el-input>
<div v-if="editYe">
<el-icon color="green" size="20"
><i class="bi-check2" @click="updateCardFunc"
/></el-icon>
<el-icon
color="red"
size="20"
@click="editData = { card: '', type: false, balance: 0 }"
><i class="bi-x"
/></el-icon>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="支出型" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.type"
inline-prompt
:loading="loading"
active-text=""
inactive-text=""
@change="
(t: boolean) => {
editYe = false;
updateType(t, scope.row);
}
"
/>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-popconfirm
:title="`确认删除 ${scope.row.card} `"
cancel-button-text="取消"
confirm-button-text="确认"
@confirm="() => deleteCardFunc(scope.row.card)"
><template #reference
><el-icon color="red"><Close /></el-icon></template
></el-popconfirm>
</template>
</el-table-column>
</el-table>
<div class="flex between pagination-div">
<div>
<label>显示余额</label
><el-switch v-model="account.show">
<template #active-action><Check /></template>
<template #inactive-action><Close /></template> </el-switch
><label v-if="account.show">
合计{{
sum(data?.data.map((e) => (e.type ? -e.balance : e.balance))).toFixed(
2,
)
}}</label
>
</div>
<div>
<el-pagination
layout="sizes, prev, pager, next"
:total="_.get(data, 'data', []).length"
v-model:current-page="page"
v-model:page-size="pageSize"
:hide-on-single-page="pageSize === 10"
/>
</div>
</div>
<el-dialog title="新增项目" v-model="dialog">
<div class="input-div flex between">
<el-input v-model="editData.card" placeholder="输入项目名称" clearable>
<template #prefix>
<el-switch
v-model="editData.type"
active-text="借贷"
inactive-text="存款"
style="--el-switch-off-color: #606266; --el-switch-on-color: red"
inline-prompt
/>
</template>
</el-input>
</div>
<div class="input-div">
<el-input
v-model="editData.balance"
type="number"
placeholder="输入当前余额"
:min="0"
:max="99999999"
><template #prefix></template></el-input
>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="addCardFunc" :disabled="adding"
>保存</el-button
>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.input-div {
margin: 1rem 2rem;
}
.dialog-footer {
padding-right: 2rem;
}
.balance-div:hover {
cursor: pointer;
}
.pagination-div {
padding: 1rem;
}
</style>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
const szData = ref({ type: true, card: "", amount: 0 });
const { data: cardList } = useRequest<{
data: { balance: number; type: boolean; card: string }[];
}>(() => myRequest(`/api/yeb`, userinfo.token));
const { runAsync: updateCard, loading } = useRequest(
() =>
myRequest(
`/api/yeb/sz`,
userinfo.token,
{ ...szData.value, amount: Number(szData.value.amount) },
"post",
),
{ manual: true },
);
const update = () => {
updateCard()
.then(() => {
szData.value.amount = 0;
ElMessage.success("成功");
})
.catch((err) => ElMessage.error(formatError(err)));
};
watch(cardList, () => {
szData.value.card = cardList.value?.data[0]?.card || "";
});
</script>
<template>
<el-form :model="szData" class="sz">
<el-form-item prop="szType">
<el-radio-group v-model="szData.type">
<el-radio :value="false">收入</el-radio>
<el-radio :value="true">支出</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item prop="card">
<el-select v-model="szData.card">
<el-option
v-for="item in cardList?.data"
:label="item.card"
:value="item.card"
>
<el-text :type="item.type ? 'danger' : ''">{{ item.card }}</el-text>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="amount">
<el-input
v-model="szData.amount"
type="number"
placeholder="输入当前余额"
:min="0"
:max="99999999"
style="width: calc(100% - 50px)"
>
<template #prefix></template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="update" :disabled="loading"
>提交</el-button
>
</el-form-item>
</el-form>
</template>
<style scoped>
.sz {
background-color: #eeea;
padding: 20px;
border-radius: 10px;
}
:global(.el-form-item__content) {
display: block;
}
</style>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import router from "@/router/router.js";
</script>
<template>
<el-result title="404" sub-title="页面无法访问" icon="error">
<template #extra>
<el-button type="primary" @click="() => router.back()">返回</el-button>
</template>
</el-result>
</template>
<style scoped></style>

View File

@ -0,0 +1,231 @@
<script setup lang="ts">
import { ArrowRight, HomeFilled } from "@element-plus/icons-vue";
import { useFileStore } from "@/pages/files/store.ts";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ref, watch } from "vue";
import { ElMessage, UploadFile, UploadUserFile } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import router from "@/router/router.ts";
const fileIcon = [
{ type: "dir", icon: "bi-folder" },
{ type: "zip", icon: "bi-file-zip" },
{ type: "image", icon: "bi-image" },
{ type: "audio", icon: "bi-music-note-beamed" },
{ type: "video", icon: "bi-camera-video" },
{ type: "doc", icon: "bi-file-earmark" },
];
const newFolder = ref({ visible: false, name: "" });
const fileList = ref<UploadUserFile[]>([]);
const imgPreview = ref({ visible: false, src: "" });
const { data, loading, run } = useRequest<{
data: { dirs: string[] | null; files: fileList[] | null };
}>(
() =>
myRequest(
`/api_file/file/upload${useFileStore.path.length > 0 ? "/" + useFileStore.path.join("/") : ""}`,
userinfo.token,
),
{ refreshDeps: [() => useFileStore.path] },
);
const { runAsync: addFolder } = useRequest(
() =>
myRequest(
`/api_file/file/upload/${useFileStore.path
.concat([newFolder.value.name])
.join("/")}`,
userinfo.token,
{},
"post",
),
{ manual: true },
);
const { runAsync: delFile } = useRequest(
(name: string) =>
myRequest(
`/api_file/file/upload/${useFileStore.path.concat([name]).join("/")}`,
userinfo.token,
{},
"delete",
),
{ manual: true },
);
const toPath = (index: number) => {
useFileStore.path = useFileStore.path.filter((_, i) => i <= index);
};
const funcAddFolder = () => {
addFolder()
.then(() => {
newFolder.value = { visible: false, name: "" };
run();
})
.catch((res) => ElMessage.error(formatError(res)));
};
const funcDelFile = (file: string) => {
delFile(file)
.then(() => {
run();
})
.catch((res) => ElMessage.error(formatError(res)));
};
const funcDownloadFile = (file: string) => {
const win = window.open("_black");
const url = `/api_file/file/download/${useFileStore.path.concat([file]).join("/")}`;
if (win) {
win.location.href = url;
}
};
const handleFileUpload = (file: UploadFile) => {
if (file.status === "success") {
fileList.value = fileList.value.filter((e) => e.name !== file.name);
run();
} else if (file.status === "fail") {
ElMessage.error(`${file.name}上传失败`);
}
};
watch(fileList, () => console.log(fileList));
</script>
<template>
<!-- 面包屑路由-->
<el-breadcrumb :separator-icon="ArrowRight" style="padding: 0.5rem">
<el-breadcrumb-item @click="() => (useFileStore.path = [])"
><el-icon size="20"><home-filled /></el-icon
></el-breadcrumb-item>
<el-breadcrumb-item v-for="(item, index) in useFileStore.path"
><el-tooltip :content="item" placement="top" effect="light"
><span class="path-item cursor-pointer" @click="() => toPath(index)">{{
item
}}</span></el-tooltip
></el-breadcrumb-item
>
</el-breadcrumb>
<!-- 分割线,新建文件夹,上传文件-->
<div class="divider">
<div style="background: #fff">
<el-button link @click="newFolder = { visible: true, name: '' }"
><el-icon><i class="bi-folder-plus" /></el-icon
><span> 新建文件夹</span></el-button
><el-upload
:action="`/api_file/file/upload-file/${useFileStore.path.length ? useFileStore.path.join('/') : 'root(根目录上传的文件)'}`"
multiple
:headers="{ Authorization: 'Bearer ' + userinfo.token }"
name="file"
:file-list="fileList"
:on-change="handleFileUpload"
><el-button link
><el-icon><i class="bi-upload" /></el-icon
><span> 上传文件</span></el-button
></el-upload
>
</div>
</div>
<!-- 文件列表-->
<el-table
v-loading="loading"
size="small"
v-if="data"
:data="
data?.data.dirs
? data.data.dirs
.map((e) => ({ name: e, type: 'dir' }))
.concat(data.data.files || [])
: data.data.files
"
:row-class-name="(d: any) => (d.row.type === 'dir' ? 'cursor-pointer' : '')"
@row-dblclick="
(r: fileList) =>
r.type === 'dir'
? (useFileStore.path = useFileStore.path.concat([r.name]))
: 1
"
>
<el-table-column label="名称" prop="name">
<template #default="scope">
<el-icon size="15"
><i
:class="`${fileIcon.filter((e) => e.type === scope.row.type)[0].icon}`"
/></el-icon>
<span>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="大小" prop="size" align="center" />
<el-table-column label="操作" align="center">
<template #default="scope">
<el-icon
v-if="scope.row.type === 'image'"
@click="
() =>
(imgPreview = {
visible: true,
src: `/api_file/file/download/${useFileStore.path
.concat([scope.row.name])
.join('/')}`,
})
"
><i class="bi-eye"
/></el-icon>
<el-icon
v-if="scope.row.type === 'video'"
@click="
router.push(
`/file/video?file=${useFileStore.path.concat([scope.row.name]).join('/')}`,
)
"
><i class="bi-play-btn"
/></el-icon>
<el-icon
color="#108ee9"
@click="() => funcDownloadFile(scope.row.name)"
v-if="scope.row.type !== 'dir'"
><i class="bi-download"
/></el-icon>
<el-popconfirm
:title="`确认删除 ${scope.row.name} `"
confirm-button-text="确认"
cancel-button-text="取消"
@confirm="() => funcDelFile(scope.row.name)"
><template #reference
><el-icon color="red"><i class="bi-trash" /></el-icon></template
></el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 新建文件夹弹窗-->
<el-dialog title="新建文件夹" v-model="newFolder.visible">
<el-input placeholder="输入文件夹名称" v-model="newFolder.name" clearable />
<template #footer>
<el-button type="primary" @click="funcAddFolder">确认</el-button>
</template>
</el-dialog>
<el-image-viewer
:url-list="[imgPreview.src]"
v-if="imgPreview.visible"
@close="imgPreview.visible = false"
/>
</template>
<style scoped>
.path-item {
display: table-cell;
max-width: 10rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 20px;
}
.divider {
text-align: center;
padding: 0 35%;
background: linear-gradient(#fff calc(50% - 1px), #0003, #fff 50%);
}
i {
padding: 0 0.2rem;
font-size: 1rem;
}
</style>

View File

@ -0,0 +1,5 @@
import { reactive } from "vue";
export const useFileStore = reactive<{ path: string[] }>({
path: [],
});

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import router from "@/router/router.js";
import { useRoute } from "vue-router";
import YlHlsPlayer from "@/components/yl-hls-player.vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
const route = useRoute();
const { data: videoCheck, loading } = useRequest(() =>
myRequest(`/api_file/file/download-video-check/${route.query.file}`),
);
</script>
<template>
<el-button link @click="router.push(`/file`)" class="back"
><el-icon><i class="bi-arrow-left-circle" /></el-icon
><span> 返回</span></el-button
>
<div v-loading="loading" class="video">
<YlHlsPlayer
v-if="videoCheck.data.video && videoCheck.data.m3u8"
:src="`/api/file/download-video/${route.query.file}`"
/>
<video controls v-else style="height: 100%; width: 100%">
<source :src="`/api/file/download/${route.query.file}`" />
</video>
</div>
</template>
<style scoped>
.back {
margin: 5px 0;
}
.video {
width: 100%;
height: calc(100vh - 140px);
}
</style>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import _ from "lodash";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import YlActionDiv from "@/components/yl-action-div.vue";
const route = useRoute();
const router = useRouter();
const { data: roomStatus, run } = useRequest(
() => myRequest(`/api/backgammon/${_.get(route, "params.id", "")}`),
{ pollingInterval: 1000, refreshOnWindowFocus: true },
);
const { runAsync: joinRoom } = useRequest(
() =>
myRequest(
`/api/backgammon/${_.get(route, "params.id", "")}`,
userinfo.token,
{},
"put",
),
{ manual: true },
);
const { runAsync: addPawn } = useRequest(
(p: string) =>
myRequest(
`/api/backgammon/${_.get(route, "params.id", "")}?place=${p}`,
userinfo.token,
{},
"post",
),
{ manual: true },
);
const { runAsync: leaveRoom } = useRequest(
() =>
myRequest(
`/api/backgammon/${_.get(route, "params.id", "")}`,
userinfo.token,
{},
"delete",
),
{ manual: true },
);
const add = (x: number, y: number) => {
if (_.get(roomStatus, "value.data.winner", "")) {
ElMessage.error("对局已结束");
return;
}
addPawn(`${x},${y}`)
.then(() => run())
.catch((res) => ElMessage.error(formatError(res)));
};
const toJoin = () => {
joinRoom()
.then()
.catch((res) => ElMessage.error(formatError(res)));
};
const toLeave = () => {
leaveRoom()
.then(() => {})
.catch((res) => ElMessage.error(formatError(res)));
router.back();
};
</script>
<template>
<yl-action-div>
<el-button link @click="toLeave">退出</el-button>
<el-button
v-if="_.get(roomStatus, 'data.player', '').split(',').length < 2"
link
@click="toJoin"
>加入对战
</el-button>
<div v-if="_.get(roomStatus, 'data.winner', '')" class="winner">
winner:{{ _.get(roomStatus, "data.winner", "") }}
</div>
</yl-action-div>
<div class="gameDiv">
<div class="player">
{{ _.get(roomStatus, "data.player", "").split(",")[0] }}
</div>
<div class="canvas">
<div
v-for="(row, col) in _.get(roomStatus, 'data.pawn_status', '').split(
';',
)"
class="flex between"
>
<div
v-for="(item, index) in row.split(',')"
:class="{ black: item === '1', white: item === '-1' }"
style="width: 50px; height: 50px"
@click="() => add(col, index)"
></div>
</div>
</div>
<div class="player">
{{ _.get(roomStatus, "data.player", "").split(",")[1] }}
</div>
</div>
</template>
<style scoped>
.gameDiv {
height: 90%;
display: flex;
align-items: center;
justify-content: space-around;
}
.black {
background: url("/src/assets/image/black.png") no-repeat;
background-size: 100% 100%;
}
.white {
background: url("/src/assets/image/white.png") no-repeat;
background-size: 100% 100%;
}
.canvas {
background: url("/src/assets/image/background.jpg") no-repeat;
background-size: 100% 100%;
font-size: 1px;
width: 750px;
height: 750px;
border: 1px solid #eaeaea;
}
.winner {
text-align: center;
font-weight: bolder;
font-size: 20px;
color: #108ee9;
}
.player {
font-weight: bolder;
font-size: 18px;
color: #ff79ce;
}
</style>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import _ from "lodash";
import YlActionDiv from "@/components/yl-action-div.vue";
const router = useRouter();
const { data: rooms, run } = useRequest(() => myRequest(`/api/backgammon`), {
pollingInterval: 3000,
});
const { runAsync: addRoom } = useRequest(
() => myRequest(`/api/backgammon`, userinfo.token, {}, "post"),
{ manual: true },
);
const toRoom = (id: number) => {
router.push(`/backgammon/${id}`);
};
const onAdd = () => {
addRoom()
.then(() => run())
.catch((res) => ElMessage.error(formatError(res)));
};
</script>
<template>
<yl-action-div>
<el-button link @click="onAdd">新建房间</el-button>
</yl-action-div>
<div class="rooms">
<div
v-for="room in _.get(rooms, 'data', []) as roomStatus[]"
class="roomDiv"
>
<div class="status" @click="() => toRoom(room.room_id as number)">
{{ room.player.split(",")[0] }}<span> vs </span
>{{ room.player.split(",")[1] }}
</div>
</div>
</div>
</template>
<style scoped>
.roomDiv {
display: inline-block;
width: 30%;
margin: 2rem;
height: 8rem;
background: #eaeaea;
}
.roomDiv:hover {
cursor: pointer;
}
.status {
text-align: center;
font-size: 2rem;
padding-top: 2rem;
color: #ff79ce;
}
</style>

View File

@ -0,0 +1,285 @@
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
import { ref, watch } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import _ from "lodash";
import YlActionDiv from "@/components/yl-action-div.vue";
const pawns =
"車馬相仕帥仕相馬車炮炮兵兵兵兵兵車馬象士将士象馬車砲砲卒卒卒卒卒";
const route = useRoute();
const router = useRouter();
const current = ref("");
const { data, run } = useRequest(
() => myRequest(`/api/chess/${route.params.id}`),
{
pollingInterval: 1000,
},
);
const { runAsync: join } = useRequest(
() => myRequest(`/api/chess/${route.params.id}`, userinfo.token, {}, "post"),
{ manual: true },
);
const { runAsync: leave } = useRequest(
() =>
myRequest(`/api/chess/${route.params.id}`, userinfo.token, {}, "delete"),
{ manual: true },
);
const { runAsync: reset } = useRequest(
() =>
myRequest(`/api/chess/${route.params.id}/reset`, userinfo.token, {}, "get"),
{ manual: true },
);
const { runAsync: update } = useRequest(
(to: string) =>
myRequest(
`/api/chess/${route.params.id}?pawn=${current.value}&des=${to}`,
userinfo.token,
{},
"put",
),
{ manual: true },
);
const { runAsync: addAi } = useRequest(
() =>
myRequest(`/api/chess/${route.params.id}/ai`, userinfo.token, {}, "post"),
{ manual: true },
);
const { runAsync: getAiStep } = useRequest(
() =>
myRequest(`/api/chess/${route.params.id}/ai`, userinfo.token, {}, "put"),
{ manual: true },
);
const status = ref<string[]>([]);
const leaveRoom = () => {
if (
_.get(data, "value.data.players", "")
.split(",")
.indexOf(userinfo.username) !== -1
) {
leave()
.then((res) => {
if ((_.get(res, "code", 0) as number) === 200) {
router.back();
}
})
.catch((res) => ElMessage.error(formatError(res)));
} else {
router.back();
}
};
const joinRoom = () => {
join()
.then((res) => {
if ((_.get(res, "code", 0) as number) === 200) {
run();
}
})
.catch((res) => ElMessage.error(formatError(res)));
};
const resetRoom = () => {
reset()
.then((res) => {
if ((_.get(res, "code", 0) as number) === 200) {
run();
}
})
.catch((res) => ElMessage.error(formatError(res)));
};
const updatePawn = (i: number, j: number) => {
(_.get(data, "value.data.players", "").split(",")[0] === userinfo.username
? update(`${9 - i},${j}`)
: update(`${i},${j}`)
)
.then((res) => {
if ((_.get(res, "code", 0) as number) === 200) {
current.value = "";
run();
if (
_.get(data, "value.data.players", "").split(",")[1] ===
"ai--" + route.params.id
) {
getAiStep()
.then((r) => {
if ((_.get(r, "code", 0) as number) === 200) {
run();
}
})
.catch((r) => ElMessage.error(formatError(r)));
}
}
})
.catch((res) => ElMessage.error(formatError(res)));
};
const addChessAi = () => {
addAi()
.then((res) => {
if ((_.get(res, "code", 0) as number) === 200) {
run();
}
})
.catch((res) => ElMessage.error(formatError(res)));
};
watch(data, () => {
status.value =
_.get(data, "value.data.players", "").split(",")[0] === userinfo.username
? _.get(data, "value.data.status", "").split(";").reverse()
: _.get(data, "value.data.status", "").split(";");
});
watch(
() => _.get(data, "value.data.current", ""),
() => (current.value = ""),
);
</script>
<template>
<yl-action-div>
<el-button link @click="leaveRoom">退出房间</el-button>
<el-button
v-if="
_.get(data, 'data.players', '')
.split(',')
.indexOf(userinfo.username) === -1
"
link
@click="joinRoom"
>加入房间
</el-button>
<el-button
v-if="_.get(data, 'data.players', '').split(',')[0] === userinfo.username"
link
@click="resetRoom"
>重置房间
</el-button>
<el-button
v-if="_.get(data, 'data.players', '') === userinfo.username"
link
@click="addChessAi"
>添加机器人
</el-button>
</yl-action-div>
<div v-if="_.get(data, 'data.winner', '')" class="winner">
winner: {{ _.get(data, "data.winner", "") }}
</div>
<div class="flex around" style="padding-top: 20px">
<div
:class="{
current:
_.get(data, 'data.current', '') ===
_.get(data, 'data.players', '').split(',')[0],
}"
class="red player"
>
{{ _.get(data, "data.players", "").split(",")[0] || "" }}
</div>
<div class="chess-div">
<div v-for="(row, i) in status" class="flex around">
<div
v-for="(col, j) in row.split(',')"
class="pawn-div"
@click="
() =>
current !== '-1' && current !== '' && current !== col
? updatePawn(i, j)
: current === col ||
(Number(col) > 15 &&
_.get(data, 'data.players', '').split(',')[0] ===
userinfo.username) ||
(Number(col) < 16 &&
_.get(data, 'data.players', '').split(',')[1] ===
userinfo.username)
? (current = '')
: (current = col)
"
>
<div
:class="{
pawn: col !== '-1',
red: Number(col) < 16,
black: Number(col) > 15,
selected: col === current,
}"
>
{{ col !== "-1" ? pawns[Number(col)] : "" }}
</div>
</div>
</div>
</div>
<div
:class="{
current:
_.get(data, 'data.current', '') ===
_.get(data, 'data.players', '').split(',')[1],
}"
class="black player"
>
{{ _.get(data, "data.players", "").split(",")[1] || "" }}
</div>
</div>
</template>
<style scoped>
.chess-div {
height: 700px;
width: 604px;
border: #2dcca7 1px solid;
background: #cc6aa510 url("/src/assets/image/chess.webp") no-repeat;
background-size: 100% 100%;
padding: 0 0;
}
.pawn-div {
margin: 10px 9px;
width: 50px;
height: 50px;
box-sizing: border-box;
line-height: 45px;
font-family: "楷体", serif;
font-size: 35px;
text-align: center;
z-index: 10;
}
.pawn {
width: 100%;
height: 100%;
border-radius: 60px;
border: #f0c237 double 1px;
box-shadow: -5px 5px 5px #f0c237ee;
background-color: #f6e3a8ee;
cursor: pointer;
}
.player {
font-size: 2rem;
}
.red {
color: #ff0000aa;
}
.black {
color: #000000aa;
}
.selected {
background-color: aquamarine;
}
.current {
border: dotted 1px #b78eff;
}
.winner {
text-align: center;
color: #108ee9;
font-size: 1.5rem;
font-weight: bolder;
}
</style>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import _ from "lodash";
import YlActionDiv from "@/components/yl-action-div.vue";
const router = useRouter();
const { data: rooms, run: refresh } = useRequest(
() => myRequest(`/api/chess`),
{ pollingInterval: 1000 },
);
const { runAsync: newRoom } = useRequest(
() => myRequest(`/api/chess`, userinfo.token, {}, "post"),
{ manual: true },
);
const createRoom = () => {
newRoom().then((res) => {
if (_.get(res, "code", 0) === 200) {
refresh();
router.push(`/chess/${_.get(res, "data", "")}`);
}
});
};
</script>
<template>
<yl-action-div>
<el-button link @click="refresh">刷新</el-button>
<el-button link @click="createRoom">新建房间</el-button>
</yl-action-div>
<div class="rooms">
<div
v-for="item in _.get(rooms, 'data', []) as chess[]"
class="room"
@click="router.push(`/chess/${item.ID}`)"
>
<div>no.{{ item.ID }}</div>
<div class="players flex around">
<span>{{ item.players?.split(",")[0] || "-" }}</span> vs
<span>{{ item.players?.split(",")[1] || "-" }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.room {
width: 20%;
display: inline-block;
margin: 1rem;
border-radius: 1rem;
background-color: #2dcca729;
text-align: center;
height: 5rem;
padding: 0.5rem;
cursor: pointer;
}
.players {
font-size: 1.5rem;
span:first-child {
color: #ff0000aa;
}
span:last-child {
color: #000000aa;
}
}
</style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
// import YlIconSelector from "@/components/yl-icon-selector.vue";
import { message, userinfo } from "@/store/store.ts";
import { onMounted, onUnmounted, ref } from "vue";
import { DateTime, Duration } from "luxon";
import { getCookie } from "@/utils/cookie.ts";
import YlAccountSz from "@/pages/account/yl-account-sz.vue";
const loginTime = ref(
Math.floor(new Date().getTime() - (Number(getCookie("loginTime")) || 0)),
);
const currentTime = ref(new Date().getTime());
let down: NodeJS.Timeout;
onMounted(() => {
down = setInterval(() => {
loginTime.value = Math.floor(
new Date().getTime() - (Number(getCookie("loginTime")) || 0),
);
currentTime.value = new Date().getTime();
}, 1000);
});
onUnmounted(() => clearInterval(down));
</script>
<template>
<h1 class="center">
welcome,{{ userinfo.userinfo.nickname || userinfo.username }}
</h1>
<el-row>
<el-col style="text-align: center" :xs="24" :sm="8">
<el-statistic title="当前在线人数" :value="message.online"></el-statistic>
</el-col>
<el-col :span="8" style="text-align: center" :xs="24" :sm="8">
<el-statistic
title="已登录时长"
:value="loginTime"
:formatter="
(v: number) =>
Duration.fromObject({ millisecond: v }).toFormat('MM-dd hh:mm:ss')
"
></el-statistic>
</el-col>
<el-col :span="8" style="text-align: center" :xs="24" :sm="8">
<el-statistic
title="当前时间"
:value="currentTime"
:formatter="
(v: number) => DateTime.fromMillis(v).toFormat('yyyy-MM-dd HH:mm:ss')
"
></el-statistic>
</el-col>
</el-row>
<el-row justify="space-around">
<el-col
style="text-align: center; max-width: 450px"
class="account"
:xs="24"
:sm="12"
>
<YlAccountSz />
</el-col>
</el-row>
</template>
<style scoped>
.account {
padding: 20px 50px;
}
</style>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import YlLogin from "@/pages/login/login.vue";
import YlRegister from "@/pages/login/register.vue";
import { loginData } from "@/store/store.js";
</script>
<template>
<div class="background">
<div class="login-div">
<yl-login
:class="{
animate__animated: true,
animate__fadeInLeft: loginData.login,
animate__fadeOutLeft: !loginData.login,
}"
></yl-login>
<yl-register
:class="{
animate__animated: true,
animate__fadeInRight: !loginData.login,
animate__fadeOutRight: loginData.login,
}"
></yl-register>
</div>
</div>
</template>
<style scoped>
.background {
height: 100vh;
width: 100vw;
background: url("@/assets/image/background.webp") no-repeat;
background-size: 100% 100%;
}
.login-div {
width: 30rem;
height: 35rem;
background-color: #ffffff19;
border-radius: 1rem;
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
box-shadow: 10px 10px 15px #ffffff30;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { loginData } from "@/store/store.ts";
import { Lock, User } from "@element-plus/icons-vue";
defineProps<{
showReInput?: boolean;
}>();
const clearLoginData = () => {
loginData.username = "";
loginData.password = "";
loginData.repeatPass = "";
};
</script>
<template>
<div class="input-div">
<el-input
v-model="loginData.username"
placeholder="输入用户名"
size="large"
clearable
autofocus
class="input"
@clear="clearLoginData"
><template #prefix>
<el-icon><User /></el-icon></template
></el-input>
<el-input
v-model="loginData.password"
placeholder="输入密码"
size="large"
type="password"
clearable
show-password
class="input"
><template #prefix>
<el-icon><Lock /></el-icon> </template
></el-input>
<el-input
v-if="showReInput"
v-model="loginData.repeatPass"
placeholder="重复输入密码"
size="large"
type="password"
clearable
show-password
class="input"
><template #prefix>
<el-icon><Lock /></el-icon> </template
></el-input>
</div>
</template>
<style scoped>
.input-div {
position: relative;
text-align: center;
}
.input {
margin: 2rem;
height: 3rem;
width: calc(100% - 8rem);
}
.input > :global(.el-input__wrapper) {
background-color: #e8f0fe !important;
border-radius: 2rem !important;
}
</style>

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import { loginData, userinfo } from "@/store/store.js";
import YlLoginData from "@/pages/login/login-data.vue";
import YlResetPwd from "@/pages/login/reset-pwd.vue";
import { CaretRight } from "@element-plus/icons-vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { ref } from "vue";
import { ElMessage } from "element-plus";
import jsEncrypt from "jsencrypt";
import { formatError } from "@/utils/utils.ts";
import { getCookie } from "@/utils/cookie.ts";
const loading = ref(false);
const { runAsync: getPubKey } = useRequest(
() => myRequest(`/api/user/login?username=${loginData.username}`),
{ manual: true },
);
const { runAsync: login } = useRequest(
(enPass: string) =>
myRequest(
`/api/user/login?username=${loginData.username}&password=${encodeURIComponent(enPass)}&auto=${loginData.autoLogin}`,
"",
{},
"post",
),
{
manual: true,
},
);
const toLogin = () => {
if (!loginData.username) {
ElMessage.error("获取公钥失败,请填写用户名");
return;
}
loading.value = true;
getPubKey()
.then((res) => {
if (res.code === 200) {
const encrypt = new jsEncrypt();
encrypt.setPublicKey(res.data as string);
const enPwd = encrypt.encrypt(loginData.password);
login(enPwd as string)
.then((res) => {
loading.value = false;
if (res.code === 200) {
userinfo.login = true;
userinfo.token = res.data as string;
}
setInterval(
() => {
!getCookie("token") && window.location.reload();
},
1000 * 60 * 60,
);
})
.catch((err) => {
loading.value = false;
ElMessage.error(`登录失败:${formatError(err)}`);
});
}
})
.catch((err) => {
loading.value = false;
ElMessage.error(`获取公钥失败:${formatError(err)}`);
});
};
</script>
<template>
<div style="position: absolute; width: 100%; height: 100%">
<div class="title">用户登录</div>
<yl-login-data />
<div class="forget">
<el-checkbox
v-model="loginData.autoLogin"
label="自动登录"
size="large"
></el-checkbox>
<yl-reset-pwd />
</div>
<div class="action-div">
<el-button
link
size="large"
@click="loginData.login = false"
:disabled="loading"
>转到注册<el-icon><CaretRight /></el-icon
></el-button>
<el-button
type="primary"
size="large"
@click="toLogin"
:disabled="loading"
>{{ loading ? "登录中。。。" : "登录" }}</el-button
>
</div>
</div>
</template>
<style scoped>
.title {
position: relative;
width: 100%;
margin: 2rem auto;
text-align: center;
font-size: 2rem;
background: linear-gradient(180deg, #ff69b4, #ff69b450);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.forget {
position: relative;
display: flex;
align-items: center;
justify-content: space-around;
margin: 2rem;
height: 3rem;
line-height: 3rem;
}
.action-div {
position: relative;
display: flex;
align-items: center;
justify-content: space-around;
width: 80%;
margin: 0 auto;
height: calc(100% - 27rem);
}
:global(.el-button.is-link) {
color: #409eff;
}
</style>

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import { loginData, userinfo } from "@/store/store.js";
import YlLoginData from "@/pages/login/login-data.vue";
import { CaretLeft } from "@element-plus/icons-vue";
import { ref } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { ElMessage } from "element-plus";
import jsEncrypt from "jsencrypt";
import { formatError } from "@/utils/utils.ts";
const loading = ref(false);
const { runAsync: getPubKey } = useRequest(
() => myRequest(`/api/user/login?username=${loginData.username}`),
{ manual: true },
);
const { runAsync: runRegister } = useRequest(
(enPass: string) =>
myRequest(
`/api/user?username=${loginData.username}&password=${encodeURIComponent(enPass)}`,
"",
{},
"post",
),
{ manual: true },
);
const toRegister = () => {
if (!loginData.username) {
ElMessage.error("请输入用户名");
return;
}
loading.value = true;
getPubKey()
.then((res) => {
if (res.code === 200) {
const encrypt = new jsEncrypt();
encrypt.setPublicKey(res.data as string);
const enPwd = encrypt.encrypt(loginData.password);
runRegister(enPwd as string)
.then((r) => {
loading.value = false;
if (r.code === 200) {
userinfo.login = true;
userinfo.username = loginData.username;
userinfo.token = r.data;
}
})
.catch((r) => {
loading.value = false;
ElMessage.error(`登录失败:${formatError(r)}`);
});
}
})
.catch((res) => {
loading.value = false;
ElMessage.error(`获取公钥失败:${formatError(res)}`);
});
};
</script>
<template>
<div style="position: absolute; width: 100%; height: 100%">
<div class="title">用户注册</div>
<yl-login-data show-re-input />
<div class="action-div">
<el-button link size="large" @click="loginData.login = true"
><el-icon><CaretLeft /></el-icon>返回登录</el-button
>
<el-button
type="primary"
size="large"
:disabled="
!loginData.username ||
!loginData.password ||
loginData.password !== loginData.repeatPass ||
loading
"
@click="toRegister"
>
{{ loading ? "注册中。。。" : "注册" }}</el-button
>
</div>
</div>
</template>
<style scoped>
.title {
position: relative;
width: 100%;
margin: 2rem auto;
text-align: center;
font-size: 2rem;
background: linear-gradient(180deg, #ff69b4, #ff69b450);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.action-div {
position: relative;
display: flex;
align-items: center;
justify-content: space-around;
width: 80%;
margin: 0 auto;
height: calc(100% - 27rem);
}
</style>

View File

@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { loginData, userinfo } from "@/store/store.ts";
import { ElMessage } from "element-plus";
import { formatError, rsaEncrypt } from "@/utils/utils.ts";
import { Lock } from "@element-plus/icons-vue";
const active = ref(0);
const visible = ref(false);
const email = ref("");
const leftTime = ref(0);
const inputData = ref({ confirmCode: "", password: "", rePass: "" });
const { runAsync: getConfirmCode } = useRequest(
() => myRequest(`/api/user/reset?username=${loginData.username}`),
{ manual: true },
);
const { runAsync: getPubKey } = useRequest(
() => myRequest(`/api/user/login?username=${loginData.username}`),
{ manual: true },
);
const { runAsync: runLogin } = useRequest(
(enPass: string) =>
myRequest(
`/api/user/login?username=${loginData.username}&password=${encodeURIComponent(enPass)}&confirmCode=${inputData.value.confirmCode}`,
"",
{},
"post",
),
{ manual: true },
);
const forgetPwd = () => {
if (!loginData.username) {
ElMessage.error("请输入用户名");
return;
}
getConfirmCode()
.then((res) => {
visible.value = true;
leftTime.value = 30;
email.value = res.data;
})
.catch((err) => {
ElMessage.error(formatError(err));
});
};
const resetPwd = () => {
getPubKey()
.then((res) => {
const enPass = rsaEncrypt(
res.data as string,
inputData.value.password as string,
);
runLogin(enPass as string)
.then((r) => {
visible.value = false;
ElMessage.success("密码重置完成");
userinfo.login = true;
userinfo.token = r.data;
})
.catch((err) => {
ElMessage.error(formatError(err));
});
})
.catch((err) => {
ElMessage.error(formatError(err));
});
};
watch(visible, () => {
active.value = 0;
});
let timer: any;
watch(leftTime, () => {
if (leftTime.value === 30) {
timer = setInterval(() => {
leftTime.value--;
}, 1000);
}
if (leftTime.value === 0) {
clearInterval(timer);
}
});
</script>
<template>
<el-button link @click="forgetPwd">忘记密码 </el-button>
<el-dialog
v-model="visible"
title="密码重置"
append-to-body
style="min-width: 30rem"
>
<el-steps :active="active" align-center>
<el-step title="邮箱验证" />
<el-step title="重置密码" />
</el-steps>
<div class="content" v-if="active === 0">
<div class="email-div flex around">
验证码已发送邮箱{{ email }} 5分钟内有效
</div>
<div class="confirm-div flex around">
<div class="flex" style="width: 15rem">
<label style="width: 5rem">验证码</label>
<el-input
v-model="inputData.confirmCode"
clearable
placeholder="请填写验证码"
/>
</div>
<div>
{{ leftTime > 0 ? `${leftTime}s 后` : ""
}}<el-button link :disabled="leftTime > 0" @click="forgetPwd"
>重新发送</el-button
>
</div>
</div>
</div>
<div class="content" v-if="active === 1">
<div class="input-div">
<el-input
v-model="inputData.password"
placeholder="输入密码"
size="large"
type="password"
clearable
show-password
class="input"
><template #prefix>
<el-icon><Lock /></el-icon> </template
></el-input>
<el-input
v-model="inputData.rePass"
placeholder="重复输入密码"
size="large"
type="password"
clearable
show-password
class="input"
><template #prefix>
<el-icon><Lock /></el-icon> </template
></el-input>
</div>
</div>
<template #footer>
<el-button link @click="active--" v-if="active > 0">上一步</el-button>
<el-button
@click="active++"
v-if="active === 0"
:disabled="!inputData.confirmCode"
>下一步</el-button
>
<el-button
type="primary"
v-if="active > 0"
:disabled="
!inputData.password || inputData.rePass !== inputData.password
"
@click="resetPwd"
>完成</el-button
>
</template>
</el-dialog>
</template>
<style scoped>
.email-div {
height: 5rem;
}
.confirm-div {
height: 5rem;
min-width: 25rem;
max-width: 30rem;
margin: 0 auto;
}
.input-div {
position: relative;
text-align: center;
}
.input {
margin: 2rem;
height: 3rem;
width: calc(100% - 8rem);
}
</style>

View File

@ -0,0 +1,45 @@
import { reactive } from "vue";
export const useNotes: {
id: string;
content: string;
preview: boolean;
toolbars: any[];
} = reactive({
id: "my-editor",
content: "",
preview: true,
toolbars: [
"bold",
"underline",
"italic",
"-",
"title",
"strikeThrough",
"sub",
"sup",
"quote",
"unorderedList",
"orderedList",
"task",
"-",
"codeRow",
"code",
"link",
"image",
"table",
"mermaid",
"katex",
"-",
"revoke",
"next",
"save",
0,
"=",
"pageFullscreen",
"fullscreen",
"preview",
"previewOnly",
"catalog",
],
});

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import axios from "axios";
import { MdCatalog, MdEditor, MdPreview, NormalToolbar } from "md-editor-v3";
import "md-editor-v3/lib/preview.css";
import "md-editor-v3/lib/style.css";
import { useNotes } from "@/pages/notes/store.ts";
import { useRoute } from "vue-router";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { onMounted, watch } from "vue";
import _ from "lodash";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import router from "@/router/router.ts";
import YlActionDiv from "@/components/yl-action-div.vue";
let scrollElement = document.getElementsByClassName("yl-editor-preview")[0];
const route = useRoute();
const {
data: note,
loading,
run,
} = useRequest(() =>
myRequest(`/api/notes/${route.params.id}`, userinfo.token),
);
const { runAsync: save } = useRequest(
() =>
myRequest(
`/api/notes/${route.params.id}`,
userinfo.token,
{ id: Number(route.params.id), content: useNotes.content },
"put",
),
{ manual: true },
);
const toSave = () => {
save()
.then(() => {
useNotes.preview = true;
run();
})
.catch((res) => ElMessage.error(formatError(res)));
};
const onUploadImg = async (files: File[], callback: (r: string[]) => void) => {
const res = (await Promise.all(
files.map((file) => {
return new Promise((rev, rej) => {
const form = new FormData();
form.append("file", file);
axios
.post(`/api/notes/${route.params.id}/file`, form, {
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${userinfo.token}`,
},
})
.then((res) => {
rev(res.data.data);
})
.catch((error) => rej(error));
});
}),
)) as string[];
callback(res);
};
onMounted(() => {
scrollElement = document.getElementsByClassName("yl-editor-preview")[0];
});
watch(note, () => {
useNotes.content = _.get(note.value, "data.content", "");
});
</script>
<template>
<div v-if="useNotes.preview">
<yl-action-div>
<el-button link @click="router.push('/notes')">返回</el-button>
</yl-action-div>
</div>
<div
class="yl-editor-preview"
v-if="useNotes.preview"
v-loading="loading"
@dblclick="() => (useNotes.preview = false)"
>
<MdPreview :editor-id="useNotes.id" :model-value="useNotes.content" />
<MdCatalog
:editor-id="useNotes.id"
:scroll-element="scrollElement as HTMLElement"
/>
</div>
<MdEditor
:editor-id="useNotes.id"
v-else
v-loading="loading"
auto-focus
v-model="useNotes.content"
@onUploadImg="onUploadImg"
@onSave="toSave"
style="height: 100%"
:toolbars="useNotes.toolbars"
><template #defToolbars>
<normal-toolbar title="退出" @on-click="() => (useNotes.preview = true)">
<template #trigger>
<el-icon
class="md-editor-icon"
aria-hidden="true"
style="padding: 4px"
>
<i class="bi-box-arrow-right" />
</el-icon>
</template>
</normal-toolbar> </template
></MdEditor>
</template>
<style scoped>
.yl-editor-preview {
display: flex;
height: calc(100% - 55px);
.md-editor-catalog {
width: 20%;
padding: 1rem;
}
}
</style>

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3";
import { useRouter } from "vue-router";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ElMessage, ElMessageBox } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import _ from "lodash";
import YlActionDiv from "@/components/yl-action-div.vue";
const router = useRouter();
const {
data: noteList,
loading,
run,
} = useRequest(() => myRequest(`/api/notes`, userinfo.token), {
refreshDeps: [() => userinfo.token],
});
const { runAsync: add } = useRequest(
() =>
myRequest(
`/api/notes`,
userinfo.token,
{ content: "# new note \n" },
"post",
),
{ manual: true },
);
const { runAsync: del } = useRequest(
(id: number) => myRequest(`/api/notes/${id}`, userinfo.token, {}, "delete"),
{ manual: true },
);
const toDetail = (id: number) => {
router.push(`/notes/${id}`);
};
const toAdd = () => {
add()
.then((res) => toDetail(_.get(res, "data", -1)))
.catch((res) => ElMessage.error(formatError(res)));
};
const toDelete = (id: number) => {
ElMessageBox.confirm(`确认删除笔记--${id}?`, "确认操作", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
del(id)
.then(() => {
ElMessage.success("删除成功");
run();
})
.catch((res) => ElMessage.error(formatError(res)));
});
};
</script>
<template>
<yl-action-div class-name="center">
<el-button link @click="toAdd"
><el-icon><i class="bi-file-earmark-plus" /></el-icon
><span>新建笔记</span></el-button
>
</yl-action-div>
<div v-loading="loading">
<div class="note-div" v-for="item in noteList?.data">
<el-icon class="del-icon" @click="() => toDelete(item.id)"
><i class="bi-x-circle"
/></el-icon>
<MdPreview
:model-value="item.content"
style="text-align: center; height: 100%"
@click="toDetail(item.id)"
/>
</div>
</div>
</template>
<style scoped>
.note-div {
position: relative;
height: 250px;
background-color: #2dcca730;
padding: 10px 30px 10px 10px;
margin-bottom: 10px;
}
.note-div:hover {
.del-icon {
display: block;
}
}
.del-icon {
display: none;
right: 10px;
position: absolute;
color: #fab6b6;
font-size: 16px;
z-index: 9;
}
</style>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import YlLine from "@/components/charts/yl-line.vue";
defineProps<{
xData: string[];
data: number[];
}>();
</script>
<template>
<div style="height: 200px; margin-bottom: 2rem">
<yl-line
:x-data="xData"
:y-data="[{ name: 'cpu', value: data }]"
per
smooth
title="CPU使用率"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import YlLine from "@/components/charts/yl-line.vue";
defineProps<{
xData: string[];
data: { point: string; per: number[] }[];
}>();
</script>
<template>
<div style="height: 200px; margin-bottom: 2rem">
<yl-line
:x-data="xData"
:y-data="data.map((e) => ({ name: e.point, value: e.per }))"
per
smooth
title="硬盘使用率"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import YlLine from "@/components/charts/yl-line.vue";
defineProps<{
xData: string[];
data: number[];
}>();
</script>
<template>
<div style="height: 200px; margin-bottom: 2rem">
<yl-line
:x-data="xData"
:y-data="[{ name: '内存', value: data }]"
per
smooth
title="内存使用率"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import YlLine from "@/components/charts/yl-line.vue";
import { transNum } from "@/utils/utils.ts";
defineProps<{
xData: string[];
data: { sent: string; rec: string }[];
}>();
</script>
<template>
<div style="height: 200px; margin-bottom: 2rem">
<yl-line
:x-data="xData"
:y-data="[
{ name: '上传', value: data.map((e) => transNum(e.sent, 'KB')) },
{ name: '下载', value: data.map((e) => transNum(e.rec, 'KB')) },
]"
smooth
title="网速(KB/s)"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { DateTime } from "luxon";
import YlCpuChart from "@/pages/sys-info/yl-cpu-chart.vue";
import YlMemChart from "@/pages/sys-info/yl-mem-chart.vue";
import YlDiskChart from "@/pages/sys-info/yl-disk-chart.vue";
import YlNetChart from "@/pages/sys-info/yl-net-chart.vue";
const date = ref<Date>(new Date());
const selectIp = ref("");
const { data: systems, loading: systems_loading } = useRequest<resSystem>(
() =>
myRequest(
`/api/sys_info/server?date=${DateTime.fromJSDate(date.value).toFormat("yyyy-LL-dd")}`,
userinfo.token,
),
{ refreshDeps: [date] },
);
const { data: sys_info, loading: info_loading } = useRequest<resSysInfo>(
() =>
myRequest(
`/api/sys_info?date=${DateTime.fromJSDate(date.value).toFormat("yyyy-LL-dd")}&ip=${selectIp.value}`,
userinfo.token,
),
{
ready: () => Boolean(selectIp.value),
refreshDeps: [date, selectIp],
},
);
</script>
<template>
<div class="flex between" style="height: 100%">
<div class="side-menu">
<div>服务器列表</div>
<div v-loading="systems_loading">
<div
v-for="item in systems?.data"
:class="{ select: item.ip === selectIp }"
@click="selectIp = item.ip"
>
{{ item.hostname }}--{{ item.ip }}
</div>
</div>
</div>
<el-scrollbar class="content" style="height: 100%; overflow-y: auto">
<div class="date-div">
日期<el-date-picker
v-model="date"
type="date"
placeholder="选择日期"
:disabled-date="(d: Date) => d > new Date()"
/>
</div>
<div v-loading="info_loading">
<yl-cpu-chart
v-if="sys_info?.data?.length"
:data="sys_info?.data.map((e) => e.cpu_per)"
:x-data="sys_info?.data.map((e) => e.datetime)"
/>
<yl-mem-chart
v-if="sys_info?.data?.length"
:data="sys_info?.data.map((e) => e.mem.mem_per)"
:x-data="sys_info?.data.map((e) => e.datetime)"
/>
<yl-disk-chart
v-if="sys_info?.data?.length"
:data="
sys_info
? sys_info.data[0].disk.map((e, i) => ({
point: e.point,
per: sys_info?.data.map(
(item) => item.disk[i].per,
) as number[],
}))
: []
"
:x-data="sys_info?.data.map((e) => e.datetime)"
/>
<yl-net-chart
v-if="sys_info?.data?.length"
:data="sys_info?.data.map((e) => e.net)"
:x-data="sys_info?.data.map((e) => e.datetime)"
/>
<el-empty v-else description="暂无数据" />
</div>
</el-scrollbar>
</div>
</template>
<style scoped>
.side-menu {
width: 200px;
height: 100%;
background-color: #2dcca755;
text-align: center;
div {
padding: 5px 0;
}
.select {
background-color: #108ee933;
}
}
.content {
height: 100%;
width: calc(100% - 150px);
text-align: center;
padding: 0 15px;
}
.date-div {
position: sticky;
top: 0;
background-color: #fff;
padding: 15px;
z-index: 9;
}
</style>

View File

@ -0,0 +1,45 @@
import { reactive } from "vue";
export const columns = [
{
title: "时间",
dataIndex: "time",
key: "time",
width: 150,
align: "center",
},
{
title: "方法",
dataIndex: "method",
key: "method",
width: 100,
align: "center",
},
{
title: "路径",
dataIndex: "path",
key: "path",
align: "center",
overflow: true,
},
{
title: "返回码",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
},
{
title: "userAgent",
dataIndex: "user_agent",
key: "user_agent",
width: "450",
align: "center",
overflow: true,
},
];
export const sysLog = reactive({
date: "",
ip: "",
});

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import YlActionDiv from "@/components/yl-action-div.vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ref, watch } from "vue";
const time = ref<[Date | string, Date | string]>(["", ""]);
const container = ref<string>("");
const message = ref<string>("");
const tableData = ref<
{
time: string;
container_name: string;
message: string;
}[]
>([]);
const page = ref(1);
const pageSize = ref(10);
const { data: containerList } = useRequest(() =>
myRequest(`/admin/logs/log-container-list`, userinfo.token),
);
const { data, run, loading } = useRequest<{
data: { time: string; container_name: string; message: string }[];
message: number;
}>(() =>
myRequest(
`/admin/logs/sys?start_time=${time.value[0] || ""}&end_time=${time.value[1] || ""}&container_name=${container.value}&message=${message.value}`,
userinfo.token,
),
);
watch([page, pageSize], () => {
if (data.value) {
tableData.value = data?.value.data.filter(
(_, i) =>
i >= (page.value - 1) * pageSize.value &&
i < page.value * pageSize.value,
);
}
});
watch(data, () => {
if (data.value) {
tableData.value = data?.value.data.filter(
(_, i) =>
i >= (page.value - 1) * pageSize.value &&
i < page.value * pageSize.value,
);
}
page.value = 1;
pageSize.value = 10;
});
watch(time, () => console.log(time.value[0], time.value[1]));
</script>
<template>
<yl-action-div class-name="flex between">
<el-select
style="width: 200px"
v-model="container"
clearable
@clear="container = ''"
>
<el-option
v-for="item in containerList?.data"
:key="item"
:value="item"
:label="item"
></el-option>
</el-select>
<div>
<el-date-picker
v-model="time"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
@clear="() => (time = ['', ''])"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</div>
<el-input
v-model="message"
style="width: 400px"
placeholder="请输入查询关键字"
/>
<el-button type="primary" @click="run">查询</el-button>
</yl-action-div>
<el-scrollbar class="table">
<el-table :data="tableData" v-loading="loading">
<el-table-column prop="time" label="时间" width="250px" align="center" />
<el-table-column
prop="container_name"
label="容器名称"
width="150px"
align="center"
/>
<el-table-column prop="message" label="日志信息" show-overflow-tooltip />
</el-table>
</el-scrollbar>
<div class="flex around" style="margin-top: 20px">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="data?.data.length"
layout="total, sizes, prev, pager, next"
hide-on-single-page
/>
</div>
</template>
<style scoped>
.table {
max-height: calc(100% - 150px);
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { columns, sysLog } from "@/pages/sys-log/store.ts";
import { userinfo } from "@/store/store.ts";
import { ref, watch } from "vue";
import YlLine from "@/components/charts/yl-line.vue";
const { data, loading } = useRequest<{
data: { ip: string; location: string }[];
}>(() => myRequest(`/admin/logs/detail?date=${sysLog.date}`, userinfo.token), {
refreshDeps: () => sysLog.date,
});
const detailLine = ref<
{ name: string; value: number; detail: { ip: string; location: string }[] }[]
>([]);
const ipIndex = ref(-1);
const modal = ref(false);
const page = ref(1);
const setIp = (index: number) => {
ipIndex.value = index;
modal.value = true;
};
watch(data, () => {
if (data.value) {
detailLine.value = Array.from(
new Set(data.value.data.map((e) => e.ip)),
).map((e) => ({
name: e,
value: data.value?.data.filter((item) => item.ip === e).length || 0,
detail: data.value?.data.filter((item) => item.ip === e) || [],
}));
}
});
watch(modal, () => {
page.value = 1;
});
</script>
<template>
<div class="select-date">详情日期{{ sysLog.date }}</div>
<div class="date-view" v-if="detailLine && data" v-loading="loading">
<yl-line
:x-data="detailLine.map((e) => e.name)"
:y-data="[{ name: '', value: detailLine.map((e) => e.value) }]"
:set-index="setIp"
clickable
default-index
smooth
/>
</div>
<el-dialog v-model="modal" style="width: 1300px" append-to-body>
<div class="title">
<label>IP地址{{ detailLine[ipIndex].name }}</label>
<div class="inline-space"></div>
<label>地址:{{ detailLine[ipIndex].detail[0].location }}</label>
</div>
<div class="flex around">
<el-pagination
hide-on-single-page
layout="prev, pager, next"
:total="detailLine[ipIndex].detail.length || 0"
:current-page="page"
@update:current-page="(p: number) => (page = p)"
/>
</div>
<el-table
:data="
detailLine[ipIndex].detail.filter(
(_, i) => i >= (page - 1) * 10 && i < page * 10,
)
"
>
<el-table-column
v-for="item in columns"
:prop="item.dataIndex"
:label="item.title"
:width="item.width"
align="center"
:show-overflow-tooltip="item.overflow"
/>
</el-table>
</el-dialog>
</template>
<style scoped>
.select-date {
text-align: center;
font-weight: bolder;
color: #f0c237;
}
.title {
text-align: center;
padding: 10px;
}
.inline-space {
width: 25px;
display: inline-block;
}
.date-view {
padding: 10px 20px;
height: 300px;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { sysLog } from "@/pages/sys-log/store.ts";
import { userinfo } from "@/store/store.ts";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import YlLine from "@/components/charts/yl-line.vue";
import YlSysLogDetail from "@/pages/sys-log/yl-nginx-log-detail.vue";
const { data, loading } = useRequest<{
data: { name: string; value: number }[];
}>(() => myRequest(`/admin/logs`, userinfo.token));
const chgDate = (index: number) => {
sysLog.date = data?.value ? data.value.data.map((e) => e.name)[index] : "";
};
</script>
<template>
<div class="date-view" v-loading="loading">
<yl-line
v-if="data?.data && data.data.length"
:x-data="data.data.map((e) => e.name)"
:y-data="[{ name: '', value: data.data.map((e) => e.value) }]"
:set-index="chgDate"
clickable
smooth
:span-limit="30"
/>
<el-empty v-else />
<yl-sys-log-detail v-if="sysLog.date" />
</div>
</template>
<style scoped>
.date-view {
padding: 10px 20px;
height: 200px;
}
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from "vue";
import YlNginxLog from "@/pages/sys-log/yl-nginx-log.vue";
import YlDockerLog from "@/pages/sys-log/yl-docker-log.vue";
const logType = ref("docker");
const options = [
{
label: "docker",
value: "docker",
icon: "/docker_icon.svg",
},
{
label: "nginx",
value: "nginx",
icon: "/nginx_icon.svg",
},
];
</script>
<template>
<div class="flex between btn-action-div">
<h3>系统运行日志</h3>
<el-segmented v-model="logType" :options="options">
<template #default="scope">
<div class="segmented">
<el-icon size="20">
<el-image :src="scope.item.icon" />
</el-icon>
<div>{{ scope.item.label }}</div>
</div>
</template>
</el-segmented>
</div>
<div class="con">
<div
:class="{
animate__animated: true,
animate__fadeInLeft: logType === 'docker',
animate__fadeOutLeft: logType !== 'docker',
}"
class="docker"
>
<yl-docker-log />
</div>
<div
:class="{
animate__animated: true,
animate__fadeInRight: logType !== 'docker',
animate__fadeOutRight: logType === 'docker',
}"
class="nginx"
>
<yl-nginx-log />
</div>
</div>
</template>
<style scoped>
h3 {
color: #415aff;
}
.segmented {
padding: 0.5rem;
width: 5rem;
}
.con {
width: 100%;
height: calc(100% - 100px);
background-color: #ffffff19;
border-radius: 0;
top: 100px;
bottom: 0;
left: 0;
right: 0;
position: absolute;
overflow: hidden;
box-sizing: border-box;
}
.docker {
height: 100%;
width: 100%;
position: absolute;
}
.nginx {
position: absolute;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,214 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import { ref, watch } from "vue";
import MenuEdit from "@/pages/sys-menu/menu-edit.vue";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import YlActionDiv from "@/components/yl-action-div.vue";
import { updateMenu } from "@/utils/menu.ts";
// 空菜单信息
const blackMenu: sysMenuList = {
route_only: false,
menu_id: "",
name: "",
path: "",
icon: "bi-list-ul",
white_list: "",
user_type: "admin",
};
const tableData = ref<sysMenuList[]>(); //菜单表格数据
const page = ref(1); // 分页当前页数
const pageSize = ref(10); // 分页当前页面行数
const dialog = ref({
visible: false,
title: "",
}); // 弹窗标题
const dialogData = ref<sysMenuList>(blackMenu); // 弹窗菜单数据
const expandList = ref<string[]>([]);
// 获取菜单列表
const {
data: menus,
loading: loading1,
run,
} = useRequest<{ data: sysMenuList[] }>(
() => myRequest(`/admin/menus`, userinfo.token),
{
refreshDeps: () => userinfo.token,
ready: () => Boolean(userinfo.token),
},
);
// 删除菜单
const { runAsync: deleteMenu } = useRequest(
(id: string) =>
myRequest(`/admin/menus?menu_id=${id}`, userinfo.token, {}, "delete"),
{ manual: true },
);
// 刷新菜单数据
const update = () => {
run();
dialog.value.visible = false;
updateMenu();
};
//删除菜单方法
const deleteMenuItem = (id: string) => {
deleteMenu(id)
.then((res) => {
if (res.code === 200) {
run();
ElMessage.success("删除成功");
}
})
.catch((res) => ElMessage.error(formatError(res)));
};
// 菜单根据id转换为二级菜单
const getId = (data: sysMenuList) => {
for (let i = 1; i <= 99; i++) {
const id = data.menu_id + (i < 10 ? "0" + i : i);
if (data.children?.filter((e) => e.menu_id === id).length === 0) {
return id;
}
}
return "";
};
// 根据菜单信息转换菜单为二级菜单数据
watch(menus, () => {
tableData.value = menus?.value?.data
.filter((item) => item.menu_id.length === 3)
.map((e) => ({
...e,
children: menus?.value?.data.filter(
(i) => i.menu_id.startsWith(e.menu_id) && i.menu_id !== e.menu_id,
),
}));
});
</script>
<template>
<yl-action-div class-name="flex between">
<div>
<el-button @click="() => run()"
><el-icon><i class="bi-arrow-clockwise" /></el-icon
><span> 刷新</span></el-button
>
<el-button
type="primary"
@click="
() => {
dialog.visible = true;
dialog.title = '添加菜单';
dialogData = { ...blackMenu };
}
"
>添加菜单</el-button
>
</div>
<div>
<el-button
v-if="expandList.length === 0"
@click="
tableData ? (expandList = tableData?.map((e) => e.menu_id)) : []
"
>全部展开</el-button
><el-button v-else @click="expandList = []">全部折叠</el-button>
</div>
</yl-action-div>
<el-table
v-loading="loading1"
:data="
tableData?.filter(
(_: object, i: number) =>
i >= (page - 1) * pageSize && i < page * pageSize,
)
"
:expand-row-keys="expandList"
@expand-change="
(row: sysMenuList, ex: boolean) => {
if (ex) {
expandList.push(row.menu_id);
} else {
expandList = expandList.filter((e) => e !== row.menu_id);
}
}
"
row-key="menu_id"
stripe
>
<el-table-column prop="menu_id" label="ID" align="center" />
<el-table-column prop="name" label="名称" align="center" />
<el-table-column prop="icon" label="图标" align="center">
<template #default="scope">
<el-icon size="20"><i :class="scope.row.icon"></i></el-icon>
</template>
</el-table-column>
<el-table-column prop="path" label="地址" align="center" />
<el-table-column prop="route_only" label="仅路由模式" align="center">
<template #default="scope">
<el-switch v-model="scope.row.route_only" disabled
/></template>
</el-table-column>
<el-table-column prop="user_type" label="用户权限" align="center" />
<el-table-column prop="white_list" label="白名单" align="center" />
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
v-if="scope.row.menu_id.length === 3"
@click="
() => {
dialog.title = '添加菜单';
dialog.visible = true;
dialogData = {
...blackMenu,
menu_id: getId(scope.row),
icon: scope.row.icon,
};
}
"
>新增</el-button
>
<el-button
link
@click="
() => {
dialog.title = '修改菜单';
dialog.visible = true;
dialogData = { ...scope.row };
}
"
>编辑</el-button
>
<el-popconfirm
title="确认删除菜单"
@confirm="() => deleteMenuItem(scope.row.menu_id)"
>
<template #reference
><el-button type="danger" size="small">删除</el-button></template
>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div class="flex around pagination">
<el-pagination
layout="sizes, prev, pager, next"
:total="tableData ? tableData.length : 0"
v-model:current-page="page"
v-model:page-size="pageSize"
hide-on-single-page
/>
</div>
<menu-edit
:dialog="dialog"
:data="dialogData"
:run="update"
:first-list="menus?.data.filter((e) => e.menu_id !== '000')"
></menu-edit>
</template>
<style scoped>
.pagination {
padding: 1rem;
}
</style>

View File

@ -0,0 +1,255 @@
<script setup lang="ts">
import YlIconSelector from "@/components/yl-icon-selector.vue";
import { iconSelector, userinfo } from "@/store/store.ts";
import { ref, watch } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { FormInstance } from "element-plus";
// 编辑菜单信息页面
const props = defineProps<{
dialog: {
title: string;
visible: boolean;
};
data: sysMenuList;
run: () => void;
firstList?: sysMenuList[];
}>();
const formRef = ref<FormInstance>();
const level = ref("first");
const first = ref("");
const second = ref("");
const name = ref("");
const route_only = ref(false);
const path = ref("");
const icon = ref("");
const user_type = ref<string[]>([]);
const white_list = ref<string[]>([]);
const filterUserList = ref<typeUserList[]>([]);
const { data: sysSettings } = useRequest<{
data: { name: string; data: sysSettings[] }[];
}>(() => myRequest(`/admin/sys`, userinfo.token), {
ready: () => Boolean(userinfo.token),
});
const { data: userList } = useRequest<{ data: typeUserList[] }>(
() => myRequest(`/admin/user`, userinfo.token),
{ ready: () => Boolean(userinfo.token) },
);
const { runAsync: updateMenu } = useRequest(
() =>
myRequest(
`/admin/menus`,
userinfo.token,
props.data,
props.dialog.title === "添加菜单" ? "post" : "put",
),
{ manual: true },
);
const checkID = (_: any, value: string, callback: (e?: Error) => void) => {
if (
!value ||
isNaN(Number(value)) ||
(value.length !== 3 && level.value === "first") ||
(value.length !== 5 && level.value === "second")
) {
callback(new Error("菜单id格式错误"));
} else if (
props.dialog.title === "新增菜单" &&
props.firstList &&
props.firstList.filter((e) => e.menu_id === value).length > 0
) {
callback(new Error("菜单id已存在"));
} else {
callback();
}
};
const checkPath = (_: any, value: string, callback: (e?: Error) => void) => {
if (level.value !== "first" && !value) {
callback(new Error("二级菜单路径不能为空"));
} else {
callback();
}
};
const rules = ref({
menu_id: [{ validator: checkID, trigger: "blur" }],
user_type: [{ required: true, message: "请选择用户权限", trigger: "blur" }],
path: [
{
validator: checkPath,
trigger: "blur",
},
],
name: [{ required: true, message: "请填写菜单名称", trigger: "blur" }],
});
const onSubmit = () => {
// emits("update", props.data);
props.data.menu_id =
level.value === "first" ? first.value : `${first.value}${second.value}`;
props.data.user_type = user_type.value.join(",");
props.data.white_list = white_list.value.join(",");
props.data.name = name.value;
props.data.path = path.value;
props.data.icon = icon.value;
props.data.route_only = route_only.value;
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (valid) {
updateMenu().then(() => props.run());
}
});
};
const userFilter = (val: string) => {
filterUserList.value = userList.value
? userList.value.data.filter(
(item) =>
item.username.indexOf(val) !== -1 ||
item.nickname.indexOf(val) !== -1,
)
: [];
};
watch(props, () => {
level.value = props.data.menu_id.length === 5 ? "second" : "first";
first.value = props.data.menu_id.substring(0, 3);
second.value = props.data.menu_id.substring(3, 5);
name.value = props.data.name;
path.value = props.data.path;
icon.value = props.data.icon;
route_only.value = props.data.route_only;
user_type.value = props.data.user_type ? props.data.user_type.split(",") : [];
white_list.value = props.data.white_list
? props.data.white_list.split(",")
: [];
});
</script>
<template>
<el-dialog :title="dialog.title" v-model="dialog.visible" destroy-on-close>
<el-form
:model="data"
label-width="100px"
class="menu-edit"
:rules="rules"
ref="formRef"
>
<el-form-item label="菜单级别">
<el-radio-group v-model="level">
<el-radio value="first">一级菜单</el-radio>
<el-radio value="second">二级菜单</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="ID" prop="menu_id">
<div class="flex between" style="width: 100%">
<el-select
:validate-event="false"
v-model="first"
v-if="level !== 'first'"
clearable
placeholder="选择一级菜单"
style="width: 40%"
>
<el-option
v-for="item in firstList?.filter((e) => e.menu_id.length === 3)"
:key="item.menu_id"
:label="item.name"
:value="item.menu_id"
/>
</el-select>
<el-input
:validate-event="false"
v-model="second"
v-if="level !== 'first'"
placeholder="2位二级菜单ID不可与已有ID重复"
style="width: 50%"
/>
<el-input
:validate-event="false"
v-model="first"
v-else
placeholder="3位一级菜单ID不可与已有ID重复"
/>
</div>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
<el-input
v-model="name"
placeholder="填写菜单名称"
:validate-event="false"
/>
</el-form-item>
<el-form-item label="菜单路径" prop="path" :required="level !== 'first'">
<el-input
v-model="path"
placeholder="填写菜单路径"
:validate-event="false"
/>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-tooltip
placement="right"
content="点击选择图标"
:validate-event="false"
>
<el-icon
size="28px"
class="menu-icon"
@click="iconSelector.visible = true"
><i :class="icon"></i></el-icon
></el-tooltip>
<yl-icon-selector @update="(value) => (icon = value)" />
</el-form-item>
<el-form-item label="仅路由模式" prop="route_only">
<el-switch :validate-event="false" v-model="route_only"></el-switch>
</el-form-item>
<el-form-item label="用户权限" prop="user_type">
<el-select
v-model="user_type"
placeholder="选择用户权限"
multiple
:validate-event="false"
>
<el-option
v-for="item in sysSettings?.data.filter(
(e) => e.name === 'user_type',
)[0].data"
:key="item.value"
:label="item.cn_name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="白名单" prop="white_list">
<el-select
v-model="white_list"
placeholder="选择用户"
multiple
filterable
:filter-method="userFilter"
:validate-event="false"
>
<el-option
v-for="item in filterUserList"
:key="item.username"
:label="item.nickname || item.username"
:value="item.username"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">确认</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<style scoped>
.menu-edit {
padding-right: 2rem;
}
.menu-icon {
padding-left: 1rem;
}
</style>

View File

@ -0,0 +1,127 @@
<script setup lang="ts">
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import YlActionDiv from "@/components/yl-action-div.vue";
import { ref } from "vue";
import _ from "lodash";
import { formatError } from "@/utils/utils.ts";
import { ElMessage } from "element-plus";
// 空系统设置选项
let _blank: sysSettings = {
name: "",
value: "",
cn_name: "",
d_type: "string",
};
// 新增或修改系统设置弹窗信息
const modal = ref<{ visible: boolean; title: string; data: sysSettings }>({
visible: false,
title: "",
data: _.cloneDeep(_blank),
});
//获取系统设置列表
const { data, run, loading } = useRequest(() =>
myRequest(`/admin/sys`, userinfo.token),
);
//更新系统设置信息
const { runAsync: updateSys } = useRequest(
() =>
myRequest(
`/admin/sys`,
userinfo.token,
modal.value.data,
modal.value.title === "新增配置" ? "post" : "put",
),
{ manual: true },
);
// 弹窗保存方法
const onSave = () => {
updateSys()
.then(() => {
modal.value.visible = false;
run();
})
.catch((err) => ElMessage.error(formatError(err)));
};
</script>
<template>
<yl-action-div>
<el-button
type="primary"
@click="
modal = { visible: true, title: '新增配置', data: _.cloneDeep(_blank) }
"
>新增配置</el-button
>
</yl-action-div>
<el-tabs tab-position="left" v-if="Boolean(data)">
<el-tab-pane v-for="item in data.data" :label="item.name">
<el-table :data="item.data" v-loading="loading">
<el-table-column label="字段名" prop="name" align="center" />
<el-table-column label="字段值" prop="value" align="center">
<template #default="scope">
<el-tag
type="info"
v-for="item in scope.row.value ? scope.row.value.split(',') : []"
v-if="scope.row.d_type === 'option'"
>{{ item }}</el-tag
>
<span v-else>{{ scope.row.value }}</span>
</template>
</el-table-column>
<el-table-column label="中文名" prop="cn_name" align="center" />
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
@click="
modal = {
visible: true,
title: '编辑配置',
data: _.cloneDeep(scope.row),
}
"
>编辑</el-button
>
<el-button type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="modal.visible" destroy-on-close :title="modal.title">
<el-form label-width="80">
<el-form-item label="字段类型">
<el-radio-group v-model="modal.data.d_type">
<el-radio value="string">字符串</el-radio>
<el-radio value="option">选项</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="字段名" required>
<el-input v-model="modal.data.name" />
</el-form-item>
<el-form-item label="字段值" required>
<el-input
v-model="modal.data.value"
v-if="modal.data.d_type == 'string'"
/>
<el-input-tag
:model-value="modal.data.value ? modal.data.value.split(',') : []"
@change="(v: string[]) => (modal.data.value = v.join(','))"
v-else-if="modal.data.d_type == 'option'"
/>
<el-input v-model="modal.data.value" disabled v-else />
</el-form-item>
<el-form-item label="中文名">
<el-input v-model="modal.data.cn_name" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" @click="onSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped></style>

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import YlActionDiv from "@/components/yl-action-div.vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { userinfo } from "@/store/store.ts";
import YlUserinfo from "@/components/yl-layout/yl-userinfo.vue";
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { formatError } from "@/utils/utils.ts";
import _ from "lodash";
const show = ref(false); // 修改用户信息弹窗状态
const tmpUserinfo = ref<userinfo>({}); // 临时保存用户信息
// 获取用户列表
const {
data: userList,
run: updateUserList,
loading,
} = useRequest(() => myRequest(`/admin/user`, userinfo.token));
//更新用户信息
const { runAsync: updateUserinfo } = useRequest(
(data: userinfo) => myRequest(`/admin/user`, userinfo.token, data, "put"),
{ manual: true },
);
//删除用户
const { runAsync: deleteUserinfo } = useRequest(
(data: string) =>
myRequest(`/admin/user?username=${data}`, userinfo.token, {}, "delete"),
{ manual: true },
);
//更新用户信息方法
const toUpdateUserinfo = (data: userinfo) => {
updateUserinfo(data)
.then(() => {
show.value = false;
ElMessage.success("更新成功");
updateUserList();
})
.catch((err) => ElMessage.error(formatError(err)));
};
//删除用户信息方法
const toDeleteUserinfo = (data: string) => {
deleteUserinfo(data)
.then(() => {
ElMessage.success("删除成功");
updateUserList();
})
.catch((err) => ElMessage.error(formatError(err)));
};
</script>
<template>
<yl-action-div class-name="flex between">
<el-button @click="updateUserList">刷新</el-button>
</yl-action-div>
<el-table :data="userList?.data" v-loading="loading">
<el-table-column prop="avatar" label="头像" width="100" align="center">
<template #default="scope">
<el-avatar
:size="50"
:src="scope.row.avatar || '/static/image/avatar.png'"
/>
</template>
</el-table-column>
<el-table-column
prop="username"
label="用户名"
min-width="200"
align="center"
/>
<el-table-column
prop="nickname"
label="昵称"
width="200"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="mobile"
label="手机号"
min-width="200"
align="center"
/>
<el-table-column prop="email" label="邮箱" min-width="200" align="center" />
<el-table-column prop="location" label="地址" width="200" align="center" />
<el-table-column prop="type" label="用户类型" width="200" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="scope">
<el-button
link
@click="
() => {
show = true;
tmpUserinfo = _.cloneDeep(scope.row);
}
"
>编辑</el-button
>
<el-popconfirm
:title="`确认删除用户 ${scope.row.username} `"
@confirm="() => toDeleteUserinfo(scope.row.username)"
>
<template #reference
><el-button type="danger" size="small">删除</el-button></template
>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-dialog title="修改信息" v-model="show" width="40rem">
<YlUserinfo @update="toUpdateUserinfo" :userinfo="tmpUserinfo" admin />
</el-dialog>
</template>
<style scoped></style>

View File

@ -0,0 +1,53 @@
import axios from "axios";
import { userinfo } from "@/store/store.ts";
import { getCookie } from "@/utils/cookie.ts";
// 自定义请求方法
export const myRequest = async (
api: string,
token: string = "",
data: object = {},
method: "get" | "post" | "put" | "delete" = "get",
) => {
// 未获取到token返回登录
if (!getCookie("token")) {
userinfo.login = false;
userinfo.token = "";
}
// 调用请求
if (method === "post") {
const res = await axios.post(api, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
return res.data;
} else if (method === "get") {
const res = await axios.get(api, {
params: data,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
return res.data;
} else if (method === "put") {
const res = await axios.put(api, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
return res.data;
} else if (method === "delete") {
const res = await axios.delete(api, {
data: data,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
return res.data;
} else return Promise.reject("暂不支持的请求");
};

View File

@ -0,0 +1,50 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/pages/home/index.vue";
import YlSysMenu from "@/pages/sys-menu/index.vue";
import YlAccount from "@/pages/account/index.vue";
import YlAccountBill from "@/pages/account-bill/index.vue";
import YlAccountBillDetail from "@/pages/account-bill/month.vue";
import YlFile from "@/pages/files/index.vue";
import ErrorPage from "@/pages/error/index.vue";
import YlVideo from "@/pages/files/yl-video.vue";
import YlSysInfo from "@/pages/sys-info/yl-sys-info.vue";
import YlSysLog from "@/pages/sys-log/yl-sys-log.vue";
import YlChess from "@/pages/games/yl-chess.vue";
import YlChessRoom from "@/pages/games/yl-chess-room.vue";
import YlBackgammon from "@/pages/games/yl-backgammon.vue";
import YlBackgammonRoom from "@/pages/games/yl-backgammon-room.vue";
import YlNote from "@/pages/notes/yl-note.vue";
import YlNoteDetail from "@/pages/notes/yl-note-detail.vue";
import YlSysSettings from "@/pages/sys-settings/yl-sys-settings.vue";
import YlSysUser from "@/pages/sys-user/yl-sys-user.vue";
export const routes = [
{ path: "/", component: Home },
{ path: "/:catchAll(.*)", component: ErrorPage },
// { path: "/admin/user", component: Home },
{ path: "/admin/menus", component: YlSysMenu },
{ path: "/account", component: YlAccount },
{ path: "/bill", component: YlAccountBill },
{ path: "/bill/:date", component: YlAccountBillDetail },
{ path: "/file", component: YlFile },
{ path: "/file/video", component: YlVideo },
{ path: "/sys-info", component: YlSysInfo },
{ path: "/sys-log", component: YlSysLog },
{ path: "/chess", component: YlChess },
{ path: "/chess/:id", component: YlChessRoom },
{ path: "/backgammon", component: YlBackgammon },
{ path: "/backgammon/:id", component: YlBackgammonRoom },
{ path: "/notes", component: YlNote },
{ path: "/notes/:id", component: YlNoteDetail },
{ path: "/sys-settings", component: YlSysSettings },
{ path: "/sys-user", component: YlSysUser },
];
const router = createRouter({
history: createWebHashHistory(),
routes: routes.filter((route) =>
["/", "/:catchAll(.*)"].includes(route.path),
),
});
export default router;

View File

@ -0,0 +1,43 @@
import { reactive } from "vue";
//登录页面
export const loginData = reactive({
login: true,
username: "",
password: "",
repeatPass: "",
autoLogin: false,
});
//用户登录信息
export const userinfo: {
login: boolean;
username: string;
token: string;
type: string;
userinfo: userinfo;
} = reactive({
login: false,
username: "",
token: "",
type: "user",
userinfo: {},
});
// websocket
export const message = reactive({
online: 0,
});
// 菜单
export const menuData: {
data: sysMenu[];
default: string;
} = reactive({
data: [],
default: "",
});
// 图标选择
export const iconSelector = reactive({
visible: false,
iconList: [],
selected: "",
});
//余额表及账单
export const account = reactive({ show: false });

96
web_vue/src/type.d.ts vendored Normal file
View File

@ -0,0 +1,96 @@
// store.ts-------------------------------------------------
interface sysMenu {
name: string;
icon: string;
path: string;
menu_id: string;
route_only: boolean;
detail?: sysMenu[];
}
interface userinfo {
avatar?: string;
email?: string;
location?: string;
mobile?: string;
nickname?: string;
type?: string;
username?: string;
}
// sys-menu-------------------------------------------------
interface sysMenuList {
id?: number;
menu_id: string;
name: string;
path: string;
icon: string;
route_only: boolean;
user_type: string;
white_list: string;
children?: sysMenuList[];
}
interface sysSettings {
name: string;
cn_name: string;
value: string;
d_type: "string" | "option";
}
interface typeUserList {
username: string;
nickname: string;
avatar: string;
mobile: string;
email: string;
type: string;
}
interface accountBillData {
date: string;
balance: number;
changes: number;
duration: string;
detail: accountBillDetailData[];
}
interface accountBillDetailData {
card: string;
balance: number;
changes: number;
}
// files-------------------------------------------------
interface fileList {
name: string;
type: string;
size?: number;
}
// sys-info-----------------------------------------------
interface resSystem {
data: { hostname: string; ip: string }[];
}
interface resSysInfo {
data: {
datetime: string;
cpu_per: number;
disk: { point: string; total: string; used: string; per: number }[];
mem: { mem_total: string; mem_used: string; mem_per: number };
net: { sent: string; rec: string };
}[];
}
// yl-chess-----------------------------------------------------
interface chess {
ID: number;
players: string;
current: string;
is_end: string;
status: string;
winner: string;
chess_id: string;
}
// yl-backgammon------------------------------------------------
interface roomStatus {
room_id: number;
player: string;
}

View File

@ -0,0 +1,13 @@
// 获取页面cookie
export const getCookie = (name: string) => {
const strCookie = document.cookie;
const arrCookie = strCookie.split("; ");
let i: number;
for (i = 0; i < arrCookie.length; i++) {
const arr = arrCookie[i].split("=");
if (arr[0] === name) {
return arr.filter((_, index) => index !== 0).join("=");
}
}
return "";
};

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { menuData, userinfo } from "@/store/store.ts";
import router, { routes } from "@/router/router.ts";
import _ from "lodash";
export const menuUpdate = () => {
const currentPath = ref("/");
const { data: menuList } = useRequest(
() => myRequest(`/api/menu`, userinfo.token),
{
refreshDeps: [() => userinfo.token],
ready: () => Boolean(userinfo.token),
},
);
// 没有菜单信息,返回首页
if (!menuList.value) {
currentPath.value = window.location.href.split("#")[1];
if (currentPath.value === "/") {
router.push("/");
}
}
// 获取菜单后更新路由
watch(menuList, () => {
router.clearRoutes();
menuData.data = [];
_.get(menuList, "value.data", [
{ name: "首页", icon: "home", path: "/" },
]).map((e: sysMenu) => {
menuData.data.push({
name: e.name,
icon: e.icon,
path: e.path,
menu_id: e.menu_id,
route_only: e.route_only,
detail: e.detail,
});
// 菜单是最后登录的页面
if (e.path === currentPath.value) {
menuData.default = e.menu_id;
}
// 菜单存在二级菜单,添加二级菜单路由
if (e.detail && e.detail.length > 0) {
e.detail.map((m) => {
if (routes.filter((item) => item.path === m.path).length > 0) {
router.addRoute(routes.filter((item) => item.path === m.path)[0]);
}
if (m.path === currentPath.value) {
menuData.default = m.menu_id;
}
});
} else {
if (routes.filter((item) => item.path === e.path).length > 0) {
router.addRoute(routes.filter((item) => item.path === e.path)[0]);
}
}
});
// router.addRoute(routes.filter((item) => item.path === "/234")[0]);
// 路由添加全局匹配页面
router.addRoute(routes.filter((item) => item.path === "/:catchAll(.*)")[0]);
console.log(currentPath.value);
if (menuList.value.length > 0) {
router.push(currentPath.value);
}
});
};
</script>
<template></template>
<style scoped></style>

66
web_vue/src/utils/menu.ts Normal file
View File

@ -0,0 +1,66 @@
import { useRequest } from "vue-request";
import { myRequest } from "@/requests/request.ts";
import { menuData, userinfo } from "@/store/store.ts";
import { ref, watch } from "vue";
import router, { routes } from "@/router/router.ts";
import _ from "lodash";
export const updateMenu = () => {
const currentPath = ref("/");
const { data: menuList } = useRequest(
() => myRequest(`/api/menu`, userinfo.token),
{
refreshDeps: [() => userinfo.token],
ready: () => Boolean(userinfo.token),
},
);
// 没有菜单信息,返回首页
if (!menuList.value) {
currentPath.value = window.location.href.split("#")[1];
router.push("/");
}
// 获取菜单后更新路由
watch(menuList, () => {
router.clearRoutes();
menuData.data = [];
_.get(menuList, "value.data", [
{ name: "首页", icon: "home", path: "/" },
]).map((e: sysMenu) => {
menuData.data.push({
name: e.name,
icon: e.icon,
path: e.path,
menu_id: e.menu_id,
route_only: e.route_only,
detail: e.detail,
});
// 菜单是最后登录的页面
if (e.path === currentPath.value) {
menuData.default = e.menu_id;
}
// 菜单存在二级菜单,添加二级菜单路由
if (e.detail && e.detail.length > 0) {
e.detail.map((m) => {
if (routes.filter((item) => item.path === m.path).length > 0) {
router.addRoute(routes.filter((item) => item.path === m.path)[0]);
}
if (m.path === currentPath.value) {
menuData.default = m.menu_id;
}
});
} else {
if (routes.filter((item) => item.path === e.path).length > 0) {
router.addRoute(routes.filter((item) => item.path === e.path)[0]);
}
}
});
// router.addRoute(routes.filter((item) => item.path === "/234")[0]);
// 路由添加全局匹配页面
router.addRoute(routes.filter((item) => item.path === "/:catchAll(.*)")[0]);
if (menuList.value.data.length > 0) {
router.push(currentPath.value);
} else {
router.push("/");
}
});
};

View File

@ -0,0 +1,22 @@
import { ref, onMounted, onUnmounted } from "vue";
// 按照惯例组合式函数名以“use”开头
export function useMouse() {
// 被组合式函数封装和管理的状态
const x = ref(0);
const y = ref(0);
// 组合式函数可以随时更改其状态。
function update(event: MouseEvent) {
x.value = event.pageX;
y.value = event.pageY;
}
// 一个组合式函数也可以挂靠在所属组件的生命周期上
// 来启动和卸载副作用
onMounted(() => window.addEventListener("mousemove", update));
onUnmounted(() => window.removeEventListener("mousemove", update));
// 通过返回值暴露所管理的状态
return { x, y };
}

View File

@ -0,0 +1,58 @@
import jsEncrypt from "jsencrypt";
import _ from "lodash";
// rsa非对称加密
export const rsaEncrypt = (pubKey: string, data: string) => {
const encrypt = new jsEncrypt();
encrypt.setPublicKey(pubKey);
return encrypt.encrypt(data);
};
// 格式化接口返回的错误信息
export const formatError = (res: any) => _.get(res, "response.data.msg", "");
//数字单位转换s带单位字符串数字和单位以空格分割d计划转换的单位默认MBt测试使用打印s
export const transNum = (s: string, d?: string, t?: string) => {
if (t) {
console.log(s);
}
// 未传参返回0
if (!s) {
return 0;
}
let n = Number(s.split(" ")[0]);
const list = ["B", "KB", "MB", "GB", "TB"];
// 数字单位由d转换为MB
if (d && d !== "MB") {
let a = list.indexOf(d);
if (a < 2) {
while (2 - a) {
n *= 1024;
a += 1;
}
} else if (a > 2) {
while (a - 2) {
n /= 1024;
a -= 1;
}
}
}
// 单位由s转换为d
const b = s.split(" ")[1];
if (b) {
if (b.indexOf("KB") !== -1) {
return Number((n / 1024).toFixed(3));
} else if (b.indexOf("MB") !== -1) {
return Number(n.toFixed(3));
} else if (b.indexOf("GB") !== -1) {
return Number((n * 1024).toFixed(3));
} else if (b.indexOf("TB") !== -1) {
return Number((n * 1024 * 1024).toFixed(3));
} else return Number((n / 1024 / 1024).toFixed(3));
}
return 0;
};
// 获取设备类型
export const isMobile = () => {
return !!window.navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i,
);
};

13
web_vue/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "lodash";
declare module "@kangc/v-md-editor";
declare module "@kangc/v-md-editor/lib/theme/vuepress.js";
declare module "@kangc/v-md-editor/lib/plugins/emoji/index";
declare module "prismjs";
declare module "prismjs";

30
web_vue/tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@": ["src"],
"@/*": ["src/*"]
},
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "vue",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

38
web_vue/vite.config.ts Normal file
View File

@ -0,0 +1,38 @@
import { defineConfig, splitVendorChunkPlugin } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), splitVendorChunkPlugin()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
proxy: {
"/api_django": {
target: "http://localhost:8000/api/",
rewrite: (path) => path.replace(/^\/api_django/, ""),
changeOrigin: true,
},
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
"/api_file": {
target: "http://localhost:8081",
changeOrigin: true,
},
"/admin": {
target: "http://localhost:8080",
changeOrigin: true,
},
"/static": {
target: "https://git-ylsa0.cn",
changeOrigin: true,
},
},
},
});