fix(admin): restore Vite admin source files

This commit is contained in:
2026-08-17 20:02:36 +08:00
parent 8fe3a89c36
commit ac5c0b2dd7
114 changed files with 17272 additions and 0 deletions
@@ -0,0 +1,128 @@
<template>
<div class="aksrt-array-table">
<n-data-table
:columns="tableColumns"
:data="modelValue || []"
:bordered="false"
size="small"
max-height="400"
/>
<n-button
size="small"
dashed
block
class="aksrt-array-table__add"
@click="addItem"
>
<FaIcon icon="fas fa-plus" :size="12" />
{{ addText || '添加行' }}
</n-button>
</div>
</template>
<script setup>
/**
* AksArrayTable — table-style editor for array data.
* Renders rows in a data table with inline editing.
*/
import { computed, h } from 'vue'
import { NInput, NSwitch, NButton, NSelect, NInputNumber } from 'naive-ui'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Column definitions: [{ key, label, type?, options?, width? }] */
columns: { type: Array, required: true },
defaults: { type: Object, default: () => ({}) },
addText: { type: String, default: '' },
maxRows: { type: Number, default: Infinity },
})
const emit = defineEmits(['update:modelValue'])
function updateCell(rowIndex, key, value) {
const list = [...(props.modelValue || [])]
list[rowIndex] = { ...list[rowIndex], [key]: value }
emit('update:modelValue', list)
}
function removeItem(index) {
const list = (props.modelValue || []).filter((_, i) => i !== index)
emit('update:modelValue', list)
}
function addItem() {
if ((props.modelValue || []).length >= props.maxRows) return
const item = {}
for (const col of props.columns) {
item[col.key] = col.default ?? (col.type === 'switch' ? false : '')
}
emit('update:modelValue', [...(props.modelValue || []), { ...item, ...props.defaults }])
}
function moveUp(index) {
if (index <= 0) return
const list = [...(props.modelValue || [])]
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
emit('update:modelValue', list)
}
function moveDown(index) {
const list = [...(props.modelValue || [])]
if (index >= list.length - 1) return
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
emit('update:modelValue', list)
}
const tableColumns = computed(() => {
const cols = props.columns.map(col => ({
title: col.label,
key: col.key,
width: col.width || 150,
render(row, rowIndex) {
const val = row[col.key]
const update = (v) => updateCell(rowIndex, col.key, v)
if (col.type === 'switch') {
return h(NSwitch, { value: val, size: 'small', 'onUpdate:value': update })
}
if (col.type === 'select') {
return h(NSelect, { value: val, options: col.options || [], size: 'small', 'onUpdate:value': update })
}
if (col.type === 'number') {
return h(NInputNumber, { value: val, size: 'small', style: 'width:100%', 'onUpdate:value': update })
}
return h(NInput, { value: val, size: 'small', 'onUpdate:value': update })
},
}))
// Add actions column
cols.push({
title: '操作',
key: '_actions',
width: 100,
render(row, rowIndex) {
return h('div', { style: 'display:flex;gap:2px' }, [
rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-arrow-up', size: 12 }),
}) : null,
rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-arrow-down', size: 12 }),
}) : null,
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-trash', size: 12 }),
}),
])
},
})
return cols
})
</script>
<style scoped>
.aksrt-array-table__add {
margin-top: 10px;
}
</style>
@@ -0,0 +1,105 @@
<template>
<div ref="editorRef" class="aks-code-editor" :class="{ 'is-disabled': disabled }" />
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
const props = defineProps({
modelValue: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: { type: Boolean, default: false },
rows: { type: Number, default: 8 },
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref(null)
const view = shallowRef(null)
let updating = false
function resolveLang(lang) {
switch (lang) {
case 'css': return css()
case 'js':
case 'javascript': return javascript()
case 'html':
default: return html()
}
}
onMounted(() => {
if (!editorRef.value) return
const state = EditorState.create({
doc: props.modelValue || '',
extensions: [
basicSetup,
resolveLang(props.language),
oneDark,
EditorView.updateListener.of((update) => {
if (update.docChanged && !updating) {
const val = update.state.doc.toString()
emit('update:modelValue', val)
}
}),
EditorView.editable.of(!props.disabled),
EditorState.readOnly.of(props.disabled),
],
})
view.value = new EditorView({ state, parent: editorRef.value })
})
watch(() => props.modelValue, (newVal) => {
if (!view.value) return
const current = view.value.state.doc.toString()
if (newVal !== current) {
updating = true
view.value.dispatch({
changes: { from: 0, to: current.length, insert: newVal || '' },
})
updating = false
}
})
watch(() => props.disabled, (val) => {
if (!view.value) return
view.value.dispatch({
effects: [
EditorView.editable.reconfigure(EditorView.editable.of(!val)),
EditorState.readOnly.reconfigure(EditorState.readOnly.of(val)),
],
})
})
onBeforeUnmount(() => {
if (view.value) {
view.value.destroy()
view.value = null
}
})
</script>
<style scoped>
.aks-code-editor {
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 4px;
overflow: hidden;
min-height: calc(var(--rows, 8) * 22px);
}
.aks-code-editor :deep(.cm-editor) {
font-size: 13px;
}
.aks-code-editor :deep(.cm-editor.cm-focused) {
outline: none;
}
.aks-code-editor.is-disabled {
opacity: 0.6;
pointer-events: none;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
<template>
<div class="aksrt-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
<div
v-if="title"
class="aksrt-field-group__header"
:class="{ 'is-clickable': collapsible }"
@click="collapsible ? toggleCollapse() : undefined"
>
<div class="aksrt-field-group__header-left">
<FaIcon v-if="icon" :icon="icon" :size="16" class="aksrt-field-group__icon" />
<div>
<h3 class="aksrt-field-group__title">{{ title }}</h3>
<p v-if="description" class="aksrt-field-group__desc">{{ description }}</p>
</div>
</div>
<div class="aksrt-field-group__header-right">
<div v-if="$slots.actions" class="aksrt-field-group__actions" @click.stop>
<slot name="actions" />
</div>
<button
v-if="collapsible"
type="button"
class="aksrt-field-group__collapse-btn"
:aria-expanded="(!collapsed).toString()"
:title="collapsed ? '展开配置' : '收起配置'"
@click.stop="toggleCollapse"
>
<FaIcon
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'"
:size="12"
/>
<span>{{ collapsed ? '展开' : '收起' }}</span>
</button>
</div>
</div>
<transition name="aksrt-collapse">
<div v-show="!collapsed" class="aksrt-field-group__body">
<slot />
</div>
</transition>
</div>
</template>
<script setup>
/**
* AksFieldGroup — card-style container for grouping related form fields.
* Supports collapsible sections, icon headers, and description text.
*
* defaultCollapsed only seeds initial state. Parent should remount the group
* (e.g. via :key when a channel turns on) if it wants to force expand again.
*/
import { ref } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
icon: { type: String, default: '' },
collapsible: { type: Boolean, default: false },
defaultCollapsed: { type: Boolean, default: false },
})
const collapsed = ref(!!props.defaultCollapsed)
function toggleCollapse() {
if (!props.collapsible) return
collapsed.value = !collapsed.value
}
</script>
<style scoped>
.aksrt-field-group {
margin-bottom: 20px;
border: 1px solid #eef1f6;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.aksrt-field-group__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 18px 20px 0;
gap: 12px;
}
.aksrt-field-group__header.is-clickable {
cursor: pointer;
user-select: none;
}
.aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title {
color: #1890ff;
}
.aksrt-field-group.is-collapsed .aksrt-field-group__header {
padding-bottom: 18px;
}
.aksrt-field-group__header-left {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.aksrt-field-group__header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.aksrt-field-group__icon {
color: #1890ff;
margin-top: 2px;
}
.aksrt-field-group__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
transition: color 0.15s;
}
.aksrt-field-group__desc {
margin: 4px 0 0;
font-size: 12px;
color: #8a909c;
line-height: 1.5;
}
.aksrt-field-group__actions {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-field-group__collapse-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e8ecf2;
background: #f7f9fc;
color: #5c6370;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.aksrt-field-group__collapse-btn:hover {
color: #1890ff;
border-color: #91caff;
background: #f0f7ff;
}
.aksrt-field-group__body {
padding: 16px 20px 20px;
}
/* Collapse transition */
.aksrt-collapse-enter-active,
.aksrt-collapse-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.aksrt-collapse-enter-from,
.aksrt-collapse-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
</style>
@@ -0,0 +1,408 @@
<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="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="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="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="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') {
const { key, value, values, not } = dependsOn
const currentVal = props.formData[key]
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: #1890ff;
margin-top: 4px;
}
.aksrt-form-renderer__title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #1a1a2e;
letter-spacing: -0.02em;
}
.aksrt-form-renderer__desc {
margin: 4px 0 0;
font-size: 13px;
color: #7a7f8a;
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: #8a909c;
font-size: 14px;
}
.aksrt-form-renderer__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: #c0c4cc;
}
.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: #fff;
border-top: 1px solid #eef1f6;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
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>
@@ -0,0 +1,167 @@
<template>
<div class="aksrt-media-field">
<div v-if="modelValue" class="aksrt-media-field__preview">
<img
v-if="isImage"
:src="modelValue"
:alt="placeholder"
class="aksrt-media-field__image"
/>
<div v-else class="aksrt-media-field__file">
<FaIcon icon="fas fa-file" :size="24" />
<span class="aksrt-media-field__filename">{{ fileName }}</span>
</div>
<div class="aksrt-media-field__overlay">
<n-button size="tiny" quaternary @click="openMediaLibrary">
<FaIcon icon="fas fa-exchange-alt" /> 更换
</n-button>
<n-button size="tiny" quaternary type="error" @click="clearMedia">
<FaIcon icon="fas fa-trash" /> 移除
</n-button>
</div>
</div>
<div v-else class="aksrt-media-field__empty" @click="openMediaLibrary">
<FaIcon icon="fas fa-cloud-upload-alt" :size="28" />
<span>{{ placeholder }}</span>
</div>
<n-input
:value="modelValue"
:placeholder="'或直接输入 URL'"
size="small"
class="aksrt-media-field__url"
@update:value="emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup>
/**
* AksMediaField — image/file selector with preview.
*
* In the WordPress admin context, this can open the WP Media Library.
* For now, it supports direct URL input with preview.
*/
import { computed } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
accept: { type: String, default: 'image/*' },
placeholder: { type: String, default: '选择图片' },
})
const emit = defineEmits(['update:modelValue'])
const isImage = computed(() => {
if (!props.modelValue) return false
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i.test(props.modelValue)
|| props.modelValue.startsWith('data:image/')
})
const fileName = computed(() => {
if (!props.modelValue) return ''
try {
const url = new URL(props.modelValue, window.location.origin)
const parts = url.pathname.split('/')
return parts[parts.length - 1] || props.modelValue
} catch {
const parts = props.modelValue.split('/')
return parts[parts.length - 1] || props.modelValue
}
})
function openMediaLibrary() {
// In WordPress admin, use wp.media to open the media library
if (typeof wp !== 'undefined' && wp.media) {
const frame = wp.media({
title: props.placeholder,
multiple: false,
library: { type: props.accept === 'image/*' ? 'image' : undefined },
})
frame.on('select', () => {
const attachment = frame.state().get('selection').first().toJSON()
emit('update:modelValue', attachment.url)
})
frame.open()
}
// Outside WP, the URL input serves as the entry point
}
function clearMedia() {
emit('update:modelValue', '')
}
</script>
<style scoped>
.aksrt-media-field__preview {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
margin-bottom: 8px;
}
.aksrt-media-field__image {
display: block;
width: 100%;
max-height: 200px;
object-fit: cover;
}
.aksrt-media-field__file {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
background: #f8fafc;
}
.aksrt-media-field__filename {
font-size: 13px;
color: #555;
word-break: break-all;
}
.aksrt-media-field__overlay {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 4px;
padding: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.aksrt-media-field__preview:hover .aksrt-media-field__overlay {
opacity: 1;
}
.aksrt-media-field__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
border: 2px dashed #d9dce3;
border-radius: 8px;
cursor: pointer;
color: #8a909c;
font-size: 13px;
transition: all 0.2s;
margin-bottom: 8px;
}
.aksrt-media-field__empty:hover {
border-color: #1890ff;
color: #1890ff;
background: #f0f7ff;
}
.aksrt-media-field__url {
margin-top: 4px;
}
</style>
@@ -0,0 +1,185 @@
<template>
<div class="aksrt-repeater">
<div v-if="modelValue && modelValue.length" class="aksrt-repeater__list">
<div
v-for="(item, index) in modelValue"
:key="index"
class="aksrt-repeater__item"
>
<div class="aksrt-repeater__item-header">
<span class="aksrt-repeater__item-index">#{{ index + 1 }}</span>
<div class="aksrt-repeater__item-actions">
<n-button
v-if="index > 0"
size="tiny"
quaternary
@click="moveUp(index)"
>
<FaIcon icon="fas fa-arrow-up" :size="12" />
</n-button>
<n-button
v-if="index < modelValue.length - 1"
size="tiny"
quaternary
@click="moveDown(index)"
>
<FaIcon icon="fas fa-arrow-down" :size="12" />
</n-button>
<n-button
size="tiny"
quaternary
type="error"
@click="removeItem(index)"
>
<FaIcon icon="fas fa-trash" :size="12" />
</n-button>
</div>
</div>
<div class="aksrt-repeater__item-fields">
<AksField
v-for="field in fields"
:key="field.key"
:schema="field"
:model-value="item[field.key]"
@update:model-value="updateField(index, field.key, $event)"
/>
</div>
</div>
</div>
<div v-else class="aksrt-repeater__empty">
<FaIcon icon="fas fa-inbox" :size="20" />
<span>{{ emptyText || '暂无数据' }}</span>
</div>
<n-button
size="small"
dashed
block
class="aksrt-repeater__add"
@click="addItem"
>
<FaIcon icon="fas fa-plus" :size="12" />
{{ addText || '添加项目' }}
</n-button>
</div>
</template>
<script setup>
/**
* AksRepeaterField — dynamic add/remove rows of structured data.
* Each row renders fields based on the provided field schemas.
*/
import AksField from './AksField.vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Array of field schemas for each row */
fields: { type: Array, required: true },
/** Default value for a new item */
defaults: { type: Object, default: () => ({}) },
emptyText: { type: String, default: '' },
addText: { type: String, default: '' },
maxItems: { type: Number, default: Infinity },
})
const emit = defineEmits(['update:modelValue'])
function createItem() {
const item = {}
for (const field of props.fields) {
item[field.key] = field.default ?? (field.type === 'switch' ? false : '')
}
return { ...item, ...props.defaults }
}
function addItem() {
if (props.modelValue.length >= props.maxItems) return
const list = [...props.modelValue, createItem()]
emit('update:modelValue', list)
}
function removeItem(index) {
const list = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', list)
}
function moveUp(index) {
if (index <= 0) return
const list = [...props.modelValue]
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
emit('update:modelValue', list)
}
function moveDown(index) {
if (index >= props.modelValue.length - 1) return
const list = [...props.modelValue]
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
emit('update:modelValue', list)
}
function updateField(index, key, value) {
const list = [...props.modelValue]
list[index] = { ...list[index], [key]: value }
emit('update:modelValue', list)
}
</script>
<style scoped>
.aksrt-repeater__list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.aksrt-repeater__item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 12px;
background: #fafbfc;
}
.aksrt-repeater__item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.aksrt-repeater__item-index {
font-size: 12px;
font-weight: 600;
color: #1890ff;
background: #e6f4ff;
padding: 2px 8px;
border-radius: 4px;
}
.aksrt-repeater__item-actions {
display: flex;
gap: 2px;
}
.aksrt-repeater__item-fields {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-repeater__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: #c0c4cc;
font-size: 13px;
}
.aksrt-repeater__add {
margin-top: 4px;
}
</style>
@@ -0,0 +1,67 @@
<template>
<i
:class="iconClass"
:style="iconStyle"
aria-hidden="true"
></i>
</template>
<script setup>
/**
* FaIcon — Font Awesome 5 icon wrapper component.
*
* Usage:
* <FaIcon icon="fas fa-cog" />
* <FaIcon icon="fa-cog" /> → defaults to "fas" prefix
* <FaIcon icon="fab fa-github" />
* <FaIcon :size="18" icon="far fa-heart" />
* <FaIcon spin icon="fas fa-spinner" />
*/
import { computed } from 'vue'
const props = defineProps({
/** FA5 class string, e.g. "fas fa-cog", "fab fa-github", or just "fa-cog" */
icon: { type: String, required: true },
/** Pixel size — maps to font-size */
size: { type: [Number, String], default: null },
/** Fixed width icon */
fw: { type: Boolean, default: false },
/** Spin animation */
spin: { type: Boolean, default: false },
/** Pulse animation */
pulse: { type: Boolean, default: false },
/** Rotation (90, 180, 270) */
rotate: { type: [Number, String], default: null },
/** Flip direction ('horizontal', 'vertical', 'both') */
flip: { type: String, default: null },
/** Additional CSS classes */
extraClass: { type: String, default: '' },
})
const iconClass = computed(() => {
const parts = []
// Auto-prefix: if only "fa-xxx" is given, default to "fas"
const raw = props.icon.trim()
if (raw.startsWith('fa-')) {
parts.push('fas', raw)
} else {
parts.push(...raw.split(/\s+/))
}
if (props.fw) parts.push('fa-fw')
if (props.spin) parts.push('fa-spin')
if (props.pulse) parts.push('fa-pulse')
if (props.rotate) parts.push(`fa-rotate-${props.rotate}`)
if (props.flip) parts.push(`fa-flip-${props.flip}`)
if (props.extraClass) parts.push(props.extraClass)
return parts.join(' ')
})
const iconStyle = computed(() => {
if (!props.size) return {}
const s = typeof props.size === 'number' ? `${props.size}px` : props.size
return { fontSize: s }
})
</script>
@@ -0,0 +1,221 @@
<template>
<div class="aksrt-fa-icon-picker">
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }">
<template #trigger>
<n-input
:value="modelValue"
:placeholder="placeholder"
readonly
clearable
@clear="$emit('update:modelValue', '')"
>
<template #prefix>
<FaIcon v-if="modelValue" :icon="modelValue" />
</template>
<template #suffix>
<FaIcon icon="fas fa-chevron-down" :size="12" />
</template>
</n-input>
</template>
<div class="aksrt-icon-picker__panel">
<div class="aksrt-icon-picker__search">
<n-input
v-model:value="search"
placeholder="搜索图标..."
size="small"
clearable
>
<template #prefix>
<FaIcon icon="fas fa-search" :size="12" />
</template>
</n-input>
</div>
<div class="aksrt-icon-picker__tabs">
<n-radio-group v-model:value="activePrefix" size="small">
<n-radio-button value="fas">Solid</n-radio-button>
<n-radio-button value="far">Regular</n-radio-button>
<n-radio-button value="fab">Brands</n-radio-button>
</n-radio-group>
</div>
<div class="aksrt-icon-picker__grid" ref="gridRef">
<div
v-for="icon in filteredIcons"
:key="icon"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `${activePrefix} ${icon}` }"
:title="icon"
@click="selectIcon(icon)"
>
<i :class="[activePrefix, icon]"></i>
</div>
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
未找到匹配图标
</div>
</div>
</div>
</n-popover>
</div>
</template>
<script setup>
/**
* FaIconPicker — searchable FA5 icon picker.
* Renders an input that opens a popover grid of FA5 icons.
*/
import { ref, computed } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '选择图标' },
})
const emit = defineEmits(['update:modelValue'])
const search = ref('')
const activePrefix = ref('fas')
const gridRef = ref(null)
// Common FA5 icons subset — a curated list covering most admin UI needs.
// Full FA5 has 1600+ icons; this subset has ~200 of the most useful ones.
const SOLID_ICONS = [
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-tools',
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync', 'fa-redo', 'fa-undo',
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user-shield', 'fa-user-circle',
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-fingerprint',
'fa-globe', 'fa-globe-americas', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
'fa-image', 'fa-images', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
'fa-folder', 'fa-folder-open', 'fa-download', 'upload', 'fa-cloud',
'fa-database', 'fa-server', 'fa-hdd', 'fa-memory', 'fa-microchip',
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard-list',
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard-check',
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-palette',
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
'fa-calendar', 'fa-calendar-alt', 'fa-history', 'fa-hourglass',
'fa-map-marker-alt', 'fa-map', 'fa-directions', 'fa-compass',
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
'fa-coins', 'fa-dollar-sign', 'fa-wallet', 'fa-receipt',
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-megaphone',
'fa-flag', 'fa-trophy', 'fa-award', 'fa-medal', 'fa-gift',
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud-sun',
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-door-open',
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
]
const REGULAR_ICONS = [
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
'fa-address-book', 'fa-address-card', 'fa-id-badge',
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
]
const BRAND_ICONS = [
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-x-twitter',
'fa-instagram', 'fa-linkedin', 'fa-linkedin-in', 'fa-youtube',
'fa-weixin', 'fa-qq', 'fa-weibo', 'fa-telegram', 'fa-telegram-plane',
'fa-discord', 'fa-slack', 'fa-whatsapp', 'fa-skype',
'fa-apple', 'fa-windows', 'fa-linux', 'fa-android', 'fa-chrome',
'fa-firefox', 'fa-edge', 'fa-safari', 'fa-opera',
'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-js-square', 'fa-node',
'fa-react', 'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python',
'fa-java', 'fa-rust', 'fa-golang', 'fa-swift',
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare',
'fa-wordpress', 'fa-joomla', 'fa-drupal', 'fa-shopify',
'fa-paypal', 'fa-stripe', 'fa-alipay', 'fa-weixin',
'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn',
'fa-twitch', 'fa-spotify', 'fa-soundcloud', 'fa-music',
'fa-bilibili',
]
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
const filteredIcons = computed(() => {
const set = iconSets[activePrefix.value] || SOLID_ICONS
const q = search.value.toLowerCase().replace(/\s+/g, '')
if (!q) return set
return set.filter(i => i.includes(q))
})
function selectIcon(icon) {
emit('update:modelValue', `${activePrefix.value} ${icon}`)
}
</script>
<style scoped>
.aksrt-icon-picker__panel {
padding: 12px;
}
.aksrt-icon-picker__search {
margin-bottom: 10px;
}
.aksrt-icon-picker__tabs {
margin-bottom: 10px;
}
.aksrt-icon-picker__grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
max-height: 260px;
overflow-y: auto;
}
.aksrt-icon-picker__item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
color: #555;
transition: all 0.15s;
}
.aksrt-icon-picker__item:hover {
background: #f0f5ff;
color: #1890ff;
}
.aksrt-icon-picker__item.is-active {
background: #1890ff;
color: #fff;
}
.aksrt-icon-picker__empty {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
color: #999;
font-size: 13px;
}
</style>