fix(island): 合并常驻与自定义岛并统一经典通知和即时课表同步
This commit is contained in:
1 parent
05848570d1
commit
e84e43544a
19 files changed
+1565
-1445
No files matched your search
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
PRIORITY_RANK,
|
||||
PRIORITY_LABELS,
|
||||
formatDate,
|
||||
islandListLayout,
|
||||
ISLAND_SIZE_DELTA,
|
||||
type IslandState,
|
||||
type IslandAppearance,
|
||||
type IslandNotification,
|
||||
type IslandNotificationKind,
|
||||
} from '@classhelper/shared';
|
||||
const props = defineProps<{ state: IslandState; appearance: IslandAppearance; maxHeight: number }>();
|
||||
const state = computed(() => props.state);
|
||||
const appearance = computed(() => props.appearance);
|
||||
const notification = computed(() => state.value.active);
|
||||
const isUrgent = computed(() => notification.value?.priority === 'URGENT');
|
||||
const isCall = computed(() => notification.value?.kind === 'call');
|
||||
const isHomework = computed(() => notification.value?.kind === 'homework');
|
||||
const pendingNotifications = computed(() =>
|
||||
[
|
||||
...new Map(
|
||||
[state.value.active, ...state.value.queued]
|
||||
.filter((n): n is IslandNotification => !!n)
|
||||
.map((n) => [n.id, n]),
|
||||
).values(),
|
||||
].sort(
|
||||
(a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] || b.createdAt.localeCompare(a.createdAt),
|
||||
),
|
||||
);
|
||||
const pendingCount = computed(() => pendingNotifications.value.length);
|
||||
const queueCount = computed(() => state.value.queued.length);
|
||||
const listLayout = computed(() =>
|
||||
pendingCount.value < 2
|
||||
? null
|
||||
: islandListLayout({
|
||||
count: pendingCount.value,
|
||||
fontSize: appearance.value.fontSize,
|
||||
maxHeight: props.maxHeight,
|
||||
expanded: state.value.listExpanded,
|
||||
}),
|
||||
);
|
||||
const isListMode = computed(() => !!listLayout.value);
|
||||
const listItems = computed(() => pendingNotifications.value.slice(0, listLayout.value?.rows ?? 0));
|
||||
const hiddenCount = computed(() => pendingCount.value - listItems.value.length);
|
||||
const listHint = computed(() => listLayout.value?.hint);
|
||||
const size = computed(() => {
|
||||
const extra = isCall.value
|
||||
? ISLAND_SIZE_DELTA.call
|
||||
: isUrgent.value
|
||||
? ISLAND_SIZE_DELTA.urgent
|
||||
: ISLAND_SIZE_DELTA.expanded;
|
||||
return {
|
||||
width: appearance.value.width + (isListMode.value ? ISLAND_SIZE_DELTA.expanded.width : extra.width),
|
||||
height: listLayout.value?.height ?? appearance.value.height + extra.height,
|
||||
};
|
||||
});
|
||||
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
|
||||
const TYPE_LABELS = { call: '叫人', homework: '作业', notification: '通知' };
|
||||
const pendingTypes = computed(() =>
|
||||
TYPE_ORDER.filter((k) => pendingNotifications.value.some((n) => (n.kind ?? 'notification') === k)).map(
|
||||
(k) => TYPE_LABELS[k],
|
||||
),
|
||||
);
|
||||
function rowKind(n: IslandNotification) {
|
||||
return n.kind ?? 'notification';
|
||||
}
|
||||
function rowBadgeText(n: IslandNotification) {
|
||||
return n.kind === 'call' ? '叫人' : n.kind === 'homework' ? '新作业' : PRIORITY_LABELS[n.priority];
|
||||
}
|
||||
function rowBadgeClass(n: IslandNotification) {
|
||||
return n.kind === 'call'
|
||||
? 'badge-call'
|
||||
: n.kind === 'homework'
|
||||
? 'badge-homework'
|
||||
: `badge-${n.priority.toLowerCase()}`;
|
||||
}
|
||||
function rowSubText(n: IslandNotification) {
|
||||
return `${n.teacherName ? n.teacherName + ' · ' : ''}${formatDate(n.createdAt, true)}`;
|
||||
}
|
||||
const badgeText = computed(() => (notification.value ? rowBadgeText(notification.value) : '通知'));
|
||||
const badgeClass = computed(() => (notification.value ? rowBadgeClass(notification.value) : 'badge-normal'));
|
||||
const subtitle = computed(() => (notification.value ? rowSubText(notification.value) : ''));
|
||||
const callHint = computed(() => notification.value?.subtitle ?? '');
|
||||
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
|
||||
const canMarkRead = computed(() => pendingNotifications.value.some((n) => n.kind !== 'homework'));
|
||||
function collapse() {
|
||||
window.island?.sendAction('collapse');
|
||||
}
|
||||
function openApp() {
|
||||
window.island?.sendAction('open-app');
|
||||
}
|
||||
function expandList() {
|
||||
window.island?.sendAction('expand-list');
|
||||
}
|
||||
function dismiss() {
|
||||
window.island?.sendAction(isListMode.value ? 'dismiss-all' : 'dismiss');
|
||||
}
|
||||
function markRead() {
|
||||
window.island?.sendAction(isListMode.value ? 'mark-all-read' : 'mark-read', notification.value?.id);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<span
|
||||
class="expanded-layer classic-notifications"
|
||||
:class="{ 'list-mode': isListMode }"
|
||||
:style="{ width: `${size.width}px`, height: `${size.height}px` }"
|
||||
>
|
||||
<header class="head">
|
||||
<span
|
||||
class="head-icon"
|
||||
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, list: isListMode }"
|
||||
>
|
||||
<svg v-if="isHomework" viewBox="0 0 24 24" 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" 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" 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"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="head-meta">
|
||||
<span class="badge" :class="isListMode ? 'badge-normal' : badgeClass">
|
||||
{{ isListMode ? `共 ${pendingCount} 条` : badgeText }}
|
||||
</span>
|
||||
<template v-if="isListMode">
|
||||
<span v-if="pendingTypes.length > 1" class="head-sub">{{ pendingTypes.join(' / ') }}</span>
|
||||
<span v-else class="head-sub">按重要程度排列</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="icon-btn" title="收起" @click.stop="collapse">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
d="m6 15 6-6 6 6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 多条消息:竖向排列,按重要程度排序,默认只显示前三条 -->
|
||||
<template v-if="isListMode">
|
||||
<h3 class="title">{{ pendingCount }} 条待处理通知</h3>
|
||||
<div class="body list-body">
|
||||
<button
|
||||
v-for="item in listItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="list-row"
|
||||
:title="`打开应用查看:${item.title}`"
|
||||
@click.stop="openApp"
|
||||
>
|
||||
<span class="row-icon" :class="rowKind(item)">
|
||||
<svg v-if="rowKind(item) === 'homework'" viewBox="0 0 24 24" 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="rowKind(item) === 'call'" viewBox="0 0 24 24" 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" 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"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="row-text">
|
||||
<span class="row-title">{{ item.title }}</span>
|
||||
<span class="row-sub">{{ rowSubText(item) }}</span>
|
||||
</span>
|
||||
<span class="row-badge" :class="rowBadgeClass(item)">{{ rowBadgeText(item) }}</span>
|
||||
</button>
|
||||
<!-- 还有没显示出来的:能展开就"展开更多",屏幕到任务栏了就只能去应用里看 -->
|
||||
<div v-if="listHint" class="list-hint">
|
||||
<button v-if="listHint === 'more'" type="button" class="more-btn" @click.stop="expandList">
|
||||
展开更多(还有 {{ hiddenCount }} 条)
|
||||
</button>
|
||||
<span v-else class="more-app">更多请前往应用内操作</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h3 class="title">{{ notification?.title ?? '' }}</h3>
|
||||
<div class="body">
|
||||
<p class="content-text">{{ notification?.content ?? '' }}</p>
|
||||
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
|
||||
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 一排按钮(多条消息时也是这一排):展开态的操作都收在这里 -->
|
||||
<footer class="foot">
|
||||
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
|
||||
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
||||
<span v-else class="more muted">来自 ClassHelper</span>
|
||||
<span class="actions">
|
||||
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
|
||||
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">标为已读</button>
|
||||
<button type="button" class="solid-btn" @click.stop="dismiss">
|
||||
{{ isCall && !isListMode ? '收到' : '知道了' }}
|
||||
</button>
|
||||
</span>
|
||||
</footer>
|
||||
</span>
|
||||
</template>
|
||||
<style src="./classic-island.css"></style>
|
||||
@@ -110,7 +110,7 @@ const style = computed<CSSProperties>(() => {
|
||||
: ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[
|
||||
n.advanced.align
|
||||
],
|
||||
textAlign: n.advanced.align === 'center' ? 'center' : 'start',
|
||||
textAlign: n.advanced.align === 'start' ? 'start' : n.advanced.align === 'end' ? 'end' : 'center',
|
||||
color: override && a.customForegroundEnabled ? a.foreground : undefined,
|
||||
background: override && a.customBackgroundEnabled ? color : undefined,
|
||||
borderRadius: override && a.customCornerRadiusEnabled ? `${a.cornerRadius}px` : undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
islandNode,
|
||||
normalizeIslandLayouts,
|
||||
type IslandLayoutSettings,
|
||||
type IslandAppearance,
|
||||
type IslandComponentKind,
|
||||
type IslandComponentNode,
|
||||
type IslandState,
|
||||
@@ -19,6 +20,7 @@ const props = defineProps<{
|
||||
settings: IslandLayoutSettings;
|
||||
state: IslandState;
|
||||
editing: boolean;
|
||||
appearance: IslandAppearance;
|
||||
schedules: ScheduleDto[];
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
@@ -84,17 +86,6 @@ function collapseResident() {
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch' || event.pointerType === 'pen') window.island?.noteResidentTouch();
|
||||
}
|
||||
function restoreClassIslandAppearance() {
|
||||
Object.assign(scheme.value, {
|
||||
scale: CI.scale,
|
||||
color: '#000000',
|
||||
opacity: CI.opacity,
|
||||
radius: CI.radius,
|
||||
spacing: CI.rowSpacing,
|
||||
});
|
||||
for (const row of [...scheme.value.rows, ...scheme.value.expandedRows]) row.gap = 12;
|
||||
save();
|
||||
}
|
||||
const selectedId = ref(''),
|
||||
selectedKind = ref<IslandComponentKind>('clock');
|
||||
const panel = ref<'component' | 'row' | 'appearance'>('appearance');
|
||||
@@ -158,14 +149,15 @@ const shellStyle = computed<CSSProperties>(() => ({
|
||||
minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined,
|
||||
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
|
||||
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
|
||||
color: scheme.value.foreground,
|
||||
fontSize: '16px',
|
||||
color: '#ffffffeb',
|
||||
fontSize: `${props.appearance.fontSize}px`,
|
||||
'--custom-color':
|
||||
scheme.value.color +
|
||||
Math.round(scheme.value.opacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0'),
|
||||
'--custom-radius': `${scheme.value.radius * (draft.value.residentEnabled ? scheme.value.scale : 1)}px`,
|
||||
props.appearance.style === 'glass'
|
||||
? '#202024cd'
|
||||
: props.appearance.style === 'tinted'
|
||||
? '#0b0b0f'
|
||||
: '#000000',
|
||||
'--custom-radius': `${props.appearance.radius}px`,
|
||||
'--custom-row-gap': `${scheme.value.spacing}px`,
|
||||
...(draft.value.residentEnabled
|
||||
? {
|
||||
@@ -364,6 +356,7 @@ function panMove(e: PointerEvent) {
|
||||
pan.value = { x: panning.originX + e.clientX - panning.x, y: panning.originY + e.clientY - panning.y };
|
||||
move(e);
|
||||
}
|
||||
let stopViewport: (() => void) | undefined;
|
||||
let observer: ResizeObserver | undefined,
|
||||
measureTimer: ReturnType<typeof setTimeout> | undefined,
|
||||
clock: ReturnType<typeof setInterval>;
|
||||
@@ -399,12 +392,13 @@ onMounted(() => {
|
||||
clock = setInterval(() => (now.value = Date.now()), 1000);
|
||||
observer = new ResizeObserver(measure);
|
||||
if (inner.value) observer.observe(inner.value);
|
||||
window.island?.onViewport((v) => (viewport.value = v));
|
||||
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
|
||||
measure();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
clearInterval(clock);
|
||||
clearTimeout(measureTimer);
|
||||
stopViewport?.();
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
@@ -495,15 +489,14 @@ onUnmounted(() => {
|
||||
</aside>
|
||||
<main :class="{ canvas: editing }" @pointerdown="panStart">
|
||||
<div v-if="editing" class="canvas-toolbar">
|
||||
<nav v-if="draft.residentEnabled" class="surface-tabs" aria-label="编辑常驻岛内容">
|
||||
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
|
||||
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
|
||||
收起时
|
||||
常态内容
|
||||
</button>
|
||||
<button :class="{ active: editingSurface === 'expanded' }" @click="setSurface('expanded')">
|
||||
无通知展开时
|
||||
</button>
|
||||
</nav>
|
||||
<span v-else>岛内预览</span>
|
||||
<div>
|
||||
<button
|
||||
:class="{ active: panel === 'appearance' }"
|
||||
@@ -755,31 +748,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="inspector-body" @change="save">
|
||||
<section class="setting-section">
|
||||
<label class="toggle-field">
|
||||
<span>常驻岛</span>
|
||||
<input type="checkbox" v-model="draft.residentEnabled" @change="setSurface('compact')" />
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
开启后始终显示收起内容,收到消息时显示铃铛,点击查看通知;没有通知时展开另一套自定义内容。
|
||||
通知、外观与弹出动画沿用经典灵动岛。在应用的灵动岛设置中调整颜色、字号、圆角和动画速度。
|
||||
</p>
|
||||
</section>
|
||||
<section v-if="draft.residentEnabled" class="setting-section">
|
||||
<h3>ClassIsland 窗口设置</h3>
|
||||
<p class="field-hint">
|
||||
基准岛高 40 px,默认缩放 1×;正文 16、时钟 18 px。背景移入淡化并穿透,右侧展开按钮保持可用。
|
||||
</p>
|
||||
<label class="toggle-field">
|
||||
<span>指针移入淡化</span>
|
||||
<input type="checkbox" v-model="draft.pointerFadeEnabled" />
|
||||
</label>
|
||||
<label v-if="draft.pointerFadeEnabled" class="toggle-field">
|
||||
<span>反转指针移入淡化</span>
|
||||
<input type="checkbox" v-model="draft.pointerFadeReversed" />
|
||||
</label>
|
||||
<button class="wide-button" @click="restoreClassIslandAppearance">
|
||||
恢复 ClassIsland 默认外观
|
||||
</button>
|
||||
</section>
|
||||
<section class="setting-section">
|
||||
<h3>当前方案</h3>
|
||||
<label class="field">
|
||||
@@ -797,40 +769,6 @@ onUnmounted(() => {
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<section class="setting-section">
|
||||
<h3>岛体</h3>
|
||||
<label class="field inline-field">
|
||||
背景颜色
|
||||
<input type="color" v-model="scheme.color" />
|
||||
</label>
|
||||
<label class="field inline-field">
|
||||
文字颜色
|
||||
<input type="color" v-model="scheme.foreground" />
|
||||
</label>
|
||||
<label class="field">
|
||||
背景透明度
|
||||
<div class="range-field">
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="scheme.opacity"
|
||||
:min="draft.residentEnabled ? 0 : 0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
/>
|
||||
<output>{{ Math.round(scheme.opacity * 100) }}%</output>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field inline-field">
|
||||
圆角
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="scheme.radius"
|
||||
min="0"
|
||||
:max="draft.residentEnabled ? CI.radiusMax : 100"
|
||||
step="0.5"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<section class="setting-section">
|
||||
<h3>多行布局</h3>
|
||||
<label class="field inline-field">
|
||||
|
||||
+127
-66
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
|
||||
import {
|
||||
CLASSISLAND_ISLAND_METRICS as CI,
|
||||
ISLAND_SHADOW_PAD,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_RANK,
|
||||
type IslandAppearance,
|
||||
type IslandLayoutSettings,
|
||||
@@ -12,6 +10,9 @@ import {
|
||||
} from '@classhelper/shared';
|
||||
import ComponentNode from './ComponentNode.vue';
|
||||
import ComponentIcon from './ComponentIcon.vue';
|
||||
import ClassicNotifications from './ClassicNotifications.vue';
|
||||
import { squirclePath } from './squircle.js';
|
||||
import { SpringValue } from './spring.js';
|
||||
|
||||
const props = defineProps<{
|
||||
settings: IslandLayoutSettings;
|
||||
@@ -28,7 +29,7 @@ const messages = computed(() => {
|
||||
);
|
||||
return [...unique.values()].sort(
|
||||
(a, b) =>
|
||||
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
|
||||
PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] ||
|
||||
Date.parse(b.createdAt) - Date.parse(a.createdAt),
|
||||
);
|
||||
});
|
||||
@@ -47,13 +48,6 @@ const anchor = computed(() =>
|
||||
? 'right'
|
||||
: 'center',
|
||||
);
|
||||
const capacity = computed(() =>
|
||||
Math.max(1, Math.floor((available.value.height / scheme.value.scale - 130) / 104)),
|
||||
);
|
||||
const visibleMessages = computed(() =>
|
||||
messages.value.slice(0, props.state.listExpanded ? capacity.value : Math.min(3, capacity.value)),
|
||||
);
|
||||
const remaining = computed(() => messages.value.length - visibleMessages.value.length);
|
||||
function fitted(size: { width: number; height: number }) {
|
||||
// 以未变形内容测量;字体、配置缩放、行数和屏幕变化都参与适配,不从动画中的壳反推内容。
|
||||
const scale = Math.min(
|
||||
@@ -66,23 +60,75 @@ function fitted(size: { width: number; height: number }) {
|
||||
const compactFit = computed(() => fitted(natural.value.compact));
|
||||
const detailsFit = computed(() => fitted(natural.value.details));
|
||||
const fit = computed(() => (expanded.value ? detailsFit.value : compactFit.value));
|
||||
const morph = ref({ width: 40, height: 40, radius: 20 });
|
||||
const springs = { width: new SpringValue(40), height: new SpringValue(40), radius: new SpringValue(20) };
|
||||
const path = computed(() => squirclePath(morph.value.width, morph.value.height, morph.value.radius));
|
||||
const style = computed<CSSProperties>(() => ({
|
||||
width: `${fit.value.width}px`,
|
||||
height: `${fit.value.height}px`,
|
||||
color: scheme.value.foreground,
|
||||
'--resident-background': `${scheme.value.color}${Math.round(scheme.value.opacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}`,
|
||||
'--resident-radius': `${scheme.value.radius * fit.value.scale}px`,
|
||||
width: `${morph.value.width}px`,
|
||||
height: `${morph.value.height}px`,
|
||||
'--card-path': `path("${path.value}")`,
|
||||
'--island-font': `${props.appearance.fontSize}px`,
|
||||
'--island-accent': props.appearance.accent,
|
||||
'--state-color':
|
||||
props.state.active?.priority === 'URGENT'
|
||||
? '#ff453a'
|
||||
: props.state.active?.kind === 'call'
|
||||
? '#ff9f0a'
|
||||
: props.appearance.accent,
|
||||
'--resident-background': 'var(--wn-bg)',
|
||||
'--resident-radius': `${morph.value.radius}px`,
|
||||
'--resident-scale': fit.value.scale,
|
||||
'--custom-row-gap': `${scheme.value.spacing}px`,
|
||||
'--resident-row-width': `${Math.max(60, available.value.width / scheme.value.scale - 58)}px`,
|
||||
'--secondary': `${CI.secondaryFontSize}px`,
|
||||
'--emphasized': `${CI.emphasizedFontSize}px`,
|
||||
'--large': `${CI.largeFontSize}px`,
|
||||
'--resident-duration': motion.value ? `${550 / props.appearance.speed}ms` : '0ms',
|
||||
'--secondary': `${props.appearance.fontSize * 0.78}px`,
|
||||
'--emphasized': `${props.appearance.fontSize * 1.1}px`,
|
||||
'--large': `${props.appearance.fontSize * 1.3}px`,
|
||||
'--resident-fade-duration': motion.value ? `${200 / props.appearance.speed}ms` : '0ms',
|
||||
}));
|
||||
let morphFrame = 0;
|
||||
let morphFallback: ReturnType<typeof setTimeout> | undefined;
|
||||
function animate() {
|
||||
const size = fit.value;
|
||||
const radius = Math.min(
|
||||
expanded.value ? props.appearance.radius * 2.4 : props.appearance.radius,
|
||||
size.width / 2,
|
||||
size.height / 2,
|
||||
);
|
||||
const target = { width: size.width, height: size.height, radius };
|
||||
const snap = () => {
|
||||
cancelAnimationFrame(morphFrame);
|
||||
morphFrame = 0;
|
||||
clearTimeout(morphFallback);
|
||||
for (const key of ['width', 'height', 'radius'] as const) springs[key].snap(target[key]);
|
||||
morph.value = target;
|
||||
void nextTick(reportHit);
|
||||
};
|
||||
if (!motion.value || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
snap();
|
||||
return;
|
||||
}
|
||||
for (const key of ['width', 'height', 'radius'] as const) springs[key].setTarget(target[key]);
|
||||
clearTimeout(morphFallback);
|
||||
morphFallback = setTimeout(snap, 1200 / props.appearance.speed);
|
||||
if (morphFrame) return;
|
||||
let last = performance.now();
|
||||
const frame = (time: number) => {
|
||||
const dt = (time - last) * props.appearance.speed;
|
||||
last = time;
|
||||
// 三个维度每帧都推进;不能用短路 || 导致后面的维度等宽度结束才开始。
|
||||
const moving = Object.values(springs)
|
||||
.map((s) => s.step(dt))
|
||||
.some(Boolean);
|
||||
morph.value = { width: springs.width.value, height: springs.height.value, radius: springs.radius.value };
|
||||
if (moving) morphFrame = requestAnimationFrame(frame);
|
||||
else {
|
||||
morphFrame = 0;
|
||||
clearTimeout(morphFallback);
|
||||
void nextTick(reportHit);
|
||||
}
|
||||
};
|
||||
morphFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
function action(value: 'expand' | 'collapse' | 'expand-list' | 'dismiss-all' | 'mark-all-read' | 'open-app') {
|
||||
window.island?.sendAction(value);
|
||||
}
|
||||
@@ -161,26 +207,36 @@ function measure() {
|
||||
});
|
||||
});
|
||||
}
|
||||
function resize() {
|
||||
function resizeCanvas(width = innerWidth, height = innerHeight) {
|
||||
available.value = {
|
||||
width: Math.max(40, innerWidth - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
height: Math.max(40, innerHeight - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
width: Math.max(40, width - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
height: Math.max(40, height - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
};
|
||||
measure();
|
||||
}
|
||||
const resize = () => resizeCanvas();
|
||||
let stopViewport: (() => void) | undefined;
|
||||
function visibility() {
|
||||
visible.value = !document.hidden;
|
||||
void nextTick(reportHit);
|
||||
}
|
||||
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
||||
watch([expanded, scheme, () => props.state.listExpanded, messages], measure, { deep: true, flush: 'post' });
|
||||
watch(fit, trackHit, { flush: 'post' });
|
||||
watch(
|
||||
[fit, motion, () => props.appearance.radius, () => props.appearance.speed],
|
||||
() => {
|
||||
animate();
|
||||
trackHit();
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
watch(
|
||||
() => props.appearance.position,
|
||||
() => void nextTick(trackHit),
|
||||
);
|
||||
onMounted(() => {
|
||||
resize();
|
||||
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
|
||||
observer = new ResizeObserver(measure);
|
||||
if (compact.value) observer.observe(compact.value);
|
||||
if (details.value) observer.observe(details.value);
|
||||
@@ -190,10 +246,13 @@ onMounted(() => {
|
||||
void document.fonts.ready.then(measure);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
stopViewport?.();
|
||||
observer?.disconnect();
|
||||
clearInterval(clock);
|
||||
clearTimeout(hitTimer);
|
||||
cancelAnimationFrame(hitFrame);
|
||||
cancelAnimationFrame(morphFrame);
|
||||
clearTimeout(morphFallback);
|
||||
window.removeEventListener('resize', resize);
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
window.island?.reportResidentControl(null);
|
||||
@@ -209,19 +268,42 @@ onUnmounted(() => {
|
||||
>
|
||||
<section
|
||||
ref="shell"
|
||||
class="custom-card resident-card"
|
||||
:class="{ 'resident-open': expanded, 'resident-separated': scheme.separated && !expanded }"
|
||||
class="custom-card resident-card island-card"
|
||||
:data-style="appearance.style"
|
||||
:class="{
|
||||
'resident-open': expanded,
|
||||
expanded,
|
||||
pill: !expanded,
|
||||
urgent: state.active?.priority === 'URGENT',
|
||||
call: state.active?.kind === 'call',
|
||||
'resident-separated': scheme.separated && !expanded,
|
||||
}"
|
||||
:style="style"
|
||||
:role="expanded ? 'region' : 'button'"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
expanded ? '常驻岛内容' : messages.length ? `查看 ${messages.length} 条通知` : '展开常驻岛'
|
||||
expanded
|
||||
? '自定义灵动岛内容'
|
||||
: messages.length
|
||||
? `查看 ${messages.length} 条通知`
|
||||
: '展开自定义灵动岛'
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
@click="open"
|
||||
@keydown="key"
|
||||
@transitionend.self="reportHit"
|
||||
>
|
||||
<svg
|
||||
class="shape"
|
||||
:width="morph.width"
|
||||
:height="morph.height"
|
||||
:viewBox="`0 0 ${morph.width} ${morph.height}`"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path :d="path" />
|
||||
</svg>
|
||||
<span class="tint" aria-hidden="true" />
|
||||
<div
|
||||
class="resident-page resident-compact-page"
|
||||
:style="{ '--resident-scale': compactFit.scale }"
|
||||
@@ -256,7 +338,7 @@ onUnmounted(() => {
|
||||
<button
|
||||
class="resident-bell"
|
||||
:class="{ 'bell-empty': !messages.length }"
|
||||
:aria-label="messages.length ? `查看 ${messages.length} 条通知` : '展开常驻岛'"
|
||||
:aria-label="messages.length ? `查看 ${messages.length} 条通知` : '展开自定义灵动岛'"
|
||||
@click.stop="action('expand')"
|
||||
>
|
||||
<ComponentIcon v-if="messages.length" kind="notification" />
|
||||
@@ -287,48 +369,27 @@ onUnmounted(() => {
|
||||
ref="details"
|
||||
class="resident-source resident-details"
|
||||
:class="{ 'with-messages': messages.length }"
|
||||
:style="
|
||||
messages.length ? { width: `${Math.min(440, available.width / scheme.scale)}px` } : undefined
|
||||
"
|
||||
>
|
||||
<header class="resident-panel-heading">
|
||||
<span>{{ messages.length ? `通知 · ${messages.length}` : '常驻岛' }}</span>
|
||||
<button class="icon-btn" aria-label="收起常驻岛" @click.stop="action('collapse')">
|
||||
<ClassicNotifications
|
||||
v-if="messages.length"
|
||||
id="custom-alert-host"
|
||||
:state="state"
|
||||
:appearance="appearance"
|
||||
:max-height="available.height / scheme.scale"
|
||||
/>
|
||||
<header v-else class="resident-panel-heading">
|
||||
<span>自定义灵动岛</span>
|
||||
<button class="icon-btn" aria-label="收起自定义灵动岛" @click.stop="action('collapse')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="m8 14 4-4 4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="messages.length" id="custom-alert-host" class="resident-messages">
|
||||
<article
|
||||
v-for="item in visibleMessages"
|
||||
:key="item.id"
|
||||
class="list-row"
|
||||
:data-priority="item.priority"
|
||||
>
|
||||
<div class="resident-message-title">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ PRIORITY_LABELS[item.priority] }}</span>
|
||||
</div>
|
||||
<p>{{ item.content }}</p>
|
||||
<small>{{ [item.teacherName, item.courseName].filter(Boolean).join(' · ') }}</small>
|
||||
</article>
|
||||
<button
|
||||
v-if="remaining"
|
||||
class="resident-more"
|
||||
@click.stop="action(state.listExpanded ? 'open-app' : 'expand-list')"
|
||||
>
|
||||
{{
|
||||
state.listExpanded ? `其余 ${remaining} 条在应用中查看` : `展开更多(还有 ${remaining} 条)`
|
||||
}}
|
||||
</button>
|
||||
<footer class="resident-actions">
|
||||
<button @click.stop="action('open-app')">查看完整内容</button>
|
||||
<button @click.stop="action('mark-all-read')">标为已读</button>
|
||||
<button class="solid-btn" @click.stop="action('dismiss-all')">知道了</button>
|
||||
</footer>
|
||||
</div>
|
||||
<div v-else class="layout-rows resident-expanded-rows" :style="{ gap: `${scheme.spacing}px` }">
|
||||
<div
|
||||
v-if="!messages.length"
|
||||
class="layout-rows resident-expanded-rows"
|
||||
:style="{ gap: `${scheme.spacing}px` }"
|
||||
>
|
||||
<section
|
||||
v-for="row in scheme.expandedRows"
|
||||
:key="row.id"
|
||||
@@ -356,4 +417,4 @@ onUnmounted(() => {
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<style src="./resident-island.css"></style>
|
||||
<style src="./custom-runtime.css"></style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import {
|
||||
defaultIslandLayouts,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@classhelper/shared';
|
||||
import FixedIsland from './IslandApp.vue';
|
||||
import CustomIsland from './CustomIsland.vue';
|
||||
import ResidentIsland from './ResidentIsland.vue';
|
||||
import CustomIslandRuntime from './CustomIslandRuntime.vue';
|
||||
const settings = ref(defaultIslandLayouts()),
|
||||
editing = ref(false),
|
||||
ready = ref(false);
|
||||
@@ -18,7 +18,7 @@ const schedules = ref<ScheduleDto[]>([]);
|
||||
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const nativeVisible = ref(false);
|
||||
const alertHost = shallowRef<HTMLElement | null>(null);
|
||||
const resident = () => settings.value.layout === 'custom' && settings.value.residentEnabled && !editing.value;
|
||||
const resident = () => settings.value.layout === 'custom' && !editing.value;
|
||||
const state = ref<IslandState>({
|
||||
mode: 'hidden',
|
||||
active: null,
|
||||
@@ -37,7 +37,10 @@ async function done() {
|
||||
editing.value = false;
|
||||
await window.island?.setEditing(false);
|
||||
}
|
||||
let heartbeat: ReturnType<typeof setInterval>;
|
||||
onUnmounted(() => clearInterval(heartbeat));
|
||||
onMounted(async () => {
|
||||
heartbeat = setInterval(() => window.island?.alive(), 5000);
|
||||
const bridge = window.island;
|
||||
if (bridge) {
|
||||
bridge.onLayout((value) => (settings.value = value));
|
||||
@@ -49,6 +52,7 @@ onMounted(async () => {
|
||||
nativeVisible.value = await bridge.getVisible();
|
||||
appearance.value = await bridge.getAppearance();
|
||||
settings.value = await bridge.getLayout();
|
||||
schedules.value = await bridge.getSchedules();
|
||||
state.value = await bridge.getState();
|
||||
editing.value = await bridge.getEditing();
|
||||
}
|
||||
@@ -57,7 +61,7 @@ onMounted(async () => {
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ResidentIsland
|
||||
<CustomIslandRuntime
|
||||
v-if="resident()"
|
||||
:settings="settings"
|
||||
:state="state"
|
||||
@@ -70,6 +74,7 @@ onMounted(async () => {
|
||||
:settings="settings"
|
||||
:state="state"
|
||||
:editing="editing"
|
||||
:appearance="appearance"
|
||||
:schedules="schedules"
|
||||
@save="save"
|
||||
@done="done"
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
/*
|
||||
* 排版系数(必须与 @classhelper/shared 的 ISLAND_TYPE_SCALE 保持一致):
|
||||
* title 1.08 / body 0.95 / meta 0.78 / badge 0.76 / pillTitle 0.92 / pillSub 0.74
|
||||
*
|
||||
* 关键:所有文本尺寸都由 --island-font 通过 calc() 推导,所以「字号」设置一改,
|
||||
* 岛内文字立即整体等比缩放(WinIsland 的 font_size 模型),不存在写死的字号。
|
||||
*/
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei', 'PingFang SC',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.island-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 卡片与形状 */
|
||||
|
||||
.island-root.embedded .island-card {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.island-card {
|
||||
position: absolute;
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: var(--island-font, 13px);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
/* 配色 token(默认:WinIsland 纯黑风格) */
|
||||
--wn-bg: #000000;
|
||||
--wn-border: rgba(255, 255, 255, 0.118);
|
||||
--wn-text-1: rgba(255, 255, 255, 0.92);
|
||||
--wn-text-2: rgba(255, 255, 255, 0.72);
|
||||
--wn-text-3: rgba(255, 255, 255, 0.58);
|
||||
--wn-surface: rgba(255, 255, 255, 0.1);
|
||||
--wn-surface-hover: rgba(255, 255, 255, 0.18);
|
||||
--wn-divider: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.island-card[data-style='glass'] {
|
||||
/* WinIsland「glass 无 host backdrop」降级色:比纯黑透一点,但保证任何桌面下都不是白底 */
|
||||
--wn-bg: rgba(32, 32, 36, 0.804);
|
||||
--wn-border: rgba(255, 255, 255, 0.157);
|
||||
--wn-surface: rgba(255, 255, 255, 0.14);
|
||||
--wn-surface-hover: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.island-card[data-style='tinted'] {
|
||||
--wn-bg: #0b0b0f;
|
||||
--wn-border: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.island-card.hidden-placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
|
||||
.island-card .shape {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.island-card .shape path {
|
||||
fill: var(--wn-bg);
|
||||
stroke: var(--wn-border);
|
||||
stroke-width: 1;
|
||||
/*
|
||||
投影**始终声明**,形态切换只改参数 —— 不要写成"只在 .expanded 上挂 filter"。
|
||||
给元素加 / 去 CSS filter 会让 Chromium 新建或销毁它的渲染表面(effect node),
|
||||
而首帧的合成可能发生在新表面栅格化完成之前,那一帧会被当作空内容画出去;
|
||||
卡片的底色恰恰就画在这个被过滤的元素上(fill 在本元素),于是**开/合的那一瞬间
|
||||
底座会闪掉一帧、只剩未过滤的文字层**(用户反馈的"开合时一瞬间的闪动")。
|
||||
胶囊态用 0 0 0:硬轮廓投影正好压在路径底下,肉眼等同无投影,与 WinIsland
|
||||
"只有展开态有投影"的观感一致。
|
||||
*/
|
||||
filter: drop-shadow(0 var(--card-shadow-y, 0px) var(--card-shadow-blur, 0px) rgba(0, 0, 0, 0.11));
|
||||
}
|
||||
|
||||
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
|
||||
.island-card .content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
clip-path: var(--card-path);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.island-card .layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 只有"当前形态"的那一层响应鼠标,避免透明层的按钮被误点 */
|
||||
.island-card.pill .pill-layer,
|
||||
.island-card.expanded .expanded-layer {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px)。
|
||||
只改参数、不动 filter 声明本身,理由见上面的 .shape path。 */
|
||||
.island-card.expanded .shape path {
|
||||
--card-shadow-y: 2px;
|
||||
--card-shadow-blur: 3px;
|
||||
}
|
||||
|
||||
/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */
|
||||
.island-card.urgent .shape path,
|
||||
.island-card.call .shape path {
|
||||
stroke: color-mix(in srgb, var(--state-color) 62%, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
|
||||
.island-card .tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.island-card.urgent .tint {
|
||||
opacity: 1;
|
||||
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 69, 58, 0.22), transparent 68%);
|
||||
}
|
||||
|
||||
.island-card.call .tint {
|
||||
opacity: 1;
|
||||
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 159, 10, 0.2), transparent 68%);
|
||||
}
|
||||
|
||||
.island-card[data-style='tinted'] .tint {
|
||||
opacity: 1;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--island-accent) 30%, transparent),
|
||||
transparent 62%
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 胶囊(compact) */
|
||||
|
||||
.pill-layer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
/* 水平对齐交给 .pill-inner(它固定为胶囊几何,且按停靠方式贴边/居中) */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/**
|
||||
* 胶囊内容盒:**固定为胶囊自己的宽高与内边距**,不随卡片形变重排。
|
||||
*
|
||||
* 为什么必须固定:卡片在开合时 268 ⇄ 424 变宽变窄,而中心停靠下卡片的左右边都会动。
|
||||
* 内容若跟着卡片宽度走,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
|
||||
* (用户反馈的"新消息那行字往右跳一下再缩回"),底部也会被收缩的形状裁掉一块。
|
||||
* 固定成胶囊几何后:文字在整段动画里位置与省略号都不变,只是随卡片一起淡出/淡入。
|
||||
*
|
||||
* 贴哪条边与卡片停靠方式一致(左停靠贴左、右停靠贴右、居中则居中对齐)——
|
||||
* 这样卡片变宽时内容也不会横移,静止态外观与之前完全一致。
|
||||
*/
|
||||
.pill-inner {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: calc(var(--island-font, 13px) * 0.62);
|
||||
width: var(--island-w, 268px);
|
||||
height: 100%;
|
||||
padding: 0 calc(var(--island-font, 13px) * 0.78);
|
||||
}
|
||||
|
||||
.island-card[data-anchor='center'] .pill-layer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.island-card[data-anchor='right'] .pill-layer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
|
||||
*
|
||||
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
|
||||
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
|
||||
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
|
||||
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
|
||||
*/
|
||||
.island-card.closing .pill-inner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: calc(var(--island-font, 13px) * 1.24);
|
||||
height: calc(var(--island-font, 13px) * 1.24);
|
||||
border-radius: 50%;
|
||||
background: var(--wn-surface);
|
||||
color: var(--wn-text-1);
|
||||
}
|
||||
|
||||
.pill-icon svg {
|
||||
width: calc(var(--island-font, 13px) * 0.86);
|
||||
height: calc(var(--island-font, 13px) * 0.86);
|
||||
}
|
||||
|
||||
.pill-icon.call {
|
||||
color: #ff9f0a;
|
||||
}
|
||||
|
||||
.pill-icon.urgent {
|
||||
color: #ff453a;
|
||||
}
|
||||
|
||||
.pill-icon.homework {
|
||||
color: var(--island-accent);
|
||||
}
|
||||
|
||||
.pill-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.pill-title {
|
||||
font-size: calc(var(--island-font, 13px) * 0.92);
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
color: var(--wn-text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pill-sub {
|
||||
font-size: calc(var(--island-font, 13px) * 0.74);
|
||||
line-height: 1.15;
|
||||
color: var(--wn-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 多条消息时把类型做成胶囊小标签(叫人/作业/通知),收起状态也能一眼看出内容构成 */
|
||||
.pill-types {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.2);
|
||||
flex: 0 0 auto;
|
||||
padding-right: calc(var(--island-font, 13px) * 0.2);
|
||||
}
|
||||
|
||||
.pill-type {
|
||||
font-size: calc(var(--island-font, 13px) * 0.6);
|
||||
line-height: 1.4;
|
||||
padding: 0 calc(var(--island-font, 13px) * 0.36);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--wn-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pill-chevron svg {
|
||||
width: calc(var(--island-font, 13px) * 0.86);
|
||||
height: calc(var(--island-font, 13px) * 0.86);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 展开卡(expanded) */
|
||||
|
||||
.expanded-layer {
|
||||
/* 关键:展开层必须纵向排列。此前把 flex-direction 从基类挪到分层时漏了这一条,
|
||||
导致展开卡内容横排(标题/元信息/按钮散落,实机可见) */
|
||||
flex-direction: column;
|
||||
padding: calc(var(--island-font, 13px) * 1.4) calc(var(--island-font, 13px) * 1.6);
|
||||
gap: calc(var(--island-font, 13px) * 0.7);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* 多条通知的列表形态。
|
||||
*
|
||||
* 这里的每个尺寸都必须与 @classhelper/shared 的 `ISLAND_LIST_METRICS` 一一对应:
|
||||
* 主进程按那份度量算出**窗口包围盒**(列表比普通展开卡高,窗口不够高会把底部按钮裁掉),
|
||||
* 渲染进程按同一份度量排布。改这里就必须改那里,否则会出现"最后一行被切掉"。
|
||||
* 选择器一律写成 `.expanded-layer.list-mode ...`(比基类的单类选择器更具体),
|
||||
* 免得被后面定义的 `.title` / `.body` 覆盖掉尺寸——那样高度就对不上算出来的值了。
|
||||
*/
|
||||
.expanded-layer.list-mode {
|
||||
/* 高度已经算准,改 flex-start:居中排版一旦有像素级误差会把上下两端同时切掉 */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.expanded-layer.list-mode .head {
|
||||
height: calc(var(--island-font, 13px) * 1.7);
|
||||
}
|
||||
|
||||
.expanded-layer.list-mode .title {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
height: calc(var(--island-font, 13px) * 1.4);
|
||||
line-height: 1.4;
|
||||
-webkit-line-clamp: unset;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.expanded-layer.list-mode .body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
gap: calc(var(--island-font, 13px) * 0.4);
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.head-icon.list {
|
||||
background: color-mix(in srgb, var(--island-accent) 20%, transparent);
|
||||
color: var(--island-accent);
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.55);
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: calc(var(--island-font, 13px) * 2.7);
|
||||
padding: 0 calc(var(--island-font, 13px) * 0.6);
|
||||
border: none;
|
||||
border-radius: calc(var(--island-font, 13px) * 0.5);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
color: var(--wn-text-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: calc(var(--island-font, 13px) * 1.3);
|
||||
height: calc(var(--island-font, 13px) * 1.3);
|
||||
border-radius: calc(var(--island-font, 13px) * 0.36);
|
||||
background: var(--wn-surface);
|
||||
color: var(--wn-text-2);
|
||||
}
|
||||
|
||||
.row-icon svg {
|
||||
width: calc(var(--island-font, 13px) * 0.86);
|
||||
height: calc(var(--island-font, 13px) * 0.86);
|
||||
}
|
||||
|
||||
.row-icon.call {
|
||||
color: #ff9f0a;
|
||||
}
|
||||
|
||||
.row-icon.homework {
|
||||
color: var(--island-accent);
|
||||
}
|
||||
|
||||
.row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-size: calc(var(--island-font, 13px) * 0.9);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row-sub {
|
||||
font-size: calc(var(--island-font, 13px) * 0.68);
|
||||
line-height: 1.1;
|
||||
color: var(--wn-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: calc(var(--island-font, 13px) * 1.25);
|
||||
padding: 0 calc(var(--island-font, 13px) * 0.5);
|
||||
border-radius: 999px;
|
||||
font-size: calc(var(--island-font, 13px) * 0.68);
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
/* 列表底部的提示行:能展开时是按钮,屏幕到任务栏了就是一句"去应用里看" */
|
||||
.list-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
height: calc(var(--island-font, 13px) * 2.1);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
border: none;
|
||||
background: var(--wn-surface);
|
||||
color: var(--wn-text-1);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: calc(var(--island-font, 13px) * 0.5);
|
||||
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.more-app {
|
||||
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||
color: var(--wn-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.7);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.head-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(var(--island-font, 13px) * 1.62);
|
||||
height: calc(var(--island-font, 13px) * 1.62);
|
||||
border-radius: calc(var(--island-font, 13px) * 0.5);
|
||||
background: var(--wn-surface);
|
||||
color: var(--wn-text-1);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.head-icon svg {
|
||||
width: calc(var(--island-font, 13px) * 1.05);
|
||||
height: calc(var(--island-font, 13px) * 1.05);
|
||||
}
|
||||
|
||||
.head-icon.urgent {
|
||||
background: rgba(255, 69, 58, 0.2);
|
||||
color: #ff453a;
|
||||
}
|
||||
|
||||
.head-icon.call {
|
||||
background: rgba(255, 159, 10, 0.2);
|
||||
color: #ff9f0a;
|
||||
}
|
||||
|
||||
.head-icon.homework {
|
||||
background: color-mix(in srgb, var(--island-accent) 22%, transparent);
|
||||
color: var(--island-accent);
|
||||
}
|
||||
|
||||
.head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.55);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: calc(var(--island-font, 13px) * 1.5);
|
||||
padding: 0 calc(var(--island-font, 13px) * 0.62);
|
||||
border-radius: 999px;
|
||||
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
background: color-mix(in srgb, var(--island-accent) 26%, transparent);
|
||||
color: color-mix(in srgb, var(--island-accent) 70%, #ffffff);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.badge-urgent {
|
||||
background: rgba(255, 69, 58, 0.24);
|
||||
color: #ff6961;
|
||||
}
|
||||
|
||||
.badge-high {
|
||||
background: rgba(255, 159, 10, 0.22);
|
||||
color: #ffb340;
|
||||
}
|
||||
|
||||
.badge-low,
|
||||
.badge-normal {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.badge-call {
|
||||
background: rgba(255, 159, 10, 0.24);
|
||||
color: #ffb340;
|
||||
}
|
||||
|
||||
.badge-homework {
|
||||
background: color-mix(in srgb, var(--island-accent) 26%, transparent);
|
||||
color: color-mix(in srgb, var(--island-accent) 72%, #ffffff);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.32);
|
||||
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||
color: #ff6961;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.live-dot.call {
|
||||
color: #ffb340;
|
||||
}
|
||||
|
||||
.live-dot.muted {
|
||||
color: var(--wn-text-3);
|
||||
}
|
||||
|
||||
.head-sub {
|
||||
font-size: calc(var(--island-font, 13px) * 0.78);
|
||||
color: var(--wn-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(var(--island-font, 13px) * 1.7);
|
||||
height: calc(var(--island-font, 13px) * 1.7);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--wn-surface);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
background var(--ch-dur-fast) var(--ch-ease-out),
|
||||
color var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.icon-btn svg {
|
||||
width: calc(var(--island-font, 13px) * 0.8);
|
||||
height: calc(var(--island-font, 13px) * 0.8);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: calc(var(--island-font, 13px) * 1.08);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #fff;
|
||||
flex: 0 0 auto;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--island-font, 13px) * 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
margin: 0;
|
||||
font-size: calc(var(--island-font, 13px) * 0.95);
|
||||
line-height: 1.42;
|
||||
color: var(--wn-text-2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.call-hint {
|
||||
margin: 0;
|
||||
font-size: calc(var(--island-font, 13px) * 0.85);
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--state-color) 66%, #ffffff);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(var(--island-font, 13px) * 0.7);
|
||||
flex: 0 0 auto;
|
||||
margin-top: calc(var(--island-font, 13px) * 0.5);
|
||||
padding-top: calc(var(--island-font, 13px) * 0.7);
|
||||
border-top: 1px solid var(--wn-divider);
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: calc(var(--island-font, 13px) * 0.78);
|
||||
color: var(--wn-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more.muted {
|
||||
color: var(--wn-text-3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.5);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.solid-btn {
|
||||
height: calc(var(--island-font, 13px) * 2.05);
|
||||
padding: 0 calc(var(--island-font, 13px) * 0.9);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--ch-dur-fast) var(--ch-ease-out),
|
||||
transform var(--ch-dur-instant) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: var(--wn-surface);
|
||||
color: var(--wn-text-1);
|
||||
}
|
||||
|
||||
.solid-btn {
|
||||
background: #ffffff;
|
||||
color: #0b0b0f;
|
||||
}
|
||||
|
||||
.island-card.call .solid-btn {
|
||||
background: #ff9f0a;
|
||||
color: #1a1205;
|
||||
}
|
||||
|
||||
.island-card.urgent .solid-btn {
|
||||
background: #ff453a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.ghost-btn:active,
|
||||
.solid-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- 悬停微交互
|
||||
*
|
||||
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
|
||||
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
|
||||
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
|
||||
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
|
||||
*/
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.list-row:hover {
|
||||
background: var(--wn-surface);
|
||||
}
|
||||
|
||||
.more-btn:hover,
|
||||
.ghost-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 同一个通知组件用于经典岛绝对层与自定义岛自然测量层。 */
|
||||
.classic-notifications {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
+47
-96
@@ -1,4 +1,4 @@
|
||||
/* ClassIsland ClassicTheme: 40px 行高、12px 内边距、550ms 背景、200ms 内容淡入。 */
|
||||
/* 自定义岛:共用经典岛形状/动效与通知排版,组件行采用居中自然尺寸并自适应。 */
|
||||
.resident-window {
|
||||
position: fixed;
|
||||
inset: 10px;
|
||||
@@ -16,18 +16,14 @@
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
clip-path: var(--card-path);
|
||||
isolation: isolate;
|
||||
pointer-events: auto;
|
||||
background: var(--resident-background);
|
||||
background: none;
|
||||
border-radius: var(--resident-radius);
|
||||
font-size: 16px;
|
||||
font-size: var(--island-font);
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 2px 3px #0000001c;
|
||||
transition:
|
||||
width var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1),
|
||||
height var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1),
|
||||
border-radius var(--resident-fade-duration) ease;
|
||||
box-shadow: none;
|
||||
}
|
||||
.resident-window[data-anchor='left'] .resident-card {
|
||||
left: 0;
|
||||
@@ -86,7 +82,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
min-height: 40px;
|
||||
min-height: max(40px, calc(var(--island-font) * 2.5));
|
||||
}
|
||||
.resident-window .resident-card.resident-separated {
|
||||
background: transparent;
|
||||
@@ -109,17 +105,20 @@
|
||||
.resident-card .layout-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.resident-card .row-components {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
min-height: 40px;
|
||||
min-height: max(40px, calc(var(--island-font) * 2.5));
|
||||
}
|
||||
.resident-card .resident-compact .component-node:not(.container) {
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
min-height: max(40px, calc(var(--island-font) * 2.5));
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 calc(var(--row-gap, 12px) / 2);
|
||||
@@ -158,6 +157,7 @@
|
||||
.resident-card .resident-details {
|
||||
min-width: 160px;
|
||||
padding: 0 12px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.resident-card .resident-panel-heading {
|
||||
display: flex;
|
||||
@@ -193,89 +193,6 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.resident-card .resident-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.resident-card .list-row {
|
||||
display: block;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #ffffff18;
|
||||
border-radius: 6px;
|
||||
background: #ffffff08;
|
||||
}
|
||||
.resident-card .resident-message-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.resident-card .resident-message-title strong {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.resident-card .resident-message-title > span {
|
||||
flex: none;
|
||||
color: #ffffff80;
|
||||
font-size: 11px;
|
||||
}
|
||||
.resident-card .list-row[data-priority='URGENT'] .resident-message-title > span {
|
||||
color: #ff9c94;
|
||||
}
|
||||
.resident-card .list-row p {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
margin: 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.resident-card .list-row:only-of-type p {
|
||||
-webkit-line-clamp: 5;
|
||||
}
|
||||
.resident-card .list-row small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: #ffffff80;
|
||||
}
|
||||
.resident-card .resident-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.resident-card .resident-actions button:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
.resident-card .resident-actions button,
|
||||
.resident-card .resident-more {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #ffffff0d;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.resident-card .resident-actions .solid-btn {
|
||||
background: #ffffffdd;
|
||||
color: #202124;
|
||||
}
|
||||
.resident-card button:focus-visible {
|
||||
outline: 2px solid #adc4ff;
|
||||
outline-offset: -2px;
|
||||
@@ -302,3 +219,37 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.resident-card .resident-details.with-messages {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.resident-card .classic-notifications {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
.resident-card .classic-notifications .head-meta {
|
||||
justify-content: center;
|
||||
}
|
||||
.resident-card .classic-notifications .foot {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.resident-card .classic-notifications .row-text {
|
||||
align-items: center;
|
||||
}
|
||||
.resident-card .classic-notifications .row-title,
|
||||
.resident-card .classic-notifications .row-sub {
|
||||
max-width: 100%;
|
||||
}
|
||||
.resident-card .resident-source .component-node {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.resident-card .resident-compact .schedule > div {
|
||||
align-items: center;
|
||||
}
|
||||
.resident-card.resident-separated > .shape,
|
||||
.resident-card.resident-separated > .tint {
|
||||
visibility: hidden;
|
||||
}
|
||||
Reference in new issue
Block a user