feat: redesign live recorder control console ui
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { Box } from "@element-plus/icons-vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
actionText?: string;
|
||||
}>(),
|
||||
{
|
||||
title: "暂无数据",
|
||||
description: "当前筛选条件下没有可展示内容",
|
||||
actionText: ""
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(event: "action"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
<div class="empty-state__title">{{ title }}</div>
|
||||
<div class="empty-state__description">{{ description }}</div>
|
||||
<el-button v-if="actionText" type="primary" plain @click="$emit('action')">
|
||||
{{ actionText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 42px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: var(--accent);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state__description {
|
||||
max-width: 36ch;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon?: Component | null;
|
||||
}>(),
|
||||
{
|
||||
description: "",
|
||||
icon: null
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card">
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
<span v-if="icon" class="metric-card__icon">
|
||||
<el-icon><component :is="icon" /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-card__value">{{ value }}</div>
|
||||
<div v-if="description" class="metric-card__description">{{ description }}</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 164px;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.metric-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(28px, 2vw, 40px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-card__description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
size?: string | number;
|
||||
}>(),
|
||||
{
|
||||
subtitle: "",
|
||||
size: "520px"
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" class="right-drawer" direction="rtl" :size="size" :with-header="false">
|
||||
<div class="right-drawer__shell">
|
||||
<header class="right-drawer__header">
|
||||
<div class="right-drawer__copy">
|
||||
<div class="right-drawer__eyebrow">详情面板</div>
|
||||
<h3 class="right-drawer__title">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="right-drawer__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<el-button class="right-drawer__close" text circle @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<div class="right-drawer__body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer v-if="$slots.footer" class="right-drawer__footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.right-drawer .el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.right-drawer__shell {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
|
||||
}
|
||||
|
||||
.right-drawer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 24px 24px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
margin-bottom: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.right-drawer__title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.right-drawer__subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.right-drawer__close {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.right-drawer__body {
|
||||
flex: 1;
|
||||
padding: 20px 24px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
type BadgeContext =
|
||||
| "generic"
|
||||
| "availability"
|
||||
| "recording"
|
||||
| "task"
|
||||
| "session"
|
||||
| "cleanup"
|
||||
| "upload"
|
||||
| "boolean";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
status?: string | number | boolean | null;
|
||||
context?: BadgeContext;
|
||||
size?: "sm" | "md";
|
||||
}>(),
|
||||
{
|
||||
label: "",
|
||||
status: null,
|
||||
context: "generic",
|
||||
size: "md"
|
||||
}
|
||||
);
|
||||
|
||||
type Tone = "gray" | "green" | "blue" | "yellow" | "red" | "orange" | "indigo";
|
||||
|
||||
function normalizeValue(value: string | number | boolean | null) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveToneByNumber(value: number, context: BadgeContext): Tone {
|
||||
if (context === "availability") {
|
||||
if (value === 2) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "recording") {
|
||||
if (value === 2) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (value === 1) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "task" || context === "session") {
|
||||
if (value === 2) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (value === 1 || value === 7) {
|
||||
return "indigo";
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
if (value === 3) {
|
||||
return "orange";
|
||||
}
|
||||
|
||||
if (value === 4) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
if (value === 5) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "upload") {
|
||||
if (value === 1) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
if (value === 2) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "boolean") {
|
||||
return value ? "green" : "gray";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function resolveToneByKeyword(value: string): Tone {
|
||||
if (
|
||||
value.includes("live") ||
|
||||
value.includes("online") ||
|
||||
value.includes("living") ||
|
||||
value.includes("开播") ||
|
||||
value.includes("直播中") ||
|
||||
value === "started" ||
|
||||
value === "completed" ||
|
||||
value.includes("归档") ||
|
||||
value.includes("healthy")
|
||||
) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
if (value.includes("recording") || value.includes("录制中") || value.includes("uploading")) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (
|
||||
value.includes("pending") ||
|
||||
value.includes("queued") ||
|
||||
value.includes("待") ||
|
||||
value.includes("waiting") ||
|
||||
value.includes("storage")
|
||||
) {
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
if (value.includes("retry") || value.includes("stopping") || value.includes("停止中")) {
|
||||
return "orange";
|
||||
}
|
||||
|
||||
if (value.includes("process") || value.includes("transcod")) {
|
||||
return "indigo";
|
||||
}
|
||||
|
||||
if (
|
||||
value.includes("error") ||
|
||||
value.includes("fail") ||
|
||||
value.includes("异常") ||
|
||||
value.includes("错误") ||
|
||||
value.includes("离线") ||
|
||||
value === "poll_failed"
|
||||
) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
const tone = computed<Tone>(() => {
|
||||
const normalized = normalizeValue(props.status);
|
||||
|
||||
if (typeof normalized === "number") {
|
||||
return resolveToneByNumber(normalized, props.context);
|
||||
}
|
||||
|
||||
if (typeof normalized === "boolean") {
|
||||
return normalized ? "green" : "gray";
|
||||
}
|
||||
|
||||
if (typeof normalized === "string" && normalized.length > 0) {
|
||||
return resolveToneByKeyword(normalized);
|
||||
}
|
||||
|
||||
return "gray";
|
||||
});
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (props.label) {
|
||||
return props.label;
|
||||
}
|
||||
|
||||
if (props.status === null || props.status === undefined || props.status === "") {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return String(props.status);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="[`status-badge--${tone}`, `status-badge--${size}`]">
|
||||
<span class="status-badge__dot"></span>
|
||||
<span>{{ displayLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge--sm {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge--md {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-badge--gray {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-badge--green {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border-color: rgba(34, 197, 94, 0.18);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.status-badge--blue {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.status-badge--yellow {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.18);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.status-badge--red {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.16);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-badge--orange {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
border-color: rgba(249, 115, 22, 0.18);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.status-badge--indigo {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.18);
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--gray {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--green {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--blue {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--yellow {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--red {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--orange {
|
||||
color: #fdba74;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--indigo {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user