feat(admin): 新增后台公告管理与发布归档
This commit is contained in:
@@ -98,6 +98,11 @@ const AdminFinancePage = lazy(() =>
|
||||
default: module.AdminFinancePage,
|
||||
}))
|
||||
)
|
||||
const AdminAnnouncementsPage = lazy(() =>
|
||||
import('@/features/admin/AdminAnnouncementsPage').then(module => ({
|
||||
default: module.AdminAnnouncementsPage,
|
||||
}))
|
||||
)
|
||||
const AdminLayout = lazy(() =>
|
||||
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
|
||||
)
|
||||
@@ -107,7 +112,6 @@ const AdminSectionPlaceholder = lazy(() =>
|
||||
|
||||
const adminSections: Array<{ path: string; title: string; description: string }> = [
|
||||
{ path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
|
||||
{ path: 'announcements', title: '公告管理', description: '维护主站与分站公告。' },
|
||||
{ path: 'system-configs', title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
|
||||
{ path: 'audit-logs', title: '审计日志', description: '查看高风险操作与变更记录。' },
|
||||
]
|
||||
@@ -149,6 +153,7 @@ export function AppRouter() {
|
||||
<Route path="withdrawals" element={<AdminWithdrawalsPage />} />
|
||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
<Route path="announcements" element={<AdminAnnouncementsPage />} />
|
||||
{adminSections.map(section => (
|
||||
<Route
|
||||
key={section.path}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
archiveAdminAnnouncement,
|
||||
createAdminAnnouncement,
|
||||
deleteAdminAnnouncement,
|
||||
fetchAdminAnnouncements,
|
||||
publishAdminAnnouncement,
|
||||
unarchiveAdminAnnouncement,
|
||||
updateAdminAnnouncement,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import { StatusTag } from '@/features/admin/components/StatusTag'
|
||||
import {
|
||||
announcementCategoryMap,
|
||||
announcementCategoryOptions,
|
||||
announcementStatusMap,
|
||||
announcementStatusOptions,
|
||||
readStatusMeta,
|
||||
} from '@/features/admin/labels'
|
||||
import type {
|
||||
AdminAnnouncement,
|
||||
AdminAnnouncementPayload,
|
||||
AdminAnnouncementQuery,
|
||||
} from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime } from '@/shared/utils/format'
|
||||
|
||||
const emptyFilters: AdminAnnouncementQuery = {
|
||||
category: '',
|
||||
status: '',
|
||||
}
|
||||
|
||||
export function AdminAnnouncementsPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<AdminAnnouncementPayload>()
|
||||
const [draft, setDraft] = useState<AdminAnnouncementQuery>(emptyFilters)
|
||||
const [applied, setApplied] = useState<AdminAnnouncementQuery>(emptyFilters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminAnnouncement | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canManage = Boolean(
|
||||
permissions?.includes('*') || permissions?.includes('announcement:manage')
|
||||
)
|
||||
|
||||
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: ['admin-announcements', applied, page, pageSize],
|
||||
queryFn: () => fetchAdminAnnouncements({ ...applied, page, page_size: pageSize }),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.setFieldsValue({
|
||||
distribution_site_id: 0,
|
||||
title: '',
|
||||
content: '',
|
||||
category: 'notice',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_important: false,
|
||||
is_popup: false,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: AdminAnnouncement) => {
|
||||
setEditing(item)
|
||||
form.setFieldsValue({
|
||||
distribution_site_id: item.distribution_site_id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
priority: item.priority,
|
||||
is_pinned: item.is_pinned,
|
||||
is_important: item.is_important,
|
||||
is_popup: item.is_popup,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload: AdminAnnouncementPayload = {
|
||||
...values,
|
||||
distribution_site_id: Number(values.distribution_site_id ?? 0),
|
||||
}
|
||||
if (editing) {
|
||||
await updateAdminAnnouncement(editing.id, payload)
|
||||
message.success('公告已更新')
|
||||
} else {
|
||||
await createAdminAnnouncement(payload)
|
||||
message.success('公告已创建为草稿')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-announcements'] })
|
||||
} catch (err) {
|
||||
const text =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? ((err as { response?: { data?: { message?: string } } }).response?.data?.message ??
|
||||
'保存失败')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: '保存失败'
|
||||
message.error(text)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAction = (title: string, content: string, action: () => Promise<void>) => {
|
||||
modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await action()
|
||||
message.success('操作成功')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-announcements'] })
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '操作失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo<TableColumnsType<AdminAnnouncement>>(
|
||||
() => [
|
||||
{
|
||||
title: '公告',
|
||||
dataIndex: 'title',
|
||||
width: 320,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{row.title}</strong>
|
||||
<Space size={4} wrap>
|
||||
{row.is_pinned ? <Tag color="gold">置顶</Tag> : null}
|
||||
{row.is_important ? <Tag color="red">重要</Tag> : null}
|
||||
{row.is_popup ? <Tag color="purple">弹窗</Tag> : null}
|
||||
{row.priority !== 0 ? <Tag>优先级 {row.priority}</Tag> : null}
|
||||
</Space>
|
||||
<span className="admin-mono">#{row.id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 110,
|
||||
render: value => announcementCategoryMap[value] ?? value,
|
||||
},
|
||||
{
|
||||
title: '站点',
|
||||
key: 'site',
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
row.distribution_site_id > 0
|
||||
? row.distribution_site_name || `分站 #${row.distribution_site_id}`
|
||||
: '主站',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => <StatusTag meta={readStatusMeta(announcementStatusMap, value)} />,
|
||||
},
|
||||
{
|
||||
title: '浏览',
|
||||
dataIndex: 'view_count',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '发布时间',
|
||||
dataIndex: 'published_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 260,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
{canManage ? (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'draft' && canManage ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
runAction('发布公告?', `发布后前台将立即展示「${row.title}」。`, () =>
|
||||
publishAdminAnnouncement(row.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'published' && canManage ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
runAction('归档公告?', `归档后「${row.title}」不再在前台展示。`, () =>
|
||||
archiveAdminAnnouncement(row.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'archived' && canManage ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
runAction('取消归档?', `「${row.title}」将恢复为已发布公告。`, () =>
|
||||
unarchiveAdminAnnouncement(row.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
取消归档
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'删除公告?',
|
||||
`删除后不可恢复:「${row.title}」。如果只是下架,建议使用归档。`,
|
||||
() => deleteAdminAnnouncement(row.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canManage]
|
||||
)
|
||||
|
||||
const patchFilter = (patch: Partial<AdminAnnouncementQuery>) => {
|
||||
setDraft(current => ({ ...current, ...patch }))
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
setApplied({ ...draft })
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = Boolean(draft.category) || Boolean(draft.status)
|
||||
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 运营"
|
||||
title="公告管理"
|
||||
description="维护主站与分站公告,支持置顶、重要、弹窗与发布归档。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {data?.total ?? 0} 条</Tag>
|
||||
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
|
||||
刷新
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建公告
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
onSearch={applyFilters}
|
||||
onReset={hasActiveFilters ? resetFilters : undefined}
|
||||
loading={isFetching}
|
||||
>
|
||||
<FilterField label="公告分类" width={160}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.category || undefined}
|
||||
placeholder="全部分类"
|
||||
options={announcementCategoryOptions}
|
||||
onChange={value => patchFilter({ category: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="公告状态" width={150}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.status || undefined}
|
||||
placeholder="全部状态"
|
||||
options={announcementStatusOptions}
|
||||
onChange={value => patchFilter({ status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="公告列表没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminAnnouncement>
|
||||
title={<span>公告列表</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
|
||||
</Typography.Text>
|
||||
}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isFetching}
|
||||
total={data?.total ?? 0}
|
||||
page={data?.page ?? page}
|
||||
pageSize={data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
}}
|
||||
emptyDescription="还没有公告"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? '编辑公告' : '新建公告'}
|
||||
width={720}
|
||||
okText={editing ? '保存' : '创建草稿'}
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void save()}
|
||||
onCancel={() => {
|
||||
if (!submitting) setEditorOpen(false)
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请输入公告标题' }, { max: 255 }]}
|
||||
>
|
||||
<Input placeholder="例如:国庆期间结算时间调整" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select options={announcementCategoryOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="优先级" style={{ width: 160 }}>
|
||||
<InputNumber style={{ width: '100%' }} precision={0} />
|
||||
</Form.Item>
|
||||
{!editing ? (
|
||||
<Form.Item
|
||||
name="distribution_site_id"
|
||||
label="归属站点"
|
||||
tooltip="0 表示主站;分站公告需填写分站 ID"
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={0} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="正文"
|
||||
rules={[{ required: true, message: '请输入公告正文' }]}
|
||||
>
|
||||
<Input.TextArea rows={8} placeholder="支持换行,前台按纯文本展示" />
|
||||
</Form.Item>
|
||||
<Space size={24} wrap>
|
||||
<Form.Item name="is_pinned" label="置顶" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_important" label="重要" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_popup" label="弹窗公告" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">
|
||||
弹窗公告每天只弹一次,需用户手动关闭。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { adminClient } from '@/shared/api/adminClient'
|
||||
import type { ApiResponse } from '@/shared/api/types'
|
||||
import type {
|
||||
AdminAnnouncement,
|
||||
AdminAnnouncementPage,
|
||||
AdminAnnouncementPayload,
|
||||
AdminAnnouncementQuery,
|
||||
AdminCaptcha,
|
||||
AdminDispute,
|
||||
AdminDisputePage,
|
||||
@@ -345,3 +349,59 @@ export async function fetchFinanceDetails(
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminAnnouncements(query: AdminAnnouncementQuery) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminAnnouncementPage>>>(
|
||||
'/admin/announcements',
|
||||
{ params: compactParams(query) }
|
||||
)
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminAnnouncement(payload: AdminAnnouncementPayload) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminAnnouncement>>(
|
||||
'/admin/announcements',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminAnnouncement(
|
||||
id: number | string,
|
||||
payload: AdminAnnouncementPayload
|
||||
) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminAnnouncement>>(
|
||||
`/admin/announcements/${id}`,
|
||||
{
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
category: payload.category,
|
||||
priority: payload.priority,
|
||||
is_pinned: payload.is_pinned,
|
||||
is_important: payload.is_important,
|
||||
is_popup: payload.is_popup,
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function publishAdminAnnouncement(id: number | string) {
|
||||
await adminClient.post(`/admin/announcements/${id}/publish`)
|
||||
}
|
||||
|
||||
export async function archiveAdminAnnouncement(id: number | string) {
|
||||
await adminClient.post(`/admin/announcements/${id}/archive`)
|
||||
}
|
||||
|
||||
export async function unarchiveAdminAnnouncement(id: number | string) {
|
||||
await adminClient.post(`/admin/announcements/${id}/unarchive`)
|
||||
}
|
||||
|
||||
export async function deleteAdminAnnouncement(id: number | string) {
|
||||
await adminClient.delete(`/admin/announcements/${id}`)
|
||||
}
|
||||
|
||||
@@ -197,3 +197,24 @@ export const financeStatusMap: Record<string, StatusMeta> = {
|
||||
settlement_diff: { label: '结算差异', tone: 'orange' },
|
||||
offline_settlement_pending: { label: '待线下结算', tone: 'gold' },
|
||||
}
|
||||
|
||||
export const announcementStatusMap: Record<string, StatusMeta> = {
|
||||
draft: { label: '草稿', tone: 'default' },
|
||||
published: { label: '已发布', tone: 'success' },
|
||||
archived: { label: '已归档', tone: 'default' },
|
||||
}
|
||||
|
||||
export const announcementCategoryMap: Record<string, string> = {
|
||||
notice: '通知',
|
||||
tutorial: '教程',
|
||||
rule: '规则',
|
||||
faq: '常见问题',
|
||||
}
|
||||
|
||||
export const announcementStatusOptions = Object.entries(announcementStatusMap).map(
|
||||
([value, meta]) => ({ value, label: meta.label })
|
||||
)
|
||||
|
||||
export const announcementCategoryOptions = Object.entries(announcementCategoryMap).map(
|
||||
([value, label]) => ({ value, label })
|
||||
)
|
||||
|
||||
@@ -533,3 +533,47 @@ export interface FinanceDetailPage {
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminAnnouncementQuery {
|
||||
category?: string
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
site_id?: number
|
||||
}
|
||||
|
||||
export interface AdminAnnouncement {
|
||||
id: number
|
||||
distribution_site_id: number
|
||||
distribution_site_name?: string
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
priority: number
|
||||
is_pinned: boolean
|
||||
is_important: boolean
|
||||
is_popup: boolean
|
||||
view_count: number
|
||||
status: string
|
||||
published_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminAnnouncementPage {
|
||||
items: AdminAnnouncement[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminAnnouncementPayload {
|
||||
distribution_site_id?: number
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
priority: number
|
||||
is_pinned: boolean
|
||||
is_important: boolean
|
||||
is_popup: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user