feat: harden recording lifecycle and refresh fnOS UI
This commit is contained in:
@@ -229,7 +229,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
|
||||
<style scoped>
|
||||
/* ==================== Shell ==================== */
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
.app-shell { display: flex; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; }
|
||||
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); z-index: 39; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.scrim.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
@@ -287,12 +287,16 @@ html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
|
||||
.collapsed .app-health { justify-content: center; padding: 10px 0; }
|
||||
|
||||
/* ==================== Main column ==================== */
|
||||
.app-main-col { flex: 1; margin-left: var(--sidebar-w); min-width: 0; transition: margin .2s ease; }
|
||||
.app-main-col {
|
||||
display: flex; flex: 1; flex-direction: column; height: 100dvh;
|
||||
min-width: 0; min-height: 0; margin-left: var(--sidebar-w); overflow: hidden;
|
||||
transition: margin .2s ease;
|
||||
}
|
||||
.app-sidebar.collapsed ~ .app-main-col { margin-left: var(--sidebar-w-collapsed); }
|
||||
|
||||
/* ==================== Topbar ==================== */
|
||||
.app-topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
position: relative; z-index: 20; flex: 0 0 var(--topbar-h);
|
||||
height: var(--topbar-h); display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 24px;
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
@@ -312,7 +316,10 @@ html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06)
|
||||
.app-user-btn__name { font-size: 13px; font-weight: 700; }
|
||||
|
||||
/* ==================== Main ==================== */
|
||||
.app-main { padding: 28px 32px; overflow-x: hidden; }
|
||||
.app-main {
|
||||
flex: 1; min-width: 0; min-height: 0; padding: 24px 28px 0;
|
||||
overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable;
|
||||
}
|
||||
.backend-alert { width: min(100%, var(--page-max)); margin: 0 auto 18px; border-radius: var(--radius-md); }
|
||||
|
||||
/* ==================== Responsive ==================== */
|
||||
@@ -322,7 +329,7 @@ html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06)
|
||||
.app-sidebar:not(.mobile-open) .app-brand { display: none; }
|
||||
.app-sidebar:not(.mobile-open) .app-sidebar__foot { display: none; }
|
||||
.app-main-col { margin-left: 0 !important; }
|
||||
.app-main { padding: 12px 12px; }
|
||||
.app-main { padding: 12px 12px 0; }
|
||||
.app-topbar { padding: 0 10px; gap: 6px; }
|
||||
.app-topbar__menu-btn { display: grid; }
|
||||
.app-breadcrumb { font-size: 11.5px; max-width: 130px; }
|
||||
|
||||
@@ -52,12 +52,16 @@ const visible = computed({
|
||||
|
||||
<style scoped>
|
||||
:deep(.right-drawer .el-drawer__body) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-drawer__shell {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -69,6 +73,7 @@ const visible = computed({
|
||||
gap: 16px;
|
||||
padding: 24px 24px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
@@ -100,9 +105,12 @@ const visible = computed({
|
||||
}
|
||||
|
||||
.right-drawer__body {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 20px 24px 24px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
@@ -111,5 +119,7 @@ const visible = computed({
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex: 0 0 auto;
|
||||
background: var(--surface);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
}>(), {
|
||||
src: "",
|
||||
alt: "",
|
||||
size: 48
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [event: Event];
|
||||
}>();
|
||||
const failed = ref(false);
|
||||
const style = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`
|
||||
}));
|
||||
|
||||
watch(() => props.src, () => {
|
||||
failed.value = false;
|
||||
});
|
||||
|
||||
function handleError(event: Event) {
|
||||
failed.value = true;
|
||||
emit("error", event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="safe-avatar" :style="style">
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleError"
|
||||
/>
|
||||
<span v-else class="safe-avatar__fallback"><slot /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.safe-avatar {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.safe-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.safe-avatar__fallback {
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
interface StorageCapacityStatus {
|
||||
isEnabled: boolean;
|
||||
isAvailable: boolean;
|
||||
checkedPath: string;
|
||||
totalBytes: number;
|
||||
usedBytes: number;
|
||||
availableBytes: number;
|
||||
usagePercent: number;
|
||||
freePercent: number;
|
||||
tier: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ status: StorageCapacityStatus }>();
|
||||
|
||||
const percentage = computed(() => {
|
||||
const value = Number(props.status.usagePercent);
|
||||
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;
|
||||
});
|
||||
|
||||
const tier = computed(() => props.status.tier?.toLowerCase() || "red");
|
||||
const statusLabel = computed(() => {
|
||||
if (!props.status.isAvailable) return "无法读取";
|
||||
if (!props.status.isEnabled) return "保护未启用";
|
||||
if (tier.value === "green") return "空间充足";
|
||||
if (tier.value === "yellow") return "空间偏低";
|
||||
return "空间紧张";
|
||||
});
|
||||
const tagType = computed<"success" | "warning" | "danger" | "info">(() => {
|
||||
if (!props.status.isAvailable) return "danger";
|
||||
if (!props.status.isEnabled) return "info";
|
||||
if (tier.value === "green") return "success";
|
||||
if (tier.value === "yellow") return "warning";
|
||||
return "danger";
|
||||
});
|
||||
const barColor = computed(() => {
|
||||
if (!props.status.isAvailable) return "var(--danger)";
|
||||
if (!props.status.isEnabled) return "var(--text-muted)";
|
||||
if (tier.value === "green") return "var(--success)";
|
||||
if (tier.value === "yellow") return "var(--warning)";
|
||||
return "var(--danger)";
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "--";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="storage-capacity" data-testid="storage-capacity">
|
||||
<div class="storage-capacity__header">
|
||||
<div>
|
||||
<div class="storage-capacity__eyebrow">录制存储</div>
|
||||
<div class="storage-capacity__headline">
|
||||
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
|
||||
<span>已使用</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="tagType" effect="light">{{ statusLabel }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:stroke-width="12"
|
||||
:show-text="false"
|
||||
:color="barColor"
|
||||
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
|
||||
/>
|
||||
|
||||
<dl class="storage-capacity__metrics">
|
||||
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
|
||||
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
|
||||
<div><dt>总容量</dt><dd>{{ status.isAvailable ? formatBytes(status.totalBytes) : "--" }}</dd></div>
|
||||
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="storage-capacity__foot">
|
||||
<span class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</span>
|
||||
<el-tooltip v-if="status.message" :content="status.message" placement="top">
|
||||
<span class="storage-capacity__help" tabindex="0">状态说明</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.storage-capacity { display: grid; gap: 18px; }
|
||||
.storage-capacity__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.storage-capacity__eyebrow { color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.storage-capacity__headline { display: flex; align-items: baseline; gap: 8px; margin-top: 5px; }
|
||||
.storage-capacity__headline strong { color: var(--text-primary); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__headline span { color: var(--text-muted); font-size: 13px; }
|
||||
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; }
|
||||
.storage-capacity__metrics > div { min-width: 0; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-muted); }
|
||||
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 11px; }
|
||||
.storage-capacity__metrics dd { margin: 4px 0 0; color: var(--text-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; color: var(--text-muted); font-size: 12px; }
|
||||
.storage-capacity__path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); }
|
||||
.storage-capacity__help { flex: 0 0 auto; color: var(--accent); cursor: help; }
|
||||
@media (max-width: 640px) {
|
||||
.storage-capacity__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.storage-capacity__foot { align-items: flex-start; flex-direction: column; }
|
||||
.storage-capacity__path { width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user