导入 AKSRT 主题开发源码
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* 404 template.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
get_header();
|
||||
|
||||
$other_page_settings = class_exists('AKSRT_Settings')
|
||||
? AKSRT_Settings::get_other_page_settings()
|
||||
: [
|
||||
'page_404_message' => __('页面未找到', 'aksrt'),
|
||||
'page_404_search' => true,
|
||||
'page_404_popular' => true,
|
||||
];
|
||||
$search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt');
|
||||
$hot_keywords = function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : [];
|
||||
|
||||
$hot_query = new WP_Query([
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => 5,
|
||||
'post_status' => 'publish',
|
||||
'orderby' => 'comment_count',
|
||||
'order' => 'DESC',
|
||||
'no_found_rows' => true,
|
||||
'ignore_sticky_posts' => true,
|
||||
]);
|
||||
|
||||
$top_cats = get_terms([
|
||||
'taxonomy' => 'category',
|
||||
'orderby' => 'count',
|
||||
'order' => 'DESC',
|
||||
'number' => 6,
|
||||
'hide_empty' => true,
|
||||
]);
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main content-shell content-shell--page" tabindex="-1">
|
||||
<section class="error-hero" aria-labelledby="error-title">
|
||||
<div class="term-page-header__body term-page-header__body--error">
|
||||
<div>
|
||||
<p class="error-hero__icon" aria-hidden="true">
|
||||
<i class="far fa-compass"></i>
|
||||
</p>
|
||||
<p class="error-hero__code" id="error-title">404</p>
|
||||
<h1 class="error-hero__title"><?php echo esc_html($other_page_settings['page_404_message']); ?></h1>
|
||||
<p class="error-hero__desc">
|
||||
<?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'aksrt'); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php if (function_exists('aksrt_render_admin_quick_link')) { aksrt_render_admin_quick_link('page-display/other', __('404 页设置', 'aksrt')); } ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($other_page_settings['page_404_search'])) : ?>
|
||||
<form role="search" method="get" class="error-hero__search" action="<?php echo esc_url(home_url('/')); ?>">
|
||||
<span class="error-hero__search-icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
</span>
|
||||
<label class="screen-reader-text" for="error-search-input"><?php esc_html_e('搜索', 'aksrt'); ?></label>
|
||||
<input type="search" id="error-search-input" name="s" placeholder="<?php echo esc_attr($search_placeholder); ?>" autocomplete="off">
|
||||
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'aksrt'); ?></button>
|
||||
</form>
|
||||
<?php aksrt_render_search_hot_keywords($hot_keywords, 'aksrt-search-hot-keywords'); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="error-hero__actions">
|
||||
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'aksrt'); ?></a>
|
||||
<a href="javascript:history.back()" class="btn-outline"><?php esc_html_e('返回上一页', 'aksrt'); ?></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (!empty($top_cats) && !is_wp_error($top_cats)) : ?>
|
||||
<section class="error-section" aria-label="<?php esc_attr_e('热门分类', 'aksrt'); ?>">
|
||||
<h2 class="error-section__title"><?php esc_html_e('热门分类', 'aksrt'); ?></h2>
|
||||
<div class="error-section__chips">
|
||||
<?php foreach ($top_cats as $cat) : ?>
|
||||
<a class="error-section__chip" href="<?php echo esc_url(get_category_link($cat->term_id)); ?>">
|
||||
<?php echo esc_html($cat->name); ?>
|
||||
<span><?php echo (int) $cat->count; ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($other_page_settings['page_404_popular']) && $hot_query->have_posts()) : ?>
|
||||
<section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'aksrt'); ?>">
|
||||
<h2 class="error-section__title"><?php esc_html_e('热门内容', 'aksrt'); ?></h2>
|
||||
<ul class="error-section__list">
|
||||
<?php while ($hot_query->have_posts()) : $hot_query->the_post(); ?>
|
||||
<li>
|
||||
<a href="<?php the_permalink(); ?>">
|
||||
<span class="error-section__list-title"><?php the_title(); ?></span>
|
||||
<span class="error-section__list-meta"><?php echo esc_html(get_the_date('Y-m-d')); ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endwhile; wp_reset_postdata(); ?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
/**
|
||||
* Resource Archive Template
|
||||
*
|
||||
* 资源归档页(/resources)。
|
||||
* v3.4 起:作为「资源虚拟归档」(post + _aksrt_is_resource) 的渲染模板,
|
||||
* 兼容历史 aksrt_resource CPT 归档。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
get_header();
|
||||
|
||||
$current_orderby = isset($_GET['orderby']) ? sanitize_key((string) $_GET['orderby']) : 'date';
|
||||
$allowed_orderby = ['date', 'downloads', 'views'];
|
||||
if (!in_array($current_orderby, $allowed_orderby, true)) $current_orderby = 'date';
|
||||
|
||||
$current_price = isset($_GET['price']) ? sanitize_key((string) $_GET['price']) : 'all';
|
||||
if (!in_array($current_price, ['all', 'free', 'paid'], true)) $current_price = 'all';
|
||||
|
||||
$price_min = isset($_GET['price_min']) && $_GET['price_min'] !== '' ? (float) $_GET['price_min'] : '';
|
||||
$price_max = isset($_GET['price_max']) && $_GET['price_max'] !== '' ? (float) $_GET['price_max'] : '';
|
||||
|
||||
$archive_url = function_exists('aksrt_resource_archive_url') ? aksrt_resource_archive_url() : home_url('/resources/');
|
||||
|
||||
$sort_options = [
|
||||
'date' => __('最新', 'aksrt'),
|
||||
'downloads' => __('热门下载', 'aksrt'),
|
||||
'views' => __('阅读最多', 'aksrt'),
|
||||
];
|
||||
|
||||
$price_options = [
|
||||
'all' => __('全部', 'aksrt'),
|
||||
'free' => __('免费', 'aksrt'),
|
||||
'paid' => __('付费', 'aksrt'),
|
||||
];
|
||||
|
||||
// 给筛选链接保留其它已有 query
|
||||
$preserve = [];
|
||||
if ($current_orderby !== 'date') $preserve['orderby'] = $current_orderby;
|
||||
if ($current_price !== 'all') $preserve['price'] = $current_price;
|
||||
if ($price_min !== '' && $price_min > 0) $preserve['price_min'] = $price_min;
|
||||
if ($price_max !== '') $preserve['price_max'] = $price_max;
|
||||
|
||||
$has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_min !== '' || $price_max !== '';
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main container" tabindex="-1">
|
||||
<header class="resource-archive-header page-cover">
|
||||
<div class="container">
|
||||
<?php if (function_exists('aksrt_breadcrumbs')) : ?>
|
||||
<nav class="resource-archive-header__breadcrumbs" aria-label="<?php esc_attr_e('面包屑', 'aksrt'); ?>">
|
||||
<?php aksrt_breadcrumbs(); ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1 class="resource-archive-header__title"><?php
|
||||
if (function_exists('post_type_exists') && post_type_exists('aksrt_resource')
|
||||
&& function_exists('is_post_type_archive') && is_post_type_archive('aksrt_resource')) {
|
||||
post_type_archive_title();
|
||||
} else {
|
||||
esc_html_e('资源', 'aksrt');
|
||||
}
|
||||
?></h1>
|
||||
<p class="resource-archive-header__sub">
|
||||
<?php
|
||||
global $wp_query;
|
||||
/* translators: %s: 资源总数 */
|
||||
printf(esc_html__('共 %s 个资源', 'aksrt'), esc_html(number_format_i18n((int) $wp_query->found_posts)));
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-wrap">
|
||||
<div class="content-layout content-layout--no-sidebar">
|
||||
<div class="content-layout__primary resource-archive">
|
||||
<nav class="resource-archive__toolbar" aria-label="<?php esc_attr_e('筛选', 'aksrt'); ?>">
|
||||
|
||||
<!-- 排序 -->
|
||||
<div class="resource-archive__filter-group">
|
||||
<span class="resource-archive__filter-label"><?php esc_html_e('排序', 'aksrt'); ?></span>
|
||||
<ul class="resource-archive__sort">
|
||||
<?php foreach ($sort_options as $key => $label) :
|
||||
$args = $preserve;
|
||||
if ($key === 'date') {
|
||||
unset($args['orderby']);
|
||||
} else {
|
||||
$args['orderby'] = $key;
|
||||
}
|
||||
$url = $args ? add_query_arg($args, $archive_url) : $archive_url;
|
||||
$active = $current_orderby === $key;
|
||||
?>
|
||||
<li>
|
||||
<a href="<?php echo esc_url($url); ?>"
|
||||
class="resource-archive__sort-link<?php echo $active ? ' is-active' : ''; ?>"
|
||||
<?php echo $active ? 'aria-current="true"' : ''; ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 付费状态 -->
|
||||
<div class="resource-archive__filter-group">
|
||||
<span class="resource-archive__filter-label"><?php esc_html_e('价格', 'aksrt'); ?></span>
|
||||
<ul class="resource-archive__sort">
|
||||
<?php foreach ($price_options as $key => $label) :
|
||||
$args = $preserve;
|
||||
if ($key === 'all') {
|
||||
unset($args['price']);
|
||||
} else {
|
||||
$args['price'] = $key;
|
||||
}
|
||||
// 切换付费状态时重置区间
|
||||
unset($args['price_min'], $args['price_max']);
|
||||
$url = $args ? add_query_arg($args, $archive_url) : $archive_url;
|
||||
$active = $current_price === $key;
|
||||
?>
|
||||
<li>
|
||||
<a href="<?php echo esc_url($url); ?>"
|
||||
class="resource-archive__sort-link<?php echo $active ? ' is-active' : ''; ?>"
|
||||
<?php echo $active ? 'aria-current="true"' : ''; ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 价格区间表单 -->
|
||||
<form class="resource-archive__price-range" method="get" action="<?php echo esc_url($archive_url); ?>">
|
||||
<?php
|
||||
// 保留 orderby / price
|
||||
if ($current_orderby !== 'date') {
|
||||
echo '<input type="hidden" name="orderby" value="' . esc_attr($current_orderby) . '">';
|
||||
}
|
||||
if ($current_price !== 'all') {
|
||||
echo '<input type="hidden" name="price" value="' . esc_attr($current_price) . '">';
|
||||
}
|
||||
?>
|
||||
<label class="resource-archive__price-input">
|
||||
<span><?php esc_html_e('最低', 'aksrt'); ?></span>
|
||||
<input type="number" name="price_min" min="0" step="1" value="<?php echo esc_attr((string) $price_min); ?>">
|
||||
</label>
|
||||
<span class="resource-archive__price-sep">—</span>
|
||||
<label class="resource-archive__price-input">
|
||||
<span><?php esc_html_e('最高', 'aksrt'); ?></span>
|
||||
<input type="number" name="price_max" min="0" step="1" value="<?php echo esc_attr((string) $price_max); ?>">
|
||||
</label>
|
||||
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('应用', 'aksrt'); ?></button>
|
||||
</form>
|
||||
|
||||
<?php if ($has_filter) : ?>
|
||||
<a class="resource-archive__reset" href="<?php echo esc_url($archive_url); ?>">
|
||||
<i class="fas fa-times-circle" aria-hidden="true"></i>
|
||||
<?php esc_html_e('清除筛选', 'aksrt'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
||||
<?php if (have_posts()) : ?>
|
||||
<div class="posts-row ajaxpager tab-pane">
|
||||
<?php
|
||||
set_query_var('aksrt_post_card_layout', 'card');
|
||||
while (have_posts()) :
|
||||
the_post();
|
||||
get_template_part('templates/parts/content', 'post-card');
|
||||
endwhile;
|
||||
set_query_var('aksrt_post_card_layout', '');
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php aksrt_render_pagination(); ?>
|
||||
<?php else : ?>
|
||||
<div class="no-results">
|
||||
<p><?php esc_html_e('暂无资源。', 'aksrt'); ?></p>
|
||||
<?php if ($has_filter) : ?>
|
||||
<p><a class="btn-outline btn-sm" href="<?php echo esc_url($archive_url); ?>"><?php esc_html_e('清除筛选条件', 'aksrt'); ?></a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* Generic archive template.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
get_header();
|
||||
|
||||
global $wp_query;
|
||||
|
||||
$list_settings = class_exists('AKSRT_Settings')
|
||||
? AKSRT_Settings::get_post_list_style_settings()
|
||||
: ['render_mode' => 'card', 'card_style' => 'standard'];
|
||||
$render_config = [
|
||||
'context' => 'archive',
|
||||
'mode' => (string) ($list_settings['render_mode'] ?? 'card'),
|
||||
'card_style' => (string) ($list_settings['card_style'] ?? 'standard'),
|
||||
];
|
||||
|
||||
// Per-category card mode override: if visiting a category in card_cat_ids, force card mode
|
||||
if (is_category() && !empty($list_settings['card_cat_ids'])) {
|
||||
$current_cat_id = (int) get_queried_object_id();
|
||||
if (in_array($current_cat_id, $list_settings['card_cat_ids'], true)) {
|
||||
$render_config['mode'] = 'card';
|
||||
}
|
||||
}
|
||||
|
||||
// Per-category multi-thumb override
|
||||
if (is_category() && class_exists('AKSRT_Settings')) {
|
||||
$thumb_settings = AKSRT_Settings::get_post_thumb_settings();
|
||||
if (!empty($thumb_settings['mult_thumb_cat_ids'])) {
|
||||
$current_cat_id = (int) get_queried_object_id();
|
||||
if (in_array($current_cat_id, $thumb_settings['mult_thumb_cat_ids'], true)) {
|
||||
$render_config['multi_thumb_enabled'] = true;
|
||||
$render_config['multi_thumb_limit'] = (int) ($thumb_settings['multi_count'] ?? 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$has_sidebar = function_exists('aksrt_is_show_sidebar') ? aksrt_is_show_sidebar() : false;
|
||||
$archive_description = trim(strip_tags((string) get_the_archive_description()));
|
||||
$archive_summary = '';
|
||||
|
||||
if (is_author()) {
|
||||
$author = get_queried_object();
|
||||
if ($author instanceof WP_User) {
|
||||
$archive_summary = sprintf(
|
||||
/* translators: %s: author display name */
|
||||
__('作者:%s', 'aksrt'),
|
||||
$author->display_name
|
||||
);
|
||||
}
|
||||
} elseif ($archive_description !== '') {
|
||||
$archive_summary = $archive_description;
|
||||
} elseif (isset($wp_query->found_posts)) {
|
||||
$archive_summary = sprintf(
|
||||
esc_html(_n('共 %d 篇内容', '共 %d 篇内容', (int) $wp_query->found_posts, 'aksrt')),
|
||||
(int) $wp_query->found_posts
|
||||
);
|
||||
}
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main content-shell" tabindex="-1">
|
||||
<div class="content-layout<?php echo $has_sidebar ? '' : ' content-layout--no-sidebar'; ?>">
|
||||
<div class="content-layout__primary">
|
||||
|
||||
<?php
|
||||
$admin_link = function_exists('aksrt_render_admin_quick_link') ? aksrt_render_admin_quick_link('post-list/list', __('列表样式设置', 'aksrt')) : '';
|
||||
set_query_var('aksrt_ph_eyebrow', __('内容归档', 'aksrt'));
|
||||
set_query_var('aksrt_ph_title', get_the_archive_title());
|
||||
set_query_var('aksrt_ph_desc', $archive_summary);
|
||||
set_query_var('aksrt_ph_extra', $admin_link);
|
||||
get_template_part('templates/parts/page-header');
|
||||
?>
|
||||
|
||||
<?php if (have_posts()) : ?>
|
||||
<?php aksrt_render_post_list(null, $render_config); ?>
|
||||
<?php aksrt_render_pagination(null, ['query_context' => 'archive', 'render_config' => $render_config]); ?>
|
||||
<?php else : ?>
|
||||
<div class="no-results">
|
||||
<p><?php esc_html_e('暂无文章', 'aksrt'); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($has_sidebar) : ?>
|
||||
<?php get_sidebar(); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* extract-naive-styles.mjs
|
||||
*
|
||||
* 在构建时直接调用 NaiveUI 组件的 .cssr.mjs 样式函数的 render() 方法,
|
||||
* 提取所有组件的运行时 JSS 为静态 CSS 文件。
|
||||
* 彻底解决 WordPress 后台环境中样式丢失问题。
|
||||
*
|
||||
* 原理:
|
||||
* 每个 NaiveUI 组件都有 src/styles/index.cssr.mjs 文件
|
||||
* 导出的函数返回 css-render 的 CNode 对象
|
||||
* 调用 CNode.render({ clsPrefix: 'n' }) 即可获取纯 CSS 字符串
|
||||
* 无需虚拟 DOM、无需 Vue、无任何浏览器 API 依赖
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const BUILD_DIR = join(__dirname, 'build')
|
||||
|
||||
// ============================================================
|
||||
// 需要提取样式的组件列表(对应各组件的 .cssr.mjs 入口)
|
||||
// 格式:[导入路径在 naive-ui/es 内部的相对路径]
|
||||
// ============================================================
|
||||
const COMPONENT_STYLES = [
|
||||
// 内部基础组件
|
||||
['_internal/scrollbar/src/styles/index.cssr.mjs'],
|
||||
// 布局
|
||||
['layout/src/styles/index.cssr.mjs'],
|
||||
['layout-sider/src/styles/index.cssr.mjs'],
|
||||
// 导航
|
||||
['menu/src/styles/index.cssr.mjs'],
|
||||
['breadcrumb/src/styles/index.cssr.mjs'],
|
||||
['tabs/src/styles/index.cssr.mjs'],
|
||||
['steps/src/styles/index.cssr.mjs'],
|
||||
['pagination/src/styles/index.cssr.mjs'],
|
||||
// 表单
|
||||
['form/src/styles/index.cssr.mjs'],
|
||||
['form-item/src/styles/index.cssr.mjs'],
|
||||
['input/src/styles/index.cssr.mjs'],
|
||||
['input-number/src/styles/index.cssr.mjs'],
|
||||
['switch/src/styles/index.cssr.mjs'],
|
||||
['select/src/styles/index.cssr.mjs'],
|
||||
['radio/src/styles/index.cssr.mjs'],
|
||||
['checkbox/src/styles/index.cssr.mjs'],
|
||||
['slider/src/styles/index.cssr.mjs'],
|
||||
['rate/src/styles/index.cssr.mjs'],
|
||||
['date-picker/src/styles/indexcode/index.cssr.mjs'],
|
||||
['time-picker/src/styles/index.cssr.mjs'],
|
||||
// 展示
|
||||
['card/src/styles/index.cssr.mjs'],
|
||||
['tag/src/styles/index.cssr.mjs'],
|
||||
['alert/src/styles/index.cssr.mjs'],
|
||||
['data-table/src/styles/index.cssr.mjs'],
|
||||
['descriptions/src/styles/index.cssr.mjs'],
|
||||
['empty/src/styles/index.cssr.mjs'],
|
||||
['result/src/styles/index.cssr.mjs'],
|
||||
['statistic/src/styles/index.cssr.mjs'],
|
||||
['list/src/styles/index.cssr.mjs'],
|
||||
['collapse/src/styles/index.cssr.mjs'],
|
||||
['timeline/src/styles/index.cssr.mjs'],
|
||||
// 反馈
|
||||
['button/src/styles/index.cssr.mjs'],
|
||||
['modal/src/styles/index.cssr.mjs'],
|
||||
['dialog/src/styles/index.cssr.mjs'],
|
||||
['message/src/styles/index.cssr.mjs'],
|
||||
['notification/src/styles/index.cssr.mjs'],
|
||||
['popconfirm/src/styles/index.cssr.mjs'],
|
||||
['popover/src/styles/index.cssr.mjs'],
|
||||
['tooltip/src/styles/index.cssr.mjs'],
|
||||
['dropdown/src/styles/index.cssr.mjs'],
|
||||
// 其他
|
||||
['icon/src/styles/index.cssr.mjs'],
|
||||
['icon-wrapper/src/styles/index.cssr.mjs'],
|
||||
['space/src/styles/index.cssr.mjs'],
|
||||
['divider/src/styles/index.cssr.mjs'],
|
||||
['gradient-text/src/styles/index.cssr.mjs'],
|
||||
['ellipsis/src/styles/index.cssr.mjs'],
|
||||
['spin/src/styles/index.cssr.mjs'],
|
||||
['loading-bar/src/styles/index.cssr.mjs'],
|
||||
['upload/src/styles/index.cssr.mjs'],
|
||||
['image/src/styles/index.cssr.mjs'],
|
||||
['carousel/src/styles/index.cssr.mjs'],
|
||||
['transfer/src/styles/index.cssr.mjs'],
|
||||
['tree-select/src/styles/index.cssr.mjs'],
|
||||
['tree/src/styles/index.cssr.mjs'],
|
||||
['thing/src/styles/index.cssr.mjs'],
|
||||
['color-picker/src/styles/indexcode/index.cssr.mjs'],
|
||||
['typography/src/styles/index.cssr.mjs'],
|
||||
['auto-complete/src/styles/index.cssr.mjs'],
|
||||
['cascader/src/styles/index.cssr.mjs'],
|
||||
['dynamic-input/src/styles/index.cssr.mjs'],
|
||||
['dynamic-tags/src/styles/index.cssr.mjs'],
|
||||
['input-otp/src/styles/indexcode/index.cssr.mjs'],
|
||||
// 过渡动画
|
||||
['_styles/transitions/fade-in-width-expand.cssr.mjs'],
|
||||
['_styles/transitions/fade-in-height-expand.cssr.mjs'],
|
||||
['_styles/transitions/icon-switch.cssr.mjs'],
|
||||
['_styles/transitions/scale-transition.cssr.mjs'],
|
||||
// 全局
|
||||
['global-style/src/styles/index.cssr.mjs'],
|
||||
]
|
||||
|
||||
async function extractStyles() {
|
||||
console.log('\n[AKSRT] 开始提取 NaiveUI 组件样式...\n')
|
||||
|
||||
const allCss = []
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const [componentPath] of COMPONENT_STYLES) {
|
||||
try {
|
||||
// 动态导入每个组件的 cssr 样式模块
|
||||
const styleModule = await import(
|
||||
`naive-ui/es/${componentPath}`
|
||||
)
|
||||
|
||||
const defaultExport = styleModule.default || styleModule
|
||||
|
||||
if (typeof defaultExport === 'function') {
|
||||
// 直接调用 render() 提取 CSS
|
||||
const css = defaultExport({
|
||||
clsPrefix: 'n',
|
||||
// 使用 light 主题变量
|
||||
})
|
||||
|
||||
if (typeof css === 'string' && css.trim()) {
|
||||
allCss.push(`/* ${componentPath} */\n${css}`)
|
||||
successCount++
|
||||
}
|
||||
} else if (defaultExport && typeof defaultExport.render === 'function') {
|
||||
// 如果是 CNode 对象,调用 render()
|
||||
const css = defaultExport.render()
|
||||
if (typeof css === 'string' && css.trim()) {
|
||||
allCss.push(`/* ${componentPath} */\n${css}`)
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 某些路径可能不存在或结构不同,跳过但不中断
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
const cssOutput = allCss.join('\n\n')
|
||||
|
||||
if (!cssOutput.trim()) {
|
||||
console.warn('[AKSRT] 警告:未提取到任何 NaiveUI 样式!')
|
||||
return null
|
||||
}
|
||||
|
||||
// 确保 build 目录存在
|
||||
await mkdir(BUILD_DIR, { recursive: true })
|
||||
|
||||
// 写入 CSS 文件
|
||||
const cssFile = join(BUILD_DIR, 'naive-ui.css')
|
||||
const fullContent = `/* Auto-extracted by extract-naive-styles.mjs */
|
||||
/* Naive UI v2 Component Styles - Build-time extraction */
|
||||
/* DO NOT EDIT MANUALLY */
|
||||
|
||||
${cssOutput}`
|
||||
|
||||
await writeFile(cssFile, fullContent, 'utf-8')
|
||||
|
||||
console.log(`[AKSRT] NaiveUI 样式提取完成:`)
|
||||
console.log(` 成功: ${successCount} 个组件`)
|
||||
console.log(` 跳过: ${failCount} 个组件`)
|
||||
console.log(` 输出: ${cssFile}`)
|
||||
console.log(` 大小: ${(Buffer.byteLength(fullContent) / 1024).toFixed(1)} KB\n`)
|
||||
|
||||
return cssFile
|
||||
}
|
||||
|
||||
// 执行
|
||||
extractStyles().catch((err) => {
|
||||
console.error('[AKSRT] 样式提取失败:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AKSRT 主题设置</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="aksrt-admin-app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1853
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","vite":"^6.0.0"}}
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<n-config-provider :style-mount-target="styleMountTarget">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-layout v-if="!isMobile" class="aksrt-admin-layout" has-sider>
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="240"
|
||||
show-collapse-trigger
|
||||
:collapsed="collapsed"
|
||||
:native-scrollbar="false"
|
||||
@collapse="collapsed = true"
|
||||
@expand="collapsed = false"
|
||||
>
|
||||
<div class="aksrt-logo">
|
||||
<span class="logo-icon">AK</span>
|
||||
<span v-if="!collapsed" class="logo-text">AKSRT</span>
|
||||
</div>
|
||||
|
||||
<n-menu
|
||||
:value="activeMenuKey"
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="64"
|
||||
:options="menuOptions"
|
||||
@update:value="handleMenuClick"
|
||||
/>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout :native-scrollbar="false" class="aksrt-admin-content">
|
||||
<div class="aksrt-admin-content__inner">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div class="aksrt-page-wrapper" :key="route.fullPath">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
|
||||
<div v-if="pageError" class="aksrt-error-fallback">
|
||||
<p class="aksrt-error-title">页面渲染异常</p>
|
||||
<p class="aksrt-error-msg">{{ pageError }}</p>
|
||||
<n-button size="small" @click="pageError = null">重试</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
|
||||
<div v-else class="aksrt-admin-mobile">
|
||||
<MobileTopBar :title="currentPageTitle" @toggle="drawerActive = true" />
|
||||
|
||||
<n-drawer v-model:show="drawerActive" placement="left" :width="280">
|
||||
<n-drawer-content :native-scrollbar="false" body-content-style="padding:0;">
|
||||
<div class="aksrt-logo aksrt-logo--drawer">
|
||||
<span class="logo-icon">AK</span>
|
||||
<span class="logo-text">AKSRT</span>
|
||||
</div>
|
||||
<n-menu
|
||||
:value="activeMenuKey"
|
||||
:options="menuOptions"
|
||||
@update:value="handleMenuClick"
|
||||
/>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<div class="aksrt-admin-mobile__body">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div class="aksrt-page-wrapper" :key="route.fullPath">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
|
||||
<div v-if="pageError" class="aksrt-error-fallback">
|
||||
<p class="aksrt-error-title">页面渲染异常</p>
|
||||
<p class="aksrt-error-msg">{{ pageError }}</p>
|
||||
<n-button size="small" @click="pageError = null">重试</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onErrorCaptured, onMounted, provide, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NConfigProvider } from 'naive-ui'
|
||||
import MobileTopBar from './components/MobileTopBar.vue'
|
||||
import { styleMountTarget } from './style-target'
|
||||
import { ADMIN_SECTIONS } from './constants/admin-sections'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const collapsed = ref(false)
|
||||
const pageError = ref(null)
|
||||
const isMobile = ref(false)
|
||||
const drawerActive = ref(false)
|
||||
|
||||
function checkMobile() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
provide('collapsed', collapsed)
|
||||
|
||||
onErrorCaptured((err) => {
|
||||
console.error('[AKSRT] 组件渲染错误:', err)
|
||||
pageError.value = err?.message || String(err)
|
||||
return false
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build key → path lookup from ADMIN_SECTIONS */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const keyToPath = {}
|
||||
for (const section of ADMIN_SECTIONS) {
|
||||
if (section.children) {
|
||||
for (const child of section.children) {
|
||||
keyToPath[child.key] = child.route
|
||||
}
|
||||
}
|
||||
// For placeholder sections (no children), the section key maps to its route
|
||||
if (section.placeholder) {
|
||||
keyToPath[section.key] = section.route
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build NaiveUI menu options from ADMIN_SECTIONS */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const menuOptions = ADMIN_SECTIONS.filter((section) => !section.hidden).map((section) => {
|
||||
if (section.placeholder) {
|
||||
// Placeholder sections have no children — render as a leaf menu item
|
||||
return {
|
||||
label: section.label,
|
||||
key: section.key,
|
||||
icon: section.icon,
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: section.label,
|
||||
key: section.key,
|
||||
icon: section.icon,
|
||||
children: section.children.map((child) => ({
|
||||
label: child.label,
|
||||
key: child.key,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Active menu key resolution */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const activeMenuKey = computed(() => {
|
||||
const path = route.path
|
||||
|
||||
// Find the child whose route matches the current path
|
||||
for (const section of ADMIN_SECTIONS) {
|
||||
if (section.children) {
|
||||
for (const child of section.children) {
|
||||
if (path === child.route || path.startsWith(child.route + '/')) {
|
||||
return child.key
|
||||
}
|
||||
}
|
||||
}
|
||||
// Placeholder section (no children)
|
||||
if (section.placeholder && (path === section.route || path.startsWith(section.route + '/'))) {
|
||||
return section.key
|
||||
}
|
||||
}
|
||||
|
||||
// Default: first section's first child
|
||||
return ADMIN_SECTIONS[0]?.children?.[0]?.key || 'global-common'
|
||||
})
|
||||
|
||||
const currentPageTitle = computed(() => route.meta?.title || 'AKSRT')
|
||||
|
||||
function handleMenuClick(key) {
|
||||
if (keyToPath[key]) {
|
||||
router.push(keyToPath[key])
|
||||
if (isMobile.value) {
|
||||
drawerActive.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||
}
|
||||
|
||||
.aksrt-logo--drawer {
|
||||
justify-content: flex-start;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #18a058;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.aksrt-admin-content__inner {
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.aksrt-admin-mobile {
|
||||
background: #f5f7fb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.aksrt-admin-mobile__body {
|
||||
padding: 14px 12px 32px;
|
||||
}
|
||||
|
||||
.aksrt-page-wrapper {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.aksrt-error-fallback {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aksrt-error-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.aksrt-error-msg {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* AKSRT Admin API Client
|
||||
*
|
||||
* Communicates with the WordPress REST API at /wp-json/aksrt/v1.
|
||||
* Methods are organized into domain modules while the client keeps the shared
|
||||
* request pipeline and schema-domain normalization contract.
|
||||
*/
|
||||
|
||||
import { apiMethods } from './modules/index.js'
|
||||
|
||||
const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1'
|
||||
const NONCE = window.aksrtAdminData?.nonce || ''
|
||||
|
||||
function normalizeDomainKey(domain) {
|
||||
return String(domain || 'global').trim().replace(/-/g, '_')
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = API_ROOT
|
||||
}
|
||||
|
||||
normalizeDomainKey(domain) {
|
||||
return normalizeDomainKey(domain)
|
||||
}
|
||||
|
||||
async request(endpointOrConfig, options = {}) {
|
||||
// Dual-signature support:
|
||||
// request('/forum/overview')
|
||||
// request({ url: '/aksrt/v1/forum/topics', method: 'GET', params: { limit: 20 } })
|
||||
// request({ url: '/aksrt/v1/verify/review', method: 'POST', data: { user_id: 1 } })
|
||||
let endpoint, method, headers, params, body, rest
|
||||
|
||||
if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) {
|
||||
const cfg = endpointOrConfig
|
||||
endpoint = cfg.url || cfg.endpoint || ''
|
||||
method = cfg.method || 'GET'
|
||||
params = cfg.params
|
||||
body = cfg.data
|
||||
headers = cfg.headers
|
||||
rest = { ...cfg }
|
||||
delete rest.url; delete rest.endpoint; delete rest.method
|
||||
delete rest.params; delete rest.data; delete rest.headers
|
||||
} else {
|
||||
endpoint = endpointOrConfig
|
||||
method = options.method || 'GET'
|
||||
params = options.params
|
||||
body = options.body !== undefined ? options.body : options.data
|
||||
headers = options.headers
|
||||
rest = { ...options }
|
||||
delete rest.method; delete rest.params; delete rest.body
|
||||
delete rest.data; delete rest.headers
|
||||
}
|
||||
|
||||
if (endpoint.startsWith('/wp-json/aksrt/v1')) {
|
||||
endpoint = endpoint.replace('/wp-json/aksrt/v1', '') || '/'
|
||||
} else if (endpoint.startsWith('/aksrt/v1')) {
|
||||
endpoint = endpoint.replace('/aksrt/v1', '') || '/'
|
||||
}
|
||||
|
||||
let url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
if (params && typeof params === 'object') {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
if (qs) url += (url.includes('?') ? '&' : '?') + qs
|
||||
}
|
||||
|
||||
let finalBody = body
|
||||
if (body !== undefined && typeof body === 'object' && !(body instanceof FormData)) {
|
||||
finalBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const config = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
...headers,
|
||||
},
|
||||
...rest,
|
||||
}
|
||||
if (finalBody !== undefined && method !== 'GET') {
|
||||
config.body = finalBody
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config)
|
||||
|
||||
const text = await response.text()
|
||||
let data = {}
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`REST 返回非 JSON,endpoint=${endpoint},status=${response.status},body=${text.slice(0, 160)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok || data.success === false) {
|
||||
const msg = data.message || data.data?.message || `HTTP ${response.status}`
|
||||
|
||||
if (response.status === 403 || response.status === 401) {
|
||||
console.warn(`[API Auth] ${endpoint}: ${msg} — 可能需要刷新页面重新登录`)
|
||||
}
|
||||
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(`[API Error] ${endpoint}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(ApiClient.prototype, apiMethods)
|
||||
|
||||
export { normalizeDomainKey }
|
||||
export default new ApiClient()
|
||||
@@ -0,0 +1,8 @@
|
||||
export const extensionsApiMethods = {
|
||||
/* ================================================================
|
||||
* 扩展&增强 (Extensions)
|
||||
* ================================================================ */
|
||||
|
||||
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', body: JSON.stringify({ ip }) }) },
|
||||
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', body: JSON.stringify({ text }) }) },
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const globalApiMethods = {
|
||||
/* ================================================================
|
||||
* 全局&功能 (Global & Functions)
|
||||
* ================================================================ */
|
||||
|
||||
getMailSettings() { return this.request('/settings/mail') },
|
||||
saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
|
||||
|
||||
getFooterSettings() { return this.request('/settings/footer') },
|
||||
saveFooterSettings(data) { return this.request('/settings/footer', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
|
||||
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
|
||||
saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
||||
|
||||
getFloatingButtons() { return this.request('/settings/floating-buttons') },
|
||||
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
||||
|
||||
getFriendLinks() { return this.request('/friend-links') },
|
||||
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
deleteFriendLink(id) { return this.request(`/friend-links/${id}`, { method: 'DELETE' }) },
|
||||
reorderFriendLinks(ids) { return this.request('/friend-links/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
||||
|
||||
getCmsFeatures() { return this.request('/settings/cms-features') },
|
||||
saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { globalApiMethods } from './global.js'
|
||||
import { pageDisplayApiMethods } from './page-display.js'
|
||||
import { permissionsApiMethods } from './permissions.js'
|
||||
import { usersApiMethods } from './users.js'
|
||||
import { paymentApiMethods } from './payment.js'
|
||||
import { extensionsApiMethods } from './extensions.js'
|
||||
import { toolsApiMethods } from './tools.js'
|
||||
import { schemaApiMethods } from './schema.js'
|
||||
|
||||
export const apiMethods = {
|
||||
...globalApiMethods,
|
||||
...pageDisplayApiMethods,
|
||||
...permissionsApiMethods,
|
||||
...usersApiMethods,
|
||||
...paymentApiMethods,
|
||||
...extensionsApiMethods,
|
||||
...toolsApiMethods,
|
||||
...schemaApiMethods,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const pageDisplayApiMethods = {
|
||||
/* ================================================================
|
||||
* 页面&显示 (Page Display)
|
||||
* ================================================================ */
|
||||
|
||||
getHomepageSettings() { return this.request('/settings/homepage') },
|
||||
saveHomepageSettings(data) { return this.request('/settings/homepage', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
getHomepageCategories() { return this.request('/homepage/categories') },
|
||||
|
||||
getCategoryRecommendSettings() { return this.request('/settings/category-recommend') },
|
||||
saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
getCategories() { return this.request('/categories') },
|
||||
updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) },
|
||||
searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) },
|
||||
|
||||
getBanners(kind = 'hero') { return this.request(`/banners?kind=${encodeURIComponent(kind)}`) },
|
||||
createBanner(data) { return this.request('/banners', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
updateBanner(id, data) { return this.request(`/banners/${id}`, { method: 'PUT', body: JSON.stringify(data) }) },
|
||||
deleteBanner(id) { return this.request(`/banners/${id}`, { method: 'DELETE' }) },
|
||||
|
||||
getHeroes() { return this.getBanners('hero') },
|
||||
createHero(data) { return this.createBanner({ ...data, kind: 'hero' }) },
|
||||
updateHero(id, data) { return this.updateBanner(id, { ...data, kind: 'hero' }) },
|
||||
deleteHero(id) { return this.deleteBanner(id) },
|
||||
|
||||
getHomepageBanners() { return this.getBanners('banner') },
|
||||
createHomepageBanner(data) { return this.createBanner({ ...data, kind: 'banner' }) },
|
||||
updateHomepageBanner(id, data) { return this.updateBanner(id, { ...data, kind: 'banner' }) },
|
||||
deleteHomepageBanner(id) { return this.deleteBanner(id) },
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const paymentApiMethods = {
|
||||
/* ================================================================
|
||||
* 支付&付费 (Payment)
|
||||
* ================================================================ */
|
||||
|
||||
getPaymentOverview() { return this.request('/payment/overview') },
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const permissionsApiMethods = {
|
||||
/* ================================================================
|
||||
* 功能&权限 (Permissions)
|
||||
* ================================================================ */
|
||||
|
||||
getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') },
|
||||
savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) },
|
||||
|
||||
getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') },
|
||||
savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) },
|
||||
|
||||
getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') },
|
||||
savePermissionsModerationSettings(data) { return this.saveSchemaPage('permissions', 'moderation', data) },
|
||||
|
||||
getCapabilities() { return this.request('/permissions/capabilities') },
|
||||
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const schemaApiMethods = {
|
||||
getSchema(domain) { return this.request(`/settings/schema/${domain}`) },
|
||||
getSchemaPage(domain, section) {
|
||||
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`)
|
||||
},
|
||||
saveSchemaPage(domain, section, data, version = '1.0.0') {
|
||||
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain: this.normalizeDomainKey(domain),
|
||||
section,
|
||||
data,
|
||||
version,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const toolsApiMethods = {
|
||||
/* ================================================================
|
||||
* 扩展&增强 / 备份&导入 (Extensions & Tools)
|
||||
* ================================================================ */
|
||||
|
||||
getHealthEmailVerification() { return this.request('/health/email-verification') },
|
||||
getUpdateInfo() { return this.request('/tools/update-info') },
|
||||
detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) },
|
||||
dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) },
|
||||
|
||||
getEmailRollbackUsers() { return this.request('/email-rollback/users') },
|
||||
executeEmailRollback(id) { return this.request(`/email-rollback/users/${id}/rollback`, { method: 'POST' }) },
|
||||
|
||||
/* ================================================================
|
||||
* Settings Management (Import / Export / Reset / Health / Schema)
|
||||
* ================================================================ */
|
||||
|
||||
exportSettings() { return this.request('/settings/export') },
|
||||
importSettings(data) { return this.request('/settings/import', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
listSettingBackups() { return this.request('/settings/backups') },
|
||||
createSettingBackup(data = {}) { return this.request('/settings/backups', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
restoreSettingBackup(id) { return this.request(`/settings/backups/${id}/restore`, { method: 'POST' }) },
|
||||
deleteSettingBackup(id) { return this.request(`/settings/backups/${id}`, { method: 'DELETE' }) },
|
||||
resetDomain(domain) { return this.request(`/settings/reset/${domain}`, { method: 'POST' }) },
|
||||
resetAllSettings() { return this.request('/settings/reset-all', { method: 'POST' }) },
|
||||
getHealthCheck() { return this.request('/settings/health') },
|
||||
getMigrationStatus() { return this.request('/settings/migration-status') },
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const usersApiMethods = {
|
||||
/* ================================================================
|
||||
* 用户&互动 (User & Interaction)
|
||||
* ================================================================ */
|
||||
|
||||
getAuthSettings() { return this.request('/settings/auth') },
|
||||
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
|
||||
getOAuthSettings() { return this.request('/settings/oauth') },
|
||||
saveOAuthSettings(data) { return this.request('/settings/oauth', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
|
||||
getNotificationSettings() { return this.request('/settings/notifications') },
|
||||
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
getNotificationOverview(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return this.request('/notifications/overview' + (qs ? `?${qs}` : ''))
|
||||
},
|
||||
markNotificationRead(id) { return this.request('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ id }) }) },
|
||||
markAllNotificationsRead() { return this.request('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ mark_all: true }) }) },
|
||||
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
|
||||
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
|
||||
|
||||
getCreditSettings() { return this.request('/credits/settings') },
|
||||
saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
listCreditUsers(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return this.request('/credits/users' + (qs ? `?${qs}` : ''))
|
||||
},
|
||||
adjustUserCredits(id, delta, reason) {
|
||||
return this.request(`/credits/users/${id}/adjust`, { method: 'POST', body: JSON.stringify({ delta, reason }) })
|
||||
},
|
||||
getCreditLog(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return this.request('/credits/log' + (qs ? `?${qs}` : ''))
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<div class="aksrt-field" :class="fieldClasses">
|
||||
<div class="aksrt-field__label-row">
|
||||
<label class="aksrt-field__label">
|
||||
{{ schema.label }}
|
||||
<span v-if="schema.required" class="aksrt-field__required">*</span>
|
||||
</label>
|
||||
<n-tooltip v-if="schema.help" trigger="hover">
|
||||
<template #trigger>
|
||||
<FaIcon icon="fas fa-question-circle" :size="13" class="aksrt-field__help-icon" />
|
||||
</template>
|
||||
{{ schema.help }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<p v-if="schema.description && !schema.help" class="aksrt-field__desc">
|
||||
{{ schema.description }}
|
||||
</p>
|
||||
|
||||
<!-- Switch -->
|
||||
<n-switch
|
||||
v-if="schema.type === 'switch'"
|
||||
:value="modelValue"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Input (text, email, url, password) -->
|
||||
<n-input
|
||||
v-else-if="schema.type === 'input' || schema.type === 'text'"
|
||||
:value="modelValue"
|
||||
:placeholder="schema.placeholder || ''"
|
||||
:type="schema.inputType || 'text'"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template v-if="schema.prefix" #prefix>{{ schema.prefix }}</template>
|
||||
<template v-if="schema.suffix" #suffix>{{ schema.suffix }}</template>
|
||||
</n-input>
|
||||
|
||||
<!-- Textarea -->
|
||||
<n-input
|
||||
v-else-if="schema.type === 'textarea'"
|
||||
:value="modelValue"
|
||||
type="textarea"
|
||||
:placeholder="schema.placeholder || ''"
|
||||
:rows="schema.rows || 4"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Number -->
|
||||
<n-input-number
|
||||
v-else-if="schema.type === 'number'"
|
||||
:value="modelValue"
|
||||
:min="schema.min"
|
||||
:max="schema.max"
|
||||
:step="schema.step || 1"
|
||||
:placeholder="schema.placeholder || ''"
|
||||
:disabled="schema.disabled"
|
||||
style="width: 100%"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Select -->
|
||||
<n-select
|
||||
v-else-if="schema.type === 'select'"
|
||||
:value="modelValue"
|
||||
:options="normalizedOptions"
|
||||
:placeholder="schema.placeholder || '请选择'"
|
||||
:multiple="schema.multiple"
|
||||
:clearable="schema.clearable !== false"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Taxonomy Select (async-loaded category/tag terms) -->
|
||||
<n-select
|
||||
v-else-if="schema.type === 'taxonomy_select'"
|
||||
:value="modelValue"
|
||||
:options="taxonomyOptions"
|
||||
:loading="taxonomyLoading"
|
||||
:placeholder="schema.placeholder || '请选择分类'"
|
||||
:multiple="schema.multiple !== false"
|
||||
:filterable="true"
|
||||
:clearable="schema.clearable !== false"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- User Select (async search) -->
|
||||
<n-select
|
||||
v-else-if="schema.type === 'user_select'"
|
||||
:value="modelValue"
|
||||
:options="userOptions"
|
||||
:loading="userLoading"
|
||||
:placeholder="schema.placeholder || '搜索用户'"
|
||||
:multiple="schema.multiple === true"
|
||||
:filterable="true"
|
||||
:remote="true"
|
||||
:clearable="schema.clearable !== false"
|
||||
:disabled="schema.disabled"
|
||||
@search="handleUserSearch"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Radio group -->
|
||||
<n-radio-group
|
||||
v-else-if="schema.type === 'radio'"
|
||||
:value="modelValue"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<n-space v-if="schema.buttonStyle === false">
|
||||
<n-radio
|
||||
v-for="opt in normalizedOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:disabled="schema.disabled"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
<n-radio-button
|
||||
v-else
|
||||
v-for="opt in normalizedOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:disabled="schema.disabled"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
<!-- Checkbox group -->
|
||||
<n-checkbox-group
|
||||
v-else-if="schema.type === 'checkbox'"
|
||||
:value="modelValue || []"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="opt in normalizedOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:disabled="schema.disabled"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
|
||||
<!-- Color picker -->
|
||||
<n-color-picker
|
||||
v-else-if="schema.type === 'color'"
|
||||
:value="modelValue"
|
||||
:modes="['hex']"
|
||||
:show-alpha="schema.showAlpha || false"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Icon picker -->
|
||||
<FaIconPicker
|
||||
v-else-if="schema.type === 'icon'"
|
||||
:model-value="modelValue"
|
||||
:placeholder="schema.placeholder || '选择图标'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Media upload -->
|
||||
<AksMediaField
|
||||
v-else-if="schema.type === 'media'"
|
||||
:model-value="modelValue"
|
||||
:accept="schema.accept"
|
||||
:placeholder="schema.placeholder || '选择图片'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Slider -->
|
||||
<n-slider
|
||||
v-else-if="schema.type === 'slider'"
|
||||
:value="modelValue"
|
||||
:min="schema.min || 0"
|
||||
:max="schema.max || 100"
|
||||
:step="schema.step || 1"
|
||||
:disabled="schema.disabled"
|
||||
:marks="schema.marks"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Code editor with syntax highlighting (CodeMirror 6) -->
|
||||
<AksCodeEditor
|
||||
v-else-if="schema.type === 'code'"
|
||||
:model-value="modelValue"
|
||||
:language="schema.language || 'html'"
|
||||
:disabled="schema.disabled"
|
||||
:rows="schema.rows || 12"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Alert/info content -->
|
||||
<n-alert
|
||||
v-else-if="schema.type === 'alert' || schema.type === 'notice'"
|
||||
:type="schema.alertType || 'info'"
|
||||
:title="schema.alertTitle"
|
||||
:closable="false"
|
||||
>
|
||||
{{ schema.alertContent || modelValue }}
|
||||
</n-alert>
|
||||
|
||||
<!-- Pure HTML content (no form field, just display) -->
|
||||
<div
|
||||
v-else-if="schema.type === 'content'"
|
||||
class="aksrt-field__content"
|
||||
v-html="schema.content || modelValue || ''"
|
||||
/>
|
||||
|
||||
<!-- Date picker -->
|
||||
<n-date-picker
|
||||
v-else-if="schema.type === 'date'"
|
||||
:value="modelValue"
|
||||
:type="schema.dateType || 'date'"
|
||||
:disabled="schema.disabled"
|
||||
style="width: 100%"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Time picker -->
|
||||
<n-time-picker
|
||||
v-else-if="schema.type === 'time'"
|
||||
:value="modelValue"
|
||||
:disabled="schema.disabled"
|
||||
style="width: 100%"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Dimensions (width x height) -->
|
||||
<div v-else-if="schema.type === 'dimensions'" class="aksrt-field__dimensions">
|
||||
<n-input-number
|
||||
:value="modelValue?.width"
|
||||
:min="0"
|
||||
:placeholder="schema.widthPlaceholder || '宽度'"
|
||||
:disabled="schema.disabled"
|
||||
style="flex: 1"
|
||||
@update:value="updateDimension('width', $event)"
|
||||
/>
|
||||
<span class="aksrt-field__dimensions-sep">×</span>
|
||||
<n-input-number
|
||||
:value="modelValue?.height"
|
||||
:min="0"
|
||||
:placeholder="schema.heightPlaceholder || '高度'"
|
||||
:disabled="schema.disabled"
|
||||
style="flex: 1"
|
||||
@update:value="updateDimension('height', $event)"
|
||||
/>
|
||||
<n-select
|
||||
v-if="schema.unit"
|
||||
:value="modelValue?.unit"
|
||||
:options="dimensionUnitOptions"
|
||||
:disabled="schema.disabled"
|
||||
style="width: 80px"
|
||||
@update:value="updateDimension('unit', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Spacing (top right bottom left) -->
|
||||
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
|
||||
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
|
||||
<n-input-number
|
||||
:value="modelValue?.top"
|
||||
:min="0"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateSpacing('top', $event)"
|
||||
/>
|
||||
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[1] || '右' }}</div>
|
||||
<n-input-number
|
||||
:value="modelValue?.right"
|
||||
:min="0"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateSpacing('right', $event)"
|
||||
/>
|
||||
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[2] || '下' }}</div>
|
||||
<n-input-number
|
||||
:value="modelValue?.bottom"
|
||||
:min="0"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateSpacing('bottom', $event)"
|
||||
/>
|
||||
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[3] || '左' }}</div>
|
||||
<n-input-number
|
||||
:value="modelValue?.left"
|
||||
:min="0"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateSpacing('left', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Background (color + image + position + repeat) -->
|
||||
<div v-else-if="schema.type === 'background'" class="aksrt-field__background">
|
||||
<div class="aksrt-field__background-row">
|
||||
<label class="aksrt-field__sublabel">背景颜色</label>
|
||||
<n-color-picker
|
||||
:value="modelValue?.color"
|
||||
:show-alpha="true"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateBackground('color', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-field__background-row">
|
||||
<label class="aksrt-field__sublabel">背景图片</label>
|
||||
<AksMediaField
|
||||
:model-value="modelValue?.image"
|
||||
:accept="'image/*'"
|
||||
:disabled="schema.disabled"
|
||||
@update:model-value="updateBackground('image', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-field__background-row">
|
||||
<label class="aksrt-field__sublabel">图片尺寸</label>
|
||||
<n-select
|
||||
:value="modelValue?.size"
|
||||
:options="bgSizeOptions"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateBackground('size', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-field__background-row">
|
||||
<label class="aksrt-field__sublabel">图片位置</label>
|
||||
<n-select
|
||||
:value="modelValue?.position"
|
||||
:options="bgPositionOptions"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateBackground('position', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-field__background-row">
|
||||
<label class="aksrt-field__sublabel">图片重复</label>
|
||||
<n-select
|
||||
:value="modelValue?.repeat"
|
||||
:options="bgRepeatOptions"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="updateBackground('repeat', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image select (radio with image preview) -->
|
||||
<div v-else-if="schema.type === 'image_select'" class="aksrt-field__image-select">
|
||||
<div
|
||||
v-for="opt in normalizedOptions"
|
||||
:key="opt.value"
|
||||
class="aksrt-field__image-select-item"
|
||||
:class="{ 'aksrt-field__image-select-item--active': modelValue === opt.value }"
|
||||
@click="!schema.disabled && emit('update:modelValue', opt.value)"
|
||||
>
|
||||
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="aksrt-field__image-select-img" />
|
||||
<div v-else class="aksrt-field__image-select-placeholder">
|
||||
<FaIcon :icon="opt.icon || 'fas fa-image'" :size="20" />
|
||||
</div>
|
||||
<span class="aksrt-field__image-select-label">{{ opt.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repeater (array of objects) -->
|
||||
<div v-else-if="schema.type === 'repeater'" class="aksrt-field__repeater">
|
||||
<div
|
||||
v-for="(item, index) in (modelValue || [])"
|
||||
:key="item._id || index"
|
||||
class="aksrt-field__repeater-item"
|
||||
>
|
||||
<div class="aksrt-field__repeater-header">
|
||||
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="error"
|
||||
text
|
||||
:disabled="schema.disabled"
|
||||
@click="removeRepeaterItem(index)"
|
||||
>
|
||||
<FaIcon icon="fas fa-trash" :size="12" />
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="aksrt-field__repeater-fields">
|
||||
<template v-for="field in (schema.fields || [])" :key="field.key">
|
||||
<AksField
|
||||
:schema="field"
|
||||
:model-value="item[field.key]"
|
||||
:disabled="schema.disabled"
|
||||
@update:model-value="updateRepeaterItem(index, field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
dashed
|
||||
:disabled="schema.disabled"
|
||||
@click="addRepeaterItem"
|
||||
>
|
||||
<FaIcon icon="fas fa-plus" :size="12" style="margin-right: 6px" />
|
||||
{{ schema.addText || '添加一项' }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- Custom slot -->
|
||||
<slot v-else-if="schema.type === 'custom'" />
|
||||
|
||||
<!-- Fallback: text input -->
|
||||
<n-input
|
||||
v-else
|
||||
:value="modelValue"
|
||||
:placeholder="schema.placeholder || ''"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Validation message -->
|
||||
<p v-if="errorMessage" class="aksrt-field__error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* AksField — renders a single form field based on its schema definition.
|
||||
*
|
||||
* Supported types: switch, input/text, textarea, number, select, radio,
|
||||
* checkbox, color, icon, media, slider, code, alert, custom (slot).
|
||||
*/
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import FaIcon from './FaIcon.vue'
|
||||
import FaIconPicker from './FaIconPicker.vue'
|
||||
import AksMediaField from './AksMediaField.vue'
|
||||
import AksCodeEditor from './AksCodeEditor.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
/** Field schema definition */
|
||||
schema: {
|
||||
type: Object,
|
||||
required: true,
|
||||
// Expected shape: { key, label, type, default, options?, help?, ... }
|
||||
},
|
||||
/** Current field value */
|
||||
modelValue: { default: null },
|
||||
/** Error message to display */
|
||||
errorMessage: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/** Normalize options: support both string[] and {label, value}[] */
|
||||
const normalizedOptions = computed(() => {
|
||||
const opts = props.schema.options || []
|
||||
return opts.map(opt => {
|
||||
if (typeof opt === 'string') return { label: opt, value: opt }
|
||||
if (typeof opt === 'object' && opt !== null) {
|
||||
return { label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
|
||||
}
|
||||
return { label: String(opt), value: opt }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ============ Taxonomy Select (async) ============
|
||||
const taxonomyOptions = ref([])
|
||||
const taxonomyLoading = ref(false)
|
||||
|
||||
async function loadTaxonomyTerms() {
|
||||
if (!props.schema.taxonomy) return
|
||||
taxonomyLoading.value = true
|
||||
try {
|
||||
const res = await api.getCategories()
|
||||
const items = res?.data || res || []
|
||||
taxonomyOptions.value = (Array.isArray(items) ? items : []).map(t => ({
|
||||
label: t.name + (t.count ? ' (' + t.count + ')' : ''),
|
||||
value: t.term_id,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('[AksField] taxonomy load failed:', e)
|
||||
taxonomyOptions.value = []
|
||||
} finally {
|
||||
taxonomyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.schema.type === 'taxonomy_select') {
|
||||
loadTaxonomyTerms()
|
||||
}
|
||||
if (props.schema.type === 'user_select') {
|
||||
loadInitialUsers()
|
||||
}
|
||||
})
|
||||
|
||||
// ============ User Select (async search) ============
|
||||
const userOptions = ref([])
|
||||
const userLoading = ref(false)
|
||||
let userSearchTimer = null
|
||||
|
||||
async function loadInitialUsers() {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const res = await api.searchUsers('', 20)
|
||||
const items = res?.data || res || []
|
||||
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
|
||||
label: u.display_name + ' (' + u.user_login + ')',
|
||||
value: u.id,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('[AksField] user load failed:', e)
|
||||
userOptions.value = []
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserSearch(query) {
|
||||
clearTimeout(userSearchTimer)
|
||||
userSearchTimer = setTimeout(async () => {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const res = await api.searchUsers(query || '', 20)
|
||||
const items = res?.data || res || []
|
||||
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
|
||||
label: u.display_name + ' (' + u.user_login + ')',
|
||||
value: u.id,
|
||||
}))
|
||||
} catch (e) {
|
||||
userOptions.value = []
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const fieldClasses = computed(() => ({
|
||||
'aksrt-field--disabled': props.schema.disabled,
|
||||
'aksrt-field--inline': props.schema.inline,
|
||||
[`aksrt-field--${props.schema.type}`]: !!props.schema.type,
|
||||
}))
|
||||
|
||||
// ============ Dimensions ============
|
||||
const dimensionUnitOptions = [
|
||||
{ label: 'px', value: 'px' },
|
||||
{ label: '%', value: '%' },
|
||||
{ label: 'em', value: 'em' },
|
||||
{ label: 'rem', value: 'rem' },
|
||||
{ label: 'vw', value: 'vw' },
|
||||
]
|
||||
|
||||
function updateDimension(part, value) {
|
||||
const current = props.modelValue || {}
|
||||
emit('update:modelValue', { ...current, [part]: value })
|
||||
}
|
||||
|
||||
// ============ Spacing ============
|
||||
function updateSpacing(part, value) {
|
||||
const current = props.modelValue || {}
|
||||
emit('update:modelValue', { ...current, [part]: value })
|
||||
}
|
||||
|
||||
// ============ Background ============
|
||||
const bgSizeOptions = [
|
||||
{ label: '覆盖', value: 'cover' },
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '原始大小', value: 'auto' },
|
||||
]
|
||||
|
||||
const bgPositionOptions = [
|
||||
{ label: '居中', value: 'center center' },
|
||||
{ label: '顶部居中', value: 'top center' },
|
||||
{ label: '底部居中', value: 'bottom center' },
|
||||
{ label: '左侧居中', value: 'center left' },
|
||||
{ label: '右侧居中', value: 'center right' },
|
||||
]
|
||||
|
||||
const bgRepeatOptions = [
|
||||
{ label: '不重复', value: 'no-repeat' },
|
||||
{ label: '平铺', value: 'repeat' },
|
||||
{ label: '水平平铺', value: 'repeat-x' },
|
||||
{ label: '垂直平铺', value: 'repeat-y' },
|
||||
]
|
||||
|
||||
function updateBackground(part, value) {
|
||||
const current = props.modelValue || {}
|
||||
emit('update:modelValue', { ...current, [part]: value })
|
||||
}
|
||||
|
||||
// ============ Repeater ============
|
||||
let repeaterIdCounter = 0
|
||||
|
||||
function addRepeaterItem() {
|
||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
|
||||
// Initialize with defaults from schema.fields
|
||||
for (const field of (props.schema.fields || [])) {
|
||||
if (field.default !== undefined) {
|
||||
newItem[field.key] = field.default
|
||||
}
|
||||
}
|
||||
current.push(newItem)
|
||||
emit('update:modelValue', current)
|
||||
}
|
||||
|
||||
function removeRepeaterItem(index) {
|
||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
current.splice(index, 1)
|
||||
emit('update:modelValue', current)
|
||||
}
|
||||
|
||||
function updateRepeaterItem(index, fieldKey, value) {
|
||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
current[index] = { ...current[index], [fieldKey]: value }
|
||||
emit('update:modelValue', current)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-field {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.aksrt-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aksrt-field__label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aksrt-field__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.aksrt-field__required {
|
||||
color: #d03050;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.aksrt-field__help-icon {
|
||||
color: #c0c4cc;
|
||||
cursor: help;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-field__help-icon:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.aksrt-field__desc {
|
||||
margin: -2px 0 8px;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.aksrt-field__error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.aksrt-field--disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dimensions field */
|
||||
.aksrt-field__dimensions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-field__dimensions-sep {
|
||||
color: #c0c4cc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Spacing field */
|
||||
.aksrt-field__spacing {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksrt-field__spacing-label {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Background field */
|
||||
.aksrt-field__background {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-field__background-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-field__sublabel {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* Image select */
|
||||
.aksrt-field__image-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-field__image-select-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 2px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.aksrt-field__image-select-item:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.aksrt-field__image-select-item--active {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.aksrt-field__image-select-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.aksrt-field__image-select-placeholder {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fb;
|
||||
border-radius: 4px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.aksrt-field__image-select-label {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Repeater */
|
||||
.aksrt-field__repeater {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-item {
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-index {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Content field */
|
||||
.aksrt-field__content {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-field__dimensions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-field__spacing {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.aksrt-field__background-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="aksrt-field-group">
|
||||
<div v-if="title" class="aksrt-field-group__header">
|
||||
<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 v-if="$slots.actions" class="aksrt-field-group__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="collapsible" class="aksrt-field-group__toggle" @click="toggleCollapse">
|
||||
<FaIcon
|
||||
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'"
|
||||
:size="12"
|
||||
class="aksrt-field-group__chevron"
|
||||
/>
|
||||
<span class="aksrt-field-group__toggle-text">
|
||||
{{ collapsed ? '展开' : '收起' }}{{ title ? ` ${title}` : '' }}
|
||||
</span>
|
||||
</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.
|
||||
*/
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
|
||||
.aksrt-field-group__header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-field-group__icon {
|
||||
color: #1890ff;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.aksrt-field-group__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.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__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
color: #8a909c;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-field-group__toggle:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.aksrt-field-group__chevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.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,365 @@
|
||||
<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="group.key || group.title">
|
||||
<AksFieldGroup
|
||||
:title="group.title"
|
||||
:description="group.description"
|
||||
:icon="group.icon"
|
||||
:collapsible="group.collapsible"
|
||||
:default-collapsed="group.defaultCollapsed"
|
||||
>
|
||||
<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('')
|
||||
|
||||
/** Check if a field should be visible based on its dependsOn rule */
|
||||
function isFieldVisible(field) {
|
||||
if (!field.dependsOn) return true
|
||||
|
||||
// dependsOn can be a string (truthy check on that key) or an object { key, value }
|
||||
if (typeof field.dependsOn === 'string') {
|
||||
return !!props.formData[field.dependsOn]
|
||||
}
|
||||
|
||||
if (typeof field.dependsOn === 'object') {
|
||||
const { key, value, not } = field.dependsOn
|
||||
const currentVal = props.formData[key]
|
||||
if (not) return currentVal !== value
|
||||
return currentVal === value
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* useDirtyState — tracks whether a form has unsaved changes.
|
||||
*
|
||||
* Usage:
|
||||
* const { isDirty, markDirty, markClean, confirmLeave } = useDirtyState()
|
||||
*
|
||||
* Call markDirty() whenever form data changes from its initial state.
|
||||
* Call markClean() after a successful save.
|
||||
* confirmLeave() returns a boolean — false means the user cancelled navigation.
|
||||
*/
|
||||
export function useDirtyState() {
|
||||
const dirty = ref(false)
|
||||
const saveCount = ref(0)
|
||||
|
||||
function markDirty() {
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function markClean() {
|
||||
dirty.value = false
|
||||
saveCount.value++
|
||||
}
|
||||
|
||||
/** Reset dirty without incrementing save count (e.g. on initial load) */
|
||||
function reset() {
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm navigation away from dirty form.
|
||||
* Returns true if safe to leave, false if user cancelled.
|
||||
*/
|
||||
function confirmLeave() {
|
||||
if (!dirty.value) return true
|
||||
return window.confirm('您有未保存的更改,确定要离开此页面吗?')
|
||||
}
|
||||
|
||||
const isDirty = computed(() => dirty.value)
|
||||
|
||||
return {
|
||||
isDirty,
|
||||
markDirty,
|
||||
markClean,
|
||||
reset,
|
||||
confirmLeave,
|
||||
saveCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useDirtyState } from './useDirtyState'
|
||||
|
||||
/**
|
||||
* useSectionForm — standard composable for schema-driven admin settings pages.
|
||||
*
|
||||
* Encapsulates: data loading, dirty tracking, save/reload logic, and loading states.
|
||||
*
|
||||
* Usage:
|
||||
* const {
|
||||
* formData, loading, saving, loadError,
|
||||
* dirty, load, save, reset,
|
||||
* } = useSectionForm({
|
||||
* fetch: () => api.getBasicSettings(),
|
||||
* submit: (data) => api.saveBasicSettings(data),
|
||||
* defaults: { comment_enabled: true, lazy_load: true },
|
||||
* })
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} options.fetch — async function returning current settings
|
||||
* @param {Function} options.submit — async function accepting form data to save
|
||||
* @param {Object} options.defaults — default values merged under fetched data
|
||||
* @param {Function} [options.onLoaded] — callback after data is loaded
|
||||
* @param {Function} [options.onSaved] — callback after successful save
|
||||
* @param {Function} [options.transform] — transform fetched data before applying
|
||||
*/
|
||||
export function useSectionForm(options) {
|
||||
const {
|
||||
fetch: fetchData,
|
||||
submit: submitData,
|
||||
defaults = {},
|
||||
onLoaded,
|
||||
onSaved,
|
||||
transform,
|
||||
} = options
|
||||
|
||||
const formData = ref({ ...defaults })
|
||||
const initialSnapshot = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref(null)
|
||||
|
||||
const { isDirty, markDirty, markClean, reset: resetDirty, confirmLeave } = useDirtyState()
|
||||
|
||||
/** Snapshot the current form state for dirty comparison */
|
||||
function snapshot() {
|
||||
initialSnapshot.value = JSON.stringify(formData.value)
|
||||
}
|
||||
|
||||
/** Check if form data differs from snapshot */
|
||||
function checkDirty() {
|
||||
const current = JSON.stringify(formData.value)
|
||||
if (current !== initialSnapshot.value) {
|
||||
markDirty()
|
||||
} else {
|
||||
resetDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep watch form data for dirty tracking */
|
||||
let watcherHandle = null
|
||||
|
||||
function startWatching() {
|
||||
if (watcherHandle) watcherHandle()
|
||||
watcherHandle = watch(formData, checkDirty, { deep: true })
|
||||
}
|
||||
|
||||
/** Load settings from API */
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await fetchData()
|
||||
const raw = data?.data ?? data ?? {}
|
||||
const merged = { ...defaults, ...(transform ? transform(raw) : raw) }
|
||||
formData.value = merged
|
||||
snapshot()
|
||||
startWatching()
|
||||
onLoaded?.(merged)
|
||||
} catch (err) {
|
||||
loadError.value = err?.message || '加载失败'
|
||||
console.error('[useSectionForm] load error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current form data */
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await submitData(formData.value)
|
||||
snapshot()
|
||||
markClean()
|
||||
onSaved?.(formData.value)
|
||||
} catch (err) {
|
||||
console.error('[useSectionForm] save error:', err)
|
||||
throw err // let caller handle the error display
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Reload form data from API (discards changes) */
|
||||
async function reload() {
|
||||
if (watcherHandle) watcherHandle()
|
||||
resetDirty()
|
||||
await load()
|
||||
}
|
||||
|
||||
/** Reset form to defaults */
|
||||
function resetToDefaults() {
|
||||
if (watcherHandle) watcherHandle()
|
||||
formData.value = { ...defaults }
|
||||
snapshot()
|
||||
startWatching()
|
||||
resetDirty()
|
||||
}
|
||||
|
||||
// Auto-load on mount
|
||||
onMounted(load)
|
||||
|
||||
// Cleanup watcher
|
||||
onBeforeUnmount(() => {
|
||||
if (watcherHandle) watcherHandle()
|
||||
})
|
||||
|
||||
return {
|
||||
formData,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
dirty: isDirty,
|
||||
load,
|
||||
save,
|
||||
reload,
|
||||
resetToDefaults,
|
||||
confirmLeave,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Admin sections — central menu and route registry.
|
||||
*
|
||||
* Sidebar menu, route metadata, schema domain mapping, and deferred-page
|
||||
* planning details are all registered in domain modules to avoid drift between
|
||||
* the navigation tree and router definitions while keeping each file focused.
|
||||
*/
|
||||
|
||||
export { ADMIN_SECTIONS, getAllAdminPages } from './sections/index.js'
|
||||
export { renderFaIcon, customPage, schemaPage, deferredPage } from './sections/helpers.js'
|
||||
@@ -0,0 +1,20 @@
|
||||
import { renderFaIcon, customPage } from './helpers'
|
||||
|
||||
export const docsSection = {
|
||||
key: 'tools-compat',
|
||||
label: '兼容&工具',
|
||||
icon: renderFaIcon('fas fa-toolbox'),
|
||||
route: '/tools',
|
||||
hidden: true,
|
||||
children: [
|
||||
customPage({
|
||||
key: 'docs-dashboard',
|
||||
name: 'DocsDashboard',
|
||||
label: '文档中心',
|
||||
route: '/docs',
|
||||
component: 'DocsPage',
|
||||
title: '文档中心',
|
||||
desc: '更新日志、文档链接和发布说明。',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const extensionsSection = {
|
||||
key: 'extensions',
|
||||
label: '扩展&增强',
|
||||
icon: renderFaIcon('fas fa-puzzle-piece'),
|
||||
route: '/extensions',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'extensions-ip',
|
||||
name: 'ExtensionsIp',
|
||||
label: 'IP归属地',
|
||||
route: '/extensions/ip',
|
||||
title: 'IP归属地',
|
||||
desc: '配置 IP 数据源、缓存策略和前台归属地展示。',
|
||||
canonicalDomain: 'extensions',
|
||||
section: 'ip',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'extensions-audit',
|
||||
name: 'ExtensionsAudit',
|
||||
label: 'API内容审核',
|
||||
route: '/extensions/audit',
|
||||
title: 'API内容审核',
|
||||
desc: '配置第三方内容审核 API 接入、审核范围和失败回退策略。',
|
||||
canonicalDomain: 'extensions',
|
||||
section: 'audit',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'extensions-push',
|
||||
name: 'ExtensionsPush',
|
||||
label: '百度推送',
|
||||
route: '/extensions/push',
|
||||
title: '百度推送',
|
||||
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
|
||||
canonicalDomain: 'extensions',
|
||||
section: 'push',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'extensions-docs-mode',
|
||||
name: 'ExtensionsDocsMode',
|
||||
label: '文档模式',
|
||||
route: '/extensions/docs-mode',
|
||||
title: '文档模式',
|
||||
desc: '文档专题内容模式与页面增强。',
|
||||
canonicalDomain: 'extensions',
|
||||
section: 'docs-mode',
|
||||
}),
|
||||
customPage({
|
||||
key: 'extensions-security',
|
||||
name: 'ExtensionsSecurity',
|
||||
label: '网站安全',
|
||||
route: '/extensions/security',
|
||||
component: 'ExtensionSecurity',
|
||||
title: '网站安全',
|
||||
desc: '扩展安全策略和第三方安全集成。',
|
||||
}),
|
||||
customPage({
|
||||
key: 'extensions-tools',
|
||||
name: 'ExtensionsTools',
|
||||
label: '系统工具',
|
||||
route: '/extensions/tools',
|
||||
component: 'ExtensionTools',
|
||||
title: '系统工具',
|
||||
desc: '额外运维工具与扩展能力。',
|
||||
}),
|
||||
customPage({
|
||||
key: 'extensions-ai',
|
||||
name: 'ExtensionAI',
|
||||
label: 'AI 子系统',
|
||||
route: '/extensions/ai',
|
||||
component: 'ExtensionAI',
|
||||
status: 'available',
|
||||
title: 'AI 子系统',
|
||||
desc: '配置 AI 内容辅助能力。',
|
||||
canonicalDomain: 'extensions',
|
||||
section: 'ai',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const forumSection = {
|
||||
key: 'forum',
|
||||
label: '社区&论坛',
|
||||
icon: renderFaIcon('fas fa-comments'),
|
||||
route: '/forum',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'forum-settings-basic',
|
||||
name: 'ForumSettingsBasic',
|
||||
label: '基础设置',
|
||||
route: '/forum/settings',
|
||||
title: '论坛基础设置',
|
||||
desc: '论坛总开关、管理员和名称定义。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'basic',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-names',
|
||||
name: 'ForumSettingsNames',
|
||||
label: '名称定义',
|
||||
route: '/forum/settings/names',
|
||||
title: '名称定义',
|
||||
desc: '自定义论坛各实体的显示名称。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'names',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-homepage',
|
||||
name: 'ForumSettingsHomepage',
|
||||
label: '首页设置',
|
||||
route: '/forum/settings/homepage',
|
||||
title: '论坛首页设置',
|
||||
desc: '首页 Tab 引擎、快速发帖入口和移动端交互配置。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'homepage',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-board-page',
|
||||
name: 'ForumSettingsBoardPage',
|
||||
label: '版块页面',
|
||||
route: '/forum/settings/board-page',
|
||||
title: '版块页面设置',
|
||||
desc: '版块页布局、Tab 栏目和移动端交互配置。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'board_page',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-post-page',
|
||||
name: 'ForumSettingsPostPage',
|
||||
label: '帖子页面',
|
||||
route: '/forum/settings/post-page',
|
||||
title: '帖子页面设置',
|
||||
desc: '面包屑、评分、高度限制和封面配置。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'post_page',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-reply',
|
||||
name: 'ForumSettingsReply',
|
||||
label: '回复评论',
|
||||
route: '/forum/settings/reply',
|
||||
title: '回复评论设置',
|
||||
desc: '回复翻页方式、工具栏功能和快捷回复配置。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'reply',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-permissions',
|
||||
name: 'ForumSettingsPermissions',
|
||||
label: '权限配置',
|
||||
route: '/forum/settings/permissions',
|
||||
title: '论坛权限配置',
|
||||
desc: '发帖限制、帖子付费和版块付费关注。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'permissions',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-other',
|
||||
name: 'ForumSettingsOther',
|
||||
label: '其它设置',
|
||||
route: '/forum/settings/other',
|
||||
title: '论坛其它设置',
|
||||
desc: '投票、版主申请说明、标题字数和 URL 别名。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'other',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-upload',
|
||||
name: 'ForumSettingsUpload',
|
||||
label: '上传权限',
|
||||
route: '/forum/settings/upload',
|
||||
title: '上传权限设置',
|
||||
desc: '图片、视频和文件上传大小与格式限制。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'upload',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'forum-settings-credits',
|
||||
name: 'ForumSettingsCredits',
|
||||
label: '积分设置',
|
||||
route: '/forum/settings/credits',
|
||||
title: '论坛积分设置',
|
||||
desc: '论坛行为积分奖励矩阵和每日上限。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'credits',
|
||||
}),
|
||||
customPage({
|
||||
key: 'forum-boards',
|
||||
name: 'ForumBoards',
|
||||
label: '板块管理',
|
||||
route: '/forum/boards',
|
||||
component: 'ForumBoards',
|
||||
title: '板块管理',
|
||||
desc: '创建、编辑和排序论坛板块。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'boards',
|
||||
}),
|
||||
customPage({
|
||||
key: 'forum-topics',
|
||||
name: 'ForumTopics',
|
||||
label: '主题治理',
|
||||
route: '/forum/topics',
|
||||
component: 'ForumTopics',
|
||||
title: '主题治理',
|
||||
desc: '集中处理主题审核、可见性、版块移动和治理状态。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'topics',
|
||||
}),
|
||||
customPage({
|
||||
key: 'forum-moderator-applications',
|
||||
name: 'ForumModeratorApplications',
|
||||
label: '版主申请',
|
||||
route: '/forum/moderator-applications',
|
||||
component: 'ForumModeratorApplications',
|
||||
title: '版主申请',
|
||||
desc: '处理前台版主申请和审核结果反馈。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'moderator-applications',
|
||||
}),
|
||||
customPage({
|
||||
key: 'forum-moderators',
|
||||
name: 'ForumModerators',
|
||||
label: '版主管理',
|
||||
route: '/forum/moderators',
|
||||
component: 'ForumModerators',
|
||||
title: '版主管理',
|
||||
desc: '为每个论坛版块分配和管理版主。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'moderators',
|
||||
}),
|
||||
customPage({
|
||||
key: 'forum-dashboard',
|
||||
name: 'ForumDashboard',
|
||||
label: '论坛概览',
|
||||
route: '/forum',
|
||||
component: 'ForumDashboard',
|
||||
title: '论坛概览',
|
||||
desc: '社区讨论、帖子管理、版块配置等论坛功能概览。',
|
||||
canonicalDomain: 'forum',
|
||||
section: 'dashboard',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const globalSection = {
|
||||
key: 'global',
|
||||
label: '全局&功能',
|
||||
icon: renderFaIcon('fas fa-globe'),
|
||||
route: '/global',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'global-common',
|
||||
name: 'GlobalCommon',
|
||||
label: '常用功能',
|
||||
route: '/global/common',
|
||||
title: '常用功能',
|
||||
desc: '评论、性能、头像源与备案等全局基础配置。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'common',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-logo',
|
||||
name: 'GlobalLogo',
|
||||
label: 'LOGO图像',
|
||||
route: '/global/logo',
|
||||
title: 'LOGO图像',
|
||||
desc: '管理站点 Logo、站点图标和品牌标识。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'logo',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-seo',
|
||||
name: 'GlobalSeo',
|
||||
label: 'SEO优化',
|
||||
route: '/global/seo',
|
||||
title: 'SEO优化',
|
||||
desc: '站点标题、描述、关键词、Sitemap 等搜索引擎优化设置。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'seo',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-layout',
|
||||
name: 'GlobalLayout',
|
||||
label: '显示&布局',
|
||||
route: '/global/layout',
|
||||
title: '显示&布局',
|
||||
desc: '全局布局风格、颜色方案、字体设置。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'layout',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-search',
|
||||
name: 'GlobalSearch',
|
||||
label: '搜索功能',
|
||||
route: '/global/search',
|
||||
title: '搜索功能',
|
||||
desc: '搜索页面样式、搜索建议、热门搜索配置。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'search',
|
||||
}),
|
||||
customPage({
|
||||
key: 'global-tabbar',
|
||||
name: 'GlobalTabbar',
|
||||
label: '手机底部Tab',
|
||||
route: '/global/tabbar',
|
||||
title: '手机底部Tab',
|
||||
desc: '移动端底部导航栏配置。',
|
||||
component: 'FooterTabbarSettings',
|
||||
}),
|
||||
customPage({
|
||||
key: 'global-floating',
|
||||
name: 'GlobalFloating',
|
||||
label: '右侧悬浮按钮',
|
||||
route: '/global/floating',
|
||||
title: '右侧悬浮按钮',
|
||||
desc: '页面右侧浮动按钮的显示与配置。',
|
||||
component: 'FloatingButtonsSettings',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-i18n',
|
||||
name: 'GlobalI18n',
|
||||
label: '多语言翻译',
|
||||
route: '/global/i18n',
|
||||
title: '多语言翻译',
|
||||
desc: '配置站点语言、语言切换器和翻译管理。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'i18n',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-email',
|
||||
name: 'GlobalEmail',
|
||||
label: 'Email邮件',
|
||||
route: '/global/email',
|
||||
title: 'Email邮件',
|
||||
desc: 'SMTP 发信配置、邮件模板与测试。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'email',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'global-custom-code',
|
||||
name: 'GlobalCustomCode',
|
||||
label: '自定义代码',
|
||||
route: '/global/custom-code',
|
||||
title: '自定义代码',
|
||||
desc: '自定义 CSS、JS 代码注入。',
|
||||
canonicalDomain: 'global',
|
||||
section: 'custom-code',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { h } from 'vue'
|
||||
|
||||
export function renderFaIcon(faClass) {
|
||||
return () => h('i', { class: faClass, style: 'font-size:18px;width:18px;text-align:center' })
|
||||
}
|
||||
|
||||
function sectionPage(config) {
|
||||
return {
|
||||
status: 'available',
|
||||
component: 'SchemaPage',
|
||||
canonicalDomain: '',
|
||||
section: '',
|
||||
phase: '',
|
||||
dependsOn: [],
|
||||
reason: '',
|
||||
features: [],
|
||||
iconClass: '',
|
||||
...config,
|
||||
}
|
||||
}
|
||||
|
||||
export function customPage(config) {
|
||||
return sectionPage(config)
|
||||
}
|
||||
|
||||
export function schemaPage(config) {
|
||||
return sectionPage({
|
||||
component: 'SchemaPage',
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
export function deferredPage(config) {
|
||||
return sectionPage({
|
||||
component: 'DeferredPage',
|
||||
status: 'deferred',
|
||||
...config,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { globalSection } from './global'
|
||||
import { pageDisplaySection } from './page-display'
|
||||
import { postListSection } from './post-list'
|
||||
import { permissionsSection } from './permissions'
|
||||
import { usersSection } from './users'
|
||||
import { paymentSection } from './payment'
|
||||
import { shopSection } from './shop'
|
||||
import { forumSection } from './forum'
|
||||
import { extensionsSection } from './extensions'
|
||||
import { themeInfoSection } from './theme-info'
|
||||
import { docsSection } from './docs'
|
||||
import { toolsSection } from './tools'
|
||||
|
||||
const primarySections = [
|
||||
globalSection,
|
||||
pageDisplaySection,
|
||||
postListSection,
|
||||
permissionsSection,
|
||||
usersSection,
|
||||
paymentSection,
|
||||
shopSection,
|
||||
forumSection,
|
||||
extensionsSection,
|
||||
]
|
||||
|
||||
const compatChildren = [
|
||||
...(toolsSection.children || []),
|
||||
...(themeInfoSection.children || []),
|
||||
...(docsSection.children || []),
|
||||
]
|
||||
|
||||
export const compatToolsSection = {
|
||||
key: 'tools-compat',
|
||||
label: '兼容&工具',
|
||||
icon: toolsSection.icon,
|
||||
route: '/tools',
|
||||
children: compatChildren,
|
||||
}
|
||||
|
||||
export const ADMIN_SECTIONS = [
|
||||
...primarySections,
|
||||
compatToolsSection,
|
||||
]
|
||||
|
||||
export function getAllAdminPages() {
|
||||
const pages = []
|
||||
|
||||
for (const section of ADMIN_SECTIONS) {
|
||||
if (section.placeholder) {
|
||||
pages.push({
|
||||
sectionKey: section.key,
|
||||
...section,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for (const child of section.children || []) {
|
||||
pages.push({
|
||||
sectionKey: section.key,
|
||||
...child,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const pageDisplaySection = {
|
||||
key: 'page-display',
|
||||
label: '页面&显示',
|
||||
icon: renderFaIcon('fas fa-desktop'),
|
||||
route: '/page-display',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'page-display-header-nav',
|
||||
name: 'PageDisplayHeaderNav',
|
||||
label: '顶部导航栏',
|
||||
route: '/page-display/header-nav',
|
||||
title: '顶部导航栏',
|
||||
desc: '导航栏布局、菜单位置、LOGO 与搜索入口配置。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'header-nav',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-background',
|
||||
name: 'PageDisplayBackground',
|
||||
label: '全局背景',
|
||||
route: '/page-display/background',
|
||||
title: '全局背景',
|
||||
desc: '配置站点全局背景图、背景色、暗色模式背景和遮罩。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'background',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-menu-decoration',
|
||||
name: 'PageDisplayMenuDecoration',
|
||||
label: '菜单装饰',
|
||||
route: '/page-display/menu-decoration',
|
||||
title: '菜单装饰',
|
||||
desc: '配置导航菜单的图标、徽章、下拉样式和图文卡片子菜单。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'menu-decoration',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-header-components',
|
||||
name: 'PageDisplayHeaderComponents',
|
||||
label: '顶部多功能组件',
|
||||
route: '/page-display/header-components',
|
||||
title: '顶部多功能组件',
|
||||
desc: '顶部轮播、图标卡片、搜索条、公告条等组件配置。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'header-components',
|
||||
}),
|
||||
customPage({
|
||||
key: 'page-display-footer',
|
||||
name: 'PageDisplayFooter',
|
||||
label: '底部页脚',
|
||||
route: '/page-display/footer',
|
||||
title: '底部页脚',
|
||||
desc: '页脚布局、友链、版权信息配置。',
|
||||
component: 'FooterSettings',
|
||||
}),
|
||||
customPage({
|
||||
key: 'page-display-homepage',
|
||||
name: 'PageDisplayHomepage',
|
||||
label: '首页配置',
|
||||
route: '/page-display/homepage',
|
||||
title: '首页配置',
|
||||
desc: '首页模块、布局、推荐位配置。',
|
||||
component: 'HomepageSettings',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-category',
|
||||
name: 'PageDisplayCategory',
|
||||
label: '分类页面',
|
||||
route: '/page-display/category',
|
||||
title: '分类页面',
|
||||
desc: '分类归档页的头图、简介、列表模式、侧栏配置。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'category',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-tag',
|
||||
name: 'PageDisplayTag',
|
||||
label: '标签页面',
|
||||
route: '/page-display/tag',
|
||||
title: '标签页面',
|
||||
desc: '标签归档页的展示样式与配置。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'tag',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-special',
|
||||
name: 'PageDisplaySpecial',
|
||||
label: '专题页面',
|
||||
route: '/page-display/special',
|
||||
title: '专题页面',
|
||||
desc: '配置专题聚合页的封面、摘要、布局、聚合规则和子页面展示策略。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'special',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-user-home',
|
||||
name: 'PageDisplayUserHome',
|
||||
label: '用户主页',
|
||||
route: '/page-display/user-home',
|
||||
title: '用户主页',
|
||||
desc: '用户公开主页的布局与展示配置。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'user-home',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'page-display-other',
|
||||
name: 'PageDisplayOther',
|
||||
label: '其它页面',
|
||||
route: '/page-display/other',
|
||||
title: '其它页面',
|
||||
desc: '404 页面、搜索结果页等其它页面配置。',
|
||||
canonicalDomain: 'page_display',
|
||||
section: 'other',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const paymentSection = {
|
||||
key: 'payment',
|
||||
label: '支付&付费',
|
||||
icon: renderFaIcon('fas fa-wallet'),
|
||||
route: '/payment',
|
||||
children: [
|
||||
customPage({
|
||||
key: 'payment-gateway',
|
||||
name: 'PaymentGateway',
|
||||
label: '收款接口',
|
||||
route: '/payment/gateway',
|
||||
component: 'PaymentGateway',
|
||||
status: 'available',
|
||||
title: '收款接口',
|
||||
desc: '支付网关适配器、测试网关和手动确认配置。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'gateway',
|
||||
}),
|
||||
customPage({
|
||||
key: 'payment-paid',
|
||||
name: 'PaymentPaid',
|
||||
label: '下载/付费内容中心',
|
||||
route: '/payment/paid',
|
||||
title: '下载/付费内容中心',
|
||||
desc: '资源下载门槛、付费内容策略和人工交付说明。',
|
||||
component: 'ResourceDownload',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'payment-recharge',
|
||||
name: 'PaymentRecharge',
|
||||
label: '余额充值',
|
||||
route: '/payment/recharge',
|
||||
status: 'available',
|
||||
title: '余额充值',
|
||||
desc: '余额充值策略、记录和手动确认配置。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'recharge',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'payment-vip',
|
||||
name: 'PaymentVip',
|
||||
label: 'VIP会员',
|
||||
route: '/payment/vip',
|
||||
status: 'available',
|
||||
title: 'VIP会员',
|
||||
desc: '会员套餐、会员特权和免扣策略。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'vip',
|
||||
}),
|
||||
customPage({
|
||||
key: 'payment-revenue',
|
||||
name: 'PaymentRevenue',
|
||||
label: '创作分成',
|
||||
route: '/payment/revenue',
|
||||
component: 'PaymentRevenue',
|
||||
status: 'available',
|
||||
title: '创作分成',
|
||||
desc: '创作者收益分配和结算记录。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'revenue',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'payment-affiliate',
|
||||
name: 'PaymentAffiliate',
|
||||
label: '推广返佣',
|
||||
route: '/payment/affiliate',
|
||||
status: 'available',
|
||||
title: '推广返佣',
|
||||
desc: '返佣规则、邀请链路和结算记录。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'affiliate',
|
||||
}),
|
||||
customPage({
|
||||
key: 'payment-charge-card',
|
||||
name: 'PaymentChargeCard',
|
||||
label: '充值卡',
|
||||
route: '/payment/charge-card',
|
||||
component: 'PaymentChargeCard',
|
||||
status: 'available',
|
||||
title: '充值卡',
|
||||
desc: '创建和管理充值卡。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'charge-card',
|
||||
}),
|
||||
customPage({
|
||||
key: 'payment-rebate',
|
||||
name: 'PaymentRebate',
|
||||
label: '返佣管理',
|
||||
route: '/payment/rebate',
|
||||
component: 'PaymentRebate',
|
||||
status: 'available',
|
||||
title: '返佣管理',
|
||||
desc: '管理订单返佣和推广返佣记录。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'rebate',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderFaIcon, customPage, schemaPage } from './helpers'
|
||||
|
||||
export const permissionsSection = {
|
||||
key: 'permissions',
|
||||
label: '功能&权限',
|
||||
icon: renderFaIcon('fas fa-shield-alt'),
|
||||
route: '/permissions',
|
||||
children: [
|
||||
customPage({
|
||||
key: 'permissions-comment',
|
||||
name: 'PermissionsComment',
|
||||
label: '评论权限',
|
||||
route: '/permissions/comment',
|
||||
component: 'CommentPermissions',
|
||||
title: '评论权限',
|
||||
desc: '游客评论、频率限制、敏感词和审核策略。',
|
||||
canonicalDomain: 'permissions',
|
||||
section: 'comment',
|
||||
}),
|
||||
customPage({
|
||||
key: 'permissions-attachment',
|
||||
name: 'PermissionsAttachment',
|
||||
label: '附件权限',
|
||||
route: '/permissions/attachment',
|
||||
component: 'AttachmentPermissions',
|
||||
title: '附件权限',
|
||||
desc: '上传角色、文件类型、大小限制和审核状态。',
|
||||
canonicalDomain: 'permissions',
|
||||
section: 'attachment',
|
||||
}),
|
||||
customPage({
|
||||
key: 'permissions-download',
|
||||
name: 'PermissionsDownload',
|
||||
label: '下载权限',
|
||||
route: '/permissions/download',
|
||||
component: 'DownloadPermissions',
|
||||
title: '下载权限',
|
||||
desc: '登录要求、积分要求、角色要求和 VIP 特权。',
|
||||
canonicalDomain: 'permissions',
|
||||
section: 'download',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'permissions-login',
|
||||
name: 'PermissionsLogin',
|
||||
label: '登录权限',
|
||||
route: '/permissions/login',
|
||||
title: '登录权限',
|
||||
desc: '登录验证、人机验证、注册限制配置。',
|
||||
canonicalDomain: 'permissions',
|
||||
section: 'login',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'permissions-moderation',
|
||||
name: 'PermissionsModeration',
|
||||
label: '审核和风控',
|
||||
route: '/permissions/moderation',
|
||||
title: '审核和风控',
|
||||
desc: '评论审核、反垃圾、限流与风控配置。',
|
||||
canonicalDomain: 'permissions',
|
||||
section: 'moderation',
|
||||
}),
|
||||
customPage({
|
||||
key: 'permissions-capabilities',
|
||||
name: 'PermissionsCapabilities',
|
||||
label: '基本权限',
|
||||
route: '/permissions/capabilities',
|
||||
component: 'PermissionsCapabilities',
|
||||
title: '基本权限 / 能力矩阵',
|
||||
desc: '统一管理用户治理、投稿和评论的角色能力矩阵。',
|
||||
canonicalDomain: 'permissions',
|
||||
section: 'capabilities',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const postListSection = {
|
||||
key: 'post-list',
|
||||
label: '文章&列表',
|
||||
icon: renderFaIcon('fas fa-file-alt'),
|
||||
route: '/post-list',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'post-list-list',
|
||||
name: 'PostListList',
|
||||
label: '文章列表',
|
||||
route: '/post-list/list',
|
||||
title: '文章列表',
|
||||
desc: '列表样式、元信息开关、分页方式配置。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'list',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-thumb',
|
||||
name: 'PostListThumb',
|
||||
label: '列表缩略图',
|
||||
route: '/post-list/thumb',
|
||||
title: '列表缩略图',
|
||||
desc: '缩略图策略、封面回退、多图模式配置。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'thumb',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-page',
|
||||
name: 'PostListPage',
|
||||
label: '文章页面',
|
||||
route: '/post-list/page',
|
||||
component: 'PostPageSettings',
|
||||
title: '文章页面',
|
||||
desc: '文章页布局、目录树、灯箱、版权区配置。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'page',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-filter',
|
||||
name: 'PostListFilter',
|
||||
label: '高级筛选',
|
||||
route: '/post-list/filter',
|
||||
title: '高级筛选',
|
||||
desc: '筛选栏、排序方式、分类筛选配置。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'filter',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-feature',
|
||||
name: 'PostListFeature',
|
||||
label: '文章功能',
|
||||
route: '/post-list/feature',
|
||||
title: '文章功能',
|
||||
desc: '收藏、分享、阅读进度、相关推荐等功能开关。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'feature',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-share',
|
||||
name: 'PostListShare',
|
||||
label: '分享设置',
|
||||
route: '/post-list/share',
|
||||
title: '分享设置',
|
||||
desc: '配置微信分享凭据、海报分享和社交分享选项。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'share',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-comment',
|
||||
name: 'PostListComment',
|
||||
label: '评论设置',
|
||||
route: '/post-list/comment',
|
||||
component: 'CommentSettings',
|
||||
title: '评论设置',
|
||||
desc: '评论排序、楼层展示、登录限制、回复限制等配置。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'comment',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'post-list-submit',
|
||||
name: 'PostListSubmit',
|
||||
label: '前台投稿',
|
||||
route: '/post-list/submit',
|
||||
title: '前台投稿',
|
||||
desc: '配置前台投稿表单字段、审核流程、附件权限和封面策略。',
|
||||
canonicalDomain: 'post_list',
|
||||
section: 'submit',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const shopSection = {
|
||||
key: 'shop',
|
||||
label: '商城&商品',
|
||||
icon: renderFaIcon('fas fa-shopping-cart'),
|
||||
route: '/shop',
|
||||
children: [
|
||||
customPage({
|
||||
key: 'shop-products',
|
||||
name: 'ShopProducts',
|
||||
label: '商品管理',
|
||||
route: '/shop/products',
|
||||
component: 'ShopProducts',
|
||||
title: '商品管理',
|
||||
desc: '管理所有商品的上架、编辑和库存。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'products',
|
||||
}),
|
||||
customPage({
|
||||
key: 'shop-orders',
|
||||
name: 'ShopOrders',
|
||||
label: '订单管理',
|
||||
route: '/shop/orders',
|
||||
component: 'ShopOrders',
|
||||
title: '订单管理',
|
||||
desc: '查看和管理所有交易订单。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'orders',
|
||||
}),
|
||||
customPage({
|
||||
key: 'shop-withdrawals',
|
||||
name: 'ShopWithdrawals',
|
||||
label: '提现管理',
|
||||
route: '/shop/withdrawals',
|
||||
component: 'ShopWithdrawals',
|
||||
title: '提现管理',
|
||||
desc: '审核和管理创作者提现申请。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'withdrawals',
|
||||
}),
|
||||
customPage({
|
||||
key: 'shop-coupons',
|
||||
name: 'ShopCoupons',
|
||||
label: '优惠券',
|
||||
route: '/shop/coupons',
|
||||
component: 'ShopCoupons',
|
||||
title: '优惠券',
|
||||
desc: '创建和管理优惠券。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'coupons',
|
||||
}),
|
||||
customPage({
|
||||
key: 'shop-after-sale',
|
||||
name: 'ShopAfterSale',
|
||||
label: '售后管理',
|
||||
route: '/shop/after-sale',
|
||||
component: 'ShopAfterSale',
|
||||
title: '售后管理',
|
||||
desc: '管理退款、换货等售后工单。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'after-sale',
|
||||
}),
|
||||
customPage({
|
||||
key: 'shop-shipping',
|
||||
name: 'ShopShipping',
|
||||
label: '物流管理',
|
||||
route: '/shop/shipping',
|
||||
component: 'ShopShipping',
|
||||
title: '物流管理',
|
||||
desc: '管理订单发货、物流单号和发货状态。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'shipping',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-basic',
|
||||
name: 'ShopBasic',
|
||||
label: '商城基础',
|
||||
route: '/shop/basic',
|
||||
title: '商城基础',
|
||||
desc: '商城总开关、首页链接、面包屑和 URL 别名设置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'basic',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-list',
|
||||
name: 'ShopList',
|
||||
label: '商品列表',
|
||||
route: '/shop/list',
|
||||
title: '商品列表',
|
||||
desc: '商品列表排序、分页、卡片样式和销量显示配置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'list',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-contact',
|
||||
name: 'ShopContact',
|
||||
label: '客服配置',
|
||||
route: '/shop/contact',
|
||||
title: '客服配置',
|
||||
desc: '商品详情页客服按钮和联系方式配置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'contact',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-single',
|
||||
name: 'ShopSingle',
|
||||
label: '商品详情',
|
||||
route: '/shop/single',
|
||||
title: '商品详情',
|
||||
desc: '商品详情页布局、相关推荐和底部内容设置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'single',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-product-defaults',
|
||||
name: 'ShopProductDefaults',
|
||||
label: '商品默认参数',
|
||||
route: '/shop/product-defaults',
|
||||
title: '商品默认参数',
|
||||
desc: '主题级商品参数默认值,作为参数继承链的根。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'product-defaults',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-shipping-config',
|
||||
name: 'ShopShippingConfig',
|
||||
label: '物流配置',
|
||||
route: '/shop/shipping-config',
|
||||
title: '物流配置',
|
||||
desc: '快递查询接口、物流公司列表和收货地址设置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'shipping-config',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-timing',
|
||||
name: 'ShopTiming',
|
||||
label: '时效与售后原因',
|
||||
route: '/shop/timing',
|
||||
title: '时效与售后原因',
|
||||
desc: '确认收货、评价、退货时效和售后原因配置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'timing',
|
||||
}),
|
||||
customPage({
|
||||
key: 'shop-dashboard',
|
||||
name: 'ShopDashboard',
|
||||
label: '商城概览',
|
||||
route: '/shop',
|
||||
component: 'ShopDashboard',
|
||||
title: '商城概览',
|
||||
desc: '商品管理、订单系统、库存管理等电商功能概览。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'dashboard',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { renderFaIcon, customPage } from './helpers'
|
||||
|
||||
export const themeInfoSection = {
|
||||
key: 'tools-compat',
|
||||
label: '兼容&工具',
|
||||
icon: renderFaIcon('fas fa-toolbox'),
|
||||
route: '/tools',
|
||||
hidden: true,
|
||||
children: [
|
||||
customPage({
|
||||
key: 'theme-info-dashboard',
|
||||
name: 'ThemeInfoDashboard',
|
||||
label: '主题信息',
|
||||
route: '/theme-info',
|
||||
component: 'ThemeInfoPage',
|
||||
title: '主题信息',
|
||||
desc: '主题版本、授权信息和帮助入口。',
|
||||
}),
|
||||
customPage({
|
||||
key: 'theme-authorization',
|
||||
name: 'ThemeAuthorization',
|
||||
label: '主题授权',
|
||||
route: '/theme-info/authorization',
|
||||
component: 'ThemeAuthorizationPage',
|
||||
title: '主题授权',
|
||||
desc: '当前版本的授权策略与产品决策说明。',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { renderFaIcon, customPage, schemaPage } from './helpers'
|
||||
|
||||
export const toolsSection = {
|
||||
key: 'tools-compat',
|
||||
label: '兼容&工具',
|
||||
icon: renderFaIcon('fas fa-toolbox'),
|
||||
route: '/tools',
|
||||
children: [
|
||||
customPage({
|
||||
key: 'tools-import-export',
|
||||
name: 'ToolsImportExport',
|
||||
label: '导入导出',
|
||||
route: '/tools/import-export',
|
||||
title: '导入导出',
|
||||
desc: '导出当前配置、执行 dry-run 导入并按域重置。',
|
||||
component: 'ToolsImportExport',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'tools-backup',
|
||||
name: 'ToolsBackup',
|
||||
label: '数据备份',
|
||||
route: '/tools/backup',
|
||||
title: '数据备份',
|
||||
desc: '管理设置快照、创建备份并执行恢复或删除。',
|
||||
canonicalDomain: 'tools',
|
||||
section: 'backup',
|
||||
}),
|
||||
customPage({
|
||||
key: 'tools-health',
|
||||
name: 'ToolsHealth',
|
||||
label: '健康检查',
|
||||
route: '/tools/health',
|
||||
title: '健康检查',
|
||||
desc: '查看主题环境、数据表、邮箱验证迁移和系统诊断状态。',
|
||||
component: 'ToolsHealth',
|
||||
}),
|
||||
customPage({
|
||||
key: 'tools-migration',
|
||||
name: 'ToolsMigration',
|
||||
label: '迁移状态',
|
||||
route: '/tools/migration',
|
||||
title: '迁移状态',
|
||||
desc: '查看数据库 schema 版本、设置迁移版本和迁移日志。',
|
||||
component: 'ToolsMigration',
|
||||
}),
|
||||
customPage({
|
||||
key: 'tools-email-rollback',
|
||||
name: 'ToolsEmailRollback',
|
||||
label: '邮件回滚',
|
||||
route: '/tools/email-rollback',
|
||||
title: '邮件回滚',
|
||||
desc: '查看 14 天邮箱变更窗口内的账号并执行回滚。',
|
||||
component: 'ToolsEmailRollback',
|
||||
}),
|
||||
customPage({
|
||||
key: 'tools-update',
|
||||
name: 'ToolsUpdate',
|
||||
label: '更新中心',
|
||||
route: '/tools/update',
|
||||
title: '更新中心',
|
||||
desc: '查看版本状态、检测更新并浏览更新日志与教程入口。',
|
||||
component: 'ToolsUpdate',
|
||||
canonicalDomain: 'tools',
|
||||
section: 'update',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
|
||||
export const usersSection = {
|
||||
key: 'users',
|
||||
label: '用户&互动',
|
||||
icon: renderFaIcon('fas fa-users'),
|
||||
route: '/users',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'users-auth',
|
||||
name: 'UsersAuth',
|
||||
label: '注册登录',
|
||||
route: '/users/auth',
|
||||
title: '注册登录',
|
||||
desc: '注册方式、登录限制和密码策略配置。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'auth',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'users-oauth',
|
||||
name: 'UsersOauth',
|
||||
label: '第三方登录',
|
||||
route: '/users/oauth',
|
||||
title: '第三方登录',
|
||||
desc: 'GitHub、Gitee、QQ、Google 等第三方登录配置。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'oauth',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-features',
|
||||
name: 'UsersFeatures',
|
||||
label: '用户功能',
|
||||
route: '/users/features',
|
||||
component: 'UsersFeatures',
|
||||
title: '用户功能',
|
||||
desc: '用户中心与个人主页模块配置。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'features',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-signin',
|
||||
name: 'UsersSignin',
|
||||
label: '签到奖励',
|
||||
route: '/users/signin',
|
||||
component: 'UsersSignin',
|
||||
title: '签到奖励',
|
||||
desc: '签到规则、连续签到奖励和签到页面配置。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'signin',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-credits',
|
||||
name: 'UsersCredits',
|
||||
label: '用户积分',
|
||||
route: '/users/credits',
|
||||
title: '用户积分',
|
||||
desc: '积分规则、积分日志和用户积分管理。',
|
||||
component: 'CmsCredits',
|
||||
canonicalDomain: 'users',
|
||||
section: 'credits',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-verify',
|
||||
name: 'UsersVerify',
|
||||
label: '身份认证',
|
||||
route: '/users/verify',
|
||||
component: 'UsersVerify',
|
||||
status: 'available',
|
||||
title: '身份认证',
|
||||
desc: '身份认证字段、审核流和展示标签。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'verify',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-level',
|
||||
name: 'UsersLevel',
|
||||
label: '用户等级',
|
||||
route: '/users/level',
|
||||
component: 'UsersLevel',
|
||||
status: 'available',
|
||||
title: '用户等级',
|
||||
desc: '等级规则、升级条件和权益映射。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'level',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-badge',
|
||||
name: 'UsersBadge',
|
||||
label: '用户徽章',
|
||||
route: '/users/badge',
|
||||
component: 'UsersBadge',
|
||||
status: 'available',
|
||||
title: '用户徽章',
|
||||
desc: '徽章规则、授予条件和前台展示。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'badge',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'users-notifications',
|
||||
name: 'UsersNotifications',
|
||||
label: '消息通知',
|
||||
route: '/users/notifications',
|
||||
title: '消息通知',
|
||||
desc: '站内通知、邮件通知和通知策略配置。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'notifications',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-invite-codes',
|
||||
name: 'UsersInviteCodes',
|
||||
label: '邀请码运营',
|
||||
route: '/users/invite-codes',
|
||||
component: 'UsersInviteCodes',
|
||||
title: '邀请码运营',
|
||||
desc: '管理邀请码列表、生成新邀请码和查看邀请统计。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'invite-codes',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'users-sms',
|
||||
name: 'UsersSms',
|
||||
label: '短信接口',
|
||||
route: '/users/sms',
|
||||
title: '短信接口',
|
||||
desc: '配置短信验证码和短信通知服务的供应商、模板和限流策略。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'sms',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'users-wechat',
|
||||
name: 'UsersWechat',
|
||||
label: '微信通知',
|
||||
route: '/users/wechat',
|
||||
title: '微信通知',
|
||||
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'wechat',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-wechat-template',
|
||||
name: 'UsersWechatTemplate',
|
||||
label: '微信模板消息',
|
||||
route: '/users/wechat-template',
|
||||
component: 'UsersWechatTemplate',
|
||||
status: 'available',
|
||||
title: '微信模板消息',
|
||||
desc: '配置微信公众号模板消息推送。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'wechat-template',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-messages',
|
||||
name: 'UsersMessages',
|
||||
label: '消息中心',
|
||||
route: '/users/messages',
|
||||
component: 'UsersMessages',
|
||||
status: 'available',
|
||||
title: '消息中心',
|
||||
desc: '管理站内通知、系统消息和用户私信记录。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'messages',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-ban',
|
||||
name: 'UsersBan',
|
||||
label: '禁封管理',
|
||||
route: '/users/ban',
|
||||
component: 'UsersBan',
|
||||
status: 'available',
|
||||
title: '禁封管理',
|
||||
desc: '查看和管理用户封禁、限制状态。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'ban',
|
||||
}),
|
||||
customPage({
|
||||
key: 'users-report',
|
||||
name: 'UsersReport',
|
||||
label: '禁封/举报',
|
||||
route: '/users/report',
|
||||
component: 'UsersReport',
|
||||
status: 'available',
|
||||
title: '禁封/举报',
|
||||
desc: '举报受理、用户封禁和治理台账。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'report',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js'
|
||||
|
||||
function cloneDefaults(defaults = {}) {
|
||||
return JSON.parse(JSON.stringify(defaults))
|
||||
}
|
||||
|
||||
export function getSectionDefaults(domain, section) {
|
||||
const domainKey = normalizeSchemaDomain(domain)
|
||||
const sectionSchema = ALL_SCHEMAS[domainKey]?.[section]
|
||||
return cloneDefaults(sectionSchema?.defaults || {})
|
||||
}
|
||||
|
||||
export function getDomainDefaults(domain) {
|
||||
const domainKey = normalizeSchemaDomain(domain)
|
||||
const sections = Object.values(ALL_SCHEMAS[domainKey] || {})
|
||||
|
||||
return sections.reduce((defaults, schema) => {
|
||||
return {
|
||||
...defaults,
|
||||
...cloneDefaults(schema?.defaults || {}),
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => {
|
||||
defaults[domain] = getDomainDefaults(domain)
|
||||
return defaults
|
||||
}, {})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import NaiveUI from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import { styleMountTarget } from './style-target'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(NaiveUI)
|
||||
|
||||
// 全局错误处理
|
||||
app.config.errorHandler = (err, vm, info) => {
|
||||
console.error('[AKSRT] 全局错误:', err, info)
|
||||
}
|
||||
|
||||
// 兜底:立即显示加载状态(防止白屏)
|
||||
function showFallback(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:#666;font-family:sans-serif;">'
|
||||
+ '<p style="font-size:18px;margin-bottom:8px;">AKSRT 正在加载...</p>'
|
||||
+ '<p style="font-size:13px;color:#999;">如果长时间停留在此页面,请按 F12 查看控制台错误</p>'
|
||||
+ '</div>'
|
||||
}
|
||||
showFallback(document.getElementById('aksrt-admin-app') || document.body.appendChild(
|
||||
Object.assign(document.createElement('div'), { id: 'aksrt-admin-app' })
|
||||
))
|
||||
|
||||
/**
|
||||
* 获取或创建一个干净的挂载容器
|
||||
* 核心问题:WordPress 后台的 jQuery/脚本会在页面加载后持续操作 DOM,
|
||||
* 导致 PHP 输出的 <div id="aksrt-admin-app"> 可能被外部修改或其兄弟节点被移除,
|
||||
* Vue patch 时遍历到 null 节点 → nextSibling 报错。
|
||||
*
|
||||
* 解决方案:在挂载前一刻,动态创建一个全新的容器元素替换旧的,
|
||||
* 确保该节点及其父级在 Vue 接管前不被任何外部脚本污染。
|
||||
*/
|
||||
function getCleanContainer() {
|
||||
const existing = document.getElementById('aksrt-admin-app')
|
||||
if (!existing) {
|
||||
// 极端情况:PHP 没有输出容器,手动创建
|
||||
const fresh = document.createElement('div')
|
||||
fresh.id = 'aksrt-admin-app'
|
||||
document.body.appendChild(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
// 创建全新容器替换旧容器
|
||||
// 这确保新容器没有 WP 脚本留下的任何副作用(事件监听、属性等)
|
||||
const parent = existing.parentNode
|
||||
if (!parent) {
|
||||
// 旧容器已脱离文档树,重新创建
|
||||
const fresh = document.createElement('div')
|
||||
fresh.id = 'aksrt-admin-app'
|
||||
document.body.appendChild(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
const fresh = document.createElement('div')
|
||||
fresh.id = 'aksrt-admin-app'
|
||||
|
||||
// 将旧容器的位置信息转移给新容器(保留 fallback 内容)
|
||||
fresh.innerHTML = existing.innerHTML || ''
|
||||
parent.replaceChild(fresh, existing)
|
||||
|
||||
return fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
*/
|
||||
function showError(container, err) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:#e74c3c;font-family:sans-serif;">'
|
||||
+ '<p style="font-size:16px;margin-bottom:12px;">页面加载失败</p>'
|
||||
+ '<p style="font-size:13px;color:#999;word-break:break-all;">' + (err.message || String(err)) + '</p>'
|
||||
+ '<button onclick="location.reload()" style="margin-top:16px;padding:8px 24px;cursor:pointer;border:1px solid #e74c3c;background:none;color:#e74c3c;border-radius:4px;font-size:14px;">重新加载</button>'
|
||||
+ '</div>'
|
||||
}
|
||||
|
||||
// 等待 router 就绪后延迟挂载
|
||||
router.isReady().then(() => {
|
||||
// 三重 rAF:确保 load-scripts.php 加载的所有 jQuery 插件完成初始化和 DOM 操作
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const container = getCleanContainer()
|
||||
// 关键:在挂载前设置样式注入目标,确保 NaiveUI 组件渲染时目标已就绪
|
||||
styleMountTarget.value = container
|
||||
app.mount(container)
|
||||
} catch (err) {
|
||||
console.error('[AKSRT] Vue 挂载失败:', err)
|
||||
showError(getCleanContainer(), err)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}).catch((err) => {
|
||||
console.error('[AKSRT] Router 初始化失败:', err)
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<h2 class=”aksrt-page-title”>积分配置</h2>
|
||||
<p class=”aksrt-page-desc”>
|
||||
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整已迁移到 WordPress 原生”用户”菜单中管理。
|
||||
</p>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header"><h3>主开关 + 基础设置</h3></div>
|
||||
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="启用积分系统">
|
||||
<n-switch v-model:value="cfg.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义货币名称">
|
||||
<n-input v-model:value="cfg.currency_name" placeholder="积分" maxlength="20" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
<n-form-item label="注册赠送积分">
|
||||
<n-input-number v-model:value="cfg.register_bonus" :min="0" :max="10000" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header"><h3>每日签到</h3></div>
|
||||
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="cfg.daily_signin.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="基础奖励">
|
||||
<n-input-number v-model:value="cfg.daily_signin.base" :min="0" :max="10000" />
|
||||
</n-form-item>
|
||||
<n-form-item label="连续奖励(JSON)">
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="streakJson"
|
||||
placeholder='{"3":2,"7":5,"30":20}'
|
||||
:autosize="{ minRows: 2 }"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">连续 N 天时额外发放的积分。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header"><h3>评论奖励</h3></div>
|
||||
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="cfg.comment_reward.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="单次奖励">
|
||||
<n-input-number v-model:value="cfg.comment_reward.amount" :min="0" :max="10000" />
|
||||
</n-form-item>
|
||||
<n-form-item label="每日上限">
|
||||
<n-input-number v-model:value="cfg.comment_reward.daily_cap" :min="0" :max="100" />
|
||||
</n-form-item>
|
||||
<n-form-item label="最少字符数">
|
||||
<n-input-number v-model:value="cfg.comment_reward.min_length" :min="0" :max="500" />
|
||||
</n-form-item>
|
||||
<n-form-item label="审核通过后发放">
|
||||
<n-switch v-model:value="cfg.comment_reward.on_approved" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header"><h3>邀请奖励</h3></div>
|
||||
<n-form :model="cfg.invite_reward" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="cfg.invite_reward.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邀请人奖励">
|
||||
<n-input-number v-model:value="cfg.invite_reward.inviter_amount" :min="0" :max="10000" />
|
||||
</n-form-item>
|
||||
<n-form-item label="被邀请人奖励">
|
||||
<n-input-number v-model:value="cfg.invite_reward.invitee_amount" :min="0" :max="10000" />
|
||||
</n-form-item>
|
||||
<n-form-item label="需邮箱验证后发放">
|
||||
<n-switch v-model:value="cfg.invite_reward.require_verify" />
|
||||
</n-form-item>
|
||||
<n-form-item label="同 IP 当日上限">
|
||||
<n-input-number v-model:value="cfg.invite_reward.ip_daily_cap" :min="0" :max="100" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header"><h3>下载消费</h3></div>
|
||||
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item :label="`默认资源价格(${cfg.currency_name || '积分'})`">
|
||||
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">资源未单独设价时回退使用。设为 0 表示免费</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-footer">
|
||||
<n-button type="primary" :loading="saving" @click="saveCfg">保存配置</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// ---------- 配置 ----------
|
||||
const cfg = reactive({
|
||||
enabled: true,
|
||||
currency_name: '',
|
||||
register_bonus: 10,
|
||||
daily_signin: { enabled: true, base: 1, streak: { 3: 2, 7: 5, 30: 20 } },
|
||||
comment_reward: { enabled: true, amount: 1, daily_cap: 5, min_length: 10, on_approved: true },
|
||||
invite_reward: {
|
||||
enabled: true,
|
||||
inviter_amount: 5,
|
||||
invitee_amount: 2,
|
||||
require_verify: true,
|
||||
ip_daily_cap: 3,
|
||||
register_field: 'optional',
|
||||
reject_invalid: true,
|
||||
},
|
||||
download_default_cost: 0,
|
||||
})
|
||||
const streakJson = ref('{}')
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadCfg() {
|
||||
try {
|
||||
const data = await api.getCreditSettings()
|
||||
Object.assign(cfg, data)
|
||||
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCfg() {
|
||||
saving.value = true
|
||||
try {
|
||||
let streak = {}
|
||||
try { streak = JSON.parse(streakJson.value || '{}') } catch (_) { /* ignore */ }
|
||||
cfg.daily_signin.streak = streak
|
||||
await api.saveCreditSettings(cfg)
|
||||
message.success('已保存')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCfg()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader
|
||||
title="下载/付费内容中心"
|
||||
desc="统一管理资源下载门槛、默认扣费、角色免扣和人工交付说明。运行中的下载 token 流程保持不变。"
|
||||
>
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button quaternary @click="go('/permissions/download')">下载权限</n-button>
|
||||
<n-button quaternary @click="go('/payment/gateway')">收款接口</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>当前联动摘要</h3>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-tag round :type="form.download_login ? 'warning' : 'default'">
|
||||
登录门槛:{{ form.download_login ? '已启用' : '已关闭' }}
|
||||
</n-tag>
|
||||
<n-tag round :type="form.download_credit ? 'warning' : 'default'">
|
||||
积分扣费:{{ form.download_credit ? '已启用' : '已关闭' }}
|
||||
</n-tag>
|
||||
<n-tag round :type="form.download_captcha ? 'warning' : 'default'">
|
||||
下载验证码:{{ form.download_captcha ? '已启用' : '已关闭' }}
|
||||
</n-tag>
|
||||
<n-tag round type="info">
|
||||
默认价格:{{ form.download_default_cost }} {{ currencyName }}
|
||||
</n-tag>
|
||||
<n-tag round :type="form.payment_vip_free_download ? 'success' : 'default'">
|
||||
角色免扣:{{ form.payment_vip_free_download ? '已启用' : '未启用' }}
|
||||
</n-tag>
|
||||
<n-tag round type="info">
|
||||
Token TTL:10 分钟
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>下载门槛</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
||||
<n-form-item label="下载需要登录">
|
||||
<n-switch v-model:value="form.download_login" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="下载启用积分扣费">
|
||||
<n-switch v-model:value="form.download_credit" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="下载启用验证码">
|
||||
<n-switch v-model:value="form.download_captcha" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="`默认资源价格(${currencyName})`">
|
||||
<n-input-number v-model:value="form.download_default_cost" :min="0" :max="100000" style="width: 180px;" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">资源未单独设价时回退到这里。角色免扣命中时会直接跳过积分扣费。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>付费内容策略</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
||||
<n-form-item label="受管内容类型">
|
||||
<n-input v-model:value="form.payment_paid_post_types" placeholder="post,course,doc" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">逗号分隔,标记纳入统一付费策略的内容类型。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="角色白名单免扣下载">
|
||||
<n-switch v-model:value="form.payment_vip_free_download" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="免扣角色列表" v-if="form.payment_vip_free_download">
|
||||
<n-input v-model:value="form.payment_vip_exempt_roles" placeholder="administrator,editor,vip" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">命中这些 WordPress role 的用户下载资源时将直接跳过积分扣费。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="前台付费提示">
|
||||
<n-input
|
||||
v-model:value="form.payment_paid_catalog_note"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
placeholder="可填写资源购买须知、交付说明或人工确认提示。"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="人工交付备注">
|
||||
<n-input
|
||||
v-model:value="form.payment_manual_delivery_note"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
当前收款模式:{{ gatewayModeLabel }}。充值、VIP、返佣和分成页均已提供稳定结构,订单和结算台账直接衔接这些字段。
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const featureSnapshot = ref({})
|
||||
const creditSnapshot = ref({})
|
||||
const overview = ref({})
|
||||
|
||||
const form = reactive({
|
||||
download_login: true,
|
||||
download_credit: true,
|
||||
download_captcha: false,
|
||||
download_default_cost: 0,
|
||||
payment_paid_post_types: 'post',
|
||||
payment_vip_free_download: false,
|
||||
payment_vip_exempt_roles: '',
|
||||
payment_paid_catalog_note: '',
|
||||
payment_manual_delivery_note: '付费或人工确认内容请在订单备注中记录交付方式。',
|
||||
})
|
||||
|
||||
const currencyName = computed(() => overview.value.currency_name || creditSnapshot.value.currency_name || '积分')
|
||||
const gatewayModeLabel = computed(() => {
|
||||
const mode = overview.value.gateway?.mode || 'manual_review'
|
||||
if (mode === 'test_gateway') return '测试网关'
|
||||
if (mode === 'adapter_ready') return '适配器就绪'
|
||||
return '人工确认'
|
||||
})
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function extractData(response) {
|
||||
return response?.data ?? response ?? {}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [featureRes, creditRes, schemaRes, overviewRes] = await Promise.all([
|
||||
api.getCmsFeatures(),
|
||||
api.getCreditSettings(),
|
||||
api.getSchemaPage('payment', 'paid'),
|
||||
api.getPaymentOverview(),
|
||||
])
|
||||
|
||||
const featureData = featureRes?.cms_features ?? featureRes?.data?.cms_features ?? {}
|
||||
const creditData = extractData(creditRes)
|
||||
const schemaData = extractData(schemaRes)
|
||||
|
||||
featureSnapshot.value = featureData
|
||||
creditSnapshot.value = creditData
|
||||
overview.value = extractData(overviewRes)
|
||||
|
||||
form.download_login = featureData.download_login !== false
|
||||
form.download_credit = featureData.download_credit !== false
|
||||
form.download_captcha = !!featureData.download_captcha
|
||||
form.download_default_cost = Number(creditData.download_default_cost || 0)
|
||||
form.payment_paid_post_types = schemaData.payment_paid_post_types || 'post'
|
||||
form.payment_vip_free_download = !!schemaData.payment_vip_free_download
|
||||
form.payment_vip_exempt_roles = schemaData.payment_vip_exempt_roles || ''
|
||||
form.payment_paid_catalog_note = schemaData.payment_paid_catalog_note || ''
|
||||
form.payment_manual_delivery_note = schemaData.payment_manual_delivery_note || '付费或人工确认内容请在订单备注中记录交付方式。'
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载下载/付费内容中心失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const nextFeatures = {
|
||||
...featureSnapshot.value,
|
||||
download_login: form.download_login,
|
||||
download_credit: form.download_credit,
|
||||
download_captcha: form.download_captcha,
|
||||
}
|
||||
const nextCredits = {
|
||||
...creditSnapshot.value,
|
||||
download_default_cost: Number(form.download_default_cost || 0),
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
api.saveCmsFeatures(nextFeatures),
|
||||
api.saveCreditSettings(nextCredits),
|
||||
api.saveSchemaPage('payment', 'paid', {
|
||||
payment_paid_post_types: form.payment_paid_post_types,
|
||||
payment_vip_free_download: form.payment_vip_free_download,
|
||||
payment_vip_exempt_roles: form.payment_vip_exempt_roles,
|
||||
payment_paid_catalog_note: form.payment_paid_catalog_note,
|
||||
payment_manual_delivery_note: form.payment_manual_delivery_note,
|
||||
}),
|
||||
])
|
||||
|
||||
featureSnapshot.value = nextFeatures
|
||||
creditSnapshot.value = nextCredits
|
||||
message.success('下载/付费内容中心已保存')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="aksrt-schema-page">
|
||||
<AksFormRenderer
|
||||
:title="pageMeta.title"
|
||||
:description="pageMeta.description"
|
||||
:icon="pageMeta.icon"
|
||||
:schema="pageMeta.groups"
|
||||
:form-data="formData"
|
||||
:dirty="dirty"
|
||||
:loading="loading"
|
||||
:saving="saving"
|
||||
:load-error="loadError"
|
||||
:show-search="showSearch"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@reload="handleReload"
|
||||
>
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-tencent="{ value, setValue }"
|
||||
>
|
||||
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-aliyun="{ value, setValue }"
|
||||
>
|
||||
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-cloudflare="{ value, setValue }"
|
||||
>
|
||||
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
#field-geetest="{ value, setValue }"
|
||||
>
|
||||
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="isPermissionsModeration"
|
||||
#field-geoip_block_countries="{ value, setValue }"
|
||||
>
|
||||
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
|
||||
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
|
||||
</template>
|
||||
|
||||
<!-- Slot for custom group content (override per page if needed) -->
|
||||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData" />
|
||||
</template>
|
||||
</AksFormRenderer>
|
||||
|
||||
<div
|
||||
v-if="isPermissionsLogin && !loading"
|
||||
class="aksrt-schema-page__tool-actions"
|
||||
>
|
||||
<n-button
|
||||
:disabled="!formData?.enabled || !formData?.active_provider"
|
||||
@click="handleCaptchaTest"
|
||||
>
|
||||
测试验证
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div v-if="showTestModal" class="captcha-test-overlay" @click.self="closeTestModal">
|
||||
<div class="captcha-test-panel">
|
||||
<div class="captcha-test-header">
|
||||
<span>测试验证码</span>
|
||||
<button class="captcha-test-close" @click="closeTestModal">×</button>
|
||||
</div>
|
||||
<div class="captcha-test-body">
|
||||
<div v-if="testLoading" style="text-align:center;padding:40px;">
|
||||
<n-spin />
|
||||
<p style="margin-top:10px;color:#999;">加载中...</p>
|
||||
</div>
|
||||
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? '#18a058' : '#d03050', textAlign:'center', padding:'20px'}">
|
||||
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
|
||||
</div>
|
||||
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* SchemaPage — generic Schema-driven settings page.
|
||||
*
|
||||
* Reads the schema from the Vue-side schema registry, fetches/saves settings
|
||||
* via the API client, and renders using AksFormRenderer.
|
||||
*
|
||||
* Usage (in router or parent):
|
||||
* <SchemaPage domain="global" section="logo" />
|
||||
*
|
||||
* Or auto-detect from route meta:
|
||||
* The router's meta.schemaKey provides domain/section lookup.
|
||||
*/
|
||||
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
|
||||
import { useSectionForm } from '@/composables/useSectionForm'
|
||||
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
|
||||
import api from '@/api/index.js'
|
||||
import TencentConfig from '@/components/captcha/TencentConfig.vue'
|
||||
import AliyunConfig from '@/components/captcha/AliyunConfig.vue'
|
||||
import CloudflareConfig from '@/components/captcha/CloudflareConfig.vue'
|
||||
import GeetestConfig from '@/components/captcha/GeetestConfig.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** Schema domain key (e.g. 'global') */
|
||||
domain: { type: String, default: '' },
|
||||
/** Schema section key (e.g. 'logo') */
|
||||
section: { type: String, default: '' },
|
||||
/** Whether to show inline search */
|
||||
showSearch: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
const showTestModal = ref(false)
|
||||
const testLoading = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
||||
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
||||
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
||||
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
||||
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
||||
|
||||
function resolveDomainFromPath(path) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
return parts[0] || 'global'
|
||||
}
|
||||
|
||||
/** Extract section from route path: /global/logo → logo */
|
||||
function resolveSectionFromPath(path) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
return parts[parts.length - 1] || 'common'
|
||||
}
|
||||
|
||||
/** Load schema from the Vue-side registry */
|
||||
const pageMeta = computed(() => {
|
||||
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
|
||||
if (schema && schema.groups && schema.groups.length > 0) {
|
||||
return {
|
||||
title: schema.title || '',
|
||||
description: schema.description || '',
|
||||
icon: schema.icon || '',
|
||||
groups: schema.groups,
|
||||
}
|
||||
}
|
||||
// Fallback: read from route meta
|
||||
return {
|
||||
title: route.meta?.title || '',
|
||||
description: route.meta?.desc || '',
|
||||
icon: '',
|
||||
groups: [],
|
||||
}
|
||||
})
|
||||
|
||||
/** Default values from schema */
|
||||
const defaults = computed(() => {
|
||||
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
|
||||
if (!schema) return {}
|
||||
const d = {}
|
||||
for (const group of schema.groups || []) {
|
||||
for (const field of group.fields || []) {
|
||||
if (field.default !== undefined) {
|
||||
d[field.key] = field.default
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
})
|
||||
|
||||
/** Use the section form composable */
|
||||
const {
|
||||
formData, loading, saving, loadError, dirty,
|
||||
save: doSave, reload,
|
||||
} = useSectionForm({
|
||||
fetch: () => api.getSchemaPage(resolvedDomain.value, resolvedSection.value),
|
||||
submit: (data) => api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, data),
|
||||
defaults: defaults.value,
|
||||
onSaved: () => {
|
||||
message.success('设置已保存')
|
||||
},
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await doSave()
|
||||
} catch (err) {
|
||||
message.error('保存失败: ' + (err?.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReload() {
|
||||
closeTestModal()
|
||||
reload()
|
||||
message.info('已放弃更改')
|
||||
}
|
||||
|
||||
function getCaptchaProviderConfig(provider) {
|
||||
if (!formData.value || !provider) return {}
|
||||
const cfg = formData.value[provider]
|
||||
return cfg && typeof cfg === 'object' ? cfg : {}
|
||||
}
|
||||
|
||||
function handleCaptchaTest() {
|
||||
const provider = formData.value?.active_provider
|
||||
if (!provider) return
|
||||
|
||||
const cfg = getCaptchaProviderConfig(provider)
|
||||
const required = {
|
||||
tencent: ['secret_id', 'secret_key', 'app_id', 'app_secret_key'],
|
||||
aliyun: ['region', 'prefix', 'scene_id', 'access_key_id', 'access_key_secret'],
|
||||
cloudflare: ['site_key', 'secret_key'],
|
||||
geetest: ['captcha_id', 'captcha_key'],
|
||||
}
|
||||
const missing = (required[provider] || []).filter((key) => !cfg[key])
|
||||
if (missing.length) {
|
||||
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === 'tencent') {
|
||||
loadTencentTest(cfg)
|
||||
return
|
||||
}
|
||||
|
||||
showTestModal.value = true
|
||||
testResult.value = null
|
||||
testLoading.value = true
|
||||
|
||||
nextTick(() => {
|
||||
createTestIframe(provider, cfg)
|
||||
})
|
||||
}
|
||||
|
||||
function loadTencentTest(cfg) {
|
||||
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||||
|
||||
if (typeof window.TencentCaptcha !== 'undefined') {
|
||||
try {
|
||||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||||
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。')
|
||||
else message.warning('验证已取消。')
|
||||
}, { userLanguage: 'zh-cn' }).show()
|
||||
} catch (error) {
|
||||
message.error('初始化失败: ' + error.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = sdkSrc
|
||||
script.onload = function () {
|
||||
try {
|
||||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||||
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
|
||||
else message.warning('验证已取消。')
|
||||
}, { userLanguage: 'zh-cn' }).show()
|
||||
} catch (error) {
|
||||
message.error('初始化失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
function createTestIframe(provider, cfg) {
|
||||
setTimeout(() => {
|
||||
testLoading.value = false
|
||||
loadProviderSDK(provider, cfg)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function loadProviderSDK(provider, cfg) {
|
||||
const oldScript = document.getElementById('captcha-test-sdk')
|
||||
if (oldScript) oldScript.remove()
|
||||
|
||||
if (window.__gtTestCaptcha) {
|
||||
try { window.__gtTestCaptcha.destroy() } catch (error) {}
|
||||
window.__gtTestCaptcha = null
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = 'captcha-test-sdk'
|
||||
|
||||
switch (provider) {
|
||||
case 'tencent':
|
||||
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||||
script.onload = function () {
|
||||
try {
|
||||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||||
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' }
|
||||
}, { userLanguage: 'zh-cn' }).show()
|
||||
} catch (error) {
|
||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||
}
|
||||
}
|
||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||||
break
|
||||
|
||||
case 'cloudflare':
|
||||
window.onTurnstileTestLoad = function() {
|
||||
try {
|
||||
window.turnstile.render('#captcha-test-box', {
|
||||
sitekey: cfg.site_key,
|
||||
theme: 'auto',
|
||||
size: cfg.size || 'normal',
|
||||
callback: function() {
|
||||
testResult.value = { success: true, message: '验证成功!' }
|
||||
message.success('Cloudflare Turnstile 测试成功!')
|
||||
setTimeout(closeTestModal, 1500)
|
||||
},
|
||||
'error-callback': function() {
|
||||
testResult.value = { success: false, message: '验证失败' }
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||
}
|
||||
}
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
|
||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||||
break
|
||||
|
||||
case 'geetest':
|
||||
script.src = 'https://static.geetest.com/v4/gt4.js'
|
||||
script.onload = function () {
|
||||
try {
|
||||
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
|
||||
window.__gtTestCaptcha = captchaObj
|
||||
const onSuccess = function() {
|
||||
testResult.value = { success: true, message: '验证成功!' }
|
||||
message.success('极验 GeeTest 测试成功!')
|
||||
setTimeout(closeTestModal, 1500)
|
||||
}
|
||||
const onError = function(error) {
|
||||
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
|
||||
}
|
||||
const onClose = function() {
|
||||
if (!testResult.value) testResult.value = { success: false, message: '已关闭' }
|
||||
}
|
||||
if ((cfg.product || 'float') === 'bind') {
|
||||
captchaObj.onReady(() => captchaObj.showCaptcha()).onSuccess(onSuccess).onError(onError).onClose(onClose)
|
||||
} else {
|
||||
captchaObj.appendTo('#captcha-test-box').onReady(() => {}).onSuccess(onSuccess).onError(onError).onClose(onClose)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||
}
|
||||
}
|
||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||||
break
|
||||
|
||||
case 'aliyun':
|
||||
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
|
||||
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
||||
script.onload = function () {
|
||||
try {
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: cfg.scene_id,
|
||||
mode: 'embed',
|
||||
element: '#captcha-test-box',
|
||||
success: function() {
|
||||
testResult.value = { success: true, message: '验证成功!' }
|
||||
message.success('阿里云测试成功!')
|
||||
setTimeout(closeTestModal, 1500)
|
||||
},
|
||||
fail: function(result) {
|
||||
testResult.value = { success: false, message: String(result) }
|
||||
},
|
||||
getInstance: function() {},
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
language: 'cn',
|
||||
})
|
||||
} catch (error) {
|
||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||
}
|
||||
}
|
||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||||
break
|
||||
|
||||
default:
|
||||
testResult.value = { success: false, message: '未知服务商' }
|
||||
return
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
showTestModal.value = false
|
||||
testResult.value = null
|
||||
testLoading.value = false
|
||||
if (window.__gtTestCaptcha) {
|
||||
try { window.__gtTestCaptcha.destroy() } catch (error) {}
|
||||
window.__gtTestCaptcha = null
|
||||
}
|
||||
const script = document.getElementById('captcha-test-sdk')
|
||||
if (script) script.remove()
|
||||
if (window.onTurnstileTestLoad) {
|
||||
try { delete window.onTurnstileTestLoad } catch (error) {}
|
||||
}
|
||||
}
|
||||
|
||||
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
|
||||
if (nextDomain !== prevDomain || nextSection !== prevSection) {
|
||||
closeTestModal()
|
||||
reload()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeTestModal()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-schema-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.aksrt-schema-page__tool-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.aksrt-schema-page__field-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.captcha-test-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.captcha-test-panel {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
width: 420px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.captcha-test-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid #e8e8ec;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.captcha-test-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.captcha-test-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.captcha-test-body {
|
||||
padding: 24px;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="站点设置" desc="集中管理评论、分页、懒加载、头像源与备案信息。">
|
||||
<template #actions>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存全部</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<!-- 1. 评论 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<h3>评论设置</h3>
|
||||
</div>
|
||||
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
|
||||
<n-form-item label="全局评论开关">
|
||||
<n-switch v-model:value="basic.comment_enabled">
|
||||
<template #checked>已开启</template>
|
||||
<template #unchecked>已关闭</template>
|
||||
</n-switch>
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">关闭后全站禁用评论功能。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="basic.comment_enabled" label="必须注册才能评论">
|
||||
<n-switch v-model:value="basic.comment_requires_registration">
|
||||
<template #checked>是</template>
|
||||
<template #unchecked>否</template>
|
||||
</n-switch>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 2. 分页 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
||||
<h3>分页设置</h3>
|
||||
</div>
|
||||
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
|
||||
<n-form-item label="文章列表分页">
|
||||
<n-radio-group v-model:value="basic.post_pagination_mode">
|
||||
<n-space size="large">
|
||||
<n-radio-button value="numbered">数字翻页</n-radio-button>
|
||||
<n-radio-button value="load_more">加载更多</n-radio-button>
|
||||
<n-radio-button value="infinite">无限滚动</n-radio-button>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="评论列表分页">
|
||||
<n-radio-group v-model:value="basic.comment_pagination_mode">
|
||||
<n-space size="large">
|
||||
<n-radio-button value="numbered">数字翻页</n-radio-button>
|
||||
<n-radio-button value="load_more">加载更多</n-radio-button>
|
||||
<n-radio-button value="infinite">无限滚动</n-radio-button>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 3. 懒加载 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
|
||||
<h3>图片懒加载</h3>
|
||||
</div>
|
||||
<n-form :model="basic" label-placement="left" label-width="140" class="aksrt-form">
|
||||
<n-form-item label="启用懒加载">
|
||||
<n-switch v-model:value="basic.lazyload_enabled">
|
||||
<template #checked>已开启</template>
|
||||
<template #unchecked>已关闭</template>
|
||||
</n-switch>
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">开启后图片在进入视口时才加载,可显著提升页面加载速度。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="basic.lazyload_enabled" label="自定义占位图 URL">
|
||||
<n-input v-model:value="basic.lazyload_placeholder_url" placeholder="留空使用默认占位图" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 4. 头像 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
||||
<h3>Gravatar 头像源</h3>
|
||||
</div>
|
||||
<n-form :model="basic" label-placement="left" label-width="130" class="aksrt-form">
|
||||
<n-form-item label="头像服务源">
|
||||
<n-select v-model:value="basic.avatar_source" :options="avatarSourceOptions" placeholder="选择头像镜像源" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">国内站点建议优先选 Cravatar。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 5. 备案 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
|
||||
<h3>备案信息</h3>
|
||||
</div>
|
||||
<n-form :model="basic" label-placement="left" label-width="120" class="aksrt-form">
|
||||
<n-form-item label="ICP 备案号">
|
||||
<n-input v-model:value="basic.icp_number" placeholder="例如:ICP 备 xxxxxxxx 号" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="公安备案号">
|
||||
<n-input v-model:value="basic.police_number" placeholder="例如:公网安备 xxxxxx 号" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存全部</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
// 基础设置(aksrt_basic_settings)
|
||||
// 后端 /settings/basic 已经做了部分合并,本接口只发送基础字段;
|
||||
// auth 相关字段由 /settings/auth 独立维护,互不影响。
|
||||
const basic = reactive({
|
||||
comment_enabled: true,
|
||||
comment_requires_registration: false,
|
||||
avatar_source: 'cn.cravatar.com',
|
||||
icp_number: '',
|
||||
police_number: '',
|
||||
lazyload_enabled: true,
|
||||
lazyload_placeholder_url: '',
|
||||
post_pagination_mode: 'numbered',
|
||||
comment_pagination_mode: 'numbered',
|
||||
})
|
||||
|
||||
const avatarSourceOptions = [
|
||||
{ label: 'Cravatar (国内推荐)', value: 'cn.cravatar.com' },
|
||||
{ label: 'Gravatar 国内镜像', value: 'cn.gravatar.com' },
|
||||
{ label: 'Gravatar 官方', value: 'gravatar.com' },
|
||||
]
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const basicRes = await api.getBasicSettings()
|
||||
if (basicRes?.code === 'success' && basicRes.data) {
|
||||
const d = basicRes.data
|
||||
basic.comment_enabled = d.comment_enabled !== false
|
||||
basic.comment_requires_registration = !!d.comment_requires_registration
|
||||
basic.avatar_source = d.avatar_source || 'cn.cravatar.com'
|
||||
basic.icp_number = d.icp_number || ''
|
||||
basic.police_number = d.police_number || ''
|
||||
basic.lazyload_enabled = d.lazyload_enabled !== false
|
||||
basic.lazyload_placeholder_url = d.lazyload_placeholder_url || ''
|
||||
basic.post_pagination_mode = d.post_pagination_mode || 'numbered'
|
||||
basic.comment_pagination_mode = d.comment_pagination_mode || 'numbered'
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const basicPayload = {
|
||||
comment_enabled: basic.comment_enabled,
|
||||
comment_requires_registration: basic.comment_requires_registration,
|
||||
avatar_source: basic.avatar_source,
|
||||
icp_number: basic.icp_number,
|
||||
police_number: basic.police_number,
|
||||
lazyload_enabled: basic.lazyload_enabled,
|
||||
lazyload_placeholder_url: basic.lazyload_placeholder_url,
|
||||
post_pagination_mode: basic.post_pagination_mode,
|
||||
comment_pagination_mode: basic.comment_pagination_mode,
|
||||
}
|
||||
|
||||
const res = await api.saveBasicSettings(basicPayload)
|
||||
if (res?.code === 'success') {
|
||||
message.success('已保存')
|
||||
} else {
|
||||
throw new Error(res?.message || '保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader
|
||||
title="Banner 管理"
|
||||
desc="管理首页 Banner 数据,供固定首页和相关展示区域调用;可控制是否显示文字。"
|
||||
/>
|
||||
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>Banner 显示开关</strong></template>
|
||||
<n-form label-placement="left" label-width="180" class="aksrt-form">
|
||||
<n-form-item label="显示首页 Banner">
|
||||
<div class="aksrt-inline-setting">
|
||||
<n-switch v-model:value="bannerEnabled" :disabled="settingsLoading || settingsSaving" />
|
||||
<span class="aksrt-inline-setting__hint">关闭后,首页 Banner 区块将不再渲染。</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="aksrt-inline-setting__actions">
|
||||
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveBannerSettings">保存 Banner 开关</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
|
||||
<rect width="18" height="14" x="3" y="5" rx="2"/>
|
||||
<path d="M3 10h18"/>
|
||||
</svg>
|
||||
</span>
|
||||
<strong class="aksrt-card-header-row__title">Banner 列表 ({{ banners.length }})</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openCreate">+ 添加 Banner</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
v-if="banners.length"
|
||||
:columns="columns"
|
||||
:data="banners"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无 Banner,添加后即可在首页使用。" />
|
||||
</n-card>
|
||||
</n-spin>
|
||||
|
||||
<n-modal
|
||||
v-model:show="modalShow"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editing.id ? '编辑 Banner' : '添加 Banner'"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editing" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="editing.title" placeholder="开启文字后显示的主标题" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="editing.subtitle" placeholder="开启文字后显示的副标题" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Banner 图片">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="editing.image_url" placeholder="https://..." />
|
||||
<n-button secondary @click="pickImage">媒体库</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="editing.image_url" class="aksrt-modal-preview">
|
||||
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
|
||||
</div>
|
||||
|
||||
<n-form-item label="跳转链接">
|
||||
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="aksrt-modal-grid">
|
||||
<n-form-item label="显示文字">
|
||||
<n-switch v-model:value="editing.show_text" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="editing.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="显示元信息">
|
||||
<n-switch v-model:value="editing.show_meta" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="modalShow = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import { openMediaPicker } from '@/utils/media'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const settingsLoading = ref(false)
|
||||
const settingsSaving = ref(false)
|
||||
const banners = ref([])
|
||||
const bannerEnabled = ref(true)
|
||||
|
||||
const modalShow = ref(false)
|
||||
const editing = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
id: 0,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
image_url: '',
|
||||
link_url: '',
|
||||
show_text: true,
|
||||
enabled: true,
|
||||
show_meta: false,
|
||||
sort_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '预览',
|
||||
key: 'image_url',
|
||||
width: 90,
|
||||
render(row) {
|
||||
if (!row.image_url) {
|
||||
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
|
||||
}
|
||||
return h(NImage, {
|
||||
src: row.image_url,
|
||||
width: 64,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
previewSrc: row.image_url,
|
||||
style: 'border-radius:4px;display:block;',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文字',
|
||||
key: 'show_text',
|
||||
width: 70,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
render(row) {
|
||||
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
|
||||
default: () => (row.show_text ? '显示' : '隐藏'),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sort_order',
|
||||
width: 64,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
},
|
||||
{
|
||||
title: '元信息',
|
||||
key: 'show_meta',
|
||||
width: 78,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
render(row) {
|
||||
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
|
||||
default: () => (row.show_meta ? '显示' : '隐藏'),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 84,
|
||||
render(row) {
|
||||
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
|
||||
default: () => (row.enabled ? '启用' : '禁用'),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render(row) {
|
||||
return h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadBanners() {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await api.getHomepageBanners()
|
||||
if (res.code === 'success') {
|
||||
banners.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannerSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const res = await api.getHomepageSettings()
|
||||
const data = res?.data || {}
|
||||
bannerEnabled.value = data.homepage_module_banner_enabled !== false
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载 Banner 开关失败')
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBannerSettings() {
|
||||
settingsSaving.value = true
|
||||
try {
|
||||
const res = await api.saveHomepageSettings({
|
||||
homepage_module_banner_enabled: bannerEnabled.value,
|
||||
})
|
||||
if (res?.code === 'success') {
|
||||
message.success('Banner 开关已保存')
|
||||
return
|
||||
}
|
||||
throw new Error(res?.message || '保存 Banner 开关失败')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存 Banner 开关失败')
|
||||
} finally {
|
||||
settingsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = emptyForm()
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editing.value = {
|
||||
id: row.id,
|
||||
title: row.title || '',
|
||||
subtitle: row.subtitle || '',
|
||||
image_url: row.image_url || '',
|
||||
link_url: row.link_url || '',
|
||||
show_text: row.show_text !== false,
|
||||
enabled: !!row.enabled,
|
||||
show_meta: row.show_meta === true,
|
||||
sort_order: Number(row.sort_order) || 0,
|
||||
}
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
async function pickImage() {
|
||||
try {
|
||||
const media = await openMediaPicker({
|
||||
title: '选择 Banner 图片',
|
||||
buttonText: '使用这张 Banner 图',
|
||||
})
|
||||
if (media?.url) {
|
||||
editing.value.image_url = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '打开媒体库失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing.value.image_url) {
|
||||
message.warning('请填写 Banner 图片')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value.id) {
|
||||
await api.updateHomepageBanner(editing.value.id, editing.value)
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await api.createHomepageBanner(editing.value)
|
||||
message.success('已添加')
|
||||
}
|
||||
modalShow.value = false
|
||||
await loadBanners()
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除 Banner「${row.title || '未命名'}」?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.deleteHomepageBanner(row.id)
|
||||
message.success('已删除')
|
||||
await loadBanners()
|
||||
} catch (e) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBanners()
|
||||
loadBannerSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-inline-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-inline-setting__hint {
|
||||
color: #7a7f8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-inline-setting__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.aksrt-modal-preview {
|
||||
margin: -8px 0 18px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.aksrt-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-modal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,988 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="页脚配置" desc="管理页脚区域的联系方式、快速链接、更多链接、友情链接和版权信息。">
|
||||
<template #actions>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<n-tabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
animated
|
||||
class="aksrt-tabs"
|
||||
>
|
||||
<!-- ============== 品牌与Logo ============== -->
|
||||
<n-tab-pane name="brand" tab="品牌与Logo">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
|
||||
<h3>品牌列(第一列)</h3>
|
||||
</div>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="显示设备">
|
||||
<n-checkbox-group v-model:value="form.footer_t1_m_s">
|
||||
<n-space>
|
||||
<n-checkbox value="pc" label="PC" />
|
||||
<n-checkbox value="mobile" label="移动端" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="页脚 Logo(日间)">
|
||||
<div class="aksrt-media-row">
|
||||
<n-input v-model:value="form.footer_t1_img" placeholder="图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMedia('footer_t1_img')">选择</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img" @click="form.footer_t1_img = ''">清除</n-button>
|
||||
</div>
|
||||
<img v-if="form.footer_t1_img" :src="form.footer_t1_img" class="aksrt-media-preview" alt="footer logo day" />
|
||||
</n-form-item>
|
||||
<n-form-item label="页脚 Logo(夜间)">
|
||||
<div class="aksrt-media-row">
|
||||
<n-input v-model:value="form.footer_t1_img_dark" placeholder="图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMedia('footer_t1_img_dark')">选择</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img_dark" @click="form.footer_t1_img_dark = ''">清除</n-button>
|
||||
</div>
|
||||
<img v-if="form.footer_t1_img_dark" :src="form.footer_t1_img_dark" class="aksrt-media-preview" alt="footer logo night" />
|
||||
</n-form-item>
|
||||
<n-form-item label="品牌标题">
|
||||
<n-input v-model:value="form.footer_t1_t" placeholder="留空则使用站点名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="品牌列自定义代码">
|
||||
<n-input v-model:value="form.fcode_t1_code" type="textarea" :rows="3" placeholder="可选,显示在品牌区域底部" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 联系方式 ============== -->
|
||||
<n-tab-pane name="contacts" tab="联系方式">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">联系方式图标</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="addContact">+ 添加图标</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">页脚中的社交平台或联系入口。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="form.contacts.length"
|
||||
:columns="contactColumns"
|
||||
:data="form.contacts"
|
||||
:row-key="(row) => row._uid"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无联系方式图标" />
|
||||
|
||||
<n-divider>标准联系字段</n-divider>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="显示设备">
|
||||
<n-checkbox-group v-model:value="form.footer_contact_m_s">
|
||||
<n-space>
|
||||
<n-checkbox value="pc" label="PC" />
|
||||
<n-checkbox value="mobile" label="移动端" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="微信二维码">
|
||||
<div class="aksrt-media-row">
|
||||
<n-input v-model:value="form.footer_contact_wechat_img" placeholder="图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMedia('footer_contact_wechat_img')">选择</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_contact_wechat_img" @click="form.footer_contact_wechat_img = ''">清除</n-button>
|
||||
</div>
|
||||
<img v-if="form.footer_contact_wechat_img" :src="form.footer_contact_wechat_img" class="aksrt-media-preview" alt="wechat qr" />
|
||||
</n-form-item>
|
||||
<n-form-item label="QQ">
|
||||
<n-input v-model:value="form.footer_contact_qq" placeholder="QQ 号或链接" />
|
||||
</n-form-item>
|
||||
<n-form-item label="微博">
|
||||
<n-input v-model:value="form.footer_contact_weibo" placeholder="微博链接" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱">
|
||||
<n-input v-model:value="form.footer_contact_email" placeholder="邮箱地址" />
|
||||
</n-form-item>
|
||||
<n-form-item label="联系区上方自定义代码">
|
||||
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="2" placeholder="可选 HTML" />
|
||||
</n-form-item>
|
||||
<n-form-item label="联系区下方自定义代码">
|
||||
<n-input v-model:value="form.fcode_t2_code_2" type="textarea" :rows="2" placeholder="备案号等 HTML" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-divider>更多联系项</n-divider>
|
||||
<n-card class="aksrt-section-card" embedded>
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<strong class="aksrt-card-header-row__title">扩展联系项</strong>
|
||||
<n-button type="primary" size="small" @click="addContactMore">+ 添加</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">每项含描述、图标、链接、图片(hover 显示),用于补充标准联系方式。</p>
|
||||
<n-data-table
|
||||
v-if="form.footer_contact_more.length"
|
||||
:columns="contactMoreColumns"
|
||||
:data="form.footer_contact_more"
|
||||
:row-key="(_, i) => i"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无扩展联系项" />
|
||||
</n-card>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 快速链接 ============== -->
|
||||
<n-tab-pane name="quick" tab="快速链接">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">快速链接</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openLinkEditor('quick_links')">+ 添加链接</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">页脚第二列的常用导航链接。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="form.quick_links.length"
|
||||
:columns="createLinkColumns('quick_links')"
|
||||
:data="form.quick_links"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无快速链接" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 更多链接 ============== -->
|
||||
<n-tab-pane name="more" tab="更多链接">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">更多链接</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openLinkEditor('more_links')">+ 添加链接</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">页脚补充链接区域。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="form.more_links.length"
|
||||
:columns="createLinkColumns('more_links')"
|
||||
:data="form.more_links"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无更多链接" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 友情链接 ============== -->
|
||||
<n-tab-pane name="friends" tab="友情链接">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">友情链接 ({{ friendLinks.length }})</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openFriendEditor()">+ 添加友链</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">站点友情链接列表,支持 Logo 头像、名称、URL 与简短描述。</p>
|
||||
|
||||
<n-data-table
|
||||
v-if="friendLinks.length"
|
||||
:columns="friendColumns"
|
||||
:data="friendLinks"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无友情链接" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 二维码图片组 ============== -->
|
||||
<n-tab-pane name="mini_qr" tab="二维码图片组">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
|
||||
<strong class="aksrt-card-header-row__title">二维码图片组(最多 4 个)</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" :disabled="form.footer_mini_img.length >= 4" @click="addMiniImg">+ 添加</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">显示在页脚第三列区域,每项含文字描述和二维码图片。</p>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="120" class="aksrt-form">
|
||||
<n-form-item label="显示设备">
|
||||
<n-checkbox-group v-model:value="form.footer_mini_img_m_s">
|
||||
<n-space>
|
||||
<n-checkbox value="pc" label="PC" />
|
||||
<n-checkbox value="mobile" label="移动端" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div v-for="(item, idx) in form.footer_mini_img" :key="idx" class="aksrt-mini-img-row">
|
||||
<n-space align="center" :size="12" style="width: 100%;">
|
||||
<n-input v-model:value="item.text" placeholder="文字描述" style="width: 160px;" />
|
||||
<div class="aksrt-media-row" style="flex: 1;">
|
||||
<n-input v-model:value="item.img" placeholder="二维码图片 URL" readonly style="flex: 1;" />
|
||||
<n-button size="small" @click="pickMiniImg(idx)">选择</n-button>
|
||||
</div>
|
||||
<n-button size="small" quaternary type="error" @click="removeMiniImg(idx)">删除</n-button>
|
||||
</n-space>
|
||||
<img v-if="item.img" :src="item.img" class="aksrt-media-preview aksrt-media-preview--sm" :alt="item.text || 'qr'" />
|
||||
</div>
|
||||
<n-empty v-if="!form.footer_mini_img.length" description="暂无二维码图片" />
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 自定义代码 ============== -->
|
||||
<n-tab-pane name="custom_code" tab="自定义代码">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
|
||||
<h3>底部自定义代码</h3>
|
||||
</div>
|
||||
<p class="section-desc">插入在页脚底部区域(版权信息之后),支持 HTML。</p>
|
||||
|
||||
<n-form :model="form" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="fcode_customize_code">
|
||||
<n-input v-model:value="form.fcode_customize_code" type="textarea" :rows="6" placeholder="自定义 HTML 代码,显示在页脚最底部" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- ============== 版权与杂项 ============== -->
|
||||
<n-tab-pane name="misc" tab="版权与杂项">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<h3>其他设置</h3>
|
||||
</div>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
|
||||
<n-form-item label="页脚布局模板">
|
||||
<n-radio-group v-model:value="form.layout_template">
|
||||
<n-radio-button value="default">默认</n-radio-button>
|
||||
<n-radio-button value="compact">紧凑</n-radio-button>
|
||||
<n-radio-button value="centered">居中</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="热门分类数量">
|
||||
<n-space align="center" :size="10">
|
||||
<n-input-number v-model:value="form.hot_categories" :min="1" :max="20" style="width: 120px;" />
|
||||
<span class="aksrt-hint-text">范围 1 - 20</span>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义版权文案">
|
||||
<n-input
|
||||
v-model:value="form.copyright_text"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="留空则使用默认版权信息"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存页脚配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<!-- ===== 联系方式 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showContactModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="编辑联系方式图标"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingContact" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="图标类型">
|
||||
<n-select v-model:value="editingContact.icon" :options="iconOptions" placeholder="选择图标" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接地址">
|
||||
<n-input v-model:value="editingContact.url" placeholder="https://..." />
|
||||
</n-form-item>
|
||||
<n-form-item label="标题提示">
|
||||
<n-input v-model:value="editingContact.title" placeholder="鼠标悬停提示文案" />
|
||||
</n-form-item>
|
||||
<n-form-item label="启用状态">
|
||||
<n-switch v-model:value="editingContact.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showContactModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveContact">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- ===== 链接 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showLinkModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editingLinkType === 'quick_links' ? '编辑快速链接' : '编辑更多链接'"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingLink" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="链接名称">
|
||||
<n-input v-model:value="editingLink.name" placeholder="例如:首页" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接地址">
|
||||
<n-input v-model:value="editingLink.url" placeholder="https:// 或 /path" />
|
||||
</n-form-item>
|
||||
<n-form-item label="启用状态">
|
||||
<n-switch v-model:value="editingLink.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showLinkModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveLink">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- ===== 友情链接 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showFriendModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="添加友情链接"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingFriend" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="站点名称">
|
||||
<n-input v-model:value="editingFriend.name" placeholder="如:某人的博客" />
|
||||
</n-form-item>
|
||||
<n-form-item label="站点 URL">
|
||||
<n-input v-model:value="editingFriend.url" placeholder="https://" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Logo 图片 URL">
|
||||
<n-input v-model:value="editingFriend.logo" placeholder="图标 URL(可选,方形 PNG 推荐)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="editingFriend.description" placeholder="一句话简介(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showFriendModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="addingFriend" @click="saveFriend">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
<!-- ===== 扩展联系项 Modal ===== -->
|
||||
<n-modal
|
||||
v-model:show="showContactMoreModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="编辑扩展联系项"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editingContactMore" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="editingContactMore.desc" placeholder="如:微信公众号" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图标类名">
|
||||
<n-input v-model:value="editingContactMore.icon" placeholder="如:fab fa-weixin" />
|
||||
</n-form-item>
|
||||
<n-form-item label="链接地址">
|
||||
<n-input v-model:value="editingContactMore.link" placeholder="https://..." />
|
||||
</n-form-item>
|
||||
<n-form-item label="Hover 图片">
|
||||
<n-input v-model:value="editingContactMore.img" placeholder="图片 URL(可选,hover 显示)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showContactMoreModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveContactMore">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { NAvatar, NButton, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { openMediaPicker } from '@/utils/media'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const activeTab = ref('contacts')
|
||||
|
||||
const form = reactive({
|
||||
contacts: [],
|
||||
quick_links: [],
|
||||
more_links: [],
|
||||
hot_categories: 5,
|
||||
copyright_text: '',
|
||||
layout_template: 'default',
|
||||
// §2.3 三列结构化字段
|
||||
footer_t1_m_s: ['pc', 'mobile'],
|
||||
footer_t1_img: '',
|
||||
footer_t1_img_dark: '',
|
||||
footer_t1_t: '',
|
||||
fcode_t1_code: '',
|
||||
footer_contact_wechat_img: '',
|
||||
footer_contact_qq: '',
|
||||
footer_contact_weibo: '',
|
||||
footer_contact_email: '',
|
||||
footer_contact_m_s: ['pc', 'mobile'],
|
||||
footer_contact_more: [],
|
||||
fcode_t2_code_1: '',
|
||||
fcode_t2_code_2: '',
|
||||
footer_mini_img_m_s: ['pc', 'mobile'],
|
||||
footer_mini_img: [],
|
||||
fcode_customize_code: '',
|
||||
})
|
||||
|
||||
// ============== 联系方式 ==============
|
||||
const showContactModal = ref(false)
|
||||
const editingContactIndex = ref(-1)
|
||||
const editingContact = reactive({
|
||||
icon: '',
|
||||
url: '',
|
||||
title: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
let contactUid = 0
|
||||
|
||||
const iconOptions = [
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'X (Twitter)', value: 'x' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: 'QQ', value: 'qq' },
|
||||
{ label: '微博', value: 'weibo' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
{ label: 'Discord', value: 'discord' },
|
||||
{ label: 'Bilibili', value: 'bilibili' },
|
||||
{ label: 'LinkedIn', value: 'linkedin' },
|
||||
{ label: 'Facebook', value: 'facebook' },
|
||||
{ label: 'YouTube', value: 'youtube' },
|
||||
{ label: 'Instagram', value: 'instagram' },
|
||||
{ label: 'RSS / 网站', value: 'web' },
|
||||
]
|
||||
|
||||
function addContact() {
|
||||
Object.assign(editingContact, {
|
||||
icon: '',
|
||||
url: '',
|
||||
title: '',
|
||||
enabled: true,
|
||||
})
|
||||
editingContactIndex.value = -1
|
||||
showContactModal.value = true
|
||||
}
|
||||
|
||||
function editContact(row, index) {
|
||||
Object.assign(editingContact, {
|
||||
icon: row.icon,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
enabled: row.enabled,
|
||||
})
|
||||
editingContactIndex.value = index
|
||||
showContactModal.value = true
|
||||
}
|
||||
|
||||
function saveContact() {
|
||||
if (!editingContact.icon || !editingContact.url) {
|
||||
message.warning('请填写图标类型和链接地址')
|
||||
return
|
||||
}
|
||||
|
||||
const item = {
|
||||
_uid: editingContactIndex.value >= 0 ? form.contacts[editingContactIndex.value]._uid : ++contactUid,
|
||||
icon: editingContact.icon,
|
||||
url: editingContact.url,
|
||||
title: editingContact.title,
|
||||
enabled: editingContact.enabled,
|
||||
}
|
||||
|
||||
if (editingContactIndex.value >= 0) {
|
||||
form.contacts[editingContactIndex.value] = item
|
||||
} else {
|
||||
form.contacts.push(item)
|
||||
}
|
||||
|
||||
showContactModal.value = false
|
||||
}
|
||||
|
||||
function deleteContact(index) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定删除这个联系方式图标吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
form.contacts.splice(index, 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const contactColumns = [
|
||||
{
|
||||
title: '平台',
|
||||
key: 'icon',
|
||||
width: 120,
|
||||
render: (row) => iconOptions.find((item) => item.value === row.icon)?.label || row.icon,
|
||||
},
|
||||
{
|
||||
title: '链接',
|
||||
key: 'url',
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: '提示',
|
||||
key: 'title',
|
||||
ellipsis: { tooltip: true },
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (row) => h(
|
||||
NTag,
|
||||
{ size: 'small', type: row.enabled ? 'success' : 'default' },
|
||||
{ default: () => (row.enabled ? '启用' : '禁用') }
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => editContact(form.contacts[index], index) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteContact(index) }, { default: () => '删除' }),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
// ============== 快速 / 更多链接 ==============
|
||||
const showLinkModal = ref(false)
|
||||
const editingLinkType = ref('quick_links')
|
||||
const editingLinkIndex = ref(-1)
|
||||
const editingLink = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
function openLinkEditor(type, row = null, index = -1) {
|
||||
editingLinkType.value = type
|
||||
editingLinkIndex.value = index
|
||||
|
||||
Object.assign(editingLink, row || {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
sort_order: type === 'quick_links' ? form.quick_links.length : form.more_links.length,
|
||||
})
|
||||
|
||||
showLinkModal.value = true
|
||||
}
|
||||
|
||||
function saveLink() {
|
||||
if (!editingLink.name || !editingLink.url) {
|
||||
message.warning('请填写链接名称和链接地址')
|
||||
return
|
||||
}
|
||||
|
||||
const target = form[editingLinkType.value]
|
||||
const item = {
|
||||
id: editingLink.id || `link_${Date.now()}`,
|
||||
name: editingLink.name,
|
||||
url: editingLink.url,
|
||||
enabled: editingLink.enabled,
|
||||
sort_order: editingLink.sort_order ?? target.length,
|
||||
}
|
||||
|
||||
if (editingLinkIndex.value >= 0) {
|
||||
target[editingLinkIndex.value] = item
|
||||
} else {
|
||||
target.push(item)
|
||||
}
|
||||
|
||||
showLinkModal.value = false
|
||||
}
|
||||
|
||||
function deleteLink(type, index) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定删除这个链接吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
form[type].splice(index, 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createLinkColumns(type) {
|
||||
return [
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
key: 'url',
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (row) => h(
|
||||
NTag,
|
||||
{ size: 'small', type: row.enabled ? 'success' : 'default' },
|
||||
{ default: () => (row.enabled ? '启用' : '禁用') }
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => openLinkEditor(type, form[type][index], index) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteLink(type, index) }, { default: () => '删除' }),
|
||||
]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ============== 友情链接 ==============
|
||||
const friendLinks = ref([])
|
||||
const showFriendModal = ref(false)
|
||||
const addingFriend = ref(false)
|
||||
const editingFriend = reactive({
|
||||
name: '',
|
||||
url: '',
|
||||
logo: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const friendColumns = [
|
||||
{
|
||||
title: 'Logo',
|
||||
key: 'logo',
|
||||
width: 60,
|
||||
render: (row) => row.logo ? h(NAvatar, { src: row.logo, size: 32, round: true }) : h('span', { style: 'color:#bbb;font-size:12px;' }, '—'),
|
||||
},
|
||||
{ title: '名称', key: 'name', width: 140, ellipsis: { tooltip: true } },
|
||||
{ title: 'URL', key: 'url', ellipsis: { tooltip: true } },
|
||||
{ title: '描述', key: 'description', ellipsis: { tooltip: true }, className: 'aksrt-table-mobile-hide' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (row) => h(NButton, {
|
||||
quaternary: true,
|
||||
type: 'error',
|
||||
size: 'tiny',
|
||||
onClick: () => deleteFriend(row),
|
||||
}, { default: () => '删除' }),
|
||||
},
|
||||
]
|
||||
|
||||
function openFriendEditor() {
|
||||
Object.assign(editingFriend, {
|
||||
name: '',
|
||||
url: '',
|
||||
logo: '',
|
||||
description: '',
|
||||
})
|
||||
showFriendModal.value = true
|
||||
}
|
||||
|
||||
async function loadFriendLinks() {
|
||||
try {
|
||||
const res = await api.getFriendLinks()
|
||||
if (res.code === 'success') friendLinks.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error('[FriendLinks] load failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFriend() {
|
||||
if (!editingFriend.name || !editingFriend.url) {
|
||||
message.warning('请填写站点名称和 URL')
|
||||
return
|
||||
}
|
||||
addingFriend.value = true
|
||||
try {
|
||||
const res = await api.addFriendLink({
|
||||
name: editingFriend.name,
|
||||
url: editingFriend.url,
|
||||
logo: editingFriend.logo,
|
||||
description: editingFriend.description,
|
||||
sort_order: friendLinks.value.length,
|
||||
})
|
||||
if (res.code === 'success') {
|
||||
message.success(res.message || '已添加')
|
||||
showFriendModal.value = false
|
||||
await loadFriendLinks()
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '添加失败')
|
||||
} finally {
|
||||
addingFriend.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteFriend(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除友链「${row.name}」?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.deleteFriendLink(row.id)
|
||||
message.success('已删除')
|
||||
await loadFriendLinks()
|
||||
} catch (e) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============== 加载 / 保存 ==============
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [footerRes] = await Promise.all([
|
||||
api.getFooterSettings(),
|
||||
loadFriendLinks(),
|
||||
])
|
||||
if (footerRes.code === 'success' && footerRes.data) {
|
||||
const data = footerRes.data
|
||||
form.contacts = (data.contacts || []).map((item) => ({ ...item, _uid: ++contactUid }))
|
||||
form.quick_links = data.quick_links || []
|
||||
form.more_links = data.more_links || []
|
||||
form.hot_categories = data.hot_categories ?? 5
|
||||
form.copyright_text = data.copyright_text || ''
|
||||
form.layout_template = data.layout_template || 'default'
|
||||
// §2.3 三列结构化字段
|
||||
form.footer_t1_m_s = Array.isArray(data.footer_t1_m_s) ? data.footer_t1_m_s : ['pc', 'mobile']
|
||||
form.footer_t1_img = data.footer_t1_img || ''
|
||||
form.footer_t1_img_dark = data.footer_t1_img_dark || ''
|
||||
form.footer_t1_t = data.footer_t1_t || ''
|
||||
form.fcode_t1_code = data.fcode_t1_code || ''
|
||||
form.footer_contact_wechat_img = data.footer_contact_wechat_img || ''
|
||||
form.footer_contact_qq = data.footer_contact_qq || ''
|
||||
form.footer_contact_weibo = data.footer_contact_weibo || ''
|
||||
form.footer_contact_email = data.footer_contact_email || ''
|
||||
form.footer_contact_m_s = Array.isArray(data.footer_contact_m_s) ? data.footer_contact_m_s : ['pc', 'mobile']
|
||||
form.footer_contact_more = Array.isArray(data.footer_contact_more) ? data.footer_contact_more : []
|
||||
form.fcode_t2_code_1 = data.fcode_t2_code_1 || ''
|
||||
form.fcode_t2_code_2 = data.fcode_t2_code_2 || ''
|
||||
form.footer_mini_img_m_s = Array.isArray(data.footer_mini_img_m_s) ? data.footer_mini_img_m_s : ['pc', 'mobile']
|
||||
form.footer_mini_img = Array.isArray(data.footer_mini_img) ? data.footer_mini_img : []
|
||||
form.fcode_customize_code = data.fcode_customize_code || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Footer] load failed:', error)
|
||||
message.error(error?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
contacts: form.contacts.map(({ _uid, ...rest }) => rest),
|
||||
quick_links: form.quick_links,
|
||||
more_links: form.more_links,
|
||||
hot_categories: form.hot_categories,
|
||||
copyright_text: form.copyright_text,
|
||||
layout_template: form.layout_template,
|
||||
// §2.3 三列结构化字段
|
||||
footer_t1_m_s: form.footer_t1_m_s,
|
||||
footer_t1_img: form.footer_t1_img,
|
||||
footer_t1_img_dark: form.footer_t1_img_dark,
|
||||
footer_t1_t: form.footer_t1_t,
|
||||
fcode_t1_code: form.fcode_t1_code,
|
||||
footer_contact_wechat_img: form.footer_contact_wechat_img,
|
||||
footer_contact_qq: form.footer_contact_qq,
|
||||
footer_contact_weibo: form.footer_contact_weibo,
|
||||
footer_contact_email: form.footer_contact_email,
|
||||
footer_contact_m_s: form.footer_contact_m_s,
|
||||
footer_contact_more: form.footer_contact_more,
|
||||
fcode_t2_code_1: form.fcode_t2_code_1,
|
||||
fcode_t2_code_2: form.fcode_t2_code_2,
|
||||
footer_mini_img_m_s: form.footer_mini_img_m_s,
|
||||
footer_mini_img: form.footer_mini_img,
|
||||
fcode_customize_code: form.fcode_customize_code,
|
||||
}
|
||||
const res = await api.saveFooterSettings(payload)
|
||||
if (res.code === 'success') {
|
||||
message.success(res.message || '保存成功')
|
||||
} else {
|
||||
message.error(res.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Footer] save failed:', error)
|
||||
message.error(error?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 媒体选择 ==============
|
||||
async function pickMedia(field) {
|
||||
try {
|
||||
const media = await openMediaPicker({ title: '选择图片' })
|
||||
if (media?.url) {
|
||||
form[field] = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message) message.warning(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 扩展联系项 ==============
|
||||
function addContactMore() {
|
||||
form.footer_contact_more.push({ desc: '', icon: '', link: '', img: '' })
|
||||
}
|
||||
|
||||
function removeContactMore(index) {
|
||||
form.footer_contact_more.splice(index, 1)
|
||||
}
|
||||
|
||||
const showContactMoreModal = ref(false)
|
||||
const editingContactMoreIndex = ref(-1)
|
||||
const editingContactMore = reactive({ desc: '', icon: '', link: '', img: '' })
|
||||
|
||||
function editContactMore(row, index) {
|
||||
Object.assign(editingContactMore, row)
|
||||
editingContactMoreIndex.value = index
|
||||
showContactMoreModal.value = true
|
||||
}
|
||||
|
||||
function saveContactMore() {
|
||||
const item = { ...editingContactMore }
|
||||
if (editingContactMoreIndex.value >= 0) {
|
||||
form.footer_contact_more[editingContactMoreIndex.value] = item
|
||||
}
|
||||
showContactMoreModal.value = false
|
||||
}
|
||||
|
||||
const contactMoreColumns = [
|
||||
{ title: '描述', key: 'desc', width: 120 },
|
||||
{ title: '图标', key: 'icon', width: 100 },
|
||||
{ title: '链接', key: 'link', ellipsis: { tooltip: true } },
|
||||
{ title: '图片', key: 'img', width: 60, render: (row) => row.img ? h('img', { src: row.img, style: 'width:32px;height:32px;object-fit:cover;border-radius:4px;' }) : h('span', { style: 'color:#bbb;' }, '—') },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => editContactMore(form.footer_contact_more[index], index) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => removeContactMore(index) }, { default: () => '删除' }),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
// ============== 二维码图片组 ==============
|
||||
function addMiniImg() {
|
||||
if (form.footer_mini_img.length < 4) {
|
||||
form.footer_mini_img.push({ text: '', img: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function removeMiniImg(idx) {
|
||||
form.footer_mini_img.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function pickMiniImg(idx) {
|
||||
try {
|
||||
const media = await openMediaPicker({ title: '选择二维码图片' })
|
||||
if (media?.url) {
|
||||
form.footer_mini_img[idx].img = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message) message.warning(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.aksrt-media-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.aksrt-media-preview {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
max-height: 80px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
object-fit: contain;
|
||||
}
|
||||
.aksrt-media-preview--sm {
|
||||
max-width: 64px;
|
||||
max-height: 64px;
|
||||
}
|
||||
.aksrt-mini-img-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
|
||||
}
|
||||
.aksrt-mini-img-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,790 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="底部Tab配置" desc="自定义移动端底部导航栏的按钮,支持排序、图标配置和返回顶部功能。">
|
||||
<template #actions>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<!-- 基础设置 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><i class="fas fa-cog"></i></span>
|
||||
<strong class="aksrt-card-header-row__title">基础设置</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
|
||||
<n-form-item label="启用底部Tab">
|
||||
<n-switch v-model:value="form.enabled" />
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
|
||||
</n-form-item>
|
||||
<n-form-item label="向下滚动隐藏">
|
||||
<n-switch v-model:value="form.scroll_down_hide" />
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
|
||||
</n-form-item>
|
||||
<n-form-item label="文章页专属Tab">
|
||||
<n-select
|
||||
v-model:value="form.footer_tabbar_single"
|
||||
:options="[
|
||||
{ label: '默认按钮', value: 'default' },
|
||||
{ label: '文章扩展按钮(评论/收藏/分享)', value: 'extend' },
|
||||
]"
|
||||
style="width: 240px;"
|
||||
/>
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Tab按钮配置 -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon"><i class="fas fa-list-ul"></i></span>
|
||||
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="addItem">
|
||||
<template #icon><i class="fas fa-plus"></i></template>
|
||||
添加按钮
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="section-desc">配置移动端底部导航栏显示的按钮,支持拖拽排序。建议按钮数量不超过5个。</p>
|
||||
<div class="section-desc section-desc--tight">当前已启用 {{ enabledPreviewItems.length }} 个按钮,最多允许 5 个。</div>
|
||||
|
||||
<draggable
|
||||
v-if="form.items.length"
|
||||
v-model="form.items"
|
||||
item-key="id"
|
||||
handle=".tabbar-sort__handle"
|
||||
ghost-class="tabbar-sort__ghost"
|
||||
animation="180"
|
||||
class="tabbar-sort"
|
||||
@end="resetSortOrder"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="tabbar-sort__item">
|
||||
<div class="tabbar-sort__main">
|
||||
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<span class="tabbar-sort__icon">
|
||||
<i v-if="element.icon" :class="element.icon"></i>
|
||||
</span>
|
||||
<div class="tabbar-sort__meta">
|
||||
<div class="tabbar-sort__title-row">
|
||||
<strong>{{ element.text || '按钮' }}</strong>
|
||||
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
|
||||
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
|
||||
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
|
||||
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
|
||||
</div>
|
||||
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabbar-sort__actions">
|
||||
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
|
||||
<template #icon><i class="fas fa-edit" style="font-size:12px;" /></template>
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
|
||||
<template #icon><i class="fas fa-trash-alt" style="font-size:12px;" /></template>
|
||||
删除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存底部Tab配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
|
||||
class="aksrt-modal-card"
|
||||
style="max-width: 520px;"
|
||||
>
|
||||
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="按钮类型">
|
||||
<n-select
|
||||
v-model:value="editingItem.type"
|
||||
:options="typeOptions"
|
||||
placeholder="选择按钮类型"
|
||||
@update:value="handleTypeChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="图标 (Font Awesome)">
|
||||
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" />
|
||||
<template #feedback>
|
||||
<div class="icon-preview-row">
|
||||
<span>预览:</span>
|
||||
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i>
|
||||
<span v-else class="muted-text">无图标</span>
|
||||
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
|
||||
<i class="fas fa-external-link-alt"></i> Font Awesome 图标库
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="显示文字">
|
||||
<n-input v-model:value="editingItem.text" placeholder="例如: 首页" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="徽标文本">
|
||||
<n-input v-model:value="editingItem.badge" placeholder="例如: NEW, 9+,留空不显示" maxlength="4" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="徽标模式">
|
||||
<n-radio-group v-model:value="editingItem.badge_mode">
|
||||
<n-space>
|
||||
<n-radio value="static">静态文本</n-radio>
|
||||
<n-radio value="dynamic">动态徽标</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-alert v-if="editingItem.badge_mode === 'dynamic'" type="info" :show-icon="false" style="margin-bottom: 16px;">
|
||||
<template v-if="editingItem.type === 'auth'">未登录时显示提醒徽标,登录后自动隐藏。</template>
|
||||
<template v-else-if="editingItem.type === 'user'">登录用户显示评论数徽标,游客不显示。</template>
|
||||
<template v-else-if="editingItem.type === 'search'">搜索结果页显示 ON 徽标,其它页面隐藏。</template>
|
||||
<template v-else>当前类型不提供动态徽标,前端将回退为静态徽标。</template>
|
||||
</n-alert>
|
||||
|
||||
<n-form-item v-if="editingItem.type === 'category'" label="指定分类">
|
||||
<n-select
|
||||
v-model:value="editingItem.category_id"
|
||||
:options="categoryOptions"
|
||||
placeholder="留空则跳转分类聚合页"
|
||||
clearable
|
||||
filterable
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="editingItem.type === 'search'" label="默认搜索类型">
|
||||
<n-radio-group v-model:value="editingItem.search_default_type">
|
||||
<n-space>
|
||||
<n-radio value="post">文章</n-radio>
|
||||
<n-radio value="category">分类</n-radio>
|
||||
<n-radio value="tag">标签</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type)" label="链接地址">
|
||||
<n-input v-model:value="editingItem.link" :placeholder="editingItem.type === 'msg' ? '留空使用默认消息中心地址' : editingItem.type === 'pay_vip' ? '留空使用默认会员页地址' : editingItem.type === 'shop_cart' ? '留空使用默认购物车地址' : 'https:// 或 /path'" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="editingItem.type === 'link'" label="新窗口打开">
|
||||
<n-switch v-model:value="editingItem.new_window" />
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="editingItem.type === 'add'" class="add-items-editor">
|
||||
<div class="add-items-editor__head">
|
||||
<strong>发布菜单项</strong>
|
||||
<n-button size="small" type="primary" @click="appendAddItem">
|
||||
<template #icon><i class="fas fa-plus" /></template>
|
||||
添加子项
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="add-items-editor__presets">
|
||||
<span class="add-items-editor__presets-label">快速添加:</span>
|
||||
<n-button
|
||||
v-for="preset in addPresets"
|
||||
:key="preset.url"
|
||||
size="tiny"
|
||||
secondary
|
||||
:type="editingItem.add_items.some((item) => item.url === preset.url) ? 'success' : 'default'"
|
||||
:disabled="editingItem.add_items.some((item) => item.url === preset.url)"
|
||||
@click="appendAddPreset(preset)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
|
||||
<draggable
|
||||
v-model="editingItem.add_items"
|
||||
item-key="id"
|
||||
handle=".add-items-editor__drag"
|
||||
ghost-class="add-items-editor__ghost"
|
||||
animation="180"
|
||||
>
|
||||
<template #item="{ element, index: subIndex }">
|
||||
<div class="add-items-editor__row">
|
||||
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
||||
<n-input v-model:value="element.icon" placeholder="图标,例如:fas fa-pen" />
|
||||
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
||||
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
|
||||
<template #icon><i class="fas fa-trash-alt" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<div v-else class="muted-text">当前没有子项,前端将回退为默认发布入口。</div>
|
||||
</div>
|
||||
|
||||
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
|
||||
<n-switch v-model:value="editingItem.ontop" />
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用状态">
|
||||
<n-switch v-model:value="editingItem.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="showModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="saveItem">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const form = reactive({
|
||||
enabled: true,
|
||||
scroll_hide: false,
|
||||
scroll_down_hide: true,
|
||||
footer_tabbar_single: 'default',
|
||||
items: [],
|
||||
})
|
||||
|
||||
const editingItem = reactive({
|
||||
id: '',
|
||||
type: 'link',
|
||||
icon: '',
|
||||
text: '',
|
||||
link: '',
|
||||
badge: '',
|
||||
badge_mode: 'static',
|
||||
category_id: null,
|
||||
search_default_type: 'post',
|
||||
new_window: false,
|
||||
add_items: [],
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
ontop: false,
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '首页', value: 'home' },
|
||||
{ label: '分类页', value: 'category' },
|
||||
{ label: '链接', value: 'link' },
|
||||
{ label: '中央发布按钮', value: 'add' },
|
||||
{ label: '用户中心', value: 'user' },
|
||||
{ label: '登录/我的', value: 'auth' },
|
||||
{ label: '搜索', value: 'search' },
|
||||
{ label: '消息中心', value: 'msg' },
|
||||
{ label: '开通会员', value: 'pay_vip' },
|
||||
{ label: '购物车', value: 'shop_cart' },
|
||||
]
|
||||
|
||||
const typeLabelMap = {
|
||||
home: '首页',
|
||||
category: '分类页',
|
||||
link: '链接',
|
||||
add: '中央发布按钮',
|
||||
user: '用户中心',
|
||||
auth: '登录/我的',
|
||||
search: '搜索',
|
||||
msg: '消息中心',
|
||||
pay_vip: '开通会员',
|
||||
shop_cart: '购物车',
|
||||
}
|
||||
|
||||
const typeDefaults = {
|
||||
home: { icon: 'fas fa-home', text: '首页' },
|
||||
category: { icon: 'fas fa-th-large', text: '分类' },
|
||||
link: { icon: 'fas fa-link', text: '链接' },
|
||||
add: { icon: 'fas fa-plus', text: '发布' },
|
||||
user: { icon: 'fas fa-user', text: '用户中心' },
|
||||
auth: { icon: 'fas fa-user', text: '我的' },
|
||||
search: { icon: 'fas fa-search', text: '搜索' },
|
||||
msg: { icon: 'fas fa-bell', text: '消息' },
|
||||
pay_vip: { icon: 'fas fa-crown', text: '会员' },
|
||||
shop_cart: { icon: 'fas fa-shopping-cart', text: '购物车' },
|
||||
}
|
||||
|
||||
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
|
||||
|
||||
function getItemSummary(item) {
|
||||
if (item.type === 'home') return item.ontop ? '首页按钮,滚动后切换返回顶部' : '首页按钮'
|
||||
if (item.type === 'category') {
|
||||
if (item.category_id) {
|
||||
const target = categoryOptions.value.find((option) => option.value === item.category_id)
|
||||
return target ? `跳转到分类: ${target.label}` : `跳转到分类 ID ${item.category_id}`
|
||||
}
|
||||
return '跳转到分类聚合页'
|
||||
}
|
||||
if (item.type === 'add') {
|
||||
return item.add_items?.length ? `展开 ${item.add_items.length} 个发布入口` : '点击后打开默认发布入口'
|
||||
}
|
||||
if (item.type === 'search') return `打开顶部搜索面板,默认搜索${item.search_default_type === 'category' ? '分类' : item.search_default_type === 'tag' ? '标签' : '文章'}`
|
||||
if (item.type === 'user') return '自动跳转到用户中心或登录页'
|
||||
if (item.type === 'auth') return '登录用户显示我的,游客显示登录'
|
||||
if (item.type === 'msg') return '跳转到消息中心页面'
|
||||
if (item.type === 'pay_vip') return item.link || '跳转到会员开通页'
|
||||
if (item.type === 'shop_cart') return item.link || '跳转到购物车页面'
|
||||
return item.link || '自定义链接按钮'
|
||||
}
|
||||
|
||||
const addPresets = [
|
||||
{ label: '写文章', icon: 'fas fa-pen-fancy', url: '/post-new' },
|
||||
{ label: '投稿页', icon: 'fas fa-file-alt', url: '/submit' },
|
||||
{ label: '发布资源', icon: 'fas fa-cloud-upload-alt', url: '/resource-new' },
|
||||
{ label: '创作中心', icon: 'fas fa-palette', url: '/creator' },
|
||||
]
|
||||
|
||||
function appendAddPreset(preset) {
|
||||
if (editingItem.add_items.some((item) => item.url === preset.url)) return
|
||||
editingItem.add_items.push({
|
||||
id: `add_${Date.now()}_${editingItem.add_items.length}`,
|
||||
label: preset.label,
|
||||
icon: preset.icon,
|
||||
url: preset.url,
|
||||
})
|
||||
}
|
||||
|
||||
function appendAddItem() {
|
||||
editingItem.add_items.push({
|
||||
id: `add_${Date.now()}_${editingItem.add_items.length}`,
|
||||
label: '',
|
||||
icon: 'fas fa-pen',
|
||||
url: '',
|
||||
})
|
||||
}
|
||||
|
||||
function removeAddItem(index) {
|
||||
editingItem.add_items.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetSortOrder() {
|
||||
form.items = form.items.map((item, index) => ({
|
||||
...item,
|
||||
sort_order: index,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyTypeDefaults(type, force = false) {
|
||||
const preset = typeDefaults[type] || typeDefaults.link
|
||||
if (force || !editingItem.icon) editingItem.icon = preset.icon
|
||||
if (force || !editingItem.text) editingItem.text = preset.text
|
||||
if (!['link', 'msg', 'pay_vip', 'shop_cart'].includes(type)) editingItem.link = ''
|
||||
if (type !== 'category') editingItem.category_id = null
|
||||
if (type !== 'search') editingItem.search_default_type = 'post'
|
||||
if (type !== 'add') editingItem.add_items = []
|
||||
if (type !== 'home') editingItem.ontop = false
|
||||
}
|
||||
|
||||
function handleTypeChange(value) {
|
||||
applyTypeDefaults(value, false)
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (form.items.length >= 5) {
|
||||
message.warning('底部Tab最多只能配置 5 个按钮')
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(editingItem, {
|
||||
id: `tab_${Date.now()}`,
|
||||
type: 'link',
|
||||
icon: 'fas fa-link',
|
||||
text: '链接',
|
||||
link: '',
|
||||
badge: '',
|
||||
badge_mode: 'static',
|
||||
category_id: null,
|
||||
search_default_type: 'post',
|
||||
new_window: false,
|
||||
add_items: [],
|
||||
enabled: true,
|
||||
sort_order: form.items.length,
|
||||
ontop: false,
|
||||
})
|
||||
editingIndex.value = -1
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function editItem(index) {
|
||||
const item = form.items[index]
|
||||
Object.assign(editingItem, {
|
||||
id: item.id,
|
||||
type: item.type || 'link',
|
||||
icon: item.icon || '',
|
||||
text: item.text || '',
|
||||
link: item.link || '',
|
||||
badge: item.badge || '',
|
||||
badge_mode: item.badge_mode || 'static',
|
||||
category_id: item.category_id || null,
|
||||
search_default_type: item.search_default_type || 'post',
|
||||
new_window: !!item.new_window,
|
||||
add_items: Array.isArray(item.add_items) ? item.add_items.map((sub) => ({ ...sub })) : [],
|
||||
enabled: item.enabled !== false,
|
||||
sort_order: item.sort_order ?? index,
|
||||
ontop: item.ontop || false,
|
||||
})
|
||||
editingIndex.value = index
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function saveItem() {
|
||||
if (editingIndex.value < 0 && form.items.length >= 5) {
|
||||
message.warning('底部Tab最多只能配置 5 个按钮')
|
||||
return
|
||||
}
|
||||
if (!editingItem.icon) {
|
||||
message.warning('请填写图标')
|
||||
return
|
||||
}
|
||||
if (!editingItem.text) {
|
||||
message.warning('请填写显示文字')
|
||||
return
|
||||
}
|
||||
|
||||
const item = {
|
||||
id: editingItem.id || `tab_${Date.now()}`,
|
||||
type: editingItem.type,
|
||||
icon: editingItem.icon,
|
||||
text: editingItem.text,
|
||||
link: ['link', 'msg', 'pay_vip', 'shop_cart'].includes(editingItem.type) ? editingItem.link : '',
|
||||
badge: (editingItem.badge || '').trim(),
|
||||
badge_mode: editingItem.badge_mode,
|
||||
category_id: editingItem.type === 'category' ? (editingItem.category_id || 0) : 0,
|
||||
search_default_type: editingItem.type === 'search' ? editingItem.search_default_type : 'post',
|
||||
new_window: editingItem.type === 'link' ? !!editingItem.new_window : false,
|
||||
add_items: editingItem.type === 'add'
|
||||
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
|
||||
id: sub.id || `add_${Date.now()}`,
|
||||
label: sub.label,
|
||||
icon: sub.icon || 'fas fa-pen',
|
||||
url: sub.url,
|
||||
}))
|
||||
: [],
|
||||
enabled: editingItem.enabled,
|
||||
sort_order: editingItem.sort_order,
|
||||
ontop: editingItem.type === 'home' ? editingItem.ontop : false,
|
||||
}
|
||||
|
||||
if (editingIndex.value >= 0) {
|
||||
form.items[editingIndex.value] = item
|
||||
} else {
|
||||
form.items.push(item)
|
||||
}
|
||||
|
||||
showModal.value = false
|
||||
message.success(editingIndex.value >= 0 ? '已更新' : '已添加')
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const res = await api.getCategories()
|
||||
if (res.code === 'success' && Array.isArray(res.data)) {
|
||||
categoryOptions.value = res.data.map((item) => ({
|
||||
label: item.name,
|
||||
value: Number(item.id),
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FooterTabbar] load categories failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteItem(index) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除「${form.items[index].text}」按钮吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
form.items.splice(index, 1)
|
||||
resetSortOrder()
|
||||
message.success('已删除')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [res] = await Promise.all([api.getFooterTabbarSettings(), loadCategories()])
|
||||
if (res.code === 'success' && res.data) {
|
||||
const data = res.data
|
||||
form.enabled = data.enabled !== false
|
||||
form.scroll_hide = data.scroll_hide || false
|
||||
form.scroll_down_hide = data.scroll_down_hide !== false
|
||||
form.footer_tabbar_single = data.footer_tabbar_single || 'default'
|
||||
form.items = (data.items || []).map((item, idx) => ({
|
||||
...item,
|
||||
badge: item.badge || '',
|
||||
badge_mode: item.badge_mode || 'static',
|
||||
category_id: item.category_id || 0,
|
||||
search_default_type: item.search_default_type || 'post',
|
||||
new_window: !!item.new_window,
|
||||
add_items: Array.isArray(item.add_items) ? item.add_items : [],
|
||||
sort_order: item.sort_order ?? idx,
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FooterTabbar] load failed:', e)
|
||||
message.error('加载配置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (form.items.length > 5) {
|
||||
message.warning('底部Tab最多只能配置 5 个按钮')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
enabled: form.enabled,
|
||||
scroll_hide: form.scroll_hide,
|
||||
scroll_down_hide: form.scroll_down_hide,
|
||||
footer_tabbar_single: form.footer_tabbar_single,
|
||||
items: form.items.map((item, idx) => ({
|
||||
...item,
|
||||
sort_order: idx,
|
||||
})),
|
||||
}
|
||||
const res = await api.saveFooterTabbarSettings(payload)
|
||||
if (res.code === 'success') {
|
||||
message.success(res.message || '保存成功')
|
||||
} else {
|
||||
message.error(res.message || '保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FooterTabbar] save failed:', e)
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-preview-row a {
|
||||
color: var(--n-primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon-preview-row a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
color: var(--n-text-color-3);
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.section-desc--tight {
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.add-items-editor {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.add-items-editor__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.add-items-editor__presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-items-editor__presets-label {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
}
|
||||
|
||||
.add-items-editor__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.add-items-editor__row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-items-editor__drag {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f3f5f7;
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.add-items-editor__ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tabbar-sort {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tabbar-sort__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tabbar-sort__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabbar-sort__handle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f3f5f7;
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tabbar-sort__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
color: #18a058;
|
||||
font-size: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tabbar-sort__meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabbar-sort__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tabbar-sort__desc {
|
||||
color: var(--n-text-color-3);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tabbar-sort__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tabbar-sort__ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.tabbar-sort__item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tabbar-sort__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-items-editor__row {
|
||||
grid-template-columns: 32px 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="Hero 轮播管理" desc="管理首页 Hero 轮播数据,供固定首页和相关展示区域调用;标题、副标题可留空。" />
|
||||
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>Hero 显示开关</strong></template>
|
||||
<n-form label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="显示首页 Hero">
|
||||
<div class="aksrt-inline-setting">
|
||||
<n-switch v-model:value="heroEnabled" :disabled="settingsLoading || settingsSaving" />
|
||||
<span class="aksrt-inline-setting__hint">关闭后,首页 Hero 区块将不再渲染。</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="aksrt-inline-setting__actions">
|
||||
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveHeroSettings">保存 Hero 开关</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-card-header-row">
|
||||
<div class="aksrt-card-header-row__left">
|
||||
<span class="header-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
||||
<circle cx="9" cy="9" r="2"/>
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||
</svg>
|
||||
</span>
|
||||
<strong class="aksrt-card-header-row__title">Hero 列表 ({{ slides.length }})</strong>
|
||||
</div>
|
||||
<n-button type="primary" size="small" @click="openCreate">+ 添加 Hero</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
v-if="slides.length"
|
||||
:columns="columns"
|
||||
:data="slides"
|
||||
:row-key="(row) => row.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无 Hero,添加后首页即可使用。" />
|
||||
</n-card>
|
||||
</n-spin>
|
||||
|
||||
<!-- 编辑/新增 弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="modalShow"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="editing.id ? '编辑 Hero' : '添加 Hero'"
|
||||
class="aksrt-modal-card"
|
||||
>
|
||||
<n-form :model="editing" label-placement="top" class="aksrt-form">
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="editing.title" placeholder="可选,主标题" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="副标题">
|
||||
<n-input v-model:value="editing.subtitle" placeholder="可选,副标题" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="背景图 URL">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="editing.image_url" placeholder="https://..." />
|
||||
<n-button secondary @click="pickImage">媒体库</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="editing.image_url" class="aksrt-modal-preview">
|
||||
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
|
||||
</div>
|
||||
|
||||
<n-form-item label="跳转链接">
|
||||
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="描述文字">
|
||||
<n-input v-model:value="editing.description" type="textarea" :rows="2" placeholder="可选,显示在副标题下方" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="视频背景 URL">
|
||||
<n-input v-model:value="editing.video_url" placeholder="可选,MP4 视频地址替代背景图" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="aksrt-modal-grid">
|
||||
<n-form-item label="链接打开方式">
|
||||
<n-radio-group v-model:value="editing.link_target">
|
||||
<n-radio-button value="_self">当前窗口</n-radio-button>
|
||||
<n-radio-button value="_blank">新窗口</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="文字对齐">
|
||||
<n-radio-group v-model:value="editing.text_align">
|
||||
<n-radio-button value="left">左</n-radio-button>
|
||||
<n-radio-button value="center">居中</n-radio-button>
|
||||
<n-radio-button value="right">右</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="设备可见性">
|
||||
<n-radio-group v-model:value="editing.device_visibility">
|
||||
<n-radio-button value="all">全部</n-radio-button>
|
||||
<n-radio-button value="pc">仅电脑</n-radio-button>
|
||||
<n-radio-button value="mobile">仅移动</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="文字视差">
|
||||
<n-switch v-model:value="editing.text_parallax" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="文字大小(PC)">
|
||||
<n-input v-model:value="editing.text_size_pc" placeholder="如 48px,留空默认" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="文字大小(移动)">
|
||||
<n-input v-model:value="editing.text_size_m" placeholder="如 28px,留空默认" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-modal-grid">
|
||||
<n-form-item label="显示文字">
|
||||
<n-switch v-model:value="editing.show_text" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="editing.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="显示元信息">
|
||||
<n-switch v-model:value="editing.show_meta" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 图片图层 -->
|
||||
<n-divider title-placement="left" style="margin:16px 0 12px;">
|
||||
图片图层(视差叠加)
|
||||
</n-divider>
|
||||
<div v-for="(layer, idx) in editing.layers" :key="idx" class="aksrt-layer-item">
|
||||
<div class="aksrt-layer-item__header">
|
||||
<strong>图层 {{ idx + 1 }}</strong>
|
||||
<n-button size="tiny" type="error" quaternary @click="removeLayer(idx)">删除</n-button>
|
||||
</div>
|
||||
<n-form-item label="图片">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="layer.image" placeholder="图片 URL" />
|
||||
<n-button secondary @click="pickLayerImage(idx)">媒体库</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<div class="aksrt-modal-grid">
|
||||
<n-form-item label="原始尺寸">
|
||||
<n-switch v-model:value="layer.free_size" />
|
||||
</n-form-item>
|
||||
<n-form-item label="对齐方式">
|
||||
<n-select v-model:value="layer.align" :options="[
|
||||
{ label: '居中', value: 'center' },
|
||||
{ label: '左上', value: 'left top' },
|
||||
{ label: '右上', value: 'right top' },
|
||||
{ label: '左下', value: 'left bottom' },
|
||||
{ label: '右下', value: 'right bottom' },
|
||||
]" />
|
||||
</n-form-item>
|
||||
<n-form-item label="视差">
|
||||
<n-switch v-model:value="layer.parallax" />
|
||||
</n-form-item>
|
||||
<n-form-item label="视差强度" v-if="layer.parallax">
|
||||
<n-slider v-model:value="layer.parallax_scale" :min="0" :max="1" :step="0.05" />
|
||||
</n-form-item>
|
||||
<n-form-item label="视差透明度" v-if="layer.parallax">
|
||||
<n-slider v-model:value="layer.parallax_opacity" :min="0" :max="1" :step="0.05" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" dashed block @click="addLayer" style="margin-top:8px;">+ 添加图层</n-button>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="aksrt-modal-footer">
|
||||
<n-button @click="modalShow = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import { openMediaPicker } from '@/utils/media'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const settingsLoading = ref(false)
|
||||
const settingsSaving = ref(false)
|
||||
const slides = ref([])
|
||||
const heroEnabled = ref(true)
|
||||
|
||||
const modalShow = ref(false)
|
||||
const editing = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
id: 0,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
image_url: '',
|
||||
video_url: '',
|
||||
link_url: '',
|
||||
link_target: '_self',
|
||||
text_align: 'left',
|
||||
device_visibility: 'all',
|
||||
enabled: true,
|
||||
show_text: true,
|
||||
show_meta: false,
|
||||
sort_order: 0,
|
||||
text_size_pc: '',
|
||||
text_size_m: '',
|
||||
text_parallax: false,
|
||||
layers: [],
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '预览',
|
||||
key: 'image_url',
|
||||
width: 90,
|
||||
render(row) {
|
||||
if (!row.image_url) {
|
||||
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
|
||||
}
|
||||
return h(NImage, {
|
||||
src: row.image_url,
|
||||
width: 64,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
previewSrc: row.image_url,
|
||||
style: 'border-radius:4px;display:block;',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文字',
|
||||
key: 'show_text',
|
||||
width: 70,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
render(row) {
|
||||
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
|
||||
default: () => (row.show_text ? '显示' : '隐藏'),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '元信息',
|
||||
key: 'show_meta',
|
||||
width: 78,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
render(row) {
|
||||
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
|
||||
default: () => (row.show_meta ? '显示' : '隐藏'),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '跳转',
|
||||
key: 'link_url',
|
||||
ellipsis: { tooltip: true },
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
render(row) {
|
||||
return row.link_url || h('span', { style: 'color:#bbb;font-size:12px;' }, '—')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sort_order',
|
||||
width: 64,
|
||||
className: 'aksrt-table-mobile-hide',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 84,
|
||||
render(row) {
|
||||
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
|
||||
default: () => (row.enabled ? '启用' : '禁用'),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render(row) {
|
||||
return h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadSlides() {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await api.getHeroes()
|
||||
if (res.code === 'success') {
|
||||
slides.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHeroSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const res = await api.getHomepageSettings()
|
||||
const data = res?.data || {}
|
||||
heroEnabled.value = data.homepage_module_hero_enabled !== false
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载 Hero 开关失败')
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHeroSettings() {
|
||||
settingsSaving.value = true
|
||||
try {
|
||||
const res = await api.saveHomepageSettings({
|
||||
homepage_module_hero_enabled: heroEnabled.value,
|
||||
})
|
||||
if (res?.code === 'success') {
|
||||
message.success('Hero 开关已保存')
|
||||
return
|
||||
}
|
||||
throw new Error(res?.message || '保存 Hero 开关失败')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存 Hero 开关失败')
|
||||
} finally {
|
||||
settingsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = emptyForm()
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editing.value = {
|
||||
id: row.id,
|
||||
title: row.title || '',
|
||||
subtitle: row.subtitle || '',
|
||||
description: row.description || '',
|
||||
image_url: row.image_url || '',
|
||||
video_url: row.video_url || '',
|
||||
link_url: row.link_url || '',
|
||||
link_target: row.link_target || '_self',
|
||||
text_align: row.text_align || 'left',
|
||||
device_visibility: row.device_visibility || 'all',
|
||||
show_text: row.show_text !== false,
|
||||
enabled: !!row.enabled,
|
||||
show_meta: row.show_meta === true,
|
||||
sort_order: Number(row.sort_order) || 0,
|
||||
text_size_pc: row.text_size_pc || '',
|
||||
text_size_m: row.text_size_m || '',
|
||||
text_parallax: !!row.text_parallax,
|
||||
layers: Array.isArray(row.layers) ? row.layers : [],
|
||||
}
|
||||
modalShow.value = true
|
||||
}
|
||||
|
||||
async function pickImage() {
|
||||
try {
|
||||
const media = await openMediaPicker({
|
||||
title: '选择 Hero 背景图',
|
||||
buttonText: '使用这张 Hero 图',
|
||||
})
|
||||
if (media?.url) {
|
||||
editing.value.image_url = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '打开媒体库失败')
|
||||
}
|
||||
}
|
||||
|
||||
function addLayer() {
|
||||
editing.value.layers.push({
|
||||
image: '',
|
||||
free_size: false,
|
||||
align: 'center',
|
||||
parallax: false,
|
||||
parallax_scale: 0.3,
|
||||
parallax_opacity: 0.6,
|
||||
})
|
||||
}
|
||||
|
||||
function removeLayer(idx) {
|
||||
editing.value.layers.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function pickLayerImage(idx) {
|
||||
try {
|
||||
const media = await openMediaPicker({
|
||||
title: '选择图层图片',
|
||||
buttonText: '使用这张图',
|
||||
})
|
||||
if (media?.url) {
|
||||
editing.value.layers[idx].image = media.url
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '打开媒体库失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing.value.image_url) {
|
||||
message.warning('请填写 Hero 背景图')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value.id) {
|
||||
await api.updateHero(editing.value.id, editing.value)
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await api.createHero(editing.value)
|
||||
message.success('已添加')
|
||||
}
|
||||
modalShow.value = false
|
||||
await loadSlides()
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除 Hero「${row.title || '未命名'}」?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.deleteHero(row.id)
|
||||
message.success('已删除')
|
||||
await loadSlides()
|
||||
} catch (e) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSlides()
|
||||
loadHeroSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-inline-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-inline-setting__hint {
|
||||
color: #7a7f8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-inline-setting__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.aksrt-modal-preview {
|
||||
margin: -8px 0 18px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.aksrt-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.aksrt-layer-item {
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.aksrt-layer-item__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-modal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,617 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader
|
||||
title="首页配置"
|
||||
desc="配置首页内容模型、模块编排、分类 Tab、卡片样式和交互方式。"
|
||||
>
|
||||
<template #actions>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<!-- Module Workstation Tab -->
|
||||
<n-tab-pane name="modules" tab="模块编排">
|
||||
<div class="aksrt-homepage__workstation">
|
||||
<div class="aksrt-homepage__canvas-panel">
|
||||
<ModuleCanvas
|
||||
:modules="homeModules"
|
||||
:selected-index="selectedModuleIndex"
|
||||
@select="selectedModuleIndex = $event"
|
||||
@add="handleAddModule"
|
||||
@remove="handleRemoveModule"
|
||||
@copy="handleCopyModule"
|
||||
@toggle="handleToggleModule"
|
||||
@move="handleMoveModule"
|
||||
@pick-preset="showPresetPicker = true"
|
||||
@export="handleExportModules"
|
||||
@import="showImportDialog = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-homepage__inspector-panel">
|
||||
<ModuleInspector
|
||||
:module="selectedModule"
|
||||
:module-index="selectedModuleIndex"
|
||||
@update="handleModuleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- Main Stream & Tabs Tab -->
|
||||
<n-tab-pane name="stream" tab="主列表 & Tab">
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<!-- Content Model -->
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>内容模型</strong></template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="首页内容模型">
|
||||
<n-radio-group v-model:value="form.homepage_content_model">
|
||||
<n-space vertical>
|
||||
<n-radio value="mixed">
|
||||
<n-text>混合模式</n-text>
|
||||
<n-text depth="3" style="margin-left:8px;">文章 + 资源(默认)</n-text>
|
||||
</n-radio>
|
||||
<n-radio value="post">
|
||||
<n-text>纯文章</n-text>
|
||||
<n-text depth="3" style="margin-left:8px;">仅标准文章</n-text>
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Main Stream -->
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>主列表(最新发布)</strong></template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="启用主列表">
|
||||
<n-switch v-model:value="form.home_posts_list_enabled" />
|
||||
<template #feedback><span class="aksrt-hint-text">关闭后首页将不显示文章列表(仅显示模块)。</span></template>
|
||||
</n-form-item>
|
||||
<n-form-item label="区块标题">
|
||||
<n-input v-model:value="form.home_list_title" placeholder="最新发布" />
|
||||
</n-form-item>
|
||||
<n-form-item label="默认排序">
|
||||
<n-select v-model:value="form.homepage_default_sort" :options="allSortOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item label="启用排序栏">
|
||||
<n-switch v-model:value="form.home_list_sort_enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.home_list_sort_enabled" label="排序栏样式">
|
||||
<n-radio-group v-model:value="form.home_list_sort_dropdown">
|
||||
<n-space size="large">
|
||||
<n-radio-button :value="false">内联链接</n-radio-button>
|
||||
<n-radio-button :value="true">下拉菜单</n-radio-button>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.home_list_sort_enabled" label="可用排序选项">
|
||||
<n-select
|
||||
v-model:value="form.home_list_sort_options"
|
||||
:options="allSortOptions"
|
||||
multiple
|
||||
placeholder="选择排序选项"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Exclusions -->
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>排除规则</strong></template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="排除文章 ID">
|
||||
<n-input
|
||||
v-model:value="excludePostIdsText"
|
||||
type="textarea"
|
||||
placeholder="逗号分隔的文章 ID,如 12,34,56"
|
||||
:rows="2"
|
||||
/>
|
||||
<template #feedback><span class="aksrt-hint-text">主列表和 Tab 查询均排除这些文章。</span></template>
|
||||
</n-form-item>
|
||||
<n-form-item label="排除分类 ID">
|
||||
<n-input
|
||||
v-model:value="excludeCategoryIdsText"
|
||||
type="textarea"
|
||||
placeholder="逗号分隔的分类 ID,如 5,8"
|
||||
:rows="2"
|
||||
/>
|
||||
<template #feedback><span class="aksrt-hint-text">主列表排除这些分类。Tab 不能绑定已排除的分类。</span></template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Tabs -->
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<strong>首页 Tab</strong>
|
||||
<n-button size="small" @click="addTab">+ 添加 Tab</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form" style="margin-bottom:16px;">
|
||||
<n-form-item label="Tab 数量上限">
|
||||
<n-input-number v-model:value="form.homepage_tab_limit" :min="1" :max="12" />
|
||||
</n-form-item>
|
||||
<n-form-item label="每 Tab 文章数">
|
||||
<n-input-number v-model:value="form.homepage_tab_posts_per_page" :min="1" :max="20" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div v-if="form.homepage_tabs.length === 0" class="homepage-settings__empty-tabs">
|
||||
<n-text depth="3">暂无 Tab 配置,点击"添加 Tab"添加。</n-text>
|
||||
</div>
|
||||
|
||||
<div v-for="(tab, index) in form.homepage_tabs" :key="index" class="homepage-settings__tab-row">
|
||||
<div class="homepage-settings__tab-fields">
|
||||
<n-select
|
||||
:value="tab.term_id"
|
||||
:options="categoryOptions"
|
||||
placeholder="分类"
|
||||
filterable
|
||||
style="flex:2;"
|
||||
@update:value="(val) => updateTab(index, 'term_id', val)"
|
||||
/>
|
||||
<n-input
|
||||
:value="tab.title"
|
||||
placeholder="自定义标题"
|
||||
style="flex:1;"
|
||||
@update:value="(val) => updateTab(index, 'title', val)"
|
||||
/>
|
||||
<n-select
|
||||
:value="tab.orderby"
|
||||
:options="allSortOptions"
|
||||
style="flex:1;"
|
||||
@update:value="(val) => updateTab(index, 'orderby', val)"
|
||||
/>
|
||||
<n-switch
|
||||
:value="tab.enabled"
|
||||
@update:value="(val) => updateTab(index, 'enabled', val)"
|
||||
/>
|
||||
<n-button size="small" quaternary type="error" @click="removeTab(index)">删除</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- Cards & Sidebar Tab -->
|
||||
<n-tab-pane name="cards" tab="卡片 & 侧栏">
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>卡片样式</strong></template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="卡片风格">
|
||||
<n-radio-group v-model:value="form.homepage_card_style">
|
||||
<n-space size="large">
|
||||
<n-radio-button value="default">默认</n-radio-button>
|
||||
<n-radio-button value="style2">风格 2</n-radio-button>
|
||||
<n-radio-button value="style3">风格 3</n-radio-button>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="多图卡片">
|
||||
<n-switch v-model:value="form.homepage_multi_thumb_enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.homepage_multi_thumb_enabled" label="最少图片数">
|
||||
<n-input-number v-model:value="form.homepage_multi_thumb_min_images" :min="2" :max="4" />
|
||||
</n-form-item>
|
||||
<n-form-item label="默认视图">
|
||||
<n-radio-group v-model:value="form.homepage_default_view">
|
||||
<n-space size="large">
|
||||
<n-radio-button value="list">列表</n-radio-button>
|
||||
<n-radio-button value="card">卡片</n-radio-button>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>侧边栏</strong></template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="迷你侧栏列表">
|
||||
<n-switch v-model:value="form.homepage_sidebar_mini_enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
||||
<template #header><strong>交互设置</strong></template>
|
||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="排序栏 AJAX">
|
||||
<n-switch v-model:value="form.homepage_sort_ajax_enabled" />
|
||||
<template #feedback><span class="aksrt-hint-text">关闭后排序链接将使用整页跳转。</span></template>
|
||||
</n-form-item>
|
||||
<n-form-item label="Tab 吸顶">
|
||||
<n-switch v-model:value="form.homepage_sticky_tabs_enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Module Status -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<template #header><strong>模块状态</strong></template>
|
||||
<n-form label-placement="left" label-width="160" class="aksrt-form">
|
||||
<n-form-item label="Hero 首屏">
|
||||
<n-tag type="info" size="small">在 Hero 页面管理</n-tag>
|
||||
<n-text depth="3" style="margin-left:8px;">首页 Hero 开关已从首页设置中移出。</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="Banner 轮播">
|
||||
<n-tag type="info" size="small">在 Banner 页面管理</n-tag>
|
||||
<n-text depth="3" style="margin-left:8px;">首页 Banner 开关已从首页设置中移出。</n-text>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- Preset Picker Dialog -->
|
||||
<ModulePresetPicker
|
||||
:show="showPresetPicker"
|
||||
@close="showPresetPicker = false"
|
||||
@apply="handleApplyPreset"
|
||||
/>
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<n-modal
|
||||
v-model:show="showImportDialog"
|
||||
title="导入模块配置"
|
||||
preset="dialog"
|
||||
style="width: 480px;"
|
||||
:show-icon="false"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="importJson"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="粘贴导出的 JSON 配置..."
|
||||
/>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="showImportDialog = false">取消</n-button>
|
||||
<n-button type="primary" :disabled="!importJson.trim()" @click="handleImportModules">导入</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import ModuleCanvas from '@/components/homepage/ModuleCanvas.vue'
|
||||
import ModuleInspector from '@/components/homepage/ModuleInspector.vue'
|
||||
import ModulePresetPicker from '@/components/homepage/ModulePresetPicker.vue'
|
||||
import { makeDefaultModule } from '@/utils/home-module-schema'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('modules')
|
||||
const categoryOptions = ref([])
|
||||
const showPresetPicker = ref(false)
|
||||
const showImportDialog = ref(false)
|
||||
const importJson = ref('')
|
||||
|
||||
// Module workstation state
|
||||
const homeModules = ref([])
|
||||
const selectedModuleIndex = ref(-1)
|
||||
|
||||
const selectedModule = computed(() => {
|
||||
if (selectedModuleIndex.value >= 0 && selectedModuleIndex.value < homeModules.value.length) {
|
||||
return homeModules.value[selectedModuleIndex.value]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const allSortOptions = [
|
||||
{ label: '最近更新', value: 'modified' },
|
||||
{ label: '发布时间', value: 'date' },
|
||||
{ label: '浏览量', value: 'views' },
|
||||
{ label: '点赞数', value: 'likes' },
|
||||
{ label: '评论数', value: 'comments' },
|
||||
{ label: '收藏数', value: 'favorites' },
|
||||
{ label: '下载量', value: 'downloads' },
|
||||
{ label: '随机', value: 'rand' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
homepage_content_model: 'mixed',
|
||||
homepage_tabs: [],
|
||||
homepage_card_style: 'default',
|
||||
homepage_multi_thumb_enabled: true,
|
||||
homepage_multi_thumb_min_images: 3,
|
||||
homepage_sidebar_mini_enabled: true,
|
||||
homepage_default_sort: 'modified',
|
||||
homepage_default_view: 'list',
|
||||
homepage_sort_ajax_enabled: true,
|
||||
homepage_sticky_tabs_enabled: true,
|
||||
home_posts_list_enabled: true,
|
||||
home_list_title: '最新发布',
|
||||
home_list_sort_enabled: true,
|
||||
home_list_sort_options: ['modified', 'date', 'views', 'likes', 'comments'],
|
||||
home_list_sort_dropdown: false,
|
||||
home_exclude_post_ids: [],
|
||||
home_exclude_category_ids: [],
|
||||
homepage_tab_posts_per_page: 6,
|
||||
homepage_tab_limit: 4,
|
||||
})
|
||||
|
||||
const excludePostIdsText = computed({
|
||||
get: () => (form.home_exclude_post_ids || []).join(','),
|
||||
set: (val) => {
|
||||
form.home_exclude_post_ids = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
|
||||
},
|
||||
})
|
||||
|
||||
const excludeCategoryIdsText = computed({
|
||||
get: () => (form.home_exclude_category_ids || []).join(','),
|
||||
set: (val) => {
|
||||
form.home_exclude_category_ids = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
|
||||
},
|
||||
})
|
||||
|
||||
// Module operations
|
||||
function handleAddModule(type) {
|
||||
const newMod = makeDefaultModule(type)
|
||||
if (newMod) {
|
||||
newMod.id = 'mod_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
newMod.enabled = true
|
||||
newMod.title = ''
|
||||
homeModules.value.push(newMod)
|
||||
selectedModuleIndex.value = homeModules.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveModule(index) {
|
||||
homeModules.value.splice(index, 1)
|
||||
if (selectedModuleIndex.value >= homeModules.value.length) {
|
||||
selectedModuleIndex.value = homeModules.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyModule(index) {
|
||||
const src = homeModules.value[index]
|
||||
if (!src) return
|
||||
const copy = JSON.parse(JSON.stringify(src))
|
||||
copy.id = 'mod_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
homeModules.value.splice(index + 1, 0, copy)
|
||||
selectedModuleIndex.value = index + 1
|
||||
}
|
||||
|
||||
function handleToggleModule(index, val) {
|
||||
if (homeModules.value[index]) {
|
||||
homeModules.value[index].enabled = val
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveModule(index, direction) {
|
||||
const newIndex = index + direction
|
||||
if (newIndex < 0 || newIndex >= homeModules.value.length) return
|
||||
const temp = homeModules.value[index]
|
||||
homeModules.value[index] = homeModules.value[newIndex]
|
||||
homeModules.value[newIndex] = temp
|
||||
// Trigger reactivity
|
||||
homeModules.value = [...homeModules.value]
|
||||
selectedModuleIndex.value = newIndex
|
||||
}
|
||||
|
||||
function handleModuleUpdate(index, key, value) {
|
||||
if (homeModules.value[index]) {
|
||||
if (key === 'config') {
|
||||
homeModules.value[index].config = value
|
||||
} else {
|
||||
homeModules.value[index][key] = value
|
||||
}
|
||||
// Trigger reactivity
|
||||
homeModules.value = [...homeModules.value]
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyPreset(preset) {
|
||||
homeModules.value = preset.modules.map((m, i) => ({
|
||||
id: 'mod_' + Date.now() + '_' + i,
|
||||
type: m.type,
|
||||
config: JSON.parse(JSON.stringify(m.config || {})),
|
||||
enabled: true,
|
||||
title: '',
|
||||
}))
|
||||
selectedModuleIndex.value = -1
|
||||
showPresetPicker.value = false
|
||||
message.success(`已应用预设「${preset.label}」,共 ${preset.modules.length} 个模块`)
|
||||
}
|
||||
|
||||
function handleExportModules() {
|
||||
const json = JSON.stringify(homeModules.value, null, 2)
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
message.success('模块配置已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
// Fallback: show in a dialog
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'homepage-modules.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('模块配置已导出')
|
||||
})
|
||||
}
|
||||
|
||||
function handleImportModules() {
|
||||
try {
|
||||
const parsed = JSON.parse(importJson.value)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON 必须是数组格式')
|
||||
}
|
||||
homeModules.value = parsed.map((m, i) => ({
|
||||
id: m.id || 'mod_' + Date.now() + '_' + i,
|
||||
type: m.type,
|
||||
config: m.config || {},
|
||||
enabled: m.enabled !== false,
|
||||
title: m.title || '',
|
||||
}))
|
||||
selectedModuleIndex.value = -1
|
||||
showImportDialog.value = false
|
||||
importJson.value = ''
|
||||
message.success(`已导入 ${homeModules.value.length} 个模块`)
|
||||
} catch (err) {
|
||||
message.error('导入失败: ' + (err.message || 'JSON 格式错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// Tab operations
|
||||
function addTab() {
|
||||
form.homepage_tabs.push({ term_id: 0, title: '', orderby: 'date', enabled: true })
|
||||
}
|
||||
|
||||
function removeTab(index) {
|
||||
form.homepage_tabs.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateTab(index, field, value) {
|
||||
form.homepage_tabs[index][field] = value
|
||||
}
|
||||
|
||||
// Data loading
|
||||
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 */ }
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.getHomepageSettings()
|
||||
const d = res?.data || {}
|
||||
|
||||
form.homepage_content_model = d.homepage_content_model || 'mixed'
|
||||
form.homepage_tabs = Array.isArray(d.homepage_tabs) ? d.homepage_tabs : []
|
||||
form.homepage_card_style = d.homepage_card_style || 'default'
|
||||
form.homepage_multi_thumb_enabled = d.homepage_multi_thumb_enabled !== false
|
||||
form.homepage_multi_thumb_min_images = d.homepage_multi_thumb_min_images || 3
|
||||
form.homepage_sidebar_mini_enabled = d.homepage_sidebar_mini_enabled !== false
|
||||
form.homepage_default_sort = d.homepage_default_sort || 'modified'
|
||||
form.homepage_default_view = d.homepage_default_view || 'list'
|
||||
form.homepage_sort_ajax_enabled = d.homepage_sort_ajax_enabled !== false
|
||||
form.homepage_sticky_tabs_enabled = d.homepage_sticky_tabs_enabled !== false
|
||||
form.home_posts_list_enabled = d.home_posts_list_enabled !== false
|
||||
form.home_list_title = d.home_list_title || '最新发布'
|
||||
form.home_list_sort_enabled = d.home_list_sort_enabled !== false
|
||||
form.home_list_sort_options = Array.isArray(d.home_list_sort_options) && d.home_list_sort_options.length
|
||||
? d.home_list_sort_options
|
||||
: ['modified', 'date', 'views', 'likes', 'comments']
|
||||
form.home_list_sort_dropdown = !!d.home_list_sort_dropdown
|
||||
form.home_exclude_post_ids = Array.isArray(d.home_exclude_post_ids) ? d.home_exclude_post_ids : []
|
||||
form.home_exclude_category_ids = Array.isArray(d.home_exclude_category_ids) ? d.home_exclude_category_ids : []
|
||||
form.homepage_tab_posts_per_page = d.homepage_tab_posts_per_page || 6
|
||||
form.homepage_tab_limit = d.homepage_tab_limit || 4
|
||||
|
||||
// Load modules
|
||||
homeModules.value = Array.isArray(d.homepage_modules) ? d.homepage_modules.map((m, i) => ({
|
||||
id: m.id || 'mod_' + i,
|
||||
type: m.type,
|
||||
config: m.config || {},
|
||||
enabled: m.enabled !== false,
|
||||
title: m.title || '',
|
||||
})) : []
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载设置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
homepage_modules: homeModules.value.map(m => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
config: m.config,
|
||||
enabled: m.enabled !== false,
|
||||
title: m.title || '',
|
||||
})),
|
||||
}
|
||||
|
||||
const res = await api.saveHomepageSettings(payload)
|
||||
if (res?.code === 'success') {
|
||||
message.success('首页设置已保存')
|
||||
return
|
||||
}
|
||||
throw new Error(res?.message || '保存失败')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-homepage__workstation {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.aksrt-homepage__canvas-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-homepage__inspector-panel {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.homepage-settings__empty-tabs {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--n-border-color);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.homepage-settings__tab-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.homepage-settings__tab-fields {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksrt-form-actions {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container content-page">
|
||||
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button :loading="cleaning" @click="runCleanup">立即清理</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="saveSettings">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||
<n-card title="通知内容开关" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="评论回复通知">
|
||||
<n-switch v-model:value="settings.enabled_types.comment_reply" />
|
||||
</n-form-item>
|
||||
<n-form-item label="评论审核通过通知">
|
||||
<n-switch v-model:value="settings.enabled_types.comment_approved" />
|
||||
</n-form-item>
|
||||
<n-form-item label="资源下载就绪通知">
|
||||
<n-switch v-model:value="settings.enabled_types.download_ready" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分增加通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_added" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分扣减通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱待验证提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.verify_email" />
|
||||
</n-form-item>
|
||||
<n-form-item label="待审核评论提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.pending_comments" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图片审核通知">
|
||||
<n-switch v-model:value="settings.enabled_types.upload_moderation" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
|
||||
<n-grid-item span="24 l:10">
|
||||
<n-card title="user 徽标规则" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="启用 user 动态徽标">
|
||||
<n-switch v-model:value="settings.user_badge_enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="徽标包含状态提醒">
|
||||
<n-switch v-model:value="settings.badge_include_stateful" />
|
||||
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="24 l:14">
|
||||
<n-card title="通知清理策略" class="aksrt-section-card">
|
||||
<n-form :model="settings" label-placement="left" label-width="180">
|
||||
<n-form-item label="已读通知保留天数">
|
||||
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="未读通知保留天数">
|
||||
<n-input-number v-model:value="settings.unread_retention_days" :min="7" :max="3650" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="每用户最大保留数">
|
||||
<n-input-number v-model:value="settings.max_per_user" :min="20" :max="5000" style="width: 180px;" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const cleaning = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
enabled_types: {
|
||||
comment_reply: true,
|
||||
comment_approved: true,
|
||||
download_ready: true,
|
||||
credits_added: true,
|
||||
credits_deducted: true,
|
||||
verify_email: true,
|
||||
pending_comments: true,
|
||||
upload_moderation: true,
|
||||
},
|
||||
user_badge_enabled: true,
|
||||
badge_include_stateful: true,
|
||||
read_retention_days: 30,
|
||||
unread_retention_days: 180,
|
||||
max_per_user: 200,
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.getNotificationSettings()
|
||||
if (res.code === 'success' && res.data) {
|
||||
Object.assign(settings, res.data)
|
||||
if (res.data.enabled_types) {
|
||||
settings.enabled_types = { ...settings.enabled_types, ...res.data.enabled_types }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api.saveNotificationSettings({
|
||||
enabled_types: settings.enabled_types,
|
||||
user_badge_enabled: settings.user_badge_enabled,
|
||||
badge_include_stateful: settings.badge_include_stateful,
|
||||
read_retention_days: settings.read_retention_days,
|
||||
unread_retention_days: settings.unread_retention_days,
|
||||
max_per_user: settings.max_per_user,
|
||||
})
|
||||
if (res.code === 'success') message.success(res.message || '保存成功')
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanup() {
|
||||
cleaning.value = true
|
||||
try {
|
||||
const res = await api.runNotificationCleanup()
|
||||
if (res.code === 'success') message.success(res.message || '清理完成')
|
||||
} catch (e) {
|
||||
message.error(e.message || '清理失败')
|
||||
} finally {
|
||||
cleaning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="文档中心" desc="主题使用文档、开发指南、迁移说明和更新日志。" />
|
||||
|
||||
<!-- Docs Mode Status -->
|
||||
<n-card class="aksrt-docs__section" size="small">
|
||||
<div class="aksrt-docs__mode-status">
|
||||
<div class="aksrt-docs__mode-info">
|
||||
<FaIcon :icon="docsModeEnabled ? 'fas fa-book-open' : 'fas fa-book'" :size="18" :color="docsModeEnabled ? '#52c41a' : '#8a909c'" />
|
||||
<div>
|
||||
<strong>{{ docsModeEnabled ? '文档模式已启用' : '文档模式未启用' }}</strong>
|
||||
<p>{{ docsModeEnabled ? '文档导航页面和文档站式侧边栏已激活' : '启用后将激活文档导航页面模板和文档站式侧边栏' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-space>
|
||||
<router-link to="/extensions/docs-mode" class="aksrt-docs__mode-link">
|
||||
<FaIcon icon="fas fa-cog" :size="12" />
|
||||
{{ docsModeEnabled ? '管理文档模式' : '启用文档模式' }}
|
||||
</router-link>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- Doc Pages Overview -->
|
||||
<n-card title="文档页面" size="small" class="aksrt-docs__section">
|
||||
<n-alert v-if="docPageCount === 0" type="info" size="small">
|
||||
尚未创建文档页面。在 WordPress 后台新建页面时,选择"文档导航页"或"专题聚合页"模板。
|
||||
</n-alert>
|
||||
<n-grid v-else :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="stat in docPageStats" :key="stat.key">
|
||||
<div class="aksrt-docs__stat">
|
||||
<FaIcon :icon="stat.icon" :size="16" :color="stat.color" />
|
||||
<div>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
<p>{{ stat.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<n-card title="常用操作" size="small" class="aksrt-docs__section">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs__action">
|
||||
<FaIcon icon="fas fa-palette" :size="16" />
|
||||
<div>
|
||||
<strong>全局设置</strong>
|
||||
<p>Logo、背景、菜单装饰、自定义代码</p>
|
||||
<router-link to="/global/common">前往配置 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs__action">
|
||||
<FaIcon icon="fas fa-home" :size="16" />
|
||||
<div>
|
||||
<strong>首页配置</strong>
|
||||
<p>首页模块、布局、排序和预设模板</p>
|
||||
<router-link to="/page-display/homepage">前往配置 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs__action">
|
||||
<FaIcon icon="fas fa-users" :size="16" />
|
||||
<div>
|
||||
<strong>用户管理</strong>
|
||||
<p>登录注册、OAuth、签到、积分</p>
|
||||
<router-link to="/users/auth">前往配置 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs__action">
|
||||
<FaIcon icon="fas fa-tools" :size="16" />
|
||||
<div>
|
||||
<strong>系统工具</strong>
|
||||
<p>导入导出、健康检查、迁移状态</p>
|
||||
<router-link to="/tools/import-export">前往配置 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- Reference Docs -->
|
||||
<div class="aksrt-docs__grid">
|
||||
<n-card
|
||||
v-for="doc in docs"
|
||||
:key="doc.key"
|
||||
class="aksrt-docs__card"
|
||||
size="small"
|
||||
hoverable
|
||||
@click="openDoc(doc)"
|
||||
>
|
||||
<div class="aksrt-docs__card-inner">
|
||||
<div class="aksrt-docs__card-icon" :style="{ background: doc.color }">
|
||||
<FaIcon :icon="doc.icon" :size="20" color="#fff" />
|
||||
</div>
|
||||
<div class="aksrt-docs__card-body">
|
||||
<h4 class="aksrt-docs__card-title">{{ doc.title }}</h4>
|
||||
<p class="aksrt-docs__card-desc">{{ doc.description }}</p>
|
||||
</div>
|
||||
<FaIcon icon="fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" />
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- Version Info -->
|
||||
<n-card title="当前版本" size="small" class="aksrt-docs__section">
|
||||
<n-descriptions :column="2" bordered label-placement="left" size="small">
|
||||
<n-descriptions-item label="主题版本">
|
||||
<n-tag type="success" size="small">{{ themeVersion }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Schema 版本">
|
||||
<n-tag type="info" size="small">{{ dbSchemaVersion }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const themeVersion = ref(window.aksrtAdminData?.themeVersion || '3.4.0-dev')
|
||||
const dbSchemaVersion = ref('—')
|
||||
const docsModeEnabled = ref(false)
|
||||
const docPageCount = ref(0)
|
||||
const docPageStats = ref([
|
||||
{ key: 'total', icon: 'fas fa-file-alt', color: '#1890ff', value: 0, label: '文档页面' },
|
||||
{ key: 'nav', icon: 'fas fa-sitemap', color: '#722ed1', value: 0, label: '导航页' },
|
||||
{ key: 'special', icon: 'fas fa-layer-group', color: '#fa8c16', value: 0, label: '专题页' },
|
||||
])
|
||||
|
||||
const docs = ref([
|
||||
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fas fa-sitemap', color: '#1890ff', file: 'admin-ia-map.md' },
|
||||
{ key: 'zibll-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → Zibll 参考 → AKSRT 目标三层对照表。', icon: 'fas fa-columns', color: '#722ed1', file: 'zibll-gap-matrix.md' },
|
||||
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
|
||||
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fas fa-clipboard-check', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
|
||||
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
|
||||
{ key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.getSchemaPage('extensions', 'docs-mode')
|
||||
if (res?.data) {
|
||||
docsModeEnabled.value = !!res.data.docs_mode_enabled
|
||||
}
|
||||
} catch {
|
||||
// Schema endpoint may not exist yet
|
||||
}
|
||||
|
||||
// Fetch doc page stats via REST if available
|
||||
try {
|
||||
const statsRes = await api.request('/docs/stats')
|
||||
if (statsRes?.data) {
|
||||
docPageCount.value = statsRes.data.total || 0
|
||||
docPageStats.value[0].value = statsRes.data.total || 0
|
||||
docPageStats.value[1].value = statsRes.data.nav_count || 0
|
||||
docPageStats.value[2].value = statsRes.data.special_count || 0
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
}
|
||||
|
||||
// Fetch schema version from health
|
||||
try {
|
||||
const healthRes = await api.getHealthCheck()
|
||||
if (healthRes?.data) {
|
||||
dbSchemaVersion.value = healthRes.data.db_schema_version || '—'
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
}
|
||||
})
|
||||
|
||||
function openDoc(doc) {
|
||||
// Link to local docs directory (admin can open in new tab)
|
||||
const base = window.aksrtAdminData?.siteUrl || window.location.origin
|
||||
window.open(`${base}/wp-content/themes/AKSRT-theme/docs/${doc.file}`, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-docs__mode-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.aksrt-docs__mode-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-docs__mode-info strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-docs__mode-info p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-docs__mode-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
background: #f5f7fa;
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.aksrt-docs__mode-link:hover {
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.aksrt-docs__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-docs__stat strong {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-docs__stat p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-docs__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-docs__card {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.aksrt-docs__card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.aksrt-docs__card-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-docs__card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.aksrt-docs__card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-docs__card-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-docs__card-desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-docs__card-arrow {
|
||||
flex-shrink: 0;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.aksrt-docs__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-docs__action {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-docs__action i {
|
||||
color: #1890ff;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.aksrt-docs__action strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.aksrt-docs__action p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-docs__action a {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.aksrt-docs__action a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="AI 子系统" desc="配置 AI 内容辅助能力,包括 SEO 建议、摘要生成、标签建议等。" />
|
||||
|
||||
<!-- 状态卡片 -->
|
||||
<div class="aksrt-ai__status">
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.enabled ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
||||
<i :class="status.enabled ? 'fas fa-robot' : 'fas fa-robot'"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">AI 状态</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.enabled ? '已启用' : '已关闭' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.configured ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
||||
<i class="fas fa-key"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">API Key</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
|
||||
<i class="fas fa-brain"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">模型</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.model || '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">可用状态</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="aksrt-ai__form-card">
|
||||
<h3>基本配置</h3>
|
||||
<div class="aksrt-form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.enabled"> 启用 AI 内容辅助
|
||||
</label>
|
||||
<p class="aksrt-form-hint">开启后可在投稿、编辑等场景使用 AI 辅助功能</p>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>AI Provider</label>
|
||||
<select v-model="form.provider" class="aksrt-select">
|
||||
<option value="none">不使用</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>API Key</label>
|
||||
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
|
||||
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
|
||||
<label>API Base URL</label>
|
||||
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://api.openai.com/v1">
|
||||
<p class="aksrt-form-hint">可替换为兼容 OpenAI 接口的第三方服务</p>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>模型</label>
|
||||
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>最大 Token 数</label>
|
||||
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>每分钟请求限制</label>
|
||||
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
|
||||
</div>
|
||||
<div class="aksrt-form-actions">
|
||||
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能说明 -->
|
||||
<div class="aksrt-ai__form-card">
|
||||
<h3>支持的 AI 功能</h3>
|
||||
<table class="aksrt-table aksrt-table--sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>功能</th>
|
||||
<th>说明</th>
|
||||
<th>使用场景</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="feat in features" :key="feat.key">
|
||||
<td>{{ feat.name }}</td>
|
||||
<td>{{ feat.desc }}</td>
|
||||
<td>{{ feat.scene }}</td>
|
||||
<td>
|
||||
<span class="aksrt-badge" :class="status.available ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
|
||||
{{ status.available ? '可用' : '未启用' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 重要提示 -->
|
||||
<div class="aksrt-ai__form-card aksrt-ai__notice">
|
||||
<h3><i class="fas fa-exclamation-triangle"></i> 重要说明</h3>
|
||||
<ul>
|
||||
<li>AI 生成的内容仅作为<strong>建议参考</strong>,不会自动应用到线上内容</li>
|
||||
<li>所有 AI 输出需要<strong>人工审核确认</strong>后才能发布</li>
|
||||
<li>AI 默认关闭,需管理员显式启用并配置 API Key</li>
|
||||
<li>API Key 不会回显到前端,不会通过 <code>wp_localize_script</code> 透出</li>
|
||||
<li>内置速率限制,防止滥用</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
|
||||
const API = '/wp-json/aksrt/v1/ai';
|
||||
|
||||
export default {
|
||||
name: 'ExtensionAI',
|
||||
components: { PageHeader },
|
||||
setup() {
|
||||
const status = ref({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
configured: false,
|
||||
model: '',
|
||||
api_key: '',
|
||||
rate_limit: 10,
|
||||
available: false,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
api_key: '',
|
||||
api_base: 'https://api.openai.com/v1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
max_tokens: 1000,
|
||||
rate_limit: 10,
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
const features = [
|
||||
{ key: 'seo', name: 'SEO 建议', desc: '生成标题、描述、关键词建议', scene: '文章编辑' },
|
||||
{ key: 'summary', name: '摘要生成', desc: '自动生成文章摘要', scene: '文章编辑、投稿' },
|
||||
{ key: 'tags', name: '标签建议', desc: '根据内容推荐标签', scene: '文章编辑、投稿' },
|
||||
{ key: 'excerpt', name: '摘要建议', desc: '生成吸引人的列表摘要', scene: '文章编辑' },
|
||||
];
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await fetch(`${API}/status`, {
|
||||
headers: { 'X-WP-Nonce': window.aksrtAdmin?.nonce || '' },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
status.value = json.data;
|
||||
form.enabled = json.data.enabled || false;
|
||||
form.provider = json.data.provider || 'none';
|
||||
form.model = json.data.model || 'gpt-3.5-turbo';
|
||||
form.rate_limit = json.data.rate_limit || 10;
|
||||
// 不回显 api_key
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load AI status failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
// 如果密钥为空且已配置,发送掩码
|
||||
if (!payload.api_key && status.value.configured) {
|
||||
payload.api_key = '***';
|
||||
}
|
||||
const res = await fetch(`${API}/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': window.aksrtAdmin?.nonce || '',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
alert('设置已保存');
|
||||
loadStatus();
|
||||
} else {
|
||||
alert(json.message || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadStatus());
|
||||
|
||||
return { status, form, saving, features, saveSettings };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-ai__status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.aksrt-ai__status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
.aksrt-ai__status-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.aksrt-ai__status-icon--ok {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.aksrt-ai__status-icon--warn {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.aksrt-ai__status-icon--off {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.aksrt-ai__status-icon--info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.aksrt-ai__status-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
.aksrt-ai__status-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.aksrt-ai__form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.aksrt-ai__form-card h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.aksrt-form-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.aksrt-ai__notice {
|
||||
background: #fff8e1;
|
||||
border-color: #ffe082;
|
||||
}
|
||||
.aksrt-ai__notice h3 {
|
||||
color: #e65100;
|
||||
}
|
||||
.aksrt-ai__notice ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.aksrt-ai__notice li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.aksrt-ai__notice code {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="文档模式" desc="启用文档专题内容模式,配置文档导航页面和文档站式体验。" />
|
||||
|
||||
<n-card class="aksrt-docs-mode__card" title="文档模式" size="small">
|
||||
<n-form label-placement="left" label-width="160">
|
||||
<n-form-item label="启用文档模式">
|
||||
<n-switch v-model:value="formData.docs_mode_enabled" />
|
||||
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
|
||||
开启后将激活文档导航页面模板和文档站式侧边栏
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="formData.docs_mode_enabled" label="文档首页模板">
|
||||
<n-select
|
||||
v-model:value="formData.docs_mode_default_template"
|
||||
:options="templateOptions"
|
||||
placeholder="选择文档首页模板"
|
||||
style="max-width: 360px;"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="formData.docs_mode_enabled" label="文档分类">
|
||||
<n-select
|
||||
v-model:value="formData.docs_mode_cats"
|
||||
:options="categoryOptions"
|
||||
:loading="categoriesLoading"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择用于文档模式的分类"
|
||||
style="max-width: 520px;"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="formData.docs_mode_enabled" label="首页排除">
|
||||
<n-switch v-model:value="formData.docs_mode_exclude" />
|
||||
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
|
||||
开启后,所选文档分类的内容不会出现在首页常规文章流中
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="formData.docs_mode_enabled" label="显示目录树">
|
||||
<n-switch v-model:value="formData.docs_mode_toc_enabled" />
|
||||
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
|
||||
在文档页面侧边栏显示章节目录树
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="formData.docs_mode_enabled" label="显示章节导航">
|
||||
<n-switch v-model:value="formData.docs_mode_chapter_nav_enabled" />
|
||||
<n-text depth="3" style="margin-left: 12px; font-size: 12px;">
|
||||
在文档底部显示上一章/下一章导航
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item v-if="formData.docs_mode_enabled" label="默认排序">
|
||||
<n-radio-group v-model:value="formData.docs_mode_default_order">
|
||||
<n-radio value="menu_order">按菜单顺序</n-radio>
|
||||
<n-radio value="date">按发布时间</n-radio>
|
||||
<n-radio value="title">按标题排序</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #action>
|
||||
<n-space justify="end">
|
||||
<n-button @click="handleReset">重置</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<n-card title="文档页面统计" size="small" class="aksrt-docs-mode__card">
|
||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs-mode__stat">
|
||||
<strong>{{ docStats.total || 0 }}</strong>
|
||||
<p>文档页面总数</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs-mode__stat">
|
||||
<strong>{{ docStats.nav_count || 0 }}</strong>
|
||||
<p>文档导航页</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-docs-mode__stat">
|
||||
<strong>{{ docStats.special_count || 0 }}</strong>
|
||||
<p>专题聚合页</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card title="可用页面模板" size="small" class="aksrt-docs-mode__card">
|
||||
<n-alert type="info" size="small" style="margin-bottom: 12px;">
|
||||
以下页面模板可用于创建文档类页面。在 WordPress 后台新建页面时,从"页面属性 → 模板"中选择对应模板。
|
||||
</n-alert>
|
||||
<n-list bordered size="small">
|
||||
<n-list-item v-for="tpl in availableTemplates" :key="tpl.file">
|
||||
<n-thing :title="tpl.name" :description="tpl.description">
|
||||
<template #avatar>
|
||||
<FaIcon :icon="tpl.icon" :size="18" color="#1890ff" />
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag size="small" type="info">{{ tpl.file }}</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
|
||||
<n-card title="快速入口" size="small" class="aksrt-docs-mode__card">
|
||||
<n-space>
|
||||
<router-link to="/page-display/special" class="aksrt-docs-mode__link">
|
||||
<FaIcon icon="fas fa-layer-group" :size="14" />
|
||||
专题页面配置
|
||||
</router-link>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const saving = ref(false)
|
||||
const docStats = ref({ total: 0, nav_count: 0, special_count: 0 })
|
||||
const categoriesLoading = ref(false)
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const defaults = {
|
||||
docs_mode_enabled: false,
|
||||
docs_mode_default_template: 'documentnav',
|
||||
docs_mode_toc_enabled: true,
|
||||
docs_mode_chapter_nav_enabled: true,
|
||||
docs_mode_default_order: 'menu_order',
|
||||
docs_mode_cats: [],
|
||||
docs_mode_exclude: true,
|
||||
}
|
||||
|
||||
const formData = ref({ ...defaults })
|
||||
|
||||
const templateOptions = [
|
||||
{ label: '文档导航页 (page-documentnav)', value: 'documentnav' },
|
||||
{ label: '专题聚合页 (page-special)', value: 'special' },
|
||||
]
|
||||
|
||||
const availableTemplates = [
|
||||
{
|
||||
file: 'page-documentnav.php',
|
||||
name: '文档导航页',
|
||||
description: '站式文档导航模板,支持侧边栏目录树、章节高亮和快速搜索。',
|
||||
icon: 'fas fa-book',
|
||||
},
|
||||
{
|
||||
file: 'page-special.php',
|
||||
name: '专题聚合页',
|
||||
description: '专题内容聚合模板,支持封面、摘要、多列布局和子页面矩阵。',
|
||||
icon: 'fas fa-layer-group',
|
||||
},
|
||||
{
|
||||
file: 'page-download.php',
|
||||
name: '下载中心页',
|
||||
description: '资源下载中心模板,支持资源类型筛选、权限说明和交易承接。',
|
||||
icon: 'fas fa-download',
|
||||
},
|
||||
{
|
||||
file: 'page-categories.php',
|
||||
name: '分类导航页',
|
||||
description: '全站分类导航模板,展示分类树和文章统计。',
|
||||
icon: 'fas fa-folder-open',
|
||||
},
|
||||
{
|
||||
file: 'page-friends.php',
|
||||
name: '友情链接页',
|
||||
description: '友情链接展示模板。',
|
||||
icon: 'fas fa-link',
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSettings(), loadDocStats(), loadCategories()])
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await api.getSchemaPage('extensions', 'docs-mode')
|
||||
if (res?.data) {
|
||||
formData.value = {
|
||||
...defaults,
|
||||
...res.data,
|
||||
docs_mode_cats: normalizeCategoryIds(res.data.docs_mode_cats),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Schema endpoint may not exist yet; use defaults
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocStats() {
|
||||
try {
|
||||
const statsRes = await api.request('/docs/stats')
|
||||
if (statsRes?.data) {
|
||||
docStats.value = statsRes.data
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
categoriesLoading.value = true
|
||||
try {
|
||||
const res = await api.getCategories()
|
||||
const items = res?.data || res || []
|
||||
categoryOptions.value = (Array.isArray(items) ? items : []).map((item) => ({
|
||||
label: item.name + (item.count ? ` (${item.count})` : ''),
|
||||
value: Number(item.term_id),
|
||||
}))
|
||||
} catch {
|
||||
categoryOptions.value = []
|
||||
} finally {
|
||||
categoriesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCategoryIds(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item > 0)
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((item) => Number.isFinite(item) && item > 0)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.saveSchemaPage('extensions', 'docs-mode', {
|
||||
...formData.value,
|
||||
docs_mode_cats: normalizeCategoryIds(formData.value.docs_mode_cats),
|
||||
})
|
||||
message.success('文档模式设置已保存')
|
||||
} catch (err) {
|
||||
message.error('保存失败: ' + (err?.message || '未知错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
formData.value = { ...defaults }
|
||||
message.info('已重置为默认值')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-docs-mode__card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-docs-mode__stat {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-docs-mode__stat strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-docs-mode__stat p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-docs-mode__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
background: #f5f7fa;
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.aksrt-docs-mode__link:hover {
|
||||
background: #e6f4ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<ExtensionsSectionPage
|
||||
title="网站安全"
|
||||
desc="扩展安全策略和第三方安全集成。"
|
||||
>
|
||||
<n-card title="安全概览" size="small" class="aksrt-security__section">
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="item in securityItems" :key="item.key">
|
||||
<div class="aksrt-security__overview-card">
|
||||
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
|
||||
<div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<p>{{ item.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card title="安全能力" size="small" class="aksrt-security__section">
|
||||
<n-list bordered size="small">
|
||||
<n-list-item v-for="feat in features" :key="feat.key">
|
||||
<n-thing :title="feat.name" :description="feat.description">
|
||||
<template #avatar>
|
||||
<FaIcon :icon="feat.icon" :size="16" :color="feat.color" />
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center" size="small">
|
||||
<n-tag :type="feat.enabled ? 'success' : 'default'" size="small">
|
||||
{{ feat.enabled ? '已启用' : '未启用' }}
|
||||
</n-tag>
|
||||
<router-link v-if="feat.settingsRoute" :to="feat.settingsRoute" class="aksrt-security__config-link">
|
||||
前往配置
|
||||
</router-link>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
|
||||
<n-card title="在线测试" size="small" class="aksrt-security__section">
|
||||
<n-grid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="IP 归属地测试">
|
||||
<n-input v-model:value="ipTest.ip" placeholder="输入 IPv4 / IPv6,例如 8.8.8.8" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button :loading="ipTest.loading" @click="handleTestIp">测试 IP 接口</n-button>
|
||||
</n-space>
|
||||
<pre v-if="ipTest.result" class="aksrt-security__output">{{ ipTest.result }}</pre>
|
||||
</n-form>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="内容审核测试">
|
||||
<n-input v-model:value="auditTest.text" type="textarea" :autosize="{ minRows: 4, maxRows: 8 }" placeholder="输入一段测试文本,检查当前审核策略是否生效" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button :loading="auditTest.loading" @click="handleTestAudit">测试内容审核</n-button>
|
||||
</n-space>
|
||||
<pre v-if="auditTest.result" class="aksrt-security__output">{{ auditTest.result }}</pre>
|
||||
</n-form>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card title="风险开关" size="small" class="aksrt-security__section">
|
||||
<n-alert type="warning" size="small" style="margin-bottom: 12px;">
|
||||
以下开关会影响站点安全策略,请谨慎操作。
|
||||
</n-alert>
|
||||
<n-form label-placement="left" label-width="200" size="small">
|
||||
<n-form-item label="允许未审核评论显示">
|
||||
<n-switch v-model:value="riskSwitches.show_unapproved_comments" />
|
||||
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
|
||||
开启后未审核评论也会在前台显示(标记为待审核)
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="允许前台投稿无需审核">
|
||||
<n-switch v-model:value="riskSwitches.skip_submit_moderation" />
|
||||
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
|
||||
开启后用户投稿直接发布,不经过审核流程
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="允许游客评论">
|
||||
<n-switch v-model:value="riskSwitches.guest_comment" />
|
||||
<n-text depth="3" style="margin-left: 8px; font-size: 12px;">
|
||||
开启后未登录用户也可以发表评论
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" size="small" :loading="saving" @click="handleSaveRisk">保存风险设置</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<n-card title="安全审计" size="small" class="aksrt-security__section">
|
||||
<n-empty v-if="auditLogs.length === 0" description="暂无审计记录" />
|
||||
<n-list v-else bordered size="small">
|
||||
<n-list-item v-for="log in auditLogs" :key="log.id">
|
||||
<n-thing :title="log.action" :description="log.detail">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 11px;">{{ log.time }}</n-text>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</ExtensionsSectionPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import ExtensionsSectionPage from '@/pages/extensions/ExtensionsSectionPage.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const saving = ref(false)
|
||||
|
||||
const securityItems = ref([
|
||||
{ key: 'email_gate', icon: 'fas fa-envelope', color: '#1890ff', value: '—', label: '邮箱验证' },
|
||||
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#52c41a', value: '—', label: '验证码保护' },
|
||||
{ key: 'moderation', icon: 'fas fa-gavel', color: '#722ed1', value: '—', label: '内容审核' },
|
||||
{ key: 'ip_geo', icon: 'fas fa-map-marker-alt', color: '#fa8c16', value: '—', label: 'IP 归属地' },
|
||||
])
|
||||
|
||||
const features = ref([
|
||||
{ key: 'email_verification', icon: 'fas fa-envelope', color: '#1890ff', name: '邮箱验证', description: '新用户注册需要邮箱验证', enabled: false, settingsRoute: '/users/auth' },
|
||||
{ key: 'captcha_comment', icon: 'fas fa-shield-alt', color: '#52c41a', name: '评论验证码', description: '发表评论时需要人机验证', enabled: false, settingsRoute: '/permissions/login' },
|
||||
{ key: 'captcha_download', icon: 'fas fa-shield-alt', color: '#52c41a', name: '下载验证码', description: '下载资源时需要人机验证', enabled: false, settingsRoute: '/permissions/login' },
|
||||
{ key: 'content_audit', icon: 'fas fa-shield-virus', color: '#722ed1', name: '内容审核 API', description: '接入第三方内容安全审核', enabled: false, settingsRoute: '/extensions/audit' },
|
||||
{ key: 'ip_geolocation', icon: 'fas fa-map-marker-alt', color: '#fa8c16', name: 'IP 归属地', description: '在评论和用户主页显示 IP 归属地', enabled: false, settingsRoute: '/extensions/ip' },
|
||||
])
|
||||
|
||||
const riskSwitches = ref({
|
||||
show_unapproved_comments: false,
|
||||
skip_submit_moderation: false,
|
||||
guest_comment: false,
|
||||
})
|
||||
|
||||
const auditLogs = ref([])
|
||||
const ipTest = ref({ ip: '8.8.8.8', loading: false, result: '' })
|
||||
const auditTest = ref({ text: '这是一段用于审核测试的示例文本。', loading: false, result: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.request('/settings/security')
|
||||
if (res?.data) {
|
||||
const d = res.data
|
||||
const svc = res.services || {}
|
||||
|
||||
securityItems.value[0].value = d.email_gate_enabled ? '已开启' : '未开启'
|
||||
securityItems.value[0].color = d.email_gate_enabled ? '#52c41a' : '#8a909c'
|
||||
securityItems.value[1].value = d.captcha_enabled ? '已开启' : '未开启'
|
||||
securityItems.value[1].color = d.captcha_enabled ? '#52c41a' : '#8a909c'
|
||||
|
||||
if (svc.audit?.enabled) {
|
||||
securityItems.value[2].value = svc.audit.configured ? '已配置' : '待补全'
|
||||
securityItems.value[2].color = svc.audit.configured ? '#52c41a' : '#faad14'
|
||||
} else {
|
||||
securityItems.value[2].value = '未开启'
|
||||
securityItems.value[2].color = '#8a909c'
|
||||
}
|
||||
|
||||
if (svc.ip_geo?.enabled) {
|
||||
securityItems.value[3].value = svc.ip_geo.configured ? '已配置' : '待补全'
|
||||
securityItems.value[3].color = svc.ip_geo.configured ? '#52c41a' : '#faad14'
|
||||
} else {
|
||||
securityItems.value[3].value = '未开启'
|
||||
securityItems.value[3].color = '#8a909c'
|
||||
}
|
||||
|
||||
features.value[0].enabled = !!d.email_gate_enabled
|
||||
features.value[1].enabled = !!d.captcha_comment_enabled
|
||||
features.value[2].enabled = !!d.captcha_download_enabled
|
||||
features.value[3].enabled = !!(svc.audit?.enabled ?? d.content_audit_enabled)
|
||||
features.value[4].enabled = !!(svc.ip_geo?.enabled ?? d.ip_geo_enabled)
|
||||
|
||||
if (d.risk_switches) {
|
||||
riskSwitches.value = { ...riskSwitches.value, ...d.risk_switches }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
}
|
||||
|
||||
try {
|
||||
const logRes = await api.request('/security/audit-log')
|
||||
if (logRes?.data?.logs) {
|
||||
auditLogs.value = logRes.data.logs.slice(0, 10)
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSaveRisk() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.request('/settings/security', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ risk_switches: riskSwitches.value }),
|
||||
})
|
||||
message.success('风险设置已保存')
|
||||
} catch (err) {
|
||||
message.error('保存失败: ' + (err?.message || '未知错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestIp() {
|
||||
ipTest.value.loading = true
|
||||
try {
|
||||
const res = await api.testIpGeo(ipTest.value.ip)
|
||||
ipTest.value.result = JSON.stringify(res?.data || {}, null, 2)
|
||||
message.success('IP 接口测试完成')
|
||||
} catch (err) {
|
||||
message.error(err?.message || 'IP 测试失败')
|
||||
} finally {
|
||||
ipTest.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestAudit() {
|
||||
auditTest.value.loading = true
|
||||
try {
|
||||
const res = await api.testContentAudit(auditTest.value.text)
|
||||
auditTest.value.result = JSON.stringify(res?.data || {}, null, 2)
|
||||
message.success('内容审核测试完成')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '内容审核测试失败')
|
||||
} finally {
|
||||
auditTest.value.loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-security__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-security__overview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.aksrt-security__overview-card strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-security__overview-card p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-security__config-link {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-security__config-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aksrt-security__output {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #101828;
|
||||
color: #d0d7e2;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="系统工具" desc="额外运维工具与扩展能力。" />
|
||||
|
||||
<!-- Health Overview -->
|
||||
<n-card title="系统健康" size="small" class="aksrt-tools__section">
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="item in healthItems" :key="item.key">
|
||||
<div class="aksrt-tools__health-card" :class="'aksrt-tools__health-card--' + item.status">
|
||||
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
|
||||
<div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<p>{{ item.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- Tool Categories -->
|
||||
<n-card title="工具集合" size="small" class="aksrt-tools__section">
|
||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="tool in tools" :key="tool.key">
|
||||
<div class="aksrt-tools__tool-card" @click="navigateTo(tool.route)">
|
||||
<div class="aksrt-tools__tool-icon" :style="{ background: tool.bg }">
|
||||
<FaIcon :icon="tool.icon" :size="18" color="#fff" />
|
||||
</div>
|
||||
<div class="aksrt-tools__tool-body">
|
||||
<strong>{{ tool.title }}</strong>
|
||||
<p>{{ tool.desc }}</p>
|
||||
</div>
|
||||
<FaIcon icon="fas fa-chevron-right" :size="12" color="#c0c4cc" />
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- Service Status -->
|
||||
<n-card title="服务状态" size="small" class="aksrt-tools__section">
|
||||
<n-list bordered size="small">
|
||||
<n-list-item v-for="svc in services" :key="svc.key">
|
||||
<n-thing :title="svc.name" :description="svc.description">
|
||||
<template #avatar>
|
||||
<FaIcon :icon="svc.icon" :size="16" :color="svc.color" />
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag :type="svc.available ? 'success' : 'warning'" size="small">
|
||||
{{ svc.available ? '可用' : '未配置' }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
|
||||
<!-- Quick Maintenance -->
|
||||
<n-card title="手动维护" size="small" class="aksrt-tools__section">
|
||||
<n-space>
|
||||
<n-button @click="handleFlushCache">
|
||||
<template #icon><FaIcon icon="fas fa-broom" :size="12" /></template>
|
||||
清除缓存
|
||||
</n-button>
|
||||
<n-button @click="handleOptimizeDb">
|
||||
<template #icon><FaIcon icon="fas fa-database" :size="12" /></template>
|
||||
优化数据表
|
||||
</n-button>
|
||||
<n-button @click="handleRebuildIndex">
|
||||
<template #icon><FaIcon icon="fas fa-sync-alt" :size="12" /></template>
|
||||
重建索引
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const healthItems = ref([
|
||||
{ key: 'php', icon: 'fas fa-code', color: '#1890ff', value: '—', label: 'PHP 版本', status: 'ok' },
|
||||
{ key: 'wp', icon: 'fab fa-wordpress', color: '#722ed1', value: '—', label: 'WordPress', status: 'ok' },
|
||||
{ key: 'db', icon: 'fas fa-database', color: '#52c41a', value: '—', label: '数据库', status: 'ok' },
|
||||
{ key: 'uploads', icon: 'fas fa-folder-open', color: '#fa8c16', value: '—', label: '上传目录', status: 'ok' },
|
||||
])
|
||||
|
||||
const tools = ref([
|
||||
{ key: 'import-export', icon: 'fas fa-exchange-alt', bg: '#1890ff', title: '导入导出', desc: '设置导入、导出和重置', route: '/tools/import-export' },
|
||||
{ key: 'health', icon: 'fas fa-heartbeat', bg: '#52c41a', title: '健康检查', desc: '系统状态检测和诊断', route: '/tools/health' },
|
||||
{ key: 'migration', icon: 'fas fa-exchange-alt', bg: '#722ed1', title: '数据迁移', desc: '设置迁移状态和兼容性', route: '/tools/migration' },
|
||||
{ key: 'email-rollback', icon: 'fas fa-undo', bg: '#fa8c16', title: '邮箱回滚', desc: '邮箱变更 14 天内回滚', route: '/tools/email-rollback' },
|
||||
])
|
||||
|
||||
const services = ref([
|
||||
{ key: 'sms', icon: 'fas fa-sms', color: '#1890ff', name: '短信服务', description: '验证码和通知短信', available: false },
|
||||
{ key: 'wechat', icon: 'fab fa-weixin', color: '#52c41a', name: '微信通知', description: '微信模板消息和订阅通知', available: false },
|
||||
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#722ed1', name: '验证码服务', description: '人机验证(极验/腾讯/阿里云)', available: false },
|
||||
{ key: 'push', icon: 'fas fa-paper-plane', color: '#fa8c16', name: '搜索引擎推送', description: '百度/Google 主动推送', available: false },
|
||||
{ key: 'audit', icon: 'fas fa-shield-virus', color: '#f5222d', name: '内容审核', description: '第三方内容安全审核 API', available: false },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.request('/tools/health')
|
||||
if (res?.data) {
|
||||
const d = res.data
|
||||
if (d.php_version) healthItems.value[0].value = d.php_version
|
||||
if (d.wp_version) healthItems.value[1].value = d.wp_version
|
||||
if (d.db_status) {
|
||||
healthItems.value[2].value = d.db_status
|
||||
healthItems.value[2].status = d.db_ok ? 'ok' : 'warn'
|
||||
}
|
||||
if (d.upload_dir) {
|
||||
healthItems.value[3].value = d.upload_dir
|
||||
healthItems.value[3].status = d.upload_ok ? 'ok' : 'warn'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
}
|
||||
|
||||
// Check service availability from structured status
|
||||
try {
|
||||
const settingsRes = await api.request('/settings/extensions')
|
||||
if (settingsRes?.data) {
|
||||
const s = settingsRes.data
|
||||
// Prefer structured services if available
|
||||
const svc = settingsRes.services || {}
|
||||
services.value[0].available = !!(svc.sms?.available ?? s.sms_enabled)
|
||||
services.value[0].status = svc.sms?.provider || (s.sms_enabled ? '已启用' : '未配置')
|
||||
services.value[1].available = !!(svc.wechat?.available ?? s.wechat_enabled)
|
||||
services.value[1].status = svc.wechat?.provider || (s.wechat_enabled ? '已启用' : '未配置')
|
||||
services.value[2].available = !!(svc.captcha?.available ?? s.captcha_enabled)
|
||||
services.value[2].status = svc.captcha?.provider || (s.captcha_enabled ? '已启用' : '未配置')
|
||||
services.value[3].available = !!(svc.push?.available ?? s.baidu_push_enabled)
|
||||
services.value[3].status = svc.push?.provider || (s.baidu_push_enabled ? '已启用' : '未配置')
|
||||
services.value[4].available = !!(svc.audit?.available ?? s.content_audit_enabled)
|
||||
services.value[4].status = svc.audit?.provider || (s.content_audit_enabled ? '已启用' : '未配置')
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
})
|
||||
|
||||
function navigateTo(route) {
|
||||
router.push(route)
|
||||
}
|
||||
|
||||
async function handleFlushCache() {
|
||||
message.info('正在清除缓存...')
|
||||
try {
|
||||
const res = await api.request('/tools/maintenance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'flush_cache' }),
|
||||
})
|
||||
message.success(res?.message || '缓存已清除')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '清除缓存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOptimizeDb() {
|
||||
message.info('正在优化数据表...')
|
||||
try {
|
||||
const res = await api.request('/tools/maintenance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'optimize_db' }),
|
||||
})
|
||||
message.success(res?.message || '数据表已优化')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '优化数据表失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRebuildIndex() {
|
||||
message.info('正在重建索引...')
|
||||
try {
|
||||
const res = await api.request('/tools/maintenance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'rebuild_index' }),
|
||||
})
|
||||
message.success(res?.message || '索引重建完成')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '重建索引失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-tools__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-tools__health-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.aksrt-tools__health-card strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-tools__health-card p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-tools__health-card--warn {
|
||||
border-color: #faad14;
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
.aksrt-tools__tool-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.aksrt-tools__tool-card:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.aksrt-tools__tool-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aksrt-tools__tool-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-tools__tool-body strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-tools__tool-body p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader :title="title" :desc="desc">
|
||||
<template #actions>
|
||||
<n-space v-if="links.length">
|
||||
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
|
||||
{{ link.label }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-card v-if="note" class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ note }}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
desc: { type: String, default: '' },
|
||||
note: { type: String, default: '' },
|
||||
links: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="版块管理" desc="创建、编辑和维护论坛版块信息,并观察版主申请积压情况。" />
|
||||
|
||||
<n-card size="small" class="aksrt-forum-boards__section">
|
||||
<n-space justify="space-between" align="center">
|
||||
<div class="aksrt-forum-boards__summary">共 {{ boards.length }} 个版块</div>
|
||||
<n-button type="primary" @click="openCreate">新增版块</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-forum-boards__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="boards"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:row-key="(row) => row.id"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<n-modal :show="modalVisible" preset="card" style="width: 520px" :title="editingId ? '编辑版块' : '新增版块'" @update:show="modalVisible = $event">
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<n-form-item label="版块名称" path="name">
|
||||
<n-input v-model:value="form.name" placeholder="输入版块名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="版块描述" path="description">
|
||||
<n-input v-model:value="form.description" type="textarea" :rows="3" placeholder="描述主题方向和讨论边界" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" path="icon">
|
||||
<n-input v-model:value="form.icon" placeholder="例如:fas fa-comments" />
|
||||
</n-form-item>
|
||||
<n-form-item label="排序" path="sort_order">
|
||||
<n-input-number v-model:value="form.sort_order" :min="0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="modalVisible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const boards = ref([])
|
||||
const modalVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'fas fa-comments',
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: { required: true, message: '请输入版块名称', trigger: 'blur' },
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: '排序', key: 'sort_order', width: 70 },
|
||||
{ title: '版块名称', key: 'name', minWidth: 180 },
|
||||
{ title: '描述', key: 'description', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '治理状态',
|
||||
key: 'governance',
|
||||
width: 180,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NTag, { size: 'small', type: 'info' }, () => `${row.post_count || 0} 主题`),
|
||||
h(NTag, { size: 'small', type: 'success' }, () => `${row.moderator_count || 0} 版主`),
|
||||
h(NTag, { size: 'small', type: (row.pending_application_count || 0) > 0 ? 'warning' : 'default' }, () => `${row.pending_application_count || 0} 申请`),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openEdit(row) }, () => '编辑'),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDelete(row) }, () => '删除'),
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.icon = 'fas fa-comments'
|
||||
form.sort_order = boards.value.length
|
||||
}
|
||||
|
||||
async function loadBoards() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request('/forum/boards')
|
||||
boards.value = res?.data?.items || []
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载版块列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
form.name = row.name || ''
|
||||
form.description = row.description || ''
|
||||
form.icon = row.icon || 'fas fa-comments'
|
||||
form.sort_order = row.sort_order || 0
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await api.request(`/forum/boards/${editingId.value}`, {
|
||||
method: 'POST',
|
||||
data: { ...form },
|
||||
})
|
||||
message.success('版块已更新')
|
||||
} else {
|
||||
await api.request('/forum/boards', {
|
||||
method: 'POST',
|
||||
data: { ...form },
|
||||
})
|
||||
message.success('版块已创建')
|
||||
}
|
||||
modalVisible.value = false
|
||||
await loadBoards()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '版块保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
dialog.warning({
|
||||
title: '确认删除版块',
|
||||
content: `确定删除“${row.name}”吗?如果仍有主题,请先完成迁移。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.request(`/forum/boards/${row.id}`, { method: 'DELETE' })
|
||||
message.success('版块已删除')
|
||||
await loadBoards()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '版块删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadBoards)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-forum-boards__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-forum-boards__summary {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="论坛概览" desc="从待审核主题、版主申请和版块状态三个视角观察论坛治理负载。" />
|
||||
|
||||
<div class="aksrt-forum-dashboard__stats">
|
||||
<div v-for="card in statCards" :key="card.key" class="aksrt-forum-dashboard__stat-card">
|
||||
<div class="aksrt-forum-dashboard__stat-icon" :style="{ background: card.color }">
|
||||
<i :class="card.icon"></i>
|
||||
</div>
|
||||
<div class="aksrt-forum-dashboard__stat-info">
|
||||
<div class="aksrt-forum-dashboard__stat-value">{{ stats[card.key] || 0 }}</div>
|
||||
<div class="aksrt-forum-dashboard__stat-label">{{ card.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-forum-dashboard__grid">
|
||||
<n-card size="small" class="aksrt-forum-dashboard__section">
|
||||
<template #header>
|
||||
<div class="aksrt-forum-dashboard__section-head">
|
||||
<strong>待审核主题</strong>
|
||||
<n-button size="small" quaternary type="primary" @click="goTopics">进入主题治理</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
v-if="pendingTopics.length"
|
||||
:columns="topicColumns"
|
||||
:data="pendingTopics"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无待审核主题" />
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-forum-dashboard__section">
|
||||
<template #header>
|
||||
<div class="aksrt-forum-dashboard__section-head">
|
||||
<strong>待处理版主申请</strong>
|
||||
<n-button size="small" quaternary type="primary" @click="goApplications">进入申请处理</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
v-if="pendingApplications.length"
|
||||
:columns="applicationColumns"
|
||||
:data="pendingApplications"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无待处理申请" />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<n-card size="small" class="aksrt-forum-dashboard__section">
|
||||
<template #header>
|
||||
<div class="aksrt-forum-dashboard__section-head">
|
||||
<strong>版块概况</strong>
|
||||
<n-button size="small" quaternary type="primary" @click="goBoards">进入版块管理</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
v-if="boards.length"
|
||||
:columns="boardColumns"
|
||||
:data="boards"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无版块" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const stats = ref({})
|
||||
const boards = ref([])
|
||||
const pendingTopics = ref([])
|
||||
const pendingApplications = ref([])
|
||||
|
||||
const statCards = [
|
||||
{ key: 'board_count', label: '版块数', icon: 'fas fa-th-large', color: '#dbeafe' },
|
||||
{ key: 'topic_count', label: '公开主题', icon: 'fas fa-list-alt', color: '#dcfce7' },
|
||||
{ key: 'pending_topic_count', label: '待审核主题', icon: 'fas fa-hourglass-half', color: '#fef3c7' },
|
||||
{ key: 'pending_application_count', label: '待处理申请', icon: 'fas fa-user-shield', color: '#fee2e2' },
|
||||
]
|
||||
|
||||
const topicColumns = [
|
||||
{ title: '标题', key: 'title', minWidth: 260 },
|
||||
{ title: '作者', key: 'author_name', width: 110, render: (row) => row.author?.display_name || '-' },
|
||||
{ title: '版块', key: 'board_name', width: 120, render: (row) => row.boards?.[0]?.name || '-' },
|
||||
{ title: '时间', key: 'date', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => goTopics() }, () => '治理'),
|
||||
},
|
||||
]
|
||||
|
||||
const applicationColumns = [
|
||||
{ title: '版块', key: 'board_name', width: 140 },
|
||||
{ title: '申请人', key: 'display_name', width: 140 },
|
||||
{ title: '理由', key: 'reason', ellipsis: { tooltip: true } },
|
||||
{ title: '时间', key: 'created_at', width: 160 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: 'warning' }, () => row.status_label || row.status || '-'),
|
||||
},
|
||||
]
|
||||
|
||||
const boardColumns = [
|
||||
{ title: '版块名称', key: 'name', minWidth: 180 },
|
||||
{ title: '主题数', key: 'post_count', width: 90 },
|
||||
{ title: '版主数', key: 'moderator_count', width: 90 },
|
||||
{ title: '待处理申请', key: 'pending_application_count', width: 120 },
|
||||
]
|
||||
|
||||
async function loadOverview() {
|
||||
try {
|
||||
const res = await api.request('/forum/overview')
|
||||
const data = res?.data || {}
|
||||
stats.value = data.stats || {}
|
||||
boards.value = data.boards || []
|
||||
pendingTopics.value = data.pending_topics || []
|
||||
pendingApplications.value = data.pending_applications || []
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载论坛概览失败')
|
||||
}
|
||||
}
|
||||
|
||||
function goBoards() {
|
||||
window.location.hash = '#/forum/boards'
|
||||
}
|
||||
|
||||
function goTopics() {
|
||||
window.location.hash = '#/forum/topics'
|
||||
}
|
||||
|
||||
function goApplications() {
|
||||
window.location.hash = '#/forum/moderator-applications'
|
||||
}
|
||||
|
||||
onMounted(loadOverview)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-forum-dashboard__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: #1d4ed8;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__stat-value {
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-forum-dashboard__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.aksrt-forum-dashboard__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="版主申请处理" desc="处理前台版主申请,完成审核、通过和拒绝反馈闭环。" />
|
||||
|
||||
<n-card size="small" class="aksrt-forum-apps__section">
|
||||
<n-space wrap>
|
||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索版块 / 用户 / 理由" style="width: 220px" @keyup.enter="loadData" />
|
||||
<n-select v-model:value="filters.board_id" :options="boardOptions" clearable placeholder="版块" style="width: 180px" />
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="状态" style="width: 160px" />
|
||||
<n-button type="primary" @click="loadData">查询</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-forum-apps__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<n-modal :show="reviewVisible" preset="card" title="处理版主申请" style="width: 520px" @update:show="reviewVisible = $event">
|
||||
<n-space vertical :size="14">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
<div>{{ activeApplication?.display_name || '-' }} / {{ activeApplication?.board_name || '-' }}</div>
|
||||
<div class="aksrt-forum-apps__reason">{{ activeApplication?.reason || '暂无申请理由' }}</div>
|
||||
</n-alert>
|
||||
<n-radio-group v-model:value="reviewForm.status">
|
||||
<n-radio-button value="approved">通过</n-radio-button>
|
||||
<n-radio-button value="rejected">拒绝</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-input v-model:value="reviewForm.note" type="textarea" :rows="4" placeholder="记录审核结论和补充说明" />
|
||||
</n-space>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="reviewVisible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="reviewing" @click="submitReview">提交处理</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const items = ref([])
|
||||
const boardOptions = ref([{ label: '全部版块', value: null }])
|
||||
const statusOptions = ref([{ label: '全部状态', value: null }])
|
||||
const reviewVisible = ref(false)
|
||||
const reviewing = ref(false)
|
||||
const activeApplication = ref(null)
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
board_id: null,
|
||||
status: null,
|
||||
})
|
||||
|
||||
const reviewForm = reactive({
|
||||
status: 'approved',
|
||||
note: '',
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '版块 / 用户',
|
||||
key: 'board_name',
|
||||
minWidth: 220,
|
||||
render: (row) => h('div', { class: 'aksrt-forum-apps__identity' }, [
|
||||
h('div', { class: 'aksrt-forum-apps__board' }, row.board_name || '-'),
|
||||
h('div', { class: 'aksrt-forum-apps__user' }, `${row.display_name || '-'} · ${row.user_email || '-'}`),
|
||||
]),
|
||||
},
|
||||
{ title: '申请理由', key: 'reason', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (row) => h(NTag, { size: 'small', type: resolveStatusType(row.status) }, () => row.status_label || row.status || '-'),
|
||||
},
|
||||
{ title: '申请时间', key: 'created_at', width: 160 },
|
||||
{ title: '处理时间', key: 'reviewed_at', width: 160, render: (row) => row.reviewed_at || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) => {
|
||||
if (row.status !== 'pending') {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return h('div', { style: 'display:flex;gap:6px;' }, [
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'success', onClick: () => openReview(row, 'approved') }, () => '通过'),
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => openReview(row, 'rejected') }, () => '拒绝'),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function resolveStatusType(status) {
|
||||
if (status === 'approved') return 'success'
|
||||
if (status === 'rejected') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request('/forum/moderator/applications', {
|
||||
params: {
|
||||
keyword: filters.keyword || '',
|
||||
board_id: filters.board_id || '',
|
||||
status: filters.status || '',
|
||||
},
|
||||
})
|
||||
|
||||
const data = res?.data || {}
|
||||
items.value = data.items || []
|
||||
boardOptions.value = [{ label: '全部版块', value: null }, ...((data.options?.boards || []).map((item) => ({ label: item.name, value: item.id })))]
|
||||
statusOptions.value = data.options?.status || [{ label: '全部状态', value: null }]
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载版主申请失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.board_id = null
|
||||
filters.status = null
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openReview(row, status) {
|
||||
activeApplication.value = row
|
||||
reviewForm.status = status
|
||||
reviewForm.note = row.review_note || ''
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (!activeApplication.value?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
reviewing.value = true
|
||||
try {
|
||||
await api.request(`/forum/moderator/applications/${activeApplication.value.id}/review`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
status: reviewForm.status,
|
||||
note: reviewForm.note,
|
||||
},
|
||||
})
|
||||
message.success('申请状态已更新')
|
||||
reviewVisible.value = false
|
||||
activeApplication.value = null
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '申请处理失败')
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-forum-apps__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-forum-apps__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-forum-apps__board {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aksrt-forum-apps__user,
|
||||
.aksrt-forum-apps__reason {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="版主管理" desc="按版块维护版主名单,支持角色分层和权限档位。" />
|
||||
|
||||
<n-grid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen" style="margin-bottom:16px;">
|
||||
<n-grid-item>
|
||||
<n-statistic label="总版主数" :value="stats.total_moderators" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-statistic label="总版块数" :value="stats.total_boards" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-statistic label="待处理申请" :value="stats.pending_applications">
|
||||
<template #suffix>
|
||||
<n-tag v-if="stats.pending_applications > 0" type="warning" size="small">待处理</n-tag>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-statistic label="高级版主" :value="stats.senior_moderators" />
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-card size="small" class="aksrt-forum-mod__toolbar">
|
||||
<n-space justify="space-between" align="center">
|
||||
<span class="aksrt-forum-mod__summary">已加载 {{ boards.length }} 个版块</span>
|
||||
<n-space>
|
||||
<n-button @click="goApplications">查看版主申请</n-button>
|
||||
<n-button type="primary" @click="showBatchModal = true">批量管理</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card v-for="board in boards" :key="board.board_id" size="small" class="aksrt-forum-mod__section">
|
||||
<template #header>
|
||||
<div class="aksrt-forum-mod__header">
|
||||
<strong>{{ board.board_name }}</strong>
|
||||
<n-space size="small">
|
||||
<n-tag size="small" type="info">{{ board.topic_count }} 主题</n-tag>
|
||||
<n-tag size="small" :type="board.pending_application_count > 0 ? 'warning' : 'default'">
|
||||
{{ board.pending_application_count }} 申请待处理
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 版主列表 -->
|
||||
<div class="aksrt-forum-mod__list">
|
||||
<div v-for="moderator in board.moderators" :key="moderator.id" class="aksrt-forum-mod__item">
|
||||
<n-avatar :src="moderator.avatar_url" :size="36" round />
|
||||
<div class="aksrt-forum-mod__info">
|
||||
<div class="aksrt-forum-mod__name-row">
|
||||
<span class="aksrt-forum-mod__name">{{ moderator.display_name }}</span>
|
||||
<n-tag :type="getRoleTagType(moderator.role)" size="tiny">{{ getRoleLabel(moderator.role) }}</n-tag>
|
||||
</div>
|
||||
<span class="aksrt-forum-mod__email">{{ moderator.user_email }}</span>
|
||||
<div v-if="moderator.permissions?.length" class="aksrt-forum-mod__permissions">
|
||||
<n-tag v-for="perm in moderator.permissions" :key="perm" size="tiny" type="default">{{ getPermissionLabel(perm) }}</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
<n-space size="small">
|
||||
<n-select
|
||||
v-model:value="moderator.role"
|
||||
:options="roleOptions"
|
||||
size="tiny"
|
||||
style="width:100px;"
|
||||
@update:value="handleRoleChange(board.board_id, moderator.id, $event)"
|
||||
/>
|
||||
<n-button size="tiny" quaternary type="error" @click="handleRemove(board.board_id, moderator.id)">移除</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-empty v-if="board.moderators.length === 0" description="暂无版主" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- 添加版主 -->
|
||||
<div class="aksrt-forum-mod__add">
|
||||
<n-input
|
||||
v-model:value="board._newUser"
|
||||
size="small"
|
||||
style="width:140px"
|
||||
placeholder="用户 ID"
|
||||
@keyup.enter="handleAdd(board)"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="board._newRole"
|
||||
:options="roleOptions"
|
||||
size="small"
|
||||
style="width:100px;"
|
||||
placeholder="角色"
|
||||
/>
|
||||
<n-button size="small" type="primary" :disabled="!board._newUser" @click="handleAdd(board)">添加版主</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-empty v-if="boards.length === 0 && !loading" description="暂无论坛版块" />
|
||||
|
||||
<!-- 批量管理弹窗 -->
|
||||
<n-modal v-model:show="showBatchModal" preset="card" title="批量管理版主" style="max-width:560px;" :bordered="false">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="选择版块">
|
||||
<n-select v-model:value="batchForm.board_id" :options="boardOptions" placeholder="选择版块" />
|
||||
</n-form-item>
|
||||
<n-form-item label="操作">
|
||||
<n-radio-group v-model:value="batchForm.action">
|
||||
<n-space>
|
||||
<n-radio value="add">批量添加</n-radio>
|
||||
<n-radio value="remove">批量移除</n-radio>
|
||||
<n-radio value="update_role">批量修改角色</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="用户 ID 列表">
|
||||
<n-input v-model:value="batchForm.user_ids" type="textarea" :rows="3" placeholder="每行一个用户 ID" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="batchForm.action === 'update_role'" label="目标角色">
|
||||
<n-select v-model:value="batchForm.target_role" :options="roleOptions" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showBatchModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="batchLoading" @click="handleBatch">执行</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const boards = ref([])
|
||||
const stats = reactive({ total_moderators: 0, total_boards: 0, pending_applications: 0, senior_moderators: 0 })
|
||||
|
||||
// 批量管理
|
||||
const showBatchModal = ref(false)
|
||||
const batchLoading = ref(false)
|
||||
const batchForm = reactive({ board_id: null, action: 'add', user_ids: '', target_role: 'moderator' })
|
||||
|
||||
// 角色选项
|
||||
const roleOptions = [
|
||||
{ label: '超级版主', value: 'super_moderator' },
|
||||
{ label: '高级版主', value: 'senior_moderator' },
|
||||
{ label: '版主', value: 'moderator' },
|
||||
{ label: '实习版主', value: 'trainee' },
|
||||
]
|
||||
|
||||
// 权限选项
|
||||
const permissionLabels = {
|
||||
manage_topics: '管理主题',
|
||||
manage_comments: '管理评论',
|
||||
manage_users: '管理用户',
|
||||
pin_topics: '置顶主题',
|
||||
close_topics: '关闭主题',
|
||||
move_topics: '移动主题',
|
||||
delete_topics: '删除主题',
|
||||
ban_users: '封禁用户',
|
||||
}
|
||||
|
||||
const boardOptions = computed(() => boards.value.map(b => ({ label: b.board_name, value: b.board_id })))
|
||||
|
||||
function getRoleLabel(role) {
|
||||
const opt = roleOptions.find(r => r.value === role)
|
||||
return opt?.label || role || '版主'
|
||||
}
|
||||
|
||||
function getRoleTagType(role) {
|
||||
const map = { super_moderator: 'error', senior_moderator: 'warning', moderator: 'info', trainee: 'default' }
|
||||
return map[role] || 'default'
|
||||
}
|
||||
|
||||
function getPermissionLabel(perm) {
|
||||
return permissionLabels[perm] || perm
|
||||
}
|
||||
|
||||
async function loadModerators() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request('/forum/moderators')
|
||||
boards.value = (res?.data?.boards || []).map(item => ({
|
||||
...item,
|
||||
_newUser: '',
|
||||
_newRole: 'moderator',
|
||||
}))
|
||||
// 计算统计
|
||||
stats.total_boards = boards.value.length
|
||||
stats.total_moderators = boards.value.reduce((sum, b) => sum + (b.moderators?.length || 0), 0)
|
||||
stats.pending_applications = boards.value.reduce((sum, b) => sum + (b.pending_application_count || 0), 0)
|
||||
stats.senior_moderators = boards.value.reduce((sum, b) =>
|
||||
sum + (b.moderators?.filter(m => ['super_moderator', 'senior_moderator'].includes(m.role))?.length || 0), 0)
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载版主列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(board) {
|
||||
const userId = parseInt(board._newUser || '', 10)
|
||||
if (!userId || userId <= 0) {
|
||||
message.warning('请输入有效的用户 ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.request('/forum/moderators', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
board_id: board.board_id,
|
||||
user_id: userId,
|
||||
role: board._newRole || 'moderator',
|
||||
action: 'add',
|
||||
},
|
||||
})
|
||||
message.success('版主已添加')
|
||||
board._newUser = ''
|
||||
board._newRole = 'moderator'
|
||||
await loadModerators()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '添加版主失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(boardId, userId) {
|
||||
try {
|
||||
await api.request('/forum/moderators', {
|
||||
method: 'POST',
|
||||
data: { board_id: boardId, user_id: userId, action: 'remove' },
|
||||
})
|
||||
message.success('版主已移除')
|
||||
await loadModerators()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '移除版主失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleChange(boardId, userId, newRole) {
|
||||
try {
|
||||
await api.request('/forum/moderators', {
|
||||
method: 'POST',
|
||||
data: { board_id: boardId, user_id: userId, role: newRole, action: 'update_role' },
|
||||
})
|
||||
message.success('角色已更新')
|
||||
await loadModerators()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '角色更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatch() {
|
||||
const userIds = batchForm.user_ids.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
|
||||
if (!batchForm.board_id || userIds.length === 0) {
|
||||
message.warning('请选择版块并输入用户 ID')
|
||||
return
|
||||
}
|
||||
|
||||
batchLoading.value = true
|
||||
try {
|
||||
await api.request('/forum/moderators/batch', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
board_id: batchForm.board_id,
|
||||
user_ids: userIds,
|
||||
action: batchForm.action,
|
||||
target_role: batchForm.target_role,
|
||||
},
|
||||
})
|
||||
message.success('批量操作完成')
|
||||
showBatchModal.value = false
|
||||
batchForm.user_ids = ''
|
||||
await loadModerators()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '批量操作失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goApplications() {
|
||||
window.location.hash = '#/forum/moderator-applications'
|
||||
}
|
||||
|
||||
onMounted(loadModerators)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-forum-mod__toolbar,
|
||||
.aksrt-forum-mod__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__summary {
|
||||
color: var(--color-ink-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--color-line-soft);
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__name {
|
||||
display: block;
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__email {
|
||||
display: block;
|
||||
color: var(--color-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__permissions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.aksrt-forum-mod__add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-line-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="主题治理" desc="集中处理论坛主题的审核、可见性、版块移动和治理状态。" />
|
||||
|
||||
<n-card size="small" class="aksrt-forum-topics__section">
|
||||
<n-space wrap>
|
||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索标题 / 作者 / 版块" style="width: 220px" @keyup.enter="loadData(1)" />
|
||||
<n-select v-model:value="filters.board_id" :options="boardOptions" clearable placeholder="版块" style="width: 180px" />
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="帖子状态" style="width: 160px" />
|
||||
<n-select v-model:value="filters.review_status" :options="reviewOptions" clearable placeholder="审核状态" style="width: 160px" />
|
||||
<n-select v-model:value="filters.visibility" :options="visibilityOptions" clearable placeholder="可见性" style="width: 160px" />
|
||||
<n-button type="primary" @click="loadData(1)">查询</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-forum-topics__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<TopicModerationDrawer
|
||||
v-model:show="detailVisible"
|
||||
:topic-id="detailTopicId"
|
||||
@updated="loadData(page)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
import TopicModerationDrawer from './components/TopicModerationDrawer.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const items = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const detailTopicId = ref(null)
|
||||
|
||||
const boardOptions = ref([])
|
||||
const statusOptions = ref([{ label: '全部', value: 'all' }])
|
||||
const reviewOptions = ref([])
|
||||
const visibilityOptions = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
board_id: null,
|
||||
status: 'all',
|
||||
review_status: null,
|
||||
visibility: null,
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '主题',
|
||||
key: 'title',
|
||||
minWidth: 280,
|
||||
render: (row) => h('div', { class: 'aksrt-forum-topics__title-cell' }, [
|
||||
h('div', { class: 'aksrt-forum-topics__title' }, row.title || '-'),
|
||||
h('div', { class: 'aksrt-forum-topics__subtitle' }, [
|
||||
row.boards?.[0]?.name || '未分配版块',
|
||||
' · ',
|
||||
row.author?.display_name || '未知作者',
|
||||
]),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '可见性',
|
||||
key: 'visibility',
|
||||
width: 110,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.visibility?.key === 'public' ? 'default' : 'warning' }, () => row.visibility?.label || '-'),
|
||||
},
|
||||
{
|
||||
title: '审核',
|
||||
key: 'review_status',
|
||||
width: 110,
|
||||
render: (row) => h(NTag, { size: 'small', type: resolveReviewType(row.review_status?.key) }, () => row.review_status?.label || '-'),
|
||||
},
|
||||
{ title: '回复', key: 'comment_count', width: 80 },
|
||||
{ title: '浏览', key: 'views', width: 80 },
|
||||
{ title: '发布时间', key: 'date', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '治理'),
|
||||
},
|
||||
]
|
||||
|
||||
function resolveReviewType(status) {
|
||||
if (status === 'approved') return 'success'
|
||||
if (status === 'rejected') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function loadData(nextPage = 1) {
|
||||
loading.value = true
|
||||
page.value = nextPage
|
||||
pagination.page = nextPage
|
||||
|
||||
try {
|
||||
const res = await api.request('/forum/topics', {
|
||||
params: {
|
||||
page: nextPage,
|
||||
per_page: pagination.pageSize,
|
||||
keyword: filters.keyword || '',
|
||||
board_id: filters.board_id || '',
|
||||
status: filters.status || 'all',
|
||||
review_status: filters.review_status || '',
|
||||
visibility: filters.visibility || '',
|
||||
},
|
||||
})
|
||||
|
||||
const data = res?.data || {}
|
||||
items.value = data.items || []
|
||||
pagination.itemCount = data.total || 0
|
||||
boardOptions.value = [{ label: '全部版块', value: null }, ...((data.options?.boards || []).map((item) => ({ label: item.name, value: item.id })))]
|
||||
statusOptions.value = data.options?.status || [{ label: '全部', value: 'all' }]
|
||||
reviewOptions.value = [{ label: '全部审核', value: null }, ...(data.options?.review || [])]
|
||||
visibilityOptions.value = [{ label: '全部可见性', value: null }, ...(data.options?.visibility || [])]
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载主题列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage) {
|
||||
loadData(nextPage)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.board_id = null
|
||||
filters.status = 'all'
|
||||
filters.review_status = null
|
||||
filters.visibility = null
|
||||
loadData(1)
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detailTopicId.value = row.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => loadData(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-forum-topics__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-forum-topics__title-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-forum-topics__title {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aksrt-forum-topics__subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="620" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="主题治理" closable>
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.topic">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="主题标题">{{ detail.topic.title || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="所属版块">{{ primaryBoardName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="作者">{{ detail.topic.author?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="可见性">{{ detail.topic.visibility?.label || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="审核状态">{{ detail.topic.review_status?.label || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="主题状态">{{ detail.topic.status || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="置顶">{{ stickySummary }}</n-descriptions-item>
|
||||
<n-descriptions-item label="精华">{{ detail.topic.featured ? '是' : '否' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="帖子类型">{{ bbsTypeLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detail.topic.bbs_type === 'question'" label="提问状态">{{ questionStatusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="收藏">{{ detail.topic.favorite_count ?? 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="评分">{{ detail.topic.score ?? 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="专题">{{ detail.topic.special_topic || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="投票">{{ voteSummary }}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理备注">{{ detail.topic.moderation_note || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div class="aksrt-topic-drawer__excerpt">
|
||||
<div class="aksrt-topic-drawer__section-title">内容摘要</div>
|
||||
<p>{{ detail.topic.excerpt || '暂无摘要' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-topic-drawer__actions">
|
||||
<div class="aksrt-topic-drawer__section-title">执行治理动作</div>
|
||||
<n-space vertical :size="12">
|
||||
<n-select v-model:value="form.action" :options="actionOptions" />
|
||||
|
||||
<n-select
|
||||
v-if="form.action === 'review'"
|
||||
v-model:value="form.review_status"
|
||||
:options="detail.options?.review || []"
|
||||
placeholder="选择审核状态"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-if="form.action === 'move_board'"
|
||||
v-model:value="form.target_board_id"
|
||||
:options="boardOptions"
|
||||
placeholder="选择目标版块"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-if="form.action === 'visibility'"
|
||||
v-model:value="form.visibility"
|
||||
:options="detail.options?.visibility || []"
|
||||
placeholder="选择可见性"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-if="form.action === 'sticky'"
|
||||
v-model:value="form.sticky_value"
|
||||
:options="stickyLevelOptions"
|
||||
placeholder="选择置顶级别"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-if="form.action === 'question_status'"
|
||||
v-model:value="form.question_status_value"
|
||||
:options="questionStatusOptions"
|
||||
placeholder="选择提问状态"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-if="booleanActions.includes(form.action) && form.action !== 'sticky'"
|
||||
v-model:value="form.value"
|
||||
:options="booleanOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
|
||||
<n-input
|
||||
v-model:value="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="记录本次处理原因,便于后续审计"
|
||||
/>
|
||||
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="submitting" @click="handleSubmit">执行</n-button>
|
||||
<n-button @click="loadDetail">刷新详情</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-empty v-else description="暂无主题详情" />
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
topicId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detail = ref({ topic: null, options: { boards: [], visibility: [], review: [] } })
|
||||
// sticky 现在是 0~3 分级选择,不再是布尔动作(§7.3 置顶分级)。
|
||||
const booleanActions = ['featured', 'locked', 'close', 'hide']
|
||||
|
||||
const form = reactive({
|
||||
action: 'review',
|
||||
review_status: 'pending',
|
||||
target_board_id: null,
|
||||
visibility: 'public',
|
||||
value: '1',
|
||||
sticky_value: 0,
|
||||
question_status_value: 0,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const actionOptions = [
|
||||
{ label: '审核状态', value: 'review' },
|
||||
{ label: '移动版块', value: 'move_board' },
|
||||
{ label: '修改可见性', value: 'visibility' },
|
||||
{ label: '置顶分级', value: 'sticky' },
|
||||
{ label: '精华切换', value: 'featured' },
|
||||
{ label: '锁定切换', value: 'locked' },
|
||||
{ label: '关闭帖子', value: 'close' },
|
||||
{ label: '隐藏帖子', value: 'hide' },
|
||||
{ label: '提问状态', value: 'question_status' },
|
||||
]
|
||||
|
||||
const booleanOptions = [
|
||||
{ label: '启用', value: '1' },
|
||||
{ label: '关闭', value: '0' },
|
||||
]
|
||||
|
||||
// 置顶分级选项(对标 zibll topping 0/1/2/3)。
|
||||
const stickyLevelOptions = [
|
||||
{ label: '不置顶', value: 0 },
|
||||
{ label: '版块置顶', value: 1 },
|
||||
{ label: '超级置顶', value: 2 },
|
||||
{ label: '全局置顶', value: 3 },
|
||||
]
|
||||
|
||||
const questionStatusOptions = [
|
||||
{ label: '未解决', value: 0 },
|
||||
{ label: '已解决', value: 1 },
|
||||
]
|
||||
|
||||
const primaryBoardName = computed(() => detail.value?.topic?.boards?.[0]?.name || '-')
|
||||
const boardOptions = computed(() => (detail.value?.options?.boards || []).map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})))
|
||||
// 置顶摘要:优先用后端返回的 sticky_label,否则按 sticky_level 映射。
|
||||
const stickySummary = computed(() => {
|
||||
const topic = detail.value?.topic
|
||||
if (!topic) return '-'
|
||||
const label = topic.sticky_label
|
||||
if (label) return label
|
||||
const level = topic.sticky_level ?? 0
|
||||
const found = stickyLevelOptions.find((opt) => opt.value === level)
|
||||
return found ? found.label : '不置顶'
|
||||
})
|
||||
const voteSummary = computed(() => {
|
||||
const vote = detail.value?.topic?.vote
|
||||
if (!vote?.enabled) {
|
||||
return '未开启'
|
||||
}
|
||||
|
||||
return `${vote.total_votes || 0} 票 / ${vote.multiple ? '多选' : '单选'}`
|
||||
})
|
||||
const bbsTypeLabel = computed(() => {
|
||||
const t = detail.value?.topic?.bbs_type
|
||||
return t === 'question' ? '提问' : '标准帖子'
|
||||
})
|
||||
const questionStatusLabel = computed(() => {
|
||||
const s = detail.value?.topic?.question_status
|
||||
return s === 1 ? '已解决' : '未解决'
|
||||
})
|
||||
|
||||
watch(() => props.show, async (visible) => {
|
||||
if (visible && props.topicId) {
|
||||
await loadDetail()
|
||||
} else if (!visible) {
|
||||
detail.value = { topic: null, options: { boards: [], visibility: [], review: [] } }
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.action = 'review'
|
||||
form.review_status = detail.value?.topic?.review_status?.key || 'pending'
|
||||
form.target_board_id = detail.value?.topic?.boards?.[0]?.id || null
|
||||
form.visibility = detail.value?.topic?.visibility?.key || 'public'
|
||||
form.value = '1'
|
||||
form.sticky_value = detail.value?.topic?.sticky_level ?? 0
|
||||
form.question_status_value = detail.value?.topic?.question_status ?? 0
|
||||
form.note = ''
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.topicId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/forum/topics/${props.topicId}`)
|
||||
detail.value = res?.data || { topic: null, options: { boards: [], visibility: [], review: [] } }
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载主题详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.topicId) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
action: form.action,
|
||||
note: form.note,
|
||||
}
|
||||
|
||||
if (form.action === 'review') {
|
||||
payload.review_status = form.review_status
|
||||
} else if (form.action === 'move_board') {
|
||||
payload.target_board_id = form.target_board_id
|
||||
} else if (form.action === 'visibility') {
|
||||
payload.visibility = form.visibility
|
||||
} else if (form.action === 'sticky') {
|
||||
// 置顶分级:发送 0~3 整数。
|
||||
payload.value = form.sticky_value
|
||||
} else if (form.action === 'question_status') {
|
||||
payload.value = form.question_status_value
|
||||
} else {
|
||||
payload.value = form.value === '1'
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.request(`/forum/topics/${props.topicId}/moderate`, {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
})
|
||||
message.success('治理动作已执行')
|
||||
await loadDetail()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '治理动作执行失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-topic-drawer__excerpt,
|
||||
.aksrt-topic-drawer__actions {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.aksrt-topic-drawer__section-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.aksrt-topic-drawer__excerpt p {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<PaymentSectionPage
|
||||
section="affiliate"
|
||||
title="推广返佣"
|
||||
desc="配置返佣比例、归因窗口和返佣说明。"
|
||||
note="返佣逻辑复用用户邀请体系作为链路入口,订单归因和结算自动衔接当前策略字段。"
|
||||
:links="[
|
||||
{ label: '用户签到/邀请', path: '/users/signin' },
|
||||
{ label: '创作分成', path: '/payment/revenue' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
|
||||
|
||||
<div class="aksrt-charge-card-page__stats">
|
||||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
||||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<n-card size="small" title="操作台">
|
||||
<n-space justify="space-between" wrap>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
|
||||
<n-button @click="batchVisible = true">批量生成</n-button>
|
||||
</n-space>
|
||||
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
|
||||
<n-space vertical :size="12">
|
||||
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
|
||||
<n-grid-item>
|
||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:pagination="pagination"
|
||||
:row-key="(row) => row.id"
|
||||
/>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
|
||||
<n-form :model="createForm" label-placement="top">
|
||||
<n-form-item label="卡号">
|
||||
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡号" />
|
||||
</n-form-item>
|
||||
<n-grid cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="面额">
|
||||
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="过期时间">
|
||||
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
<n-space justify="end">
|
||||
<n-button @click="createVisible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
|
||||
<n-form :model="batchForm" label-placement="top">
|
||||
<n-grid cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="数量">
|
||||
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="面额">
|
||||
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-grid cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="前缀">
|
||||
<n-input v-model:value="batchForm.prefix" placeholder="CARD" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="随机长度">
|
||||
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-form-item label="过期时间">
|
||||
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
<n-space justify="end">
|
||||
<n-button @click="batchVisible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
|
||||
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const createVisible = ref(false)
|
||||
const batchVisible = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const currentCardId = ref(null)
|
||||
const submittingCreate = ref(false)
|
||||
const submittingBatch = ref(false)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
active: 0,
|
||||
used: 0,
|
||||
total_amount: 0,
|
||||
used_amount: 0,
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
dateRange: null,
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
code: '',
|
||||
amount: 10,
|
||||
expiresAt: null,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const batchForm = reactive({
|
||||
count: 20,
|
||||
amount: 10,
|
||||
prefix: 'CARD',
|
||||
length: 12,
|
||||
expiresAt: null,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const pager = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const optionsState = reactive({
|
||||
statuses: [],
|
||||
})
|
||||
|
||||
const statCards = [
|
||||
{ key: 'total', label: '总卡数' },
|
||||
{ key: 'active', label: '未使用' },
|
||||
{ key: 'used', label: '已使用' },
|
||||
{ key: 'total_amount', label: '总面额' },
|
||||
{ key: 'used_amount', label: '已兑换金额' },
|
||||
]
|
||||
|
||||
const statusOptions = computed(() => optionsState.statuses.length
|
||||
? optionsState.statuses
|
||||
: [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '未使用', value: 'active' },
|
||||
{ label: '已使用', value: 'used' },
|
||||
{ label: '已过期', value: 'expired' },
|
||||
{ label: '已禁用', value: 'disabled' },
|
||||
])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: pager.page,
|
||||
pageSize: pager.pageSize,
|
||||
itemCount: pager.itemCount,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
onUpdatePage: (page) => {
|
||||
pager.page = page
|
||||
loadData()
|
||||
},
|
||||
onUpdatePageSize: (pageSize) => {
|
||||
pager.pageSize = pageSize
|
||||
pager.page = 1
|
||||
loadData()
|
||||
},
|
||||
}))
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '卡号', key: 'code', minWidth: 190, ellipsis: { tooltip: true } },
|
||||
{ title: '面额', key: 'amount', width: 90 },
|
||||
{ title: '余额', key: 'balance', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status_label',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
|
||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '使用人',
|
||||
key: 'used_by_user',
|
||||
width: 160,
|
||||
render: (row) => row.used_by_user?.display_name || '-',
|
||||
},
|
||||
{ title: '创建时间', key: 'created_at', width: 170 },
|
||||
{
|
||||
title: '过期时间',
|
||||
key: 'expires_at',
|
||||
width: 170,
|
||||
render: (row) => row.expires_at || '-',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
key: 'note',
|
||||
minWidth: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.note || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => {
|
||||
const actions = [
|
||||
h(NButton, {
|
||||
size: 'tiny',
|
||||
type: 'primary',
|
||||
quaternary: true,
|
||||
onClick: () => openDrawer(row.id),
|
||||
}, () => '详情'),
|
||||
]
|
||||
|
||||
if (row.status === 'active') {
|
||||
actions.push(h(NButton, {
|
||||
size: 'tiny',
|
||||
type: 'warning',
|
||||
quaternary: true,
|
||||
onClick: () => handleDisable(row),
|
||||
}, () => '禁用'))
|
||||
}
|
||||
|
||||
return actions
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
function formatDateValue(value) {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hours = `${date.getHours()}`.padStart(2, '0')
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, '0')
|
||||
const seconds = `${date.getSeconds()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function buildParams(page = pager.page, pageSize = pager.pageSize) {
|
||||
const params = {
|
||||
page,
|
||||
per_page: pageSize,
|
||||
}
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.status) params.status = filters.status
|
||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
||||
const [start, end] = filters.dateRange
|
||||
params.date_from = formatDateValue(start).slice(0, 10)
|
||||
params.date_to = formatDateValue(end).slice(0, 10)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request('/payment/charge-cards', { params: buildParams() })
|
||||
const data = res?.data || {}
|
||||
rows.value = data.items || []
|
||||
pager.itemCount = data.total || 0
|
||||
Object.assign(stats, data.stats || {})
|
||||
optionsState.statuses = data.options?.statuses || []
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载充值卡台账失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
filters.dateRange = null
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
submittingCreate.value = true
|
||||
try {
|
||||
await api.request('/payment/charge-cards', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: createForm.code,
|
||||
amount: createForm.amount,
|
||||
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
|
||||
note: createForm.note,
|
||||
},
|
||||
})
|
||||
message.success('充值卡已创建')
|
||||
createVisible.value = false
|
||||
createForm.code = ''
|
||||
createForm.note = ''
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '创建失败')
|
||||
} finally {
|
||||
submittingCreate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchCreate() {
|
||||
submittingBatch.value = true
|
||||
try {
|
||||
await api.request('/payment/charge-cards/batch', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
count: batchForm.count,
|
||||
amount: batchForm.amount,
|
||||
prefix: batchForm.prefix,
|
||||
length: batchForm.length,
|
||||
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
|
||||
note: batchForm.note,
|
||||
},
|
||||
})
|
||||
message.success('充值卡已批量生成')
|
||||
batchVisible.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '批量生成失败')
|
||||
} finally {
|
||||
submittingBatch.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisable(row) {
|
||||
dialog.warning({
|
||||
title: '确认禁用',
|
||||
content: `确定要禁用充值卡 ${row.code} 吗?`,
|
||||
positiveText: '禁用',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
|
||||
message.success('充值卡已禁用')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error(error?.message || '禁用失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
currentCardId.value = id
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
|
||||
const items = res?.data?.items || []
|
||||
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
|
||||
const lines = items.map((row) => [
|
||||
row.id,
|
||||
row.code,
|
||||
row.amount,
|
||||
row.balance,
|
||||
row.status_label,
|
||||
row.used_by_user?.display_name || '',
|
||||
row.created_at || '',
|
||||
row.expires_at || '',
|
||||
row.note || '',
|
||||
])
|
||||
downloadCsv('charge-cards.csv', [header, ...lines])
|
||||
message.success('导出完成')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCsv(filename, rows) {
|
||||
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-charge-card-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-charge-card-page__card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<PaymentSectionPage
|
||||
section="gateway"
|
||||
title="收款接口"
|
||||
desc="管理支付网关、查看连接状态、配置回调口令。"
|
||||
note="当前支持人工确认与测试联调;回调校验口令在接入真实网关时直接复用。"
|
||||
:links="[
|
||||
{ label: '余额充值', path: '/payment/recharge' },
|
||||
{ label: '付费内容', path: '/payment/paid' },
|
||||
]"
|
||||
>
|
||||
<template #before-form>
|
||||
<n-spin :show="loading">
|
||||
<n-card class="aksrt-section-card" title="网关状态">
|
||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="gw in gateways" :key="gw.id">
|
||||
<div class="aksrt-gateway__card">
|
||||
<div class="aksrt-gateway__icon" :class="gw.configured ? 'aksrt-gateway__icon--active' : ''">
|
||||
<i :class="gw.id === 'balance' ? 'fas fa-wallet' : 'fas fa-credit-card'"></i>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ gw.name }}</strong>
|
||||
<p>{{ gw.configured ? '已配置' : '未配置' }}</p>
|
||||
</div>
|
||||
<n-tag :type="gw.configured ? 'success' : 'default'" size="small" round>
|
||||
{{ gw.configured ? '可用' : '不可用' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-empty v-if="!loading && gateways.length === 0" description="暂无已注册的支付网关" />
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card" title="运行摘要">
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="aksrt-gateway__stat">
|
||||
<strong>{{ modeLabel }}</strong>
|
||||
<p>当前模式</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-gateway__stat">
|
||||
<strong :class="overview.gateway?.test_enabled ? 'aksrt-text-warning' : ''">
|
||||
{{ overview.gateway?.test_enabled ? '启用' : '关闭' }}
|
||||
</strong>
|
||||
<p>测试网关</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-gateway__stat">
|
||||
<strong :class="overview.gateway?.manual_review ? 'aksrt-text-info' : ''">
|
||||
{{ overview.gateway?.manual_review ? '启用' : '关闭' }}
|
||||
</strong>
|
||||
<p>人工复核</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-gateway__stat">
|
||||
<n-tag :type="overview.gateway?.callback_secret ? 'success' : 'warning'" size="small" round>
|
||||
{{ overview.gateway?.callback_secret ? '已设置' : '未设置' }}
|
||||
</n-tag>
|
||||
<p>回调口令</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-spin>
|
||||
</template>
|
||||
</PaymentSectionPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const message = useMessage()
|
||||
const gateways = ref([])
|
||||
const overview = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
const mode = overview.value.gateway?.mode
|
||||
if (mode === 'test_gateway') return '测试网关'
|
||||
if (mode === 'adapter_ready') return '适配器就绪'
|
||||
return '人工确认'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [gwRes, ovRes] = await Promise.all([
|
||||
api.request('/shop/gateways').catch(() => null),
|
||||
api.request('/payment/overview').catch(() => null),
|
||||
])
|
||||
if (gwRes?.data?.gateways) {
|
||||
gateways.value = gwRes.data.gateways
|
||||
}
|
||||
if (ovRes?.data) {
|
||||
overview.value = ovRes.data
|
||||
}
|
||||
} catch {
|
||||
message.warning('部分支付接口暂不可用')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-gateway__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2, #fafafa);
|
||||
border: 1px solid var(--color-line, #f0f0f0);
|
||||
}
|
||||
|
||||
.aksrt-gateway__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-3, #f0f0f0);
|
||||
color: var(--color-ink-4, #8a909c);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.aksrt-gateway__icon--active {
|
||||
background: var(--color-primary-soft, #f0f5ff);
|
||||
color: var(--color-primary, #1890ff);
|
||||
}
|
||||
|
||||
.aksrt-gateway__card strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--color-ink, #1a1a2e);
|
||||
}
|
||||
|
||||
.aksrt-gateway__card p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-4, #8a909c);
|
||||
}
|
||||
|
||||
.aksrt-gateway__stat {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2, #fafafa);
|
||||
}
|
||||
|
||||
.aksrt-gateway__stat strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink, #1a1a2e);
|
||||
}
|
||||
|
||||
.aksrt-gateway__stat p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-4, #8a909c);
|
||||
}
|
||||
|
||||
.aksrt-text-warning {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.aksrt-text-info {
|
||||
color: var(--color-info, #3b82f6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="返佣管理" desc="按状态、类型和时间筛选返佣记录,自定义列展示并在详情中查看处理历史。" />
|
||||
|
||||
<div class="aksrt-rebate-page__stats">
|
||||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
||||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<n-card size="small" title="筛选与列控制">
|
||||
<n-space vertical :size="12">
|
||||
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
|
||||
<n-grid-item>
|
||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索订单号、用户、备注" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="filters.type" :options="typeOptions" placeholder="类型" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-checkbox-group v-model:value="visibleColumnKeys">
|
||||
<n-space wrap>
|
||||
<n-checkbox v-for="column in columnChoices" :key="column.value" :value="column.value">
|
||||
{{ column.label }}
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-rebate-page__card" size="small" title="返佣台账">
|
||||
<n-data-table
|
||||
:columns="tableColumns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:pagination="pagination"
|
||||
:row-key="(row) => row.id"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<RebateDetailDrawer
|
||||
v-model:show="drawerVisible"
|
||||
:rebate-id="currentRebateId"
|
||||
@updated="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import RebateDetailDrawer from './components/RebateDetailDrawer.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const drawerVisible = ref(false)
|
||||
const currentRebateId = ref(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
settled: 0,
|
||||
total_amount: 0,
|
||||
settled_amount: 0,
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
type: '',
|
||||
dateRange: null,
|
||||
})
|
||||
|
||||
const pager = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const optionsState = reactive({
|
||||
statuses: [],
|
||||
types: [],
|
||||
})
|
||||
|
||||
const statCards = [
|
||||
{ key: 'total', label: '总记录' },
|
||||
{ key: 'pending', label: '待结算' },
|
||||
{ key: 'settled', label: '已结算' },
|
||||
{ key: 'total_amount', label: '总返佣' },
|
||||
{ key: 'settled_amount', label: '已结算金额' },
|
||||
]
|
||||
|
||||
const statusOptions = computed(() => optionsState.statuses.length
|
||||
? optionsState.statuses
|
||||
: [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待结算', value: 'pending' },
|
||||
{ label: '已结算', value: 'settled' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => optionsState.types.length
|
||||
? optionsState.types
|
||||
: [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '订单返佣', value: 'order' },
|
||||
{ label: '邀请返佣', value: 'invite' },
|
||||
])
|
||||
|
||||
const columnChoices = [
|
||||
{ label: '来源用户', value: 'source_user' },
|
||||
{ label: '创建时间', value: 'created_at' },
|
||||
{ label: '结算时间', value: 'settled_at' },
|
||||
{ label: '备注', value: 'note' },
|
||||
{ label: '处理记录数', value: 'history_count' },
|
||||
]
|
||||
|
||||
const visibleColumnKeys = ref(['source_user', 'created_at', 'settled_at', 'history_count'])
|
||||
|
||||
const baseColumns = computed(() => {
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '订单号', key: 'order_number', minWidth: 180, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '返佣对象',
|
||||
key: 'user',
|
||||
width: 160,
|
||||
render: (row) => row.user?.display_name || '-',
|
||||
},
|
||||
{
|
||||
title: '返佣金额',
|
||||
key: 'amount',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const typeMap = { pending: 'warning', settled: 'success', cancelled: 'default' }
|
||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (row) => row.type_label || row.type,
|
||||
},
|
||||
]
|
||||
|
||||
if (visibleColumnKeys.value.includes('source_user')) {
|
||||
columns.push({
|
||||
title: '来源用户',
|
||||
key: 'source_user',
|
||||
width: 160,
|
||||
render: (row) => row.source_user?.display_name || '-',
|
||||
})
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('created_at')) {
|
||||
columns.push({ title: '创建时间', key: 'created_at', width: 170 })
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('settled_at')) {
|
||||
columns.push({
|
||||
title: '结算时间',
|
||||
key: 'settled_at',
|
||||
width: 170,
|
||||
render: (row) => row.settled_at || '-',
|
||||
})
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('note')) {
|
||||
columns.push({
|
||||
title: '备注',
|
||||
key: 'note',
|
||||
minWidth: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.note || '-',
|
||||
})
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('history_count')) {
|
||||
columns.push({ title: '处理记录数', key: 'history_count', width: 100 })
|
||||
}
|
||||
|
||||
columns.push({
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, {
|
||||
size: 'tiny',
|
||||
type: 'primary',
|
||||
quaternary: true,
|
||||
onClick: () => openDrawer(row.id),
|
||||
}, () => '详情'),
|
||||
]),
|
||||
})
|
||||
|
||||
return columns
|
||||
})
|
||||
|
||||
const tableColumns = computed(() => baseColumns.value)
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: pager.page,
|
||||
pageSize: pager.pageSize,
|
||||
itemCount: pager.itemCount,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
onUpdatePage: (page) => {
|
||||
pager.page = page
|
||||
loadData()
|
||||
},
|
||||
onUpdatePageSize: (pageSize) => {
|
||||
pager.pageSize = pageSize
|
||||
pager.page = 1
|
||||
loadData()
|
||||
},
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
function formatDateValue(value) {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const params = {
|
||||
page: pager.page,
|
||||
per_page: pager.pageSize,
|
||||
}
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.status) params.status = filters.status
|
||||
if (filters.type) params.type = filters.type
|
||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
||||
params.date_from = formatDateValue(filters.dateRange[0])
|
||||
params.date_to = formatDateValue(filters.dateRange[1])
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request('/payment/rebate/log', { params: buildParams() })
|
||||
const data = res?.data || {}
|
||||
rows.value = data.items || []
|
||||
pager.itemCount = data.total || 0
|
||||
Object.assign(stats, data.stats || {})
|
||||
optionsState.statuses = data.options?.statuses || []
|
||||
optionsState.types = data.options?.types || []
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载返佣台账失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
filters.type = ''
|
||||
filters.dateRange = null
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
currentRebateId.value = id
|
||||
drawerVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-rebate-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-page__card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<PaymentSectionPage
|
||||
section="recharge"
|
||||
title="余额充值"
|
||||
desc="配置充值模式、套餐结构和帮助文案。"
|
||||
note="当前支持人工确认与测试联调;接入真实网关时复用同一组字段。"
|
||||
:links="[
|
||||
{ label: '下载/付费内容中心', path: '/payment/paid' },
|
||||
{ label: '收款接口', path: '/payment/gateway' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<PaymentSectionPage
|
||||
section="revenue"
|
||||
title="收益中心"
|
||||
desc="查看收入流水、收益统计和提现记录。"
|
||||
:links="[
|
||||
{ label: 'VIP 会员', path: '/payment/vip' },
|
||||
{ label: '推广返佣', path: '/payment/affiliate' },
|
||||
]"
|
||||
>
|
||||
<template #before-form>
|
||||
<n-card class="aksrt-section-card" title="收益统计">
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ revenueStats.total.toFixed(2) }}</strong>
|
||||
<p>总收益 (元)</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ revenueStats.pending.toFixed(2) }}</strong>
|
||||
<p>待结算 (元)</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ revenueStats.settled.toFixed(2) }}</strong>
|
||||
<p>已结算 (元)</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ revenueStats.count }}</strong>
|
||||
<p>收益笔数</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card" title="收益趋势">
|
||||
<template #header-extra>
|
||||
<n-space size="small">
|
||||
<n-button :type="granularity === 'day' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('day')">日</n-button>
|
||||
<n-button :type="granularity === 'week' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('week')">周</n-button>
|
||||
<n-button :type="granularity === 'month' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('month')">月</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<div v-if="timeSeries.length > 0" class="aksrt-revenue__chart">
|
||||
<div class="aksrt-revenue__chart-bars">
|
||||
<div
|
||||
v-for="(item, index) in timeSeries"
|
||||
:key="index"
|
||||
class="aksrt-revenue__chart-bar-wrap"
|
||||
:title="`${item.date}: ¥${item.amount.toFixed(2)} (${item.count}笔)`"
|
||||
>
|
||||
<div
|
||||
class="aksrt-revenue__chart-bar"
|
||||
:style="{ height: getBarHeight(item.amount) + '%' }"
|
||||
/>
|
||||
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-revenue__chart-summary">
|
||||
<span>期间总计: <strong>¥{{ periodTotal.toFixed(2) }}</strong></span>
|
||||
<span>共 {{ periodCount }} 笔</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无趋势数据" />
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card" title="提现统计">
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ withdrawalStats.total }}</strong>
|
||||
<p>提现笔数</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ withdrawalStats.pending }}</strong>
|
||||
<p>待审核</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ withdrawalStats.completed }}</strong>
|
||||
<p>已完成</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ withdrawalStats.total_paid.toFixed(2) }}</strong>
|
||||
<p>已提现 (元)</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</template>
|
||||
</PaymentSectionPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const revenueStats = ref({ total: 0, pending: 0, settled: 0, count: 0 })
|
||||
const withdrawalStats = ref({ total: 0, pending: 0, completed: 0, total_paid: 0 })
|
||||
const timeSeries = ref([])
|
||||
const granularity = ref('day')
|
||||
|
||||
const periodTotal = computed(() => timeSeries.value.reduce((s, i) => s + i.amount, 0))
|
||||
const periodCount = computed(() => timeSeries.value.reduce((s, i) => s + i.count, 0))
|
||||
const maxAmount = computed(() => Math.max(1, ...timeSeries.value.map(i => i.amount)))
|
||||
|
||||
function getBarHeight(amount) {
|
||||
return Math.max(2, (amount / maxAmount.value) * 100)
|
||||
}
|
||||
|
||||
function formatLabel(date) {
|
||||
if (granularity.value === 'month') return date.slice(5)
|
||||
if (granularity.value === 'week') return date.slice(5)
|
||||
return date.slice(5)
|
||||
}
|
||||
|
||||
function setGranularity(nextGranularity) {
|
||||
granularity.value = nextGranularity
|
||||
loadRevenue()
|
||||
}
|
||||
|
||||
async function loadRevenue() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
time_series: '1',
|
||||
granularity: granularity.value,
|
||||
})
|
||||
const res = await api.request(`/payment/revenue?${params}`)
|
||||
if (res?.data) {
|
||||
if (res.data.revenue) {
|
||||
revenueStats.value = {
|
||||
total: Number(res.data.revenue.total || 0),
|
||||
pending: Number(res.data.revenue.pending || 0),
|
||||
settled: Number(res.data.revenue.settled || 0),
|
||||
count: Number(res.data.revenue.count || 0),
|
||||
}
|
||||
}
|
||||
if (res.data.withdrawal) {
|
||||
withdrawalStats.value = {
|
||||
total: Number(res.data.withdrawal.total || 0),
|
||||
pending: Number(res.data.withdrawal.pending || 0),
|
||||
completed: Number(res.data.withdrawal.completed || 0),
|
||||
total_paid: Number(res.data.withdrawal.total_paid || 0),
|
||||
}
|
||||
}
|
||||
timeSeries.value = res.data.time_series || []
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRevenue)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-revenue__stat {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2, #fafafa);
|
||||
}
|
||||
|
||||
.aksrt-revenue__stat strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink, #1a1a2e);
|
||||
}
|
||||
|
||||
.aksrt-revenue__stat p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-4, #8a909c);
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 180px;
|
||||
padding: 0 4px;
|
||||
border-bottom: 1px solid var(--color-line, #e5e7eb);
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-bar-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-bar {
|
||||
width: 100%;
|
||||
max-width: 32px;
|
||||
min-height: 2px;
|
||||
background: var(--color-primary, #88abda);
|
||||
border-radius: 3px 3px 0 0;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-bar-wrap:hover .aksrt-revenue__chart-bar {
|
||||
background: var(--color-primary-hover, #7099cc);
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-ink-4, #8a909c);
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-3, #525866);
|
||||
}
|
||||
|
||||
.aksrt-revenue__chart-summary strong {
|
||||
color: var(--color-ink, #1a1a2e);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader :title="title" :desc="desc">
|
||||
<template #actions>
|
||||
<n-space v-if="links.length">
|
||||
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
|
||||
{{ link.label }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-card v-if="note" class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ note }}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<slot name="before-form" />
|
||||
|
||||
<AksFormRenderer
|
||||
:schema="pageMeta.groups"
|
||||
:form-data="formData"
|
||||
:dirty="dirty"
|
||||
:loading="loading"
|
||||
:saving="saving"
|
||||
:load-error="loadError"
|
||||
:show-search="false"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@reload="handleReload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
|
||||
import { useSectionForm } from '@/composables/useSectionForm'
|
||||
import { getSchema } from '@/schema'
|
||||
|
||||
const props = defineProps({
|
||||
section: { type: String, required: true },
|
||||
title: { type: String, required: true },
|
||||
desc: { type: String, default: '' },
|
||||
note: { type: String, default: '' },
|
||||
links: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const pageMeta = computed(() => getSchema('payment', props.section) || { groups: [] })
|
||||
const defaults = computed(() => {
|
||||
const result = {}
|
||||
;(pageMeta.value.groups || []).forEach((group) => {
|
||||
;(group.fields || []).forEach((field) => {
|
||||
if (field.default !== undefined) {
|
||||
result[field.key] = field.default
|
||||
}
|
||||
})
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const {
|
||||
formData,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
dirty,
|
||||
save: doSave,
|
||||
reload,
|
||||
} = useSectionForm({
|
||||
fetch: () => api.getSchemaPage('payment', props.section),
|
||||
submit: (data) => api.saveSchemaPage('payment', props.section, data),
|
||||
defaults: defaults.value,
|
||||
onSaved: () => {
|
||||
message.success('设置已保存')
|
||||
},
|
||||
})
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await doSave()
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleReload() {
|
||||
reload()
|
||||
message.info('已放弃更改')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<PaymentSectionPage
|
||||
section="vip"
|
||||
title="VIP会员"
|
||||
desc=”配置会员名称、展示徽标和套餐结构。”
|
||||
note=”角色免扣下载的运行逻辑在”下载/付费内容中心”中生效;此处管理会员套餐和权益说明。”
|
||||
:links="[
|
||||
{ label: '下载/付费内容中心', path: '/payment/paid' },
|
||||
{ label: '创作分成', path: '/payment/revenue' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="680" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="充值卡详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.card">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="卡号">{{ detail.card.code || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.card.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="过期时间">{{ detail.card.expires_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="使用时间">{{ detail.card.used_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="使用者">
|
||||
{{ detail.card.used_by_user?.display_name || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div class="aksrt-charge-drawer__section">
|
||||
<div class="aksrt-charge-drawer__title">备注</div>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ detail.card.note || '暂无备注' }}
|
||||
</n-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-empty v-else description="暂无充值卡详情" />
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
cardId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
defineEmits(['update:show'])
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const detail = ref({ card: null })
|
||||
|
||||
watch(
|
||||
() => [props.show, props.cardId],
|
||||
async ([visible, id]) => {
|
||||
if (visible && id) {
|
||||
await loadDetail()
|
||||
}
|
||||
if (!visible) {
|
||||
detail.value = { card: null }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.cardId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/payment/charge-cards/${props.cardId}`)
|
||||
detail.value = res?.data || { card: null }
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载充值卡详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-charge-drawer__section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.aksrt-charge-drawer__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="返佣详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.record">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="订单号">{{ detail.record.order_number || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="返佣对象">{{ detail.record.user?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="来源用户">{{ detail.record.source_user?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="返佣金额">{{ detail.record.amount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="类型">{{ detail.record.type_label || detail.record.type }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.record.status_label || detail.record.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.record.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="结算时间">{{ detail.record.settled_at || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">备注</div>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ detail.record.note || '暂无备注' }}
|
||||
</n-alert>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">处理记录</div>
|
||||
<div v-if="detail.history?.length" class="aksrt-rebate-drawer__timeline">
|
||||
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-rebate-drawer__timeline-item">
|
||||
<div class="aksrt-rebate-drawer__timeline-head">
|
||||
<strong>{{ entry.event || '-' }}</strong>
|
||||
<span>{{ entry.time || '-' }}</span>
|
||||
</div>
|
||||
<p>{{ entry.note || '无备注' }}</p>
|
||||
<small>{{ entry.operator_name || '系统' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无处理记录" />
|
||||
</div>
|
||||
|
||||
<div v-if="detail.record.status === 'pending'" class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">处理返佣</div>
|
||||
<n-space vertical :size="12">
|
||||
<n-input
|
||||
v-model:value="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="记录结算说明或取消原因"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="submitting === 'settle'" @click="handleAction('settle')">
|
||||
结算返佣
|
||||
</n-button>
|
||||
<n-button type="warning" secondary :loading="submitting === 'cancel'" @click="handleAction('cancel')">
|
||||
取消返佣
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-empty v-else description="暂无返佣详情" />
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
rebateId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const submitting = ref('')
|
||||
const detail = ref({ record: null, history: [] })
|
||||
const form = reactive({ note: '' })
|
||||
|
||||
watch(
|
||||
() => [props.show, props.rebateId],
|
||||
async ([visible, id]) => {
|
||||
if (visible && id) {
|
||||
await loadDetail()
|
||||
}
|
||||
if (!visible) {
|
||||
detail.value = { record: null, history: [] }
|
||||
form.note = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rebateId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/payment/rebate/${props.rebateId}`)
|
||||
detail.value = res?.data || { record: null, history: [] }
|
||||
form.note = ''
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载返佣详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
if (!props.rebateId) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = action
|
||||
try {
|
||||
await api.request(`/payment/rebate/${props.rebateId}/${action}`, {
|
||||
method: 'POST',
|
||||
data: { note: form.note },
|
||||
})
|
||||
message.success(action === 'settle' ? '返佣已结算' : '返佣已取消')
|
||||
await loadDetail()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '处理失败')
|
||||
} finally {
|
||||
submitting.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-rebate-drawer__section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item p {
|
||||
margin: 0 0 6px;
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item small {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader
|
||||
title="附件治理"
|
||||
desc="统一管理全站上传行为:头像、封面、投稿图片、评论图片、背景图和资源附件。"
|
||||
>
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button quaternary @click="go('/permissions/download')">下载权限</n-button>
|
||||
<n-button quaternary @click="goModeration">审核策略</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<!-- Summary -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>上传策略总览</h3>
|
||||
</div>
|
||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="item in summaryItems" :key="item.key">
|
||||
<div class="aksrt-attach__summary">
|
||||
<FaIcon :icon="item.icon" :size="16" :color="item.enabled ? '#52c41a' : '#8a909c'" />
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<p>{{ item.enabled ? '已启用' : '已关闭' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- Avatar & Cover -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>头像与封面</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="允许用户上传头像">
|
||||
<n-switch v-model:value="form.allow_avatar_upload" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">关闭后用户只能使用 Gravatar/Cravatar 自动头像。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="允许用户上传封面图">
|
||||
<n-switch v-model:value="form.allow_cover_upload" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">控制用户中心公开主页封面图上传入口。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="允许用户上传背景图">
|
||||
<n-switch v-model:value="form.allow_background_upload" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">控制用户中心公开可用的背景图上传入口。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="头像/封面大小上限 (MB)">
|
||||
<n-input-number v-model:value="form.avatar_max_size" :min="1" :max="10" style="max-width:160px;" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Content uploads -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>内容上传</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="允许投稿插入图片">
|
||||
<n-switch v-model:value="form.allow_submit_images" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">前台投稿编辑器中允许插入图片。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="投稿图片大小上限 (MB)">
|
||||
<n-input-number v-model:value="form.submit_image_max_size" :min="1" :max="20" style="max-width:160px;" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="允许评论插入图片">
|
||||
<n-switch v-model:value="form.allow_comment_images" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">评论工具栏中允许插入图片链接或上传图片。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="评论图片大小上限 (MB)">
|
||||
<n-input-number v-model:value="form.comment_image_max_size" :min="1" :max="10" style="max-width:160px;" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- Resource attachments -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>资源附件</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="允许资源附件上传">
|
||||
<n-switch v-model:value="form.allow_resource_upload" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">后台资源编辑器中允许上传附件文件。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="资源附件大小上限 (MB)">
|
||||
<n-input-number v-model:value="form.resource_max_size" :min="1" :max="200" style="max-width:160px;" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- AI Moderation -->
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>审核联动</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
|
||||
<n-form-item label="背景图接入 AI 审核">
|
||||
<n-switch v-model:value="form.ai_moderate_background" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">命中高风险时会阻断上传,并保留上一次背景图。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="投稿图片接入 AI 审核">
|
||||
<n-switch v-model:value="form.ai_moderate_submit" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">投稿中插入的图片也会经过内容安全审核。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const moderationSnapshot = ref({})
|
||||
|
||||
const form = reactive({
|
||||
allow_avatar_upload: true,
|
||||
allow_cover_upload: true,
|
||||
allow_background_upload: false,
|
||||
avatar_max_size: 2,
|
||||
allow_submit_images: true,
|
||||
submit_image_max_size: 5,
|
||||
allow_comment_images: false,
|
||||
comment_image_max_size: 2,
|
||||
allow_resource_upload: true,
|
||||
resource_max_size: 50,
|
||||
ai_moderate_background: true,
|
||||
ai_moderate_submit: false,
|
||||
})
|
||||
|
||||
const summaryItems = computed(() => [
|
||||
{ key: 'avatar', icon: 'fas fa-user-circle', label: '头像上传', enabled: form.allow_avatar_upload },
|
||||
{ key: 'cover', icon: 'fas fa-image', label: '封面上传', enabled: form.allow_cover_upload },
|
||||
{ key: 'background', icon: 'fas fa-photo-video', label: '背景图上传', enabled: form.allow_background_upload },
|
||||
{ key: 'submit', icon: 'fas fa-edit', label: '投稿图片', enabled: form.allow_submit_images },
|
||||
{ key: 'comment', icon: 'fas fa-comment-dots', label: '评论图片', enabled: form.allow_comment_images },
|
||||
{ key: 'resource', icon: 'fas fa-file-archive', label: '资源附件', enabled: form.allow_resource_upload },
|
||||
])
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function goModeration() { router.push('/permissions/moderation') }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [permissionRes, moderationRes] = await Promise.all([
|
||||
api.getPermissionsAttachmentSettings(),
|
||||
api.getPermissionsModerationSettings(),
|
||||
])
|
||||
|
||||
const permission = permissionRes?.data ?? permissionRes ?? {}
|
||||
const moderation = moderationRes?.data ?? moderationRes ?? {}
|
||||
|
||||
// Map permission fields
|
||||
form.allow_avatar_upload = permission.allow_avatar_upload !== false
|
||||
form.allow_cover_upload = permission.allow_cover_upload !== false
|
||||
form.allow_background_upload = !!permission.allow_background_upload
|
||||
form.avatar_max_size = Number(permission.avatar_max_size || 2)
|
||||
form.allow_submit_images = permission.allow_submit_images !== false
|
||||
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
|
||||
form.allow_comment_images = !!permission.allow_comment_images
|
||||
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
|
||||
form.allow_resource_upload = permission.allow_resource_upload !== false
|
||||
form.resource_max_size = Number(permission.resource_max_size || 50)
|
||||
|
||||
form.ai_moderate_background = moderation.ai_moderate_background !== false
|
||||
form.ai_moderate_submit = !!moderation.ai_moderate_submit
|
||||
|
||||
moderationSnapshot.value = { ...moderation }
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载附件权限失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
api.savePermissionsAttachmentSettings({
|
||||
allow_avatar_upload: form.allow_avatar_upload,
|
||||
allow_cover_upload: form.allow_cover_upload,
|
||||
allow_background_upload: form.allow_background_upload,
|
||||
avatar_max_size: form.avatar_max_size,
|
||||
allow_submit_images: form.allow_submit_images,
|
||||
submit_image_max_size: form.submit_image_max_size,
|
||||
allow_comment_images: form.allow_comment_images,
|
||||
comment_image_max_size: form.comment_image_max_size,
|
||||
allow_resource_upload: form.allow_resource_upload,
|
||||
resource_max_size: form.resource_max_size,
|
||||
}),
|
||||
api.savePermissionsModerationSettings({
|
||||
...moderationSnapshot.value,
|
||||
ai_moderate_background: form.ai_moderate_background,
|
||||
ai_moderate_submit: form.ai_moderate_submit,
|
||||
}),
|
||||
])
|
||||
moderationSnapshot.value = {
|
||||
...moderationSnapshot.value,
|
||||
ai_moderate_background: form.ai_moderate_background,
|
||||
ai_moderate_submit: form.ai_moderate_submit,
|
||||
}
|
||||
message.success('附件治理配置已保存')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-attach__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.aksrt-attach__summary strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-attach__summary p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader
|
||||
title="评论权限"
|
||||
desc="管理评论入口的登录要求与回复权限,并查看当前验证码与审核策略状态。"
|
||||
>
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button quaternary @click="go('/permissions/login')">人机验证</n-button>
|
||||
<n-button quaternary @click="go('/permissions/moderation')">审核策略</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>当前联动状态</h3>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-tag :type="status.commentCaptchaEnabled ? 'success' : 'default'" round>
|
||||
评论验证码:{{ status.commentCaptchaEnabled ? '已启用' : '未启用' }}
|
||||
</n-tag>
|
||||
<n-tag :type="status.moderationEnabled ? 'warning' : 'default'" round>
|
||||
审核链路:{{ status.moderationEnabled ? '已启用' : '未启用' }}
|
||||
</n-tag>
|
||||
<n-tag :type="status.rateLimitEnabled ? 'warning' : 'default'" round>
|
||||
评论限流:{{ status.rateLimitEnabled ? '已启用' : '未启用' }}
|
||||
</n-tag>
|
||||
<n-tag :type="status.keywordRuleCount > 0 ? 'error' : 'default'" round>
|
||||
敏感词:{{ status.keywordRuleCount }} 条
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>评论准入</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
||||
<n-form-item label="评论需要登录">
|
||||
<n-switch v-model:value="form.comment_require_login" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">开启后,游客只能浏览评论,不能直接提交评论。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="允许楼中回复">
|
||||
<n-switch v-model:value="form.comment_allow_reply" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">关闭后,前台评论列表将不再展示回复按钮。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
评论排序、分页方式、头像大小和空状态文案继续在“文章&列表 -> 评论设置”中维护;本页只负责准入与治理联动。
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
comment_require_login: false,
|
||||
comment_allow_reply: true,
|
||||
})
|
||||
|
||||
const status = reactive({
|
||||
commentCaptchaEnabled: false,
|
||||
moderationEnabled: false,
|
||||
rateLimitEnabled: false,
|
||||
keywordRuleCount: 0,
|
||||
})
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function splitKeywords(value) {
|
||||
return String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [commentData, captchaRes, moderationRes] = await Promise.all([
|
||||
api.getSchemaPage('post_list', 'comment'),
|
||||
api.getPermissionsLoginSettings(),
|
||||
api.getPermissionsModerationSettings(),
|
||||
])
|
||||
|
||||
const comment = commentData?.data ?? commentData ?? {}
|
||||
const captcha = captchaRes?.data ?? captchaRes ?? {}
|
||||
const moderation = moderationRes?.data ?? moderationRes ?? {}
|
||||
const disabledScenarios = Array.isArray(captcha.disabled_scenarios) ? captcha.disabled_scenarios : []
|
||||
|
||||
form.comment_require_login = !!comment.comment_require_login
|
||||
form.comment_allow_reply = comment.comment_allow_reply !== false
|
||||
|
||||
status.commentCaptchaEnabled = !!captcha.enabled && !disabledScenarios.includes('comment')
|
||||
status.moderationEnabled = !!moderation.enabled
|
||||
status.rateLimitEnabled = !!moderation.rate_limit_enabled
|
||||
status.keywordRuleCount = splitKeywords(moderation.blocked_keywords).length
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载评论权限失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.saveSchemaPage('post_list', 'comment', {
|
||||
comment_require_login: form.comment_require_login,
|
||||
comment_allow_reply: form.comment_allow_reply,
|
||||
})
|
||||
message.success('评论权限已保存')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader
|
||||
title="下载权限"
|
||||
desc="管理资源下载所需的登录、积分与验证码门槛,并查看当前默认下载成本。"
|
||||
>
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button quaternary @click="go('/payment/paid')">资源下载页</n-button>
|
||||
<n-button quaternary @click="go('/permissions/login')">人机验证</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>当前门槛摘要</h3>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-tag :type="form.download_login ? 'warning' : 'default'" round>
|
||||
登录要求:{{ form.download_login ? '已启用' : '已关闭' }}
|
||||
</n-tag>
|
||||
<n-tag :type="form.download_credit ? 'warning' : 'default'" round>
|
||||
积分扣费:{{ form.download_credit ? '已启用' : '已关闭' }}
|
||||
</n-tag>
|
||||
<n-tag :type="form.download_captcha ? 'warning' : 'default'" round>
|
||||
下载验证码:{{ form.download_captcha ? '已启用' : '已关闭' }}
|
||||
</n-tag>
|
||||
<n-tag type="info" round>默认下载积分:{{ defaultCost }}</n-tag>
|
||||
<n-tag type="info" round>Token TTL:10 分钟</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>下载门槛</h3>
|
||||
</div>
|
||||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
||||
<n-form-item label="下载需要登录">
|
||||
<n-switch v-model:value="form.download_login" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">关闭后,单个资源的登录要求仍可通过资源元字段单独覆盖。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="下载启用积分扣费">
|
||||
<n-switch v-model:value="form.download_credit" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">关闭后,资源价格将只作为展示信息,不会真正扣除积分。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="下载启用验证码">
|
||||
<n-switch v-model:value="form.download_captcha" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">具体验证码服务商与场景白名单仍在“登录权限”页维护。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
默认下载积分仍由积分配置维护;本页只负责访问门槛。更完整的支付、VIP 与白名单策略将在 Phase 10 统一收口。
|
||||
</n-alert>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const defaultCost = ref(0)
|
||||
const featureSnapshot = ref({
|
||||
resource_cpt_enabled: true,
|
||||
search_include_resource: true,
|
||||
download_login: true,
|
||||
download_credit: true,
|
||||
download_captcha: false,
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
download_login: true,
|
||||
download_credit: true,
|
||||
download_captcha: false,
|
||||
})
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [featureRes, creditRes] = await Promise.all([
|
||||
api.getCmsFeatures(),
|
||||
api.getCreditSettings(),
|
||||
])
|
||||
|
||||
const features = featureRes?.cms_features ?? featureRes?.data?.cms_features ?? {}
|
||||
const creditSettings = creditRes?.data ?? creditRes ?? {}
|
||||
|
||||
featureSnapshot.value = {
|
||||
resource_cpt_enabled: features.resource_cpt_enabled !== false,
|
||||
search_include_resource: features.search_include_resource !== false,
|
||||
download_login: features.download_login !== false,
|
||||
download_credit: features.download_credit !== false,
|
||||
download_captcha: !!features.download_captcha,
|
||||
}
|
||||
|
||||
form.download_login = featureSnapshot.value.download_login
|
||||
form.download_credit = featureSnapshot.value.download_credit
|
||||
form.download_captcha = featureSnapshot.value.download_captcha
|
||||
defaultCost.value = Number(creditSettings.download_default_cost || 0)
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载下载权限失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.saveCmsFeatures({
|
||||
...featureSnapshot.value,
|
||||
download_login: form.download_login,
|
||||
download_credit: form.download_credit,
|
||||
download_captcha: form.download_captcha,
|
||||
})
|
||||
featureSnapshot.value = {
|
||||
...featureSnapshot.value,
|
||||
download_login: form.download_login,
|
||||
download_credit: form.download_credit,
|
||||
download_captcha: form.download_captcha,
|
||||
}
|
||||
message.success('下载权限已保存')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader
|
||||
title="基本权限 / 能力矩阵"
|
||||
desc="统一管理用户治理、投稿和评论的角色能力矩阵。"
|
||||
>
|
||||
<template #actions>
|
||||
<n-space>
|
||||
<n-button quaternary @click="resetDefaults">恢复默认</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 240px;">
|
||||
<n-card class="aksrt-section-card" v-if="summary.cap_count">
|
||||
<div class="aksrt-section-header">
|
||||
<h3>矩阵概览</h3>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-tag round type="info">能力项:{{ summary.cap_count }}</n-tag>
|
||||
<n-tag round type="info">分组:{{ summary.group_count }}</n-tag>
|
||||
<n-tag round type="info">角色列:{{ summary.column_count }}</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card v-for="group in groups" :key="group.key" class="aksrt-section-card">
|
||||
<template #header>
|
||||
<div class="aksrt-cap-matrix__header">
|
||||
<div>
|
||||
<h3 class="aksrt-cap-matrix__title">{{ group.title }}</h3>
|
||||
<p class="aksrt-cap-matrix__desc">共 {{ group.items.length }} 项能力</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
:columns="buildColumns(group)"
|
||||
:data="group.items"
|
||||
:bordered="true"
|
||||
size="small"
|
||||
:single-line="false"
|
||||
:scroll-x="1200"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<div class="aksrt-form-actions">
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">保存能力矩阵</n-button>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { NInputNumber, NSwitch, NTag } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const groups = ref([])
|
||||
const columnsMeta = ref({})
|
||||
const summary = reactive({
|
||||
cap_count: 0,
|
||||
group_count: 0,
|
||||
column_count: 0,
|
||||
})
|
||||
const userCaps = reactive({})
|
||||
const defaultsSnapshot = ref({})
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.getCapabilities()
|
||||
const data = res?.data || {}
|
||||
|
||||
columnsMeta.value = data.columns || {}
|
||||
groups.value = data.groups || []
|
||||
Object.assign(summary, data.summary || { cap_count: 0, group_count: 0, column_count: 0 })
|
||||
|
||||
const snapshot = {}
|
||||
for (const group of groups.value) {
|
||||
for (const item of group.items || []) {
|
||||
snapshot[item.cap_id] = { ...(item.values || {}) }
|
||||
}
|
||||
}
|
||||
|
||||
replaceReactive(userCaps, snapshot)
|
||||
defaultsSnapshot.value = JSON.parse(JSON.stringify(snapshot))
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载能力矩阵失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function replaceReactive(target, source) {
|
||||
Object.keys(target).forEach((key) => delete target[key])
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
target[key] = { ...value }
|
||||
})
|
||||
}
|
||||
|
||||
function buildColumns(group) {
|
||||
const cols = [
|
||||
{
|
||||
title: '能力项',
|
||||
key: 'label',
|
||||
fixed: 'left',
|
||||
width: 280,
|
||||
render: (row) => h('div', { class: 'aksrt-cap-matrix__cap' }, [
|
||||
h('strong', row.label),
|
||||
h('code', row.cap_id),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
const visibleColumns = new Set()
|
||||
for (const item of group.items || []) {
|
||||
for (const col of item.visible_columns || []) {
|
||||
visibleColumns.add(col)
|
||||
}
|
||||
}
|
||||
|
||||
for (const col of Object.keys(columnsMeta.value || {})) {
|
||||
if (!visibleColumns.has(col)) continue
|
||||
|
||||
cols.push({
|
||||
title: columnsMeta.value[col],
|
||||
key: col,
|
||||
width: col === 'level' || col === 'vip' ? 110 : 100,
|
||||
render: (row) => renderCapCell(row, col),
|
||||
})
|
||||
}
|
||||
|
||||
return cols
|
||||
}
|
||||
|
||||
function renderCapCell(row, col) {
|
||||
const hidden = !(row.visible_columns || []).includes(col)
|
||||
if (hidden) {
|
||||
return h('span', { class: 'aksrt-cap-matrix__empty' }, '—')
|
||||
}
|
||||
|
||||
if (!userCaps[row.cap_id]) {
|
||||
userCaps[row.cap_id] = { ...(row.values || {}) }
|
||||
}
|
||||
|
||||
if (col === 'level' || col === 'vip') {
|
||||
return h(NInputNumber, {
|
||||
value: userCaps[row.cap_id]?.[col] ?? -1,
|
||||
min: -1,
|
||||
max: col === 'level' ? 10 : 2,
|
||||
step: 1,
|
||||
size: 'small',
|
||||
style: 'width: 90px',
|
||||
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value ?? -1),
|
||||
})
|
||||
}
|
||||
|
||||
return h(NSwitch, {
|
||||
value: !!userCaps[row.cap_id]?.[col],
|
||||
size: 'small',
|
||||
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value),
|
||||
})
|
||||
}
|
||||
|
||||
function updateCapValue(capId, col, value) {
|
||||
if (!userCaps[capId]) {
|
||||
userCaps[capId] = {}
|
||||
}
|
||||
userCaps[capId][col] = value
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
replaceReactive(userCaps, JSON.parse(JSON.stringify(defaultsSnapshot.value || {})))
|
||||
message.info('已恢复默认矩阵')
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
message.info('当前矩阵为平铺展示,无需展开')
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
message.info('当前矩阵为平铺展示,无需折叠')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.saveCapabilities(JSON.parse(JSON.stringify(userCaps)))
|
||||
defaultsSnapshot.value = JSON.parse(JSON.stringify(userCaps))
|
||||
message.success('能力矩阵已保存')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '保存能力矩阵失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-cap-matrix__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.aksrt-cap-matrix__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aksrt-cap-matrix__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
.aksrt-cap-matrix__cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-cap-matrix__cap code {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.aksrt-cap-matrix__empty {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.aksrt-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SchemaPage domain="post_list" section="comment" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SchemaPage from '@/pages/SchemaPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SchemaPage domain="post_list" section="page" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SchemaPage from '@/pages/SchemaPage.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="售后工作台" desc="把退款、换货、维修放在订单语境里处理,减少运营跳转。" />
|
||||
|
||||
<div class="aksrt-after-sale__stats">
|
||||
<div v-for="item in statCards" :key="item.key" class="aksrt-after-sale__stat-card">
|
||||
<div class="aksrt-after-sale__stat-icon" :style="{ background: item.color }">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="aksrt-after-sale__stat-info">
|
||||
<div class="aksrt-after-sale__stat-value">{{ stats[item.key] || 0 }}</div>
|
||||
<div class="aksrt-after-sale__stat-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card size="small" class="aksrt-after-sale__section">
|
||||
<n-space wrap>
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="售后状态" style="width: 160px" />
|
||||
<n-select v-model:value="filters.stage" :options="stageOptions" clearable placeholder="处理阶段" style="width: 160px" />
|
||||
<n-select v-model:value="filters.type" :options="typeOptions" clearable placeholder="售后类型" style="width: 160px" />
|
||||
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
|
||||
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
|
||||
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
|
||||
<n-button type="primary" @click="loadData(1)">查询</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
|
||||
<div class="aksrt-after-sale__toolbar">
|
||||
<n-space>
|
||||
<n-button type="primary" @click="showCreateModal = true">新建售后</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-after-sale__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<AfterSaleDetailDrawer
|
||||
v-model:show="detailVisible"
|
||||
:after-sale-id="detailId"
|
||||
:status-options="detailStatusOptions"
|
||||
@updated="loadData(page)"
|
||||
/>
|
||||
|
||||
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="aksrt-modal aksrt-after-sale__modal">
|
||||
<h3>新建售后工单</h3>
|
||||
<div class="aksrt-form-group">
|
||||
<label>订单号</label>
|
||||
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>售后类型</label>
|
||||
<select v-model="createForm.type" class="aksrt-select">
|
||||
<option v-for="item in typeOptions.filter((item) => item.value)" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>金额</label>
|
||||
<input v-model.number="createForm.amount" type="number" step="0.01" class="aksrt-input" placeholder="0.00">
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>原因</label>
|
||||
<textarea v-model="createForm.reason" class="aksrt-textarea" rows="3" placeholder="售后原因"></textarea>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>补充备注</label>
|
||||
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="补充说明"></textarea>
|
||||
</div>
|
||||
<div class="aksrt-modal__actions">
|
||||
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
|
||||
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
|
||||
{{ creating ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
import AfterSaleDetailDrawer from './components/AfterSaleDetailDrawer.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const items = ref([])
|
||||
const stats = ref({})
|
||||
const detailVisible = ref(false)
|
||||
const detailId = ref(null)
|
||||
const statusOptions = ref([])
|
||||
const stageOptions = ref([])
|
||||
const typeOptions = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
status: null,
|
||||
stage: null,
|
||||
type: null,
|
||||
order_number: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
order_number: '',
|
||||
type: 'refund',
|
||||
amount: 0,
|
||||
reason: '',
|
||||
note: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const statCards = [
|
||||
{ key: 'pending', label: '待处理', icon: 'fas fa-clock', color: '#f59e0b' },
|
||||
{ key: 'processing', label: '处理中', icon: 'fas fa-spinner', color: '#2563eb' },
|
||||
{ key: 'approved', label: '已通过', icon: 'fas fa-check', color: '#16a34a' },
|
||||
{ key: 'rejected', label: '已拒绝', icon: 'fas fa-times', color: '#dc2626' },
|
||||
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
|
||||
]
|
||||
|
||||
const detailStatusOptions = computed(() => statusOptions.value.filter((item) => item.value))
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
|
||||
{ title: '售后类型', key: 'type_label', width: 110 },
|
||||
{ title: '阶段', key: 'stage', width: 120, render: (row) => row.stage?.label || '-' },
|
||||
{ title: '物流联动', key: 'shipping_link', width: 170, render: (row) => row.order_context?.shipping?.latest?.status_label || row.order_context?.shipping?.latest?.status || '-' },
|
||||
{ title: '申请人', key: 'applicant', width: 120, render: (row) => row.applicant?.display_name || '-' },
|
||||
{ title: '金额', key: 'amount', width: 110, render: (row) => `¥${Number(row.amount || 0).toFixed(2)}` },
|
||||
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
|
||||
},
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
|
||||
},
|
||||
]
|
||||
|
||||
function resolveTagType(status) {
|
||||
if (status === 'approved' || status === 'completed') return 'success'
|
||||
if (status === 'processing') return 'info'
|
||||
if (status === 'rejected' || status === 'cancelled') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function loadData(nextPage = 1) {
|
||||
loading.value = true
|
||||
page.value = nextPage
|
||||
pagination.page = nextPage
|
||||
|
||||
try {
|
||||
const res = await api.request('/shop/after-sale', {
|
||||
params: {
|
||||
page: nextPage,
|
||||
per_page: pagination.pageSize,
|
||||
status: filters.status || '',
|
||||
stage: filters.stage || '',
|
||||
type: filters.type || '',
|
||||
order_number: filters.order_number || '',
|
||||
date_from: filters.date_from || '',
|
||||
date_to: filters.date_to || '',
|
||||
},
|
||||
})
|
||||
|
||||
const data = res?.data || {}
|
||||
items.value = data.items || []
|
||||
stats.value = data.stats || {}
|
||||
pagination.itemCount = data.total || 0
|
||||
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
|
||||
stageOptions.value = [{ label: '全部阶段', value: null }, ...(data.options?.stage || [])]
|
||||
typeOptions.value = [{ label: '全部类型', value: null }, ...(data.options?.type || [])]
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载售后数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage) {
|
||||
loadData(nextPage)
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detailId.value = row.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.status = null
|
||||
filters.stage = null
|
||||
filters.type = null
|
||||
filters.order_number = ''
|
||||
filters.date_from = ''
|
||||
filters.date_to = ''
|
||||
loadData(1)
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.order_number || !createForm.reason) {
|
||||
message.warning('请填写订单号和售后原因')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await api.request('/shop/after-sale', {
|
||||
method: 'POST',
|
||||
data: { ...createForm },
|
||||
})
|
||||
message.success('售后工单已创建')
|
||||
showCreateModal.value = false
|
||||
createForm.order_number = ''
|
||||
createForm.type = 'refund'
|
||||
createForm.amount = 0
|
||||
createForm.reason = ''
|
||||
createForm.note = ''
|
||||
loadData(1)
|
||||
} catch (error) {
|
||||
message.error(error?.message || '创建售后工单失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-after-sale__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__toolbar {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__modal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="优惠券管理" desc="创建和管理优惠券,支持百分比和固定金额两种类型。" />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<n-card size="small" class="aksrt-shop-coupons__section">
|
||||
<n-space justify="space-between">
|
||||
<n-space>
|
||||
<n-input v-model:value="search" placeholder="搜索优惠码" clearable style="width:200px;" @clear="loadCoupons" @keyup.enter="loadCoupons">
|
||||
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
|
||||
</n-input>
|
||||
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态" clearable style="width:120px;" @update:value="loadCoupons" />
|
||||
</n-space>
|
||||
<n-button type="primary" @click="showCreate = true">
|
||||
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
|
||||
新增优惠券
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 优惠券列表 -->
|
||||
<n-card size="small" class="aksrt-shop-coupons__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="coupons"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<n-modal :show="showCreate" @update:show="showCreate = $event" preset="card" title="新增优惠券" style="width:480px;" closable>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<n-form-item label="优惠码" path="code">
|
||||
<n-input v-model:value="form.code" placeholder="如 SAVE20" />
|
||||
</n-form-item>
|
||||
<n-form-item label="类型" path="type">
|
||||
<n-select v-model:value="form.type" :options="typeOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="form.type === 'percent' ? '折扣比例 (%)' : '减免金额 (元)'" path="value">
|
||||
<n-input-number v-model:value="form.value" :min="0" :max="form.type === 'percent' ? 100 : 99999" style="width:100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="最低消费 (元)">
|
||||
<n-input-number v-model:value="form.min_amount" :min="0" style="width:100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="使用次数上限">
|
||||
<n-input-number v-model:value="form.usage_limit" :min="0" style="width:100%;" />
|
||||
<template #feedback>0 表示不限制</template>
|
||||
</n-form-item>
|
||||
<n-form-item label="过期时间">
|
||||
<n-input v-model:value="form.expires_at" type="datetime-local" />
|
||||
<template #feedback>留空表示永不过期</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showCreate = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleCreate">创建</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { NTag, NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const coupons = ref([])
|
||||
const search = ref('')
|
||||
const statusFilter = ref(null)
|
||||
const showCreate = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
type: 'fixed',
|
||||
value: 10,
|
||||
min_amount: 0,
|
||||
usage_limit: 0,
|
||||
expires_at: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
code: { required: true, message: '请输入优惠码', trigger: 'blur' },
|
||||
value: { type: 'number', required: true, message: '请输入数值', trigger: 'blur' },
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '有效', value: 'active' },
|
||||
{ label: '已禁用', value: 'disabled' },
|
||||
]
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '固定金额', value: 'fixed' },
|
||||
{ label: '百分比折扣', value: 'percent' },
|
||||
]
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
|
||||
|
||||
const columns = [
|
||||
{ title: '优惠码', key: 'code', width: 140 },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.type === 'percent' ? 'info' : 'success' }, () => row.type === 'percent' ? `${row.value}%` : `¥${row.value}`),
|
||||
},
|
||||
{ title: '最低消费', key: 'min_amount', width: 100, render: (row) => row.min_amount > 0 ? `¥${row.min_amount}` : '—' },
|
||||
{ title: '已用/上限', key: 'usage', width: 100, render: (row) => `${row.used_count}/${row.usage_limit || '∞'}` },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'default' }, () => row.status === 'active' ? '有效' : '已禁用'),
|
||||
},
|
||||
{ title: '过期时间', key: 'expires_at', width: 160, render: (row) => row.expires_at || '永不过期' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
if (row.status !== 'active') return h('span', { style: 'color:#aaa;font-size:12px;' }, '—')
|
||||
return h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDisable(row) }, () => '禁用')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadCoupons() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(pagination.page),
|
||||
per_page: String(pagination.pageSize),
|
||||
})
|
||||
if (search.value) params.set('search', search.value)
|
||||
if (statusFilter.value) params.set('status', statusFilter.value)
|
||||
|
||||
const res = await api.request('/shop/coupons?' + params.toString())
|
||||
if (res?.data) {
|
||||
coupons.value = res.data.items || []
|
||||
pagination.itemCount = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
// endpoint may not exist yet
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
pagination.page = page
|
||||
loadCoupons()
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.request('/shop/coupons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...form }),
|
||||
})
|
||||
message.success('优惠券已创建')
|
||||
showCreate.value = false
|
||||
loadCoupons()
|
||||
} catch {
|
||||
message.error('创建失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisable(row) {
|
||||
dialog.warning({
|
||||
title: '确认禁用',
|
||||
content: `确定要禁用优惠码「${row.code}」吗?`,
|
||||
positiveText: '禁用',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.request(`/shop/coupons/${row.id}/disable`, { method: 'POST' })
|
||||
message.success('优惠券已禁用')
|
||||
loadCoupons()
|
||||
} catch {
|
||||
message.error('操作失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadCoupons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop-coupons__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="商城&商品" desc="商品管理、订单系统、库存管理等电商功能。" />
|
||||
|
||||
<div class="aksrt-shop-dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="aksrt-shop-dashboard__stats">
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-box"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ overview.product_count }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">商品数量</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-file-invoice"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ overview.order_count }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">订单数量</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-coins"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ revenueDisplay }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">累计收入 (元)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-clock"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ overview.order_pending }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">待处理订单</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近商品 -->
|
||||
<n-card class="aksrt-section-card" title="最近商品">
|
||||
<n-data-table
|
||||
v-if="products.length"
|
||||
:columns="productColumns"
|
||||
:data="products"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无商品" />
|
||||
</n-card>
|
||||
|
||||
<!-- 最近订单 -->
|
||||
<n-card class="aksrt-section-card" title="最近订单" style="margin-top:16px;">
|
||||
<n-data-table
|
||||
v-if="orders.length"
|
||||
:columns="orderColumns"
|
||||
:data="orders"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无订单" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, h } from 'vue'
|
||||
import { NTag } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const overview = ref({ product_count: 0, order_count: 0, order_pending: 0, revenue_total: 0 })
|
||||
const products = ref([])
|
||||
const orders = ref([])
|
||||
|
||||
const revenueDisplay = computed(() => (overview.value.revenue_total || 0).toFixed(2))
|
||||
|
||||
const statusMap = {
|
||||
created: { label: '已创建', type: 'default' },
|
||||
pending: { label: '待支付', type: 'warning' },
|
||||
paid: { label: '已支付', type: 'success' },
|
||||
delivered: { label: '已发货', type: 'info' },
|
||||
completed: { label: '已完成', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'default' },
|
||||
expired: { label: '已过期', type: 'default' },
|
||||
refunding: { label: '退款中', type: 'warning' },
|
||||
refunded: { label: '已退款', type: 'error' },
|
||||
}
|
||||
|
||||
const productColumns = [
|
||||
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
|
||||
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
|
||||
{ title: '库存', key: 'stock', width: 80 },
|
||||
{ title: '销量', key: 'sales', width: 80 },
|
||||
{ title: '创建时间', key: 'created', width: 160 },
|
||||
]
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', key: 'order_number', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: '产品类型', key: 'product_type', width: 100 },
|
||||
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const s = statusMap[row.status] || { label: row.status, type: 'default' }
|
||||
return h(NTag, { size: 'small', type: s.type }, () => s.label)
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [overviewRes, productsRes, ordersRes] = await Promise.all([
|
||||
api.request('/shop/overview'),
|
||||
api.request('/shop/products?per_page=5'),
|
||||
api.request('/shop/orders?per_page=5'),
|
||||
])
|
||||
if (overviewRes?.success && overviewRes.data) {
|
||||
overview.value = { ...overview.value, ...overviewRes.data }
|
||||
}
|
||||
if (productsRes?.success && productsRes.data) {
|
||||
products.value = productsRes.data.items || []
|
||||
}
|
||||
if (ordersRes?.success && ordersRes.data) {
|
||||
orders.value = ordersRes.data.items || []
|
||||
}
|
||||
} catch {
|
||||
// endpoints may not be available yet
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop-dashboard__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f0f5ff;
|
||||
color: #1890ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__label {
|
||||
font-size: 13px;
|
||||
color: #7a7f8a;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user