feat(embed-slots): soft-delete slots — admin entry, ov shutdown via enabled=false snapshot

- 契约 deleteEmbedSlot(DELETE /admin/embed-slots/{id} → 204 幂等;admin 专属)
- cn schema:embed_slots.deletedAt(迁移 20260923120000,本地已应用)
- softDelete:乐观锁 claim + outbox slot.updated(payload enabled 强制 false
  ——ov 副本无墓碑,enabled=false 快照即公开端 404 下线;priority=high)+ audit
- 列表/加载/更新过滤 deletedAt → 同码 404
- Admin 列表操作「删除」(确认:已接入网站将停止展示、不可恢复)
- API 56/56、admin 87/87 + build 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 13:37:39 +08:00
parent aa366c4268
commit 903962a46b
12 changed files with 205 additions and 142 deletions
@@ -274,10 +274,6 @@ function onStageClick(event: MouseEvent): void {
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
if (moreMenuOpen.value) {
moreMenuOpen.value = false;
return;
}
emit('close');
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
if (inMenu(event.target)) return;
@@ -440,17 +436,6 @@ function shareCurrent(): void {
shareSheetOpen.value = true;
}
// —— 顶栏「⋯」更多菜单(§5.1:必须有实际功能——减少动画开关) ——
const moreMenuOpen = ref(false);
const moreRootEl = ref<HTMLElement | null>(null);
function onOuterPointerDown(event: PointerEvent): void {
if (moreMenuOpen.value && moreRootEl.value !== null && !moreRootEl.value.contains(event.target as Node)) {
moreMenuOpen.value = false;
}
}
// —— 签名过期/下线处理 ——
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
@@ -501,14 +486,12 @@ const thumbItems = computed<ThumbnailItem[]>(() =>
onMounted(() => {
window.addEventListener('keydown', onKeydown);
document.addEventListener('pointerdown', onOuterPointerDown, true);
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; // 锁背景滚动(§5.1)
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
document.removeEventListener('pointerdown', onOuterPointerDown, true);
document.body.style.overflow = savedBodyOverflow;
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
@@ -543,34 +526,6 @@ onBeforeUnmount(() => {
</svg>
</button>
<p class="position-indicator">{{ positionText }}</p>
<div ref="moreRootEl" class="more-menu">
<button
type="button"
class="viewer-more"
:aria-label="t('viewer.more')"
aria-haspopup="menu"
:aria-expanded="moreMenuOpen"
@click="moreMenuOpen = !moreMenuOpen"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="19" cy="12" r="2" fill="currentColor" />
</svg>
</button>
<div v-if="moreMenuOpen" class="more-popover" role="menu" :aria-label="t('viewer.more')">
<button
type="button"
class="more-item"
role="menuitemcheckbox"
:aria-checked="reducedMotion"
@click="toggleReducedMotion"
>
<span class="more-check" aria-hidden="true">{{ reducedMotion ? '✓' : '' }}</span>
{{ t('viewer.reduceMotion') }}
</button>
</div>
</div>
</header>
<!-- 图片舞台(flex:1):contain 大图 + 边缘切换 + 粒子/互动占位层 -->
@@ -721,85 +676,6 @@ onBeforeUnmount(() => {
background: rgba(14, 18, 24, 0.72);
}
.viewer-close,
.viewer-more {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--viewer-touch-target, 44px);
height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 50%;
background: transparent;
color: #ffffff;
cursor: pointer;
}
.viewer-close:hover,
.viewer-more:hover {
background: rgba(255, 255, 255, 0.1);
}
.viewer-close:focus-visible,
.viewer-more:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.viewer-close svg,
.viewer-more svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.position-indicator {
margin: 0;
text-align: center;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.92);
font-variant-numeric: tabular-nums;
}
.more-menu {
position: relative;
justify-self: end;
}
.more-popover {
position: absolute;
top: calc(100% + 2px);
right: 0;
z-index: 6;
display: grid;
min-width: 176px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(22, 28, 36, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 4px;
}
.more-item {
display: flex;
align-items: center;
gap: 8px;
min-height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 8px;
background: transparent;
color: #ffffff;
padding: 0 12px;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
}
.more-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.more-item:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.more-check {
display: inline-block;
width: 1em;
color: var(--viewer-primary, #1a73e8);
font-weight: 700;
}
/* 舞台:占剩余空间;contain 完整大图(不裁切);手势区 touch-action none。 */
.viewer-stage {