feat(attachment): 按参考主题重构后台上传配置套件

- 新增 AKSRT\Attachment\UploadLimits:按用户类型解析图片/视频/文件
  上传上限(取最大值+默认兜底,钳制到 PHP 上限)、允许/禁止格式过滤、
  上传自动重命名、非管理员附件页 404;不含论坛角色
- 后台「附件配置」重做:大小限制/格式限制/上传行为三组配置,
  PHP Schema 与 SPA Schema 逐字段镜像(v1.2.0)
- 定制页 AttachmentPermissions.vue 改为标准 schemaPage,清理失效的
  API 包装、组件加载器与旧配置键(submit_image_max_size 等)
- 投稿/评论上传、批量上传数量全部切换到新链路;AksField 数字组件
  支持单位展示
- 更新 captcha harness 与 front-submit 契约测试;421 项测试通过
This commit is contained in:
2026-09-24 11:04:49 +08:00
parent 505d9156fe
commit d4bb43a275
16 changed files with 643 additions and 269 deletions
@@ -3,14 +3,6 @@ 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') },
getCapabilities() { return this.request('/permissions/capabilities') },
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
}
+26 -11
View File
@@ -60,17 +60,19 @@
/>
<!-- Number -->
<n-input-number
v-else-if="schema.type === 'number' || schema.type === 'spinner'"
: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)"
/>
<div v-else-if="schema.type === 'number' || schema.type === 'spinner'" class="aksrt-field__number">
<n-input-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)"
/>
<span v-if="schema.unit" class="aksrt-field__unit">{{ schema.unit }}</span>
</div>
<!-- Select -->
<div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select">
@@ -1210,6 +1212,19 @@ function confirmRepeaterModal() {
max-width: 360px;
}
.aksrt-field__number {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
}
.aksrt-field__unit {
color: var(--aksrt-color-muted);
font-size: 13px;
white-space: nowrap;
}
.aksrt-field__between-sep {
color: var(--aksrt-color-border-strong);
font-size: 14px;
@@ -37,14 +37,13 @@ export const permissionsSection = {
canonicalDomain: 'permissions',
section: 'moderation',
}),
customPage({
schemaPage({
key: 'permissions-attachment',
name: 'PermissionsAttachment',
label: '附件配置',
route: '/permissions/attachment',
component: 'AttachmentPermissions',
title: '附件配置',
desc: '管理文件大小、类型、安全限制和审核策略。',
desc: '头像图片、上传大小、上传格式与上传行为配置。',
canonicalDomain: 'permissions',
section: 'attachment',
}),
@@ -1,174 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="附件治理"
desc="管理各类上传文件的大小、安全限制和审核策略;准入权限请在“基本权限”中配置。"
>
<template #actions>
<n-space>
<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' : 'var(--aksrt-color-muted)'" />
<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="头像/用户资料卡背景图大小上限 (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="投稿图片大小上限 (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="评论图片大小上限 (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="资源附件大小上限 (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>
<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 form = reactive({
avatar_max_size: 2,
submit_image_max_size: 5,
comment_image_max_size: 2,
resource_max_size: 50,
})
const summaryItems = computed(() => [
{ key: 'profile', icon: 'fa fas fa-id-card', label: '资料卡图片', enabled: true },
{ key: 'submit', icon: 'fa fas fa-edit', label: '投稿图片', enabled: true },
{ key: 'comment', icon: 'fa fas fa-comment-dots', label: '评论图片', enabled: true },
{ key: 'resource', icon: 'fa fas fa-file-archive', label: '资源附件', enabled: true },
])
function go(path) {
router.push(path)
}
function goModeration() { router.push('/permissions/moderation') }
async function load() {
loading.value = true
try {
const permissionRes = await api.getPermissionsAttachmentSettings()
const permission = permissionRes?.data ?? permissionRes ?? {}
// Map permission fields
form.avatar_max_size = Number(permission.avatar_max_size || 2)
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
form.resource_max_size = Number(permission.resource_max_size || 50)
} catch (error) {
message.error(error.message || '加载附件权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.savePermissionsAttachmentSettings({
avatar_max_size: form.avatar_max_size,
submit_image_max_size: form.submit_image_max_size,
comment_image_max_size: form.comment_image_max_size,
resource_max_size: form.resource_max_size,
})
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: var(--aksrt-color-text);
}
.aksrt-attach__summary p {
margin: 2px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
}
</style>
@@ -7,7 +7,6 @@ export const corePageLoaders = {
PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'),
CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'),
PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'),
AttachmentPermissions: () => import('@/pages/permissions/AttachmentPermissions.vue'),
CmsCredits: () => import('@/pages/CmsCredits.vue'),
ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'),
ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'),
@@ -3,6 +3,30 @@
* Section keys must stay aligned with the router and PHP schema provider.
*/
/**
* User type options shared by the upload limit repeaters.
* Mirrors AKSRT\Attachment\UploadLimits::user_type_options() (levels 2-10, VIP 1-2).
*/
const uploadUserTypeOptions = [
{ label: '最小默认值(所有人)', value: 'default' },
{ label: '已登录用户', value: 'logged' },
...[2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => ({ label: `等级达到 ${level} 级`, value: `level_${level}` })),
{ label: 'VIP1 会员', value: 'vip_1' },
{ label: 'VIP2 会员', value: 'vip_2' },
{ label: '已认证用户', value: 'auth' },
{ label: '超级管理员', value: 'admin' },
]
const uploadMimeUserTypeOptions = [
{ label: '所有用户', value: 'all' },
...uploadUserTypeOptions,
]
const uploadSizeFields = [
{ key: 'type', label: '用户类型', type: 'select', default: 'logged', options: uploadUserTypeOptions },
{ key: 'val', label: '可上传', type: 'number', default: 30, min: 1, max: 2048 },
]
export const permissionsSchemas = {
/**
* Login / captcha / access guard settings
@@ -188,47 +212,140 @@ export const permissionsSchemas = {
},
/**
* Attachment permissions
* Attachment / upload governance
*/
attachment: {
key: 'permissions.attachment',
title: '附件权限',
icon: 'fa fas fa-paperclip',
description: '控制文件上传和附件下载权限。',
defaults: {
avatar_max_size: 2,
submit_image_max_size: 5,
comment_image_max_size: 2,
resource_max_size: 50,
},
groups: [
{
key: 'profile',
title: '用户资料卡背景图',
icon: 'fa fas fa-user-circle',
fields: [
{ key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 },
],
},
{
key: 'content',
title: '内容上传',
icon: 'fa fas fa-image',
fields: [
{ key: 'submit_image_max_size', label: '投稿图片大小上限 (MB)', type: 'number', min: 1, max: 20 },
{ key: 'comment_image_max_size', label: '评论图片大小上限 (MB)', type: 'number', min: 1, max: 10 },
],
},
{
key: 'resource',
title: '资源附件',
icon: 'fa fas fa-file-archive',
fields: [
{ key: 'resource_max_size', label: '资源附件大小上限 (MB)', type: 'number', min: 1, max: 200 },
],
},
key: 'permissions.attachment',
title: '附件配置',
icon: 'fa fas fa-paperclip',
description: '统一管理头像、上传大小、上传格式与上传行为;准入权限请在“基本权限”中配置。',
defaults: {
avatar_max_size: 2,
upload_img_size: [
{ type: 'default', val: 5 },
{ type: 'admin', val: 20 },
],
image_upload_multiple: 6,
upload_video_size: [
{ type: 'default', val: 30 },
{ type: 'admin', val: 2048 },
],
upload_file_size: [
{ type: 'default', val: 30 },
{ type: 'admin', val: 2048 },
],
upload_file_mimes: [
{ type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' },
{ type: 'all', pattern: 'allow', val: '' },
],
newfilename: false,
newfilename_type: 'time',
close_attachment_page: true,
},
groups: [
{
key: 'profile',
title: '用户头像与资料卡图片',
icon: 'fa fas fa-id-card',
fields: [
{ key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 },
],
},
{
key: 'size-limits',
title: '上传大小限制',
icon: 'fa fas fa-weight-hanging',
description: '设置上传大小,请考虑服务器负荷、硬盘,以及服务器最大能支持的范围。实际上传大小取「主题限制」与「PHP 环境限制」的较小值,超过 PHP 环境限制时请调整 php.ini 的 upload_max_filesize 与 post_max_size。',
fields: [
{
key: 'upload_img_size',
label: '前端图片上传限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '投稿、评论等前端场景允许上传的最大图片大小(单位 MB)。同一个用户满足多个条件则取最大值,「最小默认值」为所有人兜底。',
fields: uploadSizeFields.map((field) => ({ ...field, default: field.key === 'val' ? 5 : field.default })),
},
{ key: 'image_upload_multiple', label: '图片批量上传最大数量', type: 'number', min: 0, max: 100, unit: '张', help: '前端投稿、评论上传图片允许的单次批量上传数量(为 0 则不限制,为 1 则不能批量上传)。' },
{
key: 'upload_video_size',
label: '前端视频上传限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '前端允许上传的最大视频大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。',
fields: uploadSizeFields,
},
{
key: 'upload_file_size',
label: '前端文件上传大小限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '前端允许上传的其他文件(不含视频和图片,建议大于视频图片)最大大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。',
fields: uploadSizeFields,
},
],
},
{
key: 'mime-limits',
title: '上传格式限制',
icon: 'fa fas fa-file-code',
fields: [
{
key: 'mime_limit_notice',
label: '安全提示',
type: 'notice',
alertType: 'warning',
alertContent: '请添加文件的扩展名和 MIME 类型,用英文逗号分割。格式示例:mp3=audio/mpeg,jpg=image/jpeg。禁止的优先级大于允许。郑重提示:请勿允许上传 php、sh、exe 等可执行文件,会极大增加服务器被入侵攻击的风险。',
},
{
key: 'upload_file_mimes',
label: '前端上传文件格式限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '根据不同的用户类型,添加额外允许或禁止上传的文件格式(禁止的优先级大于允许)。',
fields: [
{ key: 'type', label: '用户类型', type: 'select', default: 'all', options: uploadMimeUserTypeOptions },
{
key: 'pattern',
label: '规则',
type: 'select',
default: 'allow',
options: [
{ label: '允许', value: 'allow' },
{ label: '禁止', value: 'prohibit' },
],
},
{ key: 'val', label: '格式', type: 'textarea', default: '', rows: 1, placeholder: '如:svg=image/svg+xml,mp3=audio/mpeg' },
],
},
],
},
{
key: 'behavior',
title: '上传行为',
icon: 'fa fas fa-cogs',
fields: [
{ key: 'newfilename', label: '上传文件自动重命名', type: 'switch', help: '开启后上传文件按规则重命名,减少重名文件查询,提高上传效率。' },
{
key: 'newfilename_type',
label: '重命名格式',
type: 'radio',
buttonStyle: false,
default: 'time',
dependsOn: { key: 'newfilename', value: true },
options: [
{ label: '时间 + 原文件名', value: 'time' },
{ label: '随机字符串', value: 'random' },
],
},
{ key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', help: '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。' },
],
},
],
},
/**
* Download link configuration
+16 -2
View File
@@ -144,6 +144,18 @@
});
}
function imageBatchLimit() {
// 0 = 不限制(沿用默认 100 的选取上限),1 = 不允许批量上传。
var configured = Number(cfg.imageUploadMultiple);
if (!isFinite(configured) || configured <= 0) {
return { multiple: true, maxItems: 100 };
}
if (configured === 1) {
return { multiple: false, maxItems: 1 };
}
return { multiple: true, maxItems: Math.min(configured, 100) };
}
function mediaDefinition(type) {
if (type === 'video') return {
title: i18n.insertVideo || '插入视频', mineTab: i18n.mineVideo || '我的视频', uploadTab: i18n.uploadVideo || '上传视频',
@@ -156,9 +168,10 @@
accept: '*/*', multiple: false, maxItems: 1, canUpload: !!editorCfg.canUploadFile && !!((cfg.uploads || {}).resource), canEmbed: false,
select: i18n.selectFile || '选择或拖入附件', hint: i18n.fileUploadHint || '上传完成后自动插入正文', externalHint: i18n.externalFileHint || '请填写附件地址:',
};
var batch = imageBatchLimit();
return {
title: i18n.insertImage || '插入图片', mineTab: i18n.mineImage || '我的图片', uploadTab: i18n.uploadImage || '上传图片', externalTab: i18n.externalImage || '外链图片',
accept: 'image/gif,image/jpeg,image/png,image/webp', multiple: true, maxItems: 100, canUpload: !!editorCfg.canUploadImage && !!((cfg.uploads || {}).image), canEmbed: false,
accept: 'image/gif,image/jpeg,image/png,image/webp', multiple: batch.multiple, maxItems: batch.maxItems, canUpload: !!editorCfg.canUploadImage && !!((cfg.uploads || {}).image), canEmbed: false,
select: i18n.selectImages || '选择或拖入图片', hint: i18n.imageUploadHint || '可批量选择,上传完成后自动插入正文', externalHint: i18n.externalImageHint || '每行填写一个图片地址:',
};
}
@@ -575,13 +588,14 @@
bindCharacterCount(editor);
if (editorCfg.canUploadImage) {
var imageBatch = imageBatchLimit();
editor.addButton('aksrt_image', {
text: '',
icon: 'image',
tooltip: i18n.insertImage || '插入图片',
onclick: function () {
window.AksrtSubmitMedia.open({
type: 'image', multiple: true, maxItems: 100,
type: 'image', multiple: imageBatch.multiple, maxItems: imageBatch.maxItems,
onSelect: function (items) {
editor.insertContent(items.map(function (item) { return mediaMarkup('image', item); }).join('') + '<p></p>');
editor.nodeChanged();