mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(ui): 界面缩放改用 transform scale — 修复缩放非 100% 时图表鼠标命中偏移
This commit is contained in:
+20
-9
@@ -5,7 +5,6 @@
|
||||
<script>
|
||||
// 预渲染主题脚本: 首屏前设置 html.dark, 避免亮暗闪烁 (FOUC)。
|
||||
// 默认暗色; 用户切过亮色后 (localStorage tf-theme=light) 保持亮色。
|
||||
// 同步应用页面缩放 (tf-page-zoom, 设置→系统→界面缩放), 避免加载后尺寸跳变。
|
||||
(function () {
|
||||
try {
|
||||
if (localStorage.getItem('tf-theme') !== 'light') {
|
||||
@@ -14,14 +13,6 @@
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
try {
|
||||
var raw = localStorage.getItem('tf-page-zoom')
|
||||
var z = raw == null ? 0.9 : JSON.parse(raw)
|
||||
if (typeof z !== 'number' || z < 0.8 || z > 1.5) z = 0.9
|
||||
document.documentElement.style.zoom = String(z)
|
||||
} catch (e) {
|
||||
document.documentElement.style.zoom = '0.9'
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -32,6 +23,26 @@
|
||||
</head>
|
||||
<body class="bg-base text-foreground">
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// 预渲染缩放脚本: #root transform scale (方案详见 src/lib/pageZoom.ts —
|
||||
// 不用 CSS zoom, zoom≠1 会让 ECharts 鼠标命中偏移)。
|
||||
// 放在 #root 之后、main.tsx (module 延迟执行) 之前: 首帧绘制前生效, 无尺寸跳变。
|
||||
(function () {
|
||||
var z = 1
|
||||
try {
|
||||
var raw = localStorage.getItem('tf-page-zoom')
|
||||
var v = raw == null ? 1 : JSON.parse(raw)
|
||||
if (typeof v === 'number' && v >= 0.8 && v <= 1.5) z = v
|
||||
} catch (e) { /* 解析失败保底不缩放 */ }
|
||||
if (z !== 1) {
|
||||
var root = document.getElementById('root')
|
||||
root.style.transformOrigin = '0 0'
|
||||
root.style.transform = 'scale(' + z + ')'
|
||||
root.style.width = (100 / z) + '%'
|
||||
root.style.height = (100 / z) + '%'
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useToggleRealtimeQuotes,
|
||||
} from '@/lib/useSharedMutations'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { getPageScale } from '@/lib/pageZoom'
|
||||
import { compareByDefaultNav, defaultNavIndex, seedHiddenForNewUser } from '@/lib/navOrder'
|
||||
import {
|
||||
Settings,
|
||||
@@ -228,10 +229,14 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine
|
||||
const openPop = () => {
|
||||
window.clearTimeout(closeTimer.current)
|
||||
const rect = linkRef.current?.getBoundingClientRect()
|
||||
if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 })
|
||||
// rect 是视觉坐标, fixed 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale 换算。
|
||||
if (rect) {
|
||||
const z = getPageScale()
|
||||
setPopPos({ left: rect.right / z, top: (rect.top + rect.height / 2) / z })
|
||||
}
|
||||
}
|
||||
const closePop = () => {
|
||||
closeTimer.current = window.setTimeout(() => setPopPos(null), 80)
|
||||
window.setTimeout(() => setPopPos(null), 80)
|
||||
}
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), [])
|
||||
useLayoutEffect(() => {
|
||||
@@ -239,7 +244,7 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine
|
||||
const h = popRef.current.offsetHeight
|
||||
const margin = 8
|
||||
const minCenter = margin + h / 2
|
||||
const maxCenter = window.innerHeight - margin - h / 2
|
||||
const maxCenter = window.innerHeight / getPageScale() - margin - h / 2
|
||||
const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top))
|
||||
if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped })
|
||||
}, [popPos])
|
||||
|
||||
@@ -16,7 +16,24 @@
|
||||
* 非编辑状态下布局意外写回)。
|
||||
*/
|
||||
import { useCallback, useMemo, type ReactNode } from 'react'
|
||||
import GridLayout, { useContainerWidth, type Layout } from 'react-grid-layout'
|
||||
import GridLayout, { useContainerWidth, setTransform, type Layout } from 'react-grid-layout'
|
||||
import { getPageScale } from '@/lib/pageZoom'
|
||||
|
||||
/** RGL 2.x 的「容器被 transform: scale 缩放」定位策略 (包根未导出 createScaledStrategy,
|
||||
* 按 dist 内官方实现等价构造): 定位走 transform, 拖拽像素位移按 scale 换算回布局坐标。
|
||||
* 界面缩放挂在 #root (见 lib/pageZoom.ts), dashboard 网格在缩放层内, 不补偿则
|
||||
* 缩放≠100% 时拖拽跟手速度偏差 1/scale。 */
|
||||
function scaledStrategy(scale: number) {
|
||||
return {
|
||||
type: 'transform' as const,
|
||||
scale,
|
||||
calcStyle: (pos: Parameters<typeof setTransform>[0]) => setTransform(pos),
|
||||
calcDragPosition: (clientX: number, clientY: number, offsetX: number, offsetY: number) => ({
|
||||
left: (clientX - offsetX) / scale,
|
||||
top: (clientY - offsetY) / scale,
|
||||
}),
|
||||
}
|
||||
}
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import './grid.css'
|
||||
import { X } from 'lucide-react'
|
||||
@@ -93,6 +110,7 @@ export function DashboardGrid({ ctx, items, onItemsChange, editing }: DashboardG
|
||||
<GridLayout
|
||||
width={width}
|
||||
layout={rglLayout}
|
||||
positionStrategy={scaledStrategy(getPageScale())}
|
||||
gridConfig={{ cols: GRID_COLS, rowHeight: GRID_ROW_HEIGHT, margin: [GRID_MARGIN, GRID_MARGIN], containerPadding: null, maxRows: Infinity }}
|
||||
dragConfig={{
|
||||
enabled: editing,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore'
|
||||
import { getPageScale } from '@/lib/pageZoom'
|
||||
import type { ActiveTask } from '@/lib/aiReportStore'
|
||||
|
||||
/**
|
||||
@@ -42,8 +43,10 @@ export function AiReportBubble() {
|
||||
}, [])
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight - 80
|
||||
// innerWidth 是视觉像素, translate3d 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale。
|
||||
const z = getPageScale()
|
||||
const maxX = window.innerWidth / z - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight / z - 80
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
|
||||
@@ -61,8 +64,10 @@ export function AiReportBubble() {
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const dx = e.clientX - dragData.current.mx
|
||||
const dy = e.clientY - dragData.current.my
|
||||
// clientX 是视觉像素, 位移需 ÷ scale 换算回布局坐标, 否则缩放下拖拽跟手速度偏差 1/z。
|
||||
const z = getPageScale()
|
||||
const dx = (e.clientX - dragData.current.mx) / z
|
||||
const dy = (e.clientY - dragData.current.my) / z
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
|
||||
const nx = dragData.current.ox + dx
|
||||
const ny = dragData.current.oy + dy
|
||||
@@ -232,8 +237,10 @@ function BubbleItem({ task, isLast, onPointerDown }: {
|
||||
const POS_KEY = 'ai_bubble_pos'
|
||||
function loadPos(): { x: number; y: number } {
|
||||
// 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素)
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200)
|
||||
// innerWidth 视觉像素 ÷ scale = #root 布局坐标 (缩放层)。
|
||||
const z = getPageScale()
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth / z - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight / z - 200)
|
||||
try {
|
||||
const v = localStorage.getItem(POS_KEY)
|
||||
if (v) {
|
||||
@@ -241,8 +248,8 @@ function loadPos(): { x: number; y: number } {
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') {
|
||||
// 钳制到当前视口(防止保存的位置在缩小后的窗口外)
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth / z - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight / z - 80, p.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
|
||||
import type { ActiveTask } from '@/lib/stockAnalysisStore'
|
||||
import { getPageScale } from '@/lib/pageZoom'
|
||||
|
||||
/**
|
||||
* AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
|
||||
@@ -30,8 +31,10 @@ export function StockAnalysisBubble() {
|
||||
}, [])
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight - 80
|
||||
// innerWidth 是视觉像素, translate3d 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale。
|
||||
const z = getPageScale()
|
||||
const maxX = window.innerWidth / z - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight / z - 80
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
|
||||
@@ -49,8 +52,10 @@ export function StockAnalysisBubble() {
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const dx = e.clientX - dragData.current.mx
|
||||
const dy = e.clientY - dragData.current.my
|
||||
// clientX 是视觉像素, 位移需 ÷ scale 换算回布局坐标, 否则缩放下拖拽跟手速度偏差 1/z。
|
||||
const z = getPageScale()
|
||||
const dx = (e.clientX - dragData.current.mx) / z
|
||||
const dy = (e.clientY - dragData.current.my) / z
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
|
||||
const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
|
||||
applyTransform(c.x, c.y)
|
||||
@@ -186,16 +191,18 @@ function BubbleItem({ task, isLast, onPointerDown }: {
|
||||
const POS_KEY = 'sa_bubble_pos'
|
||||
function loadPos(): { x: number; y: number } {
|
||||
// 默认右下,偏上一点(避开财务胶囊的右下位置)
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
|
||||
// innerWidth 视觉像素 ÷ scale = #root 布局坐标 (缩放层)。
|
||||
const z = getPageScale()
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth / z - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight / z - 320)
|
||||
try {
|
||||
const v = localStorage.getItem(POS_KEY)
|
||||
if (v) {
|
||||
const p = JSON.parse(v)
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') {
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth / z - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight / z - 80, p.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,8 @@ export function AssistantDrawer() {
|
||||
<DrawerPanel key="assistant-drawer" sending={sending} messages={messages} />
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
// #root (缩放层): 抽屉随界面缩放, 且与 Modal 同层叠上下文保持 z 序设计。
|
||||
document.getElementById('root') ?? document.body,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { getPageScale } from '@/lib/pageZoom'
|
||||
import { toggleAssistant, useAssistantStore } from '../store'
|
||||
|
||||
const POS_STORAGE_KEY = 'assistant.fab.pos.v1'
|
||||
@@ -21,12 +22,11 @@ interface BallPos {
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 页面 CSS zoom (html 上): 视觉坐标(getBoundingClientRect/clientX) 与
|
||||
* 布局坐标(style.left) 在 zoom≠1 时相差 zoom 倍, 拖拽换算必须经过它,
|
||||
/** 页面缩放 (#root transform scale): 视觉坐标(getBoundingClientRect/clientX) 与
|
||||
* 布局坐标(style.left) 在 scale≠1 时相差 scale 倍, 拖拽换算必须经过它,
|
||||
* 否则 90% 缩放下每次拖动球会向左上跳约 11%。 */
|
||||
function pageZoom(): number {
|
||||
const v = parseFloat(getComputedStyle(document.documentElement).zoom)
|
||||
return Number.isFinite(v) && v > 0 ? v : 1
|
||||
return getPageScale()
|
||||
}
|
||||
|
||||
function clampPos(pos: BallPos): BallPos {
|
||||
@@ -124,8 +124,9 @@ export function AssistantFloatingButton() {
|
||||
toggleAssistant()
|
||||
}
|
||||
|
||||
// Portal 到 body: 挂载点在侧栏 aside 内, 移动端 aside 带 transform,
|
||||
// 会让 fixed 相对 aside 定位并被 overflow-hidden 裁剪。
|
||||
// Portal 到 #root (缩放层): 与 Modal 同层叠上下文, z 序不变 (弹窗 z-50 盖住球 z-30)。
|
||||
// 挂载点在侧栏 aside 内, 移动端 aside 带 transform 会让 fixed 相对 aside 定位并被
|
||||
// overflow-hidden 裁剪; #root 的 transform 同理, 但 #root 占满视口且不裁剪, 等价于视口。
|
||||
return createPortal(
|
||||
<motion.button
|
||||
ref={ballRef}
|
||||
@@ -161,6 +162,6 @@ export function AssistantFloatingButton() {
|
||||
/>
|
||||
)}
|
||||
</motion.button>,
|
||||
document.body,
|
||||
document.getElementById('root') ?? document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ html.dark {
|
||||
/* 默认 1px 细分隔线(暗色用边框区分层次,不用 shadow) */
|
||||
* { border-color: hsl(var(--border)); }
|
||||
|
||||
/* 页面缩放 (设置→系统→界面缩放) 用 CSS zoom 作用于 <html>:
|
||||
* vh 单位不随 zoom 缩放 (zoom<1 底部露底 / zoom>1 溢出), 百分比高度链才会 —
|
||||
* 根链路统一 % 高度, 主布局与加载占位用 h-full/min-h-full 而非 h-screen。 */
|
||||
/* 页面缩放 (设置→系统→界面缩放) 用 #root transform: scale 实现 (见 lib/pageZoom.ts):
|
||||
* #root 宽高按 1/z 百分比补偿, 根链路必须是 % 高度链 (vh 不参与换算) —
|
||||
* 主布局与加载占位用 h-full/min-h-full 而非 h-screen。 */
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
@@ -84,6 +84,12 @@ body {
|
||||
}
|
||||
|
||||
/* 选区用主色 (默认蓝底破坏暗色氛围) */
|
||||
/* 联想下拉 (SuggestInput portal) 进入动画: 2px 上浮 + 淡入, 120ms 快进快出 */
|
||||
@keyframes sg-drop-in {
|
||||
from { opacity: 0; transform: translateY(-2px) scale(0.995); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--accent) / 0.3);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/**
|
||||
* 页面整体缩放 — 桌面客户端 (pywebview) 里没有浏览器缩放快捷键,
|
||||
* 由 设置→系统→界面缩放 控制。CSS zoom 作用于 <html>, 全组件等比缩放。
|
||||
* 由 设置→系统→界面缩放 控制。
|
||||
*
|
||||
* - 值域 0.8–1.5, 步进 0.05, localStorage 持久化 (storage.pageZoom); 默认 0.9。
|
||||
* - index.html 的预渲染脚本会在首屏前应用, 避免加载后尺寸跳变。
|
||||
* 实现: #root 上 transform: scale(z) + origin 0 0 + 宽高按 1/z 补偿。
|
||||
* 不用 CSS zoom: zoom≠1 时视觉坐标 (clientX/rect) 与布局坐标分离,
|
||||
* ECharts/zrender 的鼠标命中不做换算 (echarts#15103), 十字光标/tooltip
|
||||
* 会偏移约 (1-1/z); 而 transform 下浏览器规范对 offsetX 自动逆变换回
|
||||
* 元素本地坐标系, 图表与拖拽类库零改动即正确。
|
||||
*
|
||||
* - 值域 0.8–1.5, 步进 0.05, localStorage 持久化 (storage.pageZoom); 默认 1。
|
||||
* - JS 定位的浮层 (#root 内 fixed/absolute) 需把视觉坐标换算回布局坐标:
|
||||
* 统一用 getPageScale() 相除 (÷ scale)。
|
||||
* - index.html 在 #root 之后有预渲染内联脚本, 首帧绘制前应用, 无跳变。
|
||||
* - 应用后派发 window resize, echarts 等监听 resize 的图表随之重排。
|
||||
* - z=1 时清空 transform/宽高, 与未开启缩放的状态完全一致。
|
||||
*/
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
@@ -12,22 +21,49 @@ export const ZOOM_MIN = 0.8
|
||||
export const ZOOM_MAX = 1.5
|
||||
export const ZOOM_STEP = 0.05
|
||||
|
||||
export const ZOOM_DEFAULT = 0.9
|
||||
export const ZOOM_DEFAULT = 1
|
||||
|
||||
function clamp(v: number): number {
|
||||
if (!Number.isFinite(v)) return ZOOM_DEFAULT
|
||||
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(v * 100) / 100))
|
||||
}
|
||||
|
||||
/** 把缩放应用到 <html> 并通知图表重排, 返回 clamp 后的实际值。 */
|
||||
/** 把缩放应用到 #root 并通知图表重排, 返回 clamp 后的实际值。 */
|
||||
export function applyPageZoom(v: number): number {
|
||||
const z = clamp(v)
|
||||
document.documentElement.style.zoom = String(z)
|
||||
const root = document.getElementById('root')
|
||||
if (root) {
|
||||
if (z === 1) {
|
||||
root.style.transform = ''
|
||||
root.style.width = ''
|
||||
root.style.height = ''
|
||||
} else {
|
||||
root.style.transformOrigin = '0 0'
|
||||
root.style.transform = `scale(${z})`
|
||||
// 布局占位 = 视口/z, 缩放后视觉正好铺满视口; 不补偿会留白 (z<1) 或溢出 (z>1)。
|
||||
root.style.width = `${100 / z}%`
|
||||
root.style.height = `${100 / z}%`
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
return z
|
||||
}
|
||||
|
||||
/** 读取持久化的缩放值 (越界/缺失回落默认 0.9)。 */
|
||||
/** 当前生效的缩放 (读 #root transform 矩阵, 未缩放时 1)。
|
||||
* 浮层定位/拖拽做视觉→布局坐标换算时用。 */
|
||||
export function getPageScale(): number {
|
||||
const root = document.getElementById('root')
|
||||
const m = root ? getComputedStyle(root).transform : ''
|
||||
if (m && m !== 'none') {
|
||||
try {
|
||||
const s = new DOMMatrixReadOnly(m).a
|
||||
if (Number.isFinite(s) && s > 0) return s
|
||||
} catch { /* matrix 解析失败按未缩放处理 */ }
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
/** 读取持久化的缩放值 (越界/缺失回落默认 1)。 */
|
||||
export function getPageZoom(): number {
|
||||
return clamp(storage.pageZoom.get(ZOOM_DEFAULT))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user