feat(desktop+web+server): ClassIsland 风格 UI、灵动岛动画/交互修复、作业上岛与"叫人"功能
## 灵动岛修复与动画优化(用户反馈) - **收回不再出现"方框"闪烁**:卡片改为**固定逻辑尺寸 + 顶部居中锚定**, 窗口只负责露出/裁切透明区域。此前卡片随窗口拉伸,形变过程中会看到被拉扁的圆角方块, 胶囊里的铃铛/文字还会被垂直居中而"从上跳到下";两条问题同源,现已消除。 冒烟用 15 次采样断言卡片尺寸恒为固定值(`396x308` / `260x38`),一旦出现中间值即失败。 - **点击屏幕任意位置收回**:展开态临时 `setFocusable(true)` 并取焦,点到别处即失焦 → 自动回缩为胶囊; 收起后立即释放可聚焦性。断言覆盖 `focusable=true → 失焦后 mode=pill、focusable=false`。 - 动画继续打磨:危险/叫人形态改为**内部光晕**(卡片四周再留 4px 透明边,彻底避免窗口边界裁切出硬边)、 尖角与圆角统一、形态尺寸各异(胶囊 268×44 / 详情 404×316 / 紧急 424×344 / 叫人 440×360)。 ## 灵动岛"标为已读"真正同步通知中心 - 新增链路:灵动岛 `mark-read` → 主进程 `setMarkReadHandler` → 主窗口渲染进程 → `notificationApi.markRead` + 刷新 store(未读红点即时减少)。 - 真机链路断言:点击"标为已读"后 `read=false → true`、未读数 `1 → 0`(此前只从灵动岛队列移除)。 ## 作业发布也上岛 - `homework:new` → 灵动岛 `kind=homework`:胶囊显示"新作业"(青蓝描边 + 书本图标), 展开显示作业说明与"截止时间:…"。 ## 新增"叫人"功能 - 服务端:新模块 `modules/calls`(`POST /api/calls`)——校验班级可写 + 学生属于该班, 落库一条 HIGH 级通知(进通知中心、可用已读状态),广播 `notification:new` 到班级房间, 并定向 `call:new` 给被叫学生。 - 共享层:`CALL_QUICK_PHRASES`(8 条快捷短语)、`buildCallTitle()`("请 XXX 同学找 XXX 老师",老师名已含"老师"不重复)、 `ServerToClientEvents['call:new']`、`CreateCallRequest`。 - Web 管理端:学生管理列表新增 **叫人** 按钮 → 弹窗显示对象、快捷短语(点选/取消)、自定义消息(优先),发送即通知。 - 客户端:`call:new` → 灵动岛 `kind=call`,**上课时段也立即展开**(老师正在等这名学生), 琥珀金卡片 +"叫人"徽标 +"收到"按钮,90 秒后自动收起。 ## 客户端界面改为 ClassIsland 同款 Fluent 风格 - 主题令牌:Mica 浅底 `#f3f3f3`、亚克力卡片 `rgba(255,255,255,.7)` + `backdrop-filter`、 强调色 `#0F6CBD`、半透明描边、卡片 8px / 控件 4px 圆角、极轻阴影。 - Element Plus 变量整体对齐(`--el-color-primary`、圆角、表头/行分隔、滚动条等)。 - 侧边导航改为 Fluent NavigationView 观感:白色半透明底、选中项强调色淡底 + 左侧 3px 强调条; 品牌标改为强调色渐变圆角方块。附客户端五个页面实拍(`docs/screenshots/client/`)。 ## 验证 - `pnpm verify:e2e` **68/68**(新增 6 项叫人:201 + 标题模板、自定义消息优先、上课时段允许、学生 403、通知中心可见、清理) - `pnpm verify:desktop` **33/33**(新增:卡片固定尺寸、点击任意处收回、标为已读同步、作业上岛、叫人上课中立即展开、真实通知链路) - `pnpm verify:web` **16/16**(新增叫人入口:弹窗、8 条快捷短语、自定义输入、短语可选中) - `pnpm lint` / `format:check` / `typecheck` 全部通过;服务端安装包已重打并安装验证
This commit is contained in:
1 parent
f759aac0a2
commit
219f6ae83a
28 files changed
+1555
-156
No files matched your search
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { PRIORITY_LABELS, formatDate, type IslandNotification, type IslandState } from '@classhelper/shared';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
formatDate,
|
||||
type IslandNotification,
|
||||
type IslandNotificationKind,
|
||||
type IslandState,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 灵动岛渲染进程。
|
||||
@@ -25,8 +31,31 @@ const bridge = window.island;
|
||||
const notification = computed<IslandNotification | null>(() => state.value.active);
|
||||
const mode = computed(() => state.value.mode);
|
||||
const isUrgent = computed(() => notification.value?.priority === 'URGENT');
|
||||
const kind = computed<IslandNotificationKind>(() => notification.value?.kind ?? 'notification');
|
||||
const isCall = computed(() => kind.value === 'call');
|
||||
const isHomework = computed(() => kind.value === 'homework');
|
||||
const queueCount = computed(() => state.value.queued.length);
|
||||
|
||||
/** 胶囊标题:按消息类型区分(新消息 / 新作业 / 叫人) */
|
||||
const pillTitle = computed(() => {
|
||||
if (isCall.value) return '老师叫你';
|
||||
if (isHomework.value) return '新作业';
|
||||
return isUrgent.value ? '紧急通知' : '新消息';
|
||||
});
|
||||
|
||||
/** 展开态徽标:叫人 / 新作业 / 优先级文案 */
|
||||
const badgeText = computed(() => {
|
||||
if (isCall.value) return '叫人';
|
||||
if (isHomework.value) return '新作业';
|
||||
return priorityLabel.value;
|
||||
});
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
if (isCall.value) return 'badge-call';
|
||||
if (isHomework.value) return 'badge-homework';
|
||||
return `badge-${(notification.value?.priority ?? 'NORMAL').toLowerCase()}`;
|
||||
});
|
||||
|
||||
const priorityLabel = computed(() =>
|
||||
notification.value ? (PRIORITY_LABELS[notification.value.priority] ?? notification.value.priority) : '',
|
||||
);
|
||||
@@ -37,6 +66,9 @@ const subtitle = computed(() => {
|
||||
return `${teacher}${formatDate(notification.value.createdAt, true)}`;
|
||||
});
|
||||
|
||||
/** 叫人消息下方的附加说明(例如"请到办公室找我") */
|
||||
const callHint = computed(() => notification.value?.subtitle ?? '');
|
||||
|
||||
/** 下课补发时提示"其实上课期间就到了" */
|
||||
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
|
||||
|
||||
@@ -97,13 +129,26 @@ onMounted(async () => {
|
||||
key="pill"
|
||||
type="button"
|
||||
class="island-card pill"
|
||||
:class="{ urgent: isUrgent, 'after-class': showAfterClassHint }"
|
||||
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, 'after-class': showAfterClassHint }"
|
||||
@click="expand"
|
||||
>
|
||||
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
|
||||
<span class="pulse-dot" :class="{ urgent: isUrgent }"></span>
|
||||
<span v-if="isUrgent || isCall" class="glow" aria-hidden="true"></span>
|
||||
<span class="pulse-dot" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"></span>
|
||||
<span class="pill-icon">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<!-- 铃铛(通知)/ 书本(作业)/ 喇叭(叫人) -->
|
||||
<svg v-if="isHomework" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else-if="isCall" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
|
||||
@@ -112,7 +157,7 @@ onMounted(async () => {
|
||||
</span>
|
||||
<span class="pill-text">
|
||||
<span class="pill-title">
|
||||
{{ isUrgent ? '紧急通知' : '新消息' }}
|
||||
{{ pillTitle }}
|
||||
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
|
||||
</span>
|
||||
<span class="pill-sub">{{ notification.teacherName || '老师' }} · 点击查看</span>
|
||||
@@ -135,13 +180,25 @@ onMounted(async () => {
|
||||
v-else-if="mode === 'expanded' && notification"
|
||||
key="expanded"
|
||||
class="island-card expanded"
|
||||
:class="{ urgent: isUrgent }"
|
||||
:class="{ urgent: isUrgent, call: isCall, homework: isHomework }"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
|
||||
<span v-if="isUrgent || isCall" class="glow" aria-hidden="true"></span>
|
||||
<header class="head">
|
||||
<span class="head-icon" :class="{ urgent: isUrgent }">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
|
||||
<svg v-if="isHomework" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else-if="isCall" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
|
||||
@@ -149,10 +206,9 @@ onMounted(async () => {
|
||||
</svg>
|
||||
</span>
|
||||
<div class="head-meta">
|
||||
<span class="badge" :class="`badge-${notification.priority.toLowerCase()}`">
|
||||
{{ priorityLabel }}
|
||||
</span>
|
||||
<span v-if="isUrgent" class="live-dot">需立即查看</span>
|
||||
<span class="badge" :class="badgeClass">{{ badgeText }}</span>
|
||||
<span v-if="isCall" class="live-dot call">老师正在等你</span>
|
||||
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
|
||||
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
|
||||
<span class="head-sub">{{ subtitle }}</span>
|
||||
</div>
|
||||
@@ -172,6 +228,8 @@ onMounted(async () => {
|
||||
<h3 class="title">{{ notification.title }}</h3>
|
||||
<div class="body">
|
||||
<p class="content">{{ notification.content }}</p>
|
||||
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
|
||||
<p v-if="notification.subtitle && !callHint" class="call-hint">{{ notification.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
@@ -180,7 +238,7 @@ onMounted(async () => {
|
||||
<span class="actions">
|
||||
<button type="button" class="ghost-btn" @click="openApp">打开应用</button>
|
||||
<button type="button" class="ghost-btn" @click="markRead">标为已读</button>
|
||||
<button type="button" class="solid-btn" @click="dismiss">知道了</button>
|
||||
<button type="button" class="solid-btn" @click="dismiss">{{ isCall ? '收到' : '知道了' }}</button>
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -214,18 +272,17 @@ body,
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压。
|
||||
注意:窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
|
||||
在屏幕上表现为"卡片周围一圈奇怪的光晕/硬边"。因此这里**只用 inset 阴影**,
|
||||
所有装饰效果都画在卡片内部。 */
|
||||
/* 三态卡片绝对定位 + **固定逻辑尺寸、水平居中锚定**。
|
||||
关键:卡片不能跟随窗口拉伸,否则窗口形变过程中会看到一块被拉扁的"方框",
|
||||
胶囊里的图标/文字还会被垂直居中而"从上面瞬移到下面"。
|
||||
这里每种形态都是固定尺寸,窗口只负责露出/裁切这块透明区域,形变时卡片内容不缩放。
|
||||
另:窗口只比卡片大 2~4px,向外的 box-shadow/光晕会被窗口边界裁切,
|
||||
因此所有装饰只使用 inset 阴影。 */
|
||||
.island-card {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
bottom: 2px;
|
||||
left: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 24px;
|
||||
border-radius: 22px;
|
||||
color: #f7f8fa;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background:
|
||||
@@ -239,12 +296,77 @@ body,
|
||||
background 0.28s ease;
|
||||
}
|
||||
|
||||
/* 各形态固定尺寸(与主进程 ISLAND_SIZES 对齐:卡片刻意比窗口小 4~8px,四周留 2~4px 透明边) */
|
||||
.island-card.pill {
|
||||
left: calc(50% - 130px);
|
||||
width: 260px;
|
||||
height: 38px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.island-card.expanded {
|
||||
left: calc(50% - 198px);
|
||||
width: 396px;
|
||||
height: 308px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.island-card.expanded.urgent {
|
||||
left: calc(50% - 208px);
|
||||
width: 416px;
|
||||
height: 336px;
|
||||
}
|
||||
|
||||
/* 叫人:卡片略大(对应主进程 CALL_SIZE 440x360) */
|
||||
.island-card.expanded.call {
|
||||
left: calc(50% - 216px);
|
||||
width: 432px;
|
||||
height: 352px;
|
||||
}
|
||||
|
||||
/* 隐藏态占位:只保留结构,不占视觉空间 */
|
||||
.hidden-placeholder {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
opacity: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 紧急通知:红色描边 + 内部光晕呼吸(不外扩,避免窗口边界裁切出光晕硬边) */
|
||||
.island-card.urgent {
|
||||
border-color: rgba(255, 92, 92, 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* 叫人:琥珀金描边(老师点名,需要学生动作) */
|
||||
.island-card.call {
|
||||
border-color: rgba(255, 193, 94, 0.65);
|
||||
background:
|
||||
radial-gradient(120% 140% at 50% -20%, rgba(255, 193, 94, 0.18), rgba(255, 255, 255, 0) 55%),
|
||||
linear-gradient(180deg, #2a2417 0%, #0d0c09 100%);
|
||||
}
|
||||
|
||||
.island-card.call .glow {
|
||||
animation: call-breath 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes call-breath {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
inset 0 0 14px rgba(255, 193, 94, 0.2),
|
||||
inset 0 0 0 1px rgba(255, 193, 94, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 0 26px rgba(255, 193, 94, 0.4),
|
||||
inset 0 0 0 1px rgba(255, 193, 94, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
/* 新作业:青蓝描边 */
|
||||
.island-card.homework {
|
||||
border-color: rgba(108, 196, 255, 0.55);
|
||||
}
|
||||
|
||||
.glow {
|
||||
@@ -593,6 +715,75 @@ body,
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 消息类型样式 */
|
||||
|
||||
/* "叫人"(老师点名,琥珀金):图标底色、徽标、按钮 */
|
||||
.island-card.pill.call .pill-icon {
|
||||
color: #ffd79a;
|
||||
background: rgba(255, 193, 94, 0.2);
|
||||
}
|
||||
|
||||
.pulse-dot.call {
|
||||
background: #ffc15e;
|
||||
box-shadow: 0 0 0 0 rgba(255, 193, 94, 0.7);
|
||||
}
|
||||
|
||||
.head-icon.call {
|
||||
color: #ffd79a;
|
||||
background: rgba(255, 193, 94, 0.22);
|
||||
}
|
||||
|
||||
.badge-call {
|
||||
color: #3a2600;
|
||||
background: #ffc15e;
|
||||
}
|
||||
|
||||
.live-dot.call {
|
||||
color: #ffd79a;
|
||||
}
|
||||
|
||||
.island-card.expanded.call .solid-btn {
|
||||
color: #3a2600;
|
||||
background: #ffc15e;
|
||||
}
|
||||
|
||||
.island-card.expanded.call .body {
|
||||
background: rgba(255, 193, 94, 0.08);
|
||||
}
|
||||
|
||||
.call-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #ffd79a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 新作业(青蓝) */
|
||||
.island-card.pill.homework .pill-icon {
|
||||
color: #b7e2ff;
|
||||
background: rgba(108, 196, 255, 0.18);
|
||||
}
|
||||
|
||||
.pulse-dot.homework {
|
||||
background: #6cc4ff;
|
||||
box-shadow: 0 0 0 0 rgba(108, 196, 255, 0.6);
|
||||
}
|
||||
|
||||
.head-icon.homework {
|
||||
color: #b7e2ff;
|
||||
background: rgba(108, 196, 255, 0.2);
|
||||
}
|
||||
|
||||
.badge-homework {
|
||||
color: #06263f;
|
||||
background: #6cc4ff;
|
||||
}
|
||||
|
||||
.island-card.expanded.homework .solid-btn {
|
||||
color: #06263f;
|
||||
background: #6cc4ff;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 形变动画 */
|
||||
|
||||
/* 胶囊态:没有入场动画,直接出现 */
|
||||
|
||||
Reference in new issue
Block a user