导入 AKSRT 主题开发源码

This commit is contained in:
Claude Code committed 2026-07-03 22:50:34 +08:00
1 parent 9681dd3d48
commit 1268a55dd0
595 files changed
+127705

No files matched your search

@@ -0,0 +1,824 @@
<template>
<div class="aksrt-field" :class="fieldClasses">
<div 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="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'"
:value="modelValue"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Input (text, email, url, password) -->
<n-input
v-else-if="schema.type === 'input' || schema.type === 'text'"
:value="modelValue"
: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>
<!-- 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 -->
<n-input-number
v-else-if="schema.type === '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)"
/>
<!-- Select -->
<n-select
v-else-if="schema.type === 'select'"
:value="modelValue"
:options="normalizedOptions"
:placeholder="schema.placeholder || '请选择'"
:multiple="schema.multiple"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
/>
<!-- 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)"
/>
<!-- 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)"
/>
<!-- 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'"
:model-value="modelValue"
:accept="schema.accept"
:placeholder="schema.placeholder || '选择图片'"
@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.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 || ''"
/>
<!-- 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>
<!-- 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 || 'fas fa-image'" :size="20" />
</div>
<span class="aksrt-field__image-select-label">{{ opt.label }}</span>
</div>
</div>
<!-- Repeater (array of objects) -->
<div v-else-if="schema.type === 'repeater'" class="aksrt-field__repeater">
<div
v-for="(item, index) in (modelValue || [])"
:key="item._id || index"
class="aksrt-field__repeater-item"
>
<div class="aksrt-field__repeater-header">
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fas fa-trash" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
:schema="field"
:model-value="item[field.key]"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
<n-button
size="small"
dashed
:disabled="schema.disabled"
@click="addRepeaterItem"
>
<FaIcon icon="fas fa-plus" :size="12" style="margin-right: 6px" />
{{ schema.addText || '添加一项' }}
</n-button>
</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, slider, code, alert, 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 AksCodeEditor from './AksCodeEditor.vue'
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 },
/** Error message to display */
errorMessage: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
/** Normalize options: support both string[] and {label, value}[] */
const normalizedOptions = computed(() => {
const opts = props.schema.options || []
return opts.map(opt => {
if (typeof opt === 'string') return { label: opt, value: opt }
if (typeof opt === 'object' && opt !== null) {
return { label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
}
return { label: String(opt), value: opt }
})
})
// ============ 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()
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()
}
})
// ============ 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)
}
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 })
}
// ============ 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
function addRepeaterItem() {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
// Initialize with defaults from schema.fields
for (const field of (props.schema.fields || [])) {
if (field.default !== undefined) {
newItem[field.key] = field.default
}
}
current.push(newItem)
emit('update:modelValue', current)
}
function removeRepeaterItem(index) {
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)
}
</script>
<style scoped>
.aksrt-field {
margin-bottom: 18px;
}
.aksrt-field:last-child {
margin-bottom: 0;
}
.aksrt-field__label-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.aksrt-field__label {
font-size: 13px;
font-weight: 500;
color: #555;
}
.aksrt-field__required {
color: #d03050;
margin-left: 2px;
}
.aksrt-field__help-icon {
color: #c0c4cc;
cursor: help;
transition: color 0.15s;
}
.aksrt-field__help-icon:hover {
color: #1890ff;
}
.aksrt-field__desc {
margin: -2px 0 8px;
font-size: 12px;
color: #8a909c;
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: #c0c4cc;
font-size: 14px;
}
/* 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: #8a909c;
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: #8a909c;
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 #eef1f6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.aksrt-field__image-select-item:hover {
border-color: #1890ff;
}
.aksrt-field__image-select-item--active {
border-color: #1890ff;
background: #e6f7ff;
}
.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: #f5f7fb;
border-radius: 4px;
color: #c0c4cc;
}
.aksrt-field__image-select-label {
font-size: 11px;
color: #555;
text-align: center;
}
/* Repeater */
.aksrt-field__repeater {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-field__repeater-item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 12px;
background: #fafbfc;
}
.aksrt-field__repeater-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.aksrt-field__repeater-index {
font-size: 12px;
font-weight: 600;
color: #1890ff;
}
.aksrt-field__repeater-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Content field */
.aksrt-field__content {
font-size: 13px;
color: #555;
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;
}
}
</style>