WebUI 1.0.0

WebUI 的版本号直接从 1.0.0 开始,目前而言无需激烈地重构了。
WebUI 使用 pnpm 管理构建流程,构建需要使用 `pnpm build` 。Kimi 说使用 `npm run build`
可能也行,等待测试了。
This commit is contained in:
2026-04-21 13:37:38 +08:00
parent 0760e51fb8
commit 14eadaab86
59 changed files with 6641 additions and 0 deletions

8
webui/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
webui/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
webui/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
# !.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

10
webui/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

10
webui/.idea/OxcSettings.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="OxcSettings">
<option name="fixAllOnSave" value="true" />
<flags>
<entry key="fix_kind" value="ALL" />
</flags>
<option name="typeAware" value="true" />
</component>
</project>

19
webui/.idea/OxfmtSettings.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="OxfmtSettings">
<option name="fixAllOnSave" value="true" />
<option name="supportedExtensions">
<list>
<option value=".js" />
<option value=".jsx" />
<option value=".cjs" />
<option value=".mjs" />
<option value=".ts" />
<option value=".tsx" />
<option value=".cts" />
<option value=".mts" />
<option value=".vue" />
</list>
</option>
</component>
</project>

7
webui/.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>jwxt</w>
</words>
</dictionary>
</component>

8
webui/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/webui.iml" filepath="$PROJECT_DIR$/.idea/webui.iml" />
</modules>
</component>
</project>

6
webui/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

8
webui/.idea/webui.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

5
webui/.oxfmtrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}

10
webui/.oxlintrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

78
webui/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,78 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

74
webui/components.d.ts vendored Normal file
View File

@@ -0,0 +1,74 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCard: typeof import('naive-ui')['NCard']
NCode: typeof import('naive-ui')['NCode']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NFlex: typeof import('naive-ui')['NFlex']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NMenu: typeof import('naive-ui')['NMenu']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NP: typeof import('naive-ui')['NP']
NSelect: typeof import('naive-ui')['NSelect']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
// For TSX support
declare global {
const NAlert: typeof import('naive-ui')['NAlert']
const NButton: typeof import('naive-ui')['NButton']
const NButtonGroup: typeof import('naive-ui')['NButtonGroup']
const NCard: typeof import('naive-ui')['NCard']
const NCode: typeof import('naive-ui')['NCode']
const NCollapse: typeof import('naive-ui')['NCollapse']
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
const NConfigProvider: typeof import('naive-ui')['NConfigProvider']
const NDatePicker: typeof import('naive-ui')['NDatePicker']
const NFlex: typeof import('naive-ui')['NFlex']
const NForm: typeof import('naive-ui')['NForm']
const NFormItem: typeof import('naive-ui')['NFormItem']
const NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
const NH1: typeof import('naive-ui')['NH1']
const NH2: typeof import('naive-ui')['NH2']
const NH3: typeof import('naive-ui')['NH3']
const NInput: typeof import('naive-ui')['NInput']
const NInputNumber: typeof import('naive-ui')['NInputNumber']
const NMenu: typeof import('naive-ui')['NMenu']
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
const NP: typeof import('naive-ui')['NP']
const NSelect: typeof import('naive-ui')['NSelect']
const NSwitch: typeof import('naive-ui')['NSwitch']
const NTag: typeof import('naive-ui')['NTag']
const NText: typeof import('naive-ui')['NText']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
}

2
webui/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
declare const __VERSION__: string

26
webui/eslint.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

13
webui/index-schedule.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link href="/favicon.ico" rel="icon">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>NJUPT API Suan ScheduleTable</title>
</head>
<body>
<div id="app"></div>
<script src="/src/main-schedule.ts" type="module"></script>
</body>
</html>

13
webui/index.html Normal file
View File

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

53
webui/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "webui",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/"
},
"dependencies": {
"ansi_up": "^6.0.6",
"axios": "^1.15.0",
"highlight.js": "^11.11.1",
"pinia": "^3.0.4",
"typescript-cookie": "^1.0.6",
"vue": "^3.5.31",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.57.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"naive-ui": "^2.44.1",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.42.0",
"oxlint": "~1.57.0",
"sass-embedded": "^1.99.0",
"typescript": "~6.0.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

4397
webui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

18
webui/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,18 @@
overrides:
'vue': 'beta'
'@vue/compiler-core': 'beta'
'@vue/compiler-dom': 'beta'
'@vue/compiler-sfc': 'beta'
'@vue/compiler-ssr': 'beta'
'@vue/compiler-vapor': 'beta'
'@vue/reactivity': 'beta'
'@vue/runtime-core': 'beta'
'@vue/runtime-dom': 'beta'
'@vue/runtime-vapor': 'beta'
'@vue/server-renderer': 'beta'
'@vue/shared': 'beta'
'@vue/compat': 'beta'
peerDependencyRules:
allowAny:
- 'vue'

BIN
webui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

121
webui/src/App.vue Normal file
View 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
View 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>

View 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
View 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;
}

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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

View 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 }
})

View 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
View 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
View 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
}

View File

@@ -0,0 +1,6 @@
export interface FastApiDto {
message: string
success: boolean
result: object
img_url: string | null
}

View File

@@ -0,0 +1,4 @@
export interface logDto {
id: number
message: string
}

View 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',
},
]

View 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
}

22
webui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
// JSX/TSX
"jsx": "preserve",
"jsxImportSource": "vue"
}
}

11
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

27
webui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

62
webui/vite.config.ts Normal file
View File

@@ -0,0 +1,62 @@
import {fileURLToPath, URL} from 'node:url'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import {NaiveUiResolver} from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import path from 'node:path/posix'
import {readFileSync} from "node:fs";
import {resolve} from 'path';
// 从 package.json 里搞到 WebUI 版本号
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
vueJsx(),
AutoImport({
imports: ['vue', 'vue-router'],
}),
Components({
resolvers: [NaiveUiResolver()],
}),
],
define: {
__VERSION__: JSON.stringify(pkg.version)
},
build: {
rolldownOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
'index-schedule': path.resolve(__dirname, 'index-schedule.html'),
}
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/admin': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/version': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})