fix(admin): restore Vite admin source files

This commit is contained in:
kate522 committed 2026-08-17 20:02:36 +08:00
1 parent 8fe3a89c36
commit ac5c0b2dd7
114 files changed
+17272

No files matched your search

+265
View File
@@ -0,0 +1,265 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户徽章"
desc="配置勋章分类、自动授予条件(get_type / get_val)与公开展示。路由 /users/badge,存储键 user_medal_*。"
>
<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_medal_s ? 'success' : 'default'">
徽章系统:{{ form.user_medal_s ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.user_medal_public_display ? 'info' : 'default'">
公开展示:{{ form.user_medal_public_display ? '开启' : '关闭' }}
</n-tag>
<n-tag round type="info">最多显示:{{ form.user_medal_limit }} 枚</n-tag>
<n-tag round type="info">分类:{{ form.user_medal_args.length }}</n-tag>
<n-tag round type="info">条目:{{ itemCount }}</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_medal_s" />
</n-form-item>
<n-form-item v-if="form.user_medal_s" label="公开页显示徽章">
<n-switch v-model:value="form.user_medal_public_display" />
</n-form-item>
<n-form-item v-if="form.user_medal_s" label="最多显示数量">
<n-input-number v-model:value="form.user_medal_limit" :min="1" :max="8" style="width: 180px;" />
</n-form-item>
</n-form>
</n-card>
<n-card v-if="form.user_medal_s" class="aksrt-section-card">
<div class="aksrt-section-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>徽章明细(分类 → 条目)</h3>
<n-button size="small" @click="addCategory">添加分类</n-button>
</div>
<n-empty v-if="!form.user_medal_args.length" description="暂无分类,请添加" />
<div v-for="(cat, ci) in form.user_medal_args" :key="'cat-' + ci" class="medal-cat-block">
<n-card size="small" embedded>
<n-space vertical :size="12">
<n-space align="center">
<n-input v-model:value="cat.cat_name" placeholder="分类名称" style="width: 220px;" />
<n-button size="small" @click="addItem(ci)">添加条目</n-button>
<n-button size="small" quaternary type="error" @click="removeCategory(ci)">删除分类</n-button>
</n-space>
<n-empty v-if="!(cat.items && cat.items.length)" description="该分类暂无条目" />
<div v-for="(item, ii) in cat.items || []" :key="'item-' + ci + '-' + ii" class="medal-item-row">
<n-grid cols="1 s:2 m:3" :x-gap="12" :y-gap="8" responsive="screen">
<n-grid-item>
<n-input v-model:value="item.slug" placeholder="slug" />
</n-grid-item>
<n-grid-item>
<n-input v-model:value="item.name" placeholder="名称" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="item.get_type" :options="getTypeOptions" placeholder="获取条件" />
</n-grid-item>
<n-grid-item>
<n-input-number
v-model:value="item.get_val"
:min="0"
:disabled="item.get_type === 'manually_add'"
placeholder="阈值"
style="width: 100%;"
/>
</n-grid-item>
<n-grid-item>
<n-input v-model:value="item.icon" placeholder="图标 URL" />
</n-grid-item>
<n-grid-item>
<n-input v-model:value="item.desc" placeholder="描述" />
</n-grid-item>
</n-grid>
<div style="margin-top: 8px; text-align: right;">
<n-button size="tiny" quaternary type="error" @click="removeItem(ci, ii)">删除条目</n-button>
</div>
</div>
</n-space>
</n-card>
</div>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
前台已授予徽章保存在用户 meta aksrt_user_badges;佩戴为 aksrt_wear_badge(slug)。
字典来自 user_medal_args;运行时 AKSRT_User_Badges 读取 identity badge.dictionary 派生列表。
</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'
import { MEDAL_GET_TYPES, defaultMedalArgs } from '@/schema/sections/users/badge.js'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const getTypeOptions = MEDAL_GET_TYPES
const form = reactive({
user_medal_s: false,
user_medal_public_display: true,
user_medal_limit: 3,
user_medal_args: defaultMedalArgs(),
})
const itemCount = computed(() =>
form.user_medal_args.reduce((n, c) => n + (Array.isArray(c.items) ? c.items.length : 0), 0),
)
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
function emptyItem() {
return {
slug: '',
name: '',
desc: '',
icon: '',
get_type: 'manually_add',
get_val: 0,
}
}
function normalizeArgs(raw) {
if (!Array.isArray(raw)) {
return defaultMedalArgs()
}
return raw.map((cat) => ({
cat_name: String(cat?.cat_name || cat?.name || '未命名').trim() || '未命名',
items: Array.isArray(cat?.items)
? cat.items.map((it) => ({
slug: String(it?.slug || '').trim(),
name: String(it?.name || '').trim(),
desc: String(it?.desc || '').trim(),
icon: String(it?.icon || it?.icon_url || '').trim(),
get_type: String(it?.get_type || 'manually_add').trim() || 'manually_add',
get_val: Math.max(0, Number(it?.get_val ?? 0) || 0),
}))
: [],
}))
}
function addCategory() {
form.user_medal_args.push({ cat_name: '新分类', items: [emptyItem()] })
}
function removeCategory(ci) {
form.user_medal_args.splice(ci, 1)
}
function addItem(ci) {
if (!form.user_medal_args[ci].items) {
form.user_medal_args[ci].items = []
}
form.user_medal_args[ci].items.push(emptyItem())
}
function removeItem(ci, ii) {
form.user_medal_args[ci].items.splice(ii, 1)
}
async function load() {
loading.value = true
try {
const schemaRes = await api.getSchemaPage('users', 'badge')
const schemaData = extractData(schemaRes)
form.user_medal_s = !!(schemaData.user_medal_s ?? schemaData.user_badge_enabled)
form.user_medal_public_display =
schemaData.user_medal_public_display !== undefined
? schemaData.user_medal_public_display !== false
: schemaData.user_badge_public_display !== false
form.user_medal_limit = Number(
schemaData.user_medal_limit ?? schemaData.user_badge_limit ?? 3,
)
form.user_medal_args = normalizeArgs(schemaData.user_medal_args ?? null)
} catch (error) {
message.error(error.message || '加载用户徽章配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const args = normalizeArgs(form.user_medal_args)
for (const cat of args) {
for (const it of cat.items) {
if (!it.slug || !it.name || !it.get_type) {
message.error('每个条目需要 slug、名称和获取条件')
saving.value = false
return
}
}
}
await api.saveSchemaPage('users', 'badge', {
user_medal_s: !!form.user_medal_s,
user_medal_public_display: !!form.user_medal_public_display,
user_medal_limit: Number(form.user_medal_limit || 3),
user_medal_args: args,
})
form.user_medal_args = args
message.success('用户徽章配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.medal-cat-block {
margin-bottom: 16px;
}
.medal-item-row {
padding: 12px;
border: 1px solid var(--n-border-color, #efeff5);
border-radius: 8px;
margin-bottom: 8px;
}
</style>
+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>
+290
View File
@@ -0,0 +1,290 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户等级"
desc="配置等级开关、最高等级、升级经验阈值与经验获取矩阵(对齐 Zibll 用户等级 / 经验值)。"
>
<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_s ? 'success' : 'default'">
等级系统:{{ form.user_level_s ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round type="info">最高等级:{{ form.user_level_max }}</n-tag>
<n-tag round type="info">日经验上限:{{ form.user_integral_opt.day_max }}</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_s" />
</n-form-item>
<n-form-item v-if="form.user_level_s" label="公开页显示等级">
<n-switch v-model:value="form.user_level_public_display" />
</n-form-item>
<n-form-item v-if="form.user_level_s" label="最高等级">
<n-input-number v-model:value="form.user_level_max" :min="3" :max="30" style="width: 180px;" @update:value="onMaxChange" />
</n-form-item>
<n-form-item v-if="form.user_level_s" label="升级通知">
<n-switch v-model:value="form.user_level_notify" />
</n-form-item>
</n-form>
</n-card>
<n-card v-if="form.user_level_s" class="aksrt-section-card">
<div class="aksrt-section-header"><h3>升级所需经验(累计)</h3></div>
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
Lv.1 通常为 0。数值为升到该级所需的累计经验。修改最高等级后会自动补齐/截断行。
</n-alert>
<n-data-table :columns="levelColumns" :data="levelRows" :bordered="false" size="small" />
</n-card>
<n-card v-if="form.user_level_s" 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-number v-model:value="form.user_integral_opt.day_max" :min="0" :max="10000" style="width: 180px;" />
</n-form-item>
</n-form>
<n-divider />
<div v-for="group in integralGroups" :key="group" style="margin-bottom: 16px;">
<h4 style="margin: 0 0 8px;">{{ group }}</h4>
<n-grid cols="1 s:2 m:3" :x-gap="12" :y-gap="8" responsive="screen">
<n-grid-item v-for="row in INTEGRAL_CATALOG.filter((r) => r.group === group)" :key="row.key">
<div class="integral-field">
<div class="integral-field__label">{{ row.label }}</div>
<n-input-number v-model:value="form.user_integral_opt[row.key]" :min="0" :max="1000" style="width: 100%;" />
</div>
</n-grid-item>
</n-grid>
</div>
</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, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NInput, NInputNumber, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import {
INTEGRAL_CATALOG,
defaultIntegralOpt,
defaultLevelOpt,
} from '@/schema/sections/users/level.js'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
user_level_s: true,
user_level_public_display: true,
user_level_max: 10,
user_level_opt: defaultLevelOpt(10),
user_integral_opt: defaultIntegralOpt(),
user_level_notify: true,
})
const integralGroups = computed(() => [...new Set(INTEGRAL_CATALOG.map((r) => r.group))])
const levelRows = computed(() => {
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
const rows = []
for (let i = 1; i <= max; i++) {
rows.push({
level: i,
name: form.user_level_opt['name_' + i] ?? 'LV' + i,
upgrade: Number(form.user_level_opt['upgrade_integral_' + i] ?? 0),
})
}
return rows
})
const levelColumns = [
{ title: '等级', key: 'level', width: 80 },
{
title: '名称',
key: 'name',
render: (row) =>
h(NInput, {
value: form.user_level_opt['name_' + row.level] ?? '',
size: 'small',
onUpdateValue: (v) => {
form.user_level_opt['name_' + row.level] = v
},
}),
},
{
title: '等级图标 URL',
key: 'icon',
render: (row) =>
h(NInput, {
value: form.user_level_opt['icon_img_' + row.level] ?? '',
size: 'small',
placeholder: row.level <= 10 ? '留空使用主题内置图标' : '请输入图片 URL',
onUpdateValue: (v) => {
form.user_level_opt['icon_img_' + row.level] = v
},
}),
},
{
title: '升级所需累计经验',
key: 'upgrade',
width: 200,
render: (row) =>
h(NInputNumber, {
value: Number(form.user_level_opt['upgrade_integral_' + row.level] ?? 0),
min: 0,
max: 9999999,
size: 'small',
style: 'width: 160px;',
onUpdateValue: (v) => {
form.user_level_opt['upgrade_integral_' + row.level] = Number(v || 0)
},
}),
},
]
function go(path) {
router.push(path)
}
function extractData(response) {
return response?.data ?? response ?? {}
}
function onMaxChange(val) {
const max = Math.max(3, Math.min(30, Number(val) || 10))
form.user_level_max = max
const next = { ...form.user_level_opt }
const filled = defaultLevelOpt(max)
for (let i = 1; i <= max; i++) {
if (next['upgrade_integral_' + i] === undefined) {
next['upgrade_integral_' + i] = filled['upgrade_integral_' + i]
}
if (next['name_' + i] === undefined) {
next['name_' + i] = 'LV' + i
}
if (next['icon_img_' + i] === undefined) {
next['icon_img_' + i] = ''
}
}
// drop extras
for (const k of Object.keys(next)) {
const m = k.match(/_(\d+)$/)
if (m && Number(m[1]) > max) {
delete next[k]
}
}
form.user_level_opt = next
}
function normalizeIntegral(raw) {
const base = defaultIntegralOpt()
if (!raw || typeof raw !== 'object') {
return base
}
for (const row of INTEGRAL_CATALOG) {
if (raw[row.key] !== undefined) {
base[row.key] = Math.max(0, Number(raw[row.key]) || 0)
}
}
if (raw.day_max !== undefined) {
base.day_max = Math.max(0, Number(raw.day_max) || 0)
}
return base
}
function normalizeLevelOpt(raw, max) {
const base = defaultLevelOpt(max)
if (!raw || typeof raw !== 'object') {
return base
}
for (let i = 1; i <= max; i++) {
if (raw['upgrade_integral_' + i] !== undefined) {
base['upgrade_integral_' + i] = Math.max(0, Number(raw['upgrade_integral_' + i]) || 0)
}
if (raw['name_' + i] !== undefined) {
base['name_' + i] = String(raw['name_' + i] || '')
}
if (raw['icon_img_' + i] !== undefined) {
base['icon_img_' + i] = String(raw['icon_img_' + i] || '')
}
}
return base
}
async function load() {
loading.value = true
try {
const schemaRes = await api.getSchemaPage('users', 'level')
const d = extractData(schemaRes)
const enabled = d.user_level_s !== false
form.user_level_s = enabled
form.user_level_public_display = d.user_level_public_display !== false
form.user_level_max = Math.max(3, Math.min(30, Number(d.user_level_max || 10)))
form.user_level_notify = d.user_level_notify !== false
form.user_level_opt = normalizeLevelOpt(d.user_level_opt, form.user_level_max)
form.user_integral_opt = normalizeIntegral(d.user_integral_opt)
} catch (error) {
message.error(error.message || '加载用户等级配置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
const payload = {
user_level_s: !!form.user_level_s,
user_level_public_display: !!form.user_level_public_display,
user_level_max: max,
user_level_notify: !!form.user_level_notify,
user_level_opt: normalizeLevelOpt(form.user_level_opt, max),
user_integral_opt: normalizeIntegral(form.user_integral_opt),
}
await api.saveSchemaPage('users', 'level', payload)
Object.assign(form, {
user_level_max: max,
user_level_opt: payload.user_level_opt,
user_integral_opt: payload.user_integral_opt,
})
message.success('用户等级配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.integral-field__label {
font-size: 12px;
margin-bottom: 4px;
opacity: 0.8;
}
</style>
@@ -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,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>