‘add第一次提交’

This commit is contained in:
李梦 2026-02-25 08:55:10 +08:00
commit 45424bae7e
29 changed files with 5208 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_OSS_URL = https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com

6
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}

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

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

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

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

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

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

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

18
tsconfig.node.json Normal file
View 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
View 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,
},
},
},
})