feat(homeworks,island,classisland,desktop,web,server): 作业按天查看与教室机器录入 + 提醒上课时段暂存规则 + 灵动岛边距/跟随鼠标屏幕
- 作业:新增 Homework.assignDate(本地日期 YYYY-MM-DD;UTC 切片在晚上会把今天的作业算成明天),列表按天过滤与排序,新增 GET /homeworks?date= 与 GET /homeworks/days(日期高亮)供客户端与 Web 共用;客户端作业页改为"看作业 + 录作业"(不再显示完成状态),支持快捷短语(存本机配置,「设置 → 作业录入」可增删/恢复默认) - 教室机器录入:POST /homeworks 去掉 requireRole,权限下沉到 homeworks.service(staff 或本班班级账号);班级账号的 sub 是班级 id,写 createdBy 前换成该班班主任,避免撞 User 外键 - 提醒类型:ClassIslandPush.kind(notification / call)。通知类上课时段暂存、下课补弹,叫人/紧急立即弹;客户端灵动岛与 ClassIsland 插件共用同一套规则 - 灵动岛:新增 marginX / marginY(默认仍 8px,不动老外观)与「跟随鼠标屏幕」;文案统一成"灵动岛 / ClassIsland" - 设置页:新增「ClassIsland 联动」(联动状态 + 通知模式)与「作业录入」分区 - 校验:verify:e2e 增补 assignDate 默认当天 / ?date= 过滤 / days / 非法日期 422 / 班级账号录入 201 与跨班 403;verify:classisland 与插件静态契约增补上课时段暂存断言(57/57) - 文档:README 与 AGENTS.md 同步(作业按天、上课时段策略、两个方向的开关表、新增踩坑) 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 57/57 通过。 未跑:verify:e2e / verify:classisland(需后端启动)、verify:web / verify:desktop(需先构建产物)。
This commit is contained in:
1 parent
d0830f6830
commit
7536abfc95
33 files changed
+1236
-162
No files matched your search
@@ -3,6 +3,8 @@ import {
|
||||
type ClassDetailDto,
|
||||
type ClassDto,
|
||||
type ClassIslandNotificationChannel,
|
||||
type CourseDto,
|
||||
type HomeworkDaysDto,
|
||||
type DashboardSummary,
|
||||
type GradeDto,
|
||||
type HomeworkDto,
|
||||
@@ -49,6 +51,20 @@ export const classChannelApi = {
|
||||
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
|
||||
};
|
||||
|
||||
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
|
||||
export const classIslandStatusApi = {
|
||||
get: (
|
||||
classId: string,
|
||||
): Promise<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
lastSeenAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
|
||||
};
|
||||
|
||||
export const scheduleApi = {
|
||||
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
||||
api.get(API_PATHS.schedules, params),
|
||||
@@ -56,9 +72,30 @@ export const scheduleApi = {
|
||||
api.get(`${API_PATHS.schedules}/grid`, params),
|
||||
};
|
||||
|
||||
export const courseApi = {
|
||||
list: (classId?: string): Promise<CourseDto[]> =>
|
||||
api.get(API_PATHS.courses, classId ? { classId } : undefined),
|
||||
};
|
||||
|
||||
export const homeworkApi = {
|
||||
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
|
||||
api.get(API_PATHS.homeworks, params),
|
||||
list: (params?: {
|
||||
classId?: string;
|
||||
pendingOnly?: boolean;
|
||||
courseId?: string;
|
||||
/** 只看某一天(YYYY-MM-DD,按作业所属日期) */
|
||||
date?: string;
|
||||
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
|
||||
/** 哪些天有作业(日期选择器高亮) */
|
||||
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
|
||||
api.get(`${API_PATHS.homeworks}/days`, params),
|
||||
/** 在教室机器上直接录入作业(班级账号可调用;也可选所属日期) */
|
||||
create: (payload: {
|
||||
classId: string;
|
||||
courseId?: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
assignDate?: string | null;
|
||||
}): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
||||
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
||||
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
||||
|
||||
+12
-3
@@ -611,10 +611,19 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
|
||||
boardTab.click();
|
||||
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
|
||||
|
||||
// 今天可能恰好没有作业:先关掉「只看今天」,保证看板一定有卡片(断言才有意义)
|
||||
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
|
||||
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
|
||||
if (!document.querySelector('.board-host')) {
|
||||
const todaySwitch = document.querySelector('.page-header .el-switch');
|
||||
if (todaySwitch instanceof HTMLElement) todaySwitch.click();
|
||||
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
|
||||
import('../api/index.js'),
|
||||
import('../stores/auth.js'),
|
||||
]);
|
||||
const classId = useAuthStore().classId ?? undefined;
|
||||
const days = await homeworkApi.days({ classId }).catch(() => ({ days: [] }));
|
||||
const latest = days.days.length > 0 ? days.days[days.days.length - 1].date : null;
|
||||
if (latest) {
|
||||
await router.push({ path: '/homeworks', query: { date: latest } });
|
||||
}
|
||||
}
|
||||
const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000);
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } from '@classhelper/shared';
|
||||
import { homeworkApi } from '../api/index.js';
|
||||
import {
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
SOCKET_EVENTS,
|
||||
dayKeyLocal,
|
||||
formatDate,
|
||||
isDayKey,
|
||||
shiftDayKey,
|
||||
type CourseDto,
|
||||
type HomeworkDto,
|
||||
type HomeworkSubmissionDto,
|
||||
} from '@classhelper/shared';
|
||||
import { courseApi, 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 route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
@@ -17,45 +30,65 @@ const DEFAULT_BOARD = {
|
||||
mode: 'board' as 'board' | 'list',
|
||||
showTime: false,
|
||||
fontSize: 15,
|
||||
todayOnly: true,
|
||||
};
|
||||
|
||||
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 viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
|
||||
const boardShowTime = ref(DEFAULT_BOARD.showTime);
|
||||
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
|
||||
const todayOnly = ref(DEFAULT_BOARD.todayOnly);
|
||||
const savingBoard = ref(false);
|
||||
|
||||
/** 全屏放大的科目(null = 未打开) */
|
||||
const fullscreenCourse = ref<string | null>(null);
|
||||
const fullscreenBoard = ref(false);
|
||||
|
||||
/* ------------------------------------------------------------ 当天的判定 */
|
||||
/* ------------------------------------------------------------ 按天查看 */
|
||||
|
||||
/** 本地"今天"的日期串(YYYY-MM-DD) */
|
||||
function localDayKey(value: string | Date): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
/**
|
||||
* 当前查看的日期(YYYY-MM-DD,本地):默认今天,可用日期选择器或前后一天切换。
|
||||
* 支持 `?date=YYYY-MM-DD` 深链(冒烟测试与"直接打开某天的作业"都靠它)。
|
||||
*/
|
||||
const selectedDate = ref(
|
||||
typeof route.query.date === 'string' && isDayKey(route.query.date)
|
||||
? route.query.date
|
||||
: dayKeyLocal(new Date()),
|
||||
);
|
||||
/** 有作业的日期(日期选择器高亮):来自 GET /homeworks/days */
|
||||
const dayMarks = ref<Map<string, number>>(new Map());
|
||||
/** 录入作业的科目下拉 */
|
||||
const courses = ref<CourseDto[]>([]);
|
||||
|
||||
/** 选中的是否就是今天(标题与按钮文案用) */
|
||||
const isToday = computed(() => selectedDate.value === dayKeyLocal(now.value));
|
||||
|
||||
/** 日期选择器:给有作业的日期加高亮类 */
|
||||
function dayCellClass(date: Date): string {
|
||||
return dayMarks.value.has(dayKeyLocal(date)) ? 'day-has-homework' : '';
|
||||
}
|
||||
|
||||
async function selectDate(value: string | null): Promise<void> {
|
||||
if (!value || !isDayKey(value)) return;
|
||||
selectedDate.value = value;
|
||||
// 同步到地址栏:刷新/分享/冒烟测试都能落在同一天
|
||||
await router.replace({ path: '/homeworks', query: { date: value } });
|
||||
await loadHomework();
|
||||
}
|
||||
|
||||
/** 前后一天(教室电脑上不用打开日历也能翻) */
|
||||
async function shiftDate(delta: number): Promise<void> {
|
||||
await selectDate(shiftDayKey(selectedDate.value, delta));
|
||||
}
|
||||
|
||||
/** 每分钟刷新一次"现在",用于标题栏时钟与"今天"的判定 */
|
||||
const now = ref(new Date());
|
||||
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const todayKey = computed(() => localDayKey(now.value));
|
||||
const todayText = computed(
|
||||
() =>
|
||||
`${now.value.getFullYear()} 年 ${now.value.getMonth() + 1} 月 ${now.value.getDate()} 日 ` +
|
||||
@@ -63,21 +96,19 @@ const todayText = computed(
|
||||
`${String(now.value.getHours()).padStart(2, '0')}:${String(now.value.getMinutes()).padStart(2, '0')}`,
|
||||
);
|
||||
|
||||
/** 按"今天"与完成状态过滤 */
|
||||
const filtered = computed(() => {
|
||||
let list = homeworks.value;
|
||||
if (todayOnly.value) list = list.filter((item) => localDayKey(item.createdAt) === todayKey.value);
|
||||
if (filter.value === 'pending') return list.filter((item) => item.completed !== true);
|
||||
if (filter.value === 'done') return list.filter((item) => item.completed === true);
|
||||
return list;
|
||||
/**
|
||||
* 列表内容 = 服务端按天返回的结果(`?date=`),因此这里不再做二次过滤。
|
||||
*
|
||||
* 注意:"已完成"是老师端/通知中心的概念,客户端作业模块只做**展示 + 录入**,
|
||||
* 因此这里没有任何完成状态相关的筛选与统计。
|
||||
*/
|
||||
const filtered = computed(() => homeworks.value);
|
||||
const selectedDayText = computed(() => {
|
||||
if (isToday.value) return '今天';
|
||||
const [year, month, day] = selectedDate.value.split('-');
|
||||
return `${Number(year)} 年 ${Number(month)} 月 ${Number(day)} 日`;
|
||||
});
|
||||
|
||||
const todayCount = computed(
|
||||
() => homeworks.value.filter((item) => localDayKey(item.createdAt) === todayKey.value).length,
|
||||
);
|
||||
const pendingCount = computed(() => filtered.value.filter((item) => item.completed !== true).length);
|
||||
const doneCount = computed(() => filtered.value.filter((item) => item.completed === true).length);
|
||||
|
||||
/**
|
||||
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
|
||||
* 科目内部按发布时间倒序,卡片顺序按学科常见顺序排列。
|
||||
@@ -102,7 +133,6 @@ const COURSE_ORDER = [
|
||||
interface BoardColumn {
|
||||
course: string;
|
||||
items: HomeworkDto[];
|
||||
pending: number;
|
||||
}
|
||||
|
||||
const boardColumns = computed<BoardColumn[]>(() => {
|
||||
@@ -116,7 +146,6 @@ const boardColumns = computed<BoardColumn[]>(() => {
|
||||
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
|
||||
course,
|
||||
items: [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
|
||||
pending: items.filter((item) => item.completed !== true).length,
|
||||
}));
|
||||
const rank = (name: string): number => {
|
||||
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
|
||||
@@ -241,16 +270,19 @@ async function loadHomework(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const classId = auth.classId ?? undefined;
|
||||
const day = selectedDate.value;
|
||||
// 缓存按"班级 + 日期"分键:翻到别的天不会串数据,离线也能看回看过的那些天
|
||||
const result = await fetchWithCache<HomeworkDto[]>(
|
||||
'homeworks',
|
||||
'self',
|
||||
() => homeworkApi.list(classId ? { classId } : undefined),
|
||||
`${classId ?? 'self'}@${day}`,
|
||||
() => homeworkApi.list(classId ? { classId, date: day } : { date: day }),
|
||||
[],
|
||||
);
|
||||
homeworks.value = result.data;
|
||||
fromCache.value = result.fromCache;
|
||||
updatedAt.value = result.updatedAt;
|
||||
if (!result.fromCache) appStore.markSynced();
|
||||
void loadDayMarks(classId);
|
||||
if (current.value) {
|
||||
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
|
||||
}
|
||||
@@ -260,6 +292,28 @@ async function loadHomework(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取"哪些天有作业"(日期选择器高亮)。
|
||||
*
|
||||
* 范围取当前查看日期前后各 45 天:教室电脑上翻月份也能看到有作业的日子。
|
||||
*/
|
||||
async function loadDayMarks(classId: string | undefined): Promise<void> {
|
||||
try {
|
||||
const from = shiftDayKey(selectedDate.value, -45);
|
||||
const to = shiftDayKey(selectedDate.value, 45);
|
||||
const result = await homeworkApi.days({ classId, from, to });
|
||||
dayMarks.value = new Map(result.days.map((item) => [item.date, item.count]));
|
||||
} catch {
|
||||
// 离线/失败时保留上一次的高亮,不打断展示
|
||||
}
|
||||
}
|
||||
|
||||
/** 科目下拉(录入作业时可选) */
|
||||
async function loadCourses(): Promise<void> {
|
||||
if (!auth.classId) return;
|
||||
courses.value = await courseApi.list(auth.classId).catch(() => []);
|
||||
}
|
||||
|
||||
/** 读取看板偏好(主进程配置为单一事实来源) */
|
||||
async function loadBoardSettings(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
@@ -267,7 +321,6 @@ async function loadBoardSettings(): Promise<void> {
|
||||
if (!saved) return;
|
||||
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
|
||||
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
|
||||
if (typeof saved.todayOnly === 'boolean') todayOnly.value = saved.todayOnly;
|
||||
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
|
||||
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
|
||||
}
|
||||
@@ -282,7 +335,6 @@ async function saveBoardSettings(): Promise<void> {
|
||||
mode: viewMode.value,
|
||||
showTime: boardShowTime.value,
|
||||
fontSize: boardFontSize.value,
|
||||
todayOnly: todayOnly.value,
|
||||
},
|
||||
});
|
||||
fitBoards();
|
||||
@@ -308,31 +360,72 @@ function openBoardFullscreen(): void {
|
||||
fitBoards();
|
||||
}
|
||||
|
||||
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
||||
if (!item) return;
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
|
||||
/* ------------------------------------------------------------ 录入作业(教室机器) */
|
||||
|
||||
const createVisible = ref(false);
|
||||
const createSaving = ref(false);
|
||||
/** 快捷短语:来自客户端本地配置(设置 → 作业录入可增删) */
|
||||
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||
/** 短语插入到哪个输入框:跟着最后一次聚焦的字段走 */
|
||||
const lastFocused = ref<'title' | 'content'>('content');
|
||||
const createForm = ref({ courseId: '', title: '', content: '' });
|
||||
|
||||
async function loadPhrases(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
createForm.value = { courseId: '', title: '', content: '' };
|
||||
lastFocused.value = 'content';
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
/** 点快捷短语:把词追加到当前字段末尾(空格分隔,避免连成一坨) */
|
||||
function insertPhrase(phrase: string): void {
|
||||
const field = lastFocused.value;
|
||||
const current = createForm.value[field];
|
||||
const next = current.trim() ? `${current.trimEnd()} ${phrase}` : phrase;
|
||||
createForm.value = { ...createForm.value, [field]: next };
|
||||
}
|
||||
|
||||
async function submitCreate(): Promise<void> {
|
||||
const classId = auth.classId;
|
||||
if (!classId) {
|
||||
ElMessage.error('当前会话没有班级信息,无法录入作业');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('离线状态下无法录入作业,请恢复网络后重试');
|
||||
return;
|
||||
}
|
||||
const title = createForm.value.title.trim();
|
||||
const content = createForm.value.content.trim();
|
||||
if (!title) {
|
||||
ElMessage.warning('请填写作业标题');
|
||||
return;
|
||||
}
|
||||
if (!content) {
|
||||
ElMessage.warning('请填写作业要求');
|
||||
return;
|
||||
}
|
||||
|
||||
createSaving.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 ? '已标记为完成' : '已取消完成标记');
|
||||
await homeworkApi.create({
|
||||
classId,
|
||||
courseId: createForm.value.courseId || null,
|
||||
title,
|
||||
content,
|
||||
assignDate: selectedDate.value,
|
||||
});
|
||||
ElMessage.success(`已录入到 ${selectedDate.value}`);
|
||||
createVisible.value = false;
|
||||
await loadHomework();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
||||
ElMessage.error(error instanceof Error ? error.message : '录入失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
createSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +508,8 @@ let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBoardSettings();
|
||||
await loadPhrases();
|
||||
await loadCourses();
|
||||
await loadHomework();
|
||||
clockTimer = setInterval(() => {
|
||||
now.value = new Date();
|
||||
@@ -448,26 +543,30 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<h2 class="page-title">我的作业</h2>
|
||||
<p class="page-subtitle">
|
||||
今天 {{ todayCount }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
|
||||
{{ selectedDayText }} · {{ filtered.length }} 份
|
||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-switch
|
||||
v-model="todayOnly"
|
||||
active-text="只看今天"
|
||||
inactive-text="全部日期"
|
||||
@change="saveBoardSettings"
|
||||
<el-button-group>
|
||||
<el-button :icon="'ArrowLeft'" @click="shiftDate(-1)">前一天</el-button>
|
||||
<el-button :disabled="isToday" @click="selectDate(dayKeyLocal(now))">今天</el-button>
|
||||
<el-button @click="shiftDate(1)">后一天<el-icon class="ml-4"><ArrowRight /></el-icon></el-button>
|
||||
</el-button-group>
|
||||
<el-date-picker
|
||||
:model-value="selectedDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
class="day-picker"
|
||||
:cell-class-name="dayCellClass"
|
||||
@update:model-value="(value: string | null) => selectDate(value)"
|
||||
/>
|
||||
<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-radio-group v-model="viewMode" @change="saveBoardSettings">
|
||||
<el-radio-button value="board">看板</el-radio-button>
|
||||
<el-radio-button value="list">列表</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">录入作业</el-button>
|
||||
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,7 +598,7 @@ onUnmounted(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="loading" shadow="never" class="homework-body">
|
||||
<el-empty v-if="filtered.length === 0" description="今天没有作业" />
|
||||
<el-empty v-if="filtered.length === 0" :description="`${selectedDayText}没有作业`" />
|
||||
|
||||
<!-- 看板模式:按科目分卡片,点击卡片放大全屏、点击条目看详情 -->
|
||||
<div v-else-if="viewMode === 'board'" ref="boardHostRef" class="board-host">
|
||||
@@ -508,19 +607,17 @@ onUnmounted(() => {
|
||||
v-for="column in boardColumns"
|
||||
:key="column.course"
|
||||
class="board-card"
|
||||
:class="{ 'has-pending': column.pending > 0 }"
|
||||
@click="openCourseFullscreen(column)"
|
||||
>
|
||||
<header class="board-card-head">
|
||||
<span class="board-course">{{ column.course }}</span>
|
||||
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
|
||||
<span class="board-badge">{{ column.items.length }}</span>
|
||||
</header>
|
||||
<ol class="board-list">
|
||||
<li
|
||||
v-for="(item, index) in column.items"
|
||||
:key="item.id"
|
||||
class="board-item"
|
||||
:class="{ done: item.completed }"
|
||||
@click.stop="openDetail(item)"
|
||||
>
|
||||
<span class="board-index">{{ index + 1 }}.</span>
|
||||
@@ -530,7 +627,6 @@ onUnmounted(() => {
|
||||
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||
</span>
|
||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
||||
</li>
|
||||
</ol>
|
||||
<footer class="board-card-foot">
|
||||
@@ -543,35 +639,23 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 列表模式(默认表格视图) -->
|
||||
<el-table v-else :data="filtered" @row-click="openDetail">
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.completed ? 'success' : 'info'" size="small" effect="light">
|
||||
{{ row.completed ? '已完成' : '待完成' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="作业标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="课程" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" width="170">
|
||||
<el-table-column label="所属日期" width="120">
|
||||
<template #default="{ row }">{{ row.assignDate }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="录入时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
|
||||
未交名单
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
:type="row.completed ? 'warning' : 'primary'"
|
||||
:loading="submitting"
|
||||
@click.stop="toggleComplete(row)"
|
||||
>
|
||||
{{ row.completed ? '取消完成' : '标记完成' }}
|
||||
</el-button>
|
||||
<el-button v-else link type="primary" @click.stop="openDetail(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -602,7 +686,6 @@ onUnmounted(() => {
|
||||
v-for="(item, index) in fullscreenItems"
|
||||
:key="item.id"
|
||||
class="board-item"
|
||||
:class="{ done: item.completed }"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<span class="board-index">{{ index + 1 }}.</span>
|
||||
@@ -611,11 +694,10 @@ onUnmounted(() => {
|
||||
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||
</span>
|
||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<el-empty v-if="fullscreenItems.length === 0" description="该科目今天没有作业" />
|
||||
<el-empty v-if="fullscreenItems.length === 0" :description="`该科目 ${selectedDayText}没有作业`" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- 全屏:整块看板放大 -->
|
||||
@@ -639,14 +721,13 @@ onUnmounted(() => {
|
||||
<section v-for="column in boardColumns" :key="column.course" class="board-card">
|
||||
<header class="board-card-head">
|
||||
<span class="board-course">{{ column.course }}</span>
|
||||
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
|
||||
<span class="board-badge">{{ column.items.length }}</span>
|
||||
</header>
|
||||
<ol class="board-list">
|
||||
<li
|
||||
v-for="(item, index) in column.items"
|
||||
:key="item.id"
|
||||
class="board-item"
|
||||
:class="{ done: item.completed }"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<span class="board-index">{{ index + 1 }}.</span>
|
||||
@@ -662,6 +743,65 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 录入作业(教室机器):快捷短语点一下就追加到标题/内容 -->
|
||||
<el-dialog v-model="createVisible" title="录入作业" width="620px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="所属日期">
|
||||
<el-date-picker
|
||||
:model-value="selectedDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:cell-class-name="dayCellClass"
|
||||
@update:model-value="(value: string | null) => selectDate(value)"
|
||||
/>
|
||||
<span class="text-muted ml-8">默认就是当前正在查看的这一天</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="createForm.courseId" clearable placeholder="可不选" style="width: 220px">
|
||||
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题">
|
||||
<el-input
|
||||
v-model="createForm.title"
|
||||
maxlength="120"
|
||||
show-word-limit
|
||||
placeholder="例如:第 3 课生字"
|
||||
@focus="lastFocused = 'title'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="作业要求">
|
||||
<el-input
|
||||
v-model="createForm.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="5000"
|
||||
placeholder="例如:P 12 大本,背诵第 3 段"
|
||||
@focus="lastFocused = 'content'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="phrases.length > 0" label="快捷短语">
|
||||
<div class="phrase-row">
|
||||
<el-tag
|
||||
v-for="item in phrases"
|
||||
:key="item"
|
||||
class="phrase-chip"
|
||||
type="info"
|
||||
effect="plain"
|
||||
@click="insertPhrase(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span class="text-muted">点一下追加到「{{ lastFocused === 'title' ? '标题' : '作业要求' }}」</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createSaving" @click="submitCreate">保存作业</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
|
||||
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
|
||||
<p class="text-muted" style="margin-top: 0">
|
||||
@@ -693,14 +833,10 @@ onUnmounted(() => {
|
||||
<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="发布时间">
|
||||
<el-descriptions-item label="所属日期">{{ current.assignDate }}</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">
|
||||
@@ -714,24 +850,10 @@ onUnmounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-16">
|
||||
<el-button
|
||||
v-if="auth.isClassSession"
|
||||
type="primary"
|
||||
:disabled="appStore.offline"
|
||||
@click="openSubmissions(current)"
|
||||
>
|
||||
<div v-if="auth.isClassSession" class="mt-16">
|
||||
<el-button type="primary" :disabled="appStore.offline" @click="openSubmissions(current)">
|
||||
维护未交名单
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
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>
|
||||
@@ -740,6 +862,35 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 日期选择器:有作业的日子加一个圆点,翻月份时一眼看出哪天有作业 */
|
||||
:deep(.day-has-homework .el-date-table-cell__text)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 2px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin-left: -2px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.day-picker {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.phrase-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.phrase-chip {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
@@ -932,7 +1083,7 @@ onUnmounted(() => {
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.board-done-tag {
|
||||
.board-done-tag-unused {
|
||||
flex: 0 0 auto;
|
||||
font-size: calc(var(--board-font, 15px) * 0.66);
|
||||
color: #32d74b;
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
@@ -19,7 +22,7 @@ import {
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { classApi } from '../api/index.js';
|
||||
import { classIslandStatusApi } from '../api/index.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { normalizeServerUrl } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
@@ -137,6 +140,22 @@ const channelSaving = ref(false);
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
|
||||
const classIslandStatus = ref<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
lastSeenAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||
});
|
||||
|
||||
const channelOptions = computed(() =>
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||
@@ -163,6 +182,63 @@ async function applyChannel(value: ClassIslandNotificationChannel): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||
|
||||
/**
|
||||
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
|
||||
*
|
||||
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
|
||||
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
|
||||
*/
|
||||
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||
const phraseInput = ref('');
|
||||
const savingPhrases = ref(false);
|
||||
|
||||
async function loadPhrases(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||
}
|
||||
|
||||
async function savePhrases(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingPhrases.value = true;
|
||||
try {
|
||||
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
|
||||
phrases.value = [...saved.homeworkPhrases];
|
||||
} finally {
|
||||
savingPhrases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPhrase(): Promise<void> {
|
||||
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||
if (!value) return;
|
||||
if (phrases.value.includes(value)) {
|
||||
ElMessage.warning('这条短语已经有了');
|
||||
return;
|
||||
}
|
||||
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
|
||||
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
|
||||
return;
|
||||
}
|
||||
phrases.value = [...phrases.value, value];
|
||||
phraseInput.value = '';
|
||||
await savePhrases();
|
||||
ElMessage.success('已添加');
|
||||
}
|
||||
|
||||
async function removePhrase(value: string): Promise<void> {
|
||||
phrases.value = phrases.value.filter((item) => item !== value);
|
||||
await savePhrases();
|
||||
}
|
||||
|
||||
async function resetPhrases(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
||||
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||
await savePhrases();
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 灵动岛个性化 */
|
||||
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
@@ -235,11 +311,13 @@ onMounted(async () => {
|
||||
await appStore.refreshCacheStats();
|
||||
await loadIslandAppearance();
|
||||
await loadClassChannel();
|
||||
// 本班有没有 ClassIsland 设备:没接就禁用"弹在 ClassIsland"这一项,避免选了却没效果
|
||||
const classList = await classApi.list().catch(() => []);
|
||||
classIslandConnected.value = classList.some(
|
||||
(item) => item.id === auth.classId && item.classIslandConnected === true,
|
||||
);
|
||||
await loadPhrases();
|
||||
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||
if (auth.classId) {
|
||||
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -380,6 +458,36 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`左右边距 ${island.marginX}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginX"
|
||||
:min="RANGES.marginX.min"
|
||||
:max="RANGES.marginX.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ marginX: value })"
|
||||
/>
|
||||
<span class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`上下边距 ${island.marginY}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginY"
|
||||
:min="RANGES.marginY.min"
|
||||
:max="RANGES.marginY.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ marginY: value })"
|
||||
/>
|
||||
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟随鼠标屏幕">
|
||||
<el-switch
|
||||
:model-value="island.followCursorDisplay"
|
||||
@change="
|
||||
(value: boolean | string | number) =>
|
||||
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="动画">
|
||||
<el-switch
|
||||
:model-value="island.animations"
|
||||
@@ -422,10 +530,16 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>通知显示位置</span></template>
|
||||
<template #header><span>ClassIsland 联动</span></template>
|
||||
<el-descriptions :column="1" border size="small" class="mb-12">
|
||||
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="插件 / ClassIsland">
|
||||
{{ classIslandStatus?.pluginVersion ?? '—' }} / {{ classIslandStatus?.classIslandVersion ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级,
|
||||
服务端据此决定是否一并推送到教室的 ClassIsland。
|
||||
<strong>通知模式</strong>:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
:model-value="appStore.notificationChannel"
|
||||
@@ -445,6 +559,38 @@ onMounted(async () => {
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>作业录入</span></template>
|
||||
<p class="text-muted">
|
||||
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。
|
||||
最多 {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条 {{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
|
||||
</p>
|
||||
<div class="phrase-row">
|
||||
<el-tag
|
||||
v-for="item in phrases"
|
||||
:key="item"
|
||||
closable
|
||||
type="info"
|
||||
effect="plain"
|
||||
@close="removePhrase(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-input
|
||||
v-model="phraseInput"
|
||||
placeholder="新增短语,如 大本"
|
||||
style="width: 200px"
|
||||
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
|
||||
@keyup.enter="addPhrase"
|
||||
/>
|
||||
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
@@ -517,6 +663,14 @@ onMounted(async () => {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.phrase-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user