-
+
{{ getRoomAvatarText(row) }}
-
+
- {{ row.title || row.anchorName || row.roomId }}
@@ -987,32 +845,36 @@ onBeforeUnmount(() => {
- 查看详情
- 刷新
- 配置
- 复制链接
- 打开原房间
-
- 录制
-
- 删除
+ 查看详情
+
+ 更多
+
+ 开始录制
+ 刷新状态
+ 房间配置
+ 复制链接
+ 打开原房间
+ 删除
+
+
+
+
-
{
-
-
-
+
-
+
{{ getRoomAvatarText(row) }}
-
+
-
-
-
-
+
@@ -1154,9 +1009,9 @@ onBeforeUnmount(() => {
>
- 应用筛选
+ 应用筛选
@@ -230,8 +246,7 @@ onBeforeUnmount(() => {
diff --git a/frontend/src/views/MediaBrowserView.vue b/frontend/src/views/MediaBrowserView.vue
index c320a89..0bb7558 100644
--- a/frontend/src/views/MediaBrowserView.vue
+++ b/frontend/src/views/MediaBrowserView.vue
@@ -14,15 +14,36 @@ const browser = ref(null);
const previewVisible = ref(false);
const previewTitle = ref("");
const previewUrl = ref("");
+const mediaSearch = ref("");
+const mediaType = ref("all");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
+const filteredItems = computed(() => {
+ const keyword = mediaSearch.value.trim().toLowerCase();
+ return (browser.value?.items ?? []).filter((item) => {
+ const matchesKeyword = !keyword || [item.name, item.relativePath].some((value) => value.toLowerCase().includes(keyword));
+ const matchesType = mediaType.value === "all" ||
+ (mediaType.value === "directory" && item.type === "directory") ||
+ (mediaType.value === "video" && (item.type === "mp4" || item.type === "ts")) ||
+ (mediaType.value === "xml" && item.type === "xml") ||
+ (mediaType.value === "other" && !["directory", "mp4", "ts", "xml"].includes(item.type));
+ return matchesKeyword && matchesType;
+ });
+});
+
+function clearFilters() {
+ mediaSearch.value = "";
+ mediaType.value = "all";
+}
async function loadDirectory(path = "") {
loading.value = true;
loadError.value = "";
+ mediaSearch.value = "";
+ mediaType.value = "all";
try {
const { data } = await apiClient.get("/media/browser", {
@@ -181,18 +202,30 @@ onMounted(() => {
+
-
{{ row.title || row.anchorName || row.roomId }}
{{ row.anchorName || "未知主播" }}
@@ -1112,34 +974,27 @@ onBeforeUnmount(() => {
-
- 在线人数 --
- 码率 --
- 采集账号 --
-
-
- {{ formatDate(row.lastCheckedAt) }}
- 查看
- 刷新
- 配置
- 复制链接
- 打开原房间
-
- 开始录制
-
- 删除
+ 查看
+
+ 更多
+
+ 开始录制
+ 刷新状态
+ 房间配置
+ 复制链接
+ 打开原房间
+ 删除
+
+
-
+
{{ getRoomAvatarText(activeRoom) }}
-
+
([]);
const loadError = ref("");
const { isMobile } = useViewport();
@@ -31,13 +33,25 @@ const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].c
let autoRefreshTimer: number | null = null;
-async function loadLogs() {
- if (loading.value) {
+function mergeLogs(nextLogs: SystemLog[]) {
+ const previousById = new Map(logs.value.map((item) => [item.id, item]));
+ return nextLogs.map((item) => {
+ const previous = previousById.get(item.id);
+ return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
+ });
+}
+
+async function loadLogs(background = false) {
+ if (requestInFlight.value) {
return;
}
- loading.value = true;
- loadError.value = "";
+ requestInFlight.value = true;
+ initialLoading.value = !background && logs.value.length === 0;
+ refreshing.value = !initialLoading.value;
+ if (!background) {
+ loadError.value = "";
+ }
try {
const { data } = await apiClient.get("/logs", {
@@ -50,11 +64,13 @@ async function loadLogs() {
}
});
- logs.value = data;
+ logs.value = mergeLogs(data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
} finally {
- loading.value = false;
+ requestInFlight.value = false;
+ initialLoading.value = false;
+ refreshing.value = false;
}
}
@@ -63,7 +79,7 @@ async function autoRefreshLogs() {
return;
}
- await loadLogs();
+ await loadLogs(true);
}
function startAutoRefresh() {
@@ -126,7 +142,7 @@ onBeforeUnmount(() => {
@@ -187,7 +203,7 @@ onBeforeUnmount(() => {
{{ activeRoom.anchorName || "未知主播" }}
{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}
@@ -1674,28 +1529,18 @@ onBeforeUnmount(() => {
font-size: 13px;
}
+.list-filterbar {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) 180px auto;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+
.rooms-table-shell {
- overflow: hidden;
-}
-
-.rooms-table-proxy-scroll {
- position: sticky;
- top: 0;
- z-index: 5;
overflow-x: auto;
- overflow-y: hidden;
- margin-bottom: 10px;
- padding-bottom: 6px;
- background: var(--surface);
- scrollbar-width: thin;
-}
-
-.rooms-table-proxy-scroll__inner {
- height: 1px;
-}
-
-.rooms-table {
- min-width: 1920px;
}
.rooms-table :deep(.el-table__cell) {
@@ -1724,6 +1569,11 @@ onBeforeUnmount(() => {
gap: 16px;
}
+.mobile-room-pagination {
+ justify-content: center;
+ padding-top: 4px;
+}
+
.room-card__toggle {
display: flex;
align-items: center;
@@ -2040,6 +1890,9 @@ onBeforeUnmount(() => {
flex-direction: column;
}
+ .list-filterbar { grid-template-columns: 1fr 160px; }
+ .list-filterbar__count { grid-column: 1 / -1; }
+
.batch-actions {
justify-content: flex-start;
}
@@ -2063,6 +1916,11 @@ onBeforeUnmount(() => {
}
}
+@media (max-width: 600px) {
+ .list-filterbar { grid-template-columns: 1fr; }
+ .list-filterbar__count { grid-column: auto; }
+}
+
@media (max-width: 640px) {
.header-actions :deep(.el-space__item) {
width: 100%;
diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue
index 95bc5c7..484bbc3 100644
--- a/frontend/src/views/LogsView.vue
+++ b/frontend/src/views/LogsView.vue
@@ -7,7 +7,9 @@ import { logLevelLabelMap } from "@/types";
const AUTO_REFRESH_INTERVAL_MS = 15000;
-const loading = ref(false);
+const initialLoading = ref(false);
+const refreshing = ref(false);
+const requestInFlight = ref(false);
const logs = ref
- 刷新日志
+ 刷新日志
+
+
+
+
+
+
+
+
+ {{ filteredItems.length }} / {{ browser?.items.length ?? 0 }}
+
+
-
+
+
+
返回列表
刷新
上传会话
@@ -547,7 +544,7 @@ onMounted(loadDetail);
{{ formatDuration(row.durationSeconds) }}
-
+
查看分片
+
返回列表
刷新
日志已带上会话与任务关联,可看到 ffmpeg、巡检和弹幕采集的上下文。
+
+ 清除筛选
+
@@ -219,30 +252,24 @@ onMounted(() => {
{{ formatDate(row.modifiedAt) }}
-
+
diff --git a/frontend/src/views/RecordSessionDetailView.vue b/frontend/src/views/RecordSessionDetailView.vue
index 4be8f42..63ad6f9 100644
--- a/frontend/src/views/RecordSessionDetailView.vue
+++ b/frontend/src/views/RecordSessionDetailView.vue
@@ -2,7 +2,6 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
-import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
@@ -29,7 +28,6 @@ const props = defineProps<{
}>();
const router = useRouter();
-const { isMobile } = useViewport();
// Danmaku replay dialog
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
@@ -77,7 +75,6 @@ const uploadLoading = ref(false);
const loadError = ref("");
const detail = ref(null);
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
-const logTableHeight = computed(() => (isMobile.value ? undefined : 360));
const timelineDurationSeconds = computed(() => {
const total = detail.value?.timeline.totalDurationSeconds ?? 0;
@@ -283,7 +280,7 @@ onMounted(loadDetail);
-
- 进入目录
- 预览视频
- 查看 XML
-
- 下载
-
-
- 转码为 MP4
-
+ 进入
+ 预览
+ 查看
+ 下载
+
+ 更多
+
+ 下载文件
+ 转码为 MP4
+
+
-
+
@@ -792,6 +789,12 @@ onMounted(loadDetail);
color: var(--text-secondary);
}
+.segment-label,
+.log-detail {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
.preview-empty {
display: grid;
place-items: center;
diff --git a/frontend/src/views/RecordTaskDetailView.vue b/frontend/src/views/RecordTaskDetailView.vue
index 292b5c4..e6e18cb 100644
--- a/frontend/src/views/RecordTaskDetailView.vue
+++ b/frontend/src/views/RecordTaskDetailView.vue
@@ -61,7 +61,6 @@ async function toggleDanmaku() {
showDanmaku.value = true;
}
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
-const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
const activeTaskStatuses = new Set([1, 2, 3, 7]);
const canManualTranscode = computed(() => {
const task = detail.value?.task;
@@ -282,7 +281,7 @@ onMounted(loadDetailAndPreview);
-
-
+
@@ -513,10 +512,6 @@ onMounted(loadDetailAndPreview);
padding-inline: 4px;
}
-.detail-logs-table {
- min-width: 940px;
-}
-
.detail-card :deep(.el-card__body),
.preview-card :deep(.el-card__body),
.logs-card :deep(.el-card__body) {
diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue
index 25fe82f..de843f1 100644
--- a/frontend/src/views/RecordTasksView.vue
+++ b/frontend/src/views/RecordTasksView.vue
@@ -1,5 +1,5 @@
@@ -918,7 +1041,7 @@ onBeforeUnmount(() => {
+
{
padding-top: 18px;
}
+.session-pagination {
+ display: flex;
+ justify-content: center;
+ margin-top: 20px;
+}
+
.toolbar-row {
display: flex;
align-items: flex-start;
@@ -1693,7 +1777,6 @@ onBeforeUnmount(() => {
.nested-table {
border-radius: 12px;
- min-width: 1220px;
}
.nested-table :deep(.el-table__cell) {
diff --git a/frontend/src/views/RecoveryView.vue b/frontend/src/views/RecoveryView.vue
index 301735f..92a8e81 100644
--- a/frontend/src/views/RecoveryView.vue
+++ b/frontend/src/views/RecoveryView.vue
@@ -6,6 +6,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
+import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
@@ -13,8 +14,8 @@ import type {
RecoveryActionResult,
RecoveryOverview
} from "@/types";
-import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
-import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
+import { autoStartDecisionLabelMap, formatAutoStartDecisionSummary, taskStatusLabelMap } from "@/types";
+import { RefreshRight, VideoCamera } from "@element-plus/icons-vue";
const loading = ref(false);
const retryAllLoading = ref(false);
@@ -137,6 +138,10 @@ function autoStartDecisionLabel(code?: string) {
return autoStartDecisionLabelMap[code] ?? code;
}
+function autoStartDecisionSummary(item: RecoverableLiveRoom) {
+ return formatAutoStartDecisionSummary(item.lastAutoStartDecisionCode, item.lastAutoStartDecisionSummary);
+}
+
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
@@ -188,21 +193,14 @@ onMounted(loadOverview);
+
+
+
最近决策
- {{ row.lastAutoStartDecisionSummary || "暂无摘要" }}
+ {{ autoStartDecisionSummary(row) }}
原因详情
@@ -308,7 +306,7 @@ onMounted(loadOverview);
/>
{{ formatDate(row.lastAutoStartDecisionAt) }}
- ({
enableRetentionCleanup: false,
retentionDays: 30,
retentionDeleteFiles: false,
+ retentionRequireUploadSuccess: false,
retentionVideoFileCondition: "any",
retentionTaskStatuses: [],
enableAutoReconnect: true,
@@ -1128,6 +1130,10 @@ onBeforeRouteLeave(async () => {
折叠侧栏
+
-
-
+
@@ -945,28 +1068,39 @@ onBeforeUnmount(() => {
>
删除已选会话{{ selectedSessionCount > 0 ? `(${selectedSessionCount})` : "" }}
-
- 按条件清理
-
-
- 清理无分片会话
-
-
- 清理无文件分片
-
+
+ 清理工具
+
+ 按条件清理
+ 清理无分片会话
+ 清理无文件分片
+
+
+
+
+
+
+
+
+
+
+ 当前页 {{ filteredSessions.length }} / 共 {{ totalCount }}
+
+
-
+
@@ -1102,7 +1210,7 @@ onBeforeUnmount(() => {
{
{{ session.liveRoomTitle }}
@@ -1002,23 +1136,15 @@ onBeforeUnmount(() => {
- 查看会话
-
- 上传会话
-
-
- 停止会话
-
-
- 删除会话
-
+ 查看
+
+ 更多
+
+ 上传会话
+ 停止会话
+ 删除会话
+
+
@@ -1067,33 +1193,15 @@ onBeforeUnmount(() => {
- 详情
-
- 触发完成事件
-
-
- 上传
-
-
- 删除
-
+ 查看
+
+ 更多
+
+ 触发完成事件
+ 上传
+ 删除
+
+
-
- 查看会话
-
-
- 上传会话
-
-
- 停止会话
-
-
- 删除会话
-
+ 查看会话
+
+ 更多操作
+
+ 上传会话
+ 停止会话
+ 删除会话
+
+
@@ -1253,46 +1347,19 @@ onBeforeUnmount(() => {
-
+
@@ -1301,6 +1368,17 @@ onBeforeUnmount(() => {
+
+
- 详情
-
- 触发事件
-
-
- 上传
-
-
- 停止
-
-
- 删除
-
+ 查看
+
+ 更多
+
+ 触发完成事件
+ 上传
+ 停止会话
+ 删除
+
+
+
+
-
-
+
@@ -254,7 +252,7 @@ onMounted(loadOverview);
{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}
+ {{ autoStartDecisionSummary(row) }}
{{ row.lastAutoStartDecisionDetail }}
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index f6f814f..b6ca85c 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -43,6 +43,7 @@ const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
+const showAdvancedSettings = ref(false);
const { isMobile } = useViewport();
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
@@ -129,6 +130,7 @@ const form = reactive
@@ -1277,7 +1283,7 @@ onBeforeRouteLeave(async () => {
{
-
+
-
+
-
+
-
+
+
+
+
+
+
@@ -1424,7 +1435,7 @@ onBeforeRouteLeave(async () => {
-
+
(null);
const loadError = ref("");
const items = ref([]);
-const tableHeight = computed(() => (isMobile.value ? undefined : 560));
+const taskSearch = ref("");
+const taskState = ref("all");
let refreshTimer: number | null = null;
const activeCount = computed(() => items.value.filter((item) => Boolean(item.task.postProcessStage)).length);
const manualCount = computed(() => items.value.filter((item) => item.canManualTranscode).length);
const mp4Count = computed(() => items.value.filter((item) => item.task.outputFormat === 0).length);
+const filteredItems = computed(() => {
+ const keyword = taskSearch.value.trim().toLowerCase();
+ return items.value.filter((item) => {
+ const matchesKeyword = !keyword || [
+ item.task.liveRoomTitle,
+ item.task.recordSessionId,
+ item.task.id,
+ item.sourceFilePath,
+ item.result?.filePath,
+ item.task.outputFilePath
+ ].some((value) => String(value || "").toLowerCase().includes(keyword));
+ const matchesState = taskState.value === "all" ||
+ (taskState.value === "processing" && Boolean(item.task.postProcessStage)) ||
+ (taskState.value === "manual" && item.canManualTranscode) ||
+ (taskState.value === "completed" && !item.task.postProcessStage && !item.canManualTranscode);
+ return matchesKeyword && matchesState;
+ });
+});
-async function loadItems() {
- loading.value = true;
- loadError.value = "";
+function clearFilters() {
+ taskSearch.value = "";
+ taskState.value = "all";
+}
+
+function mergeItems(nextItems: TranscodeTaskItem[]) {
+ const previousById = new Map(items.value.map((item) => [item.task.id, item]));
+ return nextItems.map((item) => {
+ const previous = previousById.get(item.task.id);
+ return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
+ });
+}
+
+async function loadItems(background = false) {
+ if (requestInFlight.value) {
+ return;
+ }
+
+ requestInFlight.value = true;
+ initialLoading.value = !background && items.value.length === 0;
+ refreshing.value = !initialLoading.value;
+ if (!background) {
+ loadError.value = "";
+ }
try {
const { data } = await apiClient.get("/transcode-tasks");
- items.value = data;
+ items.value = mergeItems(data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "转码任务加载失败,请稍后重试。");
} finally {
- loading.value = false;
+ requestInFlight.value = false;
+ initialLoading.value = false;
+ refreshing.value = false;
}
}
@@ -42,7 +83,7 @@ async function startManualTranscode(taskId: string) {
try {
await apiClient.post(`/record-tasks/${taskId}/transcode`);
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
- await loadItems();
+ await loadItems(true);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败。"));
} finally {
@@ -110,7 +151,7 @@ function setupAutoRefresh() {
refreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
- loadItems();
+ void loadItems(true);
}
}, 15000);
}
@@ -141,7 +182,7 @@ onBeforeUnmount(() => {
返回录制任务
录制目录
- 刷新列表
+ 刷新列表
@@ -178,10 +219,23 @@ onBeforeUnmount(() => {
低于此比例时暂停所有录制和转码,仅保留上传
+ 低于此比例时暂停录制;空间满足安全余量时仍允许 MP4 收尾,上传继续
- 恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
- 绿色/红色水位线控制三级存储保护:绿色(正常录制) → 黄色(拒绝新录制,现有继续转码上传) → 红色(暂停所有录制转码,仅上传清盘)。 + 建议绿色水位线至少 30%、红色水位线至少 10%;系统最低允许 10% / 5%,并强制保留 5% 的黄色缓冲区。恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
+ 绿色/红色水位线控制三级存储保护:绿色(正常录制) → 黄色(拒绝新录制,现有继续转码上传) → 红色(暂停录制;有安全余量时继续 MP4 收尾,上传继续清盘)。
- - 绿色/红色水位线控制三级存储保护:绿色(正常录制) → 黄色(拒绝新录制,现有继续转码上传) → 红色(暂停所有录制转码,仅上传清盘)。 + 建议绿色水位线至少 30%、红色水位线至少 10%;系统最低允许 10% / 5%,并强制保留 5% 的黄色缓冲区。恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
+ 绿色/红色水位线控制三级存储保护:绿色(正常录制) → 黄色(拒绝新录制,现有继续转码上传) → 红色(暂停录制;有安全余量时继续 MP4 收尾,上传继续清盘)。
保留清理
按保留天数清理不活跃的会话、任务、结果和日志,可选删除磁盘文件。
路径模板
目录和文件名模板均支持变量,分片目录结构完全由模板控制。
@@ -2493,6 +2504,7 @@ onBeforeRouteLeave(async () => { .settings-quickbar { display: flex; align-items: center; + flex-wrap: wrap; gap: 18px; padding: 12px 16px; } @@ -2628,6 +2640,10 @@ onBeforeRouteLeave(async () => { padding-top: 20px; } +.settings-card--advanced { + border-style: dashed; +} + .settings-card :deep(.el-form) { max-width: 1120px; } diff --git a/frontend/src/views/TranscodeTasksView.vue b/frontend/src/views/TranscodeTasksView.vue index 471b2c7..e8908e0 100644 --- a/frontend/src/views/TranscodeTasksView.vue +++ b/frontend/src/views/TranscodeTasksView.vue @@ -5,34 +5,75 @@ import { ElMessage } from "element-plus"; import apiClient, { getApiErrorMessage } from "@/api/client"; import type { TranscodeTaskItem } from "@/types"; import { formatQualityLabel, outputFormatLabelMap, taskStatusLabelMap } from "@/types"; -import { useViewport } from "@/composables/useViewport"; const router = useRouter(); -const { isMobile } = useViewport(); - -const loading = ref(false); +const initialLoading = ref(false); +const refreshing = ref(false); +const requestInFlight = ref(false); const transcodeStartingTaskId = ref
+
+
+
+
+
+
+
+ {{ filteredItems.length }} / {{ items.length }}
+
+
+
-
+
-
+
diff --git a/frontend/src/views/UploadTasksView.vue b/frontend/src/views/UploadTasksView.vue
index 12ed347..409c2c7 100644
--- a/frontend/src/views/UploadTasksView.vue
+++ b/frontend/src/views/UploadTasksView.vue
@@ -1,5 +1,5 @@
@@ -228,38 +339,29 @@ onBeforeUnmount(() => {
录制任务
- 刷新列表
+ 刷新列表
{{ row.task.liveRoomTitle }}
@@ -222,21 +276,21 @@ onBeforeUnmount(() => {
{{ formatDate(row.task.endedAt || row.task.startedAt || row.task.createdAt) }}
- 查看分片
- 查看会话
-
- 手动转码
-
+ 查看
+
+ 更多
+
+ 查看会话
+ 手动转码
+
+
-
-
-
-
-
-
+
+
+
+
-
+
-
- 上传状态
-
-
- {{ opt.label }}
-
-
+
{
-
+
上传队列
+按状态和文件信息定位任务,批量操作仅作用于当前页。
+
+
+
+
+ 本页 {{ filteredItems.length }} / {{ items.length }}
+
+
+
+
-
+
-
-
- {{ platformLabelMap[row.platform] ?? "-" }}
-
-
-
-
-
- #{{ row.segmentIndex }}
-
-
-
-
+
-
+
-
-
-
-
-
-
-
-
- {{ row.lastUploadProvider || "-" }}
-
-
-
-
+
-
-
- {{ formatDate(row.lastUploadedAt) }}
-
-
-
-
-
- {{ row.uploadErrorMessage || "-" }}
-
-
-
-
+
@@ -445,32 +566,20 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
}
-.toolbar-row__filter {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.filter-label {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-secondary);
- white-space: nowrap;
-}
-
.toolbar-row__actions {
display: flex;
align-items: center;
gap: 10px;
}
-.table-scroll-shell {
- overflow-x: auto;
+.upload-table-shell {
+ min-width: 0;
}
-.upload-table {
- min-width: 1200px;
- border-radius: 12px;
+.upload-table { border-radius: 12px; }
+
+.table-progress {
+ margin-top: 9px;
}
.pagination-row {
@@ -497,6 +606,108 @@ onBeforeUnmount(() => {
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
+ justify-content: flex-end;
+}
+
+.upload-card-list {
+ display: grid;
+ gap: 12px;
+}
+
+.upload-item-card {
+ min-width: 0;
+ padding: 16px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 14px;
+ background: var(--surface-raised);
+}
+
+.upload-item-card__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.upload-item-card__identity {
+ display: grid;
+ min-width: 0;
+ gap: 4px;
+}
+
+.upload-item-card__identity span,
+.upload-item-card__label,
+.upload-item-card__progress span,
+.upload-item-card__facts dt {
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.upload-item-card__section {
+ display: grid;
+ min-width: 0;
+ gap: 3px;
+ margin-top: 14px;
+}
+
+.upload-item-card__progress {
+ display: grid;
+ gap: 5px;
+ margin-top: 14px;
+}
+
+.upload-item-card__facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin: 14px 0 0;
+}
+
+.upload-item-card__facts div {
+ min-width: 0;
+ padding: 10px;
+ border-radius: 10px;
+ background: var(--surface-muted);
+}
+
+.upload-item-card__facts dt,
+.upload-item-card__facts dd {
+ margin: 0;
+}
+
+.upload-item-card__facts dd {
+ margin-top: 4px;
+ overflow-wrap: anywhere;
+}
+
+.upload-item-card__error {
+ margin-top: 12px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ color: var(--danger);
+ background: color-mix(in srgb, var(--danger) 9%, transparent);
+ overflow-wrap: anywhere;
+}
+
+.upload-item-card__actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 16px;
+}
+
+.upload-item-card__actions :deep(.el-button) {
+ width: 100%;
+ margin: 0;
+}
+
+.upload-item-card__actions :deep(.el-button:only-child) {
+ grid-column: 1 / -1;
+}
+
+.path-text {
+ overflow-wrap: anywhere;
+ word-break: break-word;
}
.task-actions-cell :deep(.el-button) {
diff --git a/frontend/tests/e2e/ui-regression.spec.ts b/frontend/tests/e2e/ui-regression.spec.ts
new file mode 100644
index 0000000..2770114
--- /dev/null
+++ b/frontend/tests/e2e/ui-regression.spec.ts
@@ -0,0 +1,502 @@
+import { expect, test, type Page, type TestInfo } from "@playwright/test";
+
+const now = "2026-08-02T12:00:00Z";
+const room = {
+ id: "room-1",
+ platform: 0,
+ platformName: "哔哩哔哩",
+ sourceUrl: "https://live.example/123",
+ originalLiveRoomUrl: "https://live.example/123",
+ normalizedUrl: "https://live.example/123",
+ roomId: "123456",
+ title: "夏日音乐直播间",
+ anchorName: "示例主播",
+ isPinned: true,
+ isPriority: false,
+ overrides: {},
+ effectiveSettings: {
+ preferredQuality: "origin",
+ outputFormat: 0,
+ saveMode: 1,
+ recordingTemplate: 0,
+ segmentDurationMinutes: 30,
+ enableAutoReconnect: true,
+ reconnectDelayMaxSeconds: 60,
+ readWriteTimeoutMilliseconds: 30000,
+ enableDanmakuRecording: true,
+ danmakuIncludeNonChatEvents: false,
+ danmakuMinPollIntervalMilliseconds: 1000,
+ danmakuRetryDelayMaxSeconds: 30
+ },
+ isEnabled: true,
+ availabilityStatus: 2,
+ currentRecordingState: 2,
+ lastAutoStartDecisionCode: "started",
+ lastAutoStartDecisionSummary: "已自动开始录制",
+ lastAutoStartDecisionDetail: "直播状态确认后创建录制会话。",
+ lastAutoStartDecisionAt: now,
+ lastCheckedAt: now,
+ createdAt: now,
+ updatedAt: now
+};
+
+const task = {
+ id: "task-1",
+ liveRoomId: room.id,
+ recordSessionId: "session-12345678",
+ segmentIndex: 1,
+ liveRoomTitle: room.title,
+ platform: 0,
+ roomId: room.roomId,
+ status: 4,
+ preferredQuality: "origin",
+ outputFormat: 0,
+ outputFilePath: "/volume1/录制/示例主播/2026-08-02/分片-001.mp4",
+ createdAt: now,
+ startedAt: now,
+ endedAt: now,
+ durationSeconds: 1800,
+ uploadStatus: 0
+};
+
+const session = {
+ id: "session-12345678",
+ liveRoomId: room.id,
+ liveRoomTitle: room.title,
+ platform: 0,
+ roomId: room.roomId,
+ status: 4,
+ preferredQuality: "origin",
+ outputFormat: 0,
+ saveMode: 1,
+ activeSegmentIndex: 0,
+ segmentCount: 1,
+ createdAt: now,
+ startedAt: now,
+ endedAt: now,
+ totalFileSizeBytes: 8_589_934_592,
+ totalDanmakuMessageCount: 2680,
+ uploadedSegmentCount: 0,
+ failedUploadSegmentCount: 0,
+ uploadingSegmentCount: 0,
+ tasks: [task]
+};
+
+const uploadTask = {
+ recordTaskId: task.id,
+ recordSessionId: session.id,
+ liveRoomId: room.id,
+ liveRoomTitle: room.title,
+ platform: room.platform,
+ roomId: room.roomId,
+ segmentIndex: 1,
+ outputFormat: 0,
+ filePath: task.outputFilePath,
+ fileSizeBytes: 8_589_934_592,
+ uploadStatus: 0,
+ uploadProgressPercent: 0,
+ uploadAttemptCount: 0,
+ createdAt: now
+};
+
+const dashboard = {
+ activeRecordingCount: 1,
+ liveRoomCount: 3,
+ offlineRoomCount: 5,
+ totalRoomCount: 8,
+ todayRecordingSeconds: 12600,
+ todayDataBytes: 32_212_254_720,
+ todayDanmakuCount: 12860,
+ activeSessionCount: 1,
+ recentErrorCount: 2,
+ currentErrorCount: 0,
+ storageStatus: {
+ isEnabled: true,
+ isAvailable: true,
+ hasEnoughSpace: true,
+ message: "空间充足",
+ checkedPath: "/volume1/录制/示例主播/2026-08-02",
+ totalBytes: 1_000_000_000_000,
+ usedBytes: 750_000_000_000,
+ availableBytes: 250_000_000_000,
+ requiredBytes: 100_000_000_000,
+ tier: "Green",
+ usagePercent: 75,
+ freePercent: 25,
+ greenThresholdPercent: 30,
+ redThresholdPercent: 10
+ },
+ recentSessions: [session],
+ topRooms: [{ liveRoomId: room.id, title: room.title, anchorName: room.anchorName, platformName: room.platformName, roomId: room.roomId, sessionCount: 1, totalDurationSeconds: 12600 }],
+ pendingTranscodeCount: 2,
+ pendingUploadCount: 3,
+ queuedDataBytes: 4_294_967_296
+};
+
+async function mockApi(page: Page) {
+ await page.addInitScript(() => {
+ localStorage.setItem("live-recorder-token", "e2e-token");
+ localStorage.setItem("live-recorder-user", JSON.stringify({
+ userId: "e2e-user",
+ username: "tester",
+ displayName: "界面测试"
+ }));
+ });
+
+ await page.route(/^http:\/\/127\.0\.0\.1:47173\/api\//, async (route) => {
+ const url = new URL(route.request().url());
+ const path = url.pathname;
+ let body: unknown = {};
+
+ if (path === "/api/dashboard") body = dashboard;
+ else if (path === "/api/live-rooms") body = [room];
+ else if (path === "/api/record-sessions/page") body = {
+ items: [session],
+ totalCount: 1,
+ skip: 0,
+ take: 20,
+ totalSessionCount: 1,
+ activeSessionCount: 0,
+ totalTaskCount: 1,
+ totalDanmakuCount: session.totalDanmakuMessageCount
+ };
+ else if (path === "/api/record-sessions") body = [session];
+ else if (path === "/api/record-tasks/upload-status") body = {
+ items: [uploadTask],
+ totalCount: 1,
+ notUploadedCount: 1,
+ failedArtifactCount: 0,
+ succeededCount: 0,
+ failedCount: 0,
+ queuedCount: 0,
+ uploadingCount: 0,
+ waitingRetryCount: 0
+ };
+ else if (path === "/api/settings") body = {};
+ else if (path.includes("/record-sessions/stream")) {
+ await route.fulfill({ status: 200, contentType: "text/event-stream", body: "" });
+ return;
+ }
+
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
+ });
+}
+
+async function expectNoDocumentOverflow(page: Page) {
+ await expect.poll(() => page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth
+ }))).toEqual(await page.evaluate(() => ({
+ scrollWidth: document.documentElement.clientWidth,
+ clientWidth: document.documentElement.clientWidth
+ })));
+}
+
+async function capture(page: Page, testInfo: TestInfo, name: string) {
+ await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true });
+}
+
+test.beforeEach(async ({ page }) => {
+ page.on("pageerror", (error) => console.error("[browser pageerror]", error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") console.error("[browser console]", message.text());
+ });
+ await mockApi(page);
+});
+
+test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "仪表盘" })).toBeVisible();
+ const storage = page.getByTestId("storage-capacity");
+ await expect(storage).toContainText("75.0%");
+ await expect(storage).toContainText("已使用");
+ await expect(storage).toContainText("25.0%");
+ await expectNoDocumentOverflow(page);
+ await capture(page, testInfo, "dashboard");
+});
+
+test("live room table, drawer and dialog retain their final actions", async ({ page }, testInfo) => {
+ await page.goto("/live-rooms");
+ await expect(page.getByRole("heading", { name: "直播间列表" })).toBeVisible();
+ await expectNoDocumentOverflow(page);
+
+ if ((page.viewportSize()?.width ?? 0) >= 768) {
+ const actionHeader = page.getByRole("columnheader", { name: "操作" }).last();
+ await expect(actionHeader).toBeVisible();
+ const box = await actionHeader.boundingBox();
+ expect(box && box.x + box.width).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
+ }
+
+ await page.getByRole("button", { name: /^查看/ }).first().click();
+ const drawerFooter = page.locator(".right-drawer__footer");
+ await expect(drawerFooter).toBeVisible();
+ const footerBox = await drawerFooter.boundingBox();
+ expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
+ await page.getByRole("button", { name: "关闭", exact: true }).click();
+ await expect(drawerFooter).toBeHidden();
+
+ await page.getByRole("button", { name: "新增直播间" }).click();
+ const dialogFooter = page.locator(".el-dialog__footer");
+ await expect(dialogFooter).toBeVisible();
+ const dialogFooterBox = await dialogFooter.boundingBox();
+ expect(dialogFooterBox && dialogFooterBox.y + dialogFooterBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
+ await page.getByRole("button", { name: "取消", exact: true }).click();
+ await expect(dialogFooter).toBeHidden();
+ await capture(page, testInfo, "live-rooms");
+});
+
+test("mobile live room list paginates large collections", async ({ page }) => {
+ test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile-only pagination contract");
+ const manyRooms = Array.from({ length: 25 }, (_, index) => ({
+ ...room,
+ id: `room-${index + 1}`,
+ roomId: String(100000 + index + 1),
+ title: `分页直播间 ${index + 1}`,
+ anchorName: `分页主播 ${index + 1}`
+ }));
+ await page.route("**/api/live-rooms", async (route) => {
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(manyRooms) });
+ });
+
+ await page.goto("/live-rooms");
+ await expect(page.locator(".room-card")).toHaveCount(12);
+ await expect(page.getByText("分页直播间 1", { exact: true })).toBeVisible();
+ await page.locator(".mobile-room-pagination .btn-next").click();
+ await expect(page.getByText("分页直播间 13", { exact: true })).toBeVisible();
+ await expect(page.locator(".room-card")).toHaveCount(12);
+});
+
+test("record task cards and tables expose one primary action", async ({ page }, testInfo) => {
+ await page.goto("/record-tasks");
+ await expect(page.getByRole("heading", { name: "录制任务" })).toBeVisible();
+ await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
+ await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible();
+ await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible();
+ await expectNoDocumentOverflow(page);
+ await capture(page, testInfo, "record-tasks");
+});
+
+test("record sessions paginate on the server and bound the rendered page", async ({ page }) => {
+ const allSessions = Array.from({ length: 200 }, (_, index) => ({
+ ...session,
+ id: `session-${index + 1}`,
+ liveRoomTitle: `分页录制会话 ${index + 1}`,
+ roomId: String(200000 + index + 1),
+ tasks: [{ ...task, id: `task-${index + 1}`, recordSessionId: `session-${index + 1}` }]
+ }));
+ const requests: Array<{ skip: number; take: number }> = [];
+
+ await page.route("**/api/record-sessions/page**", async (route) => {
+ const url = new URL(route.request().url());
+ const skip = Number(url.searchParams.get("skip") || 0);
+ const take = Number(url.searchParams.get("take") || 20);
+ requests.push({ skip, take });
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ items: allSessions.slice(skip, skip + take),
+ totalCount: allSessions.length,
+ skip,
+ take,
+ totalSessionCount: allSessions.length,
+ activeSessionCount: 0,
+ totalTaskCount: allSessions.length,
+ totalDanmakuCount: allSessions.length * session.totalDanmakuMessageCount
+ })
+ });
+ });
+
+ await page.goto("/record-tasks");
+ const expectedPageSize = (page.viewportSize()?.width ?? 0) < 768 ? 12 : 20;
+ await expect.poll(() => requests.length).toBeGreaterThan(0);
+ expect(requests[0]).toEqual({ skip: 0, take: expectedPageSize });
+ await expect(page.getByText("当前页 " + expectedPageSize + " / 共 200")).toBeVisible();
+ await expect(page.locator((page.viewportSize()?.width ?? 0) < 768 ? ".session-card" : ".session-panel"))
+ .toHaveCount(expectedPageSize);
+ const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length);
+ expect(renderedNodeCount).toBeLessThan(5_000);
+
+ await page.locator(".session-pagination .btn-next").click();
+ await expect.poll(() => requests.some((item) => item.skip === expectedPageSize)).toBeTruthy();
+ await expect(page.getByText(`分页录制会话 ${expectedPageSize + 1}`, { exact: true })).toBeVisible();
+});
+
+test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => {
+ test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile refresh contract");
+
+ await page.addInitScript(() => {
+ class MockEventSource {
+ onopen: ((event: Event) => void) | null = null;
+ onerror: ((event: Event) => void) | null = null;
+ private listeners = new Map void>>();
+
+ constructor() {
+ (window as any).__recordSessionEventSource = this;
+ window.setTimeout(() => this.onopen?.(new Event("open")), 0);
+ }
+
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
+ const callback = typeof listener === "function"
+ ? listener
+ : (event: Event) => listener.handleEvent(event);
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), callback]);
+ }
+
+ emit(type: string) {
+ this.listeners.get(type)?.forEach((listener) => listener(new MessageEvent(type, { data: "{}" })));
+ }
+
+ close() {}
+ }
+
+ Object.defineProperty(window, "EventSource", { configurable: true, value: MockEventSource });
+ (window as any).__emitRecordSessionRefresh = () =>
+ (window as any).__recordSessionEventSource?.emit("refresh");
+ });
+
+ const allSessions = Array.from({ length: 60 }, (_, index) => ({
+ ...session,
+ id: `refresh-session-${index + 1}`,
+ liveRoomTitle: `刷新录制会话 ${index + 1}`,
+ tasks: [{ ...task, id: `refresh-task-${index + 1}`, recordSessionId: `refresh-session-${index + 1}` }]
+ }));
+ let requestCount = 0;
+ await page.route("**/api/record-sessions/page**", async (route) => {
+ requestCount++;
+ const url = new URL(route.request().url());
+ const skip = Number(url.searchParams.get("skip") || 0);
+ const take = Number(url.searchParams.get("take") || 12);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ items: allSessions.slice(skip, skip + take),
+ totalCount: allSessions.length,
+ skip,
+ take,
+ totalSessionCount: allSessions.length,
+ activeSessionCount: 0,
+ totalTaskCount: allSessions.length,
+ totalDanmakuCount: 0
+ })
+ });
+ });
+
+ await page.goto("/record-tasks");
+ await expect(page.locator(".session-card")).toHaveCount(12);
+ const main = page.locator(".app-main");
+ await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
+ const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
+ expect(scrollTopBefore).toBeGreaterThan(500);
+
+ await page.evaluate(() => (window as any).__emitRecordSessionRefresh());
+ await expect.poll(() => requestCount).toBeGreaterThan(1);
+ await expect(page.locator(".session-card")).toHaveCount(12);
+ const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
+ expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
+});
+
+test("upload tasks use cards on mobile and never require table horizontal scrolling", async ({ page }, testInfo) => {
+ await page.goto("/upload-tasks");
+ await expect(page.getByRole("heading", { name: "上传任务" })).toBeVisible();
+ await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
+ await expect(page.getByText(task.outputFilePath, { exact: true })).toBeVisible();
+ await expectNoDocumentOverflow(page);
+
+ if ((page.viewportSize()?.width ?? 0) < 1280) {
+ await expect(page.locator(".upload-item-card")).toBeVisible();
+ await expect(page.locator(".upload-table")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "查看详情" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "立即上传" })).toBeVisible();
+ } else {
+ await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
+ const tableScroll = page.locator(".upload-table .el-scrollbar__wrap");
+ await expect(tableScroll).toBeVisible();
+ const dimensions = await tableScroll.evaluate((element) => ({
+ clientWidth: element.clientWidth,
+ scrollWidth: element.scrollWidth
+ }));
+ expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
+ const uploadButton = page.getByRole("button", { name: "上传", exact: true });
+ await expect(uploadButton).toBeVisible();
+ const uploadButtonBox = await uploadButton.boundingBox();
+ expect(uploadButtonBox && uploadButtonBox.x + uploadButtonBox.width)
+ .toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
+ }
+
+ await capture(page, testInfo, "upload-tasks");
+});
+
+test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => {
+ test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile polling contract");
+
+ const allItems = Array.from({ length: 60 }, (_, index) => ({
+ ...uploadTask,
+ recordTaskId: `upload-task-${index + 1}`,
+ segmentIndex: index + 1,
+ liveRoomTitle: `轮询直播间 ${index + 1}`,
+ uploadStatus: 3,
+ uploadProgressPercent: 10
+ }));
+ let requestCount = 0;
+ let inFlight = 0;
+ let maxInFlight = 0;
+
+ await page.route("**/api/record-tasks/upload-status**", async (route) => {
+ requestCount++;
+ inFlight++;
+ maxInFlight = Math.max(maxInFlight, inFlight);
+ const url = new URL(route.request().url());
+ const skip = Number(url.searchParams.get("skip") || 0);
+ const take = Number(url.searchParams.get("take") || 50);
+ if (requestCount > 1) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ const progress = requestCount > 1 ? 42 : 10;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ items: allItems.slice(skip, skip + take).map((item) => ({ ...item, uploadProgressPercent: progress })),
+ totalCount: allItems.length,
+ notUploadedCount: 0,
+ failedArtifactCount: 0,
+ succeededCount: 0,
+ failedCount: 0,
+ queuedCount: 0,
+ uploadingCount: allItems.length,
+ waitingRetryCount: 0
+ })
+ });
+ inFlight--;
+ });
+
+ await page.goto("/upload-tasks");
+ await expect(page.locator(".upload-item-card")).toHaveCount(12);
+ await expect(page.getByText("10.0% · 第 0 次").first()).toBeVisible();
+
+ const main = page.locator(".app-main");
+ await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
+ const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
+ expect(scrollTopBefore).toBeGreaterThan(500);
+
+ await expect.poll(() => requestCount, { timeout: 10_000 }).toBeGreaterThan(1);
+ await expect(page.locator(".upload-item-card")).toHaveCount(12);
+ await expect(page.locator(".el-skeleton")).toHaveCount(0);
+ await expect(page.getByText("42.0% · 第 0 次").first()).toBeVisible();
+
+ const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
+ expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
+ expect(maxInFlight).toBe(1);
+});
+
+test("settings layer advanced controls without clipping the quick bar", async ({ page }, testInfo) => {
+ await page.goto("/settings/recording");
+ await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "保留清理" })).toBeHidden();
+ await page.getByText("高级设置", { exact: true }).click();
+ await expect(page.getByRole("heading", { name: "保留清理" })).toBeVisible();
+ await expectNoDocumentOverflow(page);
+ await capture(page, testInfo, "settings");
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4c90b22..a32a3b5 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -5,7 +5,7 @@ import path from "node:path";
export default defineConfig(({ command }) => ({
plugins: [
- ...(command === "serve" ? [VueDevTools()] : []),
+ ...(command === "serve" && process.env.VITE_DISABLE_DEVTOOLS !== "1" ? [VueDevTools()] : []),
vue()
],
resolve: {
diff --git a/scripts/build-fnos-package.sh b/scripts/build-fnos-package.sh
index 78648ef..9832020 100755
--- a/scripts/build-fnos-package.sh
+++ b/scripts/build-fnos-package.sh
@@ -2,9 +2,7 @@
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-VERSION=1.1.0
-NODE_VERSION=22.18.0
-NODE_ARCHIVE_SHA256=c1bfeecf1d7404fa74728f9db72e697decbd8119ccc6f5a294d795756dfcfca7
+VERSION=1.2.13
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}"
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}"
@@ -26,7 +24,7 @@ FNPACK_BIN=$(command -v "$FNPACK_BIN") || {
exit 1
}
-for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar xz node; do
+for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar node; do
command -v "$command_name" >/dev/null 2>&1 || {
printf 'required build command is missing: %s\n' "$command_name" >&2
exit 1
@@ -45,8 +43,7 @@ EXTRACT_ROOT="$WORK_DIR/debian-root"
RUNTIME_ROOT="$STAGE/app/runtime"
mkdir -p "$STAGE/app/server" "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/lib" \
- "$RUNTIME_ROOT/usr/lib/postgresql/15/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib" \
- "$RUNTIME_ROOT/usr/share/postgresql/15" "$RUNTIME_ROOT/etc/ssl/certs" \
+ "$RUNTIME_ROOT/etc/ssl/certs" \
"$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT"
cp -a "$ROOT_DIR/fnos/." "$STAGE/"
@@ -102,8 +99,6 @@ apt-get "${APT_OPTIONS[@]}" \
--no-install-recommends \
--yes \
install \
- postgresql-15 \
- postgresql-client-15 \
curl \
ca-certificates
@@ -143,44 +138,7 @@ copy_extracted_file() {
install -m "$mode" "$resolved" "$destination"
}
-copy_dereferenced_tree() {
- local source_root=$1 destination_root=$2 relative source mode
- [ -d "$source_root" ] || { printf 'missing extracted runtime directory: %s\n' "$source_root" >&2; return 1; }
- while IFS= read -r -d '' relative; do
- mkdir -p "$destination_root/${relative#./}"
- done < <(cd "$source_root" && find . -type d -print0)
- while IFS= read -r -d '' relative; do
- source="$source_root/${relative#./}"
- mode=0644
- [ -x "$source" ] && mode=0755
- copy_extracted_file "$source" "$destination_root/${relative#./}" "$mode"
- done < <(cd "$source_root" && find . \( -type f -o -type l \) -print0)
-}
-
printf 'Assembling minimal relocatable runtime...\n'
-for source in "$EXTRACT_ROOT/usr/lib/postgresql/15/bin/"*; do
- [ -f "$source" ] || [ -L "$source" ] || continue
- copy_extracted_file "$source" "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/$(basename -- "$source")" 0755
-done
-copy_dereferenced_tree \
- "$EXTRACT_ROOT/usr/share/postgresql/15" \
- "$RUNTIME_ROOT/usr/share/postgresql/15"
-
-for source in "$EXTRACT_ROOT/usr/lib/postgresql/15/lib/"*.so*; do
- [ -f "$source" ] || [ -L "$source" ] || continue
- copy_extracted_file "$source" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib/$(basename -- "$source")" 0755
-done
-
-NODE_ARCHIVE="$WORK_DIR/node-v${NODE_VERSION}-linux-x64.tar.xz"
-NODE_DIST_ROOT="$WORK_DIR/node-dist"
-printf 'Downloading pinned official Node.js %s runtime...\n' "$NODE_VERSION"
-curl --fail --location --retry 3 \
- "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
- --output "$NODE_ARCHIVE"
-printf '%s %s\n' "$NODE_ARCHIVE_SHA256" "$NODE_ARCHIVE" | sha256sum --check --status
-mkdir -p "$NODE_DIST_ROOT"
-tar -xJf "$NODE_ARCHIVE" -C "$NODE_DIST_ROOT" --strip-components=1
-install -m 0755 "$NODE_DIST_ROOT/bin/node" "$RUNTIME_ROOT/bin/node"
copy_extracted_file "$EXTRACT_ROOT/usr/bin/curl" "$RUNTIME_ROOT/bin/curl" 0755
CA_CONFIG="$EXTRACT_ROOT/etc/ca-certificates.conf"
@@ -209,7 +167,7 @@ while IFS= read -r -d '' elf_file; do
if readelf -h "$elf_file" >/dev/null 2>&1; then
ELF_QUEUE+=("$elf_file")
fi
-done < <(find "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib" -type f -print0)
+done < <(find "$RUNTIME_ROOT/bin" -type f -print0)
is_system_glibc_library() {
case "$1" in
@@ -241,10 +199,6 @@ while [ "$queue_index" -lt "${#ELF_QUEUE[@]}" ]; do
done
for required_file in \
- "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \
- "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \
- "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_ctl" \
- "$RUNTIME_ROOT/bin/node" \
"$RUNTIME_ROOT/bin/curl"; do
test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
done
@@ -263,5 +217,6 @@ mv "$PACKED_ROOT/liverecorder.fpk" "$OUTPUT"
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
)
-"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
+LIVERECORDER_VERIFY_TMPDIR="${LIVERECORDER_VERIFY_TMPDIR:-$BUILD_TMP_ROOT}" \
+ "$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
printf 'Built %s\n' "$OUTPUT"
diff --git a/scripts/build-postgresql-fnos-package.sh b/scripts/build-postgresql-fnos-package.sh
index 178fd7c..9cdd5bd 100755
--- a/scripts/build-postgresql-fnos-package.sh
+++ b/scripts/build-postgresql-fnos-package.sh
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-VERSION=15.1.0
+VERSION=15.1.1
PGVECTOR_VERSION=0.8.6
PGVECTOR_PACKAGE_VERSION=0.8.6-1.pgdg12%2B1
PGVECTOR_SHA256=b27ff894d1e2d23ebd7528fcb986923391977cbd5c5379ed74527875246854ca
diff --git a/scripts/smoke-fnos-migration.sh b/scripts/smoke-fnos-migration.sh
deleted file mode 100755
index 6cf3b12..0000000
--- a/scripts/smoke-fnos-migration.sh
+++ /dev/null
@@ -1,157 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-LIVE_PACKAGE=${1:?usage: smoke-fnos-migration.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
-POSTGRES_PACKAGE=${2:?usage: smoke-fnos-migration.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
-SMOKE_TMP_ROOT="${3:-${LIVERECORDER_MIGRATION_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
-mkdir -p "$SMOKE_TMP_ROOT"
-SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
-WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-migration-smoke.XXXXXX")
-STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/liverecorder-migration-state.XXXXXX")
-
-LIVE_PACKAGE_ROOT="$WORK_DIR/live-package"
-LIVE_APP_ROOT="$WORK_DIR/live-app"
-LIVE_DATA_ROOT="$STATE_DIR/live-var"
-LIVE_VOLUME_ROOT="$WORK_DIR/live-volume"
-PG_PACKAGE_ROOT="$WORK_DIR/postgres-package"
-PG_APP_ROOT="$WORK_DIR/postgres-app"
-PG_DATA_ROOT="$STATE_DIR/postgres-var"
-PG_VOLUME_ROOT="$WORK_DIR/postgres-volume"
-LIVE_PORT=${LIVERECORDER_MIGRATION_SMOKE_PORT:-19680}
-PRIVATE_PG_PORT=${LIVERECORDER_MIGRATION_PRIVATE_PG_PORT:-19629}
-PG_API_PORT=${POSTGRES_SERVICE_MIGRATION_API_PORT:-19633}
-PG_PORT=${POSTGRES_SERVICE_MIGRATION_PG_PORT:-19632}
-LIVE_CONTROL="$LIVE_PACKAGE_ROOT/cmd/main"
-PG_CONTROL="$PG_PACKAGE_ROOT/cmd/main"
-ADMIN_PASSWORD='LiveRecorder-Migration-2026!'
-PG_ADMIN_PASSWORD='Postgres-Migration-Admin-2026!'
-ENROLLMENT_TOKEN='Postgres-Migration-Enrollment-2026!'
-MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
-
-if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
- ! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
- mkdir -p "$WORK_DIR/system-media-stubs"
- ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffmpeg"
- ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffprobe"
- MEDIA_PATH="$WORK_DIR/system-media-stubs:$MEDIA_PATH"
-fi
-
-run_live_control() {
- TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
- TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
- LIVE_RECORDER_POSTGRES_PORT="$PRIVATE_PG_PORT" \
- POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
-}
-
-run_postgres_control() {
- TRIM_APPDEST="$PG_APP_ROOT" TRIM_PKGVAR="$PG_DATA_ROOT" \
- TRIM_APPDEST_VOL="$PG_VOLUME_ROOT" TRIM_SERVICE_PORT="$PG_API_PORT" \
- POSTGRES_SERVICE_PORT="$PG_PORT" "$PG_CONTROL" "$@"
-}
-
-cleanup() {
- status=$?
- if [ -x "$LIVE_CONTROL" ]; then run_live_control stop >/dev/null 2>&1 || true; fi
- if [ -x "$PG_CONTROL" ]; then run_postgres_control stop >/dev/null 2>&1 || true; fi
- if [ "$status" -ne 0 ]; then
- printf '%s\n' 'fnOS PostgreSQL migration smoke test failed; service logs follow:' >&2
- for log_file in \
- "$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
- "$LIVE_DATA_ROOT/log/postgresql.log" "$LIVE_DATA_ROOT/log/liverecorder.log"; do
- if [ -f "$log_file" ]; then
- printf '%s\n' "--- $log_file ---" >&2
- tail -n 200 "$log_file" >&2 || true
- fi
- done
- fi
- rm -rf -- "$WORK_DIR"
- rm -rf -- "$STATE_DIR"
- return "$status"
-}
-trap cleanup EXIT HUP INT TERM
-
-mkdir -p "$LIVE_PACKAGE_ROOT" "$LIVE_APP_ROOT" "$LIVE_VOLUME_ROOT" \
- "$PG_PACKAGE_ROOT" "$PG_APP_ROOT" "$PG_VOLUME_ROOT"
-tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
-tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
-rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
-tar -xzf "$POSTGRES_PACKAGE" -C "$PG_PACKAGE_ROOT"
-tar -xzf "$PG_PACKAGE_ROOT/app.tgz" -C "$PG_APP_ROOT"
-rm -f "$PG_PACKAGE_ROOT/app.tgz"
-
-TRIM_PKGVAR="$LIVE_DATA_ROOT" \
-wizard_admin_password="$ADMIN_PASSWORD" wizard_admin_password_confirm="$ADMIN_PASSWORD" \
-wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
- "$LIVE_PACKAGE_ROOT/cmd/install_callback"
-mv "$LIVE_DATA_ROOT/postgres-enrollment-token.seed" "$WORK_DIR/enrollment-token.seed"
-
-PRIVATE_PG_BIN="$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/bin"
-PRIVATE_PG_SHARE="$LIVE_APP_ROOT/runtime/usr/share/postgresql/15"
-PRIVATE_PG_LIB="$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
-PRIVATE_RUNTIME_LIBS="$LIVE_APP_ROOT/runtime/lib:$PRIVATE_PG_LIB"
-PRIVATE_PG_DATA="$LIVE_DATA_ROOT/postgres"
-PRIVATE_RUN_ROOT="$LIVE_DATA_ROOT/run"
-mkdir -p "$PRIVATE_PG_DATA" "$PRIVATE_RUN_ROOT" "$LIVE_DATA_ROOT/log"
-chmod 0700 "$PRIVATE_PG_DATA" "$PRIVATE_RUN_ROOT"
-env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" "$PRIVATE_PG_BIN/initdb" \
- -D "$PRIVATE_PG_DATA" -L "$PRIVATE_PG_SHARE" --username=liverecorder \
- --auth-local=trust --auth-host=reject --encoding=UTF8 --no-locale \
- >"$LIVE_DATA_ROOT/log/postgresql.log" 2>&1
-{
- printf "listen_addresses = ''\n"
- printf "port = %s\n" "$PRIVATE_PG_PORT"
- printf "unix_socket_directories = '%s'\n" "$PRIVATE_RUN_ROOT"
- printf "max_connections = 40\nshared_buffers = '64MB'\ntimezone = 'UTC'\nlog_timezone = 'UTC'\n"
-} >>"$PRIVATE_PG_DATA/postgresql.conf"
-
-# With a legacy PG15 cluster and no enrollment seed, the package must boot the
-# old database. The Web API then creates the exact EF schema and initial admin.
-run_live_control start
-curl -fsS "http://127.0.0.1:$LIVE_PORT/health/ready" | grep -q '"status":"ready"'
-source_user_count=$(env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" \
- "$PRIVATE_PG_BIN/psql" -h "$PRIVATE_RUN_ROOT" -p "$PRIVATE_PG_PORT" \
- -U liverecorder -d live_recorder -Atqc 'SELECT count(*) FROM "UserAccounts"')
-test "$source_user_count" -gt 0
-run_live_control stop
-
-TRIM_PKGVAR="$PG_DATA_ROOT" \
-wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
-wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
-wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
-wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
- "$PG_PACKAGE_ROOT/cmd/install_callback"
-mv "$WORK_DIR/enrollment-token.seed" "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
-chmod 0600 "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
-run_postgres_control start
-run_live_control start
-curl -fsS "http://127.0.0.1:$LIVE_PORT/health/ready" | grep -q '"status":"ready"'
-
-MARKER="$LIVE_DATA_ROOT/postgres-migration/shared-database.active"
-DUMP="$LIVE_DATA_ROOT/postgres-migration/private-postgres-15.dump"
-test -s "$MARKER"
-grep -q '^migrated_at=' "$MARKER"
-test -s "$DUMP"
-sha256sum -c "$DUMP.sha256" >/dev/null
-test -f "$LIVE_DATA_ROOT/postgres/PG_VERSION"
-if env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" \
- "$PRIVATE_PG_BIN/pg_ctl" -D "$PRIVATE_PG_DATA" status >/dev/null 2>&1; then
- printf '%s\n' 'legacy private PostgreSQL was still running after migration' >&2
- exit 1
-fi
-
-CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
-shared_database=$(sed -n 's/^database=//p' "$CREDENTIALS")
-shared_user=$(sed -n 's/^username=//p' "$CREDENTIALS")
-shared_password=$(sed -n 's/^password=//p' "$CREDENTIALS")
-SHARED_PG_BIN="$PG_APP_ROOT/runtime/usr/lib/postgresql/15/bin"
-SHARED_PG_LIBS="$PG_APP_ROOT/runtime/usr/lib/x86_64-linux-gnu:$PG_APP_ROOT/runtime/lib/x86_64-linux-gnu:$PG_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
-target_user_count=$(env LD_LIBRARY_PATH="$SHARED_PG_LIBS" PGPASSWORD="$shared_password" \
- "$SHARED_PG_BIN/psql" -h 127.0.0.1 -p "$PG_PORT" -U "$shared_user" \
- -d "$shared_database" -Atqc 'SELECT count(*) FROM "UserAccounts"')
-test "$source_user_count" = "$target_user_count"
-
-run_live_control stop
-run_postgres_control status
-curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
-
-printf '%s\n' 'fnOS migration smoke test passed: populated legacy PG15 migrated with row-count parity, checksum dump and rollback data preserved'
diff --git a/scripts/smoke-fnos-package.sh b/scripts/smoke-fnos-package.sh
index e3b1cee..c5a4887 100755
--- a/scripts/smoke-fnos-package.sh
+++ b/scripts/smoke-fnos-package.sh
@@ -26,6 +26,11 @@ ADMIN_PASSWORD='LiveRecorder-Smoke-2026!'
PG_ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!'
ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!'
MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
+NODEJS_ROOT="$WORK_DIR/nodejs-v22-target"
+NODEJS_BIN="${NODEJS_SMOKE_BIN:-$(type -P node)}"
+
+mkdir -p "$NODEJS_ROOT/bin"
+ln -s "$NODEJS_BIN" "$NODEJS_ROOT/bin/node"
if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
@@ -38,7 +43,8 @@ fi
run_live_control() {
TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
- POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
+ POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" NODEJS_ROOT="$NODEJS_ROOT" \
+ PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
}
run_postgres_control() {
@@ -55,7 +61,7 @@ cleanup() {
printf '%s\n' 'fnOS shared-stack smoke test failed; service logs follow:' >&2
for log_file in \
"$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
- "$LIVE_DATA_ROOT/log/postgresql.log" "$LIVE_DATA_ROOT/log/liverecorder.log"; do
+ "$LIVE_DATA_ROOT/log/liverecorder.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 160 "$log_file" >&2 || true
@@ -77,6 +83,12 @@ tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
+test ! -e "$LIVE_APP_ROOT/runtime/usr/lib/postgresql"
+test ! -e "$LIVE_APP_ROOT/runtime/usr/share/postgresql"
+test ! -e "$LIVE_APP_ROOT/runtime/lib/libLLVM-14.so.1"
+test ! -e "$LIVE_APP_ROOT/runtime/lib/libz3.so.4"
+test ! -e "$LIVE_APP_ROOT/runtime/bin/node"
+
TRIM_PKGVAR="$PG_DATA_ROOT" \
wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
@@ -100,16 +112,13 @@ curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
curl -fsS "$BASE_URL/" | grep -q ''
CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
-MARKER="$LIVE_DATA_ROOT/postgres-migration/shared-database.active"
test -s "$CREDENTIALS"
test "$(stat -c '%a' "$CREDENTIALS")" = "600"
grep -q '^host=127\.0\.0\.1$' "$CREDENTIALS"
grep -q "^port=$PG_PORT$" "$CREDENTIALS"
grep -q '^database=appdb_liverecorder_' "$CREDENTIALS"
grep -q '^username=app_liverecorder_' "$CREDENTIALS"
-test -s "$MARKER"
-grep -q '^fresh_install_at=' "$MARKER"
-test ! -f "$LIVE_DATA_ROOT/postgres/PG_VERSION"
+test ! -e "$LIVE_DATA_ROOT/postgres"
test ! -e "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
login_response=$(curl -fsS -H 'Content-Type: application/json' \
@@ -120,13 +129,21 @@ test -n "$token"
settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings")
expected_record_root="$LIVE_VOLUME_ROOT/@appshare/liverecorder/records"
printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\""
+session_page_response=$(curl -fsS -H "Authorization: Bearer $token" \
+ "$BASE_URL/api/record-sessions/page?skip=0&take=12&state=all")
+printf '%s' "$session_page_response" | grep -q '"items":\[\]'
+printf '%s' "$session_page_response" | grep -q '"totalCount":0'
+printf '%s' "$session_page_response" | grep -q '"take":12'
+filtered_session_page_response=$(curl -fsS -H "Authorization: Bearer $token" \
+ "$BASE_URL/api/record-sessions/page?skip=0&take=12&state=active&search=needle")
+printf '%s' "$filtered_session_page_response" | grep -q '"totalCount":0'
-runtime_libs="$LIVE_APP_ROOT/runtime/lib:$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
+runtime_libs="$LIVE_APP_ROOT/runtime/lib"
ca_bundle="$LIVE_APP_ROOT/runtime/etc/ssl/certs/ca-certificates.crt"
-node_bin="$LIVE_APP_ROOT/runtime/bin/node"
+node_bin="$NODEJS_ROOT/bin/node"
curl_bin="$LIVE_APP_ROOT/runtime/bin/curl"
signer="$LIVE_APP_ROOT/server/Platforms/Douyin/Signing/sign-xbogus.js"
-LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -q '^v22\.18\.0$'
+LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -Eq '^v[0-9]+\.'
signature=$(LD_LIBRARY_PATH="$runtime_libs" "$node_bin" "$signer" \
'aid=6383&device_platform=web&room_id=1' 'Mozilla/5.0 LiveRecorder fnOS shared-stack smoke test')
test -n "$signature"
@@ -145,4 +162,16 @@ run_live_control start
run_live_control status
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
+run_live_control stop
+run_postgres_control stop
+rm -f "$CREDENTIALS" "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
+mkdir -p "$LIVE_DATA_ROOT/postgres"
+printf '15\n' >"$LIVE_DATA_ROOT/postgres/PG_VERSION"
+if run_live_control start; then
+ printf '%s\n' 'Live Recorder unexpectedly fell back to a legacy private PostgreSQL database' >&2
+ exit 1
+fi
+grep -q '无法连接 PostgreSQL 共享服务或取得数据库凭据,应用不会启动' \
+ "$LIVE_DATA_ROOT/log/liverecorder.log"
+
printf '%s\n' 'fnOS shared-stack smoke test passed: independent PostgreSQL stayed running, Live Recorder enrolled, persisted credentials and restarted without Docker'
diff --git a/scripts/smoke-postgresql-fnos-package.sh b/scripts/smoke-postgresql-fnos-package.sh
index fb7bcf6..46bb406 100755
--- a/scripts/smoke-postgresql-fnos-package.sh
+++ b/scripts/smoke-postgresql-fnos-package.sh
@@ -71,6 +71,7 @@ curl -fsS -c "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \
"$BASE_URL/api/v1/auth/login" >/dev/null
curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/overview" | grep -q '"version"'
+curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/sessions" | grep -q '^\['
enroll() {
app_id=$1
diff --git a/scripts/verify-fnos-package.sh b/scripts/verify-fnos-package.sh
index 8ee665e..1670f20 100755
--- a/scripts/verify-fnos-package.sh
+++ b/scripts/verify-fnos-package.sh
@@ -73,21 +73,25 @@ if awk '
fi
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
-grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
-grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
-grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt"
-grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt"
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
if [ "$appname" = "liverecorder" ]; then
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt"
- grep -q '^runtime/bin/node$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt"
+ test "$(manifest_value install_dep_apps)" = "nxsir.postgresql:nodejs_v22"
+ if grep -Eq '^runtime/(bin/node|usr/(lib|share)/postgresql/|lib/(libLLVM|libz3))' "$WORK_DIR/app-files.txt"; then
+ printf 'Live Recorder must use shared PostgreSQL and the fnOS nodejs_v22 dependency\n' >&2
+ exit 1
+ fi
else
grep -q '^server/PostgresService.WebApi$' "$WORK_DIR/app-files.txt"
+ grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
+ grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
+ grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt"
+ grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/lib/vector.so$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/share/postgresql/15/extension/vector.control$' "$WORK_DIR/app-files.txt"
fi
diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs
index 523880a..ddc139f 100644
--- a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs
+++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs
@@ -61,8 +61,28 @@ public interface IRecordSessionRepository
{
Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetByIdsAsync(
+ IReadOnlyCollection ids,
+ CancellationToken cancellationToken = default);
+
Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
+ Task> ListOverviewAsync(Guid? liveRoomId, int take, CancellationToken cancellationToken = default);
+
+ Task<(IReadOnlyList Items, int TotalCount)> ListPageAsync(
+ Guid? liveRoomId,
+ IReadOnlyCollection? statuses,
+ string? search,
+ int skip,
+ int take,
+ CancellationToken cancellationToken = default);
+
+ Task GetOverviewTotalsAsync(
+ Guid? liveRoomId = null,
+ CancellationToken cancellationToken = default);
+
+ Task> ListActiveIdsAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
+
Task> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
@@ -80,6 +100,12 @@ public interface IRecordSessionRepository
void Remove(RecordSession recordSession);
}
+public sealed record RecordSessionOverviewTotals(
+ int TotalSessionCount,
+ int ActiveSessionCount,
+ int TotalTaskCount,
+ int TotalDanmakuCount);
+
public interface IRecordResultRepository
{
Task GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
@@ -90,6 +116,8 @@ public interface IRecordResultRepository
Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
+ Task CountFailedArtifactAsync(CancellationToken cancellationToken = default);
+
Task
+
+
+
+
+
+
+
+
+
+ {{ row.liveRoomTitle }}
+ {{ platformLabelMap[row.platform] ?? "-" }} · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}
+
+
+ 本地文件
+ {{ row.filePath || "-" }}
+ {{ formatFileSize(row.fileSizeBytes) }}
+
+
+
+
+ {{ formatProgress(row.uploadProgressPercent) }} · 第 {{ row.uploadAttemptCount || 0 }} 次
+
+
+ -
+
- 上传方式
- {{ row.lastUploadProvider || "-" }}
- 上传时间
- {{ formatDate(row.lastUploadedAt) }}
+ 远端路径
+ {{ row.remoteVideoPath }}
+
+ {{ row.uploadErrorMessage }}
+
+
+ 查看详情
+ {{ row.uploadStatus === 2 ? "重试上传" : "立即上传" }}
+
+ {{ row.liveRoomTitle }}
- {{ row.roomId }}
+ {{ platformLabelMap[row.platform] ?? "-" }} · {{ row.roomId }} · #{{ row.segmentIndex }}
{{ row.filePath || "-" }}
{{ formatFileSize(row.fileSizeBytes) }}
+
{
下次:{{ formatDate(row.nextUploadAttemptAt) }}
- -
{{ row.remoteVideoPath || "-" }}
+ {{ row.lastUploadProvider || "-" }} · {{ formatDate(row.lastUploadedAt) }}
+ {{ row.uploadErrorMessage }}
- 详情
+ 查看
- {{ row.uploadStatus === 2 ? "重试" : "上传" }}
-
+ >{{ row.uploadStatus === 2 ? "重试" : "上传" }}
- > ListUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
int skip,
@@ -139,6 +167,17 @@ public interface ISystemLogRepository
Task
- > ListUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
int skip,
diff --git a/src/LiveRecorder.Infrastructure/Properties/AssemblyInfo.cs b/src/LiveRecorder.Infrastructure/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..a735368
--- /dev/null
+++ b/src/LiveRecorder.Infrastructure/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("LiveRecorder.Tests")]
diff --git a/src/LiveRecorder.Infrastructure/Services/CleanupOperationCoordinator.cs b/src/LiveRecorder.Infrastructure/Services/CleanupOperationCoordinator.cs
index da9f036..ae7a7bf 100644
--- a/src/LiveRecorder.Infrastructure/Services/CleanupOperationCoordinator.cs
+++ b/src/LiveRecorder.Infrastructure/Services/CleanupOperationCoordinator.cs
@@ -119,6 +119,7 @@ public sealed class CleanupOperationCoordinator
new RetentionCleanupOperationFilters
{
RetentionDays = settings.RetentionDays,
+ RequireUploadSuccess = settings.RetentionRequireUploadSuccess,
VideoFileCondition = settings.RetentionVideoFileCondition,
TaskStatuses = settings.RetentionTaskStatuses
},
diff --git a/src/LiveRecorder.Infrastructure/Services/CleanupOperationSupport.cs b/src/LiveRecorder.Infrastructure/Services/CleanupOperationSupport.cs
index 47ca2cb..f8047d0 100644
--- a/src/LiveRecorder.Infrastructure/Services/CleanupOperationSupport.cs
+++ b/src/LiveRecorder.Infrastructure/Services/CleanupOperationSupport.cs
@@ -85,6 +85,8 @@ internal class ConditionalCleanupOperationFilters
internal sealed class RetentionCleanupOperationFilters : ConditionalCleanupOperationFilters
{
public int RetentionDays { get; init; } = 30;
+
+ public bool RequireUploadSuccess { get; init; }
}
internal sealed class EmptyCleanupOperationFilters
diff --git a/src/LiveRecorder.Infrastructure/Services/CompletionDispatchService.cs b/src/LiveRecorder.Infrastructure/Services/CompletionDispatchService.cs
new file mode 100644
index 0000000..a25d970
--- /dev/null
+++ b/src/LiveRecorder.Infrastructure/Services/CompletionDispatchService.cs
@@ -0,0 +1,144 @@
+using LiveRecorder.Application.Abstractions.Scripting;
+using LiveRecorder.Domain.Enums;
+using LiveRecorder.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace LiveRecorder.Infrastructure.Services;
+
+public sealed class CompletionDispatchService
+{
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
+ private readonly LiveRecorderDbContext _dbContext;
+ private readonly IEventScriptService _eventScriptService;
+ private readonly RecordUploadService _recordUploadService;
+
+ public CompletionDispatchService(
+ LiveRecorderDbContext dbContext,
+ IEventScriptService eventScriptService,
+ RecordUploadService recordUploadService)
+ {
+ _dbContext = dbContext;
+ _eventScriptService = eventScriptService;
+ _recordUploadService = recordUploadService;
+ }
+
+ public async Task