fix(admin): restore Vite admin source files
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="aksrt-deferred-page">
|
||||
<div class="aksrt-deferred-page__icon">
|
||||
<FaIcon :icon="resolvedIcon" :size="48" />
|
||||
</div>
|
||||
<h2 class="aksrt-deferred-page__title">{{ resolvedTitle }}</h2>
|
||||
<p class="aksrt-deferred-page__desc">{{ resolvedDesc }}</p>
|
||||
<div class="aksrt-deferred-page__meta">
|
||||
<n-tag size="small" type="info">{{ resolvedStatus }}</n-tag>
|
||||
<n-tag v-if="resolvedPhase" size="small" type="warning">Phase {{ resolvedPhase }}</n-tag>
|
||||
</div>
|
||||
<div v-if="resolvedDependsOn.length" class="aksrt-deferred-page__depends">
|
||||
<span class="aksrt-deferred-page__label">依赖</span>
|
||||
<div class="aksrt-deferred-page__depends-list">
|
||||
<n-tag v-for="item in resolvedDependsOn" :key="item" size="small" type="default">{{ item }}</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resolvedReason" class="aksrt-deferred-page__reason">
|
||||
<span class="aksrt-deferred-page__label">原因</span>
|
||||
<p>{{ resolvedReason }}</p>
|
||||
</div>
|
||||
<div v-if="resolvedFeatures && resolvedFeatures.length" class="aksrt-deferred-page__features">
|
||||
<div
|
||||
v-for="(feat, i) in resolvedFeatures"
|
||||
:key="i"
|
||||
class="aksrt-deferred-page__feature"
|
||||
>
|
||||
<FaIcon icon="fas fa-check-circle" :size="14" />
|
||||
<span>{{ feat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-deferred-page__badge">
|
||||
<n-tag type="info" size="small">即将推出</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* DeferredPage — "Coming Soon" page component for deferred sections.
|
||||
* Shows a friendly message instead of a blank page.
|
||||
*
|
||||
* When used as a route component, reads props from route.meta.deferredProps.
|
||||
* When used directly, accepts props normally.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import FaIcon from './form/FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
features: { type: Array, default: () => [] },
|
||||
status: { type: String, default: '' },
|
||||
phase: { type: String, default: '' },
|
||||
dependsOn: { type: Array, default: () => [] },
|
||||
reason: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** Merge direct props with route meta deferredProps */
|
||||
const resolvedTitle = computed(() => props.title || route.meta?.deferredProps?.title || '功能开发中')
|
||||
const resolvedDesc = computed(() => props.description || route.meta?.deferredProps?.description || '此功能正在规划开发中,将在后续版本上线。')
|
||||
const resolvedIcon = computed(() => props.icon || route.meta?.deferredProps?.icon || 'fas fa-rocket')
|
||||
const resolvedStatus = computed(() => props.status || route.meta?.deferredProps?.status || 'deferred')
|
||||
const resolvedPhase = computed(() => props.phase || route.meta?.deferredProps?.phase || '')
|
||||
const resolvedDependsOn = computed(() => {
|
||||
if (props.dependsOn && props.dependsOn.length) return props.dependsOn
|
||||
return route.meta?.deferredProps?.dependsOn || []
|
||||
})
|
||||
const resolvedReason = computed(() => props.reason || route.meta?.deferredProps?.reason || '')
|
||||
const resolvedFeatures = computed(() => {
|
||||
if (props.features && props.features.length) return props.features
|
||||
return route.meta?.deferredProps?.features || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-deferred-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__icon {
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__desc {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
color: #8a909c;
|
||||
max-width: 400px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__depends,
|
||||
.aksrt-deferred-page__reason {
|
||||
width: min(520px, 100%);
|
||||
margin-bottom: 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__label {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__depends-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__reason p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.aksrt-deferred-page__feature :deep(i) {
|
||||
color: #52c41a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="aksrt-empty-state">
|
||||
<div class="aksrt-empty-state__icon">
|
||||
<i :class="icon" style="font-size: 48px;"></i>
|
||||
</div>
|
||||
<h3 class="aksrt-empty-state__title">{{ title }}</h3>
|
||||
<p class="aksrt-empty-state__desc">{{ desc }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '功能开发中' },
|
||||
desc: { type: String, default: '此功能模块正在规划中,后续版本将逐步开放。' },
|
||||
icon: { type: String, default: 'fas fa-hourglass-half' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aksrt-empty-state__icon {
|
||||
margin-bottom: 20px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.aksrt-empty-state__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-empty-state__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #7a7f8a;
|
||||
max-width: 360px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<header class="aksrt-mobile-topbar">
|
||||
<button
|
||||
class="aksrt-mobile-topbar__menu"
|
||||
type="button"
|
||||
aria-label="打开菜单"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="4" y1="12" x2="20" y2="12"/>
|
||||
<line x1="4" y1="18" x2="20" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="aksrt-mobile-topbar__title">{{ title }}</div>
|
||||
|
||||
<div class="aksrt-mobile-topbar__brand">AK</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: 'AKSRT' },
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-mobile-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eceff5;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.aksrt-mobile-topbar__menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #1a1a2e;
|
||||
cursor: pointer;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
.aksrt-mobile-topbar__menu:hover {
|
||||
background: #f3f5f9;
|
||||
}
|
||||
|
||||
.aksrt-mobile-topbar__title {
|
||||
flex: 1 1 auto;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aksrt-mobile-topbar__brand {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #18a058;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<n-card class="oauth-provider-card" title="">
|
||||
<!-- 平台信息头部 -->
|
||||
<div class="oauth-provider-header">
|
||||
<div class="oauth-header-left">
|
||||
<span class="oauth-platform-icon">
|
||||
<img v-if="icon" :src="icon" :alt="name" class="oauth-platform-img" />
|
||||
<span v-else class="oauth-platform-text">{{ name.charAt(0) }}</span>
|
||||
</span>
|
||||
<div class="oauth-platform-info">
|
||||
<h4>{{ name }}</h4>
|
||||
<a :href="docUrl" target="_blank" class="oauth-doc-link">
|
||||
前往 {{ name }} 创建应用 ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<n-switch v-model:value="modelValue.enabled" class="oauth-enable-switch">
|
||||
<template #checked>启用</template>
|
||||
<template #unchecked>停用</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
|
||||
<!-- 表单区域(启用后才展开) -->
|
||||
<div v-if="modelValue.enabled" class="oauth-form-body">
|
||||
<n-form label-placement="top" class="aksrt-form oauth-form-grid">
|
||||
<n-form-item
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
>
|
||||
<n-input
|
||||
v-if="!field.readonly"
|
||||
v-model:value="modelValue[field.key]"
|
||||
:type="field.secret ? 'password' : 'text'"
|
||||
:show-password-on-click="field.secret"
|
||||
:placeholder="'输入 ' + field.label"
|
||||
size="large"
|
||||
/>
|
||||
<n-input
|
||||
v-else
|
||||
:value="callbackUrl"
|
||||
readonly
|
||||
size="large"
|
||||
style='font-family: "SF Mono", Consolas, monospace; font-size:13px;'
|
||||
/>
|
||||
<template v-if="field.hint" #feedback>
|
||||
<span class="aksrt-hint-text">{{ field.hint }}</span>
|
||||
</template>
|
||||
<template v-if="field.readonly && !field.hint" #feedback>
|
||||
<span class="aksrt-hint-text">将此地址填写到对应平台的「授权回调域 / 回调 URL」中</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
|
||||
<!-- 未启用时的提示 -->
|
||||
<div v-else class="oauth-disabled-hint">
|
||||
<p>开启开关以配置此平台的 OAuth 凭证</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
name: { type: String, required: true },
|
||||
docUrl: { type: String, required: true },
|
||||
fields: { type: Array, required: true },
|
||||
icon: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const callbackUrl = computed(() => {
|
||||
const siteUrl = window.aksrtAdminData?.siteUrl || window.location.origin
|
||||
return `${siteUrl}/oauth/callback/`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oauth-provider-card {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* 平台头部 */
|
||||
.oauth-provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.oauth-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.oauth-platform-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.oauth-platform-img {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.oauth-platform-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.oauth-platform-info h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.oauth-doc-link {
|
||||
font-size: 12px;
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.oauth-doc-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 启用开关 */
|
||||
.oauth-enable-switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 表单体 */
|
||||
.oauth-form-body {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f5;
|
||||
}
|
||||
|
||||
.oauth-form-grid :deep(.n-form-item) {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* 禁用提示 */
|
||||
.oauth-disabled-hint {
|
||||
text-align: center;
|
||||
padding: 28px 0 10px;
|
||||
}
|
||||
.oauth-disabled-hint p {
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<header class="aksrt-page-header">
|
||||
<div class="aksrt-page-header__main">
|
||||
<h2 class="aksrt-page-header__title">{{ title }}</h2>
|
||||
<p v-if="desc" class="aksrt-page-header__desc">
|
||||
<slot name="desc">{{ desc }}</slot>
|
||||
</p>
|
||||
<div v-else-if="$slots.desc" class="aksrt-page-header__desc">
|
||||
<slot name="desc" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="aksrt-page-header__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
desc: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-page-header__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-page-header__title {
|
||||
margin: 0 0 6px;
|
||||
color: #1a1a2e;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.aksrt-page-header__desc {
|
||||
margin: 0;
|
||||
color: #7a7f8a;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.aksrt-page-header__actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-page-header {
|
||||
margin-bottom: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-page-header__title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.aksrt-page-header__desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-page-header__actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<n-form label-placement="left" label-width="130">
|
||||
<n-space vertical style="width:100%">
|
||||
<n-divider title-placement="left">前端配置</n-divider>
|
||||
<n-form-item label="所属地区">
|
||||
<n-select
|
||||
v-model:value="model.region"
|
||||
:options="[
|
||||
{ label: '中国内地 (cn)', value: 'cn' },
|
||||
{ label: '新加坡 (sgp)', value: 'sgp' },
|
||||
]"
|
||||
placeholder="选择地区"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="身份标 (prefix)">
|
||||
<n-input
|
||||
v-model:value="model.prefix"
|
||||
placeholder="从概览页获取身份标"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="场景ID">
|
||||
<n-input
|
||||
v-model:value="model.scene_id"
|
||||
placeholder="从验证码场景列表获取 SceneId"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">后端 API 密钥(用于服务端验签)</n-divider>
|
||||
<n-form-item label="AccessKey ID">
|
||||
<n-input
|
||||
v-model:value="model.access_key_id"
|
||||
placeholder="阿里云 RAM 用户 AccessKey ID"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="AccessKey Secret">
|
||||
<n-input
|
||||
v-model:value="model.access_key_secret"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
autocomplete="current-password"
|
||||
placeholder="阿里云 RAM 用户 AccessKey Secret"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-alert type="warning" style="margin-bottom: 8px;">
|
||||
请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。
|
||||
</n-alert>
|
||||
|
||||
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
|
||||
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Object, default: () => ({}) })
|
||||
|
||||
const isConfigured = computed(() => {
|
||||
const c = model.value
|
||||
return !!(c.region && c.prefix && c.scene_id && c.access_key_id && c.access_key_secret)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<n-form label-placement="left" label-width="130">
|
||||
<n-space vertical style="width:100%">
|
||||
<n-form-item label="Site Key">
|
||||
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Secret Key">
|
||||
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="current-password" />
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义域名">
|
||||
<n-input v-model:value="model.domain" placeholder="留空使用默认域名" autocomplete="url" />
|
||||
</n-form-item>
|
||||
<n-form-item label="主题">
|
||||
<n-select v-model:value="model.theme" :options="themeOptions" clearable />
|
||||
</n-form-item>
|
||||
<n-form-item label="尺寸">
|
||||
<n-select v-model:value="model.size" :options="sizeOptions" clearable />
|
||||
</n-form-item>
|
||||
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
|
||||
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Object, default: () => ({}) })
|
||||
|
||||
const isConfigured = computed(() => {
|
||||
const c = model.value
|
||||
return !!(c.site_key && c.secret_key)
|
||||
})
|
||||
|
||||
const themeOptions = [
|
||||
{ label: '自动', value: 'auto' },
|
||||
{ label: '浅色', value: 'light' },
|
||||
{ label: '深色', value: 'dark' },
|
||||
]
|
||||
const sizeOptions = [
|
||||
{ label: '正常', value: 'normal' },
|
||||
{ label: '紧凑', value: 'compact' },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<n-form label-placement="left" label-width="130">
|
||||
<n-space vertical style="width:100%">
|
||||
<n-form-item label="Captcha ID">
|
||||
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Captcha Key">
|
||||
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="current-password" />
|
||||
</n-form-item>
|
||||
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
|
||||
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) })
|
||||
|
||||
const isConfigured = computed(() => {
|
||||
const c = model.value
|
||||
return !!(c.captcha_id && c.captcha_key)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<n-form label-placement="left" label-width="130">
|
||||
<n-space vertical style="width:100%">
|
||||
<n-divider title-placement="left">云 API 密钥(用于 TC3 签名)</n-divider>
|
||||
<n-alert type="warning" style="margin-bottom: 12px;">
|
||||
请从 <n-a href="https://console.cloud.tencent.com/cam/capi" target="_blank">访问管理控制台 → API 密钥管理</n-a> 获取。建议使用子账号密钥并仅授予 <code>QcloudCaptchaFullAccess</code> 权限。
|
||||
</n-alert>
|
||||
<n-form-item label="SecretId">
|
||||
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
|
||||
</n-form-item>
|
||||
<n-form-item label="SecretKey">
|
||||
<n-input v-model:value="model.secret_key" type="text" placeholder="腾讯云 API SecretKey" autocomplete="off" show-password-on="click" />
|
||||
</n-form-item>
|
||||
|
||||
<n-divider title-placement="left">验证码密钥</n-divider>
|
||||
<n-form-item label="CaptchaAppId">
|
||||
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
|
||||
</n-form-item>
|
||||
<n-form-item label="AppSecretKey">
|
||||
<n-input v-model:value="model.app_secret_key" type="text" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" show-password-on="click" />
|
||||
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 12px;">
|
||||
腾讯云验证码 2.0 — 验证类型(滑动拼图、文字点选、空间语义等)请在<a href="https://console.cloud.tencent.com/captcha/graphical" target="_blank">验证码控制台</a>配置。
|
||||
</n-alert>
|
||||
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
|
||||
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const model = defineModel({ type: Object, default: () => ({}) })
|
||||
|
||||
const isConfigured = computed(() => {
|
||||
const c = model.value
|
||||
return !!(c.secret_id && c.secret_key && c.app_id && c.app_secret_key)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="aksrt-array-table">
|
||||
<n-data-table
|
||||
:columns="tableColumns"
|
||||
:data="modelValue || []"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
max-height="400"
|
||||
/>
|
||||
|
||||
<n-button
|
||||
size="small"
|
||||
dashed
|
||||
block
|
||||
class="aksrt-array-table__add"
|
||||
@click="addItem"
|
||||
>
|
||||
<FaIcon icon="fas fa-plus" :size="12" />
|
||||
{{ addText || '添加行' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* AksArrayTable — table-style editor for array data.
|
||||
* Renders rows in a data table with inline editing.
|
||||
*/
|
||||
import { computed, h } from 'vue'
|
||||
import { NInput, NSwitch, NButton, NSelect, NInputNumber } from 'naive-ui'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
/** Column definitions: [{ key, label, type?, options?, width? }] */
|
||||
columns: { type: Array, required: true },
|
||||
defaults: { type: Object, default: () => ({}) },
|
||||
addText: { type: String, default: '' },
|
||||
maxRows: { type: Number, default: Infinity },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function updateCell(rowIndex, key, value) {
|
||||
const list = [...(props.modelValue || [])]
|
||||
list[rowIndex] = { ...list[rowIndex], [key]: value }
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function removeItem(index) {
|
||||
const list = (props.modelValue || []).filter((_, i) => i !== index)
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if ((props.modelValue || []).length >= props.maxRows) return
|
||||
const item = {}
|
||||
for (const col of props.columns) {
|
||||
item[col.key] = col.default ?? (col.type === 'switch' ? false : '')
|
||||
}
|
||||
emit('update:modelValue', [...(props.modelValue || []), { ...item, ...props.defaults }])
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
const list = [...(props.modelValue || [])]
|
||||
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
const list = [...(props.modelValue || [])]
|
||||
if (index >= list.length - 1) return
|
||||
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
const tableColumns = computed(() => {
|
||||
const cols = props.columns.map(col => ({
|
||||
title: col.label,
|
||||
key: col.key,
|
||||
width: col.width || 150,
|
||||
render(row, rowIndex) {
|
||||
const val = row[col.key]
|
||||
const update = (v) => updateCell(rowIndex, col.key, v)
|
||||
|
||||
if (col.type === 'switch') {
|
||||
return h(NSwitch, { value: val, size: 'small', 'onUpdate:value': update })
|
||||
}
|
||||
if (col.type === 'select') {
|
||||
return h(NSelect, { value: val, options: col.options || [], size: 'small', 'onUpdate:value': update })
|
||||
}
|
||||
if (col.type === 'number') {
|
||||
return h(NInputNumber, { value: val, size: 'small', style: 'width:100%', 'onUpdate:value': update })
|
||||
}
|
||||
return h(NInput, { value: val, size: 'small', 'onUpdate:value': update })
|
||||
},
|
||||
}))
|
||||
|
||||
// Add actions column
|
||||
cols.push({
|
||||
title: '操作',
|
||||
key: '_actions',
|
||||
width: 100,
|
||||
render(row, rowIndex) {
|
||||
return h('div', { style: 'display:flex;gap:2px' }, [
|
||||
rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, {
|
||||
default: () => h(FaIcon, { icon: 'fas fa-arrow-up', size: 12 }),
|
||||
}) : null,
|
||||
rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, {
|
||||
default: () => h(FaIcon, { icon: 'fas fa-arrow-down', size: 12 }),
|
||||
}) : null,
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
|
||||
default: () => h(FaIcon, { icon: 'fas fa-trash', size: 12 }),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-array-table__add {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div ref="editorRef" class="aks-code-editor" :class="{ 'is-disabled': disabled }" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, shallowRef } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
language: { type: String, default: 'html' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
rows: { type: Number, default: 8 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const editorRef = ref(null)
|
||||
const view = shallowRef(null)
|
||||
let updating = false
|
||||
|
||||
function resolveLang(lang) {
|
||||
switch (lang) {
|
||||
case 'css': return css()
|
||||
case 'js':
|
||||
case 'javascript': return javascript()
|
||||
case 'html':
|
||||
default: return html()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!editorRef.value) return
|
||||
const state = EditorState.create({
|
||||
doc: props.modelValue || '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
resolveLang(props.language),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !updating) {
|
||||
const val = update.state.doc.toString()
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
}),
|
||||
EditorView.editable.of(!props.disabled),
|
||||
EditorState.readOnly.of(props.disabled),
|
||||
],
|
||||
})
|
||||
view.value = new EditorView({ state, parent: editorRef.value })
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (!view.value) return
|
||||
const current = view.value.state.doc.toString()
|
||||
if (newVal !== current) {
|
||||
updating = true
|
||||
view.value.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: newVal || '' },
|
||||
})
|
||||
updating = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.disabled, (val) => {
|
||||
if (!view.value) return
|
||||
view.value.dispatch({
|
||||
effects: [
|
||||
EditorView.editable.reconfigure(EditorView.editable.of(!val)),
|
||||
EditorState.readOnly.reconfigure(EditorState.readOnly.of(val)),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (view.value) {
|
||||
view.value.destroy()
|
||||
view.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aks-code-editor {
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-height: calc(var(--rows, 8) * 22px);
|
||||
}
|
||||
.aks-code-editor :deep(.cm-editor) {
|
||||
font-size: 13px;
|
||||
}
|
||||
.aks-code-editor :deep(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
.aks-code-editor.is-disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="aksrt-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
|
||||
<div
|
||||
v-if="title"
|
||||
class="aksrt-field-group__header"
|
||||
:class="{ 'is-clickable': collapsible }"
|
||||
@click="collapsible ? toggleCollapse() : undefined"
|
||||
>
|
||||
<div class="aksrt-field-group__header-left">
|
||||
<FaIcon v-if="icon" :icon="icon" :size="16" class="aksrt-field-group__icon" />
|
||||
<div>
|
||||
<h3 class="aksrt-field-group__title">{{ title }}</h3>
|
||||
<p v-if="description" class="aksrt-field-group__desc">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-field-group__header-right">
|
||||
<div v-if="$slots.actions" class="aksrt-field-group__actions" @click.stop>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<button
|
||||
v-if="collapsible"
|
||||
type="button"
|
||||
class="aksrt-field-group__collapse-btn"
|
||||
:aria-expanded="(!collapsed).toString()"
|
||||
:title="collapsed ? '展开配置' : '收起配置'"
|
||||
@click.stop="toggleCollapse"
|
||||
>
|
||||
<FaIcon
|
||||
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'"
|
||||
:size="12"
|
||||
/>
|
||||
<span>{{ collapsed ? '展开' : '收起' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="aksrt-collapse">
|
||||
<div v-show="!collapsed" class="aksrt-field-group__body">
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* AksFieldGroup — card-style container for grouping related form fields.
|
||||
* Supports collapsible sections, icon headers, and description text.
|
||||
*
|
||||
* defaultCollapsed only seeds initial state. Parent should remount the group
|
||||
* (e.g. via :key when a channel turns on) if it wants to force expand again.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
defaultCollapsed: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const collapsed = ref(!!props.defaultCollapsed)
|
||||
|
||||
function toggleCollapse() {
|
||||
if (!props.collapsible) return
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-field-group {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aksrt-field-group__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-field-group__header.is-clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.aksrt-field-group.is-collapsed .aksrt-field-group__header {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.aksrt-field-group__header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-field-group__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aksrt-field-group__icon {
|
||||
color: #1890ff;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.aksrt-field-group__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-field-group__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.aksrt-field-group__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-field-group__collapse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #e8ecf2;
|
||||
background: #f7f9fc;
|
||||
color: #5c6370;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-field-group__collapse-btn:hover {
|
||||
color: #1890ff;
|
||||
border-color: #91caff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.aksrt-field-group__body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
/* Collapse transition */
|
||||
.aksrt-collapse-enter-active,
|
||||
.aksrt-collapse-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aksrt-collapse-enter-from,
|
||||
.aksrt-collapse-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="aksrt-form-renderer">
|
||||
<!-- Page header with title and description -->
|
||||
<div v-if="title" class="aksrt-form-renderer__header">
|
||||
<div class="aksrt-form-renderer__header-left">
|
||||
<FaIcon v-if="icon" :icon="icon" :size="22" class="aksrt-form-renderer__header-icon" />
|
||||
<div>
|
||||
<h1 class="aksrt-form-renderer__title">{{ title }}</h1>
|
||||
<p v-if="description" class="aksrt-form-renderer__desc">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSearch" class="aksrt-form-renderer__search">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索设置项..."
|
||||
size="small"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<FaIcon icon="fas fa-search" :size="12" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="aksrt-form-renderer__loading">
|
||||
<n-spin size="medium" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<n-alert
|
||||
v-else-if="loadError"
|
||||
type="error"
|
||||
:title="'加载失败'"
|
||||
:style="{ marginBottom: '16px' }"
|
||||
>
|
||||
{{ loadError }}
|
||||
<template #footer>
|
||||
<n-button size="small" @click="$emit('reload')">重试</n-button>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<!-- Form content -->
|
||||
<template v-else-if="schema && schema.length">
|
||||
<template v-for="group in filteredSchema" :key="groupKey(group)">
|
||||
<AksFieldGroup
|
||||
v-if="isGroupVisible(group)"
|
||||
:title="group.title"
|
||||
:description="group.description"
|
||||
:icon="group.icon"
|
||||
:collapsible="!!group.collapsible"
|
||||
:default-collapsed="resolveGroupCollapsed(group)"
|
||||
>
|
||||
<template v-if="group.fields">
|
||||
<template v-for="field in group.fields" :key="field.key">
|
||||
<template v-if="isFieldVisible(field)">
|
||||
<slot
|
||||
v-if="field.type === 'custom'"
|
||||
:name="`field-${field.key}`"
|
||||
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
|
||||
>
|
||||
<AksField
|
||||
:schema="field"
|
||||
:model-value="getFieldValue(field.key)"
|
||||
:error-message="errors?.[field.key]"
|
||||
@update:model-value="setFieldValue(field.key, $event)"
|
||||
/>
|
||||
</slot>
|
||||
<AksField
|
||||
v-else
|
||||
:schema="field"
|
||||
:model-value="getFieldValue(field.key)"
|
||||
:error-message="errors?.[field.key]"
|
||||
@update:model-value="setFieldValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Custom group content slot -->
|
||||
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
|
||||
</AksFieldGroup>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="aksrt-form-renderer__empty">
|
||||
<FaIcon icon="fas fa-cog" :size="32" />
|
||||
<p>暂无配置项</p>
|
||||
</div>
|
||||
|
||||
<!-- Sticky save bar -->
|
||||
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
|
||||
<div class="aksrt-form-renderer__save-bar__inner">
|
||||
<div class="aksrt-form-renderer__save-bar__info">
|
||||
<FaIcon icon="fas fa-exclamation-circle" :size="14" />
|
||||
<span>您有未保存的更改</span>
|
||||
</div>
|
||||
<div class="aksrt-form-renderer__save-bar__actions">
|
||||
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<FaIcon v-if="!saving" icon="fas fa-save" :size="12" />
|
||||
保存设置
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* AksFormRenderer — the core schema-driven form engine.
|
||||
*
|
||||
* Takes a schema definition (array of field groups) and a reactive form data object,
|
||||
* and renders a complete settings page with:
|
||||
* - Grouped fields in card-style containers
|
||||
* - Inline search/filter
|
||||
* - Dirty state tracking with sticky save bar
|
||||
* - Dependency-based field visibility
|
||||
* - Loading and error states
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
import AksFieldGroup from './AksFieldGroup.vue'
|
||||
import AksField from './AksField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** Page title */
|
||||
title: { type: String, default: '' },
|
||||
/** Page description */
|
||||
description: { type: String, default: '' },
|
||||
/** Title icon (FA5 class) */
|
||||
icon: { type: String, default: '' },
|
||||
/**
|
||||
* Schema definition: array of field groups.
|
||||
* Each group: { key, title, description?, icon?, collapsible?, fields: FieldDef[] }
|
||||
* Each field: { key, label, type, default, options?, dependsOn?, help?, ... }
|
||||
*/
|
||||
schema: { type: Array, default: () => [] },
|
||||
/** Reactive form data object */
|
||||
formData: { type: Object, default: () => ({}) },
|
||||
/** Whether the form has unsaved changes */
|
||||
dirty: { type: Boolean, default: false },
|
||||
/** Whether data is currently loading */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** Whether a save is in progress */
|
||||
saving: { type: Boolean, default: false },
|
||||
/** Load error message */
|
||||
loadError: { type: String, default: '' },
|
||||
/** Whether to show the search bar */
|
||||
showSearch: { type: Boolean, default: true },
|
||||
/** Validation errors keyed by field key */
|
||||
errors: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:formData', 'save', 'reload'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
/** Evaluate dependsOn rule against current form data */
|
||||
function matchDependsOn(dependsOn) {
|
||||
if (!dependsOn) return true
|
||||
if (typeof dependsOn === 'string') {
|
||||
return !!props.formData[dependsOn]
|
||||
}
|
||||
if (typeof dependsOn === 'object') {
|
||||
const { key, value, values, not } = dependsOn
|
||||
const currentVal = props.formData[key]
|
||||
if (not) {
|
||||
if (Array.isArray(values)) {
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return !values.some((v) => currentVal == v)
|
||||
}
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return currentVal !== value && currentVal != value
|
||||
}
|
||||
// values: match any of a list (e.g. provider ∈ [qq, polling])
|
||||
if (Array.isArray(values)) {
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return values.some((v) => currentVal == v)
|
||||
}
|
||||
// Loose equality so true matches 1 / '1' from some option stores.
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return currentVal == value || (!!value === true && !!currentVal)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Check if a field should be visible based on its dependsOn rule */
|
||||
function isFieldVisible(field) {
|
||||
return matchDependsOn(field.dependsOn)
|
||||
}
|
||||
|
||||
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
||||
function isGroupVisible(group) {
|
||||
return matchDependsOn(group.dependsOn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse preference for a group.
|
||||
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
|
||||
* - defaultCollapsed: static default
|
||||
*/
|
||||
function resolveGroupCollapsed(group) {
|
||||
if (group.expandWhen) {
|
||||
const on = !!props.formData?.[group.expandWhen]
|
||||
return !on
|
||||
}
|
||||
return !!group.defaultCollapsed
|
||||
}
|
||||
|
||||
/** Stable key; remount when expandWhen flips so newly enabled channels open expanded. */
|
||||
function groupKey(group) {
|
||||
const base = group.key || group.title || 'group'
|
||||
if (group.expandWhen) {
|
||||
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/** Get field value from formData */
|
||||
function getFieldValue(key) {
|
||||
return props.formData?.[key]
|
||||
}
|
||||
|
||||
/** Set field value, emitting update for parent */
|
||||
function setFieldValue(key, value) {
|
||||
const newData = { ...props.formData, [key]: value }
|
||||
emit('update:formData', newData)
|
||||
}
|
||||
|
||||
/** Filter schema groups/fields by search query */
|
||||
const filteredSchema = computed(() => {
|
||||
const q = searchQuery.value?.toLowerCase().trim()
|
||||
if (!q) return props.schema
|
||||
|
||||
return props.schema
|
||||
.map(group => {
|
||||
if (!group.fields) return null
|
||||
|
||||
// Check if group title matches
|
||||
const groupMatch = (group.title || '').toLowerCase().includes(q)
|
||||
|| (group.description || '').toLowerCase().includes(q)
|
||||
|
||||
// Filter fields by label, key, or help text matching query
|
||||
const matchingFields = group.fields.filter(field => {
|
||||
if (groupMatch) return true
|
||||
return (field.label || '').toLowerCase().includes(q)
|
||||
|| (field.key || '').toLowerCase().includes(q)
|
||||
|| (field.help || '').toLowerCase().includes(q)
|
||||
|| (field.description || '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
if (matchingFields.length === 0) return null
|
||||
|
||||
return { ...group, fields: groupMatch ? group.fields : matchingFields }
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-form-renderer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__header-icon {
|
||||
color: #1890ff;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #7a7f8a;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__search {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 0;
|
||||
color: #8a909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 0;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Sticky save bar */
|
||||
.aksrt-form-renderer__save-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eef1f6;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__save-bar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 28px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__save-bar__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #d48806;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__save-bar__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-form-renderer__header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__search {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__save-bar__inner {
|
||||
flex-direction: column;
|
||||
padding: 10px 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__save-bar__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-form-renderer__save-bar__actions .n-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="aksrt-media-field">
|
||||
<div v-if="modelValue" class="aksrt-media-field__preview">
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="modelValue"
|
||||
:alt="placeholder"
|
||||
class="aksrt-media-field__image"
|
||||
/>
|
||||
<div v-else class="aksrt-media-field__file">
|
||||
<FaIcon icon="fas fa-file" :size="24" />
|
||||
<span class="aksrt-media-field__filename">{{ fileName }}</span>
|
||||
</div>
|
||||
<div class="aksrt-media-field__overlay">
|
||||
<n-button size="tiny" quaternary @click="openMediaLibrary">
|
||||
<FaIcon icon="fas fa-exchange-alt" /> 更换
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" @click="clearMedia">
|
||||
<FaIcon icon="fas fa-trash" /> 移除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="aksrt-media-field__empty" @click="openMediaLibrary">
|
||||
<FaIcon icon="fas fa-cloud-upload-alt" :size="28" />
|
||||
<span>{{ placeholder }}</span>
|
||||
</div>
|
||||
|
||||
<n-input
|
||||
:value="modelValue"
|
||||
:placeholder="'或直接输入 URL'"
|
||||
size="small"
|
||||
class="aksrt-media-field__url"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* AksMediaField — image/file selector with preview.
|
||||
*
|
||||
* In the WordPress admin context, this can open the WP Media Library.
|
||||
* For now, it supports direct URL input with preview.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
accept: { type: String, default: 'image/*' },
|
||||
placeholder: { type: String, default: '选择图片' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const isImage = computed(() => {
|
||||
if (!props.modelValue) return false
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i.test(props.modelValue)
|
||||
|| props.modelValue.startsWith('data:image/')
|
||||
})
|
||||
|
||||
const fileName = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
try {
|
||||
const url = new URL(props.modelValue, window.location.origin)
|
||||
const parts = url.pathname.split('/')
|
||||
return parts[parts.length - 1] || props.modelValue
|
||||
} catch {
|
||||
const parts = props.modelValue.split('/')
|
||||
return parts[parts.length - 1] || props.modelValue
|
||||
}
|
||||
})
|
||||
|
||||
function openMediaLibrary() {
|
||||
// In WordPress admin, use wp.media to open the media library
|
||||
if (typeof wp !== 'undefined' && wp.media) {
|
||||
const frame = wp.media({
|
||||
title: props.placeholder,
|
||||
multiple: false,
|
||||
library: { type: props.accept === 'image/*' ? 'image' : undefined },
|
||||
})
|
||||
frame.on('select', () => {
|
||||
const attachment = frame.state().get('selection').first().toJSON()
|
||||
emit('update:modelValue', attachment.url)
|
||||
})
|
||||
frame.open()
|
||||
}
|
||||
// Outside WP, the URL input serves as the entry point
|
||||
}
|
||||
|
||||
function clearMedia() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-media-field__preview {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef1f6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.aksrt-media-field__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.aksrt-media-field__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aksrt-media-field__filename {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.aksrt-media-field__overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.aksrt-media-field__preview:hover .aksrt-media-field__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.aksrt-media-field__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
border: 2px dashed #d9dce3;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #8a909c;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.aksrt-media-field__empty:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.aksrt-media-field__url {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="aksrt-repeater">
|
||||
<div v-if="modelValue && modelValue.length" class="aksrt-repeater__list">
|
||||
<div
|
||||
v-for="(item, index) in modelValue"
|
||||
:key="index"
|
||||
class="aksrt-repeater__item"
|
||||
>
|
||||
<div class="aksrt-repeater__item-header">
|
||||
<span class="aksrt-repeater__item-index">#{{ index + 1 }}</span>
|
||||
<div class="aksrt-repeater__item-actions">
|
||||
<n-button
|
||||
v-if="index > 0"
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="moveUp(index)"
|
||||
>
|
||||
<FaIcon icon="fas fa-arrow-up" :size="12" />
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="index < modelValue.length - 1"
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="moveDown(index)"
|
||||
>
|
||||
<FaIcon icon="fas fa-arrow-down" :size="12" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
<FaIcon icon="fas fa-trash" :size="12" />
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-repeater__item-fields">
|
||||
<AksField
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:schema="field"
|
||||
:model-value="item[field.key]"
|
||||
@update:model-value="updateField(index, field.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="aksrt-repeater__empty">
|
||||
<FaIcon icon="fas fa-inbox" :size="20" />
|
||||
<span>{{ emptyText || '暂无数据' }}</span>
|
||||
</div>
|
||||
|
||||
<n-button
|
||||
size="small"
|
||||
dashed
|
||||
block
|
||||
class="aksrt-repeater__add"
|
||||
@click="addItem"
|
||||
>
|
||||
<FaIcon icon="fas fa-plus" :size="12" />
|
||||
{{ addText || '添加项目' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* AksRepeaterField — dynamic add/remove rows of structured data.
|
||||
* Each row renders fields based on the provided field schemas.
|
||||
*/
|
||||
import AksField from './AksField.vue'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
/** Array of field schemas for each row */
|
||||
fields: { type: Array, required: true },
|
||||
/** Default value for a new item */
|
||||
defaults: { type: Object, default: () => ({}) },
|
||||
emptyText: { type: String, default: '' },
|
||||
addText: { type: String, default: '' },
|
||||
maxItems: { type: Number, default: Infinity },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function createItem() {
|
||||
const item = {}
|
||||
for (const field of props.fields) {
|
||||
item[field.key] = field.default ?? (field.type === 'switch' ? false : '')
|
||||
}
|
||||
return { ...item, ...props.defaults }
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (props.modelValue.length >= props.maxItems) return
|
||||
const list = [...props.modelValue, createItem()]
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function removeItem(index) {
|
||||
const list = props.modelValue.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
const list = [...props.modelValue]
|
||||
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
if (index >= props.modelValue.length - 1) return
|
||||
const list = [...props.modelValue]
|
||||
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function updateField(index, key, value) {
|
||||
const list = [...props.modelValue]
|
||||
list[index] = { ...list[index], [key]: value }
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-repeater__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__item {
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.aksrt-repeater__item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__item-index {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
background: #e6f4ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__item-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-repeater__add {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<i
|
||||
:class="iconClass"
|
||||
:style="iconStyle"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* FaIcon — Font Awesome 5 icon wrapper component.
|
||||
*
|
||||
* Usage:
|
||||
* <FaIcon icon="fas fa-cog" />
|
||||
* <FaIcon icon="fa-cog" /> → defaults to "fas" prefix
|
||||
* <FaIcon icon="fab fa-github" />
|
||||
* <FaIcon :size="18" icon="far fa-heart" />
|
||||
* <FaIcon spin icon="fas fa-spinner" />
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** FA5 class string, e.g. "fas fa-cog", "fab fa-github", or just "fa-cog" */
|
||||
icon: { type: String, required: true },
|
||||
/** Pixel size — maps to font-size */
|
||||
size: { type: [Number, String], default: null },
|
||||
/** Fixed width icon */
|
||||
fw: { type: Boolean, default: false },
|
||||
/** Spin animation */
|
||||
spin: { type: Boolean, default: false },
|
||||
/** Pulse animation */
|
||||
pulse: { type: Boolean, default: false },
|
||||
/** Rotation (90, 180, 270) */
|
||||
rotate: { type: [Number, String], default: null },
|
||||
/** Flip direction ('horizontal', 'vertical', 'both') */
|
||||
flip: { type: String, default: null },
|
||||
/** Additional CSS classes */
|
||||
extraClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
const parts = []
|
||||
|
||||
// Auto-prefix: if only "fa-xxx" is given, default to "fas"
|
||||
const raw = props.icon.trim()
|
||||
if (raw.startsWith('fa-')) {
|
||||
parts.push('fas', raw)
|
||||
} else {
|
||||
parts.push(...raw.split(/\s+/))
|
||||
}
|
||||
|
||||
if (props.fw) parts.push('fa-fw')
|
||||
if (props.spin) parts.push('fa-spin')
|
||||
if (props.pulse) parts.push('fa-pulse')
|
||||
if (props.rotate) parts.push(`fa-rotate-${props.rotate}`)
|
||||
if (props.flip) parts.push(`fa-flip-${props.flip}`)
|
||||
if (props.extraClass) parts.push(props.extraClass)
|
||||
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const iconStyle = computed(() => {
|
||||
if (!props.size) return {}
|
||||
const s = typeof props.size === 'number' ? `${props.size}px` : props.size
|
||||
return { fontSize: s }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="aksrt-fa-icon-picker">
|
||||
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }">
|
||||
<template #trigger>
|
||||
<n-input
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
readonly
|
||||
clearable
|
||||
@clear="$emit('update:modelValue', '')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FaIcon v-if="modelValue" :icon="modelValue" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FaIcon icon="fas fa-chevron-down" :size="12" />
|
||||
</template>
|
||||
</n-input>
|
||||
</template>
|
||||
|
||||
<div class="aksrt-icon-picker__panel">
|
||||
<div class="aksrt-icon-picker__search">
|
||||
<n-input
|
||||
v-model:value="search"
|
||||
placeholder="搜索图标..."
|
||||
size="small"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<FaIcon icon="fas fa-search" :size="12" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-icon-picker__tabs">
|
||||
<n-radio-group v-model:value="activePrefix" size="small">
|
||||
<n-radio-button value="fas">Solid</n-radio-button>
|
||||
<n-radio-button value="far">Regular</n-radio-button>
|
||||
<n-radio-button value="fab">Brands</n-radio-button>
|
||||
</n-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-icon-picker__grid" ref="gridRef">
|
||||
<div
|
||||
v-for="icon in filteredIcons"
|
||||
:key="icon"
|
||||
class="aksrt-icon-picker__item"
|
||||
:class="{ 'is-active': modelValue === `${activePrefix} ${icon}` }"
|
||||
:title="icon"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<i :class="[activePrefix, icon]"></i>
|
||||
</div>
|
||||
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
|
||||
未找到匹配图标
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* FaIconPicker — searchable FA5 icon picker.
|
||||
* Renders an input that opens a popover grid of FA5 icons.
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '选择图标' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const search = ref('')
|
||||
const activePrefix = ref('fas')
|
||||
const gridRef = ref(null)
|
||||
|
||||
// Common FA5 icons subset — a curated list covering most admin UI needs.
|
||||
// Full FA5 has 1600+ icons; this subset has ~200 of the most useful ones.
|
||||
const SOLID_ICONS = [
|
||||
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-tools',
|
||||
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync', 'fa-redo', 'fa-undo',
|
||||
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
|
||||
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
|
||||
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user-shield', 'fa-user-circle',
|
||||
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-fingerprint',
|
||||
'fa-globe', 'fa-globe-americas', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
|
||||
'fa-image', 'fa-images', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
|
||||
'fa-folder', 'fa-folder-open', 'fa-download', 'upload', 'fa-cloud',
|
||||
'fa-database', 'fa-server', 'fa-hdd', 'fa-memory', 'fa-microchip',
|
||||
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
|
||||
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
|
||||
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard-list',
|
||||
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
|
||||
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
|
||||
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard-check',
|
||||
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-palette',
|
||||
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
|
||||
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
|
||||
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
|
||||
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
|
||||
'fa-calendar', 'fa-calendar-alt', 'fa-history', 'fa-hourglass',
|
||||
'fa-map-marker-alt', 'fa-map', 'fa-directions', 'fa-compass',
|
||||
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
|
||||
'fa-coins', 'fa-dollar-sign', 'fa-wallet', 'fa-receipt',
|
||||
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-megaphone',
|
||||
'fa-flag', 'fa-trophy', 'fa-award', 'fa-medal', 'fa-gift',
|
||||
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud-sun',
|
||||
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
|
||||
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
|
||||
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
|
||||
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
|
||||
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
|
||||
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-door-open',
|
||||
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
|
||||
]
|
||||
|
||||
const REGULAR_ICONS = [
|
||||
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
|
||||
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
|
||||
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
|
||||
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
|
||||
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
|
||||
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
|
||||
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
|
||||
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
|
||||
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
|
||||
'fa-address-book', 'fa-address-card', 'fa-id-badge',
|
||||
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
|
||||
]
|
||||
|
||||
const BRAND_ICONS = [
|
||||
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
|
||||
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-x-twitter',
|
||||
'fa-instagram', 'fa-linkedin', 'fa-linkedin-in', 'fa-youtube',
|
||||
'fa-weixin', 'fa-qq', 'fa-weibo', 'fa-telegram', 'fa-telegram-plane',
|
||||
'fa-discord', 'fa-slack', 'fa-whatsapp', 'fa-skype',
|
||||
'fa-apple', 'fa-windows', 'fa-linux', 'fa-android', 'fa-chrome',
|
||||
'fa-firefox', 'fa-edge', 'fa-safari', 'fa-opera',
|
||||
'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-js-square', 'fa-node',
|
||||
'fa-react', 'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python',
|
||||
'fa-java', 'fa-rust', 'fa-golang', 'fa-swift',
|
||||
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare',
|
||||
'fa-wordpress', 'fa-joomla', 'fa-drupal', 'fa-shopify',
|
||||
'fa-paypal', 'fa-stripe', 'fa-alipay', 'fa-weixin',
|
||||
'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
|
||||
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn',
|
||||
'fa-twitch', 'fa-spotify', 'fa-soundcloud', 'fa-music',
|
||||
'fa-bilibili',
|
||||
]
|
||||
|
||||
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
|
||||
|
||||
const filteredIcons = computed(() => {
|
||||
const set = iconSets[activePrefix.value] || SOLID_ICONS
|
||||
const q = search.value.toLowerCase().replace(/\s+/g, '')
|
||||
if (!q) return set
|
||||
return set.filter(i => i.includes(q))
|
||||
})
|
||||
|
||||
function selectIcon(icon) {
|
||||
emit('update:modelValue', `${activePrefix.value} ${icon}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-icon-picker__panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__tabs {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__item:hover {
|
||||
background: #f0f5ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__item.is-active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker__empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="aksrt-module-canvas">
|
||||
<div class="aksrt-module-canvas__toolbar">
|
||||
<n-space>
|
||||
<n-dropdown :options="addModuleOptions" trigger="click" @select="handleAddModule">
|
||||
<n-button type="primary" size="small">
|
||||
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
|
||||
添加模块
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button size="small" @click="$emit('pick-preset')">
|
||||
<template #icon><FaIcon icon="fas fa-magic" :size="12" /></template>
|
||||
应用预设
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('export')">
|
||||
<template #icon><FaIcon icon="fas fa-download" :size="12" /></template>
|
||||
导出
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('import')">
|
||||
<template #icon><FaIcon icon="fas fa-upload" :size="12" /></template>
|
||||
导入
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<div v-if="modules.length === 0" class="aksrt-module-canvas__empty">
|
||||
<FaIcon icon="fas fa-th-large" :size="32" color="#c0c4cc" />
|
||||
<p>暂无首页模块,点击"添加模块"或"应用预设"开始配置。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="aksrt-module-canvas__list">
|
||||
<div
|
||||
v-for="(mod, index) in modules"
|
||||
:key="mod.id || index"
|
||||
class="aksrt-module-canvas__item"
|
||||
:class="{
|
||||
'aksrt-module-canvas__item--selected': selectedIndex === index,
|
||||
'aksrt-module-canvas__item--disabled': !mod.enabled,
|
||||
'aksrt-module-canvas__item--dragging': dragIndex === index,
|
||||
'aksrt-module-canvas__item--drag-over': dragOverIndex === index && dragOverIndex !== dragIndex,
|
||||
}"
|
||||
:draggable="true"
|
||||
@click="$emit('select', index)"
|
||||
@dragstart="handleDragStart(index, $event)"
|
||||
@dragover.prevent="handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.prevent="handleDrop(index)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div class="aksrt-module-canvas__item-handle" title="拖拽排序">
|
||||
<FaIcon icon="fas fa-grip-vertical" :size="12" color="#c0c4cc" />
|
||||
</div>
|
||||
<div class="aksrt-module-canvas__item-icon">
|
||||
<FaIcon :icon="getModuleIcon(mod.type)" :size="14" />
|
||||
</div>
|
||||
<div class="aksrt-module-canvas__item-info">
|
||||
<span class="aksrt-module-canvas__item-label">{{ getModuleLabel(mod.type) }}</span>
|
||||
<span v-if="mod.title" class="aksrt-module-canvas__item-title">{{ mod.title }}</span>
|
||||
</div>
|
||||
<div class="aksrt-module-canvas__item-actions">
|
||||
<n-switch
|
||||
:value="mod.enabled !== false"
|
||||
size="small"
|
||||
@update:value="(val) => $emit('toggle', index, val)"
|
||||
@click.stop
|
||||
/>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
title="复制"
|
||||
@click.stop="$emit('copy', index)"
|
||||
>
|
||||
<FaIcon icon="fas fa-copy" :size="11" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
title="删除"
|
||||
@click.stop="$emit('remove', index)"
|
||||
>
|
||||
<FaIcon icon="fas fa-trash" :size="11" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="index === 0"
|
||||
title="上移"
|
||||
@click.stop="$emit('move', index, -1)"
|
||||
>
|
||||
<FaIcon icon="fas fa-chevron-up" :size="11" />
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="index === modules.length - 1"
|
||||
title="下移"
|
||||
@click.stop="$emit('move', index, 1)"
|
||||
>
|
||||
<FaIcon icon="fas fa-chevron-down" :size="11" />
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modules: { type: Array, default: () => [] },
|
||||
selectedIndex: { type: Number, default: -1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'add', 'remove', 'copy', 'toggle', 'move', 'pick-preset', 'export', 'import'])
|
||||
|
||||
// Drag-and-drop state
|
||||
const dragIndex = ref(-1)
|
||||
const dragOverIndex = ref(-1)
|
||||
|
||||
function handleDragStart(index, event) {
|
||||
dragIndex.value = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', String(index))
|
||||
}
|
||||
|
||||
function handleDragOver(index) {
|
||||
if (dragIndex.value !== index) {
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
function handleDrop(targetIndex) {
|
||||
if (dragIndex.value >= 0 && dragIndex.value !== targetIndex) {
|
||||
const offset = targetIndex - dragIndex.value
|
||||
emit('move', dragIndex.value, offset)
|
||||
}
|
||||
dragIndex.value = -1
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragIndex.value = -1
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
const addModuleOptions = computed(() =>
|
||||
MODULE_LIBRARY.map(m => ({
|
||||
label: m.label,
|
||||
key: m.type,
|
||||
icon: () => {
|
||||
const iconMap = {
|
||||
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
|
||||
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
|
||||
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
|
||||
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
|
||||
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
|
||||
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
|
||||
'shopping-bag': 'fas fa-shopping-bag',
|
||||
}
|
||||
return h('i', { class: iconMap[m.icon] || 'fas fa-cube', style: 'font-size:12px;margin-right:6px;' })
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
function getModuleIcon(type) {
|
||||
const meta = MODULE_LIBRARY.find(m => m.type === type)
|
||||
const iconMap = {
|
||||
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
|
||||
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
|
||||
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
|
||||
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
|
||||
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
|
||||
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
|
||||
'shopping-bag': 'fas fa-shopping-bag',
|
||||
}
|
||||
return iconMap[meta?.icon] || 'fas fa-cube'
|
||||
}
|
||||
|
||||
function getModuleLabel(type) {
|
||||
const meta = MODULE_LIBRARY.find(m => m.type === type)
|
||||
return meta?.label || type
|
||||
}
|
||||
|
||||
function handleAddModule(type) {
|
||||
emit('add', type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { h } from 'vue'
|
||||
export default {
|
||||
// h is used in addModuleOptions computed icon renderer
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-module-canvas__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
border: 2px dashed #e0e0e0;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item:hover {
|
||||
border-color: #d0d0d0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--dragging {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--drag-over {
|
||||
border-color: #1890ff;
|
||||
border-width: 2px;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-handle {
|
||||
cursor: grab;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-title {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-module-canvas__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<template>
|
||||
<div class="aksrt-module-inspector">
|
||||
<div v-if="!module" class="aksrt-module-inspector__empty">
|
||||
<FaIcon icon="fas fa-mouse-pointer" :size="24" color="#c0c4cc" />
|
||||
<p>选择左侧模块查看和编辑配置</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="aksrt-module-inspector__header">
|
||||
<FaIcon :icon="getModuleIcon(module.type)" :size="16" />
|
||||
<span class="aksrt-module-inspector__type">{{ getModuleLabel(module.type) }}</span>
|
||||
</div>
|
||||
|
||||
<n-scrollbar style="max-height: calc(100vh - 320px);">
|
||||
<n-form
|
||||
:model="localConfig"
|
||||
label-placement="top"
|
||||
size="small"
|
||||
class="aksrt-module-inspector__form"
|
||||
>
|
||||
<!-- Common fields -->
|
||||
<n-form-item label="模块标题">
|
||||
<n-input
|
||||
:value="module.title || ''"
|
||||
placeholder="留空使用默认标题"
|
||||
@update:value="(val) => updateField('title', val)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用模块">
|
||||
<n-switch
|
||||
:value="module.enabled !== false"
|
||||
@update:value="(val) => updateField('enabled', val)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<!-- Type-specific fields from schema -->
|
||||
<template v-if="metaFields.length > 0">
|
||||
<n-form-item
|
||||
v-for="field in metaFields"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
>
|
||||
<!-- Switch -->
|
||||
<n-switch
|
||||
v-if="field.type === 'switch'"
|
||||
:value="localConfig[field.key] ?? field.default ?? false"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Number -->
|
||||
<n-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="localConfig[field.key] ?? field.default ?? 0"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
style="width: 100%;"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Text -->
|
||||
<n-input
|
||||
v-else-if="field.type === 'text'"
|
||||
:value="localConfig[field.key] ?? field.default ?? ''"
|
||||
:placeholder="field.placeholder || ''"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Textarea -->
|
||||
<n-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
type="textarea"
|
||||
:value="localConfig[field.key] ?? field.default ?? ''"
|
||||
:rows="field.rows || 3"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Select -->
|
||||
<n-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="localConfig[field.key] ?? field.default"
|
||||
:options="field.options || []"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Radio -->
|
||||
<n-radio-group
|
||||
v-else-if="field.type === 'radio'"
|
||||
:value="localConfig[field.key] ?? field.default"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
>
|
||||
<n-space>
|
||||
<n-radio
|
||||
v-for="opt in field.options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
|
||||
<!-- Category (single) -->
|
||||
<n-select
|
||||
v-else-if="field.type === 'cat'"
|
||||
:value="localConfig[field.key] ?? field.default ?? 0"
|
||||
:options="categoryOptions"
|
||||
filterable
|
||||
placeholder="选择分类"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Categories (multiple) -->
|
||||
<n-select
|
||||
v-else-if="field.type === 'cats'"
|
||||
:value="localConfig[field.key] ?? field.default ?? []"
|
||||
:options="categoryOptions"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="选择分类"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
|
||||
<!-- Media Picker -->
|
||||
<div v-else-if="field.type === 'media'" class="aksrt-module-inspector__media">
|
||||
<div v-if="localConfig[field.key]" class="aksrt-module-inspector__media-preview">
|
||||
<img :src="localConfig[field.key]" alt="" />
|
||||
<n-button size="tiny" quaternary type="error" @click="updateConfig(field.key, '')">
|
||||
移除
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input
|
||||
v-else
|
||||
:value="localConfig[field.key] ?? ''"
|
||||
placeholder="输入图片 URL 或点击下方选择"
|
||||
@update:value="(val) => updateConfig(field.key, val)"
|
||||
/>
|
||||
<n-button size="tiny" quaternary @click="openMediaPicker(field.key)">
|
||||
<template #icon><FaIcon icon="fas fa-image" :size="12" /></template>
|
||||
从媒体库选择
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- Repeater -->
|
||||
<div v-else-if="field.type === 'repeater'" class="aksrt-module-inspector__repeater">
|
||||
<div
|
||||
v-for="(item, idx) in (localConfig[field.key] ?? [])"
|
||||
:key="idx"
|
||||
class="aksrt-module-inspector__repeater-item"
|
||||
>
|
||||
<div class="aksrt-module-inspector__repeater-fields">
|
||||
<n-input
|
||||
v-for="sub in (field.fields || [])"
|
||||
:key="sub.key"
|
||||
:value="item[sub.key] ?? ''"
|
||||
:placeholder="sub.placeholder || sub.label || ''"
|
||||
size="small"
|
||||
@update:value="(val) => updateRepeaterItem(field.key, idx, sub.key, val)"
|
||||
/>
|
||||
</div>
|
||||
<n-button size="tiny" quaternary type="error" @click="removeRepeaterItem(field.key, idx)">
|
||||
<FaIcon icon="fas fa-times" :size="10" />
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button size="tiny" quaternary @click="addRepeaterItem(field.key, field.fields)">
|
||||
<template #icon><FaIcon icon="fas fa-plus" :size="10" /></template>
|
||||
添加一项
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<n-text v-else depth="3" style="font-size: 12px;">
|
||||
此模块类型暂无可配置字段。
|
||||
</n-text>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<!-- Image URL input modal (replaces native prompt) -->
|
||||
<n-modal v-model:show="showImageUrlInput" preset="card" title="输入图片 URL" style="max-width: 400px;" :bordered="false">
|
||||
<n-input v-model:value="imageUrlValue" placeholder="https://example.com/image.jpg" @keyup.enter="confirmImageUrl" />
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showImageUrlInput = false">取消</n-button>
|
||||
<n-button type="primary" @click="confirmImageUrl">确定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
module: { type: Object, default: null },
|
||||
moduleIndex: { type: Number, default: -1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const meta = computed(() => {
|
||||
if (!props.module) return null
|
||||
return MODULE_LIBRARY.find(m => m.type === props.module.type) || null
|
||||
})
|
||||
|
||||
const metaFields = computed(() => {
|
||||
return meta.value?.fields || []
|
||||
})
|
||||
|
||||
const localConfig = computed(() => {
|
||||
return props.module?.config || {}
|
||||
})
|
||||
|
||||
function getModuleIcon(type) {
|
||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
||||
const iconMap = {
|
||||
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
|
||||
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
|
||||
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
|
||||
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
|
||||
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
|
||||
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
|
||||
'shopping-bag': 'fas fa-shopping-bag',
|
||||
}
|
||||
return iconMap[m?.icon] || 'fas fa-cube'
|
||||
}
|
||||
|
||||
function getModuleLabel(type) {
|
||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
||||
return m?.label || type
|
||||
}
|
||||
|
||||
function updateField(key, value) {
|
||||
emit('update', props.moduleIndex, key, value)
|
||||
}
|
||||
|
||||
function updateConfig(key, value) {
|
||||
const newConfig = { ...(props.module?.config || {}), [key]: value }
|
||||
emit('update', props.moduleIndex, 'config', newConfig)
|
||||
}
|
||||
|
||||
// Image URL input state (replaces native prompt)
|
||||
const showImageUrlInput = ref(false)
|
||||
const imageUrlFieldKey = ref('')
|
||||
const imageUrlValue = ref('')
|
||||
|
||||
function openMediaPicker(fieldKey) {
|
||||
if (!window.wp || !window.wp.media) {
|
||||
// Fallback: show inline input instead of native prompt
|
||||
imageUrlFieldKey.value = fieldKey
|
||||
imageUrlValue.value = localConfig.value[fieldKey] || ''
|
||||
showImageUrlInput.value = true
|
||||
return
|
||||
}
|
||||
const frame = wp.media({
|
||||
title: '选择图片',
|
||||
button: { text: '使用此图片' },
|
||||
multiple: false,
|
||||
library: { type: 'image' },
|
||||
})
|
||||
frame.on('select', function () {
|
||||
const attachment = frame.state().get('selection').first().toJSON()
|
||||
updateConfig(fieldKey, attachment.url)
|
||||
})
|
||||
frame.open()
|
||||
}
|
||||
|
||||
function confirmImageUrl() {
|
||||
if (imageUrlValue.value.trim()) {
|
||||
updateConfig(imageUrlFieldKey.value, imageUrlValue.value.trim())
|
||||
}
|
||||
showImageUrlInput.value = false
|
||||
}
|
||||
|
||||
function addRepeaterItem(fieldKey, subFields) {
|
||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
||||
const newItem = {}
|
||||
;(subFields || []).forEach(f => { newItem[f.key] = f.default ?? '' })
|
||||
current.push(newItem)
|
||||
updateConfig(fieldKey, current)
|
||||
}
|
||||
|
||||
function removeRepeaterItem(fieldKey, index) {
|
||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
||||
current.splice(index, 1)
|
||||
updateConfig(fieldKey, current)
|
||||
}
|
||||
|
||||
function updateRepeaterItem(fieldKey, index, subKey, value) {
|
||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
||||
if (current[index]) {
|
||||
current[index] = { ...current[index], [subKey]: value }
|
||||
updateConfig(fieldKey, current)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const res = await api.getHomepageCategories()
|
||||
const cats = res?.data || []
|
||||
categoryOptions.value = cats.map(c => ({
|
||||
label: `${c.name} (${c.count})`,
|
||||
value: c.term_id,
|
||||
}))
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
onMounted(loadCategories)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-module-inspector__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: #999;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__type {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__form {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__media {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__media-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__media-preview img {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__repeater {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__repeater-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.aksrt-module-inspector__repeater-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
title="应用预设模板"
|
||||
preset="dialog"
|
||||
style="width: 560px;"
|
||||
:show-icon="false"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="aksrt-preset-picker">
|
||||
<p class="aksrt-preset-picker__hint">
|
||||
选择一个预设模板快速配置首页模块。应用预设将替换当前所有模块。
|
||||
</p>
|
||||
|
||||
<div class="aksrt-preset-picker__list">
|
||||
<div
|
||||
v-for="preset in presets"
|
||||
:key="preset.key"
|
||||
class="aksrt-preset-picker__item"
|
||||
:class="{ 'aksrt-preset-picker__item--selected': selectedKey === preset.key }"
|
||||
@click="selectedKey = preset.key"
|
||||
>
|
||||
<div class="aksrt-preset-picker__item-header">
|
||||
<strong>{{ preset.label }}</strong>
|
||||
<n-tag size="small" type="info">{{ preset.modules.length }} 个模块</n-tag>
|
||||
</div>
|
||||
<p class="aksrt-preset-picker__item-desc">{{ preset.desc }}</p>
|
||||
<div class="aksrt-preset-picker__item-modules">
|
||||
<n-tag
|
||||
v-for="(mod, i) in preset.modules"
|
||||
:key="i"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="aksrt-preset-picker__module-tag"
|
||||
>
|
||||
{{ getModuleLabel(mod.type) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="$emit('close')">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!selectedKey"
|
||||
@click="handleApply"
|
||||
>
|
||||
应用预设
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { PRESET_TEMPLATES, MODULE_LIBRARY } from '@/utils/home-module-schema'
|
||||
|
||||
defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'apply'])
|
||||
const selectedKey = ref('')
|
||||
const presets = PRESET_TEMPLATES
|
||||
|
||||
function getModuleLabel(type) {
|
||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
||||
return m?.label || type
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const preset = presets.find(p => p.key === selectedKey.value)
|
||||
if (preset) {
|
||||
emit('apply', preset)
|
||||
selectedKey.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-preset-picker__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item {
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item:hover {
|
||||
border-color: #d0d0d0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item--selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-header strong {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-desc {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__item-modules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-preset-picker__module-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user