feat(desktop-client): 阶段4-6 Electron EXE 客户端(登录/课表/作业/通知/成绩/设置 + Socket.IO 实时 + IndexedDB 离线缓存与自动同步 + electron-builder 打包)
This commit is contained in:
1 parent
4566d7cd5e
commit
6e4c7c1050
42 files changed
+5767
-60
No files matched your search
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
averageOf,
|
||||
formatDate,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
type GradeDto,
|
||||
} from '@classhelper/shared';
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const grades = ref<GradeDto[]>([]);
|
||||
const fromCache = ref(false);
|
||||
const updatedAt = ref<number | null>(null);
|
||||
const chartRef = ref<HTMLDivElement>();
|
||||
let chart: ReturnType<typeof echarts.init> | null = null;
|
||||
|
||||
const averagePercent = computed(() =>
|
||||
grades.value.length === 0
|
||||
? 0
|
||||
: averageOf(grades.value.map((item) => gradePercent(item.score, item.totalScore))),
|
||||
);
|
||||
|
||||
const bestGrade = computed(() => {
|
||||
if (grades.value.length === 0) return null;
|
||||
return grades.value.reduce((best, item) =>
|
||||
gradePercent(item.score, item.totalScore) > gradePercent(best.score, best.totalScore) ? item : best,
|
||||
);
|
||||
});
|
||||
|
||||
const latestGrades = computed(() =>
|
||||
[...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5),
|
||||
);
|
||||
|
||||
const levelCounts = computed(() => {
|
||||
const levels: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, E: 0 };
|
||||
for (const item of grades.value) {
|
||||
levels[gradeLevel(gradePercent(item.score, item.totalScore))] =
|
||||
(levels[gradeLevel(gradePercent(item.score, item.totalScore))] ?? 0) + 1;
|
||||
}
|
||||
return levels;
|
||||
});
|
||||
|
||||
async function loadGrades(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await fetchWithCache<GradeDto[]>('grades', 'my', () => gradeApi.my(), []);
|
||||
grades.value = result.data;
|
||||
fromCache.value = result.fromCache;
|
||||
updatedAt.value = result.updatedAt;
|
||||
if (!result.fromCache) appStore.markSynced();
|
||||
renderChart();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(): void {
|
||||
if (!chartRef.value) return;
|
||||
chart ??= echarts.init(chartRef.value);
|
||||
|
||||
// 按考试聚合平均得分率(最近 8 次考试)
|
||||
const byExam = new Map<string, number[]>();
|
||||
for (const item of grades.value) {
|
||||
const bucket = byExam.get(item.examName) ?? [];
|
||||
bucket.push(gradePercent(item.score, item.totalScore));
|
||||
byExam.set(item.examName, bucket);
|
||||
}
|
||||
const exams = [...byExam.entries()].slice(-8);
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 44, right: 18, top: 30, bottom: 34 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: exams.map(([name]) => name),
|
||||
axisLabel: { interval: 0, rotate: exams.length > 4 ? 20 : 0, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', max: 100, name: '得分率%' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barMaxWidth: 46,
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 11 },
|
||||
data: exams.map(([, percents]) => averageOf(percents)),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleResize(): void {
|
||||
chart?.resize();
|
||||
}
|
||||
|
||||
function onGradeEvent(): void {
|
||||
void loadGrades();
|
||||
}
|
||||
|
||||
function onRecovered(): void {
|
||||
void loadGrades();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadGrades();
|
||||
window.addEventListener('resize', handleResize);
|
||||
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
appStore.onServerRecovered(onRecovered);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
appStore.offServerRecovered(onRecovered);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">我的成绩</h2>
|
||||
<p class="page-subtitle">
|
||||
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
|
||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">平均得分率</div>
|
||||
<div class="stat-value">{{ averagePercent }}%</div>
|
||||
<div class="stat-hint">共 {{ grades.length }} 次记录</div>
|
||||
</div>
|
||||
<div class="stat-card mt-12">
|
||||
<div class="stat-label">最好成绩</div>
|
||||
<div class="stat-value">
|
||||
{{ bestGrade ? `${bestGrade.score}/${bestGrade.totalScore}` : '-' }}
|
||||
</div>
|
||||
<div class="stat-hint">{{ bestGrade?.examName ?? '暂无数据' }}</div>
|
||||
</div>
|
||||
<div class="stat-card mt-12">
|
||||
<div class="stat-label">等级分布</div>
|
||||
<div class="level-list">
|
||||
<el-tag v-for="(count, level) in levelCounts" :key="level" effect="plain">
|
||||
{{ level }} 等:{{ count }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="16">
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<template #header><span>各次考试平均得分率</span></template>
|
||||
<div ref="chartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" class="mt-16">
|
||||
<template #header><span>成绩明细</span></template>
|
||||
<el-table :data="grades" empty-text="暂无成绩" max-height="420">
|
||||
<el-table-column prop="examName" label="考试" min-width="150" />
|
||||
<el-table-column label="课程" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分数" width="130">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="得分率" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="gradePercent(row.score, row.totalScore)"
|
||||
:stroke-width="10"
|
||||
:status="gradePercent(row.score, row.totalScore) >= 60 ? 'success' : 'exception'"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="latestGrades.length > 0" shadow="never" class="mt-16">
|
||||
<template #header><span>最近更新</span></template>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="item in latestGrades"
|
||||
:key="item.id"
|
||||
:timestamp="formatDate(item.publishedAt, true)"
|
||||
placement="top"
|
||||
>
|
||||
{{ item.examName }} · {{ item.course?.name ?? '未关联课程' }}:
|
||||
<strong>{{ item.score }}/{{ item.totalScore }}</strong>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.level-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { SOCKET_EVENTS, formatDate, formatDeadline, isOverdue, type HomeworkDto } from '@classhelper/shared';
|
||||
import { homeworkApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const homeworks = ref<HomeworkDto[]>([]);
|
||||
const filter = ref<'all' | 'pending' | 'done'>('all');
|
||||
const fromCache = ref(false);
|
||||
const updatedAt = ref<number | null>(null);
|
||||
const detailVisible = ref(false);
|
||||
const current = ref<HomeworkDto | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
|
||||
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
|
||||
return homeworks.value;
|
||||
});
|
||||
|
||||
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
|
||||
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
|
||||
|
||||
async function loadHomework(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const classId = auth.classId ?? undefined;
|
||||
const result = await fetchWithCache<HomeworkDto[]>(
|
||||
'homeworks',
|
||||
'self',
|
||||
() => homeworkApi.list(classId ? { classId } : undefined),
|
||||
[],
|
||||
);
|
||||
homeworks.value = result.data;
|
||||
fromCache.value = result.fromCache;
|
||||
updatedAt.value = result.updatedAt;
|
||||
if (!result.fromCache) appStore.markSynced();
|
||||
if (current.value) {
|
||||
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(item: HomeworkDto): void {
|
||||
current.value = item;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
||||
if (!item) return;
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const next = item.completed !== true;
|
||||
await homeworkApi.updateStatus(item.id, next);
|
||||
item.completed = next;
|
||||
if (item.homeworkStatus) item.homeworkStatus.completed = next;
|
||||
else
|
||||
item.homeworkStatus = {
|
||||
id: `local-${item.id}`,
|
||||
homeworkId: item.id,
|
||||
userId: auth.user?.id ?? '',
|
||||
completed: next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onHomeworkEvent(): void {
|
||||
void loadHomework();
|
||||
}
|
||||
|
||||
/** 附件链接交给系统浏览器打开(模板里无法直接访问 window) */
|
||||
function openAttachment(url: string | null | undefined): void {
|
||||
if (!url) return;
|
||||
if (window.desktop) void window.desktop.openExternal(url);
|
||||
else window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function onRecovered(): void {
|
||||
void loadHomework();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHomework();
|
||||
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
||||
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
||||
appStore.onServerRecovered(onRecovered);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
||||
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
||||
appStore.offServerRecovered(onRecovered);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">我的作业</h2>
|
||||
<p class="page-subtitle">
|
||||
共 {{ homeworks.length }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
|
||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-radio-group v-model="filter">
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
<el-radio-button value="pending">未完成</el-radio-button>
|
||||
<el-radio-button value="done">已完成</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<el-empty v-if="filtered.length === 0" description="没有符合条件的作业" />
|
||||
<el-table v-else :data="filtered" @row-click="openDetail">
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.completed ? 'success' : isOverdue(row.dueAt) ? 'danger' : 'info'"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.completed ? '已完成' : isOverdue(row.dueAt) ? '已逾期' : '待完成' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="作业标题" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="课程" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-danger': isOverdue(row.dueAt) && !row.completed }">
|
||||
{{ row.completed ? '—' : formatDeadline(row.dueAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
:type="row.completed ? 'warning' : 'primary'"
|
||||
:loading="submitting"
|
||||
@click.stop="toggleComplete(row)"
|
||||
>
|
||||
{{ row.completed ? '取消完成' : '标记完成' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="截止时间">
|
||||
{{ formatDate(current.dueAt, true) || '不限' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">
|
||||
{{ formatDate(current.createdAt, true) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="我的状态" :span="2">
|
||||
<el-tag :type="current.completed ? 'success' : 'info'" size="small">
|
||||
{{ current.completed ? '已完成' : '未完成' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="mt-16">
|
||||
<h4>作业要求</h4>
|
||||
<p class="content-block">{{ current.content }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="current.attachmentUrl" class="mt-12">
|
||||
<el-button link type="primary" @click="openAttachment(current.attachmentUrl)">
|
||||
打开附件链接
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-16">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="appStore.offline"
|
||||
@click="toggleComplete(current)"
|
||||
>
|
||||
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
|
||||
</el-button>
|
||||
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' });
|
||||
const testing = ref(false);
|
||||
const testResult = ref<'ok' | 'fail' | null>(null);
|
||||
|
||||
const rules: FormRules = {
|
||||
serverUrl: [
|
||||
{
|
||||
validator: (_rule, value: string, callback: (error?: Error) => void) => {
|
||||
if (!value?.trim()) callback(new Error('请输入服务器地址'));
|
||||
else if (!normalizeServerUrl(value)) callback(new Error('地址格式应为 http://主机:端口'));
|
||||
else callback();
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// 主进程已保存过服务器地址/上次用户名则预填
|
||||
if (window.desktop) {
|
||||
const config = await window.desktop.getConfig();
|
||||
if (config.serverUrl) form.serverUrl = config.serverUrl;
|
||||
if (config.username) form.username = config.username;
|
||||
}
|
||||
});
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
const normalized = normalizeServerUrl(form.serverUrl);
|
||||
if (!normalized) {
|
||||
ElMessage.warning('请先填写正确的服务器地址');
|
||||
return;
|
||||
}
|
||||
testing.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
setApiBaseUrl(normalized);
|
||||
const result = await pingHealth();
|
||||
testResult.value = result.ok ? 'ok' : 'fail';
|
||||
if (result.ok) ElMessage.success('服务器连接正常');
|
||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
try {
|
||||
await auth.login(form.serverUrl, form.username.trim(), form.password);
|
||||
await appStore.init(form.serverUrl);
|
||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||
ElMessage.success(`欢迎回来,${auth.displayName}`);
|
||||
await router.replace('/schedule');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 已保存登录态但服务器不可达时,允许用缓存数据离线进入 */
|
||||
async function enterOffline(): Promise<void> {
|
||||
if (!auth.isAuthenticated) return;
|
||||
await appStore.init(appStore.serverUrl);
|
||||
ElMessage.warning('已进入离线模式,仅显示上次同步的数据');
|
||||
await router.replace('/schedule');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-head">
|
||||
<el-icon :size="30" color="#409eff"><School /></el-icon>
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="服务器地址" prop="serverUrl">
|
||||
<el-input v-model="form.serverUrl" placeholder="http://127.0.0.1:4000" clearable>
|
||||
<template #prefix>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-actions">
|
||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="testResult === 'ok'"
|
||||
class="mt-12"
|
||||
type="success"
|
||||
:closable="false"
|
||||
title="服务器可达,可以登录"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="testResult === 'fail'"
|
||||
class="mt-12"
|
||||
type="error"
|
||||
:closable="false"
|
||||
title="无法连接服务器"
|
||||
description="请确认后端已启动(pnpm dev:server)、地址与端口正确、防火墙未拦截。"
|
||||
/>
|
||||
</el-form>
|
||||
|
||||
<el-divider v-if="auth.isAuthenticated">或</el-divider>
|
||||
<el-button v-if="auth.isAuthenticated" class="offline-button" @click="enterOffline">
|
||||
<el-icon><Connection /></el-icon>
|
||||
离线进入(使用本地缓存数据)
|
||||
</el-button>
|
||||
|
||||
<p class="login-tip">
|
||||
演示账号:student01 / student123(高一(1)班)
|
||||
<br />
|
||||
数据来源:教师或管理员在 Web 管理端发布的内容。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 55%, #409eff 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 28px 26px 20px;
|
||||
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
|
||||
}
|
||||
|
||||
.login-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.offline-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_TAG_TYPES,
|
||||
formatDate,
|
||||
relativeTime,
|
||||
type NotificationDto,
|
||||
type NotificationPriority,
|
||||
} from '@classhelper/shared';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const store = useNotificationStore();
|
||||
|
||||
const filter = ref<'all' | 'unread'>('all');
|
||||
const detailVisible = ref(false);
|
||||
const current = ref<NotificationDto | null>(null);
|
||||
const marking = ref(false);
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
|
||||
);
|
||||
|
||||
async function openDetail(item: NotificationDto): Promise<void> {
|
||||
current.value = item;
|
||||
detailVisible.value = true;
|
||||
if (!item.read) await markRead(item);
|
||||
}
|
||||
|
||||
async function markRead(item: NotificationDto): Promise<void> {
|
||||
if (appStore.offline || item.read) return;
|
||||
marking.value = true;
|
||||
try {
|
||||
await store.markRead(item.id);
|
||||
if (current.value?.id === item.id) current.value = { ...item, read: true };
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '标记已读失败');
|
||||
} finally {
|
||||
marking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markAll(): Promise<void> {
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('离线状态下无法同步已读状态');
|
||||
return;
|
||||
}
|
||||
marking.value = true;
|
||||
try {
|
||||
const marked = await store.markAllRead();
|
||||
ElMessage.success(`已将 ${marked} 条通知标为已读`);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败');
|
||||
} finally {
|
||||
marking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.load().catch(() => undefined);
|
||||
await store.refreshUnreadCount();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// store 是全局的,这里无需清理;保留钩子以便将来取消订阅
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">通知</h2>
|
||||
<p class="page-subtitle">
|
||||
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
|
||||
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-radio-group v-model="filter">
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
<el-radio-button value="unread">未读</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :loading="marking" :disabled="store.unreadCount === 0" @click="markAll">
|
||||
全部已读
|
||||
</el-button>
|
||||
<el-button :icon="'Refresh'" @click="store.load()">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="store.loading" shadow="never">
|
||||
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
|
||||
<ul v-else class="notice-list">
|
||||
<li
|
||||
v-for="item in visibleItems"
|
||||
:key="item.id"
|
||||
class="notice-item"
|
||||
:class="{ unread: !item.read }"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="notice-main">
|
||||
<div class="notice-title">
|
||||
<span v-if="!item.read" class="unread-dot"></span>
|
||||
<el-tag
|
||||
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
|
||||
</el-tag>
|
||||
<span class="title-text">{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="notice-content">{{ item.content }}</div>
|
||||
</div>
|
||||
<div class="notice-meta">
|
||||
<div>{{ relativeTime(item.createdAt) }}</div>
|
||||
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
|
||||
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
|
||||
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
|
||||
<template v-if="current">
|
||||
<div class="drawer-meta">
|
||||
<el-tag :type="PRIORITY_TAG_TYPES[current.priority as NotificationPriority]" size="small">
|
||||
{{ PRIORITY_LABELS[current.priority as NotificationPriority] }}
|
||||
</el-tag>
|
||||
<span class="text-muted">{{ formatDate(current.createdAt, true) }}</span>
|
||||
<span class="text-muted">发布人:{{ current.creator?.name ?? '-' }}</span>
|
||||
</div>
|
||||
<p class="content-block">{{ current.content }}</p>
|
||||
<el-alert
|
||||
v-if="!current.read && appStore.offline"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="离线状态:已读状态将在恢复网络后同步"
|
||||
/>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notice-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 6px;
|
||||
border-bottom: 1px dashed var(--ch-border);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.notice-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.notice-item.unread {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.notice-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notice-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
WEEKDAYS,
|
||||
WEEKDAY_LABELS,
|
||||
buildWeekOptions,
|
||||
formatWeekRange,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
} from '@classhelper/shared';
|
||||
import { dashboardApi, scheduleApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const grid = ref<ScheduleWeekView | null>(null);
|
||||
const items = ref<ScheduleDto[]>([]);
|
||||
const week = ref(appStore.currentWeek);
|
||||
const fromCache = ref(false);
|
||||
const updatedAt = ref<number | null>(null);
|
||||
const weekOptions = ref<number[]>(buildWeekOptions(20));
|
||||
|
||||
const periods = computed(() => {
|
||||
const buckets = new Map<string, ScheduleDto[]>();
|
||||
for (const item of items.value) {
|
||||
const key = `${item.startTime}-${item.endTime}`;
|
||||
const bucket = buckets.get(key) ?? [];
|
||||
bucket.push(item);
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, list]) => {
|
||||
const [start, end] = key.split('-');
|
||||
return { start: start ?? '', end: end ?? '', items: list };
|
||||
});
|
||||
});
|
||||
|
||||
function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): ScheduleDto[] {
|
||||
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
|
||||
}
|
||||
|
||||
const todayWeekday = computed(() => {
|
||||
const day = new Date().getDay();
|
||||
return day === 0 ? 7 : day;
|
||||
});
|
||||
|
||||
const courseCount = computed(() => new Set(items.value.map((item) => item.courseId)).size);
|
||||
|
||||
async function loadTerm(): Promise<void> {
|
||||
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
|
||||
currentWeek: 1,
|
||||
maxWeek: 20,
|
||||
});
|
||||
appStore.currentWeek = result.data.currentWeek;
|
||||
appStore.maxWeek = result.data.maxWeek;
|
||||
weekOptions.value = buildWeekOptions(result.data.maxWeek);
|
||||
if (!week.value || week.value === 1) week.value = result.data.currentWeek;
|
||||
}
|
||||
|
||||
async function loadSchedules(): Promise<void> {
|
||||
const classId = auth.classId ?? undefined;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await fetchWithCache<ScheduleDto[]>(
|
||||
'schedules',
|
||||
`week:${week.value}`,
|
||||
() => scheduleApi.list({ classId, week: week.value }),
|
||||
[],
|
||||
);
|
||||
items.value = result.data;
|
||||
fromCache.value = result.fromCache;
|
||||
updatedAt.value = result.updatedAt;
|
||||
grid.value = {
|
||||
week: week.value,
|
||||
columns: WEEKDAYS.map((dayOfWeek) => ({
|
||||
dayOfWeek,
|
||||
label: WEEKDAY_LABELS[dayOfWeek] ?? '',
|
||||
items: result.data.filter((item) => item.dayOfWeek === dayOfWeek),
|
||||
})),
|
||||
};
|
||||
if (!result.fromCache) appStore.markSynced();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeWeek(delta: number): Promise<void> {
|
||||
const next = week.value + delta;
|
||||
if (next < 1 || next > appStore.maxWeek) return;
|
||||
week.value = next;
|
||||
await loadSchedules();
|
||||
}
|
||||
|
||||
function onScheduleEvent(): void {
|
||||
void loadSchedules();
|
||||
}
|
||||
|
||||
function onRecovered(): void {
|
||||
void loadSchedules();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTerm();
|
||||
await loadSchedules();
|
||||
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
||||
appStore.onServerRecovered(onRecovered);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
||||
appStore.offServerRecovered(onRecovered);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">我的课表</h2>
|
||||
<p class="page-subtitle">
|
||||
第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程
|
||||
<el-tag v-if="fromCache" class="mt-12" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)">上一周</el-button>
|
||||
<el-select v-model="week" style="width: 120px" @change="loadSchedules">
|
||||
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
|
||||
</el-select>
|
||||
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
|
||||
下一周
|
||||
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
|
||||
<el-table v-else :data="periods" border size="small" :cell-class-name="() => ''">
|
||||
<el-table-column label="节次" width="110" fixed>
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.start }}</div>
|
||||
<div class="timetable-meta">{{ row.end }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="day in WEEKDAYS"
|
||||
:key="day"
|
||||
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
|
||||
min-width="132"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
|
||||
<div class="timetable-course">{{ item.course?.name ?? '课程' }}</div>
|
||||
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
|
||||
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { formatDate } from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { normalizeServerUrl } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
|
||||
const storeLabels: Record<string, string> = {
|
||||
profile: '登录资料',
|
||||
classes: '班级 / 学期',
|
||||
schedules: '课表',
|
||||
homeworks: '作业',
|
||||
notifications: '通知',
|
||||
grades: '成绩',
|
||||
};
|
||||
|
||||
const cacheRows = computed(() =>
|
||||
appStore.cacheStoreStats.map((item) => ({
|
||||
label: storeLabels[item.store] ?? item.store,
|
||||
store: item.store,
|
||||
count: item.count,
|
||||
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadAppInfo(): Promise<void> {
|
||||
if (!window.desktop) return;
|
||||
appInfo.value = await window.desktop.getAppInfo();
|
||||
}
|
||||
|
||||
async function saveServer(): Promise<void> {
|
||||
const normalized = normalizeServerUrl(serverInput.value);
|
||||
if (!normalized) {
|
||||
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await appStore.applyServerUrl(normalized);
|
||||
serverInput.value = normalized;
|
||||
ElMessage.success('服务器地址已保存');
|
||||
await testConnection();
|
||||
if (auth.token) realtime.connect(normalized, auth.token);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await pingHealth();
|
||||
if (result.ok) ElMessage.success('服务器连接正常');
|
||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync(): Promise<void> {
|
||||
setApiBaseUrl(appStore.serverUrl);
|
||||
await appStore.ping();
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
||||
return;
|
||||
}
|
||||
await notifications.load();
|
||||
appStore.markSynced();
|
||||
ElMessage.success('已同步最新数据');
|
||||
}
|
||||
|
||||
async function clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||
realtime.disconnect();
|
||||
await auth.logout();
|
||||
ElMessage.success('已退出登录');
|
||||
await router.replace('/login');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAppInfo();
|
||||
await appStore.refreshCacheStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">设置</h2>
|
||||
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>服务器连接</span></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="服务器地址">
|
||||
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
||||
<template #prefix>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||
<el-button @click="manualSync">立即同步</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions class="mt-16" :column="1" border size="small">
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
||||
{{ realtime.statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ auth.user?.username ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||
<el-table-column prop="label" label="数据类型" width="130" />
|
||||
<el-table-column label="条目数" width="90">
|
||||
<template #default="{ row }">{{ row.count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近更新" min-width="150">
|
||||
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="离线可用"
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>客户端信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user