417 lines
12 KiB
Vue
417 lines
12 KiB
Vue
<template>
|
|
<div class="aksrt-form-renderer">
|
|
<!-- Page header with title and description -->
|
|
<div v-if="title" class="aksrt-form-renderer__header">
|
|
<div class="aksrt-form-renderer__header-left">
|
|
<FaIcon v-if="icon" :icon="icon" :size="22" class="aksrt-form-renderer__header-icon" />
|
|
<div>
|
|
<h1 class="aksrt-form-renderer__title">{{ title }}</h1>
|
|
<p v-if="description" class="aksrt-form-renderer__desc">{{ description }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="showSearch" class="aksrt-form-renderer__search">
|
|
<n-input
|
|
v-model:value="searchQuery"
|
|
placeholder="搜索设置项..."
|
|
size="small"
|
|
clearable
|
|
>
|
|
<template #prefix>
|
|
<FaIcon icon="fa fas fa-search" :size="12" />
|
|
</template>
|
|
</n-input>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div v-if="loading" class="aksrt-form-renderer__loading">
|
|
<n-spin size="medium" />
|
|
<span>加载中...</span>
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<n-alert
|
|
v-else-if="loadError"
|
|
type="error"
|
|
:title="'加载失败'"
|
|
:style="{ marginBottom: '16px' }"
|
|
>
|
|
{{ loadError }}
|
|
<template #footer>
|
|
<n-button size="small" @click="$emit('reload')">重试</n-button>
|
|
</template>
|
|
</n-alert>
|
|
|
|
<!-- Form content -->
|
|
<template v-else-if="schema && schema.length">
|
|
<template v-for="group in filteredSchema" :key="groupKey(group)">
|
|
<AksFieldGroup
|
|
v-if="isGroupVisible(group)"
|
|
:title="group.title"
|
|
:description="group.description"
|
|
:icon="group.icon"
|
|
:collapsible="!!group.collapsible"
|
|
:default-collapsed="resolveGroupCollapsed(group)"
|
|
>
|
|
<template v-if="group.fields">
|
|
<template v-for="field in group.fields" :key="field.key">
|
|
<template v-if="isFieldVisible(field)">
|
|
<slot
|
|
v-if="field.type === 'custom'"
|
|
:name="`field-${field.key}`"
|
|
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
|
|
>
|
|
<AksField
|
|
:schema="field"
|
|
:model-value="getFieldValue(field.key)"
|
|
:error-message="errors?.[field.key]"
|
|
@update:model-value="setFieldValue(field.key, $event)"
|
|
/>
|
|
</slot>
|
|
<AksField
|
|
v-else
|
|
:schema="field"
|
|
:model-value="getFieldValue(field.key)"
|
|
:error-message="errors?.[field.key]"
|
|
@update:model-value="setFieldValue(field.key, $event)"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Custom group content slot -->
|
|
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
|
|
</AksFieldGroup>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else class="aksrt-form-renderer__empty">
|
|
<FaIcon icon="fa fas fa-cog" :size="32" />
|
|
<p>暂无配置项</p>
|
|
</div>
|
|
|
|
<!-- Sticky save bar -->
|
|
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
|
|
<div class="aksrt-form-renderer__save-bar__inner">
|
|
<div class="aksrt-form-renderer__save-bar__info">
|
|
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
|
|
<span>您有未保存的更改</span>
|
|
</div>
|
|
<div class="aksrt-form-renderer__save-bar__actions">
|
|
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
|
|
<n-button
|
|
size="small"
|
|
type="primary"
|
|
:loading="saving"
|
|
@click="$emit('save')"
|
|
>
|
|
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
|
|
保存设置
|
|
</n-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
/**
|
|
* AksFormRenderer — the core schema-driven form engine.
|
|
*
|
|
* Takes a schema definition (array of field groups) and a reactive form data object,
|
|
* and renders a complete settings page with:
|
|
* - Grouped fields in card-style containers
|
|
* - Inline search/filter
|
|
* - Dirty state tracking with sticky save bar
|
|
* - Dependency-based field visibility
|
|
* - Loading and error states
|
|
*/
|
|
import { ref, computed } from 'vue'
|
|
import FaIcon from './FaIcon.vue'
|
|
import AksFieldGroup from './AksFieldGroup.vue'
|
|
import AksField from './AksField.vue'
|
|
|
|
const props = defineProps({
|
|
/** Page title */
|
|
title: { type: String, default: '' },
|
|
/** Page description */
|
|
description: { type: String, default: '' },
|
|
/** Title icon (FA5 class) */
|
|
icon: { type: String, default: '' },
|
|
/**
|
|
* Schema definition: array of field groups.
|
|
* Each group: { key, title, description?, icon?, collapsible?, fields: FieldDef[] }
|
|
* Each field: { key, label, type, default, options?, dependsOn?, help?, ... }
|
|
*/
|
|
schema: { type: Array, default: () => [] },
|
|
/** Reactive form data object */
|
|
formData: { type: Object, default: () => ({}) },
|
|
/** Whether the form has unsaved changes */
|
|
dirty: { type: Boolean, default: false },
|
|
/** Whether data is currently loading */
|
|
loading: { type: Boolean, default: false },
|
|
/** Whether a save is in progress */
|
|
saving: { type: Boolean, default: false },
|
|
/** Load error message */
|
|
loadError: { type: String, default: '' },
|
|
/** Whether to show the search bar */
|
|
showSearch: { type: Boolean, default: true },
|
|
/** Validation errors keyed by field key */
|
|
errors: { type: Object, default: () => ({}) },
|
|
})
|
|
|
|
const emit = defineEmits(['update:formData', 'save', 'reload'])
|
|
|
|
const searchQuery = ref('')
|
|
|
|
/** Evaluate dependsOn rule against current form data */
|
|
function matchDependsOn(dependsOn) {
|
|
if (!dependsOn) return true
|
|
if (typeof dependsOn === 'string') {
|
|
return !!props.formData[dependsOn]
|
|
}
|
|
if (typeof dependsOn === 'object') {
|
|
if (Array.isArray(dependsOn.all)) return dependsOn.all.every(matchDependsOn)
|
|
if (Array.isArray(dependsOn.any)) return dependsOn.any.some(matchDependsOn)
|
|
const { key, value, values, not, operator } = dependsOn
|
|
const currentVal = props.formData[key]
|
|
if (operator === 'truthy') return !!currentVal
|
|
if (operator === 'falsy') return !currentVal
|
|
if (operator === 'not_empty') return Array.isArray(currentVal) ? currentVal.length > 0 : currentVal !== '' && currentVal != null
|
|
if (operator === 'empty') return Array.isArray(currentVal) ? currentVal.length === 0 : currentVal === '' || currentVal == null
|
|
if (operator === 'eq') return currentVal === value
|
|
if (operator === 'neq') return currentVal !== value
|
|
if (not) {
|
|
if (Array.isArray(values)) {
|
|
// eslint-disable-next-line eqeqeq
|
|
return !values.some((v) => currentVal == v)
|
|
}
|
|
// eslint-disable-next-line eqeqeq
|
|
return currentVal !== value && currentVal != value
|
|
}
|
|
// values: match any of a list (e.g. provider ∈ [qq, polling])
|
|
if (Array.isArray(values)) {
|
|
// eslint-disable-next-line eqeqeq
|
|
return values.some((v) => currentVal == v)
|
|
}
|
|
// Loose equality so true matches 1 / '1' from some option stores.
|
|
// eslint-disable-next-line eqeqeq
|
|
return currentVal == value || (!!value === true && !!currentVal)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/** Check if a field should be visible based on its dependsOn rule */
|
|
function isFieldVisible(field) {
|
|
return matchDependsOn(field.dependsOn)
|
|
}
|
|
|
|
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
|
function isGroupVisible(group) {
|
|
return matchDependsOn(group.dependsOn)
|
|
}
|
|
|
|
/**
|
|
* Collapse preference for a group.
|
|
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
|
|
* - defaultCollapsed: static default
|
|
*/
|
|
function resolveGroupCollapsed(group) {
|
|
if (group.expandWhen) {
|
|
const on = !!props.formData?.[group.expandWhen]
|
|
return !on
|
|
}
|
|
return !!group.defaultCollapsed
|
|
}
|
|
|
|
/** Stable key; remount when expandWhen flips so newly enabled channels open expanded. */
|
|
function groupKey(group) {
|
|
const base = group.key || group.title || 'group'
|
|
if (group.expandWhen) {
|
|
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
|
|
}
|
|
return base
|
|
}
|
|
|
|
/** Get field value from formData */
|
|
function getFieldValue(key) {
|
|
return props.formData?.[key]
|
|
}
|
|
|
|
/** Set field value, emitting update for parent */
|
|
function setFieldValue(key, value) {
|
|
const newData = { ...props.formData, [key]: value }
|
|
emit('update:formData', newData)
|
|
}
|
|
|
|
/** Filter schema groups/fields by search query */
|
|
const filteredSchema = computed(() => {
|
|
const q = searchQuery.value?.toLowerCase().trim()
|
|
if (!q) return props.schema
|
|
|
|
return props.schema
|
|
.map(group => {
|
|
if (!group.fields) return null
|
|
|
|
// Check if group title matches
|
|
const groupMatch = (group.title || '').toLowerCase().includes(q)
|
|
|| (group.description || '').toLowerCase().includes(q)
|
|
|
|
// Filter fields by label, key, or help text matching query
|
|
const matchingFields = group.fields.filter(field => {
|
|
if (groupMatch) return true
|
|
return (field.label || '').toLowerCase().includes(q)
|
|
|| (field.key || '').toLowerCase().includes(q)
|
|
|| (field.help || '').toLowerCase().includes(q)
|
|
|| (field.description || '').toLowerCase().includes(q)
|
|
})
|
|
|
|
if (matchingFields.length === 0) return null
|
|
|
|
return { ...group, fields: groupMatch ? group.fields : matchingFields }
|
|
})
|
|
.filter(Boolean)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.aksrt-form-renderer__header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
margin-bottom: 24px;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.aksrt-form-renderer__header-left {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.aksrt-form-renderer__header-icon {
|
|
color: var(--aksrt-color-primary);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.aksrt-form-renderer__title {
|
|
margin: 0;
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: var(--aksrt-color-text);
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.aksrt-form-renderer__desc {
|
|
margin: 4px 0 0;
|
|
font-size: 13px;
|
|
color: var(--aksrt-color-muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.aksrt-form-renderer__search {
|
|
min-width: 200px;
|
|
max-width: 280px;
|
|
}
|
|
|
|
.aksrt-form-renderer__loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 60px 0;
|
|
color: var(--aksrt-color-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.aksrt-form-renderer__empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 60px 0;
|
|
color: var(--aksrt-color-border-strong);
|
|
}
|
|
|
|
.aksrt-form-renderer__empty p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* Sticky save bar */
|
|
.aksrt-form-renderer__save-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 1000;
|
|
background: var(--aksrt-color-surface);
|
|
border-top: 1px solid var(--aksrt-color-border);
|
|
box-shadow: var(--aksrt-shadow-floating);
|
|
animation: slideUp 0.2s ease;
|
|
}
|
|
|
|
.aksrt-form-renderer__save-bar__inner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 12px 28px;
|
|
gap: 16px;
|
|
}
|
|
|
|
.aksrt-form-renderer__save-bar__info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #d48806;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.aksrt-form-renderer__save-bar__actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
transform: translateY(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* Mobile */
|
|
@media (max-width: 768px) {
|
|
.aksrt-form-renderer__header {
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.aksrt-form-renderer__search {
|
|
max-width: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.aksrt-form-renderer__save-bar__inner {
|
|
flex-direction: column;
|
|
padding: 10px 14px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.aksrt-form-renderer__save-bar__actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.aksrt-form-renderer__save-bar__actions .n-button {
|
|
flex: 1;
|
|
}
|
|
}
|
|
</style>
|