fix(ui): render Feature Flags descriptions and align with Settings layout

This commit is contained in:
matevip 2026-05-02 19:05:11 +08:00
parent 44ea8c784b
commit dcd5a95982

View File

@ -1,27 +1,26 @@
<template> <template>
<div class="feature-flags-page"> <div class="settings-section feature-flags-section">
<header class="page-header"> <div class="section-header">
<div class="mc-page-kicker">{{ t('settings.kicker') }}</div> <h2 class="section-title">{{ t('settings.featureFlags.title') }}</h2>
<h2 class="page-title">{{ t('settings.featureFlags.title') }}</h2> <p class="section-desc">{{ t('settings.featureFlags.description') }}</p>
<p class="page-desc">{{ t('settings.featureFlags.description') }}</p> </div>
</header>
<div v-if="loading" class="state-row"> <div v-if="loading" class="settings-card state-card">
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
<span>{{ t('common.loading') }}</span> <span>{{ t('common.loading') }}</span>
</div> </div>
<div v-else-if="error" class="state-row state-row--error"> <div v-else-if="error" class="settings-card state-card state-card--error">
<el-icon><WarningFilled /></el-icon> <el-icon><WarningFilled /></el-icon>
<span>{{ error }}</span> <span>{{ error }}</span>
<el-button size="small" @click="load">{{ t('common.retry', 'Retry') }}</el-button> <button class="btn-secondary" @click="load">{{ t('common.retry', 'Retry') }}</button>
</div> </div>
<ul v-else-if="flags.length > 0" class="flag-list"> <div v-else-if="flags.length > 0" class="settings-card">
<li v-for="flag in flags" :key="flag.flagKey" class="flag-row"> <div v-for="flag in flags" :key="flag.flagKey" class="setting-item">
<div class="flag-text"> <div class="setting-info">
<div class="flag-key">{{ flag.flagKey }}</div> <div class="setting-label flag-key">{{ flag.flagKey }}</div>
<div v-if="describe(flag)" class="flag-desc">{{ describe(flag) }}</div> <div v-if="describe(flag)" class="setting-hint">{{ describe(flag) }}</div>
<div v-if="hasScope(flag)" class="flag-scope"> <div v-if="hasScope(flag)" class="flag-scope">
<span v-if="flag.whitelistKbIds"> <span v-if="flag.whitelistKbIds">
{{ t('settings.featureFlags.scope.kb') }}: {{ flag.whitelistKbIds }} {{ t('settings.featureFlags.scope.kb') }}: {{ flag.whitelistKbIds }}
@ -34,46 +33,46 @@
</span> </span>
</div> </div>
</div> </div>
<div class="flag-actions"> <div class="setting-control">
<el-switch <label class="toggle-switch">
:model-value="flag.enabled" <input
:loading="pending[flag.flagKey] === true" type="checkbox"
:disabled="pending[flag.flagKey] === true" :checked="flag.enabled"
@change="(value: any) => onToggle(flag, !!value)" :disabled="pending[flag.flagKey] === true"
/> @change="onToggle(flag, ($event.target as HTMLInputElement).checked)"
/>
<span class="toggle-slider"></span>
</label>
</div> </div>
</li> </div>
</ul> </div>
<div v-else class="state-row"> <div v-else class="settings-card state-card">
<span>{{ t('settings.featureFlags.empty') }}</span> <span>{{ t('settings.featureFlags.empty') }}</span>
</div> </div>
<footer class="page-footer"> <p class="section-footer-note">{{ t('settings.featureFlags.footer') }}</p>
<p>{{ t('settings.featureFlags.footer') }}</p>
</footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElButton, ElIcon, ElMessage, ElSwitch } from 'element-plus' import { ElIcon, ElMessage } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue' import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { featureFlagApi, type FeatureFlag } from '@/api/index' import { featureFlagApi, type FeatureFlag } from '@/api/index'
const { t, te } = useI18n() const { t, getLocaleMessage, locale } = useI18n()
/** // Walk the message tree manually vue-i18n's t()/tm() interpret dots as a
* Resolves a flag's description from i18n first, falling back to the backend // nested path, and our flagKey ("wiki.ocr.enabled") contains dots that should
* column when no translation key is registered. The backend value is English- // be treated as part of a single property name. Backend description is the
* only by design (it's a stable reference identifier in the DB seed); UI // fallback when a flag has no localized copy.
* copy lives next to other strings in the locale files.
*/
function describe(flag: FeatureFlag): string { function describe(flag: FeatureFlag): string {
const i18nKey = `settings.featureFlags.descriptions.${flag.flagKey}` const messages = getLocaleMessage(locale.value) as any
if (te(i18nKey)) { const localized = messages?.settings?.featureFlags?.descriptions?.[flag.flagKey]
return t(i18nKey) if (typeof localized === 'string' && localized.length > 0) {
return localized
} }
return flag.description ?? '' return flag.description ?? ''
} }
@ -107,12 +106,11 @@ async function onToggle(flag: FeatureFlag, next: boolean) {
pending[flag.flagKey] = true pending[flag.flagKey] = true
try { try {
await featureFlagApi.update(flag.flagKey, { enabled: next }) await featureFlagApi.update(flag.flagKey, { enabled: next })
flag.enabled = next // optimistic local update flag.enabled = next
ElMessage.success(t(next ? 'settings.featureFlags.enabled' : 'settings.featureFlags.disabled', ElMessage.success(t(next ? 'settings.featureFlags.enabled' : 'settings.featureFlags.disabled',
{ key: flag.flagKey })) { key: flag.flagKey }))
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.message ?? t('settings.featureFlags.toggleFailed')) ElMessage.error(e?.message ?? t('settings.featureFlags.toggleFailed'))
// Revert by refreshing list to true server state.
await load() await load()
} finally { } finally {
pending[flag.flagKey] = false pending[flag.flagKey] = false
@ -123,99 +121,65 @@ onMounted(load)
</script> </script>
<style scoped> <style scoped>
.feature-flags-page { .settings-section { width: 100%; }
display: flex; .section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
flex-direction: column; .section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
gap: 18px; .section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
max-width: 880px;
}
.page-header { .settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04); width: 100%; }
border-bottom: 1px solid var(--mc-border-light); .setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
padding-bottom: 16px; .setting-item:last-child { border-bottom: none; }
} .setting-info { flex: 1; min-width: 0; }
.page-title { .setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
font-size: 22px; .setting-hint { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.5; }
font-weight: 700; .setting-control { width: 80px; display: flex; align-items: center; justify-content: flex-end; }
margin: 4px 0 8px;
color: var(--mc-text-primary);
}
.page-desc {
color: var(--mc-text-secondary);
font-size: 13px;
line-height: 1.6;
margin: 0;
}
.flag-list { .toggle-switch { position: relative; display: inline-flex; width: 44px; height: 24px; }
list-style: none; .toggle-switch input { opacity: 0; width: 0; height: 0; }
padding: 0; .toggle-slider { position: absolute; inset: 0; cursor: pointer; background: var(--mc-border); border-radius: 999px; transition: 0.2s; }
margin: 0; .toggle-slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
display: flex; .toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
flex-direction: column; .toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
gap: 1px; .toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
background: var(--mc-border-light);
border-radius: 10px;
overflow: hidden;
}
.flag-row { .btn-secondary { border: 1px solid var(--mc-border); border-radius: 10px; padding: 6px 12px; font-size: 13px; cursor: pointer; background: var(--mc-bg-elevated); color: var(--mc-text-primary); transition: all 0.15s; }
display: flex; .btn-secondary:hover { background: var(--mc-bg-sunken); }
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: var(--mc-bg-base);
}
.flag-text {
flex: 1;
min-width: 0;
}
.flag-key { .flag-key {
font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 13px; font-size: 13px;
font-weight: 600;
color: var(--mc-text-primary);
}
.flag-desc {
font-size: 12px;
color: var(--mc-text-secondary);
margin-top: 4px;
line-height: 1.5;
} }
.flag-scope { .flag-scope {
font-size: 11px; font-size: 11px;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
margin-top: 4px; margin-top: 6px;
display: flex; display: flex;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.state-row { .state-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 18px; padding: 18px;
color: var(--mc-text-secondary); color: var(--mc-text-secondary);
background: var(--mc-bg-muted);
border-radius: 10px;
} }
.state-row--error { .state-card--error {
color: var(--el-color-danger); color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
} }
.page-footer { .section-footer-note {
font-size: 12px; font-size: 12px;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
line-height: 1.5; line-height: 1.5;
padding-top: 12px; margin-top: 16px;
border-top: 1px solid var(--mc-border-light); }
@media (max-width: 900px) {
.setting-item { flex-direction: column; }
.setting-control { width: 100%; justify-content: flex-start; }
} }
</style> </style>