refactor(admin): unify sortable/draggable/repeater list UI
- Add shared sortable-list.css, SORTABLE_LIST_ATTRS and AksSortHandle - Migrate schema sortable, selected order, inline/modal repeater, sorter, floating buttons, footer tabbar and ticket prereply lists to the shared design - Split AksField.vue (1885 -> 969 lines) into focused components/helpers - Remove dead AksArrayTable/AksRepeaterField and move-up/down helpers - Replace hardcoded colors and --n-* vars with --aksrt-color-* tokens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
135fb4eaa4
commit
8843ae4261
20 files changed
+1442
-1796
No files matched your search
@@ -1,23 +1,29 @@
|
||||
<template>
|
||||
<div class="float-btn-panel">
|
||||
<div class="float-btn-panel__head" @click="expanded = !expanded">
|
||||
<button type="button" class="float-btn-panel__handle" aria-label="拖拽排序" @click.stop>
|
||||
<i class="fa fas fa-arrows-alt-v" />
|
||||
</button>
|
||||
<strong class="float-btn-panel__title">{{ meta.title }}</strong>
|
||||
<template v-if="modelValue.type !== 'more'">
|
||||
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
|
||||
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
|
||||
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
|
||||
</template>
|
||||
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
|
||||
<span v-if="meta.warn" class="float-btn-panel__warn">
|
||||
<i class="fa fas fa-info-circle" /> {{ meta.warn }}
|
||||
</span>
|
||||
<i class="fa fas float-btn-panel__arrow" :class="expanded ? 'fa-angle-up' : 'fa-angle-down'" />
|
||||
<div class="aksrt-sortable__head is-clickable" @click="expanded = !expanded">
|
||||
<AksSortHandle @click.stop />
|
||||
<div class="aksrt-sortable__main">
|
||||
<div class="aksrt-sortable__title-row">
|
||||
<span class="aksrt-sortable__title">{{ meta.title }}</span>
|
||||
<template v-if="modelValue.type !== 'more'">
|
||||
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
|
||||
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
|
||||
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
|
||||
</template>
|
||||
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
|
||||
</div>
|
||||
<span v-if="meta.warn" class="aksrt-sortable__desc float-btn-panel__warn">
|
||||
<i class="fa fas fa-info-circle" aria-hidden="true" /> {{ meta.warn }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
class="fa fas aksrt-sortable__toggle"
|
||||
:class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="expanded" class="float-btn-panel__body">
|
||||
<div v-show="expanded" class="aksrt-sortable__body">
|
||||
<div class="float-btn-panel__subtitle">{{ meta.panel }}</div>
|
||||
|
||||
<FloatMoreButtonsEditor
|
||||
@@ -56,9 +62,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** 悬浮按钮排序列表中的单个卡片;外层 .aksrt-sortable__item 由 FloatingButtonsSettings 提供。 */
|
||||
import { computed, ref } from 'vue'
|
||||
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||
import AksMediaField from '@/components/form/AksMediaField.vue'
|
||||
import AksSortHandle from '@/components/form/AksSortHandle.vue'
|
||||
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
|
||||
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
|
||||
import { FLOAT_BTN_META } from './floating-defaults.js'
|
||||
@@ -82,76 +90,18 @@ function patch(partial) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.float-btn-panel {
|
||||
border: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
|
||||
border-radius: 8px;
|
||||
background: var(--n-card-color, #fff);
|
||||
}
|
||||
|
||||
.float-btn-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.float-btn-panel__handle {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--n-text-color-3, #999);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.float-btn-panel__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.float-btn-panel__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.float-btn-panel__arrow {
|
||||
margin-left: auto;
|
||||
color: var(--n-text-color-3, #999);
|
||||
}
|
||||
|
||||
.float-btn-panel__warn {
|
||||
font-size: 12px;
|
||||
color: #f0a020;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.float-btn-panel__body {
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
|
||||
color: var(--aksrt-color-warning, #D97706);
|
||||
}
|
||||
|
||||
.float-btn-panel__subtitle {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--aksrt-color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.float-btn-panel__extra {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.float-btn-panel__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.float-btn-panel__desc {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +0,0 @@
|
||||
<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="fa 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: 'fa 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: 'fa fas fa-arrow-down', size: 12 }),
|
||||
}) : null,
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
|
||||
default: () => h(FaIcon, { icon: 'fa fas fa-trash-alt', size: 12 }),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-array-table__add {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<!-- Dimensions (width x height) -->
|
||||
<div v-if="schema.type === 'dimensions'" class="aksrt-composite aksrt-composite--dimensions">
|
||||
<n-input-number
|
||||
:value="current.width"
|
||||
:min="0"
|
||||
:placeholder="schema.widthPlaceholder || '宽度'"
|
||||
:disabled="schema.disabled"
|
||||
style="flex: 1"
|
||||
@update:value="update('width', $event)"
|
||||
/>
|
||||
<span class="aksrt-composite__sep">×</span>
|
||||
<n-input-number
|
||||
:value="current.height"
|
||||
:min="0"
|
||||
:placeholder="schema.heightPlaceholder || '高度'"
|
||||
:disabled="schema.disabled"
|
||||
style="flex: 1"
|
||||
@update:value="update('height', $event)"
|
||||
/>
|
||||
<n-select
|
||||
v-if="schema.unit"
|
||||
:value="current.unit"
|
||||
:options="dimensionUnitOptions"
|
||||
:disabled="schema.disabled"
|
||||
style="width: 80px"
|
||||
@update:value="update('unit', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Between number (min/max pair) -->
|
||||
<div v-else-if="schema.type === 'between_number'" class="aksrt-composite aksrt-composite--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="update('min', $event)"
|
||||
/>
|
||||
<span class="aksrt-composite__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="update('max', $event)"
|
||||
/>
|
||||
<span v-if="schema.unit" class="aksrt-composite__unit">{{ schema.unit }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacing (top right bottom left) -->
|
||||
<div v-else-if="schema.type === 'spacing'" class="aksrt-composite--spacing">
|
||||
<template v-for="(side, i) in spacingSides" :key="side">
|
||||
<div class="aksrt-composite__label">{{ schema.spacingLabels?.[i] || spacingDefaults[i] }}</div>
|
||||
<n-input-number
|
||||
:value="current[side]"
|
||||
:min="0"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="update(side, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Background (color + image + size + position + repeat) -->
|
||||
<div v-else-if="schema.type === 'background'" class="aksrt-composite--background">
|
||||
<div class="aksrt-composite__row">
|
||||
<label class="aksrt-composite__label">背景颜色</label>
|
||||
<n-color-picker
|
||||
:value="current.color"
|
||||
:show-alpha="true"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="update('color', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-composite__row">
|
||||
<label class="aksrt-composite__label">背景图片</label>
|
||||
<AksMediaField
|
||||
:model-value="current.image"
|
||||
:accept="'image/*'"
|
||||
:disabled="schema.disabled"
|
||||
@update:model-value="update('image', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="row in backgroundSelects" :key="row.key" class="aksrt-composite__row">
|
||||
<label class="aksrt-composite__label">{{ row.label }}</label>
|
||||
<n-select
|
||||
:value="current[row.key]"
|
||||
:options="row.options"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="update(row.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** 对象型复合字段:dimensions / between_number / spacing / background。 */
|
||||
import { computed } from 'vue'
|
||||
import AksMediaField from './AksMediaField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
schema: { type: Object, required: true },
|
||||
modelValue: { default: null },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const current = computed(() => (
|
||||
props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
: {}
|
||||
))
|
||||
|
||||
function update(part, value) {
|
||||
emit('update:modelValue', { ...current.value, [part]: value })
|
||||
}
|
||||
|
||||
function betweenValue(part) {
|
||||
return current.value[part] !== undefined ? current.value[part] : (props.schema.default?.[part] ?? 0)
|
||||
}
|
||||
|
||||
const dimensionUnitOptions = ['px', '%', 'em', 'rem', 'vw'].map(unit => ({ label: unit, value: unit }))
|
||||
const spacingSides = ['top', 'right', 'bottom', 'left']
|
||||
const spacingDefaults = ['上', '右', '下', '左']
|
||||
|
||||
const backgroundSelects = [
|
||||
{
|
||||
key: 'size',
|
||||
label: '图片尺寸',
|
||||
options: [
|
||||
{ label: '覆盖', value: 'cover' },
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '原始大小', value: 'auto' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
label: '图片位置',
|
||||
options: [
|
||||
{ label: '居中', value: 'center center' },
|
||||
{ label: '顶部居中', value: 'top center' },
|
||||
{ label: '底部居中', value: 'bottom center' },
|
||||
{ label: '左侧居中', value: 'center left' },
|
||||
{ label: '右侧居中', value: 'center right' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'repeat',
|
||||
label: '图片重复',
|
||||
options: [
|
||||
{ label: '不重复', value: 'no-repeat' },
|
||||
{ label: '平铺', value: 'repeat' },
|
||||
{ label: '水平平铺', value: 'repeat-x' },
|
||||
{ label: '垂直平铺', value: 'repeat-y' },
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-composite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-composite--between {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.aksrt-composite__sep {
|
||||
color: var(--aksrt-color-border-strong);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aksrt-composite__unit {
|
||||
color: var(--aksrt-color-muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-composite--spacing {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-composite--background {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-composite__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-composite__label {
|
||||
min-width: 70px;
|
||||
color: var(--aksrt-color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-composite--spacing .aksrt-composite__label {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-composite--dimensions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-composite--spacing {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.aksrt-composite__row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="aksrt-image-select" role="radiogroup">
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="aksrt-image-select__item"
|
||||
:class="{ 'is-active': modelValue === opt.value }"
|
||||
:aria-checked="modelValue === opt.value"
|
||||
:disabled="disabled"
|
||||
@click="emit('update:modelValue', opt.value)"
|
||||
>
|
||||
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="aksrt-image-select__img" />
|
||||
<span v-else class="aksrt-image-select__placeholder">
|
||||
<FaIcon :icon="opt.icon || 'fa fas fa-image'" :size="20" />
|
||||
</span>
|
||||
<span class="aksrt-image-select__label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** image_select:带预览图的单选。 */
|
||||
import FaIcon from './FaIcon.vue'
|
||||
|
||||
defineProps({
|
||||
modelValue: { default: null },
|
||||
options: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-image-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-image-select__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 80px;
|
||||
padding: 8px;
|
||||
border: 2px solid var(--aksrt-color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aksrt-color-surface);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s ease, background-color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-image-select__item:hover,
|
||||
.aksrt-image-select__item:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--aksrt-color-primary);
|
||||
}
|
||||
|
||||
.aksrt-image-select__item.is-active {
|
||||
border-color: var(--aksrt-color-primary);
|
||||
background: var(--aksrt-color-primary-soft);
|
||||
}
|
||||
|
||||
.aksrt-image-select__item:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.aksrt-image-select__img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.aksrt-image-select__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
background: var(--aksrt-color-canvas);
|
||||
color: var(--aksrt-color-border-strong);
|
||||
}
|
||||
|
||||
.aksrt-image-select__label {
|
||||
color: var(--aksrt-color-text-secondary);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aksrt-image-select__item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,185 +0,0 @@
|
||||
<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="fa fas fa-arrow-up" :size="12" />
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="index < modelValue.length - 1"
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="moveDown(index)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-arrow-down" :size="12" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-trash-alt" :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="fa fas fa-inbox" :size="20" />
|
||||
<span>{{ emptyText || '暂无数据' }}</span>
|
||||
</div>
|
||||
|
||||
<n-button
|
||||
size="small"
|
||||
dashed
|
||||
block
|
||||
class="aksrt-repeater__add"
|
||||
@click="addItem"
|
||||
>
|
||||
<FaIcon icon="fa 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 var(--aksrt-color-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--aksrt-color-canvas);
|
||||
}
|
||||
|
||||
.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: var(--aksrt-color-primary);
|
||||
background: var(--aksrt-color-primary-soft);
|
||||
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: var(--aksrt-color-border-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__add {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="aksrt-repeater-list">
|
||||
<draggable
|
||||
v-if="rows.length"
|
||||
v-model="rows"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
:item-key="rowKey"
|
||||
:disabled="schema.disabled"
|
||||
class="aksrt-sortable"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head">
|
||||
<AksSortHandle />
|
||||
<span class="aksrt-sortable__icon" :class="preview(element).iconClass">
|
||||
<span v-if="preview(element).customIcon" class="aksrt-repeater-list__icon-html" v-html="preview(element).customIcon" />
|
||||
<i v-else-if="preview(element).icon" :class="preview(element).icon" aria-hidden="true" />
|
||||
<FaIcon v-else icon="fa fas fa-star" :size="16" />
|
||||
</span>
|
||||
<div class="aksrt-sortable__main">
|
||||
<span class="aksrt-sortable__title">{{ preview(element).title }}</span>
|
||||
<span class="aksrt-sortable__desc">{{ preview(element).desc }}</span>
|
||||
</div>
|
||||
<div class="aksrt-sortable__actions">
|
||||
<n-button size="tiny" quaternary type="primary" :disabled="schema.disabled" @click="openModal(index)">
|
||||
<template #icon><FaIcon icon="fa fas fa-edit" :size="12" /></template>
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" :disabled="schema.disabled || reachedMinimum" @click="remove(index)">
|
||||
<template #icon><FaIcon icon="fa fas fa-trash-alt" :size="12" /></template>
|
||||
删除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div v-else class="aksrt-sortable__empty">
|
||||
<FaIcon icon="fa fas fa-inbox" :size="18" />
|
||||
<span>{{ schema.emptyText || '暂无数据' }}</span>
|
||||
</div>
|
||||
|
||||
<n-button size="small" dashed block :disabled="schema.disabled || reachedMaximum" @click="openModal(-1)">
|
||||
<template #icon><FaIcon icon="fa fas fa-plus" :size="12" /></template>
|
||||
{{ schema.button_title || schema.addText || '添加一项' }}
|
||||
</n-button>
|
||||
|
||||
<n-modal
|
||||
v-model:show="modalShow"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editingIndex >= 0 ? (schema.editText || '编辑项目') : (schema.addText || '添加项目')"
|
||||
class="aksrt-modal-card"
|
||||
style="max-width: 640px; width: 92vw;"
|
||||
>
|
||||
<div class="aksrt-repeater-list__modal-fields">
|
||||
<AksField
|
||||
v-for="field in draftFields"
|
||||
:key="field.key"
|
||||
:schema="field"
|
||||
:model-value="draft[field.key]"
|
||||
:form-data="formData"
|
||||
@update:model-value="draft = { ...draft, [field.key]: $event }"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="closeModal">取消</n-button>
|
||||
<n-button type="primary" @click="confirmModal">确定</n-button>
|
||||
</div>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** repeater / group 的 list-modal 形态:可拖拽卡片列表 + 弹窗编辑单行。 */
|
||||
import { computed, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
import AksField from './AksField.vue'
|
||||
import AksSortHandle from './AksSortHandle.vue'
|
||||
import { matchDependency } from './field-dependency.js'
|
||||
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
|
||||
import { createRepeaterRow, createRepeaterRowId, repeaterRowPreview } from './repeater-rows.js'
|
||||
|
||||
const props = defineProps({
|
||||
schema: { type: Object, required: true },
|
||||
modelValue: { default: null },
|
||||
formData: { type: Object, default: () => ({}) },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const rows = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
|
||||
})
|
||||
const reachedMinimum = computed(() => rows.value.length <= Number(props.schema.min || 0))
|
||||
const reachedMaximum = computed(() => Number(props.schema.max || 0) > 0 && rows.value.length >= Number(props.schema.max))
|
||||
|
||||
const keys = new WeakMap()
|
||||
let keySeed = 0
|
||||
function rowKey(row) {
|
||||
if (!row || typeof row !== 'object') return String(row)
|
||||
if (row._id) return row._id
|
||||
if (!keys.has(row)) keys.set(row, `rpt_list_${++keySeed}`)
|
||||
return keys.get(row)
|
||||
}
|
||||
|
||||
function preview(row) {
|
||||
return repeaterRowPreview(row, props.schema.listPreview || {})
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
if (reachedMinimum.value) return
|
||||
const next = [...rows.value]
|
||||
next.splice(index, 1)
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
const modalShow = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const draft = ref({})
|
||||
const draftFields = computed(() => (props.schema.fields || []).filter(field => matchDependency(field.dependsOn, draft.value)))
|
||||
|
||||
function openModal(index) {
|
||||
if (props.schema.disabled) return
|
||||
if (index < 0 && reachedMaximum.value) return
|
||||
const base = createRepeaterRow(props.schema.fields)
|
||||
draft.value = index >= 0 ? { ...base, ...(rows.value[index] || {}) } : base
|
||||
editingIndex.value = index
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalShow.value = false
|
||||
editingIndex.value = -1
|
||||
draft.value = {}
|
||||
}
|
||||
|
||||
function confirmModal() {
|
||||
const next = [...rows.value]
|
||||
const row = { ...draft.value }
|
||||
if (!row._id) row._id = createRepeaterRowId()
|
||||
if (editingIndex.value >= 0 && editingIndex.value < next.length) {
|
||||
next[editingIndex.value] = row
|
||||
} else {
|
||||
next.push(row)
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
closeModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-repeater-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-repeater-list__icon-html {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.aksrt-repeater-list__icon-html :deep(svg),
|
||||
.aksrt-repeater-list__icon-html :deep(img) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.aksrt-repeater-list__modal-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: min(70vh, 560px);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<draggable
|
||||
v-model="values"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
:item-key="valueKey"
|
||||
:disabled="disabled"
|
||||
class="aksrt-sortable aksrt-sortable--compact aksrt-selected-order"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head">
|
||||
<AksSortHandle />
|
||||
<span class="aksrt-sortable__index">{{ index + 1 }}</span>
|
||||
<span class="aksrt-sortable__title">{{ labelOf(element) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** 多选 + sortable 的已选值排序列表(select / async_select 下方)。 */
|
||||
import { computed } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import AksSortHandle from './AksSortHandle.vue'
|
||||
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, required: true },
|
||||
options: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const values = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (next) => emit('update:modelValue', next),
|
||||
})
|
||||
|
||||
function valueKey(value) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function labelOf(value) {
|
||||
return props.options.find(option => option.value === value)?.label || String(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-selected-order {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<button type="button" class="aksrt-sortable__handle" :title="label" :aria-label="label">
|
||||
<i class="fa fas fa-grip-vertical" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** 拖拽排序手柄;SORTABLE_LIST_ATTRS.handle 指向它的类名。 */
|
||||
defineProps({
|
||||
label: { type: String, default: '拖拽排序' },
|
||||
})
|
||||
</script>
|
||||
@@ -1,62 +1,51 @@
|
||||
<template>
|
||||
<div class="aksrt-sorter">
|
||||
<div class="aksrt-sorter__column">
|
||||
<div
|
||||
v-for="column in COLUMNS"
|
||||
:key="column.key"
|
||||
class="aksrt-sorter__column"
|
||||
:class="`aksrt-sorter__column--${column.key}`"
|
||||
>
|
||||
<div class="aksrt-sorter__title">
|
||||
<span>已启用</span>
|
||||
<n-tag size="small" type="success" :bordered="false">{{ columns.enabled.length }}</n-tag>
|
||||
<span>{{ column.title }}</span>
|
||||
<n-tag size="small" :type="column.tagType" :bordered="false">{{ columns[column.key].length }}</n-tag>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="columns.enabled"
|
||||
v-model="columns[column.key]"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
item-key="value"
|
||||
group="aksrt-sorter"
|
||||
handle=".aksrt-sorter__handle"
|
||||
ghost-class="aksrt-sorter__ghost"
|
||||
animation="180"
|
||||
class="aksrt-sorter__list"
|
||||
class="aksrt-sortable aksrt-sortable--compact aksrt-sorter__list"
|
||||
@change="commit"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="aksrt-sorter__item">
|
||||
<i class="fa fas fa-grip-vertical aksrt-sorter__handle" aria-hidden="true"></i>
|
||||
<i v-if="element.icon" :class="element.icon" aria-hidden="true"></i>
|
||||
<span>{{ element.label }}</span>
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head">
|
||||
<AksSortHandle />
|
||||
<i v-if="element.icon" :class="element.icon" class="aksrt-sorter__icon" aria-hidden="true"></i>
|
||||
<span class="aksrt-sortable__title">{{ element.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<n-empty v-if="columns.enabled.length === 0" size="small" description="暂无启用项" />
|
||||
</div>
|
||||
|
||||
<div class="aksrt-sorter__column aksrt-sorter__column--disabled">
|
||||
<div class="aksrt-sorter__title">
|
||||
<span>未启用</span>
|
||||
<n-tag size="small" :bordered="false">{{ columns.disabled.length }}</n-tag>
|
||||
<div v-if="columns[column.key].length === 0" class="aksrt-sortable__empty aksrt-sorter__empty">
|
||||
{{ column.empty }}
|
||||
</div>
|
||||
<draggable
|
||||
v-model="columns.disabled"
|
||||
item-key="value"
|
||||
group="aksrt-sorter"
|
||||
handle=".aksrt-sorter__handle"
|
||||
ghost-class="aksrt-sorter__ghost"
|
||||
animation="180"
|
||||
class="aksrt-sorter__list"
|
||||
@change="commit"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="aksrt-sorter__item">
|
||||
<i class="fa fas fa-grip-vertical aksrt-sorter__handle" aria-hidden="true"></i>
|
||||
<i v-if="element.icon" :class="element.icon" aria-hidden="true"></i>
|
||||
<span>{{ element.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<n-empty v-if="columns.disabled.length === 0" size="small" description="暂无未启用项" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** 启用 / 未启用两列拖拽排序器;样式见 styles/sortable-list.css。 */
|
||||
import { reactive, watch } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import AksSortHandle from './AksSortHandle.vue'
|
||||
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'enabled', title: '已启用', tagType: 'success', empty: '拖拽到此处启用' },
|
||||
{ key: 'disabled', title: '未启用', tagType: 'default', empty: '拖拽到此处停用' },
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
@@ -100,13 +89,55 @@ watch(() => [props.modelValue, props.options], syncColumns, { immediate: true, d
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-sorter { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
|
||||
.aksrt-sorter__column { min-height:210px; padding:12px; border:1px solid var(--aksrt-color-border); border-radius:6px; background:var(--aksrt-color-surface); }
|
||||
.aksrt-sorter__column--disabled { background:var(--aksrt-color-canvas); }
|
||||
.aksrt-sorter__title { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; font-weight:600; }
|
||||
.aksrt-sorter__list { display:grid; gap:7px; min-height:44px; }
|
||||
.aksrt-sorter__item { display:flex; align-items:center; gap:9px; min-height:38px; padding:7px 10px; border:1px solid var(--aksrt-color-border); border-radius:4px; background:var(--aksrt-color-surface); }
|
||||
.aksrt-sorter__handle { color:var(--aksrt-color-text-tertiary); cursor:grab; }
|
||||
.aksrt-sorter__ghost { opacity:.45; border-color:var(--aksrt-color-primary); }
|
||||
@media (max-width:720px) { .aksrt-sorter { grid-template-columns:1fr; } }
|
||||
.aksrt-sorter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.aksrt-sorter__column {
|
||||
position: relative;
|
||||
min-height: 210px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--aksrt-color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aksrt-color-canvas);
|
||||
}
|
||||
|
||||
.aksrt-sorter__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
color: var(--aksrt-color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 空列也要留出可放置区域,拖拽目标不能塌成 0 高度。 */
|
||||
.aksrt-sorter__list {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.aksrt-sorter__empty {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
top: 46px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aksrt-sorter__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
color: var(--aksrt-color-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.aksrt-sorter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* schema 字段的 dependsOn 规则求值。sourceValue 是规则引用的数据对象
|
||||
* (fieldset 值 / repeater 行 / 弹窗草稿)。
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* repeater / group 行的纯函数:新行默认值、行 ID、listPreview 摘要与预览。
|
||||
* inline 与 list-modal 两种渲染共用。
|
||||
*/
|
||||
let rowIdCounter = 0
|
||||
|
||||
export function createRepeaterRowId() {
|
||||
return `rpt_${Date.now()}_${++rowIdCounter}`
|
||||
}
|
||||
|
||||
export function createRepeaterRow(fields) {
|
||||
const row = { _id: createRepeaterRowId() }
|
||||
for (const field of (Array.isArray(fields) ? fields : [])) {
|
||||
// UI-only chrome rows (notice / alert) are presentation only.
|
||||
if (field.ui_only) continue
|
||||
if (field.default !== undefined) {
|
||||
row[field.key] = field.default
|
||||
} else if (field.type === 'switch') {
|
||||
row[field.key] = false
|
||||
} else if (field.type === 'checkbox' || field.type === 'repeater') {
|
||||
row[field.key] = []
|
||||
} else {
|
||||
row[field.key] = ''
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
|
||||
function rowDuration(row, cfg) {
|
||||
if (!cfg.descKey) return ''
|
||||
const raw = row[cfg.descKey]
|
||||
if (raw === '' || raw == null) return ''
|
||||
const amount = Number(raw)
|
||||
if (!Number.isFinite(amount)) return String(raw)
|
||||
if (amount <= 0) return '永久'
|
||||
if (!cfg.descUnitKey) return String(raw)
|
||||
const unit = String(row[cfg.descUnitKey] || '')
|
||||
if (unit === 'month') return `${amount} 个月`
|
||||
if (unit === 'day') return `${amount} 天`
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
|
||||
export function repeaterRowSummary(row, cfg = {}) {
|
||||
if (!row) return ''
|
||||
const title = String(row[cfg.titleKey || 'title'] ?? '').trim()
|
||||
const prefix = String(cfg.prefix || '')
|
||||
if (title === '') return prefix
|
||||
const duration = rowDuration(row, cfg)
|
||||
return duration ? `${prefix}${title} · ${duration}` : `${prefix}${title}`
|
||||
}
|
||||
|
||||
/** list-modal 卡片预览:图标 / 自定义图标 HTML / 图标类 / 标题 / 描述。 */
|
||||
export function repeaterRowPreview(row, cfg = {}) {
|
||||
const read = (key) => String(row?.[key] || '').trim()
|
||||
const desc = read(cfg.descKey || 'desc') || read(cfg.urlKey || 'url')
|
||||
return {
|
||||
icon: read(cfg.iconKey || 'icon'),
|
||||
customIcon: read(cfg.customIconKey || 'custom_icon'),
|
||||
iconClass: read(cfg.iconClassKey || 'icon_class'),
|
||||
title: read(cfg.titleKey || 'title') || '未命名卡片',
|
||||
desc: desc || '暂无描述',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 所有后台 vuedraggable 列表共用的 Sortable 选项(v-bind 到 <draggable>)。
|
||||
* 配套样式见 src/styles/sortable-list.css,拖拽手柄统一用 AksSortHandle。
|
||||
*/
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
export const SORTABLE_LIST_ATTRS = Object.freeze({
|
||||
handle: '.aksrt-sortable__handle',
|
||||
ghostClass: 'aksrt-sortable__ghost',
|
||||
chosenClass: 'aksrt-sortable__chosen',
|
||||
dragClass: 'aksrt-sortable__drag',
|
||||
animation: prefersReducedMotion ? 0 : 180,
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
/**
|
||||
* AksField 的远程选项加载:async_select / taxonomy_select / user_select / page_select。
|
||||
* 仅按 schema.type 在挂载时拉取对应数据源。
|
||||
*/
|
||||
export function useFieldRemoteOptions(props) {
|
||||
const asyncOptions = ref([])
|
||||
const asyncLoading = ref(false)
|
||||
const taxonomyOptions = ref([])
|
||||
const taxonomyLoading = ref(false)
|
||||
const userOptions = ref([])
|
||||
const userLoading = ref(false)
|
||||
const pageOptions = ref([])
|
||||
const pageLoading = ref(false)
|
||||
let asyncTimer = null
|
||||
let userTimer = null
|
||||
let pageTimer = 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers(query = '') {
|
||||
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) {
|
||||
console.warn('[AksField] user load failed:', e)
|
||||
userOptions.value = []
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserSearch(query) {
|
||||
clearTimeout(userTimer)
|
||||
userTimer = setTimeout(() => loadUsers(query || ''), 300)
|
||||
}
|
||||
|
||||
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(pageTimer)
|
||||
pageTimer = setTimeout(() => loadPages(query), 250)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const type = props.schema.type
|
||||
if (type === 'taxonomy_select') loadTaxonomyTerms()
|
||||
if (type === 'user_select') loadUsers()
|
||||
if (type === 'async_select') loadAsyncOptions()
|
||||
if (type === 'page_select') loadPages()
|
||||
})
|
||||
|
||||
return {
|
||||
asyncOptions,
|
||||
asyncLoading,
|
||||
handleAsyncSearch,
|
||||
taxonomyOptions,
|
||||
taxonomyLoading,
|
||||
userOptions,
|
||||
userLoading,
|
||||
handleUserSearch,
|
||||
pageOptions,
|
||||
pageLoading,
|
||||
handlePageSearch,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import NaiveUI from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import './styles/sortable-list.css'
|
||||
import { styleMountTarget } from './style-target'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -51,11 +51,9 @@
|
||||
<div class="float-settings__desc">设置并排序需要显示的按钮</div>
|
||||
<draggable
|
||||
v-model="form.items"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
item-key="type"
|
||||
handle=".float-btn-panel__handle"
|
||||
ghost-class="float-settings__ghost"
|
||||
animation="180"
|
||||
class="float-settings__list"
|
||||
class="aksrt-sortable"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<FloatBtnItemPanel :model-value="element" @update:model-value="updateItem(index, $event)" />
|
||||
@@ -76,6 +74,7 @@ import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AksSaveBar from '@/components/form/AksSaveBar.vue'
|
||||
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
|
||||
import FloatBtnItemPanel from '@/components/floating/FloatBtnItemPanel.vue'
|
||||
import { defaultSettings, normalizeItems } from '@/components/floating/floating-defaults.js'
|
||||
|
||||
@@ -163,13 +162,13 @@ onMounted(loadSettings)
|
||||
|
||||
.float-settings__desc {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
}
|
||||
|
||||
.float-settings__label {
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
}
|
||||
|
||||
.float-settings__section {
|
||||
@@ -183,14 +182,4 @@ onMounted(loadSettings)
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.float-settings__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.float-settings__ghost {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
@@ -61,42 +61,38 @@
|
||||
<draggable
|
||||
v-if="form.items.length"
|
||||
v-model="form.items"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
item-key="id"
|
||||
handle=".tabbar-sort__handle"
|
||||
ghost-class="tabbar-sort__ghost"
|
||||
animation="180"
|
||||
class="tabbar-sort"
|
||||
class="aksrt-sortable"
|
||||
@end="resetSortOrder"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="tabbar-sort__item">
|
||||
<div class="tabbar-sort__main">
|
||||
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
|
||||
<i class="fa fas fa-bars"></i>
|
||||
</button>
|
||||
<span class="tabbar-sort__icon">
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head">
|
||||
<AksSortHandle />
|
||||
<span class="aksrt-sortable__icon">
|
||||
<IconValue v-if="element.icon" :value="element.icon" />
|
||||
</span>
|
||||
<div class="tabbar-sort__meta">
|
||||
<div class="tabbar-sort__title-row">
|
||||
<strong>{{ element.text || '按钮' }}</strong>
|
||||
<div class="aksrt-sortable__main">
|
||||
<div class="aksrt-sortable__title-row">
|
||||
<span class="aksrt-sortable__title">{{ element.text || '按钮' }}</span>
|
||||
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
|
||||
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
|
||||
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
|
||||
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
|
||||
</div>
|
||||
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
|
||||
<div class="aksrt-sortable__desc">{{ getItemSummary(element) }}</div>
|
||||
</div>
|
||||
<div class="aksrt-sortable__actions">
|
||||
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
|
||||
<template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
|
||||
<template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
|
||||
删除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabbar-sort__actions">
|
||||
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
|
||||
<template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
|
||||
<template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
|
||||
删除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -216,22 +212,21 @@
|
||||
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
|
||||
<draggable
|
||||
v-model="editingItem.add_items"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
item-key="id"
|
||||
handle=".add-items-editor__drag"
|
||||
ghost-class="add-items-editor__ghost"
|
||||
animation="180"
|
||||
class="aksrt-sortable aksrt-sortable--compact"
|
||||
>
|
||||
<template #item="{ element, index: subIndex }">
|
||||
<div class="add-items-editor__row">
|
||||
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
|
||||
<i class="fa fas fa-bars"></i>
|
||||
</button>
|
||||
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
||||
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
|
||||
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
||||
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
|
||||
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
||||
</n-button>
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head add-items-editor__row">
|
||||
<AksSortHandle />
|
||||
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
||||
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
|
||||
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
||||
<n-button quaternary type="error" aria-label="删除子项" @click="removeAddItem(subIndex)">
|
||||
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
@@ -267,6 +262,8 @@ import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||
import IconValue from '@/components/form/IconValue.vue'
|
||||
import AksSortHandle from '@/components/form/AksSortHandle.vue'
|
||||
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -609,7 +606,7 @@ onMounted(loadSettings)
|
||||
}
|
||||
|
||||
.icon-preview-row a {
|
||||
color: var(--n-primary-color);
|
||||
color: var(--aksrt-color-primary, #18A058);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -618,18 +615,12 @@ onMounted(loadSettings)
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: #999;
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
color: var(--n-text-color-3);
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
@@ -659,127 +650,19 @@ onMounted(loadSettings)
|
||||
|
||||
.add-items-editor__presets-label {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
}
|
||||
|
||||
.add-items-editor__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
}
|
||||
|
||||
.add-items-editor__row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
|
||||
grid-template-columns: 28px 1fr 1fr 1.2fr 34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-items-editor__drag {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f3f5f7;
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.add-items-editor__ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tabbar-sort {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tabbar-sort__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tabbar-sort__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabbar-sort__handle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f3f5f7;
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tabbar-sort__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
color: var(--aksrt-color-primary);
|
||||
font-size: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tabbar-sort__meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabbar-sort__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tabbar-sort__desc {
|
||||
color: var(--n-text-color-3);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tabbar-sort__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tabbar-sort__ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.tabbar-sort__item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tabbar-sort__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-items-editor__row {
|
||||
grid-template-columns: 32px 1fr;
|
||||
grid-template-columns: 28px 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -19,20 +19,23 @@
|
||||
<draggable
|
||||
v-else
|
||||
v-model="rows"
|
||||
v-bind="SORTABLE_LIST_ATTRS"
|
||||
item-key="id"
|
||||
handle=".aksrt-ticket-prereply__handle"
|
||||
:animation="160"
|
||||
class="aksrt-ticket-prereply"
|
||||
class="aksrt-sortable"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="aksrt-ticket-prereply__row">
|
||||
<span class="aksrt-ticket-prereply__handle" title="拖动排序">⋮⋮</span>
|
||||
<span class="aksrt-ticket-prereply__index">{{ index + 1 }}</span>
|
||||
<span class="aksrt-ticket-prereply__content">{{ element.content }}</span>
|
||||
<n-space size="small">
|
||||
<n-button size="tiny" type="primary" quaternary @click="openEdit(element)">编辑</n-button>
|
||||
<n-button size="tiny" type="error" quaternary @click="handleDelete(element)">删除</n-button>
|
||||
</n-space>
|
||||
<div class="aksrt-sortable__item">
|
||||
<div class="aksrt-sortable__head">
|
||||
<AksSortHandle />
|
||||
<span class="aksrt-sortable__index">{{ index + 1 }}</span>
|
||||
<div class="aksrt-sortable__main">
|
||||
<span class="aksrt-ticket-prereply__content">{{ element.content }}</span>
|
||||
</div>
|
||||
<div class="aksrt-sortable__actions">
|
||||
<n-button size="tiny" type="primary" quaternary @click="openEdit(element)">编辑</n-button>
|
||||
<n-button size="tiny" type="error" quaternary @click="handleDelete(element)">删除</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
@@ -71,6 +74,8 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { NButton, NSpace, useDialog, useMessage } from 'naive-ui'
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import AksSortHandle from '@/components/form/AksSortHandle.vue'
|
||||
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -188,38 +193,11 @@ defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-ticket-prereply {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-ticket-prereply__row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px var(--n-border-color, #efeff5) solid;
|
||||
border-radius: 8px;
|
||||
background: var(--n-color, #fff);
|
||||
}
|
||||
|
||||
.aksrt-ticket-prereply__handle {
|
||||
color: var(--aksrt-color-muted);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.aksrt-ticket-prereply__index {
|
||||
color: var(--aksrt-color-muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.aksrt-ticket-prereply__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--aksrt-color-text, #1F2923);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -341,6 +341,16 @@
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* 弹层底部操作栏;弹层 teleport 到 #aksrt-admin-app 之外,token 带回退值。 */
|
||||
.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, #E2E8E3);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
#aksrt-admin-app .aksrt-form-renderer__save-bar {
|
||||
right: 0;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 共享拖拽排序列表(vuedraggable)。
|
||||
*
|
||||
* 后台所有可排序列表(schema 排序字段 / 重复器 / 启用-停用排序器 / 悬浮按钮 /
|
||||
* 底部 Tab / 工单预设回复)统一使用这套类名,并配合 SORTABLE_LIST_ATTRS:
|
||||
*
|
||||
* .aksrt-sortable 列表容器(draggable 根节点)
|
||||
* .aksrt-sortable__item 卡片行
|
||||
* .aksrt-sortable__head 行头:handle / index / icon / main / actions
|
||||
* .aksrt-sortable__body 可选:展开后的字段区
|
||||
*
|
||||
* 弹窗内容会被 teleport 到 #aksrt-admin-app 之外,因此 token 都带回退值。
|
||||
*/
|
||||
.aksrt-sortable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-sortable--compact {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aksrt-sortable__item {
|
||||
border: 1px solid var(--aksrt-color-border, #E2E8E3);
|
||||
border-radius: 8px;
|
||||
background: var(--aksrt-color-surface, #FFFFFF);
|
||||
box-shadow: var(--aksrt-shadow-card, 0 1px 2px rgba(31, 41, 35, .04));
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-sortable__item:hover {
|
||||
border-color: var(--aksrt-color-border-strong, #CCD7CE);
|
||||
}
|
||||
|
||||
.aksrt-sortable__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
}
|
||||
|
||||
.aksrt-sortable--compact .aksrt-sortable__head {
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
}
|
||||
|
||||
.aksrt-sortable__head.is-clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.aksrt-sortable__head.is-clickable:hover .aksrt-sortable__title,
|
||||
.aksrt-sortable__head.is-clickable:hover .aksrt-sortable__toggle {
|
||||
color: var(--aksrt-color-primary, #18A058);
|
||||
}
|
||||
|
||||
.aksrt-sortable__handle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 13px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transition: color .15s ease, background-color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-sortable__handle:hover,
|
||||
.aksrt-sortable__handle:focus-visible {
|
||||
outline: none;
|
||||
background: var(--aksrt-color-primary-soft, #EAF7EF);
|
||||
color: var(--aksrt-color-primary, #18A058);
|
||||
}
|
||||
|
||||
.aksrt-sortable__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.aksrt-sortable__index {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 11px;
|
||||
background: var(--aksrt-color-canvas, #F5F7F6);
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.aksrt-sortable__icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--aksrt-color-primary-soft, #EAF7EF);
|
||||
color: var(--aksrt-color-primary, #18A058);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.aksrt-sortable__icon svg,
|
||||
.aksrt-sortable__icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.aksrt-sortable__main {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-sortable__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-sortable__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--aksrt-color-text, #1F2923);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-sortable__desc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-sortable__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.aksrt-sortable__toggle {
|
||||
flex: 0 0 auto;
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 12px;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
|
||||
.aksrt-sortable__body {
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid var(--aksrt-color-border, #E2E8E3);
|
||||
}
|
||||
|
||||
/* 拖拽中:原位置留下虚线占位,被拖动的卡片浮起。 */
|
||||
.aksrt-sortable__chosen {
|
||||
border-color: var(--aksrt-color-primary, #18A058);
|
||||
}
|
||||
|
||||
.aksrt-sortable__drag {
|
||||
box-shadow: var(--aksrt-shadow-floating, 0 12px 32px rgba(31, 41, 35, .12));
|
||||
}
|
||||
|
||||
.aksrt-sortable__ghost {
|
||||
border-style: dashed;
|
||||
border-color: var(--aksrt-color-primary, #18A058);
|
||||
background: var(--aksrt-color-primary-soft, #EAF7EF);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.aksrt-sortable__ghost > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.aksrt-sortable__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 20px 12px;
|
||||
border: 1px dashed var(--aksrt-color-border-strong, #CCD7CE);
|
||||
border-radius: 8px;
|
||||
background: var(--aksrt-color-canvas, #F5F7F6);
|
||||
color: var(--aksrt-color-muted, #6F7A72);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.aksrt-sortable__head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-sortable__main {
|
||||
flex-basis: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aksrt-sortable__item,
|
||||
.aksrt-sortable__handle,
|
||||
.aksrt-sortable__title,
|
||||
.aksrt-sortable__toggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user