‘add第一次提交’
This commit is contained in:
commit
45424bae7e
1
.env
Normal file
1
.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_OSS_URL = https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
src/auto-imports.d.ts
|
||||||
|
src/components.d.ts
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>学校数据看板 - AI作业批改统计</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3080
package-lock.json
generated
Normal file
3080
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "school-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vueuse/core": "^12.5.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"element-plus": "^2.9.5",
|
||||||
|
"pinia": "^2.2.8",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"sass": "^1.83.4",
|
||||||
|
"typescript": "~5.7.3",
|
||||||
|
"unplugin-auto-import": "^19.1.0",
|
||||||
|
"unplugin-vue-components": "^28.4.1",
|
||||||
|
"vite": "^6.1.0",
|
||||||
|
"vue-tsc": "^2.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/App.vue
Normal file
32
src/App.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const token = params.get('token')
|
||||||
|
const schoolId = params.get('schoolId')
|
||||||
|
const schoolName = params.get('schoolName')
|
||||||
|
|
||||||
|
if (token && schoolId) {
|
||||||
|
userStore.setAuth({
|
||||||
|
token,
|
||||||
|
schoolId,
|
||||||
|
schoolName: schoolName ? decodeURIComponent(schoolName) : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/api/http.ts
Normal file
36
src/api/http.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: '/api/main',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (userStore.token) {
|
||||||
|
config.headers.Authorization = userStore.token
|
||||||
|
}
|
||||||
|
config.headers['X-Tenant-Id'] = '1966391196339204098'
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const { code, message } = response.data
|
||||||
|
if (code === 200) return response
|
||||||
|
return Promise.reject(new Error(message || '请求失败'))
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const status = error.response?.status
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
|
const userStore = useUserStore()
|
||||||
|
userStore.logout()
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const http = instance
|
||||||
37
src/api/login.ts
Normal file
37
src/api/login.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { post, get } from './request'
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
userId: string
|
||||||
|
schoolId: string
|
||||||
|
schoolName: string
|
||||||
|
nickName: string
|
||||||
|
account: string
|
||||||
|
schoolClass?: {
|
||||||
|
schoolId: string
|
||||||
|
schoolName: string
|
||||||
|
classList: { classId: string; className: string }[]
|
||||||
|
}[]
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginApi(data: LoginParams) {
|
||||||
|
return post<string>('/login', {
|
||||||
|
clientType: 'MANAGE',
|
||||||
|
simSerialNumber: 'web',
|
||||||
|
model: 'web',
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserInfoApi() {
|
||||||
|
return get<UserInfo>('/personal/getHomePage')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutApi() {
|
||||||
|
return get<void>('/logout')
|
||||||
|
}
|
||||||
18
src/api/request.ts
Normal file
18
src/api/request.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
|
||||||
|
export async function get<T>(
|
||||||
|
url: string,
|
||||||
|
params?: Record<string, any>,
|
||||||
|
): Promise<T> {
|
||||||
|
const config = params ? { params } : {}
|
||||||
|
const res = await http.get(url, config)
|
||||||
|
return res.data.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(
|
||||||
|
url: string,
|
||||||
|
data?: any,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await http.post(url, data)
|
||||||
|
return res.data.data as T
|
||||||
|
}
|
||||||
66
src/api/schoolBoard.ts
Normal file
66
src/api/schoolBoard.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { get } from './request'
|
||||||
|
|
||||||
|
export interface AiHomeworkOverview {
|
||||||
|
assignedHomeworkCount: number
|
||||||
|
submittedHomeworkCount: number
|
||||||
|
unsubmittedHomeworkCount: number
|
||||||
|
excellentCount: number
|
||||||
|
goodCount: number
|
||||||
|
passCount: number
|
||||||
|
completionRate: number
|
||||||
|
excellentRate: number
|
||||||
|
goodRate: number
|
||||||
|
passRate: number
|
||||||
|
aiCorrectedTitleCount: number
|
||||||
|
participantTeacherCount: number
|
||||||
|
participantStudentCount: number
|
||||||
|
participantClassCount: number
|
||||||
|
teacherTimeSavedMinutes: number
|
||||||
|
aiAccuracyRate: string
|
||||||
|
studentAppealRate: string
|
||||||
|
aiAvgResponseTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewParams {
|
||||||
|
schoolId: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 Long/BigDecimal 可能序列化为字符串,统一转为 number 防止 + 变拼接
|
||||||
|
*/
|
||||||
|
function normalizeOverview(raw: any): AiHomeworkOverview {
|
||||||
|
const toNum = (v: any) => {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isNaN(n) ? 0 : n
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
assignedHomeworkCount: toNum(raw.assignedHomeworkCount),
|
||||||
|
submittedHomeworkCount: toNum(raw.submittedHomeworkCount),
|
||||||
|
unsubmittedHomeworkCount: toNum(raw.unsubmittedHomeworkCount),
|
||||||
|
excellentCount: toNum(raw.excellentCount),
|
||||||
|
goodCount: toNum(raw.goodCount),
|
||||||
|
passCount: toNum(raw.passCount),
|
||||||
|
completionRate: toNum(raw.completionRate),
|
||||||
|
excellentRate: toNum(raw.excellentRate),
|
||||||
|
goodRate: toNum(raw.goodRate),
|
||||||
|
passRate: toNum(raw.passRate),
|
||||||
|
aiCorrectedTitleCount: toNum(raw.aiCorrectedTitleCount),
|
||||||
|
participantTeacherCount: toNum(raw.participantTeacherCount),
|
||||||
|
participantStudentCount: toNum(raw.participantStudentCount),
|
||||||
|
participantClassCount: toNum(raw.participantClassCount),
|
||||||
|
teacherTimeSavedMinutes: toNum(raw.teacherTimeSavedMinutes),
|
||||||
|
aiAccuracyRate: String(raw.aiAccuracyRate ?? ''),
|
||||||
|
studentAppealRate: String(raw.studentAppealRate ?? ''),
|
||||||
|
aiAvgResponseTime: String(raw.aiAvgResponseTime ?? ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAiHomeworkOverview(params: OverviewParams) {
|
||||||
|
const raw = await get<any>(
|
||||||
|
'/school/report/aiHomeworkData/overview',
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return normalizeOverview(raw)
|
||||||
|
}
|
||||||
259
src/components/AiHeroBanner.vue
Normal file
259
src/components/AiHeroBanner.vue
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
|
import { formatNumber, formatMinutes } from '@/utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
aiCorrectedTitleCount: number
|
||||||
|
teacherTimeSavedMinutes: number
|
||||||
|
aiAccuracyRate: string
|
||||||
|
studentAppealRate: string
|
||||||
|
aiAvgResponseTime: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const animated = ref(false)
|
||||||
|
onMounted(() => { setTimeout(() => animated.value = true, 100) })
|
||||||
|
|
||||||
|
const metrics = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'AI批改题目总数',
|
||||||
|
value: formatNumber(props.aiCorrectedTitleCount),
|
||||||
|
sub: '累计批改',
|
||||||
|
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
delay: '0ms',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '节约教师总时长',
|
||||||
|
value: formatMinutes(props.teacherTimeSavedMinutes),
|
||||||
|
sub: '解放教学生产力',
|
||||||
|
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
delay: '80ms',
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI批改准确率',
|
||||||
|
value: props.aiAccuracyRate || '98%',
|
||||||
|
sub: '精准可信赖',
|
||||||
|
icon: 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z',
|
||||||
|
delay: '160ms',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '学生申诉率',
|
||||||
|
value: props.studentAppealRate || '0',
|
||||||
|
sub: '批改结果高认可',
|
||||||
|
icon: 'M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||||
|
delay: '240ms',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI平均响应时长',
|
||||||
|
value: props.aiAvgResponseTime || '2min',
|
||||||
|
sub: '极速反馈',
|
||||||
|
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||||
|
delay: '320ms',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="ai-hero" :class="{ 'ai-hero--animated': animated }">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<div class="ai-hero__bg-grid"></div>
|
||||||
|
<div class="ai-hero__bg-glow ai-hero__bg-glow--1"></div>
|
||||||
|
<div class="ai-hero__bg-glow ai-hero__bg-glow--2"></div>
|
||||||
|
|
||||||
|
<!-- 标题区 -->
|
||||||
|
<div class="ai-hero__header">
|
||||||
|
<div class="ai-hero__badge">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
|
AI 智能引擎
|
||||||
|
</div>
|
||||||
|
<h2 class="ai-hero__title">AI 智能批改效能总览</h2>
|
||||||
|
<p class="ai-hero__desc">人工智能赋能教学,全面提升作业批改效率与质量</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 指标卡片 -->
|
||||||
|
<div class="ai-hero__metrics">
|
||||||
|
<div
|
||||||
|
v-for="m in metrics"
|
||||||
|
:key="m.label"
|
||||||
|
class="ai-metric-card"
|
||||||
|
:class="{ 'ai-metric-card--highlight': m.highlight }"
|
||||||
|
:style="{ animationDelay: m.delay }"
|
||||||
|
>
|
||||||
|
<div class="ai-metric-card__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path :d="m.icon"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="ai-metric-card__value">{{ m.value }}</div>
|
||||||
|
<div class="ai-metric-card__label">{{ m.label }}</div>
|
||||||
|
<div class="ai-metric-card__sub">{{ m.sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-hero {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 36px 40px 40px;
|
||||||
|
background: linear-gradient(135deg, #0F172A 0%, #1E3A8A 50%, #1E40AF 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&__bg-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bg-glow {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&--1 {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
top: -80px;
|
||||||
|
right: -40px;
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
&--2 {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
bottom: -60px;
|
||||||
|
left: 10%;
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
color: #FCD34D;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__metrics {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-metric-card {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 18px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform var(--transition), background var(--transition), border-color var(--transition);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
|
||||||
|
.ai-hero--animated & {
|
||||||
|
animation: fadeInUp 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--highlight {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border-color: rgba(245, 158, 11, 0.3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border-color: rgba(245, 158, 11, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-metric-card__icon {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: #FCD34D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-metric-card__value {
|
||||||
|
color: #FCD34D;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #93C5FD;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-family: 'Fira Code', 'DIN Alternate', monospace;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
70
src/components/CompletionGauge.vue
Normal file
70
src/components/CompletionGauge.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = defineProps<{ rate: number }>()
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
chart?.dispose()
|
||||||
|
chart = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'gauge',
|
||||||
|
startAngle: 200,
|
||||||
|
endAngle: -20,
|
||||||
|
radius: '88%',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
splitNumber: 5,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
|
{ offset: 0, color: '#F59E0B' },
|
||||||
|
{ offset: 0.5, color: '#3B82F6' },
|
||||||
|
{ offset: 1, color: '#1E40AF' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
progress: { show: true, width: 18, roundCap: true },
|
||||||
|
pointer: { show: true, length: '50%', width: 5, itemStyle: { color: '#1E40AF' } },
|
||||||
|
axisLine: { lineStyle: { width: 18, color: [[1, '#F1F5F9']], round: true } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { distance: 24, color: '#94A3B8', fontSize: 12, fontFamily: 'Fira Code, monospace' },
|
||||||
|
title: { show: false },
|
||||||
|
detail: {
|
||||||
|
valueAnimation: true,
|
||||||
|
formatter: '{value}%',
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#0F172A',
|
||||||
|
offsetCenter: [0, '70%'],
|
||||||
|
fontFamily: 'Fira Code, monospace',
|
||||||
|
},
|
||||||
|
data: [{ value: Number(props.rate) }],
|
||||||
|
animationDuration: 1200,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.rate, render)
|
||||||
|
onMounted(render)
|
||||||
|
onBeforeUnmount(() => { chart?.dispose(); chart = null })
|
||||||
|
|
||||||
|
const handleResize = () => chart?.resize()
|
||||||
|
defineExpose({ handleResize })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="chart-box"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-box { width: 100%; height: 100%; min-height: 260px; }
|
||||||
|
</style>
|
||||||
140
src/components/DateFilter.vue
Normal file
140
src/components/DateFilter.vue
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ search: [] }>()
|
||||||
|
|
||||||
|
const startTime = defineModel<string>('startTime', { default: '' })
|
||||||
|
const endTime = defineModel<string>('endTime', { default: '' })
|
||||||
|
|
||||||
|
const activeShortcut = ref('')
|
||||||
|
|
||||||
|
const shortcuts = [
|
||||||
|
{ key: 'today', text: '今天', action: () => { const d = dayjs().format('YYYY-MM-DD'); startTime.value = d; endTime.value = d } },
|
||||||
|
{ key: '7d', text: '近7天', action: () => { startTime.value = dayjs().subtract(6, 'day').format('YYYY-MM-DD'); endTime.value = dayjs().format('YYYY-MM-DD') } },
|
||||||
|
{ key: '30d', text: '近30天', action: () => { startTime.value = dayjs().subtract(29, 'day').format('YYYY-MM-DD'); endTime.value = dayjs().format('YYYY-MM-DD') } },
|
||||||
|
{ key: 'semester', text: '本学期', action: () => {
|
||||||
|
const m = dayjs().month()
|
||||||
|
startTime.value = (m >= 1 && m <= 6 ? dayjs().month(1) : dayjs().month(7)).startOf('month').format('YYYY-MM-DD')
|
||||||
|
endTime.value = dayjs().format('YYYY-MM-DD')
|
||||||
|
}},
|
||||||
|
]
|
||||||
|
|
||||||
|
function applyShortcut(s: typeof shortcuts[0]) {
|
||||||
|
activeShortcut.value = s.key
|
||||||
|
s.action()
|
||||||
|
emit('search')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
startTime.value = ''
|
||||||
|
endTime.value = ''
|
||||||
|
activeShortcut.value = ''
|
||||||
|
emit('search')
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledStart = (d: Date) => endTime.value ? dayjs(d).isAfter(dayjs(endTime.value), 'day') : false
|
||||||
|
const disabledEnd = (d: Date) => startTime.value ? dayjs(d).isBefore(dayjs(startTime.value), 'day') : false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="date-filter">
|
||||||
|
<div class="date-filter__shortcuts">
|
||||||
|
<button
|
||||||
|
v-for="s in shortcuts"
|
||||||
|
:key="s.key"
|
||||||
|
class="shortcut-btn"
|
||||||
|
:class="{ 'shortcut-btn--active': activeShortcut === s.key }"
|
||||||
|
@click="applyShortcut(s)"
|
||||||
|
>
|
||||||
|
{{ s.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-filter__pickers">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="startTime"
|
||||||
|
type="date"
|
||||||
|
placeholder="开始日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disabledStart"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
@change="activeShortcut = ''"
|
||||||
|
/>
|
||||||
|
<span class="date-filter__sep">—</span>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="endTime"
|
||||||
|
type="date"
|
||||||
|
placeholder="结束日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disabledEnd"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
@change="activeShortcut = ''"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="$emit('search')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="onReset">重置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.date-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__shortcuts {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pickers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
color: var(--primary-light);
|
||||||
|
background: var(--primary-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
src/components/GradeBarChart.vue
Normal file
118
src/components/GradeBarChart.vue
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
excellent: number
|
||||||
|
good: number
|
||||||
|
pass: number
|
||||||
|
excellentRate: number
|
||||||
|
goodRate: number
|
||||||
|
passRate: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
chart?.dispose()
|
||||||
|
chart = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
const categories = ['优秀', '良好', '合格']
|
||||||
|
const counts = [Number(props.excellent), Number(props.good), Number(props.pass)]
|
||||||
|
const rates = [Number(props.excellentRate), Number(props.goodRate), Number(props.passRate)]
|
||||||
|
const colors = ['#1E40AF', '#3B82F6', '#93C5FD']
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const i = params[0].dataIndex
|
||||||
|
return `<b>${categories[i]}</b><br/>数量: ${counts[i]}<br/>占比: ${rates[i]}%`
|
||||||
|
},
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
textStyle: { color: '#0F172A' },
|
||||||
|
},
|
||||||
|
grid: { left: '4%', right: '6%', bottom: '10%', top: '16%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: categories,
|
||||||
|
axisLabel: { color: '#475569', fontSize: 13, fontWeight: 500 },
|
||||||
|
axisLine: { lineStyle: { color: '#E2E8F0' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '数量',
|
||||||
|
nameTextStyle: { color: '#94A3B8', fontSize: 12, padding: [0, 0, 0, -20] },
|
||||||
|
axisLabel: { color: '#94A3B8' },
|
||||||
|
splitLine: { lineStyle: { color: '#F1F5F9', type: 'dashed' } },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '占比',
|
||||||
|
nameTextStyle: { color: '#94A3B8', fontSize: 12 },
|
||||||
|
axisLabel: { color: '#94A3B8', formatter: '{value}%' },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: '38%',
|
||||||
|
label: { show: true, position: 'top', color: '#475569', fontWeight: 600, fontSize: 13 },
|
||||||
|
data: counts.map((v, i) => ({
|
||||||
|
value: v,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: colors[i] },
|
||||||
|
{ offset: 1, color: echarts.color.modifyAlpha(colors[i], 0.3) },
|
||||||
|
]),
|
||||||
|
borderRadius: [6, 6, 0, 0],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
animationDuration: 800,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: rates,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { color: '#F59E0B', width: 2.5 },
|
||||||
|
itemStyle: { color: '#F59E0B', borderWidth: 2, borderColor: '#fff' },
|
||||||
|
label: { show: true, position: 'top', formatter: '{c}%', color: '#D97706', fontSize: 12, fontWeight: 600 },
|
||||||
|
animationDuration: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.excellent, props.good, props.pass, props.excellentRate, props.goodRate, props.passRate],
|
||||||
|
render,
|
||||||
|
)
|
||||||
|
onMounted(render)
|
||||||
|
onBeforeUnmount(() => { chart?.dispose(); chart = null })
|
||||||
|
|
||||||
|
const handleResize = () => chart?.resize()
|
||||||
|
defineExpose({ handleResize })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="chart-box"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-box { width: 100%; height: 100%; min-height: 280px; }
|
||||||
|
</style>
|
||||||
75
src/components/HomeworkPieChart.vue
Normal file
75
src/components/HomeworkPieChart.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
total: number
|
||||||
|
submitted: number
|
||||||
|
unsubmitted: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
chart?.dispose()
|
||||||
|
chart = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
const total = props.total || (Number(props.submitted) + Number(props.unsubmitted))
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c}份 ({d}%)' },
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
right: '4%',
|
||||||
|
top: 'center',
|
||||||
|
textStyle: { color: '#475569', fontSize: 13 },
|
||||||
|
itemWidth: 12,
|
||||||
|
itemHeight: 12,
|
||||||
|
itemGap: 18,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['50%', '76%'],
|
||||||
|
center: ['36%', '50%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
padAngle: 3,
|
||||||
|
itemStyle: { borderRadius: 8 },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'center',
|
||||||
|
formatter: () => `{t|${total.toLocaleString()}}\n{s|作业总份数}`,
|
||||||
|
rich: {
|
||||||
|
t: { fontSize: 32, fontWeight: 700, color: '#0F172A', lineHeight: 42, fontFamily: 'Fira Code, monospace' },
|
||||||
|
s: { fontSize: 13, color: '#94A3B8', lineHeight: 22 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{ value: Number(props.submitted), name: '已提交', itemStyle: { color: '#3B82F6' } },
|
||||||
|
{ value: Number(props.unsubmitted), name: '未提交', itemStyle: { color: '#F59E0B' } },
|
||||||
|
],
|
||||||
|
animationType: 'scale',
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
animationDuration: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.total, props.submitted, props.unsubmitted], render)
|
||||||
|
onMounted(render)
|
||||||
|
onBeforeUnmount(() => { chart?.dispose(); chart = null })
|
||||||
|
|
||||||
|
const handleResize = () => chart?.resize()
|
||||||
|
defineExpose({ handleResize })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="chart-box"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-box { width: 100%; height: 100%; min-height: 280px; }
|
||||||
|
</style>
|
||||||
84
src/components/ParticipantStats.vue
Normal file
84
src/components/ParticipantStats.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
teacherCount: number
|
||||||
|
studentCount: number
|
||||||
|
classCount: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
chart?.dispose()
|
||||||
|
chart = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ name: '参与教师', value: Number(props.teacherCount), color: '#1E40AF' },
|
||||||
|
{ name: '参与学生', value: Number(props.studentCount), color: '#3B82F6' },
|
||||||
|
{ name: '参与班级', value: Number(props.classCount), color: '#F59E0B' },
|
||||||
|
]
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
textStyle: { color: '#0F172A' },
|
||||||
|
},
|
||||||
|
grid: { left: '8%', right: '8%', bottom: '10%', top: '10%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: items.map(d => d.name),
|
||||||
|
axisLabel: { color: '#475569', fontSize: 13, fontWeight: 500 },
|
||||||
|
axisLine: { lineStyle: { color: '#E2E8F0' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { color: '#94A3B8' },
|
||||||
|
splitLine: { lineStyle: { color: '#F1F5F9', type: 'dashed' } },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: '44%',
|
||||||
|
label: { show: true, position: 'top', fontSize: 15, fontWeight: 700, color: '#0F172A', fontFamily: 'Fira Code, monospace' },
|
||||||
|
data: items.map(d => ({
|
||||||
|
value: d.value,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: d.color },
|
||||||
|
{ offset: 1, color: echarts.color.modifyAlpha(d.color, 0.25) },
|
||||||
|
]),
|
||||||
|
borderRadius: [8, 8, 0, 0],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
animationDuration: 800,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.teacherCount, props.studentCount, props.classCount], render)
|
||||||
|
onMounted(render)
|
||||||
|
onBeforeUnmount(() => { chart?.dispose(); chart = null })
|
||||||
|
|
||||||
|
const handleResize = () => chart?.resize()
|
||||||
|
defineExpose({ handleResize })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="chart-box"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-box { width: 100%; height: 100%; min-height: 260px; }
|
||||||
|
</style>
|
||||||
117
src/components/StatCard.vue
Normal file
117
src/components/StatCard.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
value: number | string
|
||||||
|
suffix?: string
|
||||||
|
iconPath: string
|
||||||
|
color?: string
|
||||||
|
trend?: 'up' | 'down' | ''
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
suffix: '',
|
||||||
|
color: '#3B82F6',
|
||||||
|
trend: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
const v = props.value
|
||||||
|
if (typeof v === 'string') return v
|
||||||
|
if (String(v).includes('.')) return Number(v).toFixed(2)
|
||||||
|
return v.toLocaleString('zh-CN')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stat-card" :style="{ '--c': color }">
|
||||||
|
<div class="stat-card__top">
|
||||||
|
<span class="stat-card__title">{{ title }}</span>
|
||||||
|
<div class="stat-card__icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path :d="iconPath" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card__value">
|
||||||
|
{{ displayValue }}<span v-if="suffix" class="stat-card__suffix">{{ suffix }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 22px 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition);
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--c);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
|
||||||
|
&::after { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--c) 10%, transparent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--c);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.15;
|
||||||
|
font-family: 'Fira Code', 'DIN Alternate', monospace;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__suffix {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 2px;
|
||||||
|
font-family: 'Fira Sans', sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
21
src/main.ts
Normal file
21
src/main.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles/global.scss'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
31
src/router/index.ts
Normal file
31
src/router/index.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/Login.vue'),
|
||||||
|
meta: { isPublic: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/Dashboard.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!to.meta.isPublic && !token) {
|
||||||
|
return next('/login')
|
||||||
|
}
|
||||||
|
if (to.path === '/login' && token) {
|
||||||
|
return next('/')
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
68
src/stores/user.ts
Normal file
68
src/stores/user.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { loginApi, getUserInfoApi, logoutApi, type LoginParams, type UserInfo } from '@/api/login'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const token = ref(localStorage.getItem('token') || '')
|
||||||
|
const schoolId = ref(localStorage.getItem('schoolId') || '')
|
||||||
|
const schoolName = ref(localStorage.getItem('schoolName') || '')
|
||||||
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
function setToken(val: string) {
|
||||||
|
const bearerToken = val.startsWith('Bearer ') ? val : `Bearer ${val}`
|
||||||
|
token.value = bearerToken
|
||||||
|
localStorage.setItem('token', bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSchoolInfo(id: string, name: string) {
|
||||||
|
schoolId.value = id
|
||||||
|
schoolName.value = name
|
||||||
|
localStorage.setItem('schoolId', id)
|
||||||
|
localStorage.setItem('schoolName', name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(params: LoginParams) {
|
||||||
|
const tokenStr = await loginApi(params)
|
||||||
|
setToken(tokenStr)
|
||||||
|
await fetchUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
const info = await getUserInfoApi()
|
||||||
|
userInfo.value = info
|
||||||
|
|
||||||
|
let sid = info.schoolId || ''
|
||||||
|
let sname = info.schoolName || ''
|
||||||
|
|
||||||
|
if (info.schoolClass?.length) {
|
||||||
|
const school = info.schoolClass[0]
|
||||||
|
sid = sid || school.schoolId
|
||||||
|
sname = sname || school.schoolName
|
||||||
|
}
|
||||||
|
|
||||||
|
setSchoolInfo(sid, sname)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try { await logoutApi() } catch { /* ignore */ }
|
||||||
|
token.value = ''
|
||||||
|
schoolId.value = ''
|
||||||
|
schoolName.value = ''
|
||||||
|
userInfo.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('schoolId')
|
||||||
|
localStorage.removeItem('schoolName')
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持从 URL 参数直接设置鉴权信息
|
||||||
|
function setAuth(data: { token: string; schoolId: string; schoolName?: string }) {
|
||||||
|
setToken(data.token)
|
||||||
|
setSchoolInfo(data.schoolId, data.schoolName || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, schoolId, schoolName, userInfo, isLoggedIn, login, fetchUserInfo, logout, setAuth }
|
||||||
|
})
|
||||||
91
src/styles/global.scss
Normal file
91
src/styles/global.scss
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #1E40AF;
|
||||||
|
--primary-light: #3B82F6;
|
||||||
|
--primary-lighter: #DBEAFE;
|
||||||
|
--primary-dark: #1E3A8A;
|
||||||
|
--accent: #F59E0B;
|
||||||
|
--accent-light: #FCD34D;
|
||||||
|
--accent-dark: #D97706;
|
||||||
|
--bg: #F8FAFC;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--text-primary: #0F172A;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #94A3B8;
|
||||||
|
--border: #E2E8F0;
|
||||||
|
--success: #10B981;
|
||||||
|
--danger: #EF4444;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.04), 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 2px 4px rgba(15, 23, 42, 0.04);
|
||||||
|
--shadow-lg: 0 10px 32px rgba(15, 23, 42, 0.08), 0 4px 8px rgba(15, 23, 42, 0.04);
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Fira Sans', 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes countUp {
|
||||||
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #E2E8F0 25%, #F1F5F9 50%, #E2E8F0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/utils/format.ts
Normal file
27
src/utils/format.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 格式化数值为带千分位的字符串
|
||||||
|
*/
|
||||||
|
export function formatNumber(value: number | undefined | null): string {
|
||||||
|
if (value == null) return '0'
|
||||||
|
return value.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保留指定小数位
|
||||||
|
*/
|
||||||
|
export function toFixed(value: number | undefined | null, digits = 2): string {
|
||||||
|
if (value == null) return '0.00'
|
||||||
|
return Number(value).toFixed(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分钟数转为可读时间描述
|
||||||
|
*/
|
||||||
|
export function formatMinutes(minutes: number | undefined | null): string {
|
||||||
|
if (!minutes) return '0分钟'
|
||||||
|
if (minutes < 60) return `${minutes}分钟`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
if (mins === 0) return `${hours}小时`
|
||||||
|
return `${hours}小时${mins}分钟`
|
||||||
|
}
|
||||||
368
src/views/Dashboard.vue
Normal file
368
src/views/Dashboard.vue
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getAiHomeworkOverview, type AiHomeworkOverview } from '@/api/schoolBoard'
|
||||||
|
import { formatNumber, toFixed } from '@/utils/format'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import StatCard from '@/components/StatCard.vue'
|
||||||
|
import DateFilter from '@/components/DateFilter.vue'
|
||||||
|
import AiHeroBanner from '@/components/AiHeroBanner.vue'
|
||||||
|
import HomeworkPieChart from '@/components/HomeworkPieChart.vue'
|
||||||
|
import GradeBarChart from '@/components/GradeBarChart.vue'
|
||||||
|
import CompletionGauge from '@/components/CompletionGauge.vue'
|
||||||
|
import ParticipantStats from '@/components/ParticipantStats.vue'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const logoUrl = `${import.meta.env.VITE_OSS_URL}/urm/logo.png`
|
||||||
|
const loading = ref(false)
|
||||||
|
const startTime = ref('')
|
||||||
|
const endTime = ref('')
|
||||||
|
const data = ref<AiHomeworkOverview | null>(null)
|
||||||
|
const lastUpdate = ref('')
|
||||||
|
|
||||||
|
const pieRef = ref<InstanceType<typeof HomeworkPieChart> | null>(null)
|
||||||
|
const barRef = ref<InstanceType<typeof GradeBarChart> | null>(null)
|
||||||
|
const gaugeRef = ref<InstanceType<typeof CompletionGauge> | null>(null)
|
||||||
|
const participantRef = ref<InstanceType<typeof ParticipantStats> | null>(null)
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
if (!userStore.schoolId) {
|
||||||
|
ElMessage.warning('未获取到学校信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { schoolId: userStore.schoolId }
|
||||||
|
if (startTime.value) params.startTime = `${startTime.value} 00:00:00`
|
||||||
|
if (endTime.value) params.endTime = `${endTime.value} 23:59:59`
|
||||||
|
data.value = await getAiHomeworkOverview(params)
|
||||||
|
lastUpdate.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('获取看板数据失败', e)
|
||||||
|
ElMessage.error(e?.message || '数据加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCards = computed(() => {
|
||||||
|
if (!data.value) return []
|
||||||
|
const d = data.value
|
||||||
|
return [
|
||||||
|
{ title: '布置作业总份数', value: formatNumber(d.assignedHomeworkCount), icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: '#1E40AF' },
|
||||||
|
{ title: '提交作业总份数', value: formatNumber(d.submittedHomeworkCount), icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', color: '#3B82F6' },
|
||||||
|
{ title: '未提交作业数', value: formatNumber(d.unsubmittedHomeworkCount), icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z', color: '#F59E0B' },
|
||||||
|
{ title: '作业完成率', value: toFixed(d.completionRate), suffix: '%', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', color: '#10B981' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
userStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
pieRef.value?.handleResize()
|
||||||
|
barRef.value?.handleResize()
|
||||||
|
gaugeRef.value?.handleResize()
|
||||||
|
participantRef.value?.handleResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar__left">
|
||||||
|
<div class="topbar__logo">
|
||||||
|
<img :src="logoUrl" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
<div class="topbar__info">
|
||||||
|
<h1 class="topbar__title">{{ userStore.schoolName || '学校' }} · 数据看板</h1>
|
||||||
|
<span v-if="lastUpdate" class="topbar__time">数据更新于 {{ lastUpdate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__right">
|
||||||
|
<el-button text @click="fetchData" :loading="loading">
|
||||||
|
<svg v-if="!loading" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button text @click="handleLogout">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||||
|
退出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main class="content" v-loading="loading" element-loading-text="正在加载数据..." element-loading-background="rgba(248,250,252,0.8)">
|
||||||
|
<!-- 筛选区域 -->
|
||||||
|
<section class="section filter-section">
|
||||||
|
<DateFilter v-model:startTime="startTime" v-model:endTime="endTime" @search="fetchData" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-if="data">
|
||||||
|
<!-- AI 效能 Hero (最突出的位置) -->
|
||||||
|
<section class="section" style="animation: fadeInUp 0.4s ease both">
|
||||||
|
<AiHeroBanner
|
||||||
|
:ai-corrected-title-count="data.aiCorrectedTitleCount"
|
||||||
|
:teacher-time-saved-minutes="data.teacherTimeSavedMinutes"
|
||||||
|
:ai-accuracy-rate="data.aiAccuracyRate"
|
||||||
|
:student-appeal-rate="data.studentAppealRate"
|
||||||
|
:ai-avg-response-time="data.aiAvgResponseTime"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 核心指标卡片 -->
|
||||||
|
<section class="card-grid" style="animation: fadeInUp 0.5s ease 0.1s both">
|
||||||
|
<StatCard
|
||||||
|
v-for="(card, i) in summaryCards"
|
||||||
|
:key="card.title"
|
||||||
|
:title="card.title"
|
||||||
|
:value="card.value"
|
||||||
|
:suffix="card.suffix"
|
||||||
|
:icon-path="card.icon"
|
||||||
|
:color="card.color"
|
||||||
|
:style="{ animationDelay: `${i * 60}ms` }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 图表第一行:作业提交饼图 + 成绩分布 -->
|
||||||
|
<section class="chart-row" style="animation: fadeInUp 0.5s ease 0.2s both">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<h3 class="chart-card__title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"/><path d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"/></svg>
|
||||||
|
作业提交情况
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<HomeworkPieChart
|
||||||
|
ref="pieRef"
|
||||||
|
:total="data.assignedHomeworkCount"
|
||||||
|
:submitted="data.submittedHomeworkCount"
|
||||||
|
:unsubmitted="data.unsubmittedHomeworkCount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<h3 class="chart-card__title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||||
|
作业成绩分布
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<GradeBarChart
|
||||||
|
ref="barRef"
|
||||||
|
:excellent="data.excellentCount"
|
||||||
|
:good="data.goodCount"
|
||||||
|
:pass="data.passCount"
|
||||||
|
:excellent-rate="data.excellentRate"
|
||||||
|
:good-rate="data.goodRate"
|
||||||
|
:pass-rate="data.passRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 图表第二行:完成率仪表盘 + 参与统计 -->
|
||||||
|
<section class="chart-row" style="animation: fadeInUp 0.5s ease 0.3s both">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<h3 class="chart-card__title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||||
|
作业完成率
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<CompletionGauge ref="gaugeRef" :rate="data.completionRate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<h3 class="chart-card__title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||||
|
参与情况统计
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<ParticipantStats
|
||||||
|
ref="participantRef"
|
||||||
|
:teacher-count="data.participantTeacherCount"
|
||||||
|
:student-count="data.participantStudentCount"
|
||||||
|
:class-count="data.participantClassCount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<template v-else-if="loading">
|
||||||
|
<div class="skeleton-hero skeleton" style="height:240px;border-radius:var(--radius-lg);margin-bottom:20px"></div>
|
||||||
|
<div class="card-grid" style="margin-bottom:20px">
|
||||||
|
<div v-for="n in 4" :key="n" class="skeleton" style="height:100px;border-radius:var(--radius)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="skeleton" style="height:320px;border-radius:var(--radius)"></div>
|
||||||
|
<div class="skeleton" style="height:320px;border-radius:var(--radius)"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#CBD5E1" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/></svg>
|
||||||
|
<p>暂无数据,请调整筛选条件后重试</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 32px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--primary-lighter);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__right {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
@media (max-width: 1100px) { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
@media (max-width: 900px) { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
317
src/views/Login.vue
Normal file
317
src/views/Login.vue
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
const logoUrl = `${import.meta.env.VITE_OSS_URL}/urm/logo.png`
|
||||||
|
const form = ref({ account: '', password: '' })
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await userStore.login({ account: form.value.account, password: form.value.password })
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.replace('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '登录失败,请检查账号密码')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-bg">
|
||||||
|
<div class="login-bg__grid"></div>
|
||||||
|
<div class="login-bg__glow login-bg__glow--1"></div>
|
||||||
|
<div class="login-bg__glow login-bg__glow--2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<!-- 左侧品牌区 -->
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="login-brand__inner">
|
||||||
|
<div class="login-brand__icon">
|
||||||
|
<img :src="logoUrl" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
<h1 class="login-brand__title">学校数据看板</h1>
|
||||||
|
<p class="login-brand__desc">AI 作业批改数据分析平台</p>
|
||||||
|
|
||||||
|
<div class="login-brand__features">
|
||||||
|
<div class="feature-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||||
|
<span>作业完成率实时追踪</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"/><path d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"/></svg>
|
||||||
|
<span>成绩分布可视化分析</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/></svg>
|
||||||
|
<span>AI 批改效能一目了然</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧表单区 -->
|
||||||
|
<div class="login-form-wrap">
|
||||||
|
<div class="login-form-inner">
|
||||||
|
<h2 class="login-form__title">欢迎登录</h2>
|
||||||
|
<p class="login-form__subtitle">请使用教师账号登录查看学校数据</p>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
size="large"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="account">
|
||||||
|
<el-input v-model="form.account" placeholder="请输入账号" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input v-model="form.password" :type="showPassword ? 'text' : 'password'" placeholder="请输入密码">
|
||||||
|
<template #prefix>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<span class="pwd-toggle" @click="showPassword = !showPassword">
|
||||||
|
<svg v-if="showPassword" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94A3B8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<button type="button" class="login-btn" :disabled="loading" @click="handleLogin">
|
||||||
|
<svg v-if="loading" class="spin" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||||
|
{{ loading ? '登录中...' : '登 录' }}
|
||||||
|
</button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0F172A 0%, #1E3A8A 50%, #1E40AF 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__glow {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
|
||||||
|
&--1 { width: 500px; height: 500px; top: -150px; right: -80px; background: rgba(59,130,246,0.2); }
|
||||||
|
&--2 { width: 400px; height: 400px; bottom: -120px; left: -60px; background: rgba(245,158,11,0.12); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
width: 900px;
|
||||||
|
min-height: 500px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 25px 80px rgba(0,0,0,0.35);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
animation: fadeInUp 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 48px 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.08);
|
||||||
|
|
||||||
|
&__inner { width: 100%; }
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__features {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-wrap {
|
||||||
|
width: 400px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 48px 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-inner { width: 100%; }
|
||||||
|
|
||||||
|
.login-form__title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwd-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #1E40AF 0%, #3B82F6 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(30,64,175,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
box-shadow: 0 0 0 1px var(--border);
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover { box-shadow: 0 0 0 1px #93C5FD; }
|
||||||
|
&.is-focus { box-shadow: 0 0 0 2px #3B82F6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
tsconfig.app.json
Normal file
23
tsconfig.app.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/env.d.ts"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
18
tsconfig.node.json
Normal file
18
tsconfig.node.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
47
vite.config.ts
Normal file
47
vite.config.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
imports: ['vue', 'vue-router', 'pinia'],
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'src/auto-imports.d.ts',
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'src/components.d.ts',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 600,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
echarts: ['echarts'],
|
||||||
|
'element-plus': ['element-plus'],
|
||||||
|
vendor: ['vue', 'vue-router', 'pinia', 'axios'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3200,
|
||||||
|
proxy: {
|
||||||
|
'/api/main': {
|
||||||
|
target: 'http://43.136.52.196:9053',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user