WebUI 1.0.0
WebUI 的版本号直接从 1.0.0 开始,目前而言无需激烈地重构了。 WebUI 使用 pnpm 管理构建流程,构建需要使用 `pnpm build` 。Kimi 说使用 `npm run build` 可能也行,等待测试了。
This commit is contained in:
121
webui/src/App.vue
Normal file
121
webui/src/App.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts" setup>
|
||||
import HeaderNav from '@/coms/HeaderNav.vue'
|
||||
import SidebarNav from '@/coms/SidebarNav.vue'
|
||||
|
||||
import {dateZhCN, zhCN} from 'naive-ui'
|
||||
import hljs from 'highlight.js'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import LoginCard from '@/coms/LoginCard.vue'
|
||||
import {onMounted} from 'vue'
|
||||
import {getCookie, removeCookie, setCookie} from 'typescript-cookie'
|
||||
import {validateAdminToken} from '@/tools/web.ts'
|
||||
import {useConfig} from '@/stores/config.ts'
|
||||
import FooterNav from '@/coms/FooterNav.vue'
|
||||
|
||||
const CONFIG = useConfig()
|
||||
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
|
||||
onMounted(async () => {
|
||||
const token = getCookie('token')
|
||||
if (token) {
|
||||
if (await validateAdminToken(token)) {
|
||||
CONFIG.tokenValidated = true
|
||||
setCookie('token', token)
|
||||
} else {
|
||||
removeCookie('token')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:date-locale="dateZhCN"
|
||||
:hljs
|
||||
:locale="zhCN"
|
||||
:theme-overrides="{
|
||||
common: {
|
||||
fontFamily: 'Maple Mono CN, sans-serif',
|
||||
fontFamilyMono: 'Maple Mono CN, monospace',
|
||||
fontWeightStrong: '700',
|
||||
fontWeight: '500',
|
||||
},
|
||||
}"
|
||||
inline-theme-disabled
|
||||
>
|
||||
<n-message-provider>
|
||||
<div v-if="CONFIG.tokenValidated" class="main-container">
|
||||
<div id="start-container">
|
||||
<header-nav />
|
||||
</div>
|
||||
<div id="center-container">
|
||||
<div id="center-sidebar">
|
||||
<sidebar-nav />
|
||||
</div>
|
||||
<div id="center-content">
|
||||
<div id="content-container">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="end-container" style="padding-top: 6px">
|
||||
<footer-nav />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="main-container">
|
||||
<login-card />
|
||||
</div>
|
||||
</n-message-provider>
|
||||
<n-global-style />
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-container {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100svh;
|
||||
max-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
#start-container,
|
||||
#end-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#center-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
|
||||
#center-sidebar {
|
||||
flex: 0;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
#center-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
#content-container {
|
||||
border: 1px solid #519f72;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
webui/src/AppSchedule.vue
Normal file
19
webui/src/AppSchedule.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:theme-overrides="{
|
||||
common: {
|
||||
fontFamily: 'Maple Mono CN, sans-serif',
|
||||
fontFamilyMono: 'Maple Mono CN, monospace',
|
||||
fontWeightStrong: '700',
|
||||
fontWeight: '500',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<router-view />
|
||||
<n-global-style />
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
19
webui/src/assets/ansi-color.css
Normal file
19
webui/src/assets/ansi-color.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.ansi-green-fg {
|
||||
color: #2fda2f;
|
||||
}
|
||||
|
||||
.ansi-blue-fg {
|
||||
color: #86b6bc;
|
||||
}
|
||||
|
||||
.ansi-cyan-fg {
|
||||
color: #7ecce8;
|
||||
}
|
||||
|
||||
.ansi-red-fg {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
.ansi-yellow-fg {
|
||||
color: #f5f543;
|
||||
}
|
||||
26
webui/src/assets/font.css
Normal file
26
webui/src/assets/font.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@font-face {
|
||||
font-family: 'Maple Mono CN';
|
||||
src: url('https://file.mangofanfan.cn/directlink/assets/maple-mono-cn/MapleMono-CN-Medium.woff2') format('woff2'),
|
||||
url('https://file.mangofanfan.cn/directlink/assets/maple-mono-cn/MapleMono-CN-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Maple Mono CN';
|
||||
src: url('https://file.mangofanfan.cn/directlink/assets/maple-mono-cn/MapleMono-CN-Bold.woff2') format('woff2'),
|
||||
url('https://file.mangofanfan.cn/directlink/assets/maple-mono-cn/MapleMono-CN-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Smiley sans';
|
||||
src: url('https://file.mangofanfan.cn/directlink/assets/smiley-sans/SmileySans-Oblique.ttf.woff2') format('woff2'),
|
||||
url("https://file.mangofanfan.cn/directlink/assets/smiley-sans/SmileySans-Oblique.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
57
webui/src/assets/style.css
Normal file
57
webui/src/assets/style.css
Normal file
@@ -0,0 +1,57 @@
|
||||
@import 'ansi-color.css';
|
||||
@import "font.css";
|
||||
|
||||
/* 滚动条 */
|
||||
div::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
div::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 5px rgb(108 108 108 / 0.2);
|
||||
background: #519f72;
|
||||
}
|
||||
|
||||
div::-webkit-scrollbar-track {
|
||||
box-shadow: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-margin-bottom {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.no-margin {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.line-1 {
|
||||
width: 120px;
|
||||
white-space: nowrap; /* 1. 强制文本不换行 */
|
||||
overflow: hidden; /* 2. 隐藏超出部分 */
|
||||
text-overflow: ellipsis; /* 3. 超出部分显示... */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.min-width {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.max-width {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.min-width-large {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.max-width-large {
|
||||
max-width: 300px;
|
||||
}
|
||||
24
webui/src/coms/ApiCard.vue
Normal file
24
webui/src/coms/ApiCard.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
method: 'GET' | 'POST'
|
||||
path: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card>
|
||||
<template #header>
|
||||
<n-flex>
|
||||
<n-tag type="primary">{{ method }}</n-tag>
|
||||
<n-tag type="info">{{ path }}</n-tag>
|
||||
<n-text strong>{{ description }}</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #default>
|
||||
<slot />
|
||||
</template>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
32
webui/src/coms/FooterNav.vue
Normal file
32
webui/src/coms/FooterNav.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<div class="footer-container">
|
||||
<div class="footer-card c1"><strong>NJUPT Suan API</strong></div>
|
||||
<div class="footer-card c2">made with love by MangoFanFanw</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div.footer-container {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
|
||||
div.footer-card {
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
|
||||
&.c1 {
|
||||
border: 1px solid #2bf8ff;
|
||||
background-color: #d1fff1;
|
||||
}
|
||||
|
||||
&.c2 {
|
||||
border: 1px solid #c3ff2b;
|
||||
background-color: #f7ffe2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
webui/src/coms/HeaderNav.vue
Normal file
27
webui/src/coms/HeaderNav.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<div id="nav-container">
|
||||
<div id="nav-title">
|
||||
<n-h3 class="no-margin-bottom">NJUPT Suan API</n-h3>
|
||||
</div>
|
||||
<n-button secondary type="primary">概览</n-button>
|
||||
<n-button secondary type="info">GitHub</n-button>
|
||||
<n-button secondary type="error">芒果</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#nav-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 100vw;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#nav-title {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
56
webui/src/coms/LoginCard.vue
Normal file
56
webui/src/coms/LoginCard.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { validateAdminToken } from '@/tools/web.ts'
|
||||
import { useConfig } from '@/stores/config.ts'
|
||||
import { setCookie } from 'typescript-cookie'
|
||||
|
||||
const MESSAGE = useMessage()
|
||||
const CONFIG = useConfig()
|
||||
|
||||
const token = ref('')
|
||||
|
||||
async function login() {
|
||||
if (await validateAdminToken(token.value)) {
|
||||
MESSAGE.success('令牌验证成功,欢迎使用 NJUPT Suan API WebUI ~')
|
||||
CONFIG.tokenValidated = true
|
||||
setCookie('token', token.value, { expires: 2 })
|
||||
} else {
|
||||
MESSAGE.error('令牌验证失败,请重新验证!')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="login-card">
|
||||
<n-h1 class="no-margin-bottom" prefix="bar" style="width: 90%">NJUPT Suan API 的 WebUI</n-h1>
|
||||
<n-p style="font-size: 20px">请提供令牌(在启动日志中打印)</n-p>
|
||||
<n-flex align="center" style="width: 90%" vertical>
|
||||
<n-input v-model:value="token" />
|
||||
<n-button-group>
|
||||
<n-button type="primary" @click="login()">登录</n-button>
|
||||
<n-button type="info">O.o?</n-button>
|
||||
</n-button-group>
|
||||
</n-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#login-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 460px;
|
||||
background: linear-gradient(to bottom right, lightblue, #cbb4ff);
|
||||
border: solid 2px #000000;
|
||||
border-radius: 16px;
|
||||
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
262
webui/src/coms/ScheduleTable.vue
Normal file
262
webui/src/coms/ScheduleTable.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script lang="tsx" setup>
|
||||
import {getGradientColor} from "@/tools/random.ts";
|
||||
import type {CourseDatumDto, CourseDto} from '@/types/schedule.ts'
|
||||
import {NH4, NTooltip} from "naive-ui";
|
||||
import {onMounted, reactive} from 'vue'
|
||||
import FooterNav from "@/coms/FooterNav.vue";
|
||||
|
||||
const { courses } = defineProps<{
|
||||
courses: CourseDto[] | null
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
// 生成空的 courseData
|
||||
const generateCourseKeys = () => {
|
||||
const keys: Record<number, Record<number, CourseDatumDto | null>> = {}
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
keys[i] ={}
|
||||
for (let j = 1; j <= 12; j++) {
|
||||
keys[i]![j] = null
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const courseData = reactive<Record<number, Record<number, CourseDatumDto | null>>>(generateCourseKeys())
|
||||
|
||||
onMounted(() => {
|
||||
if (courses === null) return
|
||||
for (const course of courses) {
|
||||
courseData[course.day]![course.classes.at(0)!] = {
|
||||
name: course.name,
|
||||
alias: course.alias,
|
||||
teacher: course.teacher,
|
||||
classroom: course.classroom,
|
||||
during: course.classes.length
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 生成基础 Course 卡片。
|
||||
function baseCourseCard(
|
||||
title: string,
|
||||
content?: string | null,
|
||||
extraClass?: string | null,
|
||||
extraStyle?: object | null,
|
||||
during: number=1,
|
||||
tooltip: string | null = null) {
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
return (
|
||||
<div class={["base-card", extraClass]} style={{
|
||||
'min-height': 60 * during + 2 * (during - 1) + 'px',
|
||||
...extraStyle
|
||||
}}>
|
||||
<NTooltip>{{
|
||||
trigger: () => <NH4 class={["no-margin-bottom", "line-1"]}>{title}</NH4>,
|
||||
default: () => tooltip ? tooltip : "O.o?"
|
||||
}}</NTooltip>
|
||||
{ content ? <n-p class={["no-margin", "line-1"]} innerHTML={content}></n-p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function indexCardList() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({length: 12}, (_, i) =>
|
||||
baseCourseCard((i + 1).toString(), null, `index-card-${i + 1}`))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function dayCourseCardList(day: number) {
|
||||
const items = Object.values(courseData[day]!)
|
||||
let left = 0
|
||||
return (
|
||||
<>
|
||||
{items.map((item, _) => {
|
||||
if (item === null) {
|
||||
if (left === 0) {
|
||||
return baseCourseCard('FREE TIME', null, 'no-course-card')
|
||||
}
|
||||
else {
|
||||
left -= 1
|
||||
return null
|
||||
}
|
||||
}
|
||||
left = item.during - 1
|
||||
if (item.teacher === null) item.teacher = ''
|
||||
return baseCourseCard(
|
||||
item.alias ? item.alias : item.name,
|
||||
item.teacher + ( item.classroom === null ? '' : '<br />' + item.classroom),
|
||||
'course-card',
|
||||
{'background-image': getGradientColor()},
|
||||
item.during,
|
||||
item.name
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schedule-table-container">
|
||||
<div class="schedule-table-header-container">
|
||||
<div class="header-card">
|
||||
<n-h1 class="title no-margin">{{ title ? title : '芒果酸的课程表' }}</n-h1>
|
||||
<n-h3 class="no-margin">{{ subtitle ? subtitle : '我也要上吗?' }}</n-h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-table-body-container">
|
||||
<!-- 标题列 -->
|
||||
<div class="background-panel">
|
||||
<component :is="indexCardList()" />
|
||||
</div>
|
||||
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(1)" />
|
||||
</div>
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(2)" />
|
||||
</div>
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(3)" />
|
||||
</div>
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(4)" />
|
||||
</div>
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(5)" />
|
||||
</div>
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(6)" />
|
||||
</div>
|
||||
<div class="background-panel">
|
||||
<component :is="dayCourseCardList(7)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer-nav />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div.schedule-table-container {
|
||||
div.schedule-table-header-container {
|
||||
padding: 10px;
|
||||
|
||||
// 真正的标题卡片
|
||||
div.header-card {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: linear-gradient(8deg, #a6ffe9, #f2fffc);
|
||||
border-radius: 6px;
|
||||
border: 2px solid #2bdbff;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
|
||||
.title {
|
||||
font-family: 'Smiley sans', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
div.header-card::before,
|
||||
div.header-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-radius: 6px 0 0 0;
|
||||
width: 300px;
|
||||
height: 10px;
|
||||
background-color: #2bdbff;
|
||||
}
|
||||
|
||||
div.header-card::before {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 0 0 6px 0;
|
||||
}
|
||||
|
||||
div.header-card::after {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-radius: 6px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
div.schedule-table-body-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
|
||||
div.background-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
background-color: #efefef;
|
||||
border-radius: 6px;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
|
||||
div.base-card {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
div.index-card-1,
|
||||
div.index-card-2 {
|
||||
background-color: #ffefd1;
|
||||
}
|
||||
|
||||
div.index-card-3,
|
||||
div.index-card-4,
|
||||
div.index-card-5 {
|
||||
background-color: #dbf4ff;
|
||||
}
|
||||
|
||||
div.index-card-6,
|
||||
div.index-card-7,
|
||||
div.index-card-8,
|
||||
div.index-card-9 {
|
||||
background-color: #d1ffff;
|
||||
}
|
||||
|
||||
div.index-card-10,
|
||||
div.index-card-11,
|
||||
div.index-card-12 {
|
||||
background-color: #d7d2ff;
|
||||
}
|
||||
|
||||
div.no-course-card {
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
h4 {
|
||||
color: #cdcdcd;
|
||||
}
|
||||
}
|
||||
|
||||
div.course-card {
|
||||
border: 2px solid #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
webui/src/coms/SettingCard.vue
Normal file
58
webui/src/coms/SettingCard.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
import type {SelectOption} from 'naive-ui'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
message?: string
|
||||
showSwitch?: boolean
|
||||
showInput?: boolean
|
||||
showNumber?: boolean
|
||||
showSelect?: boolean
|
||||
showDatePicker?: boolean
|
||||
disabled?: boolean
|
||||
selectionOptions?: SelectOption[]
|
||||
}>()
|
||||
|
||||
const booleanValue = defineModel<boolean | undefined>('booleanValue', { required: false })
|
||||
const stringValue = defineModel<string | undefined>('stringValue', { required: false })
|
||||
const numberValue = defineModel<number | undefined>('numberValue', { required: false })
|
||||
const selectionValue = defineModel<string | undefined>('selectionValue', { required: false })
|
||||
const dateValue = defineModel<string | undefined>('dateValue', { required: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card :title="title">
|
||||
<template v-if="showSwitch" #header-extra>
|
||||
<n-switch v-model:value="booleanValue" :disabled size="large" />
|
||||
</template>
|
||||
<template v-if="showInput" #header-extra>
|
||||
<n-input v-model:value="stringValue" :disabled size="large" />
|
||||
</template>
|
||||
<template v-if="showNumber" #header-extra>
|
||||
<n-input-number v-model:value="numberValue" :disabled size="large" />
|
||||
</template>
|
||||
<template v-if="showDatePicker" #header-extra>
|
||||
<n-date-picker
|
||||
v-model:formatted-value="dateValue"
|
||||
:disabled
|
||||
format="yyyy-MM-dd"
|
||||
size="large"
|
||||
type="date"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="showSelect" #header-extra>
|
||||
<n-select
|
||||
v-model:value="selectionValue"
|
||||
:disabled
|
||||
:options="selectionOptions"
|
||||
class="min-width-large"
|
||||
size="large"
|
||||
/>
|
||||
</template>
|
||||
<n-p v-if="message">{{ message }}</n-p>
|
||||
<slot />
|
||||
<template #header-extra><slot name="header-extra" /></template>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
41
webui/src/coms/SidebarNav.vue
Normal file
41
webui/src/coms/SidebarNav.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import type {MenuOption} from 'naive-ui'
|
||||
import {ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeKey = ref('/')
|
||||
const options: MenuOption[] = [
|
||||
{
|
||||
label: '面板主页',
|
||||
key: '/',
|
||||
},
|
||||
{
|
||||
label: '课表管理',
|
||||
key: '/schedule',
|
||||
},
|
||||
{
|
||||
label: '样式设计',
|
||||
key: '/style',
|
||||
},
|
||||
{
|
||||
label: '酸酸日志',
|
||||
key: '/log',
|
||||
},
|
||||
{
|
||||
label: '酸酸设置',
|
||||
key: '/config',
|
||||
},
|
||||
]
|
||||
|
||||
function handleClick(key: string) {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-menu v-model:value="activeKey" :options @update:value="handleClick" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
24
webui/src/coms/ToolCard.vue
Normal file
24
webui/src/coms/ToolCard.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
type: 'Tool' | 'Resource' | 'Prompt'
|
||||
name: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card>
|
||||
<template #header>
|
||||
<n-flex>
|
||||
<n-tag type="primary">{{ type }}</n-tag>
|
||||
<n-tag type="info">{{ name }}</n-tag>
|
||||
<n-text strong>{{ description }}</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #default>
|
||||
<slot />
|
||||
</template>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
11
webui/src/main-schedule.ts
Normal file
11
webui/src/main-schedule.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {createApp} from 'vue'
|
||||
|
||||
import '@/assets/style.css'
|
||||
|
||||
import AppSchedule from '@/AppSchedule.vue'
|
||||
import scheduleRouter from '@/router/index-schedule.ts'
|
||||
|
||||
const app = createApp(AppSchedule)
|
||||
app.use(scheduleRouter)
|
||||
|
||||
app.mount('#app')
|
||||
14
webui/src/main.ts
Normal file
14
webui/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {createApp} from 'vue'
|
||||
import {createPinia} from 'pinia'
|
||||
|
||||
import '@/assets/style.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
160
webui/src/pages/Config.vue
Normal file
160
webui/src/pages/Config.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="tsx" setup>
|
||||
import SettingCard from '@/coms/SettingCard.vue'
|
||||
import {useConfig} from '@/stores/config.ts'
|
||||
import {onActivated, onDeactivated, onMounted, ref} from 'vue'
|
||||
import {JwxtLoginMethodOptions} from '@/types/options.ts'
|
||||
import {NCode, NTag, useMessage} from "naive-ui"
|
||||
|
||||
defineOptions({
|
||||
name: 'Config',
|
||||
})
|
||||
|
||||
const CONFIG = useConfig()
|
||||
const MESSAGE = useMessage()
|
||||
|
||||
const extraVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
CONFIG.update()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
extraVisible.value = false
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
extraVisible.value = true
|
||||
})
|
||||
|
||||
function varTag(code: string, description: string) {
|
||||
return (
|
||||
<NTag type="info">
|
||||
<NCode inline>{code}</NCode> - {description}
|
||||
</NTag>
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="config-container">
|
||||
<n-h2 prefix="bar">酸酸设置</n-h2>
|
||||
<n-alert title="设置注意" type="info">
|
||||
<n-p class="no-margin">
|
||||
<n-text strong type="warning">系统设置</n-text>
|
||||
- 需要完全重新启动 Suan API 才能应用;
|
||||
</n-p>
|
||||
<n-p class="no-margin">其他的设置可以即时生效。</n-p>
|
||||
</n-alert>
|
||||
|
||||
<n-collapse v-if="CONFIG.dataStatus" style="margin-top: 1rem">
|
||||
<n-collapse-item name="system" title="系统设置">
|
||||
<n-flex vertical>
|
||||
<setting-card
|
||||
v-model:string-value="CONFIG.data.system.host"
|
||||
message="host"
|
||||
show-input
|
||||
title="监听地址"
|
||||
/>
|
||||
<setting-card
|
||||
v-model:number-value="CONFIG.data.system.port"
|
||||
message="port"
|
||||
show-number
|
||||
title="监听端口"
|
||||
/>
|
||||
<setting-card
|
||||
v-model:boolean-value="CONFIG.data.system.reload"
|
||||
message="使用 uvicorn.run(..., reload=True) ,不建议在正式部署时开启。(据说会降低性能且可能导致内存泄露)"
|
||||
show-switch
|
||||
title="热重载 Python 代码文件"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
<n-collapse-item name="schedule" title="课表设置">
|
||||
<n-flex vertical>
|
||||
<setting-card
|
||||
v-model:selection-value="CONFIG.data.schedule.jwxt_login_method"
|
||||
:selection-options="JwxtLoginMethodOptions"
|
||||
message="连接到校园网时,可以使用教务系统直接登录;在校园外则需要使用 SSO 统一身份认证。"
|
||||
show-select
|
||||
title="教务系统登录方式"
|
||||
/>
|
||||
<setting-card
|
||||
v-model:date-value="CONFIG.data.schedule.semester_start_date"
|
||||
message="当前学期从哪一天开始?我说的是第一周的星期一。给出非星期一的日期是未定义行为,请避免。"
|
||||
show-date-picker
|
||||
title="学期开始日期"
|
||||
/>
|
||||
<setting-card
|
||||
v-model:string-value="CONFIG.data.schedule.schedule_title_template"
|
||||
show-input
|
||||
title="课表渲染图片的标题"
|
||||
>
|
||||
<n-p>标题中支持添加 Python 风格占位符,以代表动态填充的内容。</n-p>
|
||||
<n-p>
|
||||
具体来说,支持以下变量:
|
||||
<component :is="varTag('week', '第几周课表')" />、
|
||||
<component :is="varTag('week_start_day', '该周开始日期(YYYY-MM-DD)')" />、
|
||||
<component :is="varTag('week_end_day', '该周结束日期(YYYY-MM-DD)')" />。
|
||||
</n-p>
|
||||
<n-p>
|
||||
使用
|
||||
<n-code inline>第 {week} 周</n-code>
|
||||
来插入这些变量。
|
||||
</n-p>
|
||||
</setting-card>
|
||||
<setting-card
|
||||
v-model:string-value="CONFIG.data.schedule.schedule_subtitle_template"
|
||||
message="简单来说,和上面一样可以使用变量。"
|
||||
show-input
|
||||
title="课表渲染图片的副标题"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
<n-collapse-item name="log" title="日志设置">
|
||||
<n-flex vertical>
|
||||
<setting-card
|
||||
v-model:boolean-value="CONFIG.data.log.log_api_request_details"
|
||||
message="除去下面的特殊端点之外的所有端点"
|
||||
show-switch
|
||||
title="记录 API 调用详细日志"
|
||||
/>
|
||||
<setting-card
|
||||
v-model:boolean-value="CONFIG.data.log.log_mcp_request_details"
|
||||
message="端点 /mcp 的请求会被自动忽略,MCP 调用日志"
|
||||
show-switch
|
||||
title="记录 MCP 调用详细日志"
|
||||
/>
|
||||
<setting-card
|
||||
v-model:boolean-value="CONFIG.data.log.log_assets_request"
|
||||
message="端点位于 /assets(建议关闭,只有在生成图片和使用 WebUI 时才会访问其中资源。)"
|
||||
show-switch
|
||||
title="记录静态资源调用请求"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<teleport v-if="extraVisible" defer to="#center-container">
|
||||
<div class="header-card">
|
||||
<n-flex vertical>
|
||||
<n-button
|
||||
circle
|
||||
size="large"
|
||||
type="success"
|
||||
@click="
|
||||
() => {
|
||||
CONFIG.save()
|
||||
MESSAGE.success('保存设置成功,后端会自动应用新的设置 ~')
|
||||
}
|
||||
"
|
||||
>保存</n-button
|
||||
>
|
||||
<n-button circle size="large" type="warning">重启</n-button>
|
||||
<n-button circle size="large" type="error">关闭</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
29
webui/src/pages/IndexSchedule.vue
Normal file
29
webui/src/pages/IndexSchedule.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import ScheduleTable from '@/coms/ScheduleTable.vue'
|
||||
import {onMounted, ref} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import type {CourseDto} from '@/types/schedule.ts'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const courses = ref<CourseDto[]>([])
|
||||
const title = ref('')
|
||||
const subtitle = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.data) {
|
||||
courses.value = JSON.parse(route.query.data as string)
|
||||
} else {
|
||||
console.warn('🐔 未提供 data query参数,无法渲染课程表》')
|
||||
}
|
||||
|
||||
if (route.query.title) title.value = route.query.title as string
|
||||
if (route.query.subtitle) subtitle.value = route.query.subtitle as string
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<schedule-table v-if="courses.length !== 0" :courses :subtitle :title />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
106
webui/src/pages/Log.vue
Normal file
106
webui/src/pages/Log.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" setup>
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue'
|
||||
import {AnsiUp} from 'ansi_up' // 用于解析日志颜色
|
||||
import type {logDto} from '@/types/logger'
|
||||
|
||||
defineOptions({
|
||||
name: 'Log',
|
||||
})
|
||||
|
||||
const logContent = ref('')
|
||||
const logBox = useTemplateRef('logBox')
|
||||
const ansiUp = new AnsiUp()
|
||||
ansiUp.use_classes = true
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
const initWebSocket = () => {
|
||||
ws = new WebSocket('ws://127.0.0.1:8000/ws/logs')
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 1. 解析 ANSI 颜色编码
|
||||
const logs = JSON.parse(event.data)['logs'] as logDto[]
|
||||
for (const log of logs) {
|
||||
logContent.value += ansiUp.ansi_to_html(log.message) + '<br>'
|
||||
}
|
||||
|
||||
// 2. 自动滚动到底部
|
||||
nextTick(() => {
|
||||
if (logBox.value) {
|
||||
logBox.value.scrollTop = logBox.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ws.onerror = (e) => console.error('WebSocket Error:', e)
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket Closed, reconnecting...')
|
||||
setTimeout(initWebSocket, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initWebSocket)
|
||||
onBeforeUnmount(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="log-container">
|
||||
<n-h2 prefix="bar">酸酸日志</n-h2>
|
||||
<n-alert title="酸……吗?" type="info">
|
||||
<n-p>
|
||||
FastAPI 后端的日志会在此处显示,这样也许可以方便你检查酸 API 的运行情况。
|
||||
<n-text strong type="warning">这不是 WebUI 的日志。</n-text>
|
||||
</n-p>
|
||||
</n-alert>
|
||||
<n-alert title="如果遇到严重错误" type="warning">
|
||||
<n-p>
|
||||
如果 FastAPI 后端遇到严重错误,日志可能无法被顺利发送到 WebUI。所以如果你发现酸 API
|
||||
出现事实性故障,那么你可能
|
||||
<n-text strong type="warning">需要亲自查看终端和文件中的日志。</n-text>
|
||||
</n-p>
|
||||
</n-alert>
|
||||
|
||||
<div ref="logBox" class="log-container">
|
||||
<pre id="log-content" v-html="logContent" />
|
||||
</div>
|
||||
|
||||
<n-p>
|
||||
日志会以追加模式(w)写入到
|
||||
<n-code inline>data/app.log</n-code>
|
||||
文件中。
|
||||
</n-p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div.log-container {
|
||||
padding: 6px;
|
||||
width: 98%;
|
||||
min-width: 0;
|
||||
height: 500px;
|
||||
overflow: auto;
|
||||
|
||||
margin-top: 10px;
|
||||
background-color: #191a1c;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre#log-content {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1;
|
||||
color: #cdcdcd;
|
||||
|
||||
font-family:
|
||||
Maple Mono CN,
|
||||
sans-serif;
|
||||
letter-spacing: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
57
webui/src/pages/Overview.vue
Normal file
57
webui/src/pages/Overview.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import {onMounted, ref} from 'vue'
|
||||
import {useConfig} from '@/stores/config.ts'
|
||||
import type {FastApiDto} from '@/types/fastapi.ts'
|
||||
|
||||
defineOptions({
|
||||
name: 'Overview',
|
||||
})
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const api_version = ref('unknown')
|
||||
const webui_version = __VERSION__
|
||||
|
||||
interface VerDto {
|
||||
version: string
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetch('/version', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data: FastApiDto) => data.result as VerDto)
|
||||
.then((data: VerDto) => {
|
||||
api_version.value = data.version
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="overview-container">
|
||||
<n-h1 prefix="bar">NJUPT Suan API WebUI</n-h1>
|
||||
|
||||
<n-flex vertical>
|
||||
<n-card title="FastAPI 后端状态">
|
||||
<template #header-extra>
|
||||
<n-tag type="info">版本 {{ api_version }}</n-tag>
|
||||
</template>
|
||||
</n-card>
|
||||
<n-card title="WebUI 前端状态">
|
||||
<template #header-extra>
|
||||
<n-tag type="info">版本 {{ webui_version }}</n-tag>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
|
||||
<n-p>
|
||||
欢迎使用 NJUPT Suan API。本项目仍然处于早期开发阶段,更多新功能和稳定性改善仍在计划中~
|
||||
</n-p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
21
webui/src/pages/Schedule.vue
Normal file
21
webui/src/pages/Schedule.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import ScheduleDescription from '@/pages/ScheduleDescription.vue'
|
||||
import ScheduleQuery from '@/pages/ScheduleQuery.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'Schedule',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="schedule-container">
|
||||
<n-h2 prefix="bar">课表管理</n-h2>
|
||||
<n-p>可以设置 API 以及 MCP 如何获取你的课表数据。</n-p>
|
||||
|
||||
<schedule-description />
|
||||
|
||||
<schedule-query />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
108
webui/src/pages/ScheduleDescription.vue
Normal file
108
webui/src/pages/ScheduleDescription.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import ApiCard from '@/coms/ApiCard.vue'
|
||||
import ToolCard from '@/coms/ToolCard.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-alert title="通用参数" type="info">
|
||||
<n-p class="no-margin">
|
||||
<n-code inline>week</n-code> 数字,指示获取第几周的课表。可选,默认值为 0 表明获取全部。
|
||||
</n-p>
|
||||
<n-p class="no-margin">
|
||||
<n-code inline>img</n-code> 布尔值,指示是否生成课表图片。可选,默认值为 False。若为
|
||||
True,生成的图片的临时链接会在 <n-code inline>img_url</n-code> 中提供。
|
||||
</n-p>
|
||||
<n-p class="no-margin">
|
||||
<n-text strong type="warning">
|
||||
目前课表图片只考虑了生成某一周的课程表,因此
|
||||
<n-code inline>week=0;img=true</n-code>
|
||||
是未定义行为,请注意避免。
|
||||
</n-text>
|
||||
</n-p>
|
||||
</n-alert>
|
||||
|
||||
<n-collapse style="margin-top: 1rem">
|
||||
<n-collapse-item name="1" title="可用接口">
|
||||
<n-flex vertical>
|
||||
<api-card description="获取班级课表" method="POST" path="/api/schedule/class">
|
||||
<n-p>
|
||||
可以携带
|
||||
<n-code inline>username</n-code>
|
||||
和
|
||||
<n-code inline>password</n-code>
|
||||
两个参数,携带时将会使用它们登录教务系统并查询。携带时,将会查询该学生所在班级的课表。
|
||||
</n-p>
|
||||
<n-p>
|
||||
不携带上述参数调用时,会返回数据库中存储的班级课表。你可以在下方的「课表查询」中保存一个班级课表。
|
||||
</n-p>
|
||||
</api-card>
|
||||
<api-card description="获取学生课表" method="POST" path="/api/schedule/student">
|
||||
<n-p>
|
||||
需要携带
|
||||
<n-code inline>username</n-code>
|
||||
和
|
||||
<n-code inline>password</n-code>
|
||||
两个参数,确保学生本人查询。
|
||||
</n-p>
|
||||
</api-card>
|
||||
<api-card description="获取课表图片" method="GET" path="/api/schedule/img/{name}">
|
||||
<n-p>
|
||||
<n-text strong type="warning">不接收包括上面的通用参数在内的任何参数。</n-text>
|
||||
</n-p>
|
||||
<n-p>
|
||||
图片名需要被包含在请求路径中。如果你在调用上面两个方法时指示生成图片,那么图片的完整路径应当已经被提供给你。
|
||||
</n-p>
|
||||
</api-card>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item name="2" title="可用工具">
|
||||
<n-flex vertical>
|
||||
<tool-card description="获取默认班级课表" name="tool_schedule_class" type="Tool">
|
||||
返回数据库中存储的班级课表。你可以在下方的「课表查询」中保存一个班级课表。
|
||||
</tool-card>
|
||||
<tool-card
|
||||
description="获取指定学生的班级课表"
|
||||
name="tool_schedule_class_special"
|
||||
type="Tool"
|
||||
>
|
||||
<n-p>
|
||||
需要携带
|
||||
<n-code inline>username</n-code>
|
||||
和
|
||||
<n-code inline>password</n-code>
|
||||
两个参数,确保学生本人查询。
|
||||
</n-p>
|
||||
</tool-card>
|
||||
<tool-card
|
||||
description="获取指定学生的个人课表"
|
||||
name="tool_schedule_student_special"
|
||||
type="Tool"
|
||||
>
|
||||
<n-p>
|
||||
需要携带
|
||||
<n-code inline>username</n-code>
|
||||
和
|
||||
<n-code inline>password</n-code>
|
||||
两个参数,确保学生本人查询。
|
||||
</n-p>
|
||||
</tool-card>
|
||||
<tool-card description="直接获取课表图片" name="tool_schedule_image" type="Tool">
|
||||
<n-p>
|
||||
<n-text strong type="warning">本工具不接收上面的公共参数。</n-text>
|
||||
</n-p>
|
||||
<n-p>
|
||||
与其他工具返回图片的不同点在于,此工具会以 MCP 协议直接返回图片本身,避免客户端及 LLM
|
||||
二次下载。图片直接包含在会话中也可以避免酸 API 本地的图片被清理时导致 404。
|
||||
</n-p>
|
||||
<n-p>
|
||||
本工具只接收一个参数 <n-code inline>img_name</n-code> 作为图片的
|
||||
<n-text strong type="success">文件名</n-text>。
|
||||
</n-p>
|
||||
</tool-card>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
73
webui/src/pages/ScheduleQuery.vue
Normal file
73
webui/src/pages/ScheduleQuery.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {scheduleTypeOptions} from '@/types/options.ts'
|
||||
import {admin} from '@/tools/web.ts'
|
||||
|
||||
const formValue = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
scheduleType: 'class',
|
||||
})
|
||||
|
||||
const queryTestResult = ref<object | null>(null)
|
||||
const queryTestResultJson = computed(() => JSON.stringify(queryTestResult.value, null, 2))
|
||||
|
||||
function query() {
|
||||
admin
|
||||
.post('schedule/test', formValue.value)
|
||||
.then((res) => res.data)
|
||||
.then((data) => {
|
||||
queryTestResult.value = data
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
admin
|
||||
.get('schedule/test')
|
||||
.then((res) => res.data)
|
||||
.then((data) => {
|
||||
queryTestResult.value = data
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-h3>课表查询</n-h3>
|
||||
|
||||
<n-p>
|
||||
在这里尝试查询课表,检查是否成功。在此处查询的最后一次
|
||||
<n-text strong type="info">班级课表</n-text>
|
||||
会被保存在数据库中,以供无凭证无参数调用酸 API 获取班级课表时返回。
|
||||
</n-p>
|
||||
<n-p>
|
||||
<n-text strong type="warning"
|
||||
>出于隐私保护的考虑,个人课表不会被保存,需要每次都携带凭证查询。</n-text
|
||||
>
|
||||
</n-p>
|
||||
|
||||
<n-card>
|
||||
<template #default>
|
||||
<n-form :model="formValue" class="no-margin-bottom" inline label-width="auto">
|
||||
<n-form-item label="学号" path="username">
|
||||
<n-input v-model:value="formValue.username" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码" path="password">
|
||||
<n-input v-model:value="formValue.password" />
|
||||
</n-form-item>
|
||||
<n-form-item label="课表类型" path="scheduleType">
|
||||
<n-select
|
||||
v-model:value="formValue.scheduleType"
|
||||
:options="scheduleTypeOptions"
|
||||
class="min-width"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" @click="query()">查询</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-code :code="queryTestResultJson" language="json" />
|
||||
</template>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
163
webui/src/pages/Style.vue
Normal file
163
webui/src/pages/Style.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts" setup>
|
||||
import ScheduleTable from '@/coms/ScheduleTable.vue'
|
||||
import {onMounted, ref} from 'vue'
|
||||
import type {AliasDto, CourseDto} from '@/types/schedule.ts'
|
||||
import {admin, api} from '@/tools/web.ts'
|
||||
import type {FastApiDto} from '@/types/fastapi.ts'
|
||||
import SettingCard from '@/coms/SettingCard.vue'
|
||||
import {scheduleTypeOptions} from '@/types/options.ts'
|
||||
import {useMessage} from 'naive-ui'
|
||||
|
||||
const MESSAGE = useMessage()
|
||||
|
||||
defineOptions({
|
||||
name: 'Style',
|
||||
})
|
||||
|
||||
const courses = ref<CourseDto[] | null>(null)
|
||||
const aliases = ref<AliasDto[]>([])
|
||||
|
||||
const originalName = ref('')
|
||||
const aliasName = ref('')
|
||||
|
||||
const scheduleImgUrl = ref<string | null>(null)
|
||||
|
||||
function query() {
|
||||
courses.value = null
|
||||
console.log(formValue.value)
|
||||
api
|
||||
.post(scheduleType.value === 'class' ? 'schedule/class' : 'schedule/student', {
|
||||
username: formValue.value.username === '' ? null : formValue.value.username,
|
||||
password: formValue.value.password === '' ? null : formValue.value.password,
|
||||
week: formValue.value.week,
|
||||
img: formValue.value.img,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
.then((data: FastApiDto) => {
|
||||
courses.value = data.result as CourseDto[]
|
||||
scheduleImgUrl.value = data.img_url
|
||||
})
|
||||
}
|
||||
|
||||
function addAlias() {
|
||||
if (originalName.value === '' || aliasName.value === '') {
|
||||
MESSAGE.error('需要输入别名……')
|
||||
return
|
||||
}
|
||||
admin
|
||||
.post('schedule/alias', {
|
||||
originalName: originalName.value,
|
||||
aliasName: aliasName.value,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
.then((data: FastApiDto) => {
|
||||
if (data.success) {
|
||||
MESSAGE.success('课程别名添加成功~')
|
||||
originalName.value = ''
|
||||
aliasName.value = ''
|
||||
} else {
|
||||
MESSAGE.error(`课程别名添加失败:${data.message}`)
|
||||
console.error(`课程别名添加失败:${data.message}`)
|
||||
}
|
||||
})
|
||||
refreshAlias()
|
||||
}
|
||||
|
||||
function refreshAlias() {
|
||||
admin
|
||||
.get('schedule/alias')
|
||||
.then((res) => res.data)
|
||||
.then((data: FastApiDto) => (aliases.value = data.result as AliasDto[]))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
query()
|
||||
refreshAlias()
|
||||
})
|
||||
|
||||
const formValue = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
week: 7,
|
||||
img: false,
|
||||
})
|
||||
|
||||
const scheduleType = ref('class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="style-container">
|
||||
<n-h2 prefix="bar">样式设计</n-h2>
|
||||
<n-p>在这里预览酸 API 返回的课程表图片的样式效果。</n-p>
|
||||
|
||||
<n-flex>
|
||||
<setting-card
|
||||
message="看看谁的哪一周的课表呢?学号也可以为空,以预览默认班级课表。"
|
||||
title="预览设置"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-flex justify="right" style="width: max-content">
|
||||
<n-select
|
||||
v-model:value="scheduleType"
|
||||
:options="scheduleTypeOptions"
|
||||
class="min-width max-width"
|
||||
/>
|
||||
<n-button type="success" @click="query()">查</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #default>
|
||||
<n-form :model="formValue" class="no-margin-bottom" inline label-width="auto">
|
||||
<n-form-item label="学号" path="username">
|
||||
<n-input v-model:value="formValue.username" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码" path="password">
|
||||
<n-input v-model:value="formValue.password" />
|
||||
</n-form-item>
|
||||
<n-form-item label="第()周" path="week">
|
||||
<n-input-number v-model:value="formValue.week" />
|
||||
</n-form-item>
|
||||
<n-form-item label="生成图片" path="img">
|
||||
<n-switch v-model:value="formValue.img" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-p v-if="scheduleImgUrl">
|
||||
课表图片位于 <n-text strong type="success">{{ scheduleImgUrl }}</n-text>
|
||||
</n-p>
|
||||
</template>
|
||||
</setting-card>
|
||||
|
||||
<setting-card
|
||||
message="为前几个字易混淆的课程设置别名,方便区分。例如,将「计算机问题求解(使用C语言)」的别名设置为「C语言」。"
|
||||
title="课程别名"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-flex justify="right" style="width: max-content">
|
||||
<n-input
|
||||
v-model:value="originalName"
|
||||
class="min-width-large max-width-large"
|
||||
placeholder="计算机问题求解(使用C语言)"
|
||||
/>
|
||||
<n-input v-model:value="aliasName" class="min-width max-width" placeholder="C" />
|
||||
<n-button type="primary" @click="addAlias()">添加别名</n-button>
|
||||
<n-button secondary type="info" @click="refreshAlias()">刷新</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #default>
|
||||
<n-flex>
|
||||
<n-tag v-for="alias in aliases" :key="alias.id" type="info">
|
||||
{{ alias.originalName }} => {{ alias.aliasName }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-p
|
||||
>如需更新预览效果,添加别名之后需要重新<n-text strong type="success">查</n-text
|
||||
>一次课表哦。</n-p
|
||||
>
|
||||
</template>
|
||||
</setting-card>
|
||||
</n-flex>
|
||||
|
||||
<schedule-table v-if="courses !== null" :courses />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
9
webui/src/router/index-schedule.ts
Normal file
9
webui/src/router/index-schedule.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {createRouter, createWebHashHistory} from 'vue-router'
|
||||
import IndexSchedule from '@/pages/IndexSchedule.vue'
|
||||
|
||||
const scheduleRouter = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [{ path: '/', component: IndexSchedule }],
|
||||
})
|
||||
|
||||
export default scheduleRouter
|
||||
19
webui/src/router/index.ts
Normal file
19
webui/src/router/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {createRouter, createWebHashHistory} from 'vue-router'
|
||||
import Overview from '@/pages/Overview.vue'
|
||||
import Schedule from '@/pages/Schedule.vue'
|
||||
import Log from '@/pages/Log.vue'
|
||||
import Style from '@/pages/Style.vue'
|
||||
import Config from '@/pages/Config.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', component: Overview, name: 'Overview' },
|
||||
{ path: '/schedule', component: Schedule, name: 'Schedule' },
|
||||
{ path: '/style', component: Style, name: 'Style' },
|
||||
{ path: '/log', component: Log, name: 'Log' },
|
||||
{ path: '/config', component: Config, name: 'Config' },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
58
webui/src/stores/config.ts
Normal file
58
webui/src/stores/config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {ref} from 'vue'
|
||||
import type {
|
||||
ConfigDataDto,
|
||||
ConfigLogDto,
|
||||
ConfigScheduleDto,
|
||||
ConfigSystemDto,
|
||||
} from '@/types/config.ts'
|
||||
import {admin} from '@/tools/web.ts'
|
||||
import type {FastApiDto} from '@/types/fastapi.ts'
|
||||
|
||||
export const useConfig = defineStore('config', () => {
|
||||
const tokenValidated = ref(false)
|
||||
|
||||
const dataStatus = ref(false)
|
||||
|
||||
const data = ref<{
|
||||
system: ConfigSystemDto
|
||||
schedule: ConfigScheduleDto
|
||||
log: ConfigLogDto
|
||||
}>({
|
||||
system: {},
|
||||
schedule: {},
|
||||
log: {},
|
||||
})
|
||||
|
||||
function update() {
|
||||
dataStatus.value = false
|
||||
admin
|
||||
.get('config')
|
||||
.then((res) => res.data)
|
||||
.then((_data: FastApiDto) => _data.result)
|
||||
// @ts-ignore
|
||||
.then((_data: ConfigDataDto) => {
|
||||
data.value.system = _data.system
|
||||
data.value.schedule = _data.schedule
|
||||
data.value.log = _data.log
|
||||
})
|
||||
.finally(() => (dataStatus.value = true))
|
||||
}
|
||||
|
||||
function save() {
|
||||
dataStatus.value = false
|
||||
admin
|
||||
.post('config', { data: data.value })
|
||||
.then((res) => res.data)
|
||||
.then((_data: FastApiDto) => _data.result)
|
||||
// @ts-ignore
|
||||
.then((_data: ConfigDataDto) => {
|
||||
data.value.system = _data.system
|
||||
data.value.schedule = _data.schedule
|
||||
data.value.log = _data.log
|
||||
})
|
||||
.finally(() => (dataStatus.value = true))
|
||||
}
|
||||
|
||||
return { tokenValidated, dataStatus, data, update, save }
|
||||
})
|
||||
6
webui/src/tools/random.ts
Normal file
6
webui/src/tools/random.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function getGradientColor() {
|
||||
const h = Math.floor(Math.random() * 200 + 20) // 20-220
|
||||
const s = Math.floor(Math.random() * 20 + 80) // 70%-100% 饱和度
|
||||
const l = Math.floor(Math.random() * 30 + 60) // 60%-90% 亮度
|
||||
return `linear-gradient(45deg, hsl(${h}, ${s}%, ${l}%), hsl(${h}, ${s - 20}%, ${l + 10}%))`
|
||||
}
|
||||
34
webui/src/tools/web.ts
Normal file
34
webui/src/tools/web.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import axios from 'axios'
|
||||
import type { FastApiDto } from '@/types/fastapi.ts'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
})
|
||||
|
||||
export const admin = axios.create({
|
||||
baseURL: '/admin',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证给定的 Token 是否正确。
|
||||
* 不读取、不保存,仅在正确时自动设置 admin axios 的验证头。
|
||||
*/
|
||||
export async function validateAdminToken(token: string) {
|
||||
const success = await axios
|
||||
.post(
|
||||
'/admin/validateToken',
|
||||
{ token: token },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
.then((res) => res.data)
|
||||
.then((data: FastApiDto) => data.success)
|
||||
if (success) {
|
||||
admin.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
console.log('✅ Token 有效,已经为 admin axios 设置。')
|
||||
} else {
|
||||
console.log('❌ Token 验证失败。')
|
||||
}
|
||||
return success
|
||||
}
|
||||
24
webui/src/types/config.ts
Normal file
24
webui/src/types/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ConfigSystemDto {
|
||||
host?: string
|
||||
port?: number
|
||||
reload?: boolean
|
||||
}
|
||||
|
||||
export interface ConfigScheduleDto {
|
||||
jwxt_login_method?: 'jwxt' | 'sso'
|
||||
semester_start_date?: string
|
||||
schedule_title_template?: string
|
||||
schedule_subtitle_template?: string
|
||||
}
|
||||
|
||||
export interface ConfigLogDto {
|
||||
log_api_request_details?: boolean
|
||||
log_mcp_request_details?: boolean
|
||||
log_assets_request?: boolean
|
||||
}
|
||||
|
||||
export interface ConfigDataDto {
|
||||
system: ConfigSystemDto
|
||||
schedule: ConfigScheduleDto
|
||||
log: ConfigLogDto
|
||||
}
|
||||
6
webui/src/types/fastapi.ts
Normal file
6
webui/src/types/fastapi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FastApiDto {
|
||||
message: string
|
||||
success: boolean
|
||||
result: object
|
||||
img_url: string | null
|
||||
}
|
||||
4
webui/src/types/logger.ts
Normal file
4
webui/src/types/logger.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface logDto {
|
||||
id: number
|
||||
message: string
|
||||
}
|
||||
23
webui/src/types/options.ts
Normal file
23
webui/src/types/options.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {SelectOption} from 'naive-ui'
|
||||
|
||||
export const scheduleTypeOptions: SelectOption[] = [
|
||||
{
|
||||
label: '班级课表',
|
||||
value: 'class',
|
||||
},
|
||||
{
|
||||
label: '学生个人课表',
|
||||
value: 'student',
|
||||
},
|
||||
]
|
||||
|
||||
export const JwxtLoginMethodOptions: SelectOption[] = [
|
||||
{
|
||||
label: '教务系统直接登录',
|
||||
value: 'jwxt',
|
||||
},
|
||||
{
|
||||
label: 'SSO 统一身份认证',
|
||||
value: 'sso',
|
||||
},
|
||||
]
|
||||
23
webui/src/types/schedule.ts
Normal file
23
webui/src/types/schedule.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface CourseDto {
|
||||
classroom: string
|
||||
name: string
|
||||
alias: string | null
|
||||
teacher: string
|
||||
day: number
|
||||
weeks: number[]
|
||||
classes: number[]
|
||||
}
|
||||
|
||||
export interface CourseDatumDto {
|
||||
name: string
|
||||
alias: string | null
|
||||
teacher: string
|
||||
classroom: string
|
||||
during: number
|
||||
}
|
||||
|
||||
export interface AliasDto {
|
||||
id: number
|
||||
originalName: string
|
||||
aliasName: string
|
||||
}
|
||||
Reference in New Issue
Block a user