feat: 插件声明式设置体系——registerSettings schema + 标准控件表单 + 一级导航入口开关
This commit is contained in:
@@ -27,10 +27,13 @@ import type {
|
||||
SessionState,
|
||||
ThemeMode
|
||||
} from '@shared/types'
|
||||
import type { PluginSettingsSchema, PluginSettingsSchemas, PluginSettingValues, PluginSettingsValuesMap } from '@aso/plugin-sdk'
|
||||
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
|
||||
import { usePluginList } from '../plugins/store'
|
||||
import { PluginIcon } from '../plugins/PluginPageFrame'
|
||||
|
||||
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands'
|
||||
type PluginSettingsTab = `plugin:${string}`
|
||||
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
|
||||
|
||||
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
|
||||
const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
|
||||
@@ -52,7 +55,7 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I
|
||||
}
|
||||
]
|
||||
|
||||
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
||||
const TAB_META: Record<Exclude<Tab, PluginSettingsTab>, { title: string; desc: string }> = {
|
||||
general: { title: '常规', desc: '主题色等外观偏好。' },
|
||||
model: { title: '模型设置', desc: '' },
|
||||
prompt: { title: '系统提示词', desc: '' },
|
||||
@@ -631,7 +634,9 @@ function PluginsPanel({
|
||||
togglingId,
|
||||
onToggle,
|
||||
devBuild,
|
||||
onOpenDir
|
||||
onOpenDir,
|
||||
onOpenSettings,
|
||||
onNavToggle
|
||||
}: {
|
||||
/** 正在切换中的插件 id(切换期间全部开关禁用,避免并发重建运行时) */
|
||||
togglingId: string | null
|
||||
@@ -639,6 +644,10 @@ function PluginsPanel({
|
||||
/** 开发版构建(第三方装载开关开启);用户版展示关闭提示 */
|
||||
devBuild: boolean
|
||||
onOpenDir: (path: string) => void
|
||||
/** 打开插件自己的二级配置页(仅已声明设置表单的插件出现) */
|
||||
onOpenSettings: (pluginId: string) => void
|
||||
/** 把设置入口固定/移出到设置页一级导航 */
|
||||
onNavToggle: (pluginId: string, on: boolean) => void
|
||||
}): ReactNode {
|
||||
const plugins = usePluginList()
|
||||
const [scope, setScope] = useState<'user' | 'project' | 'builtin'>('user')
|
||||
@@ -727,6 +736,41 @@ function PluginsPanel({
|
||||
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-ink-3">{p.enabled ? '已启用' : '已停用'}</span>
|
||||
{p.hasSettings && p.enabled && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="打开配置页"
|
||||
aria-label={`打开${p.manifest.name}配置页`}
|
||||
onClick={() => onOpenSettings(p.manifest.id)}
|
||||
className="shrink-0 rounded-control p-1 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<LuSettings2 size={13} />
|
||||
</button>
|
||||
<span
|
||||
className={`shrink-0 rounded-control px-1.5 py-0.5 text-[10px] transition-colors ${
|
||||
p.settingsNav ? 'bg-accent/10 text-accent' : 'bg-hover text-ink-3'
|
||||
}`}
|
||||
>
|
||||
一级入口
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={p.settingsNav}
|
||||
aria-label={`一级入口${p.manifest.name}`}
|
||||
title="把设置入口固定到左侧导航「Agent 能力」分组"
|
||||
onClick={() => onNavToggle(p.manifest.id, !p.settingsNav)}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
|
||||
p.settingsNav ? 'bg-accent' : 'bg-line-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 size-4 rounded-full bg-white shadow transition-[left] ${p.settingsNav ? 'left-[18px]' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
@@ -753,6 +797,291 @@ function PluginsPanel({
|
||||
)
|
||||
}
|
||||
|
||||
// 插件设置导航图标(svg 字符串 → 组件;按 id+svg 缓存保证身份稳定,避免导航项重挂载)
|
||||
const pluginSettingsIconCache = new Map<string, IconType>()
|
||||
function pluginSettingsIcon(id: string, svg: string | undefined): IconType {
|
||||
const key = `${id}:${svg ?? ''}`
|
||||
const cached = pluginSettingsIconCache.get(key)
|
||||
if (cached) return cached
|
||||
const Icon: IconType = (props) => (
|
||||
<PluginIcon svg={svg} size={typeof props.size === 'number' ? props.size : 16} className={props.className} />
|
||||
)
|
||||
pluginSettingsIconCache.set(key, Icon)
|
||||
return Icon
|
||||
}
|
||||
|
||||
/** 插件设置表单:按声明式 schema 渲染标准控件;值变化即时回写宿主(持久化 + 广播回推插件) */
|
||||
function PluginSettingsForm({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
onAction
|
||||
}: {
|
||||
schema: PluginSettingsSchema
|
||||
values: PluginSettingValues
|
||||
onChange: (key: string, value: unknown) => void
|
||||
onAction: (key: string) => void
|
||||
}): ReactNode {
|
||||
const [picking, setPicking] = useState(false)
|
||||
// image 控件缩略图(file:// 在 dev http 源不可直接作 src,经 IPC 换 data URL;按路径缓存)
|
||||
const [imgUrls, setImgUrls] = useState<Record<string, string>>({})
|
||||
const imgUrlCache = useRef<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
for (const item of schema.items) {
|
||||
if (item.type !== 'image' || typeof values[item.key] !== 'string') continue
|
||||
const path = values[item.key] as string
|
||||
const cached = imgUrlCache.current[path]
|
||||
if (cached) {
|
||||
setImgUrls((prev) => ({ ...prev, [item.key]: cached }))
|
||||
continue
|
||||
}
|
||||
window.api
|
||||
.pluginsReadImage(path)
|
||||
.then((url) => {
|
||||
if (!alive || !url) return
|
||||
imgUrlCache.current[path] = url
|
||||
setImgUrls((prev) => ({ ...prev, [item.key]: url }))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [schema, values])
|
||||
|
||||
const pickPath = async (mode: 'file' | 'directory' | 'image', key: string): Promise<void> => {
|
||||
setPicking(true)
|
||||
try {
|
||||
const path = await window.api.pluginsPickPath(mode)
|
||||
if (path) onChange(key, path)
|
||||
} finally {
|
||||
setPicking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{schema.items.map((item, idx) => {
|
||||
const key = 'key' in item ? item.key : `__item${idx}`
|
||||
const value = values[key]
|
||||
const desc =
|
||||
'description' in item && item.description ? (
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-ink-3">{item.description}</div>
|
||||
) : null
|
||||
|
||||
if (item.type === 'divider') {
|
||||
return item.label ? (
|
||||
<div key={key} className="pt-3 text-[11.5px] font-medium text-ink-2">
|
||||
{item.label}
|
||||
</div>
|
||||
) : (
|
||||
<div key={key} className="my-2 border-t border-line" />
|
||||
)
|
||||
}
|
||||
if (item.type === 'info') {
|
||||
return (
|
||||
<div key={key} className="rounded-card bg-inset px-3 py-2 text-[12px] leading-5 text-ink-2">
|
||||
{item.label}
|
||||
{desc}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (item.type === 'button') {
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] text-ink">{item.label}</div>
|
||||
{desc}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction(item.key)}
|
||||
className={
|
||||
item.variant === 'primary'
|
||||
? 'shrink-0 rounded-control bg-accent px-3 py-1.5 text-[11.5px] font-medium text-white hover:opacity-90'
|
||||
: 'shrink-0 rounded-control border border-line-strong px-3 py-1.5 text-[11.5px] text-ink hover:bg-hover'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let control: ReactNode = null
|
||||
switch (item.type) {
|
||||
case 'string':
|
||||
control = (
|
||||
<input
|
||||
type={item.password ? 'password' : 'text'}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
placeholder={item.placeholder}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
className={`${inputCls} w-56 shrink-0`}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'text':
|
||||
return (
|
||||
<div key={key} className="rounded-card border border-line px-3 py-2.5">
|
||||
<div className="text-[12.5px] text-ink">{item.label}</div>
|
||||
{desc}
|
||||
<textarea
|
||||
rows={item.rows ?? 3}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
placeholder={item.placeholder}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
className={`${inputCls} mt-2 resize-y font-mono`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'number':
|
||||
control = (
|
||||
<input
|
||||
type="number"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
onChange={(e) => onChange(item.key, e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
className={`${inputCls} w-28 shrink-0`}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'boolean':
|
||||
control = (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value === true}
|
||||
aria-label={item.label}
|
||||
onClick={() => onChange(item.key, !(value === true))}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
|
||||
value === true ? 'bg-accent' : 'bg-line-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 size-4 rounded-full bg-white shadow transition-[left] ${value === true ? 'left-[18px]' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
break
|
||||
case 'select':
|
||||
control = (
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
className="w-44 shrink-0 rounded-control border border-line-strong bg-surface px-2 py-1.5 text-[12px] text-ink outline-none focus:border-ink-3"
|
||||
>
|
||||
{!value && <option value="">(未设置)</option>}
|
||||
{item.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
break
|
||||
case 'multiSelect': {
|
||||
const arr = Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
|
||||
control = (
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
{item.options.map((o) => {
|
||||
const on = arr.includes(o.value)
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange(item.key, on ? arr.filter((v) => v !== o.value) : [...arr, o.value])}
|
||||
className={`rounded-control border px-2 py-1 text-[11px] transition-colors ${
|
||||
on ? 'border-accent bg-accent/10 text-accent' : 'border-line text-ink-3 hover:text-ink-2'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'slider':
|
||||
control = (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step ?? 1}
|
||||
value={typeof value === 'number' ? value : item.min}
|
||||
onChange={(e) => onChange(item.key, Number(e.target.value))}
|
||||
className="w-36"
|
||||
/>
|
||||
<span className="w-10 text-right font-mono text-[11px] text-ink-3">
|
||||
{typeof value === 'number' ? value : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'color':
|
||||
control = (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-mono text-[11px] text-ink-3">{typeof value === 'string' ? value : '—'}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888'}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
className="h-7 w-9 cursor-pointer rounded-control border border-line bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'path':
|
||||
case 'image': {
|
||||
const mode = item.type === 'image' ? 'image' : item.mode
|
||||
control = (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{item.type === 'image' && imgUrls[item.key] && (
|
||||
<img src={imgUrls[item.key]} alt="" className="h-8 w-8 rounded-control border border-line object-cover" />
|
||||
)}
|
||||
<span
|
||||
className="max-w-44 truncate font-mono text-[11px] text-ink-3"
|
||||
title={typeof value === 'string' ? value : undefined}
|
||||
>
|
||||
{typeof value === 'string' && value ? value : ('placeholder' in item && item.placeholder) || '未设置'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={picking}
|
||||
onClick={() => void pickPath(mode, item.key)}
|
||||
className="shrink-0 rounded-control border border-line-strong px-2 py-1 text-[11px] text-ink hover:bg-hover disabled:opacity-50"
|
||||
>
|
||||
选择
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] text-ink">{item.label}</div>
|
||||
{desc}
|
||||
</div>
|
||||
{control}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 系统提示词预览:整段 pi 提示词是大文本,memo 化避免弹窗内任意状态变化触发重渲染;挂载耗时靠过渡提交兜住 */
|
||||
const PromptPreview = memo(function PromptPreview({ loading, text }: { loading: boolean; text: string }): ReactNode {
|
||||
return (
|
||||
@@ -774,6 +1103,50 @@ export function SettingsModal({
|
||||
onClose: () => void
|
||||
}): JSX.Element | null {
|
||||
const [tab, setTab] = useState<Tab>('model')
|
||||
// ---- 插件设置(声明式 schema):plugins 列表 + schemas/values 快照(事件驱动刷新) ----
|
||||
const plugins = usePluginList()
|
||||
const [settingSchemas, setSettingSchemas] = useState<PluginSettingsSchemas>({})
|
||||
const [settingValues, setSettingValues] = useState<PluginSettingsValuesMap>({})
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let alive = true
|
||||
const loadSchemas = (): void => {
|
||||
window.api
|
||||
.pluginsSettingsSchemas()
|
||||
.then((s) => {
|
||||
if (alive) setSettingSchemas(s)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const loadValues = (): void => {
|
||||
window.api
|
||||
.pluginsSettingsValues()
|
||||
.then((v) => {
|
||||
if (alive) setSettingValues(v)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
loadSchemas()
|
||||
loadValues()
|
||||
const offSchemas = window.api.onPluginsSettingsSchemas(loadSchemas)
|
||||
const offValues = window.api.onPluginsSettingsValues(loadValues)
|
||||
return () => {
|
||||
alive = false
|
||||
offSchemas()
|
||||
offValues()
|
||||
}
|
||||
}, [open])
|
||||
// 已声明设置表单且打开了一级入口的插件 → 「Agent 能力」组追加导航项
|
||||
const navGroups = useMemo(() => {
|
||||
const pluginItems = plugins
|
||||
.filter((p) => p.enabled && p.hasSettings && p.settingsNav)
|
||||
.map((p) => ({
|
||||
id: `plugin:${p.manifest.id}` as Tab,
|
||||
label: p.manifest.name,
|
||||
icon: pluginSettingsIcon(p.manifest.id, p.manifest.icon)
|
||||
}))
|
||||
return NAV.map((g) => (g.group === 'Agent 能力' ? { ...g, items: [...g.items, ...pluginItems] } : g))
|
||||
}, [plugins])
|
||||
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -949,7 +1322,20 @@ export function SettingsModal({
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const meta = TAB_META[tab]
|
||||
const meta = tab.startsWith('plugin:')
|
||||
? {
|
||||
title: `${plugins.find((p) => `plugin:${p.manifest.id}` === tab)?.manifest.name ?? '插件'} 设置`,
|
||||
desc: ''
|
||||
}
|
||||
: TAB_META[tab as Exclude<Tab, PluginSettingsTab>]
|
||||
|
||||
const handleSetSettingValue = (pluginId: string, key: string, value: unknown): void => {
|
||||
setSettingValues((prev) => ({ ...prev, [pluginId]: { ...(prev[pluginId] ?? {}), [key]: value } }))
|
||||
void window.api.pluginsSetSettingValue(pluginId, key, value)
|
||||
}
|
||||
const handleSetSettingsNav = (pluginId: string, on: boolean): void => {
|
||||
void window.api.pluginsSetSettingsNav(pluginId, on)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25" onClick={onClose}>
|
||||
@@ -960,7 +1346,7 @@ export function SettingsModal({
|
||||
>
|
||||
{/* 左侧:分组导航 */}
|
||||
<nav className="w-[196px] shrink-0 overflow-y-auto border-r border-line bg-inset/70 p-2.5">
|
||||
{NAV.map((group) => (
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.group} className="mb-2">
|
||||
<div className="px-2 pb-1 pt-2 text-[11px] font-medium text-ink-3">{group.group}</div>
|
||||
{group.items.map(({ id, label, icon: Icon }) => (
|
||||
@@ -1058,9 +1444,34 @@ export function SettingsModal({
|
||||
onToggle={togglePlugin}
|
||||
devBuild={import.meta.env.DEV}
|
||||
onOpenDir={(path) => void window.api.openPath(path)}
|
||||
onOpenSettings={(pluginId) => setTab(`plugin:${pluginId}`)}
|
||||
onNavToggle={handleSetSettingsNav}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab.startsWith('plugin:') &&
|
||||
(() => {
|
||||
const pid = tab.slice(7)
|
||||
const schema = settingSchemas[pid]
|
||||
if (!schema || schema.items.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
|
||||
该插件未提供配置项(或插件已停用 / 移除)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
<PluginSettingsForm
|
||||
schema={schema}
|
||||
values={settingValues[pid] ?? {}}
|
||||
onChange={(key, value) => handleSetSettingValue(pid, key, value)}
|
||||
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{tab === 'prompt' && (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<PromptPreview loading={promptLoading} text={prompt} />
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
WorkspaceFile,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
|
||||
|
||||
export type {
|
||||
AgentUiEvent,
|
||||
@@ -120,6 +121,15 @@ declare global {
|
||||
pluginsUiState: () => Promise<PluginsUiState>
|
||||
pluginsDirs: () => Promise<{ user: string[]; project: string[]; global: string[] }>
|
||||
pluginsReadRenderer: (id: string) => Promise<string>
|
||||
pluginsSettingsSchemas: () => Promise<PluginSettingsSchemas>
|
||||
pluginsSettingsValues: () => Promise<PluginSettingsValuesMap>
|
||||
pluginsSetSettingValue: (id: string, key: string, value: unknown) => Promise<void>
|
||||
pluginsSetSettingsNav: (id: string, on: boolean) => Promise<void>
|
||||
pluginsSettingsAction: (id: string, key: string) => Promise<void>
|
||||
pluginsPickPath: (mode: 'file' | 'directory' | 'image') => Promise<string | null>
|
||||
pluginsReadImage: (path: string) => Promise<string | null>
|
||||
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
|
||||
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
|
||||
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
|
||||
pluginsReload: () => Promise<void>
|
||||
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
|
||||
|
||||
Reference in New Issue
Block a user