feat: localize quality labels and add quality template token

This commit is contained in:
2026-05-01 07:16:03 +08:00
parent 0b8f2c9775
commit f0f9ed3456
9 changed files with 147 additions and 31 deletions
+22
View File
@@ -572,6 +572,28 @@ export const recordingTemplateLabelMap: Record<number, string> = {
2: "归档 TS"
};
export const qualityLabelMap: Record<string, string> = {
origin: "原画",
FULL_HD: "超清",
HD: "高清",
SD: "标清"
};
export const qualityOptionList = [
{ value: "origin", label: qualityLabelMap.origin },
{ value: "FULL_HD", label: qualityLabelMap.FULL_HD },
{ value: "HD", label: qualityLabelMap.HD },
{ value: "SD", label: qualityLabelMap.SD }
] as const;
export function formatQualityLabel(value?: string | null) {
if (!value) {
return "-";
}
return qualityLabelMap[value] ?? value;
}
export const autoStartDecisionLabelMap: Record<string, string> = {
started: "已启动",
skipped_disabled: "已禁用",
+78 -15
View File
@@ -6,9 +6,11 @@ import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
autoStartDecisionLabelMap,
formatQualityLabel,
availabilityLabelMap,
currentRecordingStateLabelMap,
outputFormatLabelMap,
qualityOptionList,
recordingTemplateLabelMap,
saveModeLabelMap
} from "@/types";
@@ -89,7 +91,8 @@ const platformOptions = [
{ label: "虎牙", value: 3 }
];
const qualityOptions = ["origin", "FULL_HD", "HD", "SD"];
const qualityOptions = qualityOptionList;
const qualitySupportHint = "抖音和 Bilibili 支持按画质选流;虎牙暂未实现。若目标档位不可用,平台会自动回退到最接近的可用画质。";
const booleanOverrideOptions = [
{ label: "跟随全局", value: inheritValue },
{ label: "开启", value: "true" },
@@ -143,6 +146,35 @@ function getRoomsTableScrollWrap() {
shell.querySelector<HTMLElement>(".el-table__body-wrapper");
}
function getRoomsTableContentWidth(wrap: HTMLElement) {
const shell = roomsTableShellRef.value;
if (!shell) {
return wrap.scrollWidth;
}
const widths = [
wrap.scrollWidth,
wrap.firstElementChild?.scrollWidth ?? 0,
shell.querySelector<HTMLElement>(".el-table__body table")?.scrollWidth ?? 0,
shell.querySelector<HTMLElement>(".el-table__header table")?.scrollWidth ?? 0
];
const wrapRect = wrap.getBoundingClientRect();
let maxRight = 0;
shell.querySelectorAll<HTMLElement>(
".el-table__header th, .el-table__body td, .room-actions-cell, .config-summary, .auto-start-cell"
).forEach((element) => {
const rect = element.getBoundingClientRect();
maxRight = Math.max(maxRight, rect.right - wrapRect.left + wrap.scrollLeft);
});
widths.push(Math.ceil(maxRight) + 24);
return Math.max(...widths);
}
function handleRoomsTableBodyScroll() {
if (syncingRoomsTableProxy) {
return;
@@ -235,11 +267,11 @@ async function syncRoomsTableProxyScroll() {
return;
}
const scrollWidth = Math.max(wrap.scrollWidth, wrap.firstElementChild?.scrollWidth ?? 0);
const scrollWidth = getRoomsTableContentWidth(wrap);
const clientWidth = wrap.clientWidth;
const canScrollHorizontally = scrollWidth > clientWidth + 1;
roomsTableProxyInnerWidth.value = scrollWidth;
roomsTableProxyInnerWidth.value = canScrollHorizontally ? scrollWidth : 0;
showRoomsTableProxyScroll.value = canScrollHorizontally;
if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) {
@@ -653,6 +685,10 @@ function getRoomAvatarText(room: LiveRoom) {
return source.slice(0, 1).toUpperCase();
}
function getQualityLabel(quality?: string | null) {
return formatQualityLabel(quality);
}
function autoStartDecisionLabel(code?: string) {
if (!code) {
return "未记录";
@@ -862,7 +898,7 @@ onBeforeUnmount(() => {
<div style="grid-column: 1 / -1;">
<dt>房间配置</dt>
<dd>
{{ row.effectiveSettings.preferredQuality }} ·
{{ getQualityLabel(row.effectiveSettings.preferredQuality) }} ·
{{ outputFormatLabelMap[row.effectiveSettings.outputFormat] }} ·
{{ saveModeLabelMap[row.effectiveSettings.saveMode] }} ·
{{ row.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
@@ -914,7 +950,7 @@ onBeforeUnmount(() => {
v-loading="loading"
:height="tableHeight"
class="premium-table rooms-table"
table-layout="auto"
table-layout="fixed"
row-key="id"
@selection-change="handleRoomSelectionChange"
>
@@ -982,10 +1018,10 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="房间配置" min-width="240">
<el-table-column label="房间配置" width="280">
<template #default="{ row }">
<div class="config-summary">
<span>画质 {{ row.effectiveSettings.preferredQuality }}</span>
<span>画质 {{ getQualityLabel(row.effectiveSettings.preferredQuality) }}</span>
<span>{{ outputFormatLabelMap[row.effectiveSettings.outputFormat] }}</span>
<span>{{ saveModeLabelMap[row.effectiveSettings.saveMode] }}</span>
<span>{{ row.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}</span>
@@ -1012,7 +1048,7 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="操作" width="320">
<el-table-column label="操作" width="380">
<template #default="{ row }">
<div class="room-actions-cell">
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
@@ -1172,10 +1208,17 @@ onBeforeUnmount(() => {
<el-form label-position="top">
<el-form-item label="清晰度">
<el-select v-model="recordForm.preferredQuality">
<el-option v-for="option in qualityOptions" :key="option" :label="option" :value="option" />
<el-option
v-for="option in qualityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<div class="field-help">{{ qualitySupportHint }}</div>
<el-form-item label="输出格式">
<el-select v-model="recordForm.outputFormat">
<el-option
@@ -1241,10 +1284,17 @@ onBeforeUnmount(() => {
<el-form-item label="默认画质">
<el-select v-model="settingsForm.preferredQualityOverride">
<el-option label="跟随全局" :value="inheritValue" />
<el-option v-for="option in qualityOptions" :key="option" :label="option" :value="option" />
<el-option
v-for="option in qualityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<div class="field-help field-help--full">{{ qualitySupportHint }}</div>
<el-form-item label="默认输出格式">
<el-select v-model="settingsForm.outputFormatOverride">
<el-option label="跟随全局" :value="inheritValue" />
@@ -1454,7 +1504,7 @@ onBeforeUnmount(() => {
}
.rooms-table {
min-width: 1380px;
min-width: 1920px;
}
.rooms-table :deep(.el-table__cell) {
@@ -1462,7 +1512,7 @@ onBeforeUnmount(() => {
}
.rooms-table :deep(.cell) {
overflow: visible;
overflow: hidden;
}
.room-avatar {
@@ -1557,16 +1607,29 @@ onBeforeUnmount(() => {
}
.room-actions-cell {
display: flex;
align-items: center;
display: grid;
grid-template-columns: repeat(2, minmax(0, max-content));
gap: 8px;
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.room-actions-cell :deep(.el-button) {
margin: 0;
}
.field-help {
margin-top: -8px;
margin-bottom: 14px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.field-help--full {
grid-column: 1 / -1;
}
.settings-note {
display: grid;
gap: 6px;
@@ -12,6 +12,7 @@ import type {
RecordSessionTimelineSegment
} from "@/types";
import {
formatQualityLabel,
logLevelLabelMap,
outputFormatLabelMap,
platformLabelMap,
@@ -277,7 +278,7 @@ onMounted(loadDetail);
{{ outputFormatLabelMap[detail.session.outputFormat] }}
</el-descriptions-item>
<el-descriptions-item label="清晰度">
<span class="monospace">{{ detail.session.preferredQuality }}</span>
<span>{{ formatQualityLabel(detail.session.preferredQuality) }}</span>
</el-descriptions-item>
<el-descriptions-item label="分片数量">
{{ detail.session.segmentCount }}
+2 -1
View File
@@ -6,6 +6,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { RecordArtifactUploadItemResult, RecordPreviewTicket, RecordTaskDetail } from "@/types";
import {
formatQualityLabel,
logLevelLabelMap,
outputFormatLabelMap,
sessionStatusLabelMap,
@@ -249,7 +250,7 @@ onMounted(loadDetailAndPreview);
<span class="monospace">#{{ detail.task.segmentIndex }}</span>
</el-descriptions-item>
<el-descriptions-item label="RoomId">{{ detail.task.roomId }}</el-descriptions-item>
<el-descriptions-item label="清晰度">{{ detail.task.preferredQuality }}</el-descriptions-item>
<el-descriptions-item label="清晰度">{{ formatQualityLabel(detail.task.preferredQuality) }}</el-descriptions-item>
<el-descriptions-item label="输出格式">
{{ outputFormatLabelMap[detail.task.outputFormat] }}
</el-descriptions-item>
+3 -3
View File
@@ -17,6 +17,7 @@ import type {
RecordTask
} from "@/types";
import {
formatQualityLabel,
outputFormatLabelMap,
saveModeLabelMap,
sessionStatusLabelMap,
@@ -747,7 +748,7 @@ onBeforeUnmount(() => {
<div class="data-card__grid">
<div>
<dt>清晰度</dt>
<dd class="monospace">{{ task.preferredQuality }}</dd>
<dd>{{ formatQualityLabel(task.preferredQuality) }}</dd>
</div>
<div>
<dt>时长</dt>
@@ -905,7 +906,7 @@ onBeforeUnmount(() => {
<el-table-column label="清晰度" width="120">
<template #default="{ row }">
<span class="monospace">{{ row.preferredQuality }}</span>
<span>{{ formatQualityLabel(row.preferredQuality) }}</span>
</template>
</el-table-column>
@@ -1435,4 +1436,3 @@ onBeforeUnmount(() => {
}
}
</style>
+24 -6
View File
@@ -8,7 +8,7 @@ import type {
SystemSettings,
WebhookTestResult
} from "@/types";
import { outputFormatLabelMap, recordingTemplateLabelMap, saveModeLabelMap } from "@/types";
import { outputFormatLabelMap, qualityOptionList, recordingTemplateLabelMap, saveModeLabelMap } from "@/types";
import { useAuthStore } from "@/stores/auth";
import { useViewport } from "@/composables/useViewport";
import { useUiPreferences } from "@/composables/useUiPreferences";
@@ -42,6 +42,8 @@ const webhookTestResult = ref<WebhookTestResult | null>(null);
const retentionCleanupResult = ref<RetentionCleanupResult | null>(null);
const loadError = ref("");
const activeSettingTab = ref("recording");
const qualitySupportHint = "抖音和 Bilibili 支持按画质选流;虎牙暂未实现。若目标档位不可用,平台会自动回退到最接近的可用画质。";
const qualityOptions = qualityOptionList;
const savebarStyle = computed(() => {
if (isMobile.value) {
@@ -63,7 +65,7 @@ const form = reactive<SystemSettings>({
ffmpegPath: "ffmpeg",
outputRoot: "records",
outputDirectoryTemplate: "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
outputFileNameTemplate: "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}",
outputFileNameTemplate: "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}",
defaultQuality: "origin",
defaultOutputFormat: 0,
saveMode: 0,
@@ -187,6 +189,7 @@ const outputTemplateTokens = [
"{roomId}",
"{anchor}",
"{title}",
"{quality}",
"{yyyy}",
"{MM}",
"{dd}",
@@ -266,12 +269,12 @@ const canSendTestWebhook = computed(() => Boolean(form.webhookUrl.trim()));
const segmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789_00001.${extension}`;
return `Douyin/2026/04/15/主播名/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
});
const nestedSegmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789/221530_主播名_直播标题_123456789_00001.${extension}`;
return `Douyin/origin/2026/04/15/主播名/221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
});
async function loadSettings() {
@@ -599,7 +602,14 @@ onMounted(loadSettings);
</el-col>
<el-col :span="12">
<el-form-item label="默认清晰度">
<el-input v-model="form.defaultQuality" placeholder="origin / FULL_HD / HD / SD" />
<el-select v-model="form.defaultQuality">
<el-option
v-for="option in qualityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -670,6 +680,10 @@ onMounted(loadSettings);
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
{{ qualitySupportHint }}
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
@@ -815,7 +829,7 @@ onMounted(loadSettings);
v-model="form.outputFileNameTemplate"
type="textarea"
:rows="3"
placeholder="{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"
placeholder="{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"
/>
</el-form-item>
</el-form>
@@ -834,6 +848,10 @@ onMounted(loadSettings);
目录模板和文件名模板中的时间变量统一按北京时间UTC+8渲染示例路径也按北京时间展示
</div>
<div class="helper-panel">
<code>{quality}</code> 输出稳定画质键例如 <code>origin</code><code>FULL_HD</code><code>HD</code><code>SD</code>适合放进目录名或文件名里做脚本处理
</div>
<div class="token-list">
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
@@ -52,7 +52,7 @@ public sealed class SystemSettingsDto
public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}";
public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}";
public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}";
public string DefaultQuality { get; set; } = "origin";
@@ -233,7 +233,7 @@ public sealed class UpdateSystemSettingsRequest
public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}";
public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}";
public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}";
public string DefaultQuality { get; set; } = "origin";
@@ -284,6 +284,7 @@ public sealed class RecordService
liveRoom.RoomId,
storageAnchorName,
liveRoom.Title,
streamResult.SelectedQuality,
outputFormat,
saveMode,
now);
@@ -770,6 +771,7 @@ public sealed class RecordService
string roomId,
string? anchorName,
string? title,
string selectedQuality,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
DateTimeOffset now)
@@ -783,6 +785,7 @@ public sealed class RecordService
safeRoomId,
anchorName,
title,
selectedQuality,
localNow,
segmentSuffix: string.Empty);
var directoryPath = BuildDirectoryPath(
@@ -791,6 +794,7 @@ public sealed class RecordService
safeRoomId,
anchorName,
title,
selectedQuality,
localNow,
baseFileStem);
var fileNameStem = BuildFileNameStem(
@@ -799,6 +803,7 @@ public sealed class RecordService
safeRoomId,
anchorName,
title,
selectedQuality,
localNow,
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
var folder = Path.Combine(outputRoot, directoryPath);
@@ -838,6 +843,7 @@ public sealed class RecordService
string roomId,
string? anchorName,
string? title,
string quality,
DateTimeOffset now,
string fileStem)
{
@@ -847,6 +853,7 @@ public sealed class RecordService
roomId,
anchorName,
title,
quality,
now,
forPathSegment: true,
fileStem,
@@ -866,6 +873,7 @@ public sealed class RecordService
string roomId,
string? anchorName,
string? title,
string quality,
DateTimeOffset now,
string segmentSuffix)
{
@@ -875,6 +883,7 @@ public sealed class RecordService
roomId,
anchorName,
title,
quality,
now,
forPathSegment: false,
fileStem: string.Empty,
@@ -885,7 +894,7 @@ public sealed class RecordService
private static string EnsureSegmentSuffixTemplate(string template, RecordSaveMode saveMode)
{
var effectiveTemplate = string.IsNullOrWhiteSpace(template)
? "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"
? "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"
: template.Trim();
if (saveMode != RecordSaveMode.Segmented)
@@ -908,13 +917,14 @@ public sealed class RecordService
string roomId,
string? anchorName,
string? title,
string quality,
DateTimeOffset now,
bool forPathSegment,
string fileStem,
string segmentSuffix)
{
var effectiveTemplate = string.IsNullOrWhiteSpace(template)
? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}")
? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}")
: template;
// Path tokens must stay case-sensitive so {MM} (month) and {mm} (minute) don't collide.
@@ -924,6 +934,7 @@ public sealed class RecordService
["roomId"] = roomId,
["anchor"] = NormalizeTokenValue(anchorName, "unknown-anchor"),
["title"] = NormalizeTokenValue(title, "untitled"),
["quality"] = NormalizeTokenValue(quality, "origin"),
["fileStem"] = fileStem,
["segmentSuffix"] = segmentSuffix,
["yyyy"] = now.ToString("yyyy"),
@@ -115,7 +115,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
FfmpegPath = GetValue(lookup, FfmpegPathKey, "ffmpeg"),
OutputRoot = GetValue(lookup, OutputRootKey, "records"),
OutputDirectoryTemplate = GetValue(lookup, OutputDirectoryTemplateKey, "{platform}/{yyyy}/{MM}/{dd}/{anchor}"),
OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"),
OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"),
DefaultQuality = GetValue(lookup, DefaultQualityKey, "origin"),
DefaultOutputFormat = Enum.TryParse(GetValue(lookup, DefaultOutputFormatKey, "Mp4"), true, out RecordOutputFormat outputFormat)
? outputFormat