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:
kate522andClaude Fable 5 committed 2026-10-28 13:41:26 +08:00
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,
}
}
+1
View File
@@ -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>
+10
View File
@@ -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;
+236
View File
@@ -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;
}
}