Files
wordpress-theme-aksrt/assets/admin/src/components/form/AksField.vue
T
kate522 1a9a2762f3 feat(payment): 新增「售后策略」配置与前台售后弹窗链路
- 后台「支付&付费 → 售后策略」:售后功能开关、申请时效、超时效提示(HTML)、
  预置原因、申请售后说明、所需材料、申请说明占位文案、可上传图片开关;
  PHP schema 与 SPA schema 1:1 对齐,group 字段支持拖拽排序
- PaymentRuntime 新增售后设置解析(缺键回退 schema 默认、HTML 走 kses、
  中文原因稳定 slug)与 after_sale_expired 时效判定(支付时间起算)
- 售后工单支持举证图片(order_aftersale.images 列,最多 3 张,
  复用评论图片上传校验)与上传路由;订单视图模型输出 after_sale 按钮状态
- 前台申请 / 超时效提示 / 售后详细三个独立弹窗(modal-mini + 蓝色炫彩头),
  订单详情弹窗不再内嵌售后表单并清理相关死代码
- 新增 after-sale-policy 契约测试与运行时 harness,527 项契约测试通过
2026-10-06 22:21:47 +08:00

1655 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="aksrt-field" :class="fieldClasses">
<div v-if="schema.label" class="aksrt-field__label-row">
<label class="aksrt-field__label">
{{ schema.label }}
<span v-if="schema.required" class="aksrt-field__required">*</span>
</label>
<n-tooltip v-if="schema.help" trigger="hover">
<template #trigger>
<FaIcon icon="fa fas fa-question-circle" :size="13" class="aksrt-field__help-icon" />
</template>
{{ schema.help }}
</n-tooltip>
</div>
<p v-if="schema.description && !schema.help" class="aksrt-field__desc">
{{ schema.description }}
</p>
<!-- Switch -->
<n-switch
v-if="schema.type === 'switch' || schema.type === 'switcher'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Input (text, email, url, password) -->
<div v-else-if="schema.type === 'input' || schema.type === 'text' || schema.type === 'url'" class="aksrt-field__input-wrap">
<n-input
:value="secretDisplayValue"
:placeholder="schema.placeholder || ''"
:type="schema.inputType || 'text'"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
>
<template v-if="schema.prefix" #prefix>{{ schema.prefix }}</template>
<template v-if="schema.suffix" #suffix>{{ schema.suffix }}</template>
</n-input>
<n-button
v-if="schema.secret && (modelValue === '***' || secretMarkedForClear)"
size="small"
secondary
:type="secretMarkedForClear ? 'warning' : 'error'"
@click="toggleSecretClear"
>
{{ secretMarkedForClear ? '取消清除' : '清除已配置密钥' }}
</n-button>
<span v-if="secretMarkedForClear" class="aksrt-field__secret-clear-hint">保存后将删除此密钥</span>
</div>
<!-- Textarea -->
<n-input
v-else-if="schema.type === 'textarea'"
:value="modelValue"
type="textarea"
:placeholder="schema.placeholder || ''"
:rows="schema.rows || 4"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Number -->
<div v-else-if="schema.type === 'number' || schema.type === 'spinner'" class="aksrt-field__number">
<n-input-number
:value="modelValue"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.placeholder || ''"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<span v-if="schema.unit" class="aksrt-field__unit">{{ schema.unit }}</span>
</div>
<!-- Select -->
<div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select">
<n-select
:value="modelValue"
:options="normalizedOptions"
:placeholder="schema.placeholder || '请选择'"
:multiple="schema.multiple"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<div v-if="schema.multiple && schema.sortable && Array.isArray(modelValue)" class="aksrt-field__selected-order">
<div v-for="(value, index) in modelValue" :key="value" class="aksrt-field__selected-order-item">
<span>{{ selectedOptionLabel(normalizedOptions, value) }}</span>
<span><n-button text size="tiny" :disabled="index === 0" @click="moveSelectedValue(index, -1)">↑</n-button><n-button text size="tiny" :disabled="index === modelValue.length - 1" @click="moveSelectedValue(index, 1)">↓</n-button></span>
</div>
</div>
</div>
<div v-else-if="schema.type === 'async_select'" class="aksrt-field__ordered-select">
<n-select
:value="schema.multiple === true && !Array.isArray(modelValue) ? [] : modelValue"
:options="asyncOptions"
:loading="asyncLoading"
:placeholder="schema.placeholder || '输入关键词搜索内容'"
:multiple="schema.multiple === true"
filterable
remote
clearable
@search="handleAsyncSearch"
@update:value="emit('update:modelValue', $event)"
/>
<div v-if="schema.multiple && schema.sortable && Array.isArray(modelValue)" class="aksrt-field__selected-order">
<div v-for="(value, index) in modelValue" :key="value" class="aksrt-field__selected-order-item">
<span>{{ selectedOptionLabel(asyncOptions, value) }}</span>
<span><n-button text size="tiny" :disabled="index === 0" @click="moveSelectedValue(index, -1)">↑</n-button><n-button text size="tiny" :disabled="index === modelValue.length - 1" @click="moveSelectedValue(index, 1)">↓</n-button></span>
</div>
</div>
</div>
<!-- Taxonomy Select (async-loaded category/tag terms) -->
<n-select
v-else-if="schema.type === 'taxonomy_select'"
:value="modelValue"
:options="taxonomyOptions"
:loading="taxonomyLoading"
:placeholder="schema.placeholder || '请选择分类'"
:multiple="schema.multiple !== false"
:filterable="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- User Select (async search) -->
<n-select
v-else-if="schema.type === 'user_select'"
:value="modelValue"
:options="userOptions"
:loading="userLoading"
:placeholder="schema.placeholder || '搜索用户'"
:multiple="schema.multiple === true"
:filterable="true"
:remote="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@search="handleUserSearch"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Page Select (async page list) -->
<n-select
v-else-if="schema.type === 'page_select'"
:value="modelValue"
:options="pageOptions"
:loading="pageLoading"
:placeholder="schema.placeholder || '选择页面'"
:multiple="schema.multiple === true"
:filterable="true"
:remote="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@search="handlePageSearch"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Radio group -->
<n-radio-group
v-else-if="schema.type === 'radio'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
>
<n-space v-if="schema.buttonStyle === false">
<n-radio
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="schema.disabled"
>
{{ opt.label }}
</n-radio>
</n-space>
<n-radio-button
v-else
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="schema.disabled"
>
{{ opt.label }}
</n-radio-button>
</n-radio-group>
<!-- Checkbox group -->
<n-checkbox-group
v-else-if="schema.type === 'checkbox'"
:value="modelValue || []"
@update:value="emit('update:modelValue', $event)"
>
<n-space>
<n-checkbox
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:disabled="schema.disabled"
/>
</n-space>
</n-checkbox-group>
<!-- Color picker -->
<n-color-picker
v-else-if="schema.type === 'color'"
:value="modelValue"
:modes="['hex']"
:show-alpha="schema.showAlpha || false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<div v-else-if="schema.type === 'palette'" class="aksrt-field__palette">
<button
v-for="opt in normalizedOptions"
:key="opt.value"
type="button"
class="aksrt-field__palette-item"
:class="{ 'is-active': modelValue === opt.value }"
:style="{ background: opt.label }"
:title="opt.value"
@click="emit('update:modelValue', opt.value)"
/>
</div>
<!-- Icon picker -->
<FaIconPicker
v-else-if="schema.type === 'icon'"
:model-value="modelValue"
:placeholder="schema.placeholder || '选择图标'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Media upload -->
<AksMediaField
v-else-if="schema.type === 'media' || schema.type === 'upload'"
:model-value="modelValue"
:accept="schema.accept"
:placeholder="schema.placeholder || '选择图片'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- WordPress image gallery (attachment ID array) -->
<AksGalleryField
v-else-if="schema.type === 'gallery'"
:model-value="Array.isArray(modelValue) ? modelValue : []"
:title="schema.label || '选择图片'"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Slider -->
<n-slider
v-else-if="schema.type === 'slider'"
:value="modelValue"
:min="schema.min || 0"
:max="schema.max || 100"
:step="schema.step || 1"
:disabled="schema.disabled"
:marks="schema.marks"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Code editor with syntax highlighting (CodeMirror 6) -->
<AksCodeEditor
v-else-if="schema.type === 'code'"
:model-value="modelValue"
:language="schema.language || 'html'"
:disabled="schema.disabled"
:rows="schema.rows || 12"
@update:model-value="emit('update:modelValue', $event)"
/>
<!-- Alert/info content -->
<n-alert
v-else-if="schema.type === 'alert' || schema.type === 'notice'"
:type="schema.alertType || 'info'"
:title="schema.alertTitle"
:closable="false"
>
{{ schema.content || schema.alertContent || modelValue }}
</n-alert>
<!-- Pure HTML content (no form field, just display) -->
<div
v-else-if="schema.type === 'content'"
class="aksrt-field__content"
v-html="schema.content || modelValue || ''"
/>
<!-- Action button -->
<n-button
v-else-if="schema.type === 'button'"
:type="schema.buttonType || 'default'"
:loading="schema.loading"
:disabled="schema.disabled"
@click="emit('action', schema.key)"
>
{{ schema.buttonText || schema.label || '执行' }}
</n-button>
<!-- Date picker -->
<n-date-picker
v-else-if="schema.type === 'date'"
:value="modelValue"
:type="schema.dateType || 'date'"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Time picker -->
<n-time-picker
v-else-if="schema.type === 'time'"
:value="modelValue"
:disabled="schema.disabled"
style="width: 100%"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Dimensions (width x height) -->
<div v-else-if="schema.type === 'dimensions'" class="aksrt-field__dimensions">
<n-input-number
:value="modelValue?.width"
:min="0"
:placeholder="schema.widthPlaceholder || '宽度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateDimension('width', $event)"
/>
<span class="aksrt-field__dimensions-sep">×</span>
<n-input-number
:value="modelValue?.height"
:min="0"
:placeholder="schema.heightPlaceholder || '高度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateDimension('height', $event)"
/>
<n-select
v-if="schema.unit"
:value="modelValue?.unit"
:options="dimensionUnitOptions"
:disabled="schema.disabled"
style="width: 80px"
@update:value="updateDimension('unit', $event)"
/>
</div>
<!-- Between number (min/max pair, Zibll between_number) -->
<div v-else-if="schema.type === 'between_number'" class="aksrt-field__between">
<n-input-number
:value="betweenValue('min')"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.minPlaceholder || '最小'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateBetween('min', $event)"
/>
<span class="aksrt-field__between-sep">—</span>
<n-input-number
:value="betweenValue('max')"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.maxPlaceholder || '最大'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateBetween('max', $event)"
/>
<span v-if="schema.unit" class="aksrt-field__between-unit">{{ schema.unit }}</span>
</div>
<!-- Spacing (top right bottom left) -->
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
<n-input-number
:value="modelValue?.top"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('top', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[1] || '右' }}</div>
<n-input-number
:value="modelValue?.right"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('right', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[2] || '下' }}</div>
<n-input-number
:value="modelValue?.bottom"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('bottom', $event)"
/>
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[3] || '左' }}</div>
<n-input-number
:value="modelValue?.left"
:min="0"
:disabled="schema.disabled"
@update:value="updateSpacing('left', $event)"
/>
</div>
<!-- Background (color + image + position + repeat) -->
<div v-else-if="schema.type === 'background'" class="aksrt-field__background">
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">背景颜色</label>
<n-color-picker
:value="modelValue?.color"
:show-alpha="true"
:disabled="schema.disabled"
@update:value="updateBackground('color', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">背景图片</label>
<AksMediaField
:model-value="modelValue?.image"
:accept="'image/*'"
:disabled="schema.disabled"
@update:model-value="updateBackground('image', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片尺寸</label>
<n-select
:value="modelValue?.size"
:options="bgSizeOptions"
:disabled="schema.disabled"
@update:value="updateBackground('size', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片位置</label>
<n-select
:value="modelValue?.position"
:options="bgPositionOptions"
:disabled="schema.disabled"
@update:value="updateBackground('position', $event)"
/>
</div>
<div class="aksrt-field__background-row">
<label class="aksrt-field__sublabel">图片重复</label>
<n-select
:value="modelValue?.repeat"
:options="bgRepeatOptions"
:disabled="schema.disabled"
@update:value="updateBackground('repeat', $event)"
/>
</div>
</div>
<!-- Image select (radio with image preview) -->
<div v-else-if="schema.type === 'image_select'" class="aksrt-field__image-select">
<div
v-for="opt in normalizedOptions"
:key="opt.value"
class="aksrt-field__image-select-item"
:class="{ 'aksrt-field__image-select-item--active': modelValue === opt.value }"
@click="!schema.disabled && emit('update:modelValue', opt.value)"
>
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="aksrt-field__image-select-img" />
<div v-else class="aksrt-field__image-select-placeholder">
<FaIcon :icon="opt.icon || 'fa fas fa-image'" :size="20" />
</div>
<span class="aksrt-field__image-select-label">{{ opt.label }}</span>
</div>
</div>
<!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
<n-input :value="modelValue?.url || ''" placeholder="https://" @update:value="updateLink('url', $event)" />
<n-select :value="modelValue?.target || ''" :options="linkTargetOptions" @update:value="updateLink('target', $event)" />
<n-input :value="modelValue?.text || ''" placeholder="链接文字(可选)" @update:value="updateLink('text', $event)" />
</div>
<div v-else-if="schema.type === 'sortable'" class="aksrt-field__sortable">
<div v-for="(item, index) in sortableItems" :key="item.key" class="aksrt-field__sortable-item">
<div class="aksrt-field__sortable-head">
<strong>{{ item.label }}</strong>
<span>
<n-button text size="tiny" :disabled="index === 0" @click="moveSortable(index, -1)">↑</n-button>
<n-button text size="tiny" :disabled="index === sortableItems.length - 1" @click="moveSortable(index, 1)">↓</n-button>
</span>
</div>
<div v-if="item.fields && item.fields.length" class="aksrt-field__sortable-body">
<AksField v-for="field in item.fields" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" :form-data="formData" @update:model-value="updateSortable(item.key, field.key, $event)" />
</div>
</div>
</div>
<!-- Repeater (array of objects) — inline expand (default, draggable) -->
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
<draggable
v-model="repeaterDragList"
:item-key="repeaterDragKey"
handle=".aksrt-field__repeater-handle"
ghost-class="aksrt-field__repeater-ghost"
animation="180"
>
<template #item="{ element, index }">
<div class="aksrt-field__repeater-item">
<div class="aksrt-field__repeater-header">
<div class="aksrt-field__repeater-title">
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true"></i>
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
</div>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, element)"
:schema="field"
:model-value="element[field.key]"
:form-data="formData"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
</template>
</draggable>
<n-button
size="small"
dashed
:disabled="schema.disabled || reachedRepeaterMaximum"
@click="addRepeaterItem"
>
<FaIcon icon="fa fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.button_title || schema.addText || '添加一项' }}
</n-button>
</div>
<!-- Repeater list-modal: compact list + modal editor -->
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui === 'list-modal'" class="aksrt-field__repeater aksrt-field__repeater--list-modal">
<div v-if="repeaterItems.length" class="aksrt-field__repeater-list">
<div
v-for="(item, index) in repeaterItems"
:key="item._id || index"
class="aksrt-field__repeater-list-item"
>
<div class="aksrt-field__repeater-list-actions">
<n-button
v-if="index > 0"
size="tiny"
quaternary
:disabled="schema.disabled"
@click="moveRepeaterItem(index, -1)"
>
<FaIcon icon="fa fas fa-arrow-up" :size="12" />
</n-button>
<n-button
v-if="index < repeaterItems.length - 1"
size="tiny"
quaternary
:disabled="schema.disabled"
@click="moveRepeaterItem(index, 1)"
>
<FaIcon icon="fa fas fa-arrow-down" :size="12" />
</n-button>
<n-button
size="tiny"
quaternary
type="primary"
:disabled="schema.disabled"
@click="openRepeaterModal(index)"
>
<FaIcon icon="fa fas fa-edit" :size="12" />
编辑
</n-button>
<n-button
size="tiny"
quaternary
type="error"
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
删除
</n-button>
</div>
<div class="aksrt-field__repeater-list-preview">
<div class="aksrt-field__repeater-list-icon" :class="getRepeaterPreviewIconClass(item)">
<span
v-if="getRepeaterPreviewCustomIcon(item)"
class="aksrt-field__repeater-list-icon-html"
v-html="getRepeaterPreviewCustomIcon(item)"
/>
<i v-else-if="getRepeaterPreviewIcon(item)" :class="getRepeaterPreviewIcon(item)" aria-hidden="true" />
<FaIcon v-else icon="fa fas fa-star" :size="16" />
</div>
<div class="aksrt-field__repeater-list-meta">
<div class="aksrt-field__repeater-list-title">
{{ getRepeaterPreviewTitle(item) }}
</div>
<div class="aksrt-field__repeater-list-desc">
{{ getRepeaterPreviewDesc(item) }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="aksrt-field__repeater-empty">
<FaIcon icon="fa fas fa-inbox" :size="18" />
<span>{{ schema.emptyText || '暂无数据' }}</span>
</div>
<n-button
size="small"
dashed
block
:disabled="schema.disabled || reachedRepeaterMaximum"
@click="openRepeaterModal(-1)"
>
<FaIcon icon="fa fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.button_title || schema.addText || '添加一项' }}
</n-button>
<n-modal
v-model:show="repeaterModalShow"
:mask-closable="false"
preset="card"
:title="repeaterEditingIndex >= 0 ? (schema.editText || '编辑项目') : (schema.addText || '添加项目')"
class="aksrt-modal-card"
style="max-width: 640px; width: 92vw;"
>
<div class="aksrt-field__repeater-modal-fields">
<AksField
v-for="field in (schema.fields || []).filter(field => matchDependency(field.dependsOn, repeaterDraft))"
:key="field.key"
:schema="field"
:model-value="repeaterDraft[field.key]"
:form-data="formData"
@update:model-value="updateRepeaterDraft(field.key, $event)"
/>
</div>
<div class="aksrt-modal-footer">
<n-button @click="closeRepeaterModal">取消</n-button>
<n-button type="primary" @click="confirmRepeaterModal">确定</n-button>
</div>
</n-modal>
</div>
<!-- Custom slot -->
<slot v-else-if="schema.type === 'custom'" />
<!-- Fallback: text input -->
<n-input
v-else
:value="modelValue"
:placeholder="schema.placeholder || ''"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Validation message -->
<p v-if="errorMessage" class="aksrt-field__error">{{ errorMessage }}</p>
</div>
</template>
<script setup>
/**
* AksField — renders a single form field based on its schema definition.
*
* Supported types: switch, input/text, textarea, number, select, radio,
* checkbox, color, icon, media, gallery, slider, code, alert, fieldset, repeater (inline|list-modal), custom (slot).
*/
import { computed, ref, onMounted, watch } from 'vue'
import FaIcon from './FaIcon.vue'
import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue'
import draggable from 'vuedraggable'
import api from '@/api'
const props = defineProps({
/** Field schema definition */
schema: {
type: Object,
required: true,
// Expected shape: { key, label, type, default, options?, help?, ... }
},
/** Current field value */
modelValue: { default: null },
/** Whole-form data — used to resolve dynamic options (schema.options as string = formData key). */
formData: { type: Object, default: () => ({}) },
/** Error message to display */
errorMessage: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'action'])
const secretMarkedForClear = computed(() => props.schema.secret && props.modelValue === '__AKSRT_CLEAR_SECRET__')
const secretDisplayValue = computed(() => secretMarkedForClear.value ? '' : props.modelValue)
function toggleSecretClear() {
emit('update:modelValue', secretMarkedForClear.value ? '***' : '__AKSRT_CLEAR_SECRET__')
}
const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.modelValue.length : 0)
/** 数组安全视图:非数组(如旧版字符串值)一律按空列表渲染,避免逐字符假条目。 */
const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
const repeaterDragList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
})
/**
* 行 key:优先用行自身 _id;服务端历史数据没有 _id 时按对象引用派生稳定 key,
* 避免每次输入都重算 key 导致子字段重挂载、输入焦点丢失。
*/
const dragRowKeys = new WeakMap()
let dragRowKeySeed = 0
function repeaterDragKey(item) {
if (item && typeof item === 'object') {
if (item._id) return item._id
let key = dragRowKeys.get(item)
if (!key) {
key = `rpt_drag_${++dragRowKeySeed}`
dragRowKeys.set(item, key)
}
return key
}
return String(item)
}
function fieldsetValue(field) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
}
function updateFieldsetValue(key, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
emit('update:modelValue', { ...current, [key]: value })
}
/** Normalize options: support string[], {label, value}[], and dynamic formData-key references */
const normalizedOptions = computed(() => {
const opts = props.schema.options
if (typeof opts === 'string') {
// 动态选项:引用 formData 中同名字段(如 custom_filter 维度定义 [{key,name,vals}])。
const source = props.formData?.[opts]
if (!Array.isArray(source)) return []
return source
.map(item => {
const value = String(item?.key ?? '')
if (value === '') return null
return { label: String(item?.name ?? item?.key ?? value), value }
})
.filter(Boolean)
}
return (Array.isArray(opts) ? opts : []).map(opt => {
if (typeof opt === 'string') return { label: opt, value: opt }
if (typeof opt === 'object' && opt !== null) {
return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
}
return { label: String(opt), value: opt }
})
})
const visibleNestedFields = computed(() => (props.schema.fields || []).filter(field => matchDependency(field.dependsOn, props.modelValue)))
const linkTargetOptions = [{ label: '当前窗口', value: '' }, { label: '当前窗口', value: '_self' }, { label: '新窗口', value: '_blank' }]
function matchDependency(rule, sourceValue) {
if (!rule) return true
const source = sourceValue && typeof sourceValue === 'object' ? sourceValue : {}
if (rule.all) return rule.all.every(item => matchDependency(item, source))
if (rule.any) return rule.any.some(item => matchDependency(item, source))
const value = source[rule.key]
if (rule.operator === 'truthy') return !!value
if (rule.operator === 'falsy') return !value
if (rule.operator === 'not_empty') return Array.isArray(value) ? value.length > 0 : value !== '' && value != null
if (rule.operator === 'empty') return Array.isArray(value) ? value.length === 0 : value === '' || value == null
if (rule.operator === 'eq') return value === rule.value
if (rule.operator === 'neq') return value !== rule.value
return rule.value === undefined || value === rule.value
}
function updateLink(key, value) {
emit('update:modelValue', { ...(props.modelValue || {}), [key]: value })
}
function selectedOptionLabel(options, value) {
return options.find(option => option.value === value)?.label || String(value)
}
function moveSelectedValue(index, delta) {
if (!Array.isArray(props.modelValue)) return
const target = index + delta
if (target < 0 || target >= props.modelValue.length) return
const values = [...props.modelValue]
;[values[index], values[target]] = [values[target], values[index]]
emit('update:modelValue', values)
}
const sortableItems = computed(() => {
const definitions = new Map((props.schema.items || []).map(item => [item.key, item]))
const order = Object.keys(props.modelValue || {}).filter(key => definitions.has(key))
for (const key of definitions.keys()) if (!order.includes(key)) order.push(key)
return order.map(key => definitions.get(key))
})
function updateSortable(itemKey, fieldKey, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [itemKey]: { ...(current[itemKey] || {}), [fieldKey]: value } })
}
function moveSortable(index, delta) {
const items = [...sortableItems.value]
const target = index + delta
if (target < 0 || target >= items.length) return
;[items[index], items[target]] = [items[target], items[index]]
const current = props.modelValue || {}
emit('update:modelValue', Object.fromEntries(items.map(item => [item.key, current[item.key] || {}])))
}
const asyncOptions = ref([])
const asyncLoading = ref(false)
let asyncTimer = null
async function loadAsyncOptions(query = '') {
asyncLoading.value = true
try {
const selected = Array.isArray(props.modelValue) ? props.modelValue : (props.modelValue ? [props.modelValue] : [])
const response = await api.searchPageDisplayOptions(props.schema.source, query, query ? [] : selected)
asyncOptions.value = response?.data || []
} catch (error) {
asyncOptions.value = []
} finally {
asyncLoading.value = false
}
}
function handleAsyncSearch(query) {
clearTimeout(asyncTimer)
asyncTimer = setTimeout(() => loadAsyncOptions(query), 250)
}
// ============ Taxonomy Select (async) ============
const taxonomyOptions = ref([])
const taxonomyLoading = ref(false)
async function loadTaxonomyTerms() {
if (!props.schema.taxonomy) return
taxonomyLoading.value = true
try {
const res = await api.getCategories(props.schema.taxonomy || 'category')
const items = res?.data || res || []
taxonomyOptions.value = (Array.isArray(items) ? items : []).map(t => ({
label: t.name + (t.count ? ' (' + t.count + ')' : ''),
value: t.term_id,
}))
} catch (e) {
console.warn('[AksField] taxonomy load failed:', e)
taxonomyOptions.value = []
} finally {
taxonomyLoading.value = false
}
}
onMounted(() => {
if (props.schema.type === 'taxonomy_select') {
loadTaxonomyTerms()
}
if (props.schema.type === 'user_select') {
loadInitialUsers()
}
if (props.schema.type === 'async_select') {
loadAsyncOptions()
}
if (props.schema.type === 'page_select') {
loadPages()
}
})
// ============ User Select (async search) ============
const userOptions = ref([])
const userLoading = ref(false)
let userSearchTimer = null
async function loadInitialUsers() {
userLoading.value = true
try {
const res = await api.searchUsers('', 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
console.warn('[AksField] user load failed:', e)
userOptions.value = []
} finally {
userLoading.value = false
}
}
function handleUserSearch(query) {
clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(async () => {
userLoading.value = true
try {
const res = await api.searchUsers(query || '', 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
userOptions.value = []
} finally {
userLoading.value = false
}
}, 300)
}
// ============ Page Select (async page list) ============
const pageOptions = ref([])
const pageLoading = ref(false)
let pageSearchTimer = null
async function loadPages(query = '') {
pageLoading.value = true
try {
const selected = props.modelValue ? [props.modelValue] : []
const res = await api.searchPageDisplayOptions('pages', query, query ? [] : selected)
const items = res?.data || res || []
pageOptions.value = Array.isArray(items) ? items : []
} catch (e) {
console.warn('[AksField] page load failed:', e)
pageOptions.value = []
} finally {
pageLoading.value = false
}
}
function handlePageSearch(query) {
clearTimeout(pageSearchTimer)
pageSearchTimer = setTimeout(() => loadPages(query), 250)
}
const fieldClasses = computed(() => ({
'aksrt-field--disabled': props.schema.disabled,
'aksrt-field--inline': props.schema.inline,
[`aksrt-field--${props.schema.type}`]: !!props.schema.type,
}))
// ============ Dimensions ============
const dimensionUnitOptions = [
{ label: 'px', value: 'px' },
{ label: '%', value: '%' },
{ label: 'em', value: 'em' },
{ label: 'rem', value: 'rem' },
{ label: 'vw', value: 'vw' },
]
function updateDimension(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Between Number (min/max pair) ============
function betweenValue(part) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
return current[part] !== undefined ? current[part] : (props.schema.default?.[part] ?? 0)
}
function updateBetween(part, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Spacing ============
function updateSpacing(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Background ============
const bgSizeOptions = [
{ label: '覆盖', value: 'cover' },
{ label: '包含', value: 'contain' },
{ label: '原始大小', value: 'auto' },
]
const bgPositionOptions = [
{ label: '居中', value: 'center center' },
{ label: '顶部居中', value: 'top center' },
{ label: '底部居中', value: 'bottom center' },
{ label: '左侧居中', value: 'center left' },
{ label: '右侧居中', value: 'center right' },
]
const bgRepeatOptions = [
{ label: '不重复', value: 'no-repeat' },
{ label: '平铺', value: 'repeat' },
{ label: '水平平铺', value: 'repeat-x' },
{ label: '垂直平铺', value: 'repeat-y' },
]
function updateBackground(part, value) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Repeater ============
let repeaterIdCounter = 0
const repeaterModalShow = ref(false)
const repeaterEditingIndex = ref(-1)
const repeaterDraft = ref({})
function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
for (const field of (props.schema.fields || [])) {
if (field.default !== undefined) {
newItem[field.key] = field.default
} else if (field.type === 'switch') {
newItem[field.key] = false
} else if (field.type === 'checkbox' || field.type === 'repeater') {
newItem[field.key] = []
} else {
newItem[field.key] = ''
}
}
return newItem
}
function addRepeaterItem() {
if (reachedRepeaterMaximum.value) return
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.push(createRepeaterItem())
emit('update:modelValue', current)
}
function removeRepeaterItem(index) {
if (reachedRepeaterMinimum.value) return
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
function updateRepeaterItem(index, fieldKey, value) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current[index] = { ...current[index], [fieldKey]: value }
emit('update:modelValue', current)
}
function moveRepeaterItem(index, delta) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const target = index + delta
if (target < 0 || target >= current.length) return
const tmp = current[index]
current[index] = current[target]
current[target] = tmp
emit('update:modelValue', current)
}
function getRepeaterPreviewConfig() {
return props.schema.listPreview || {}
}
function getRepeaterPreviewIcon(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.iconKey || 'icon'
return String(item?.[key] || '').trim()
}
function getRepeaterPreviewCustomIcon(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.customIconKey || 'custom_icon'
return String(item?.[key] || '').trim()
}
function getRepeaterPreviewIconClass(item) {
const cfg = getRepeaterPreviewConfig()
const colorKey = cfg.iconClassKey || 'icon_class'
return String(item?.[colorKey] || '').trim()
}
function getRepeaterPreviewTitle(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.titleKey || 'title'
const title = String(item?.[key] || '').trim()
return title || '未命名卡片'
}
function getRepeaterPreviewDesc(item) {
const cfg = getRepeaterPreviewConfig()
const key = cfg.descKey || 'desc'
const desc = String(item?.[key] || '').trim()
if (desc) return desc
const urlKey = cfg.urlKey || 'url'
const url = String(item?.[urlKey] || '').trim()
return url || '暂无描述'
}
function openRepeaterModal(index) {
if (index < 0 && reachedRepeaterMaximum.value) return
if (props.schema.disabled) return
if (index >= 0) {
const item = (Array.isArray(props.modelValue) ? props.modelValue : [])[index] || {}
repeaterDraft.value = { ...createRepeaterItem(), ...item }
repeaterEditingIndex.value = index
} else {
repeaterDraft.value = createRepeaterItem()
repeaterEditingIndex.value = -1
}
repeaterModalShow.value = true
}
function closeRepeaterModal() {
repeaterModalShow.value = false
repeaterEditingIndex.value = -1
repeaterDraft.value = {}
}
function updateRepeaterDraft(fieldKey, value) {
repeaterDraft.value = { ...repeaterDraft.value, [fieldKey]: value }
}
function confirmRepeaterModal() {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const item = { ...repeaterDraft.value }
if (!item._id) {
item._id = `rpt_${Date.now()}_${++repeaterIdCounter}`
}
if (repeaterEditingIndex.value >= 0 && repeaterEditingIndex.value < current.length) {
current[repeaterEditingIndex.value] = item
} else {
current.push(item)
}
emit('update:modelValue', current)
closeRepeaterModal()
}
</script>
<style scoped>
.aksrt-field {
margin-bottom: 18px;
}
.aksrt-field:last-child {
margin-bottom: 0;
}
.aksrt-field__input-wrap {
display: grid;
gap: 8px;
}
.aksrt-field__input-wrap .n-button {
justify-self: start;
}
.aksrt-field__secret-clear-hint {
color: var(--aksrt-color-warning, #d97706);
font-size: 12px;
}
.aksrt-field__label-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.aksrt-field__label {
font-size: 13px;
font-weight: 500;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__required {
color: #d03050;
margin-left: 2px;
}
.aksrt-field__help-icon {
color: var(--aksrt-color-border-strong);
cursor: help;
transition: color 0.15s;
}
.aksrt-field__help-icon:hover {
color: var(--aksrt-color-primary);
}
.aksrt-field__desc {
margin: -2px 0 8px;
font-size: 12px;
color: var(--aksrt-color-muted);
line-height: 1.5;
}
.aksrt-field__error {
margin: 4px 0 0;
font-size: 12px;
color: #d03050;
}
.aksrt-field--disabled {
opacity: 0.6;
pointer-events: none;
}
/* Dimensions field */
.aksrt-field__dimensions {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-field__dimensions-sep {
color: var(--aksrt-color-border-strong);
font-size: 14px;
}
/* Between number (min/max) */
.aksrt-field__between {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
}
.aksrt-field__number {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
}
.aksrt-field__unit {
color: var(--aksrt-color-muted);
font-size: 13px;
white-space: nowrap;
}
.aksrt-field__between-sep {
color: var(--aksrt-color-border-strong);
font-size: 14px;
}
.aksrt-field__between-unit {
color: var(--aksrt-color-muted);
font-size: 13px;
white-space: nowrap;
}
/* Spacing field */
.aksrt-field__spacing {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.aksrt-field__spacing-label {
font-size: 12px;
color: var(--aksrt-color-muted);
text-align: center;
}
/* Background field */
.aksrt-field__background {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__background-row {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-field__sublabel {
font-size: 12px;
color: var(--aksrt-color-muted);
min-width: 70px;
}
/* Image select */
.aksrt-field__image-select {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.aksrt-field__image-select-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px;
border: 2px solid var(--aksrt-color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.aksrt-field__image-select-item:hover {
border-color: var(--aksrt-color-primary);
}
.aksrt-field__image-select-item--active {
border-color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary-soft);
}
.aksrt-field__image-select-img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.aksrt-field__image-select-placeholder {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: var(--aksrt-color-canvas);
border-radius: 4px;
color: var(--aksrt-color-border-strong);
}
.aksrt-field__image-select-label {
font-size: 11px;
color: var(--aksrt-color-text-secondary);
text-align: center;
}
.aksrt-field__palette {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aksrt-field__palette-item {
width: 34px;
height: 26px;
padding: 0;
border: 3px solid #fff;
border-radius: 3px;
box-shadow: 0 0 0 1px var(--aksrt-color-border-strong);
cursor: pointer;
}
.aksrt-field__palette-item.is-active {
box-shadow: 0 0 0 2px var(--aksrt-color-primary);
}
.aksrt-field__link {
display: grid;
grid-template-columns: minmax(220px, 1fr) 120px minmax(140px, .5fr);
gap: 8px;
}
.aksrt-field__sortable {
display: grid;
gap: 8px;
}
.aksrt-field__selected-order {
display: grid;
gap: 5px;
margin-top: 8px;
}
.aksrt-field__selected-order-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 9px;
border: 1px solid var(--aksrt-color-border);
border-radius: 3px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__sortable-item {
border: 1px solid var(--aksrt-color-border);
background: var(--aksrt-color-surface);
}
.aksrt-field__sortable-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__sortable-body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 12px;
}
/* Repeater */
.aksrt-field__fieldset {
padding: 14px;
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__repeater {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__repeater-item {
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
padding: 12px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__repeater-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.aksrt-field__repeater-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.aksrt-field__repeater-handle {
cursor: grab;
color: var(--aksrt-color-text-tertiary);
}
.aksrt-field__repeater-ghost {
opacity: 0.45;
border-color: var(--aksrt-color-primary);
}
.aksrt-field__repeater-index {
font-size: 12px;
font-weight: 600;
color: var(--aksrt-color-primary);
}
.aksrt-field__repeater-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Content field */
.aksrt-field__content {
font-size: 13px;
color: var(--aksrt-color-text-secondary);
line-height: 1.6;
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-field__dimensions {
flex-wrap: wrap;
}
.aksrt-field__spacing {
grid-template-columns: auto 1fr;
}
.aksrt-field__background-row {
flex-direction: column;
align-items: flex-start;
}
.aksrt-field__link,
.aksrt-field__sortable-body {
grid-template-columns: 1fr;
}
}
/* Repeater list-modal */
.aksrt-field__repeater--list-modal {
display: flex;
flex-direction: column;
gap: 10px;
}
.aksrt-field__repeater-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.aksrt-field__repeater-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__repeater-list-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
flex-shrink: 0;
}
.aksrt-field__repeater-list-preview {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.aksrt-field__repeater-list-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aksrt-color-primary-soft);
color: var(--aksrt-color-primary);
flex-shrink: 0;
overflow: hidden;
font-size: 16px;
}
.aksrt-field__repeater-list-icon-html {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.aksrt-field__repeater-list-icon-html :deep(svg),
.aksrt-field__repeater-list-icon-html :deep(img) {
width: 20px;
height: 20px;
object-fit: contain;
}
.aksrt-field__repeater-list-meta {
min-width: 0;
flex: 1;
}
.aksrt-field__repeater-list-title {
font-size: 13px;
font-weight: 600;
color: var(--aksrt-color-text);
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-field__repeater-list-desc {
margin-top: 2px;
font-size: 12px;
color: var(--aksrt-color-muted);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-field__repeater-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 22px 12px;
color: var(--aksrt-color-border-strong);
font-size: 13px;
border: 1px dashed var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.aksrt-field__repeater-modal-fields {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(70vh, 560px);
overflow: auto;
padding-right: 2px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--aksrt-color-border);
}
</style>