完成40%
This commit is contained in:
+319
-11
@@ -14,7 +14,7 @@ import {
|
||||
} from '@/data/mock.js'
|
||||
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
|
||||
import { AUTH_TAC_SCENE, assertSmsCode } from '@/utils/auth-verification.js'
|
||||
import { GENEALOGY_ACCESS_PRESET } from '@/utils/genealogy-contracts.js'
|
||||
import { GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess } from '@/utils/genealogy-contracts.js'
|
||||
import { session } from '@/utils/session.js'
|
||||
|
||||
const successCodes = [0, 200]
|
||||
@@ -251,6 +251,100 @@ const normalizeFeedbackPayload = (payload) => {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const normalizeMyGenealogyId = (value) => {
|
||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
|
||||
throw createRequestError('我的家谱响应包含无效标识', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
|
||||
const normalizeGenealogyPathId = (value) => {
|
||||
const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
|
||||
if (typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) {
|
||||
throw createRequestError('家谱标识无效', 'GENEALOGY_ID_INVALID')
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const normalizeOptionalGenealogyText = (value, label) => {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (typeof value !== 'string') {
|
||||
throw createRequestError(`我的家谱响应字段 ${label} 无效`, 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const normalizeAppGenealogy = (item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw createRequestError('我的家谱响应包含无效条目', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
const name = normalizeOptionalGenealogyText(item.genealogyName, 'genealogyName')
|
||||
if (!name) {
|
||||
throw createRequestError('我的家谱响应缺少家谱名称', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
for (const field of ['canManage', 'canEditContent']) {
|
||||
if (typeof item[field] !== 'boolean') {
|
||||
throw createRequestError(`我的家谱响应字段 ${field} 无效`, 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
}
|
||||
if (item.canView === false) {
|
||||
throw createRequestError('当前账号无权查看该家谱', 'GENEALOGY_FORBIDDEN')
|
||||
}
|
||||
if (!Number.isSafeInteger(item.memberCount) || item.memberCount < 0) {
|
||||
throw createRequestError('我的家谱响应成员数量无效', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
const personCount = item.personCount ?? null
|
||||
if (personCount !== null && (!Number.isSafeInteger(personCount) || personCount < 0)) {
|
||||
throw createRequestError('我的家谱响应世系人数无效', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
return {
|
||||
id: normalizeMyGenealogyId(item.genealogyId),
|
||||
name,
|
||||
surname: normalizeOptionalGenealogyText(item.surname, 'surname'),
|
||||
hall: normalizeOptionalGenealogyText(item.ancestralHall, 'ancestralHall'),
|
||||
location:
|
||||
normalizeOptionalGenealogyText(item.regionFullName, 'regionFullName') ||
|
||||
normalizeOptionalGenealogyText(item.regionName, 'regionName') ||
|
||||
normalizeOptionalGenealogyText(item.originPlace, 'originPlace') ||
|
||||
normalizeOptionalGenealogyText(item.addressDetail, 'addressDetail') ||
|
||||
'地区待补',
|
||||
memberCount: item.memberCount,
|
||||
personCount,
|
||||
accessPreset: fromApiGenealogyAccess({
|
||||
visibility: normalizeOptionalGenealogyText(item.visibility, 'visibility'),
|
||||
joinMode: normalizeOptionalGenealogyText(item.joinMode, 'joinMode')
|
||||
}),
|
||||
accessRole: item.canManage ? 'owner' : 'member',
|
||||
canManage: item.canManage,
|
||||
canEditContent: item.canEditContent,
|
||||
intro: normalizeOptionalGenealogyText(item.intro, 'intro'),
|
||||
joinTime: normalizeOptionalGenealogyText(item.joinTime, 'joinTime')
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeMyGenealogies = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw createRequestError('我的家谱响应不是列表', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
const normalized = value.map((item) => {
|
||||
const genealogy = normalizeAppGenealogy(item)
|
||||
return {
|
||||
id: genealogy.id,
|
||||
name: genealogy.name,
|
||||
surname: genealogy.surname,
|
||||
hall: genealogy.hall,
|
||||
location: genealogy.location,
|
||||
memberCount: genealogy.memberCount,
|
||||
accessRole: genealogy.accessRole,
|
||||
canManage: genealogy.canManage,
|
||||
canEditContent: genealogy.canEditContent
|
||||
}
|
||||
})
|
||||
if (new Set(normalized.map((item) => item.id)).size !== normalized.length) {
|
||||
throw createRequestError('我的家谱响应包含重复标识', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
const saveLogin = (loginResult) => {
|
||||
const token = loginResult?.access_token
|
||||
if (!token) throw new Error('登录响应未包含会话令牌')
|
||||
@@ -276,6 +370,183 @@ const toTreeNode = (person, index = 0) => {
|
||||
}
|
||||
}
|
||||
|
||||
const lineageTreeError = (message) =>
|
||||
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
|
||||
|
||||
const normalizeLineagePersonId = (value) => {
|
||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
|
||||
throw lineageTreeError('世系树包含无效人物标识')
|
||||
}
|
||||
|
||||
const normalizeLineageText = (value, label, { required = false } = {}) => {
|
||||
if (value === undefined || value === null) {
|
||||
if (required) throw lineageTreeError(`世系树缺少${label}`)
|
||||
return ''
|
||||
}
|
||||
if (typeof value !== 'string') throw lineageTreeError(`世系树${label}无效`)
|
||||
const normalized = value.trim()
|
||||
if (required && !normalized) throw lineageTreeError(`世系树缺少${label}`)
|
||||
return normalized
|
||||
}
|
||||
|
||||
const lineageDatePart = (value, label) => {
|
||||
const normalized = normalizeLineageText(value, label)
|
||||
if (!normalized) return ''
|
||||
if (!/^\d{4}-\d{2}-\d{2}/.test(normalized)) throw lineageTreeError(`世系树${label}无效`)
|
||||
return normalized.slice(0, 10)
|
||||
}
|
||||
|
||||
const normalizeLineageTree = (value) => {
|
||||
if (!Array.isArray(value)) throw lineageTreeError('世系树响应不是列表')
|
||||
const seen = new Set()
|
||||
const normalized = []
|
||||
const appendNode = (node, parentId, relationOverride = '') => {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
||||
throw lineageTreeError('世系树包含无效节点')
|
||||
}
|
||||
const id = normalizeLineagePersonId(node.personId)
|
||||
if (seen.has(id)) throw lineageTreeError('世系树包含重复人物标识')
|
||||
seen.add(id)
|
||||
if (seen.size > 5000) throw lineageTreeError('世系树节点数量超出客户端上限')
|
||||
if (!Number.isSafeInteger(node.generation) || node.generation < 1) {
|
||||
throw lineageTreeError('世系树人物世代无效')
|
||||
}
|
||||
const generationName = normalizeLineageText(node.generationName, '字辈')
|
||||
const birthDate = lineageDatePart(node.birthDate, '出生日期')
|
||||
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
|
||||
normalized.push({
|
||||
id,
|
||||
parentId,
|
||||
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
|
||||
relation:
|
||||
relationOverride ||
|
||||
normalizeLineageText(node.relationName, '人物关系') ||
|
||||
(parentId ? '后代' : '始祖'),
|
||||
generation: node.generation,
|
||||
branch: generationName
|
||||
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
|
||||
: '字辈待补',
|
||||
years:
|
||||
birthDate || deathDate
|
||||
? `${birthDate}—${deathDate}`
|
||||
: '生卒待补',
|
||||
sex: normalizeLineageText(node.sex, '性别'),
|
||||
personStatus: normalizeLineageText(node.personStatus, '人物状态')
|
||||
})
|
||||
return id
|
||||
}
|
||||
const walk = (node, parentId = null, depth = 0) => {
|
||||
if (depth > 64) throw lineageTreeError('世系树深度超出客户端上限')
|
||||
const id = appendNode(node, parentId)
|
||||
const spouses = node.spouses ?? []
|
||||
const children = node.children ?? []
|
||||
if (!Array.isArray(spouses) || !Array.isArray(children)) {
|
||||
throw lineageTreeError('世系树亲属集合无效')
|
||||
}
|
||||
spouses.forEach((spouse) => appendNode(spouse, parentId, '配偶'))
|
||||
children.forEach((child) => walk(child, id, depth + 1))
|
||||
}
|
||||
value.forEach((root) => walk(root))
|
||||
return normalized
|
||||
}
|
||||
|
||||
const lineagePersonError = (message) =>
|
||||
createRequestError(message, 'LINEAGE_PERSON_RESPONSE_INVALID')
|
||||
|
||||
const normalizeLineagePersonIdentity = (value, label) => {
|
||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
|
||||
throw lineagePersonError(`成员详情${label}无效`)
|
||||
}
|
||||
|
||||
const normalizeLineagePersonText = (value, label, { required = false } = {}) => {
|
||||
if (value === undefined || value === null) {
|
||||
if (required) throw lineagePersonError(`成员详情缺少${label}`)
|
||||
return ''
|
||||
}
|
||||
if (typeof value !== 'string') throw lineagePersonError(`成员详情${label}无效`)
|
||||
const normalized = value.trim()
|
||||
if (required && !normalized) throw lineagePersonError(`成员详情缺少${label}`)
|
||||
return normalized
|
||||
}
|
||||
|
||||
const normalizeLineagePersonDate = (value, label) => {
|
||||
const normalized = normalizeLineagePersonText(value, label)
|
||||
if (!normalized) return ''
|
||||
if (!/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(normalized)) {
|
||||
throw lineagePersonError(`成员详情${label}无效`)
|
||||
}
|
||||
const datePart = normalized.slice(0, 10)
|
||||
const parsed = new Date(`${datePart}T00:00:00Z`)
|
||||
if (
|
||||
Number.isNaN(parsed.getTime()) ||
|
||||
parsed.toISOString().slice(0, 10) !== datePart
|
||||
) {
|
||||
throw lineagePersonError(`成员详情${label}无效`)
|
||||
}
|
||||
return datePart
|
||||
}
|
||||
|
||||
const normalizeLineagePersonDetail = (
|
||||
value,
|
||||
expectedGenealogyId,
|
||||
expectedPersonId,
|
||||
) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw lineagePersonError('成员详情响应不是对象')
|
||||
}
|
||||
const genealogyId = normalizeLineagePersonIdentity(value.genealogyId, '家谱标识')
|
||||
const id = normalizeLineagePersonIdentity(value.personId, '人物标识')
|
||||
if (genealogyId !== expectedGenealogyId || id !== expectedPersonId) {
|
||||
throw lineagePersonError('成员详情响应标识与请求不匹配')
|
||||
}
|
||||
if (!Number.isSafeInteger(value.generation) || value.generation < 1) {
|
||||
throw lineagePersonError('成员详情世代无效')
|
||||
}
|
||||
const name = normalizeLineagePersonText(value.name, '姓名', { required: true })
|
||||
const generationName = normalizeLineagePersonText(value.generationName, '字辈')
|
||||
const birthDate = normalizeLineagePersonDate(value.birthDate, '出生日期')
|
||||
const deathDate = normalizeLineagePersonDate(value.deathDate, '逝世日期')
|
||||
const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态').toUpperCase()
|
||||
const relatives = []
|
||||
for (const relation of [
|
||||
{ id: value.fatherId, name: value.fatherName, label: '父亲' },
|
||||
{ id: value.motherId, name: value.motherName, label: '母亲' }
|
||||
]) {
|
||||
if (relation.id === undefined || relation.id === null) continue
|
||||
const relativeId = normalizeLineagePersonIdentity(relation.id, `${relation.label}标识`)
|
||||
if (relativeId === id || relatives.some((item) => item.id === relativeId)) {
|
||||
throw lineagePersonError('成员详情亲属标识冲突')
|
||||
}
|
||||
relatives.push({
|
||||
id: relativeId,
|
||||
name: normalizeLineagePersonText(relation.name, `${relation.label}姓名`) || relation.label,
|
||||
relation: relation.label
|
||||
})
|
||||
}
|
||||
return {
|
||||
id,
|
||||
genealogyId,
|
||||
genealogyName: normalizeLineagePersonText(value.genealogyName, '家谱名称') || '当前家谱',
|
||||
name,
|
||||
generation: value.generation,
|
||||
generationName,
|
||||
relation: value.generation === 1 ? '始祖' : '家谱成员',
|
||||
branch: generationName
|
||||
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
|
||||
: '字辈待补',
|
||||
sex: normalizeLineagePersonText(value.sex, '性别'),
|
||||
birthDate,
|
||||
deathDate,
|
||||
years: birthDate || deathDate ? `${birthDate}—${deathDate}` : '生卒待补',
|
||||
birthplace: normalizeLineagePersonText(value.birthPlace, '出生地'),
|
||||
biography: normalizeLineagePersonText(value.biography, '生平'),
|
||||
status: ['DECEASED', 'DEAD'].includes(personStatus) ? 'deceased' : 'normal',
|
||||
relatives
|
||||
}
|
||||
}
|
||||
|
||||
export const appApi = {
|
||||
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
@@ -373,8 +644,20 @@ export const appApi = {
|
||||
async getProfile() {
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/auth/profile' }) : currentUser
|
||||
},
|
||||
async getMyGenealogies() {
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/genealogies/mine' }) : genealogies
|
||||
async getMyGenealogies(requestOptions = {}) {
|
||||
if (!hasRemoteConfig()) {
|
||||
return genealogies.map((item) => ({
|
||||
...item,
|
||||
accessRole: item.membership === 'created' ? 'owner' : 'member'
|
||||
}))
|
||||
}
|
||||
const result = await requestStrict({
|
||||
url: '/genealogy/app/genealogies/mine',
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
return normalizeMyGenealogies(result)
|
||||
},
|
||||
async createGenealogy(payload) {
|
||||
if (hasRemoteConfig()) return request({ url: '/genealogy/app/genealogies', method: 'POST', data: payload })
|
||||
@@ -388,22 +671,47 @@ export const appApi = {
|
||||
async getGenealogy(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}` }) : genealogies.find((item) => String(item.id) === String(genealogyId))
|
||||
},
|
||||
async getOverview(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/overview` }) : await this.getGenealogy(genealogyId)
|
||||
async getOverview(genealogyId, requestOptions = {}) {
|
||||
const normalizedId = normalizeGenealogyPathId(genealogyId)
|
||||
if (!hasRemoteConfig()) return this.getGenealogy(normalizedId)
|
||||
const result = await requestStrict({
|
||||
url: `/genealogy/app/genealogies/${normalizedId}/overview`,
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
const overview = normalizeAppGenealogy(result)
|
||||
if (overview.id !== normalizedId) {
|
||||
throw createRequestError('家谱概览响应标识不匹配', 'GENEALOGY_RESPONSE_INVALID')
|
||||
}
|
||||
return overview
|
||||
},
|
||||
async getTree(genealogyId) {
|
||||
async getTree(genealogyId, requestOptions = {}) {
|
||||
if (hasRemoteConfig()) {
|
||||
const tree = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/tree` })
|
||||
return (tree || []).map(toTreeNode)
|
||||
const normalizedId = normalizeGenealogyPathId(genealogyId)
|
||||
const tree = await requestStrict({
|
||||
url: `/genealogy/app/genealogies/${normalizedId}/lineage/tree`,
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
return normalizeLineageTree(tree)
|
||||
}
|
||||
return treeMembers
|
||||
.filter((item) => String(item.genealogyId) === String(genealogyId))
|
||||
.map(toTreeNode)
|
||||
},
|
||||
async getPerson(genealogyId, personId) {
|
||||
async getPerson(genealogyId, personId, requestOptions = {}) {
|
||||
if (hasRemoteConfig()) {
|
||||
const person = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/persons/${personId}` })
|
||||
return toTreeNode(person)
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
|
||||
const result = await requestStrict({
|
||||
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
return normalizeLineagePersonDetail(result, normalizedGenealogyId, normalizedPersonId)
|
||||
}
|
||||
const person = treeMembers.find(
|
||||
(item) => String(item.id) === String(personId) &&
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const DEFAULT_COOLDOWN_SECONDS = 60;
|
||||
const sceneExpiresAt = new Map();
|
||||
|
||||
const requireFunction = (value, name) => {
|
||||
if (typeof value !== "function") {
|
||||
throw new TypeError(`${name} must be a function`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createAuthSmsCooldown = ({
|
||||
sceneCode,
|
||||
onChange,
|
||||
now = Date.now,
|
||||
setIntervalFn = setInterval,
|
||||
clearIntervalFn = clearInterval,
|
||||
}) => {
|
||||
if (typeof sceneCode !== "string" || !sceneCode.trim()) {
|
||||
throw new TypeError("sceneCode must be a non-empty string");
|
||||
}
|
||||
const normalizedSceneCode = sceneCode.trim();
|
||||
const publish = requireFunction(onChange, "onChange");
|
||||
const readNow = requireFunction(now, "now");
|
||||
const scheduleInterval = requireFunction(setIntervalFn, "setIntervalFn");
|
||||
const cancelInterval = requireFunction(clearIntervalFn, "clearIntervalFn");
|
||||
let timer = null;
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer === null) return;
|
||||
cancelInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const expiresAt = sceneExpiresAt.get(normalizedSceneCode) || 0;
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.ceil((expiresAt - Number(readNow())) / 1000),
|
||||
);
|
||||
if (remaining === 0) {
|
||||
sceneExpiresAt.delete(normalizedSceneCode);
|
||||
stopTimer();
|
||||
} else if (timer === null) {
|
||||
timer = scheduleInterval(sync, 1000);
|
||||
}
|
||||
publish(remaining);
|
||||
return remaining;
|
||||
};
|
||||
|
||||
const start = (seconds = DEFAULT_COOLDOWN_SECONDS) => {
|
||||
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300) {
|
||||
throw new TypeError("cooldown seconds must be an integer from 1 to 300");
|
||||
}
|
||||
sceneExpiresAt.set(
|
||||
normalizedSceneCode,
|
||||
Number(readNow()) + seconds * 1000,
|
||||
);
|
||||
return sync();
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
start,
|
||||
sync,
|
||||
dispose: stopTimer,
|
||||
});
|
||||
};
|
||||
@@ -4,9 +4,6 @@ export const AUTH_TAC_SCENE = Object.freeze({
|
||||
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
|
||||
});
|
||||
|
||||
export const PASSWORD_TAC_BLOCKED_MESSAGE =
|
||||
"密码登录的服务端安全验证尚未开放,请先使用验证码登录";
|
||||
|
||||
const SUPPORTED_TAC_TYPES = new Set([
|
||||
"SLIDER",
|
||||
"ROTATE",
|
||||
@@ -112,3 +109,23 @@ export const normalizeTacSuccess = (value, expectedRequestId) => {
|
||||
}
|
||||
return { requestId: value.requestId, validToken, expireSeconds };
|
||||
};
|
||||
|
||||
export const isSmsDeliveryOutcomeUnknown = (error) => {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (
|
||||
error.code === "REQUEST_TIMEOUT" ||
|
||||
error.code === "NETWORK_ERROR" ||
|
||||
error.code === "RESPONSE_INVALID" ||
|
||||
error.code === "REQUEST_CANCELLED"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (error.code === "HTTP_ERROR" || error.code === "BUSINESS_ERROR") {
|
||||
const status = Number(
|
||||
error.code === "HTTP_ERROR" ? error.httpStatus : error.businessCode,
|
||||
);
|
||||
if (!Number.isInteger(status)) return true;
|
||||
return !(status >= 400 && status < 500 && status !== 408);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export const runtimeConfig = {
|
||||
mode: 'mock',
|
||||
mode: 'remote',
|
||||
baseUrl: 'https://backend-api.ddxcjp.cn',
|
||||
clientId: '428a8310cd442757ae699df5d894f051',
|
||||
tenantId: '000000'
|
||||
|
||||
Reference in New Issue
Block a user