导入 AKSRT 主题开发源码

This commit is contained in:
Claude Code committed 2026-07-03 22:50:34 +08:00
1 parent 9681dd3d48
commit 1268a55dd0
595 files changed
+127705

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户徽章"
desc="维护公开徽章字典、展示数量和前台展示位。具体授予逻辑可以后续再接业务,但字段和展示契约已经固定。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/users/notifications')">消息通知</n-button>
<n-button quaternary @click="go('/users/level')">用户等级</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.user_badge_enabled ? 'success' : 'default'">
徽章系统:{{ form.user_badge_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.user_badge_public_display ? 'info' : 'default'">
公开展示:{{ form.user_badge_public_display ? '开启' : '关闭' }}
</n-tag>
<n-tag round type="info">
最多显示:{{ form.user_badge_limit }} 枚
</n-tag>
<n-tag round :type="notificationSummary.dynamicBadge ? 'success' : 'default'">
通知红点联动:{{ notificationSummary.dynamicBadge ? '启用' : '关闭' }}
</n-tag>
<n-tag round type="info">
字典条目:{{ badgeCount }}
</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.user_badge_enabled" />
</n-form-item>
<n-form-item label="公开页显示徽章" v-if="form.user_badge_enabled">
<n-switch v-model:value="form.user_badge_public_display" />
</n-form-item>
<n-form-item label="最多显示数量" v-if="form.user_badge_enabled">
<n-input-number v-model:value="form.user_badge_limit" :min="1" :max="8" style="width: 180px;" />
</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.user_badge_dictionary"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="founder|创始成员|warning&#10;staff|站务团队|info"
:disabled="!form.user_badge_enabled"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个徽章,格式 `slug|显示文案|色调`,色调支持 `neutral / info / success / warning / brand`。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
前台从 `aksrt_user_badges` user meta 读取已授予徽章列表,并在公开主页和本人中心头部展示。这里只负责定义合法徽章字典和展示上限。
</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 form = reactive({
user_badge_enabled: false,
user_badge_public_display: true,
user_badge_limit: 3,
user_badge_dictionary: 'founder|创始成员|warning\nstaff|站务团队|info',
})
const notificationSummary = reactive({
dynamicBadge: false,
})
const badgeCount = computed(() => {
return String(form.user_badge_dictionary || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.length
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
async function load() {
loading.value = true
try {
const [schemaRes, notificationRes] = await Promise.all([
api.getSchemaPage('users', 'badge'),
api.getNotificationSettings(),
])
const schemaData = extractData(schemaRes)
const notificationData = extractData(notificationRes)
form.user_badge_enabled = !!schemaData.user_badge_enabled
form.user_badge_public_display = schemaData.user_badge_public_display !== false
form.user_badge_limit = Number(schemaData.user_badge_limit || 3)
form.user_badge_dictionary = schemaData.user_badge_dictionary || 'founder|创始成员|warning\nstaff|站务团队|info'
notificationSummary.dynamicBadge = !!notificationData.user_badge_enabled
} catch (error) {
message.error(error.message || '加载用户徽章配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'badge', {
user_badge_enabled: form.user_badge_enabled,
user_badge_public_display: form.user_badge_public_display,
user_badge_limit: Number(form.user_badge_limit || 3),
user_badge_dictionary: form.user_badge_dictionary,
})
message.success('用户徽章配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="禁封管理"
desc="查看和管理用户封禁、限制状态,支持按用户 ID 查询和解除限制。"
/>
<n-grid cols="1 s:2 m:3" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-statistic label="总限制记录" :value="stats.total" />
</n-grid-item>
<n-grid-item>
<n-statistic label="当前生效" :value="stats.active">
<template #suffix>
<n-tag v-if="stats.active > 0" type="error" size="small">{{ stats.active }}</n-tag>
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="封禁账号" :value="stats.banned">
<template #suffix>
<n-tag v-if="stats.banned > 0" type="error" size="small">严重</n-tag>
</template>
</n-statistic>
</n-grid-item>
</n-grid>
<!-- Search by user ID -->
<n-card size="small" title="查询用户限制" style="margin-top:16px;">
<n-space>
<n-input-number v-model:value="searchUserId" placeholder="用户 ID" :min="1" style="width:160px;" />
<n-button type="primary" @click="searchRestrictions" :loading="searching">查询</n-button>
</n-space>
</n-card>
<!-- User restrictions list -->
<n-card v-if="userRestrictions.length" size="small" :title="`用户 #${searchUserId} 的限制记录`" style="margin-top:16px;">
<n-data-table
:columns="columns"
:data="userRestrictions"
:loading="searching"
:bordered="false"
/>
</n-card>
<!-- Add restriction form -->
<n-card size="small" title="添加限制" style="margin-top:16px;">
<n-form :model="addForm" label-placement="left" label-width="100" class="aksrt-form">
<n-form-item label="用户 ID">
<n-input-number v-model:value="addForm.user_id" :min="1" style="width:200px;" />
</n-form-item>
<n-form-item label="限制类型">
<n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" />
</n-form-item>
<n-form-item label="原因">
<n-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" />
</n-form-item>
<n-form-item label="持续时间">
<n-select v-model:value="addForm.duration" :options="durationOptions" style="width:200px;" />
</n-form-item>
<n-form-item>
<n-button type="error" @click="addRestriction" :loading="adding" :disabled="!addForm.user_id || !addForm.reason">
添加限制
</n-button>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup>
import { h, reactive, ref, onMounted } from 'vue'
import { NButton, NTag, NSpace, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api'
const message = useMessage()
const searching = ref(false)
const adding = ref(false)
const searchUserId = ref(null)
const userRestrictions = ref([])
const stats = reactive({ total: 0, active: 0, banned: 0 })
const addForm = reactive({
user_id: null,
type: 'muted',
reason: '',
duration: '7d',
})
const restrictionTypes = [
{ label: '禁言', value: 'muted' },
{ label: '限制', value: 'restricted' },
{ label: '封禁', value: 'banned' },
]
const durationOptions = [
{ label: '1 天', value: '1d' },
{ label: '3 天', value: '3d' },
{ label: '7 天', value: '7d' },
{ label: '30 天', value: '30d' },
{ label: '永久', value: 'permanent' },
]
const columns = [
{ title: 'ID', key: 'id', width: 60 },
{
title: '类型', key: 'type', width: 80,
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
},
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
{ title: '生效时间', key: 'created_at', width: 160 },
{ title: '到期时间', key: 'expires_at', width: 160 },
{
title: '状态', key: 'status', width: 80,
render: (row) => {
const active = !row.expires_at || new Date(row.expires_at) > new Date()
return h(NTag, { type: active ? 'error' : 'default', size: 'small' }, () => active ? '生效中' : '已过期')
},
},
{
title: '操作', key: 'actions', width: 100,
render: (row) => h(NButton, { size: 'tiny', type: 'error', onClick: () => removeRestriction(row) }, () => '解除'),
},
]
function getTypeTag(type) {
const map = { muted: 'warning', restricted: 'error', banned: 'error' }
return map[type] || 'default'
}
function getTypeLabel(type) {
const map = { muted: '禁言', restricted: '限制', banned: '封禁' }
return map[type] || type
}
async function loadStats() {
try {
const res = await api.request('/users/restrictions/stats')
const data = res.data || res
stats.total = data.total || 0
stats.active = data.active || 0
stats.banned = data.banned || 0
} catch (e) {
// Stats may not be available yet
}
}
async function searchRestrictions() {
if (!searchUserId.value) {
message.warning('请输入用户 ID')
return
}
searching.value = true
try {
const res = await api.request(`/users/${searchUserId.value}/restrictions`)
const data = res.data || res
userRestrictions.value = data.restrictions || []
if (userRestrictions.value.length === 0) {
message.info('该用户无限制记录')
}
} catch (e) {
message.error('查询失败: ' + (e.message || '未知错误'))
} finally {
searching.value = false
}
}
async function addRestriction() {
if (!addForm.user_id || !addForm.reason) {
message.warning('请填写用户 ID 和原因')
return
}
adding.value = true
try {
await api.request(`/users/${addForm.user_id}/restrictions`, {
method: 'POST',
data: {
type: addForm.type,
reason: addForm.reason,
duration: addForm.duration,
},
})
message.success('限制已添加')
addForm.reason = ''
if (searchUserId.value === addForm.user_id) {
await searchRestrictions()
}
await loadStats()
} catch (e) {
message.error('添加失败: ' + (e.message || '未知错误'))
} finally {
adding.value = false
}
}
async function removeRestriction(row) {
try {
await api.request(`/users/restrictions/${row.id}`, { method: 'DELETE' })
message.success('限制已解除')
userRestrictions.value = userRestrictions.value.filter((r) => r.id !== row.id)
await loadStats()
} catch (e) {
message.error('解除失败: ' + (e.message || '未知错误'))
}
}
onMounted(loadStats)
</script>
@@ -0,0 +1,195 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户功能"
desc="统一配置用户中心和公开主页的功能模块,让前台页面和后台字段使用同一份契约。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/page-display/user-home')">公开主页</n-button>
<n-button quaternary @click="go('/permissions/attachment')">附件权限</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="info" round>固定入口:个人信息 / 账号设置 / 消息通知</n-tag>
<n-tag round :type="form.user_cover_upload ? 'success' : 'default'">
背景图编辑位:{{ form.user_cover_upload ? '已展示' : '已隐藏' }}
</n-tag>
<n-tag round :type="backgroundUploadAllowed ? 'success' : 'warning'">
背景图上传权限:{{ backgroundUploadAllowed ? '已开放' : '未开放' }}
</n-tag>
<n-tag round :type="homeSummary.sidebar ? 'success' : 'default'">
公开主页侧栏:{{ homeSummary.sidebar ? '开启' : '关闭' }}
</n-tag>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>用户中心面板</h3>
</div>
<n-form label-placement="left" label-width="170" class="aksrt-form">
<n-form-item label="固定面板">
<n-space>
<n-tag round>个人信息</n-tag>
<n-tag round>账号设置</n-tag>
<n-tag round>消息通知</n-tag>
</n-space>
</n-form-item>
<n-form-item label="可切换业务面板">
<n-checkbox-group v-model:value="form.dynamicTabs">
<n-space vertical>
<n-checkbox value="posts">我的文章</n-checkbox>
<n-checkbox value="comments">我的评论</n-checkbox>
<n-checkbox value="favorites">我的收藏</n-checkbox>
<n-checkbox value="credits">账户余额 / 签到</n-checkbox>
</n-space>
</n-checkbox-group>
<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="170" class="aksrt-form">
<n-form-item label="头像编辑入口">
<n-switch v-model:value="form.user_avatar_upload" />
<template #feedback>
<span class="aksrt-hint-text">开启后,用户中心会显示跳转到 WordPress 原生资料页的头像修改入口。</span>
</template>
</n-form-item>
<n-form-item label="背景图编辑位">
<n-switch v-model:value="form.user_cover_upload" />
<template #feedback>
<span class="aksrt-hint-text">这里只控制前台入口显示;真正的上传权限仍由“附件权限”页治理。</span>
</template>
</n-form-item>
<n-form-item label="公开社交链接">
<n-switch v-model:value="form.user_social_links" />
<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">
公开主页的封面、侧栏、关注按钮和公开 Tab 仍在“页面&显示 / 用户主页”维护;本页负责“用户中心能否编辑哪些内容”。
</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 backgroundUploadAllowed = ref(false)
const homeSummary = reactive({
tabs: [],
sidebar: true,
})
const form = reactive({
dynamicTabs: ['posts', 'comments', 'favorites'],
user_avatar_upload: true,
user_cover_upload: true,
user_social_links: true,
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
function parseTabs(value) {
const items = Array.isArray(value)
? value
: String(value || '')
.split(/[\s,]+/)
.filter(Boolean)
return items
.map((item) => String(item).trim())
.filter((item) => ['posts', 'comments', 'favorites', 'credits'].includes(item))
}
async function load() {
loading.value = true
try {
const [featureRes, homeRes, permissionRes] = await Promise.all([
api.getSchemaPage('users', 'features'),
api.getSchemaPage('page_display', 'user-home'),
api.getPermissionsAttachmentSettings(),
])
const featureData = extractData(featureRes)
const homeData = extractData(homeRes)
const permissionData = extractData(permissionRes)
form.dynamicTabs = parseTabs(featureData.user_center_tabs || 'profile,posts,comments,favorites')
form.user_avatar_upload = featureData.user_avatar_upload !== false
form.user_cover_upload = featureData.user_cover_upload !== false
form.user_social_links = featureData.user_social_links !== false
homeSummary.tabs = parseTabs(homeData.user_home_tabs || 'posts,comments,favorites')
homeSummary.sidebar = homeData.user_home_sidebar !== false
backgroundUploadAllowed.value = !!permissionData.allow_background_upload
} catch (error) {
message.error(error.message || '加载用户功能配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'features', {
user_center_tabs: ['profile', ...form.dynamicTabs].join(','),
user_avatar_upload: form.user_avatar_upload,
user_cover_upload: form.user_cover_upload,
user_social_links: form.user_social_links,
})
message.success('用户功能配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,512 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="邀请码运营" desc="统一管理用户邀请码与独立邀请码,支持批量生成、批量禁用、备注追踪和筛选导出。" />
<div class="aksrt-invite-ops__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-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-card title="批量生成邀请码" size="small">
<n-form :model="generateForm" label-placement="top">
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="生成数量">
<n-input-number v-model:value="generateForm.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="generateForm.length" :min="4" :max="12" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="前缀">
<n-input v-model:value="generateForm.prefix" placeholder="AK" />
</n-form-item>
<n-form-item label="批次备注">
<n-input
v-model:value="generateForm.note"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="例如:618 拉新活动 / 渠道合作批次"
/>
</n-form-item>
<n-space>
<n-button type="primary" :loading="generating" @click="handleGenerate">批量生成</n-button>
<n-button @click="loadData">刷新列表</n-button>
</n-space>
</n-form>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="批次输出" size="small">
<n-alert type="info" :show-icon="false">
独立邀请码会接入注册校验链路;导出前建议补齐批次备注,便于渠道回溯。
</n-alert>
<div v-if="generatedCodes.length" class="aksrt-invite-ops__generated">
<n-tag type="success" round :bordered="false">批次 {{ generatedBatchId || '-' }}</n-tag>
<n-input
type="textarea"
:value="generatedCodes.join('\n')"
readonly
:autosize="{ minRows: 7, maxRows: 10 }"
/>
</div>
<n-empty v-else description="生成后的邀请码会显示在这里" />
</n-card>
</n-grid-item>
</n-grid>
<n-card title="邀请码台账" size="small" class="aksrt-invite-ops__table-card">
<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" placeholder="搜索邀请码、批次、用户、备注" clearable />
</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.source" :options="sourceOptions" 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>
<n-space justify="space-between" wrap>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
<n-button :disabled="!checkedRows.length" @click="handleBulkAction('disable')">批量禁用</n-button>
<n-button :disabled="!checkedRows.length" @click="handleBulkAction('delete')">批量删除</n-button>
</n-space>
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
</n-space>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.row_key"
:checked-row-keys="checkedRowKeys"
@update:checked-row-keys="handleCheckedRowKeysChange"
/>
</n-space>
</n-card>
</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'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const generating = ref(false)
const rows = ref([])
const checkedRowKeys = ref([])
const generatedCodes = ref([])
const generatedBatchId = ref('')
const stats = reactive({
total_codes: 0,
active_codes: 0,
used_codes: 0,
disabled_codes: 0,
user_codes: 0,
standalone_codes: 0,
total_invites: 0,
total_rewarded: 0,
})
const generateForm = reactive({
count: 20,
length: 6,
prefix: 'AK',
note: '',
})
const filters = reactive({
keyword: '',
status: '',
source: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
sources: [],
})
const statCards = [
{ key: 'total_codes', label: '总邀请码' },
{ key: 'active_codes', label: '当前可用' },
{ key: 'used_codes', label: '已使用' },
{ key: 'disabled_codes', label: '已禁用' },
{ key: 'standalone_codes', label: '独立邀请码' },
{ key: 'total_rewarded', label: '已完成兑现' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '可用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已禁用', value: 'disabled' },
])
const sourceOptions = computed(() => optionsState.sources.length
? optionsState.sources
: [
{ label: '全部来源', value: '' },
{ label: '用户邀请码', value: 'user' },
{ label: '独立邀请码', value: 'standalone' },
])
const checkedRows = computed(() => rows.value.filter((row) => checkedRowKeys.value.includes(row.row_key)))
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 = [
{
type: 'selection',
disabled: (row) => !row.can_bulk,
},
{
title: '来源',
key: 'source_type',
width: 120,
render: (row) => h(NTag, { size: 'small', round: true, type: row.source_type === 'standalone' ? 'info' : 'default' }, () => row.source_label),
},
{
title: '邀请码',
key: 'invite_code',
width: 180,
ellipsis: { tooltip: true },
},
{
title: '归属/使用人',
key: 'owner',
width: 180,
render: (row) => row.user?.display_name || row.invitee?.display_name || '-',
},
{
title: '状态',
key: 'status_label',
width: 110,
render: (row) => {
const typeMap = { active: 'success', used: 'warning', disabled: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '注册状态',
key: 'registration_status',
width: 110,
render: (row) => row.registration_status || '-',
},
{
title: '批次备注',
key: 'note',
minWidth: 180,
ellipsis: { tooltip: true },
render: (row) => row.note || row.batch_id || '-',
},
{
title: '创建时间',
key: 'created_at',
width: 170,
},
{
title: '使用时间',
key: 'used_at',
width: 170,
render: (row) => row.used_at || '-',
},
{
title: '操作',
key: 'actions',
width: 170,
render: (row) => h(NSpace, { size: 'small' }, () => {
const actions = [
h(NButton, {
size: 'tiny',
quaternary: true,
type: 'warning',
onClick: () => handleDisable(row),
}, () => '禁用'),
]
if (row.source_type === 'standalone') {
actions.push(
h(NButton, {
size: 'tiny',
quaternary: true,
type: 'error',
onClick: () => handleDelete(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')
return `${year}-${month}-${day}`
}
function buildQuery(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 (filters.source) params.source = filters.source
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('/invite/codes', { params: buildQuery() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
optionsState.sources = data.options?.sources || []
checkedRowKeys.value = []
} catch (error) {
message.error(error?.message || '加载邀请码台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.source = ''
filters.dateRange = null
pager.page = 1
loadData()
}
function handleCheckedRowKeysChange(keys) {
checkedRowKeys.value = keys
}
async function handleGenerate() {
generating.value = true
try {
const res = await api.request('/invite/codes', {
method: 'POST',
data: {
count: generateForm.count,
length: generateForm.length,
prefix: generateForm.prefix,
note: generateForm.note,
},
})
generatedCodes.value = res?.data?.codes || []
generatedBatchId.value = res?.data?.batch_id || ''
message.success(res?.message || '邀请码已生成')
await loadData()
} catch (error) {
message.error(error?.message || '生成失败')
} finally {
generating.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用邀请码 ${row.invite_code} 吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
if (row.source_type === 'user') {
await api.request(`/invite/codes/${row.user?.id}/disable`, { method: 'POST' })
} else {
await api.request('/invite/codes/bulk', {
method: 'POST',
data: {
action: 'disable',
targets: [{ source_type: 'standalone', code: row.invite_code }],
},
})
}
message.success('邀请码已禁用')
await loadData()
} catch (error) {
message.error(error?.message || '操作失败')
}
},
})
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定要删除独立邀请码 ${row.invite_code} 吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request('/invite/codes/bulk', {
method: 'POST',
data: {
action: 'delete',
targets: [{ source_type: 'standalone', code: row.invite_code }],
},
})
message.success('邀请码已删除')
await loadData()
} catch (error) {
message.error(error?.message || '删除失败')
}
},
})
}
async function handleBulkAction(action) {
if (!checkedRows.value.length) {
return
}
try {
await api.request('/invite/codes/bulk', {
method: 'POST',
data: {
action,
targets: checkedRows.value.map((row) => ({
source_type: row.source_type,
user_id: row.user?.id || 0,
code: row.invite_code,
})),
},
})
message.success(action === 'delete' ? '批量删除完成' : '批量禁用完成')
await loadData()
} catch (error) {
message.error(error?.message || '批量操作失败')
}
}
async function handleExport() {
try {
const res = await api.request('/invite/codes', { params: buildQuery(1, 200) })
const items = res?.data?.items || []
const header = ['来源', '邀请码', '归属用户', '使用人', '状态', '注册状态', '批次', '备注', '创建时间', '使用时间']
const lines = items.map((row) => [
row.source_label || '',
row.invite_code || '',
row.user?.display_name || '',
row.invitee?.display_name || '',
row.status_label || '',
row.registration_status || '',
row.batch_id || '',
row.note || '',
row.created_at || '',
row.used_at || '',
])
downloadCsv('invite-codes.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-invite-ops__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-invite-ops__generated {
display: grid;
gap: 12px;
margin-top: 16px;
}
.aksrt-invite-ops__table-card {
margin-top: 16px;
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户等级"
desc="配置等级来源、展示前缀和阈值规则。前台公开主页会按照这里的规则输出等级身份芯片。"
>
<template #actions>
<n-space>
<n-button quaternary @click="go('/users/credits')">用户积分</n-button>
<n-button quaternary @click="go('/users/badge')">用户徽章</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.user_level_enabled ? 'success' : 'default'">
等级系统:{{ form.user_level_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="creditsSummary.enabled ? 'info' : 'warning'">
积分系统:{{ creditsSummary.enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="info">
当前货币:{{ creditsSummary.currencyName }}
</n-tag>
<n-tag round :type="form.user_level_source === 'credits_balance' ? 'success' : 'warning'">
来源:{{ form.user_level_source === 'credits_balance' ? '积分余额' : '手动 Meta' }}
</n-tag>
<n-tag round type="info">
阈值条目:{{ thresholdCount }}
</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.user_level_enabled" />
</n-form-item>
<n-form-item label="公开页显示等级" v-if="form.user_level_enabled">
<n-switch v-model:value="form.user_level_public_display" />
</n-form-item>
<n-form-item label="等级前缀" v-if="form.user_level_enabled">
<n-input v-model:value="form.user_level_prefix" maxlength="12" placeholder="Lv." />
</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-radio-group v-model:value="form.user_level_source" :disabled="!form.user_level_enabled">
<n-space size="large">
<n-radio value="credits_balance">积分余额</n-radio>
<n-radio value="manual_meta">手动 Meta</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="等级阈值">
<n-input
v-model:value="form.user_level_thresholds"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="1|0&#10;2|30&#10;3|100&#10;4|300"
:disabled="!form.user_level_enabled"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个等级,格式 `等级|所需积分`。手动 Meta 模式下仍建议保留完整字典,方便后续切换回自动计算。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
手动模式读取 `aksrt_user_level` user meta;自动模式则根据当前积分余额和上方阈值表实时计算等级,不需要单独写库。
</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 form = reactive({
user_level_enabled: false,
user_level_public_display: true,
user_level_source: 'credits_balance',
user_level_prefix: 'Lv.',
user_level_thresholds: '1|0\n2|30\n3|100\n4|300',
})
const creditsSummary = reactive({
enabled: true,
currencyName: '积分',
})
const thresholdCount = computed(() => {
return String(form.user_level_thresholds || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.length
})
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
async function load() {
loading.value = true
try {
const [schemaRes, creditRes] = await Promise.all([
api.getSchemaPage('users', 'level'),
api.getCreditSettings(),
])
const schemaData = extractData(schemaRes)
const creditData = extractData(creditRes)
form.user_level_enabled = !!schemaData.user_level_enabled
form.user_level_public_display = schemaData.user_level_public_display !== false
form.user_level_source = schemaData.user_level_source || 'credits_balance'
form.user_level_prefix = schemaData.user_level_prefix || 'Lv.'
form.user_level_thresholds = schemaData.user_level_thresholds || '1|0\n2|30\n3|100\n4|300'
creditsSummary.enabled = creditData.enabled !== false
creditsSummary.currencyName = creditData.currency_name || '积分'
} catch (error) {
message.error(error.message || '加载用户等级配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'level', {
user_level_enabled: form.user_level_enabled,
user_level_public_display: form.user_level_public_display,
user_level_source: form.user_level_source,
user_level_prefix: form.user_level_prefix,
user_level_thresholds: form.user_level_thresholds,
})
message.success('用户等级配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,308 @@
<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">
<n-grid-item>
<n-statistic label="全部消息" :value="stats.total" />
</n-grid-item>
<n-grid-item>
<n-statistic label="未读消息" :value="stats.unread">
<template #suffix>
<n-tag v-if="stats.unread > 0" type="warning" size="small">{{ stats.unread }}</n-tag>
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="今日新增" :value="stats.today" />
</n-grid-item>
<n-grid-item>
<n-statistic label="系统通知" :value="stats.system" />
</n-grid-item>
</n-grid>
<n-card size="small" title="消息列表" style="margin-top:16px;">
<template #header-extra>
<n-space>
<n-select
v-model:value="filters.type"
placeholder="消息类型"
:options="typeOptions"
clearable
style="width:160px;"
@update:value="handleFilterChange"
/>
<n-select
v-model:value="filters.read"
placeholder="已读状态"
:options="readOptions"
clearable
style="width:120px;"
@update:value="handleFilterChange"
/>
<n-button @click="loadMessages" :loading="loading">刷新</n-button>
<n-button type="primary" @click="markAllRead" :loading="markingAll">全部已读</n-button>
</n-space>
</template>
<n-data-table
:columns="columns"
:data="messages"
:loading="loading"
:pagination="pagination"
:row-class-name="rowClassName"
remote
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</n-card>
<n-drawer v-model:show="showDetail" :width="480">
<n-drawer-content :title="detailMessage?.title || '消息详情'">
<template v-if="detailMessage">
<n-descriptions bordered :column="1" label-placement="left">
<n-descriptions-item label="类型">
<n-tag :type="getTypeTag(detailMessage.type)" size="small">{{ getTypeLabel(detailMessage.type) }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag :type="detailMessage.is_read ? 'success' : 'warning'" size="small">
{{ detailMessage.is_read ? '已读' : '未读' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="用户">{{ detailMessage.user_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="时间">{{ detailMessage.created_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-message-detail">
<h4>{{ detailMessage.title }}</h4>
<p>{{ detailMessage.description || detailMessage.content || '暂无详细内容' }}</p>
<a
v-if="detailMessage.target_url"
:href="detailMessage.target_url"
target="_blank"
rel="noreferrer"
>
查看目标
</a>
</div>
</template>
<template #footer>
<n-space>
<n-button @click="showDetail = false">关闭</n-button>
<n-button v-if="detailMessage && !detailMessage.is_read" type="primary" @click="markCurrentRead">
标记已读
</n-button>
</n-space>
</template>
</n-drawer-content>
</n-drawer>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api'
const message = useMessage()
const loading = ref(false)
const markingAll = ref(false)
const showDetail = ref(false)
const detailMessage = ref(null)
const messages = ref([])
const typeStats = ref([])
const stats = reactive({ total: 0, unread: 0, today: 0, system: 0 })
const filters = reactive({ type: null, read: null })
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, pageCount: 1, showSizePicker: true, pageSizes: [20, 50, 100] })
const readOptions = [
{ label: '未读', value: '0' },
{ label: '已读', value: '1' },
]
const typeOptions = computed(() => typeStats.value.map((item) => ({
label: getTypeLabel(item.type),
value: item.type,
})))
const columns = [
{
title: '类型',
key: 'type',
width: 120,
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
},
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
{ title: '用户', key: 'user_name', width: 140, ellipsis: { tooltip: true } },
{ title: '时间', key: 'created_at', width: 180 },
{
title: '状态',
key: 'is_read',
width: 90,
render: (row) => h(NTag, { type: row.is_read ? 'success' : 'warning', size: 'small' }, () => (row.is_read ? '已读' : '未读')),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', onClick: () => openDetail(row) }, () => '查看'),
!row.is_read ? h(NButton, { size: 'tiny', type: 'primary', onClick: () => markOneRead(row) }, () => '已读') : null,
]),
},
]
function getTypeTag(type) {
const map = {
notice: 'info',
system: 'info',
comment_reply: 'success',
comment_approved: 'success',
credits_added: 'warning',
credits_deducted: 'warning',
download_ready: 'primary',
verify_email: 'info',
pending_comments: 'warning',
}
return map[type] || 'default'
}
function getTypeLabel(type) {
const map = {
notice: '系统通知',
system: '系统通知',
comment_reply: '评论回复',
comment_approved: '评论审核',
credits_added: '积分增加',
credits_deducted: '积分扣减',
download_ready: '下载就绪',
verify_email: '邮箱验证',
pending_comments: '待审评论',
}
return map[type] || type || '通知'
}
function rowClassName(row) {
return row.is_read ? '' : 'aksrt-row--unread'
}
async function loadMessages() {
loading.value = true
try {
const response = await api.getNotificationOverview({
page: pagination.page,
per_page: pagination.pageSize,
...(filters.type ? { type: filters.type } : {}),
...(filters.read !== null ? { is_read: filters.read } : {}),
})
const payload = response.data || {}
const summary = payload.summary || {}
const pager = payload.pagination || {}
messages.value = Array.isArray(payload.recent) ? payload.recent : []
typeStats.value = Array.isArray(payload.types) ? payload.types : []
stats.total = Number(summary.total || payload.total || 0)
stats.unread = Number(summary.unread || payload.unread || 0)
stats.today = Number(summary.today || payload.today || 0)
stats.system = Number(summary.system || payload.system || 0)
pagination.itemCount = Number(pager.total_items || messages.value.length)
pagination.pageCount = Number(pager.total_pages || 1)
pagination.page = Number(pager.page || pagination.page)
pagination.pageSize = Number(pager.per_page || pagination.pageSize)
} catch (error) {
message.error('加载消息失败: ' + (error.message || '未知错误'))
} finally {
loading.value = false
}
}
function openDetail(row) {
detailMessage.value = row
showDetail.value = true
}
async function markOneRead(row) {
try {
await api.markNotificationRead(row.id)
row.is_read = true
stats.unread = Math.max(0, stats.unread - 1)
if (detailMessage.value && detailMessage.value.id === row.id) {
detailMessage.value.is_read = true
}
message.success('已标记为已读')
} catch (error) {
message.error(error.message || '操作失败')
}
}
async function markCurrentRead() {
if (detailMessage.value) {
await markOneRead(detailMessage.value)
}
}
async function markAllRead() {
markingAll.value = true
try {
await api.markAllNotificationsRead()
messages.value = messages.value.map((item) => ({ ...item, is_read: true }))
if (detailMessage.value) {
detailMessage.value = { ...detailMessage.value, is_read: true }
}
stats.unread = 0
message.success('全部已读')
} catch (error) {
message.error(error.message || '操作失败')
} finally {
markingAll.value = false
}
}
function handleFilterChange() {
pagination.page = 1
loadMessages()
}
function handlePageChange(page) {
pagination.page = page
loadMessages()
}
function handlePageSizeChange(pageSize) {
pagination.pageSize = pageSize
pagination.page = 1
loadMessages()
}
onMounted(loadMessages)
</script>
<style scoped>
.aksrt-row--unread {
font-weight: 600;
}
.aksrt-message-detail {
margin-top: 16px;
}
.aksrt-message-detail h4 {
margin: 0 0 8px;
font-size: 14px;
}
.aksrt-message-detail p {
margin: 0 0 12px;
color: #666;
line-height: 1.7;
white-space: pre-wrap;
}
</style>
@@ -0,0 +1,357 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="举报治理"
desc="统一维护公开举报入口与后台工单台,支持证据查看、状态流转和处罚语义联动。"
/>
<n-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-card size="small" title="治理开关">
<n-space wrap>
<n-tag round :type="form.user_report_enabled ? 'success' : 'default'">
举报入口:{{ form.user_report_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.user_report_public_entry ? 'info' : 'default'">
公开入口:{{ form.user_report_public_entry ? '显示' : '隐藏' }}
</n-tag>
<n-tag round :type="moderationSummary.enabled ? 'success' : 'default'">
审核系统:{{ moderationSummary.enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="warning">
待处理:{{ counts.pending }}
</n-tag>
<n-tag round type="success">
已解决:{{ counts.resolved }}
</n-tag>
</n-space>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" title="前台说明">
<n-form :model="form" label-placement="top">
<n-form-item label="限制提示文案">
<n-input v-model:value="form.user_report_ban_notice_text" maxlength="80" />
</n-form-item>
<n-form-item label="举报分类字典">
<n-input
v-model:value="form.user_report_categories"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="spam|垃圾信息&#10;abuse|人身攻击"
/>
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
</n-grid>
<n-card size="small" title="入口配置" class="aksrt-report-page__card">
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="启用举报/禁封结构">
<n-switch v-model:value="form.user_report_enabled" />
</n-form-item>
<n-form-item label="公开主页展示举报入口">
<n-switch v-model:value="form.user_report_public_entry" :disabled="!form.user_report_enabled" />
</n-form-item>
<n-form-item label="公开显示限制提示">
<n-switch v-model:value="form.user_report_public_notice" :disabled="!form.user_report_enabled" />
</n-form-item>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-form>
</n-card>
<n-card size="small" title="举报工单台" class="aksrt-report-page__card">
<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.target_type" :options="targetTypeOptions" 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-button type="primary" block @click="handleSearch">筛选工单</n-button>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="reports"
:loading="reportLoading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-space>
</n-card>
<ReportDetailDrawer
v-model:show="drawerVisible"
:report-id="currentReportId"
@updated="loadReports"
/>
</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 ReportDetailDrawer from './components/ReportDetailDrawer.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const reportLoading = ref(false)
const reports = ref([])
const drawerVisible = ref(false)
const currentReportId = ref(null)
const form = reactive({
user_report_enabled: false,
user_report_public_entry: true,
user_report_public_notice: true,
user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。',
user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉',
})
const moderationSummary = reactive({
enabled: false,
})
const filters = reactive({
keyword: '',
status: '',
target_type: '',
dateRange: null,
})
const counts = reactive({
pending: 0,
reviewing: 0,
resolved: 0,
dismissed: 0,
total: 0,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
targets: [],
})
const statusOptions = computed(() => optionsState.statuses.length
? [{ label: '全部状态', value: '' }, ...optionsState.statuses]
: [
{ label: '全部状态', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'reviewing' },
{ label: '已解决', value: 'resolved' },
{ label: '已驳回', value: 'dismissed' },
])
const targetTypeOptions = computed(() => optionsState.targets.length
? [{ label: '全部类型', value: '' }, ...optionsState.targets]
: [
{ label: '全部类型', value: '' },
{ label: '文章', value: 'post' },
{ label: '评论', value: 'comment' },
{ label: '用户', value: 'user' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50],
onUpdatePage: (page) => {
pager.page = page
loadReports()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadReports()
},
}))
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{
title: '举报人',
key: 'reporter',
width: 160,
render: (row) => row.reporter?.display_name || '-',
},
{
title: '目标',
key: 'target',
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.target?.title || row.target?.display_name || row.target?.excerpt || `目标 #${row.target_id}`,
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', reviewing: 'info', resolved: 'success', dismissed: 'default' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status)
},
},
{
title: '分类',
key: 'category',
width: 120,
render: (row) => row.category || '-',
},
{
title: '处理记录',
key: 'note_count',
width: 90,
},
{
title: '提交时间',
key: 'created_at',
width: 170,
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '查看'),
]),
},
]
onMounted(async () => {
await load()
})
function extractData(response) {
return response?.data ?? response ?? {}
}
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 buildReportParams() {
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.target_type) params.target_type = filters.target_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 load() {
loading.value = true
try {
const [schemaRes, moderationRes] = await Promise.all([
api.getSchemaPage('users', 'report'),
api.getPermissionsModerationSettings(),
])
const schemaData = extractData(schemaRes)
const moderationData = extractData(moderationRes)
form.user_report_enabled = !!schemaData.user_report_enabled
form.user_report_public_entry = schemaData.user_report_public_entry !== false
form.user_report_public_notice = schemaData.user_report_public_notice !== false
form.user_report_ban_notice_text = schemaData.user_report_ban_notice_text || '当前账号处于限制状态,部分互动能力已关闭。'
form.user_report_categories = schemaData.user_report_categories || 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉'
moderationSummary.enabled = !!moderationData.enabled
await loadReports()
} catch (error) {
message.error(error?.message || '加载举报治理页失败')
} finally {
loading.value = false
}
}
async function loadReports() {
reportLoading.value = true
try {
const res = await api.request('/users/reports', { params: buildReportParams() })
const data = res?.data || {}
reports.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(counts, data.counts || {})
optionsState.statuses = data.options?.statuses || []
optionsState.targets = data.options?.targets || []
} catch (error) {
message.error(error?.message || '加载举报工单失败')
} finally {
reportLoading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'report', {
user_report_enabled: form.user_report_enabled,
user_report_public_entry: form.user_report_public_entry,
user_report_public_notice: form.user_report_public_notice,
user_report_ban_notice_text: form.user_report_ban_notice_text,
user_report_categories: form.user_report_categories,
})
message.success('举报治理配置已保存')
} catch (error) {
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
function handleSearch() {
pager.page = 1
loadReports()
}
function openDrawer(reportId) {
currentReportId.value = reportId
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-report-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,40 @@
<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" />
<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,308 @@
<template>
<UsersSectionPage
title="签到奖励"
desc="直接管理签到与邀请奖励的真实运行配置,保存后会立即影响前台签到、邀请链接和注册页邀请码行为。"
:links="[
{ label: '注册登录', path: '/users/auth' },
{ label: '用户积分', path: '/users/credits' },
]"
>
<template #before-form>
<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.credits_enabled ? 'success' : 'warning'">
积分系统:{{ form.credits_enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round :type="form.signin_enabled ? 'success' : 'default'">
签到能力:{{ form.signin_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.invite_enabled ? 'success' : 'default'">
邀请奖励:{{ form.invite_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.invite_registration_enabled ? 'info' : 'default'">
邀请注册:{{ form.invite_registration_enabled ? '开启' : '关闭' }}
</n-tag>
<n-tag round :type="captchaSummary.register ? 'info' : 'default'">
注册验证码:{{ captchaSummary.register ? '启用' : '未启用' }}
</n-tag>
<n-tag round :type="captchaSummary.signin ? 'info' : 'default'">
签到验证码:{{ captchaSummary.signin ? '启用' : '未启用' }}
</n-tag>
</n-space>
</n-card>
<n-card v-if="!form.credits_enabled" class="aksrt-section-card">
<n-alert type="warning" :show-icon="false">
当前积分系统处于关闭状态。即使保存签到和邀请奖励配置,前台也不会真正发放奖励,直到“用户积分”页重新启用积分系统。
</n-alert>
</n-card>
</n-spin>
</template>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>每日签到</h3>
</div>
<n-form-item label="启用签到奖励">
<n-switch v-model:value="form.signin_enabled" />
</n-form-item>
<n-form-item label="基础奖励" v-if="form.signin_enabled">
<n-input-number v-model:value="form.signin_base" :min="0" :max="100000" style="width: 180px;" />
<template #feedback>
<span class="aksrt-hint-text">每次签到基础发放 {{ currencyLabel }} 数量。</span>
</template>
</n-form-item>
<n-form-item label="连续签到加成" v-if="form.signin_enabled">
<n-input
v-model:value="form.signin_streak_text"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="3|2&#10;7|5&#10;30|20"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个周期,格式 `天数|额外奖励`。例如 `7|5` 表示连续 7 天额外获得 5 {{ currencyLabel }}。</span>
</template>
</n-form-item>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>邀请奖励与注册页邀请码</h3>
</div>
<n-form-item label="启用邀请奖励">
<n-switch v-model:value="form.invite_enabled" />
</n-form-item>
<n-form-item label="开放邀请注册">
<n-switch v-model:value="form.invite_registration_enabled" />
<template #feedback>
<span class="aksrt-hint-text">关闭后,邀请链接入口、注册页邀请码字段和个人中心邀请模块会一起隐藏。</span>
</template>
</n-form-item>
<template v-if="form.invite_registration_enabled">
<n-form-item label="邀请码长度">
<n-input-number v-model:value="form.invite_code_length" :min="4" :max="12" style="width: 180px;" />
</n-form-item>
<n-form-item label="注册页邀请码字段">
<n-radio-group v-model:value="form.invite_register_field">
<n-space size="large">
<n-radio value="hidden">隐藏</n-radio>
<n-radio value="optional">选填</n-radio>
<n-radio value="required">必填</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="无效邀请码处理" v-if="form.invite_register_field === 'required'">
<n-switch v-model:value="form.invite_reject_invalid" />
<template #feedback>
<span class="aksrt-hint-text">开启后,必填模式下无效邀请码会直接拒绝注册。关闭则忽略邀请码错误并继续注册。</span>
</template>
</n-form-item>
</template>
<template v-if="form.invite_enabled">
<n-form-item label="邀请人奖励">
<n-input-number v-model:value="form.inviter_amount" :min="0" :max="100000" style="width: 180px;" />
</n-form-item>
<n-form-item label="被邀请人奖励">
<n-input-number v-model:value="form.invitee_amount" :min="0" :max="100000" style="width: 180px;" />
</n-form-item>
<n-form-item label="验证邮箱后发放">
<n-switch v-model:value="form.require_verify" />
</n-form-item>
<n-form-item label="同 IP 日上限">
<n-input-number v-model:value="form.ip_daily_cap" :min="0" :max="1000" style="width: 180px;" />
</n-form-item>
</template>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-form>
</UsersSectionPage>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
import UsersSectionPage from '@/pages/users/UsersSectionPage.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const creditSnapshot = ref({})
const authSnapshot = ref({})
const captchaSummary = reactive({
register: false,
signin: false,
})
const form = reactive({
credits_enabled: true,
currency_name: '',
signin_enabled: true,
signin_base: 1,
signin_streak_text: '3|2\n7|5\n30|20',
invite_enabled: true,
inviter_amount: 5,
invitee_amount: 2,
require_verify: true,
ip_daily_cap: 3,
invite_registration_enabled: true,
invite_code_length: 6,
invite_register_field: 'optional',
invite_reject_invalid: true,
})
const currencyLabel = computed(() => form.currency_name || '积分')
function extractData(response) {
return response?.data ?? response ?? {}
}
function formatStreak(streak) {
const entries = Object.entries(streak || {})
.map(([day, bonus]) => [Number(day), Number(bonus)])
.filter(([day, bonus]) => Number.isFinite(day) && day > 0 && Number.isFinite(bonus) && bonus >= 0)
.sort((left, right) => left[0] - right[0])
return entries.map(([day, bonus]) => `${day}|${bonus}`).join('\n')
}
function parseStreakText(value) {
const rows = String(value || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
const streak = {}
rows.forEach((row) => {
const parts = row.split('|').map((item) => item.trim())
if (parts.length !== 2) {
return
}
const day = Number(parts[0])
const bonus = Number(parts[1])
if (!Number.isFinite(day) || !Number.isFinite(bonus) || day <= 0 || bonus < 0) {
return
}
streak[Math.trunc(day)] = Math.trunc(bonus)
})
return streak
}
async function load() {
loading.value = true
try {
const [creditRes, authRes, captchaRes] = await Promise.all([
api.getCreditSettings(),
api.getAuthSettings(),
api.getPermissionsLoginSettings(),
])
const creditData = extractData(creditRes)
const authData = extractData(authRes)
const captchaData = extractData(captchaRes)
const dailySignin = creditData.daily_signin || {}
const inviteReward = creditData.invite_reward || {}
const disabledScenarios = Array.isArray(captchaData.disabled_scenarios)
? captchaData.disabled_scenarios
: []
creditSnapshot.value = creditData
authSnapshot.value = authData
form.credits_enabled = creditData.enabled !== false
form.currency_name = creditData.currency_name || ''
form.signin_enabled = dailySignin.enabled !== false
form.signin_base = Number(dailySignin.base || 0)
form.signin_streak_text = formatStreak(dailySignin.streak || { 3: 2, 7: 5, 30: 20 })
form.invite_enabled = inviteReward.enabled !== false
form.inviter_amount = Number(inviteReward.inviter_amount || 0)
form.invitee_amount = Number(inviteReward.invitee_amount || 0)
form.require_verify = inviteReward.require_verify !== false
form.ip_daily_cap = Number(inviteReward.ip_daily_cap || 0)
form.invite_registration_enabled = authData.invite_registration_enabled !== false
form.invite_code_length = Number(authData.invite_code_length || 6)
form.invite_register_field = authData.invite_register_field || 'optional'
form.invite_reject_invalid = authData.invite_reject_invalid !== false
captchaSummary.register = !!captchaData.enabled && !disabledScenarios.includes('register')
captchaSummary.signin = !!captchaData.enabled && !disabledScenarios.includes('signin')
} catch (error) {
message.error(error.message || '加载签到奖励配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const nextCredit = {
...creditSnapshot.value,
daily_signin: {
...(creditSnapshot.value.daily_signin || {}),
enabled: form.signin_enabled,
base: Number(form.signin_base || 0),
streak: parseStreakText(form.signin_streak_text),
},
invite_reward: {
...(creditSnapshot.value.invite_reward || {}),
enabled: form.invite_enabled,
inviter_amount: Number(form.inviter_amount || 0),
invitee_amount: Number(form.invitee_amount || 0),
require_verify: form.require_verify,
ip_daily_cap: Number(form.ip_daily_cap || 0),
register_field: form.invite_register_field,
reject_invalid: form.invite_reject_invalid,
},
}
const nextAuth = {
...authSnapshot.value,
invite_registration_enabled: form.invite_registration_enabled,
invite_code_length: Number(form.invite_code_length || 6),
invite_register_field: form.invite_register_field,
invite_reject_invalid: form.invite_reject_invalid,
}
await Promise.all([
api.saveCreditSettings(nextCredit),
api.saveAuthSettings(nextAuth),
])
creditSnapshot.value = nextCredit
authSnapshot.value = nextAuth
message.success('签到奖励配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,338 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="身份认证"
desc="维护公开认证展示和后台审核台,统一查看待审、已通过、已驳回的人工认证记录。"
/>
<n-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-card size="small" title="当前状态">
<n-space wrap>
<n-tag round :type="form.user_verify_enabled ? 'success' : 'default'">
认证结构:{{ form.user_verify_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="authSummary.emailVerificationEnabled ? 'info' : 'default'">
邮箱验证:{{ authSummary.emailVerificationEnabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="warning">
待审核:{{ counts.pending }}
</n-tag>
<n-tag round type="success">
已通过:{{ counts.verified }}
</n-tag>
<n-tag round type="default">
已驳回:{{ counts.rejected }}
</n-tag>
</n-space>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" title="审核说明">
<n-alert type="info" :show-icon="false">
人工审核台读取 `aksrt_user_verify_status`、`aksrt_user_verify_reason` 和审核日志。开启“邮箱验证通过视为已认证”后,公开页仍会优先展示邮件验证结果。
</n-alert>
</n-card>
</n-grid-item>
</n-grid>
<n-card class="aksrt-verify-page__card" size="small" title="公开展示与审核开关">
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="启用身份认证结构">
<n-switch v-model:value="form.user_verify_enabled" />
</n-form-item>
<n-form-item label="公开显示认证徽标">
<n-switch v-model:value="form.user_verify_public_display" :disabled="!form.user_verify_enabled" />
</n-form-item>
<n-form-item label="认证徽标文案">
<n-input v-model:value="form.user_verify_badge_text" :disabled="!form.user_verify_enabled" maxlength="20" />
</n-form-item>
<n-form-item label="邮箱验证通过视为已认证">
<n-switch v-model:value="form.user_verify_accept_email_verified" :disabled="!form.user_verify_enabled" />
</n-form-item>
<n-form-item label="启用人工审核台">
<n-switch v-model:value="form.user_verify_require_manual_review" :disabled="!form.user_verify_enabled" />
</n-form-item>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-form>
</n-card>
<n-card class="aksrt-verify-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-button type="primary" block @click="handleSearch">筛选记录</n-button>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="requests"
:loading="reviewLoading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.user_id"
/>
</n-space>
</n-card>
<VerifyReviewDrawer
v-model:show="drawerVisible"
:user-id="currentUserId"
@updated="loadRequests"
/>
</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 VerifyReviewDrawer from './components/VerifyReviewDrawer.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const reviewLoading = ref(false)
const requests = ref([])
const drawerVisible = ref(false)
const currentUserId = ref(null)
const form = reactive({
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_badge_text: '已认证',
user_verify_accept_email_verified: true,
user_verify_require_manual_review: false,
})
const authSummary = reactive({
emailVerificationEnabled: false,
})
const counts = reactive({
pending: 0,
verified: 0,
rejected: 0,
total: 0,
})
const filters = reactive({
keyword: '',
status: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
})
const statusOptions = computed(() => optionsState.statuses.length
? [{ label: '全部状态', value: '' }, ...optionsState.statuses]
: [
{ label: '全部状态', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'verified' },
{ label: '已驳回', value: 'rejected' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50],
onUpdatePage: (page) => {
pager.page = page
loadRequests()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadRequests()
},
}))
const columns = [
{
title: '用户',
key: 'display_name',
width: 160,
},
{
title: '邮箱',
key: 'email',
minWidth: 220,
ellipsis: { tooltip: true },
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', verified: 'success', rejected: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status)
},
},
{
title: '邮箱验证',
key: 'email_verified',
width: 100,
render: (row) => row.email_verified ? '已完成' : '未完成',
},
{
title: '申请时间',
key: 'requested_at',
width: 170,
},
{
title: '处理时间',
key: 'reviewed_at',
width: 170,
render: (row) => row.reviewed_at || '-',
},
{
title: '历史',
key: 'history_count',
width: 80,
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.user_id),
}, () => '审核'),
]),
},
]
onMounted(async () => {
await load()
})
function extractData(response) {
return response?.data ?? response ?? {}
}
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 (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 load() {
loading.value = true
try {
const [schemaRes, authRes] = await Promise.all([
api.getSchemaPage('users', 'verify'),
api.getAuthSettings(),
])
const schemaData = extractData(schemaRes)
const authData = extractData(authRes)
form.user_verify_enabled = schemaData.user_verify_enabled !== false
form.user_verify_public_display = schemaData.user_verify_public_display !== false
form.user_verify_badge_text = schemaData.user_verify_badge_text || '已认证'
form.user_verify_accept_email_verified = schemaData.user_verify_accept_email_verified !== false
form.user_verify_require_manual_review = !!schemaData.user_verify_require_manual_review
authSummary.emailVerificationEnabled = !!authData.registration_email_verification_enabled
await loadRequests()
} catch (error) {
message.error(error?.message || '加载身份认证页失败')
} finally {
loading.value = false
}
}
async function loadRequests() {
reviewLoading.value = true
try {
const res = await api.request('/verify/requests', { params: buildParams() })
const data = res?.data || {}
requests.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(counts, data.counts || {})
optionsState.statuses = data.options?.statuses || []
} catch (error) {
message.error(error?.message || '加载认证审核台失败')
} finally {
reviewLoading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'verify', {
user_verify_enabled: form.user_verify_enabled,
user_verify_public_display: form.user_verify_public_display,
user_verify_badge_text: form.user_verify_badge_text,
user_verify_accept_email_verified: form.user_verify_accept_email_verified,
user_verify_require_manual_review: form.user_verify_require_manual_review,
})
message.success('身份认证配置已保存')
} catch (error) {
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
function handleSearch() {
pager.page = 1
loadRequests()
}
function openDrawer(userId) {
currentUserId.value = userId
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-verify-page__card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,258 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="微信模板消息" desc="配置微信公众号模板消息推送。" />
<!-- 状态卡片 -->
<div class="aksrt-wechat__status">
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon" :class="status.configured ? 'aksrt-wechat__status-icon--ok' : 'aksrt-wechat__status-icon--warn'">
<i :class="status.configured ? 'fas fa-check' : 'fas fa-exclamation'"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">配置状态</div>
<div class="aksrt-wechat__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon" :class="status.has_token ? 'aksrt-wechat__status-icon--ok' : 'aksrt-wechat__status-icon--warn'">
<i :class="status.has_token ? 'fas fa-key' : 'fas fa-key'"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">Access Token</div>
<div class="aksrt-wechat__status-value">{{ status.has_token ? '已获取' : '未获取' }}</div>
</div>
</div>
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon" :class="status.has_template ? 'aksrt-wechat__status-icon--ok' : 'aksrt-wechat__status-icon--warn'">
<i :class="status.has_template ? 'fas fa-file-alt' : 'fas fa-file-alt'"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">模板 ID</div>
<div class="aksrt-wechat__status-value">{{ status.has_template ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-wechat__status-card">
<div class="aksrt-wechat__status-icon aksrt-wechat__status-icon--info">
<i class="fas fa-users"></i>
</div>
<div class="aksrt-wechat__status-info">
<div class="aksrt-wechat__status-label">绑定用户</div>
<div class="aksrt-wechat__status-value">{{ status.bound_users || 0 }} 人</div>
</div>
</div>
</div>
<!-- 配置表单 -->
<div class="aksrt-wechat__form-card">
<h3>基本配置</h3>
<div class="aksrt-form-group">
<label>
<input type="checkbox" v-model="form.enabled"> 启用微信模板消息
</label>
</div>
<div class="aksrt-form-group">
<label>AppID</label>
<input v-model="form.app_id" type="text" class="aksrt-input" placeholder="微信公众号 AppID">
</div>
<div class="aksrt-form-group">
<label>AppSecret</label>
<input v-model="form.app_secret" type="password" class="aksrt-input" placeholder="微信公众号 AppSecret">
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
</div>
<div class="aksrt-form-group">
<label>模板 ID</label>
<input v-model="form.template_id" type="text" class="aksrt-input" placeholder="微信模板消息模板 ID">
</div>
<div class="aksrt-form-actions">
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
<!-- 通知类型 -->
<div class="aksrt-wechat__form-card">
<h3>支持的通知类型</h3>
<p class="aksrt-form-hint">以下事件类型可触发微信模板消息推送(需用户绑定微信且开启对应订阅):</p>
<table class="aksrt-table aksrt-table--sm">
<thead>
<tr>
<th>事件类型</th>
<th>说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="evt in eventTypes" :key="evt.key">
<td><code>{{ evt.key }}</code></td>
<td>{{ evt.desc }}</td>
<td>
<span class="aksrt-badge" :class="evt.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
{{ evt.ready ? '可用' : '待接入' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
import api from '@/api';
export default {
name: 'UsersWechatTemplate',
components: { PageHeader },
setup() {
const status = ref({
enabled: false,
configured: false,
has_token: false,
has_template: false,
bound_users: 0,
});
const form = reactive({
enabled: false,
app_id: '',
app_secret: '',
template_id: '',
});
const saving = ref(false);
// Event type definitions — readiness driven by backend allowed_types
const eventTypeDefs = [
{ key: 'comment_reply', desc: '评论回复通知' },
{ key: 'system_notice', desc: '系统公告通知' },
{ key: 'order_paid', desc: '订单支付通知' },
{ key: 'credits_change', desc: '积分变动通知' },
{ key: 'forum_reply', desc: '论坛回复通知' },
];
const allowedTypes = ref([]);
const eventTypes = computed(() =>
eventTypeDefs.map(et => ({
...et,
ready: allowedTypes.value.length === 0 || allowedTypes.value.includes(et.key),
}))
);
async function loadStatus() {
try {
const json = await api.request('/wechat/status');
if (json.success) {
status.value = json.data;
form.enabled = json.data.enabled || false;
form.app_id = json.data.appid || '';
form.app_secret = json.data.app_secret === '***' ? '' : '';
form.template_id = json.data.template_id || '';
// Use backend allowed_types for readiness (empty = all allowed)
allowedTypes.value = json.data.allowed_types || [];
}
} catch (e) {
console.error('Load WeChat status failed:', e);
}
}
async function saveSettings() {
saving.value = true;
try {
const payload = { ...form };
// 如果密码为空且状态显示已配置,发送掩码保留原值
if (!payload.app_secret && status.value.configured) {
payload.app_secret = '***';
}
const json = await api.request('/wechat/settings', {
method: 'POST',
data: payload,
});
if (json.success) {
alert('设置已保存');
loadStatus();
} else {
alert(json.message || '保存失败');
}
} catch (e) {
console.error('Save failed:', e);
alert('保存失败: ' + (e.message || '未知错误'));
} finally {
saving.value = false;
}
}
onMounted(() => loadStatus());
return { status, form, saving, eventTypes, saveSettings };
},
};
</script>
<style scoped>
.aksrt-wechat__status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-wechat__status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-wechat__status-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.aksrt-wechat__status-icon--ok {
background: #d4edda;
color: #155724;
}
.aksrt-wechat__status-icon--warn {
background: #fff3cd;
color: #856404;
}
.aksrt-wechat__status-icon--info {
background: #d1ecf1;
color: #0c5460;
}
.aksrt-wechat__status-value {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.aksrt-wechat__status-label {
font-size: 12px;
color: #888;
}
.aksrt-wechat__form-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.aksrt-wechat__form-card h3 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
}
.aksrt-form-hint {
font-size: 12px;
color: #888;
margin-top: 4px;
}
</style>
@@ -0,0 +1,242 @@
<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.report">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="举报人">
{{ detail.report.reporter?.display_name || `用户 #${detail.report.reporter?.id || 0}` }}
</n-descriptions-item>
<n-descriptions-item label="举报目标">
{{ targetTitle }}
</n-descriptions-item>
<n-descriptions-item label="目标类型">
{{ detail.report.target_type }}
</n-descriptions-item>
<n-descriptions-item label="分类">
{{ detail.report.category || '-' }}
</n-descriptions-item>
<n-descriptions-item label="当前状态">
{{ detail.report.status }}
</n-descriptions-item>
<n-descriptions-item label="提交时间">
{{ detail.report.created_at || '-' }}
</n-descriptions-item>
</n-descriptions>
<div class="aksrt-report-drawer__section">
<div class="aksrt-report-drawer__title">证据链</div>
<n-alert type="info" :show-icon="false">
{{ detail.report.reason || '暂无举报原因' }}
</n-alert>
<n-space class="aksrt-report-drawer__meta" wrap>
<n-tag size="small" round :bordered="false">
目标 ID:{{ detail.report.target_id }}
</n-tag>
<n-tag
v-if="detail.report.target?.status"
size="small"
type="warning"
round
:bordered="false"
>
目标状态:{{ detail.report.target.status }}
</n-tag>
<a
v-if="detail.report.target?.url"
:href="detail.report.target.url"
target="_blank"
rel="noreferrer"
class="aksrt-report-drawer__link"
>
打开目标内容
</a>
</n-space>
</div>
<div class="aksrt-report-drawer__section">
<div class="aksrt-report-drawer__title">处理记录</div>
<div v-if="detail.report.notes?.length" class="aksrt-report-drawer__timeline">
<div v-for="(entry, index) in detail.report.notes" :key="index" class="aksrt-report-drawer__timeline-item">
<div class="aksrt-report-drawer__timeline-head">
<strong>{{ entry.status || '-' }}</strong>
<span>{{ entry.timestamp || '-' }}</span>
</div>
<p>{{ entry.note || '无备注' }}</p>
<small>{{ entry.operator_name || '系统' }} {{ entry.effect || '' }}</small>
</div>
</div>
<n-empty v-else description="暂无处理记录" />
</div>
<div class="aksrt-report-drawer__section">
<div class="aksrt-report-drawer__title">处理工单</div>
<n-space vertical :size="12">
<n-select v-model:value="form.status" :options="detail.options?.statuses || []" />
<n-select v-model:value="form.action_taken" :options="detail.options?.actions || []" />
<n-input
v-model:value="form.note"
type="textarea"
:rows="4"
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'
const props = defineProps({
show: { type: Boolean, default: false },
reportId: { 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({ report: null, options: { statuses: [], actions: [] } })
const form = reactive({
status: 'reviewing',
action_taken: 'none',
note: '',
})
const targetTitle = computed(() => {
const target = detail.value?.report?.target
if (!target) {
return '-'
}
return target.title || target.display_name || target.excerpt || `目标 #${target.id || 0}`
})
watch(
() => [props.show, props.reportId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { report: null, options: { statuses: [], actions: [] } }
resetForm()
}
},
)
function resetForm() {
form.status = 'reviewing'
form.action_taken = 'none'
form.note = ''
}
async function loadDetail() {
if (!props.reportId) {
return
}
loading.value = true
try {
const res = await api.request(`/users/reports/${props.reportId}`)
detail.value = res?.data || { report: null, options: { statuses: [], actions: [] } }
form.status = detail.value?.report?.status || 'reviewing'
form.action_taken = 'none'
form.note = ''
} catch (error) {
message.error(error?.message || '加载举报详情失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!props.reportId) {
return
}
submitting.value = true
try {
await api.request(`/users/reports/${props.reportId}/process`, {
method: 'POST',
data: {
status: form.status,
action_taken: form.action_taken,
note: form.note,
},
})
message.success('举报工单已处理')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '处理失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-report-drawer__section {
margin-top: 20px;
}
.aksrt-report-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-report-drawer__meta {
margin-top: 12px;
}
.aksrt-report-drawer__link {
color: #2563eb;
text-decoration: none;
}
.aksrt-report-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-report-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-report-drawer__timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
color: #374151;
}
.aksrt-report-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-report-drawer__timeline-item small {
color: #6b7280;
}
</style>
@@ -0,0 +1,180 @@
<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.request">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="用户">{{ detail.request.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="邮箱">{{ detail.request.email || '-' }}</n-descriptions-item>
<n-descriptions-item label="当前状态">{{ detail.request.status || '-' }}</n-descriptions-item>
<n-descriptions-item label="邮箱验证">
{{ detail.request.email_verified ? '已完成' : '未完成' }}
</n-descriptions-item>
<n-descriptions-item label="申请时间">{{ detail.request.requested_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="最近处理">{{ detail.request.reviewed_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-verify-drawer__section">
<div class="aksrt-verify-drawer__title">当前备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.request.reason || '暂无备注' }}
</n-alert>
</div>
<div class="aksrt-verify-drawer__section">
<div class="aksrt-verify-drawer__title">审核历史</div>
<div v-if="detail.history?.length" class="aksrt-verify-drawer__timeline">
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-verify-drawer__timeline-item">
<div class="aksrt-verify-drawer__timeline-head">
<strong>{{ entry.status || '-' }}</strong>
<span>{{ entry.time || '-' }}</span>
</div>
<p>{{ entry.reason || '无备注' }}</p>
<small>{{ entry.reviewer_name || '系统' }}</small>
</div>
</div>
<n-empty v-else description="暂无审核历史" />
</div>
<div class="aksrt-verify-drawer__section">
<div class="aksrt-verify-drawer__title">执行审核</div>
<n-space vertical :size="12">
<n-select v-model:value="form.status" :options="detail.options?.statuses || []" />
<n-input
v-model:value="form.note"
type="textarea"
:rows="4"
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 { reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
userId: { 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({ request: null, history: [], options: { statuses: [] } })
const form = reactive({
status: 'verified',
note: '',
})
watch(
() => [props.show, props.userId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { request: null, history: [], options: { statuses: [] } }
form.status = 'verified'
form.note = ''
}
},
)
async function loadDetail() {
if (!props.userId) {
return
}
loading.value = true
try {
const res = await api.request(`/verify/requests/${props.userId}`)
detail.value = res?.data || { request: null, history: [], options: { statuses: [] } }
form.status = detail.value?.request?.status === 'rejected' ? 'verified' : 'verified'
form.note = ''
} catch (error) {
message.error(error?.message || '加载认证详情失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!props.userId) {
return
}
submitting.value = true
try {
await api.request(`/verify/requests/${props.userId}/review`, {
method: 'POST',
data: {
status: form.status,
note: form.note,
},
})
message.success('认证审核已提交')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '审核失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-verify-drawer__section {
margin-top: 20px;
}
.aksrt-verify-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-verify-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-verify-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-verify-drawer__timeline-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.aksrt-verify-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-verify-drawer__timeline-item small {
color: #6b7280;
}
</style>