优化: Provider 配置体验(测试读表单值 + 保存反馈 + 报错友好 + 厂商预设,部分完成)
This commit is contained in:
@@ -365,6 +365,53 @@ pub async fn ai_fetch_models(
|
|||||||
Ok(merged)
|
Ok(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一次性测试连接(P0-1):用**表单当前值**而非 DB 值测试,不写库。
|
||||||
|
///
|
||||||
|
/// 解决:`ai_fetch_models` 从 DB 读 provider,用户改了表单未保存就点「测试连接」时
|
||||||
|
/// 测的是旧值(base_url/api_key 已改但 DB 未落),给出误导性「成功/失败」。
|
||||||
|
///
|
||||||
|
/// 行为:
|
||||||
|
/// - api_key 非空 → 直接用表单值(base_url + api_key)测,完全不读 DB。
|
||||||
|
/// - api_key 空 + provider_id 给出 → 编辑态用户没重填密钥(留空=不改),
|
||||||
|
/// 从 DB 取该 provider 记录经 FR-S1 内存解析出真实 key,base_url 仍用表单值
|
||||||
|
/// (用户可能改了 URL,密钥沿用旧)。
|
||||||
|
/// - api_key 空 + provider_id 空 → 新建态没填 key,直接报错(无来源可解析)。
|
||||||
|
///
|
||||||
|
/// 一次性:不写回 model_configs(测试≠落库);成功后用户可继续编辑或保存。
|
||||||
|
/// 保留 `ai_fetch_models`(从 DB 读 + 写回,用于「已保存配置重测」场景),本 IPC 不破坏它。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_test_connection(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider_type: String,
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
) -> Result<Vec<ModelConfig>, String> {
|
||||||
|
// 解析测试用 api_key:表单非空用表单;空则从 DB provider 沿用旧密钥(编辑不改 key 场景)
|
||||||
|
let api_key = if !api_key.is_empty() {
|
||||||
|
api_key
|
||||||
|
} else {
|
||||||
|
let pid = provider_id.ok_or_else(|| {
|
||||||
|
"测试连接失败:api_key 为空且无已保存配置可沿用,请填写 api_key".to_string()
|
||||||
|
})?;
|
||||||
|
let provider = state
|
||||||
|
.ai_providers
|
||||||
|
.get_by_id(&pid)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.ok_or_else(|| format!("提供商不存在: {}", pid))?;
|
||||||
|
// FR-S1:内存解析,绝不外泄(不入日志/返回值/错误信息)
|
||||||
|
df_storage::secret::resolve_provider_secret(&provider)
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetch_type = normalize_provider_type_for_fetch(&provider_type);
|
||||||
|
let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &base_url, &api_key)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
|
||||||
|
Ok(probed)
|
||||||
|
}
|
||||||
|
|
||||||
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
|
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
|
||||||
///
|
///
|
||||||
/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。
|
/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。
|
||||||
|
|||||||
@@ -442,6 +442,8 @@ pub fn run() {
|
|||||||
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
|
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
|
||||||
commands::ai::ai_fetch_models,
|
commands::ai::ai_fetch_models,
|
||||||
commands::ai::ai_probe_model,
|
commands::ai::ai_probe_model,
|
||||||
|
// P0-1:用表单当前值一次性测试连接(不写库,新建/编辑未保存均可测)
|
||||||
|
commands::ai::ai_test_connection,
|
||||||
// AI 对话管理
|
// AI 对话管理
|
||||||
commands::ai::ai_conversation_create,
|
commands::ai::ai_conversation_create,
|
||||||
commands::ai::ai_conversation_list,
|
commands::ai::ai_conversation_list,
|
||||||
|
|||||||
@@ -204,6 +204,27 @@ export const aiApi = {
|
|||||||
return invoke('ai_fetch_models', { providerId })
|
return invoke('ai_fetch_models', { providerId })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性测试连接(P0-1):用**表单当前值**测试,不写库。
|
||||||
|
* - 新建态:传表单填的 providerType/baseUrl/apiKey,providerId=null。
|
||||||
|
* - 编辑态(apiKey 留空=不改):传 providerId 让后端从 DB 沿用旧密钥;
|
||||||
|
* apiKey 非空则直接用表单新值。
|
||||||
|
* 成功返回模型列表(ModelConfig,不含 api_key),失败抛错。
|
||||||
|
*/
|
||||||
|
testConnection(input: {
|
||||||
|
providerType: string
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
providerId?: string | null
|
||||||
|
}): Promise<ModelConfig[]> {
|
||||||
|
return invoke('ai_test_connection', {
|
||||||
|
providerType: input.providerType,
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
apiKey: input.apiKey,
|
||||||
|
providerId: input.providerId ?? null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单模型探测:纯 CPU 启发式 + 预设表,无网络。
|
* 单模型探测:纯 CPU 启发式 + 预设表,无网络。
|
||||||
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
|
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
|
||||||
|
|||||||
@@ -790,6 +790,8 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||||
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||||
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||||
|
/* P0-2: ToastType 新增 'success' 兜底样式 */
|
||||||
|
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||||
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||||
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ import SettingRow from './SettingRow.vue'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
|
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
|
||||||
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>()
|
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
||||||
|
|
||||||
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
|
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
|
||||||
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
|
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ const { t } = useI18n()
|
|||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void
|
// P0-2: 对齐 ToastType 加 'success'(本面板暂未用,保类型一致)
|
||||||
|
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -87,14 +87,19 @@
|
|||||||
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
|
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
|
||||||
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
|
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
|
||||||
|
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
|
||||||
|
用户看到错误同时表单保持编辑态,改完点保存即可重试。 -->
|
||||||
|
<div v-if="providerForm.saveError" class="save-error-banner">
|
||||||
|
<pre class="save-error-msg">{{ providerForm.saveError }}</pre>
|
||||||
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
|
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
|
||||||
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
|
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost" @click="fetchModels" :disabled="providerForm.fetching || !providerForm.editId">
|
<button class="btn btn-ghost" @click="fetchModels" :disabled="providerForm.fetching || !canTestConnection">
|
||||||
{{ providerForm.fetching ? $t('settings.fetchingModels') : $t('settings.testConnection') }}
|
{{ providerForm.fetching ? $t('settings.fetchingModels') : $t('settings.testConnection') }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="!providerForm.editId" class="form-hint">{{ $t('settings.fetchHintSaveFirst') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ -->
|
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ -->
|
||||||
@@ -127,7 +132,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { reactive, ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import type { AiProviderConfig, ModelConfig } from '@/api/types'
|
import type { AiProviderConfig, ModelConfig } from '@/api/types'
|
||||||
@@ -148,7 +153,8 @@ async function syncAiStoreProviders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void
|
// P0-2: 加 'success'(保存成功绿色 toast)
|
||||||
|
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
|
||||||
(e: 'providers-changed'): void
|
(e: 'providers-changed'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -172,6 +178,10 @@ const providerForm = reactive({
|
|||||||
// F-01 阶段6:模型拉取 + 列表
|
// F-01 阶段6:模型拉取 + 列表
|
||||||
fetching: false,
|
fetching: false,
|
||||||
models: [] as ModelConfig[],
|
models: [] as ModelConfig[],
|
||||||
|
// P0-2: 保存失败的持久错误横幅(非自隐 toast)。
|
||||||
|
// toast 3s 自隐易被忽略,保存失败时表单内常驻错误条 + 可操作建议,
|
||||||
|
// 直到下次保存尝试/重开表单才清除。null=无错误。
|
||||||
|
saveError: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadProviders() {
|
async function loadProviders() {
|
||||||
@@ -207,25 +217,44 @@ function openProviderForm(p?: AiProviderConfig) {
|
|||||||
providerForm.models = []
|
providerForm.models = []
|
||||||
}
|
}
|
||||||
providerForm.fetching = false
|
providerForm.fetching = false
|
||||||
|
providerForm.saveError = null // P0-2: 重开表单清旧错误横幅
|
||||||
providerForm.visible = true
|
providerForm.visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试连接并拉取模型列表(F-01 阶段6)。
|
* 测试连接按钮可用条件(P0-1):
|
||||||
* 后端 ai_fetch_models:DB 取 provider + 内存解析 api_key(FR-S1)+ 网络拉取 + 探测 4 维度 + 写回。
|
* - baseUrl 必填(无论新建/编辑,表单值都要有)
|
||||||
* 新建态(editId 空)无 provider_id → 按钮已 disabled,提示先保存。
|
* - 新建态(editId 空):api_key 必填(无已保存配置可沿用)
|
||||||
|
* - 编辑态(editId 有):api_key 可空(留空=沿用已保存密钥)
|
||||||
|
*/
|
||||||
|
const canTestConnection = computed(() => {
|
||||||
|
if (!providerForm.baseUrl) return false
|
||||||
|
if (!providerForm.editId && !providerForm.apiKey) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试连接并拉取模型列表(P0-1)。
|
||||||
|
* 用**表单当前值**测试(非 DB 值):新建态直接传表单值;编辑态传表单值 + editId,
|
||||||
|
* apiKey 留空时后端从已保存配置沿用密钥。一次性测试不写库。
|
||||||
*
|
*
|
||||||
* enabled toggle / weight input 为本地会话内编辑(仅本批展示交互,持久化另见 issues);
|
* 注:本路径成功后仅更新 model_configs 视图,不落库;用户需点「保存」才会持久化
|
||||||
* 下次「测试连接」会以最新探测结果覆盖 model_configs 落库。
|
* (避免「测试中」产生半成品配置)。原 ai_fetch_models(从 DB 读 + 写回)保留,
|
||||||
|
* 供未来「已保存配置重测」场景,当前 UI 不再走它。
|
||||||
*/
|
*/
|
||||||
async function fetchModels() {
|
async function fetchModels() {
|
||||||
if (!providerForm.editId) {
|
if (!canTestConnection.value) {
|
||||||
emit('toast', t('settings.fetchHintSaveFirst'), 'warning')
|
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
providerForm.fetching = true
|
providerForm.fetching = true
|
||||||
try {
|
try {
|
||||||
const models = await aiApi.fetchModels(providerForm.editId)
|
const models = await aiApi.testConnection({
|
||||||
|
providerType: providerForm.providerType,
|
||||||
|
baseUrl: providerForm.baseUrl,
|
||||||
|
apiKey: providerForm.apiKey,
|
||||||
|
providerId: providerForm.editId || null,
|
||||||
|
})
|
||||||
providerForm.models = models.map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
|
providerForm.models = models.map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
|
||||||
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
|
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -241,6 +270,29 @@ async function fetchModels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0-2: 把后端返回的错误文本分类成「用户可操作的建议」。
|
||||||
|
*
|
||||||
|
* 后端 commands/provider.rs 已对密钥相关失败返回友好中文 Err(含原因+建议),
|
||||||
|
* 这里只做轻量分类挂上"建议下一步",避免裸 Err 文本吓到用户:
|
||||||
|
* - keyring/迁移类:后端 Err 含「钥匙串」/「迁移」→ 指引查系统钥匙串权限
|
||||||
|
* - 其余(DB/字段/未知):透传后端文案,笼统提示重试
|
||||||
|
*
|
||||||
|
* 注:不依赖具体 Err 子串判定成败(后端 Err 语义=失败),分类仅用于建议文案。
|
||||||
|
*/
|
||||||
|
function classifySaveError(raw: string): { title: string; detail: string } {
|
||||||
|
if (raw.includes('钥匙串') || raw.includes('迁移')) {
|
||||||
|
return {
|
||||||
|
title: t('settings.saveErrKeyringTitle'),
|
||||||
|
detail: t('settings.saveErrKeyringDetail', { msg: raw }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: t('settings.saveErrGenericTitle'),
|
||||||
|
detail: t('settings.saveErrGenericDetail', { msg: raw }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProvider() {
|
async function saveProvider() {
|
||||||
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
|
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
|
||||||
const needKey = !providerForm.editId
|
const needKey = !providerForm.editId
|
||||||
@@ -259,12 +311,19 @@ async function saveProvider() {
|
|||||||
defaultModel: providerForm.defaultModel,
|
defaultModel: providerForm.defaultModel,
|
||||||
modelConfigs: providerForm.models,
|
modelConfigs: providerForm.models,
|
||||||
})
|
})
|
||||||
|
// P0-2: 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
|
||||||
|
providerForm.saveError = null
|
||||||
providerForm.visible = false
|
providerForm.visible = false
|
||||||
await loadProviders()
|
await loadProviders()
|
||||||
emit('toast', t('settings.toastSaved'), 'info')
|
emit('toast', t('settings.toastSaved'), 'success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
|
// P0-2: 失败时①表单保持编辑态(不关)②表单内常驻错误横幅(分类建议,非自隐)
|
||||||
emit('toast', t('settings.toastSaveFail', { msg: errMsg(e) }), 'error')
|
// ③仍发 error toast(用户视线不在表单时仍可见),双通道确保反馈不被忽略。
|
||||||
|
const raw = errMsg(e)
|
||||||
|
console.error(t('settings.toastSaveFail', { msg: raw }), e)
|
||||||
|
const { title, detail } = classifySaveError(raw)
|
||||||
|
providerForm.saveError = title + '\n' + detail
|
||||||
|
emit('toast', t('settings.toastSaveFail', { msg: raw }), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
providerForm.saving = false
|
providerForm.saving = false
|
||||||
}
|
}
|
||||||
@@ -436,6 +495,23 @@ onMounted(loadProviders)
|
|||||||
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
|
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
|
||||||
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
|
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||||
|
|
||||||
|
/* P0-2: 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
|
||||||
|
.save-error-banner {
|
||||||
|
background: var(--df-danger-bg);
|
||||||
|
border: 0.5px solid var(--df-danger);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.save-error-msg {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--df-font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--df-danger);
|
||||||
|
white-space: pre-wrap; /* 保留 title\ndetail 换行 + 长建议自动折行 */
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
|
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
|
||||||
.setting-input:focus { border-color: var(--df-accent); }
|
.setting-input:focus { border-color: var(--df-accent); }
|
||||||
.setting-input--sm { min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
|
.setting-input--sm { min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
import { reactive, onUnmounted } from 'vue'
|
import { reactive, onUnmounted } from 'vue'
|
||||||
|
|
||||||
export type ToastType = 'info' | 'error' | 'warning'
|
// P0-2: 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确)
|
||||||
|
export type ToastType = 'info' | 'error' | 'warning' | 'success'
|
||||||
|
|
||||||
export interface ToastState {
|
export interface ToastState {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user