第二次迭代优化
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-vue-next": "^0.468.0",
|
"lucide-vue-next": "^0.468.0",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"vant": "^4.9.16",
|
"vant": "^4.9.16",
|
||||||
@@ -25,8 +26,8 @@
|
|||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.1",
|
|
||||||
"@tsconfig/node22": "^22.0.0",
|
"@tsconfig/node22": "^22.0.0",
|
||||||
|
"@types/node": "^22.13.1",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export const mockApi = {
|
|||||||
{
|
{
|
||||||
id: `WO${order.id}`,
|
id: `WO${order.id}`,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
|
commodityName: order.commodityName,
|
||||||
packageName: order.commodityName,
|
packageName: order.commodityName,
|
||||||
userName: '当前员工',
|
userName: '当前员工',
|
||||||
createTime: order.writeOffTime
|
createTime: order.writeOffTime
|
||||||
|
|||||||
@@ -39,13 +39,16 @@
|
|||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--app-bg);
|
background: var(--app-bg);
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-width: 320px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -58,6 +61,12 @@ button {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 0 14px 78px;
|
padding: 0 14px 78px;
|
||||||
@@ -76,6 +85,7 @@ button {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
width: min(100%, 560px);
|
width: min(100%, 560px);
|
||||||
|
min-width: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 14px 0 18px;
|
padding: 14px 0 18px;
|
||||||
}
|
}
|
||||||
@@ -162,6 +172,7 @@ button {
|
|||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid rgba(226, 232, 228, 0.9);
|
border: 1px solid rgba(226, 232, 228, 0.9);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -292,7 +303,7 @@ button {
|
|||||||
|
|
||||||
.order-card__main {
|
.order-card__main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 76px 1fr;
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,14 +345,29 @@ button {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-row > *,
|
||||||
|
.detail-row > * {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row span,
|
||||||
|
.detail-row span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-row strong,
|
.meta-row strong,
|
||||||
.detail-row strong {
|
.detail-row strong {
|
||||||
|
flex: 0 1 auto;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface CommodityPackageConfig {
|
|||||||
export interface WriteOffRecord {
|
export interface WriteOffRecord {
|
||||||
id?: string
|
id?: string
|
||||||
orderId?: string
|
orderId?: string
|
||||||
|
commodityName?: string
|
||||||
packageName?: string
|
packageName?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
userName?: string
|
userName?: string
|
||||||
|
|||||||
32
src/utils/writeOffCode.ts
Normal file
32
src/utils/writeOffCode.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface WriteOffCodePayload {
|
||||||
|
orderId: string
|
||||||
|
packageName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeDecode = (value: string) => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseWriteOffCode = (rawValue: string): WriteOffCodePayload => {
|
||||||
|
const raw = rawValue.trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('未识别到核销码')
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitIndex = raw.indexOf('&')
|
||||||
|
const orderId = safeDecode(splitIndex >= 0 ? raw.slice(0, splitIndex) : raw).trim()
|
||||||
|
const packageName = splitIndex >= 0 ? safeDecode(raw.slice(splitIndex + 1)).trim() : ''
|
||||||
|
|
||||||
|
if (!orderId) {
|
||||||
|
throw new Error('核销码缺少订单号')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
packageName: packageName || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ const events = ref<EventData[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const todoOrders = computed(() => orders.value.filter((item) => item.orderStatus === '2'))
|
const todoOrders = computed(() => orders.value.filter((item) => item.orderStatus === '2'))
|
||||||
const doneOrders = computed(() => orders.value.filter((item) => item.orderStatus === '6'))
|
const confirmOrders = computed(() => orders.value.filter((item) => item.orderStatus === '1'))
|
||||||
const activeEvents = computed(() => events.value.filter((item) => item.showStatusDesc === '生效中'))
|
const activeEvents = computed(() => events.value.filter((item) => item.showStatusDesc === '生效中'))
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -69,9 +69,9 @@ onMounted(loadData)
|
|||||||
<div class="stat-hint">可进入详情核销</div>
|
<div class="stat-hint">可进入详情核销</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">今日核销</div>
|
<div class="stat-label">待确认订单</div>
|
||||||
<div class="stat-value">{{ doneOrders.length }}</div>
|
<div class="stat-value">{{ confirmOrders.length }}</div>
|
||||||
<div class="stat-hint">含列表记录</div>
|
<div class="stat-hint">需要员工处理</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">生效事件</div>
|
<div class="stat-label">生效事件</div>
|
||||||
|
|||||||
@@ -136,8 +136,13 @@ const handleLogin = async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.login-page {
|
.login-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
overflow-x: hidden;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, #dff4ec 0, rgba(246, 248, 247, 0) 44%),
|
linear-gradient(180deg, #dff4ec 0, rgba(246, 248, 247, 0) 44%),
|
||||||
@@ -145,8 +150,11 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: min(100%, 430px);
|
width: 100%;
|
||||||
|
max-width: 430px;
|
||||||
|
min-width: 0;
|
||||||
padding: 22px 16px 16px;
|
padding: 22px 16px 16px;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -157,13 +165,19 @@ const handleLogin = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-brand > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.login-logo {
|
.login-logo {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
|
flex: 0 0 54px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--primary-soft);
|
background: var(--primary-soft);
|
||||||
color: var(--primary-deep);
|
color: var(--primary-deep);
|
||||||
@@ -177,6 +191,7 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-brand h1 {
|
.login-brand h1 {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
@@ -185,19 +200,62 @@ const handleLogin = async () => {
|
|||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form :deep(.van-cell) {
|
.login-form :deep(.van-cell) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.van-field__value),
|
||||||
|
.login-form :deep(.van-field__body),
|
||||||
|
.login-form :deep(.van-field__control) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.van-field__button) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form :deep(.van-button) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.login-note {
|
.login-note {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 340px) {
|
||||||
|
.login-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding: 18px 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-basis: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-size: 23px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ onMounted(loadDetail)
|
|||||||
<div v-for="record in detail.writeOffRecordList" :key="record.id" class="record-row">
|
<div v-for="record in detail.writeOffRecordList" :key="record.id" class="record-row">
|
||||||
<CheckCircle2 :size="18" />
|
<CheckCircle2 :size="18" />
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ record.packageName || detail.commodityName }}</strong>
|
<strong>{{ record.commodityName || detail.commodityName }}</strong>
|
||||||
|
<p v-if="record.packageName">套餐名称:{{ record.packageName }}</p>
|
||||||
<p>{{ record.createTime }} · {{ record.userName || '员工' }}</p>
|
<p>{{ record.createTime }} · {{ record.userName || '员工' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,11 +157,17 @@ onMounted(loadDetail)
|
|||||||
|
|
||||||
.order-hero {
|
.order-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 112px 1fr;
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-hero__content,
|
||||||
|
.package-row div,
|
||||||
|
.record-row div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.order-hero img {
|
.order-hero img {
|
||||||
width: 112px;
|
width: 112px;
|
||||||
height: 112px;
|
height: 112px;
|
||||||
@@ -215,6 +222,7 @@ onMounted(loadDetail)
|
|||||||
.package-row strong,
|
.package-row strong,
|
||||||
.record-row strong {
|
.record-row strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-row p,
|
.package-row p,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import { QrCode, X } from 'lucide-vue-next'
|
import { QrCode, X } from 'lucide-vue-next'
|
||||||
|
import jsQR from 'jsqr'
|
||||||
import { parseWriteOffCode, type WriteOffCodePayload } from '@/utils/writeOffCode'
|
import { parseWriteOffCode, type WriteOffCodePayload } from '@/utils/writeOffCode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -13,7 +14,10 @@ const scanError = ref('')
|
|||||||
|
|
||||||
let stream: MediaStream | null = null
|
let stream: MediaStream | null = null
|
||||||
let detector: { detect: (source: HTMLVideoElement) => Promise<Array<{ rawValue: string }>> } | null = null
|
let detector: { detect: (source: HTMLVideoElement) => Promise<Array<{ rawValue: string }>> } | null = null
|
||||||
|
let canvas: HTMLCanvasElement | null = null
|
||||||
|
let canvasContext: CanvasRenderingContext2D | null = null
|
||||||
let frameId = 0
|
let frameId = 0
|
||||||
|
let lastDecodeAt = 0
|
||||||
|
|
||||||
const getBarcodeDetector = () => {
|
const getBarcodeDetector = () => {
|
||||||
return (window as unknown as {
|
return (window as unknown as {
|
||||||
@@ -36,6 +40,8 @@ const stopScan = () => {
|
|||||||
if (videoRef.value) {
|
if (videoRef.value) {
|
||||||
videoRef.value.srcObject = null
|
videoRef.value.srcObject = null
|
||||||
}
|
}
|
||||||
|
detector = null
|
||||||
|
lastDecodeAt = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const goConfirm = (payload: WriteOffCodePayload) => {
|
const goConfirm = (payload: WriteOffCodePayload) => {
|
||||||
@@ -54,14 +60,41 @@ const handleCodeValue = (rawValue: string) => {
|
|||||||
goConfirm(payload)
|
goConfirm(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detectWithBarcodeDetector = async (video: HTMLVideoElement) => {
|
||||||
|
if (!detector) return ''
|
||||||
|
const results = await detector.detect(video)
|
||||||
|
return results[0]?.rawValue || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectWithJsQr = (video: HTMLVideoElement) => {
|
||||||
|
const width = video.videoWidth
|
||||||
|
const height = video.videoHeight
|
||||||
|
if (!width || !height) return ''
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
canvas = document.createElement('canvas')
|
||||||
|
}
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
canvasContext = canvas.getContext('2d', { willReadFrequently: true })
|
||||||
|
}
|
||||||
|
if (!canvasContext) return ''
|
||||||
|
|
||||||
|
canvasContext.drawImage(video, 0, 0, width, height)
|
||||||
|
const imageData = canvasContext.getImageData(0, 0, width, height)
|
||||||
|
return jsQR(imageData.data, width, height, { inversionAttempts: 'dontInvert' })?.data || ''
|
||||||
|
}
|
||||||
|
|
||||||
const detectLoop = async () => {
|
const detectLoop = async () => {
|
||||||
if (!scanning.value || !videoRef.value || !detector) return
|
if (!scanning.value || !videoRef.value) return
|
||||||
const video = videoRef.value
|
const video = videoRef.value
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
const now = Date.now()
|
||||||
const results = await detector.detect(video)
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && now - lastDecodeAt > 120) {
|
||||||
const value = results[0]?.rawValue
|
lastDecodeAt = now
|
||||||
|
const value = detector ? await detectWithBarcodeDetector(video) : detectWithJsQr(video)
|
||||||
if (value) {
|
if (value) {
|
||||||
handleCodeValue(value)
|
handleCodeValue(value)
|
||||||
return
|
return
|
||||||
@@ -79,16 +112,16 @@ const detectLoop = async () => {
|
|||||||
const startScan = async () => {
|
const startScan = async () => {
|
||||||
if (scanning.value || loading.value) return
|
if (scanning.value || loading.value) return
|
||||||
const BarcodeDetector = getBarcodeDetector()
|
const BarcodeDetector = getBarcodeDetector()
|
||||||
if (!BarcodeDetector) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
scanError.value = '当前浏览器不支持摄像头扫码'
|
scanError.value = '当前浏览器无法打开摄像头'
|
||||||
showToast('当前浏览器不支持摄像头扫码')
|
showToast('当前浏览器无法打开摄像头')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
scanError.value = ''
|
scanError.value = ''
|
||||||
try {
|
try {
|
||||||
detector = new BarcodeDetector({ formats: ['qr_code'] })
|
detector = BarcodeDetector ? new BarcodeDetector({ formats: ['qr_code'] }) : null
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: {
|
video: {
|
||||||
|
|||||||
@@ -703,6 +703,11 @@ https-proxy-agent@^5.0.1:
|
|||||||
agent-base "6"
|
agent-base "6"
|
||||||
debug "4"
|
debug "4"
|
||||||
|
|
||||||
|
jsqr@^1.4.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.npmmirror.com/jsqr/-/jsqr-1.4.0.tgz#8efb8d0a7cc6863cb6d95116b9069123ce9eb2d1"
|
||||||
|
integrity sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==
|
||||||
|
|
||||||
lucide-vue-next@^0.468.0:
|
lucide-vue-next@^0.468.0:
|
||||||
version "0.468.0"
|
version "0.468.0"
|
||||||
resolved "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.468.0.tgz#5e3c84b534f43d5e82fb757b91933cfb4ab24bf9"
|
resolved "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.468.0.tgz#5e3c84b534f43d5e82fb757b91933cfb4ab24bf9"
|
||||||
|
|||||||
Reference in New Issue
Block a user