链接上数据库
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
// IP: 192.168.1.62
|
||||
// Kong HTTP Port: 8000
|
||||
//自己的配置自己解开即可
|
||||
//export const SUPA_URL: string = 'http://192.168.1.61:18000'
|
||||
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
||||
export const SUPA_URL: string = 'http://192.168.1.61:18000'
|
||||
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
||||
//export const SUPA_URL: string = 'http://192.168.1.62:18000'
|
||||
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
||||
export const SUPA_URL: string = 'http://192.168.1.61:18000'
|
||||
export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
|
||||
// export const SUPA_URL: string = 'http://192.168.1.61:18000'
|
||||
// export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
|
||||
|
||||
// WebSocket 实时连接(内网使用 ws:// 而非 wss://)
|
||||
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// /components/supadb/aksupa.uts
|
||||
import { AkReqResponse, AkReqUploadOptions, AkReq } from '@/uni_modules/ak-req/index.uts'
|
||||
import type { AkReqOptions } from '@/uni_modules/ak-req/index.uts'
|
||||
import { toUniError } from '@/utils/utils.uts'
|
||||
import { IS_TEST_MODE } from '@/ak/config.uts'
|
||||
|
||||
export type AkSupaSignInResult = {
|
||||
access_token : string;
|
||||
@@ -807,11 +809,19 @@ export class AkSupa {
|
||||
*/
|
||||
async select(table : string, filter ?: string | null, options ?: AkSupaSelectOptions) : Promise<AkReqResponse<any>> {
|
||||
let url = this.baseUrl + '/rest/v1/' + table;
|
||||
|
||||
const token = AkReq.getToken()
|
||||
let headers = {
|
||||
apikey: this.apikey,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${AkReq.getToken() ?? ''}`
|
||||
'Content-Type': 'application/json'
|
||||
} as UTSJSONObject;
|
||||
|
||||
// 只有在明确有用户 token 的情况下才发送 Authorization
|
||||
// 否则只带 apikey,这样 Kong 会自动映射到 anon 角色,避免 JWT 校验失败
|
||||
if (token != null && token != '') {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let params : string[] = [];
|
||||
if (options != null) {
|
||||
if (options.columns != null && !(options.columns == "")) params.push('select=' + encodeURIComponent(options.columns ?? ""));
|
||||
@@ -897,10 +907,17 @@ async select_uts(table : string, filter ?: UTSJSONObject | null, options ?: AkSu
|
||||
*/
|
||||
async insert(table : string, row : UTSJSONObject | Array<UTSJSONObject>) : Promise<AkReqResponse<any>> {
|
||||
const url = this.baseUrl + '/rest/v1/' + table;
|
||||
const headers = {
|
||||
|
||||
const token = AkReq.getToken()
|
||||
let authHeader = `Bearer ${this.apikey}`;
|
||||
if (token != null && token != '') {
|
||||
authHeader = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let headers = {
|
||||
apikey: this.apikey,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
|
||||
Authorization: authHeader,
|
||||
Prefer: 'return=representation'
|
||||
} as UTSJSONObject;
|
||||
|
||||
@@ -910,7 +927,7 @@ async select_uts(table : string, filter ?: UTSJSONObject | null, options ?: AkSu
|
||||
url,
|
||||
method: 'POST',
|
||||
headers,
|
||||
data: row, // 可以是单个对象或数组
|
||||
data: row,
|
||||
contentType: 'application/json'
|
||||
};
|
||||
return await this.requestWithAutoRefresh(reqOptions);
|
||||
@@ -928,12 +945,18 @@ async update(table : string, filter : string | null, values : UTSJSONObject) : P
|
||||
if (filter!=null && filter !== "") {
|
||||
url += '?' + filter;
|
||||
}
|
||||
const headers = {
|
||||
|
||||
const token = AkReq.getToken()
|
||||
let headers = {
|
||||
apikey: this.apikey,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
|
||||
Prefer: 'return=representation'
|
||||
} as UTSJSONObject;
|
||||
|
||||
if (token != null && token != '') {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let reqOptions : AkReqOptions = {
|
||||
url,
|
||||
method: 'PATCH',
|
||||
@@ -955,12 +978,18 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
|
||||
if (filter!=null && filter !== "") {
|
||||
url += '?' + filter;
|
||||
}
|
||||
const headers = {
|
||||
|
||||
const token = AkReq.getToken()
|
||||
let headers = {
|
||||
apikey: this.apikey,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
|
||||
Prefer: 'return=representation'
|
||||
} as UTSJSONObject;
|
||||
|
||||
if (token != null && token != '') {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let reqOptions : AkReqOptions = {
|
||||
url,
|
||||
method: 'DELETE',
|
||||
@@ -978,11 +1007,17 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
|
||||
*/
|
||||
async rpc(functionName : string, params ?: UTSJSONObject) : Promise<AkReqResponse<any>> {
|
||||
const url = `${this.baseUrl}/rest/v1/rpc/${functionName}`;
|
||||
const headers = {
|
||||
|
||||
const token = AkReq.getToken()
|
||||
let headers = {
|
||||
apikey: this.apikey,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${AkReq.getToken() ?? ''}`
|
||||
'Content-Type': 'application/json'
|
||||
} as UTSJSONObject;
|
||||
|
||||
if (token != null && token != '') {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let reqOptions : AkReqOptions = {
|
||||
url,
|
||||
method: 'POST',
|
||||
@@ -1042,24 +1077,34 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
|
||||
// AkSupa类内新增:自动刷新封装
|
||||
async requestWithAutoRefresh(reqOptions : AkReqOptions, isRetry = false) : Promise<AkReqResponse<any>> {
|
||||
let res = await AkReq.request(reqOptions, false);
|
||||
// JWT过期:Supabase风格
|
||||
const isJwtExpired = (res.status == 401); //res != null && res.data != null && typeof res.data == 'object' && (res.data as UTSJSONObject)?.getString('code') == 'PGRST301';
|
||||
// 401未授权
|
||||
const isUnauthorized = (res.status == 401);
|
||||
if ((isJwtExpired || isUnauthorized) && !isRetry) {
|
||||
// JWT过期/401未授权
|
||||
const needsHandle = (res.status == 401);
|
||||
|
||||
if (needsHandle && !isRetry) {
|
||||
const ok = await this.refreshSession();
|
||||
if (ok) {
|
||||
const newToken = AkReq.getToken() ?? ''
|
||||
let headers = reqOptions.headers
|
||||
if (headers == null) {
|
||||
headers = new UTSJSONObject()
|
||||
}
|
||||
if (typeof headers.set == 'function') {
|
||||
headers.set('Authorization', `Bearer ${AkReq.getToken() ?? ''}`)
|
||||
headers.set('Authorization', `Bearer ${newToken}`)
|
||||
reqOptions.headers = headers
|
||||
}
|
||||
|
||||
res = await AkReq.request(reqOptions, false);
|
||||
} else {
|
||||
// 如果是测试模式且失败(401且无法刷新),不再抛出异常阻止执行,但确保 res.error 有值
|
||||
if (IS_TEST_MODE === true) {
|
||||
console.warn('[TestMode] Token expired or not found, but continuing anyway. Status:', res.status)
|
||||
console.log('[TestMode] Response body:', JSON.stringify(res.data))
|
||||
if (res.error == null) {
|
||||
res.error = toUniError('认证失败 (401)', 'UNAUTHORIZED');
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
uni.removeStorageSync('user_id');
|
||||
uni.removeStorageSync('token');
|
||||
|
||||
@@ -1067,6 +1112,12 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
|
||||
throw toUniError('登录已过期,请重新登录', '用户认证失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 额外检查:如果 status >= 400 且 res.error 为空,注入一个 error
|
||||
if (res.status >= 400 && res.error == null) {
|
||||
res.error = toUniError(`请求失败: ${res.status}`, 'HTTP_ERROR');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<view class="filter-item item-w">
|
||||
<text class="label-txt">优惠券名称:</text>
|
||||
<view class="input-wrap">
|
||||
<input class="search-input" placeholder="请输入优惠券名称" v-model="filter.name" />
|
||||
<text class="count-txt">0/18</text>
|
||||
<input class="search-input" v-model="filter.name" placeholder="请输入优惠券名称" />
|
||||
<text class="count-txt">{{ filter.name.length }}/18</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -52,71 +52,86 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 表格主体 -->
|
||||
<!-- 表格主体 - 使用 CSS Grid 确保严格对齐 -->
|
||||
<view class="table-container">
|
||||
<view class="table-header-row">
|
||||
<view class="th" style="width: 80px;">ID</view>
|
||||
<view class="th" style="width: 180px;">优惠券名称</view>
|
||||
<view class="th" style="width: 120px;">优惠券类型</view>
|
||||
<view class="th" style="width: 100px;">面值</view>
|
||||
<view class="th" style="width: 120px;">领取方式</view>
|
||||
<view class="th" style="width: 150px;">领取日期</view>
|
||||
<view class="th" style="width: 120px;">使用时间</view>
|
||||
<view class="th" style="width: 120px;">发布数量</view>
|
||||
<view class="th" style="width: 100px;">是否开启</view>
|
||||
<view class="th" style="flex: 1; min-width: 220px;">操作</view>
|
||||
<!-- 表头 -->
|
||||
<view class="table-header-row table-grid">
|
||||
<view class="th p-1">ID</view>
|
||||
<view class="th p-1 name-col">优惠券名称</view>
|
||||
<view class="th p-2">优惠券类型</view>
|
||||
<view class="th p-1">面值/门槛</view>
|
||||
<view class="th p-3">领取/使用限制</view>
|
||||
<view class="th p-3">领取日期</view>
|
||||
<view class="th p-2">发布数量</view>
|
||||
<view class="th p-2">是否开启</view>
|
||||
<view class="th p-1 op-cell shadow-left">操作</view>
|
||||
</view>
|
||||
|
||||
<!-- 表身 -->
|
||||
<scroll-view class="table-body-scroll" scroll-x="true">
|
||||
<view class="table-body">
|
||||
<view v-for="(item, index) in dataList" :key="item.id" class="table-row">
|
||||
<view class="td" style="width: 80px;"><text class="td-txt">{{ item.id }}</text></view>
|
||||
<view class="td" style="width: 180px;"><text class="td-txt name-bold">{{ item.name }}</text></view>
|
||||
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.type }}</text></view>
|
||||
<view class="td" style="width: 100px;"><text class="td-txt price-txt">{{ item.value.toFixed(2) }}</text></view>
|
||||
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.receiveType }}</text></view>
|
||||
<view class="td" style="width: 150px;">
|
||||
<text v-if="item.id === 1628" class="td-txt date-small">2023-10-18 00:00 - 2025-11-05 00:00</text>
|
||||
<text v-else class="td-txt">{{ item.receiveDate }}</text>
|
||||
<view v-for="(item, index) in dataList" :key="item.id" class="table-row table-grid">
|
||||
<view class="td p-1"><text class="td-txt">{{ item.id }}</text></view>
|
||||
<view class="td p-1 name-col">
|
||||
<view class="name-box">
|
||||
<text class="td-txt name-bold" :title="item.description">{{ item.name }}</text>
|
||||
<text class="date-small">{{ item.createdAt }} 创建</text>
|
||||
<text v-if="item.description" class="desc-tip">{{ item.description }}</text>
|
||||
</view>
|
||||
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.useTime }}</text></view>
|
||||
<view class="td" style="width: 120px;">
|
||||
<view v-if="item.publishTotal > 0" class="pub-info">
|
||||
<text class="pub-txt">发布: {{ item.publishTotal }}</text>
|
||||
<text class="pub-txt danger">剩余: {{ item.publishRemain }}</text>
|
||||
</view>
|
||||
<text v-else class="td-txt">不限量</text>
|
||||
<view class="td p-2"><text class="td-txt">{{ item.type }}</text></view>
|
||||
<view class="td p-1">
|
||||
<view class="value-req">
|
||||
<text class="td-txt price-txt">{{ item.value.toFixed(2) }}{{ item.type.includes('折扣') ? '折' : '元' }}</text>
|
||||
<text class="date-small">{{ item.minOrderAmount > 0 ? '满' + item.minOrderAmount + '元可用' : '无门槛' }}</text>
|
||||
<text v-if="item.maxDiscountAmount != null" class="date-small danger">最多减{{ item.maxDiscountAmount }}元</text>
|
||||
</view>
|
||||
<view class="td" style="width: 100px;">
|
||||
</view>
|
||||
<view class="td p-3">
|
||||
<view class="limit-info">
|
||||
<text class="td-txt">{{ item.receiveType }}</text>
|
||||
<text class="date-small">每人限领: {{ item.perUserLimit }}张</text>
|
||||
<text class="date-small">领取限制: {{ item.usageLimit === 0 ? '不限' : item.usageLimit + '次/天' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td p-3"><text class="td-txt date-small">{{ item.receiveDate }}</text></view>
|
||||
<view class="td p-2">
|
||||
<view class="pub-info">
|
||||
<text class="td-txt">{{ item.publishTotal === 0 ? '不限量' : '总: ' + item.publishTotal }}</text>
|
||||
<text v-if="item.publishTotal > 0" class="pub-txt danger">剩: {{ item.publishRemain }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td p-2">
|
||||
<view :class="['switch-box', item.isOpen ? 'active' : '']" @click="toggleStatus(index)">
|
||||
<view class="switch-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td" style="flex: 1; min-width: 220px;">
|
||||
<!-- 操作列:Sticky 固定 -->
|
||||
<view class="td p-1 op-cell shadow-left">
|
||||
<view class="op-links">
|
||||
<text class="op-link">领取记录</text>
|
||||
<text class="op-link" @click="handleShowRecords(item)">领取记录</text>
|
||||
<text class="op-split">|</text>
|
||||
<text class="op-link">编辑</text>
|
||||
<text class="op-link" @click="handleEdit(item)">编辑</text>
|
||||
<text class="op-split">|</text>
|
||||
<text class="op-link">复制</text>
|
||||
<text class="op-split">|</text>
|
||||
<text class="op-link text-danger">删除</text>
|
||||
<text class="op-link text-danger" @click="handleDelete(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 分页 -->
|
||||
<view class="pagination-footer">
|
||||
<text class="total-txt">共 16 条</text>
|
||||
<text class="total-txt">共 {{ dataList.length }} 条</text>
|
||||
<view class="page-select">
|
||||
<text class="page-val">15条/页 ▼</text>
|
||||
<text class="page-val">10 条/页 ▼</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn disabled"><</text>
|
||||
<text class="p-btn active">1</text>
|
||||
<text class="p-btn">2</text>
|
||||
<text class="p-btn">></text>
|
||||
<view class="p-btn disabled"><text><</text></view>
|
||||
<view class="p-btn active"><text>1</text></view>
|
||||
<view class="p-btn"><text>2</text></view>
|
||||
<view class="p-btn"><text>></text></view>
|
||||
</view>
|
||||
<view class="page-jump">
|
||||
<text class="jump-txt">前往</text>
|
||||
@@ -126,54 +141,266 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 领取记录模态框 -->
|
||||
<view v-if="showRecordsModal" class="modal-mask" @click="showRecordsModal = false">
|
||||
<view class="modal-container" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">领取记录 - {{ selectedCoupon?.name }}</text>
|
||||
<text class="modal-close" @click="showRecordsModal = false">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="record-table">
|
||||
<view class="record-header record-grid">
|
||||
<text class="r-th">ID</text>
|
||||
<text class="r-th">用户名</text>
|
||||
<text class="r-th">用户头像</text>
|
||||
<text class="r-th">领取时间</text>
|
||||
</view>
|
||||
<scroll-view scroll-y="true" class="record-list">
|
||||
<view v-for="rec in currentRecords" :key="rec.id" class="record-row record-grid">
|
||||
<text class="r-td">{{ rec.id }}</text>
|
||||
<text class="r-td">{{ rec.username }}</text>
|
||||
<view class="r-td">
|
||||
<image class="avatar-img" :src="rec.avatar" mode="aspectFill"></image>
|
||||
</view>
|
||||
<text class="r-td">{{ rec.time }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
interface CouponItem {
|
||||
id: number
|
||||
id_uuid: string // 保持对 UUID 的引用以进行后续操作
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
value: number
|
||||
minOrderAmount: number
|
||||
maxDiscountAmount: number | null
|
||||
receiveType: string
|
||||
receiveDate: string
|
||||
useTime: string
|
||||
publishTotal: number
|
||||
publishRemain: number
|
||||
perUserLimit: number
|
||||
usageLimit: number
|
||||
isOpen: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface RecordItem {
|
||||
id: string
|
||||
username: string
|
||||
avatar: string
|
||||
time: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 权限和弹窗逻辑说明:
|
||||
* - isAdmin: 模拟当前用户的 admin 权限状态。
|
||||
* - handleDelete: 点击删除时首先弹出确认对话框。
|
||||
* - 权限校验: 在确认删除的回调中检查 isAdmin 状态。
|
||||
* - 拦截行为: 如果非 admin,弹出错误警告并阻止执行删除操作。
|
||||
*/
|
||||
const isAdmin = ref(true) // 为了方便测试,设为 true
|
||||
|
||||
const filter = reactive({
|
||||
name: ''
|
||||
})
|
||||
|
||||
const dataList = ref<CouponItem[]>([
|
||||
{ id: 1643, name: '满100减30', type: '通用券', value: 30.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: false },
|
||||
{ id: 1642, name: '满10减7', type: '通用券', value: 7.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1641, name: '会员优惠券', type: '通用券', value: 200.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1640, name: '会员优惠券', type: '通用券', value: 29.90, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1639, name: '会员优惠券', type: '通用券', value: 1.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1638, name: '商品券', type: '商品券', value: 1.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '200天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1636, name: '测试多个商品消耗一个券', type: '商品券', value: 500.00, receiveType: '系统赠送', receiveDate: '不限时', useTime: '3天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1635, name: '优惠券', type: '通用券', value: 10.00, receiveType: '系统赠送', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1634, name: '限时优惠', type: '通用券', value: 20.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '5天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1633, name: '店庆券', type: '品类券', value: 100.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 0, publishRemain: 0, isOpen: true },
|
||||
{ id: 1632, name: '优惠券', type: '品类券', value: 99.00, receiveType: '用户领取', receiveDate: '不限时', useTime: '10天', publishTotal: 8999, publishRemain: 8604, isOpen: true },
|
||||
{ id: 1628, name: '全场通用券', type: '通用券', value: 9.90, receiveType: '用户领取', receiveDate: 'RANGE', useTime: '不限时', publishTotal: 59999, publishRemain: 59331, isOpen: true }
|
||||
])
|
||||
const dataList = ref<CouponItem[]>([])
|
||||
|
||||
const handleQuery = () => { console.log('Querying...') }
|
||||
const handleAdd = () => { console.log('Adding coupon...') }
|
||||
const toggleStatus = (index: number) => {
|
||||
dataList.value[index].isOpen = !dataList.value[index].isOpen
|
||||
const fetchCouponTemplates = async () => {
|
||||
try {
|
||||
let query = supa.from('ml_coupon_templates').select('*')
|
||||
|
||||
// 如果有名称筛选
|
||||
if (filter.name.trim() != '') {
|
||||
query = query.like('name', `%${filter.name}%`)
|
||||
}
|
||||
|
||||
const res = await query.order('created_at', { ascending: false }).execute()
|
||||
|
||||
if (res.error != null || res.status >= 400) {
|
||||
const msg = res.error?.message ?? `获取数据失败 (${res.status})`
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(res.data)) {
|
||||
console.warn('Expected array but got:', res.data)
|
||||
dataList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = res.data as Array<UTSJSONObject>
|
||||
dataList.value = rawData.map((item : UTSJSONObject) : CouponItem => {
|
||||
// 优先获取 cid (自增 ID),如果没有则取 id (UUID) 的后几位或 0
|
||||
const displayId = (item.get('cid') as number | null) ?? 0
|
||||
const uuid = item.get('id') as string ?? ''
|
||||
|
||||
const couponType = item.get('coupon_type') as number ?? 1
|
||||
let typeStr = '未知类型'
|
||||
if (couponType === 1) typeStr = '满减券'
|
||||
else if (couponType === 2) typeStr = '折扣券'
|
||||
else if (couponType === 3) typeStr = '免运费券'
|
||||
|
||||
// 获取面值逻辑:如果是折扣券 (discount_type=2),展示方式可能不同
|
||||
const discountType = item.get('discount_type') as number ?? 1
|
||||
const discountValue = item.get('discount_value') as number ?? 0
|
||||
let displayValue = discountValue
|
||||
|
||||
const startTime = item.get('start_time') as string ?? ''
|
||||
const endTime = item.get('end_time') as string ?? ''
|
||||
const createdAt = item.get('created_at') as string ?? ''
|
||||
const dateStr = (startTime != '' && endTime != '')
|
||||
? `${startTime.slice(0, 10)} 至 ${endTime.slice(0, 10)}`
|
||||
: '不限时'
|
||||
|
||||
return {
|
||||
id: displayId,
|
||||
id_uuid: uuid,
|
||||
name: item.get('name') as string ?? '未命名优惠券',
|
||||
description: item.get('description') as string ?? '',
|
||||
type: typeStr,
|
||||
value: displayValue,
|
||||
minOrderAmount: item.get('min_order_amount') as number ?? 0,
|
||||
maxDiscountAmount: item.get('max_discount_amount') as number | null,
|
||||
receiveType: (item.get('merchant_id') == null ? '平台发放' : '商家发放'),
|
||||
receiveDate: dateStr,
|
||||
useTime: dateStr,
|
||||
publishTotal: (item.get('total_quantity') as number ?? 0),
|
||||
publishRemain: 0,
|
||||
perUserLimit: item.get('per_user_limit') as number ?? 1,
|
||||
usageLimit: item.get('usage_limit') as number ?? 1,
|
||||
isOpen: (item.get('status') as number) === 1,
|
||||
createdAt: createdAt.slice(0, 16).replace('T', ' ')
|
||||
} as CouponItem
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Fetch Coupons Error:', e)
|
||||
uni.showToast({ title: '访问数据库异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
fetchCouponTemplates()
|
||||
}
|
||||
const handleAdd = () => {
|
||||
uni.showToast({ title: '导航至添加页面', icon: 'none' })
|
||||
}
|
||||
|
||||
const toggleStatus = async (index: number) => {
|
||||
const item = dataList.value[index]
|
||||
const newStatus = item.isOpen ? 2 : 1 // 1: 正常, 2: 暂停
|
||||
|
||||
try {
|
||||
const res = await supa.from('ml_coupon_templates')
|
||||
.update({ status: newStatus } as UTSJSONObject)
|
||||
.eq('id', item.id_uuid)
|
||||
.execute()
|
||||
|
||||
if (res.error != null || res.status >= 400) {
|
||||
uni.showToast({ title: '状态更新失败: ' + (res.error?.message ?? `HTTP ${res.status}`), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
item.isOpen = !item.isOpen
|
||||
uni.showToast({ title: '状态更新成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '更新状态异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 领取记录逻辑
|
||||
const showRecordsModal = ref(false)
|
||||
const selectedCoupon = ref<CouponItem | null>(null)
|
||||
const currentRecords = ref<RecordItem[]>([])
|
||||
|
||||
const handleShowRecords = (item: CouponItem) => {
|
||||
selectedCoupon.value = item
|
||||
// 模拟接口数据
|
||||
currentRecords.value = [
|
||||
{ id: '70074', username: '131****7722', avatar: 'https://placeholder.com/40', time: '2025-02-25 21:33:35' },
|
||||
{ id: '36794', username: '147****4489', avatar: 'https://placeholder.com/40', time: '2025-02-25 21:58:27' },
|
||||
{ id: '60902', username: '花开花落', avatar: 'https://placeholder.com/40', time: '2025-02-25 21:59:14' },
|
||||
{ id: '70312', username: '55454', avatar: 'https://placeholder.com/40', time: '2025-02-25 23:20:00' }
|
||||
]
|
||||
showRecordsModal.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (item: CouponItem) => {
|
||||
uni.showToast({ title: `编辑: ${item.name}`, icon: 'none' })
|
||||
}
|
||||
|
||||
const handleCopy = (item: CouponItem) => {
|
||||
uni.showToast({ title: `复制: ${item.name}`, icon: 'success' })
|
||||
}
|
||||
|
||||
// 删除逻辑与权限验证
|
||||
const handleDelete = (item: CouponItem) => {
|
||||
uni.showModal({
|
||||
title: '确认提示',
|
||||
content: `确定要删除“${item.name}”这一条优惠券数据吗?此操作不可逆。`,
|
||||
cancelText: '取消',
|
||||
confirmText: '确定',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
// 🔒 权限校验:检查用户是否具有 admin 权限
|
||||
if (!isAdmin.value) {
|
||||
uni.showModal({
|
||||
title: '操作被拦截',
|
||||
content: '您无权限删除,请联系系统管理员。',
|
||||
showCancel: false,
|
||||
confirmText: '阅读并关闭'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 实际从数据库删除
|
||||
const delRes = await supa.from('ml_coupon_templates')
|
||||
.eq('id', item.id_uuid)
|
||||
.delete()
|
||||
.execute()
|
||||
|
||||
if (delRes.error != null || delRes.status >= 400) {
|
||||
uni.showToast({ title: '删除失败: ' + (delRes.error?.message ?? `HTTP ${delRes.status}`), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 移除本地列表数据
|
||||
dataList.value = dataList.value.filter(i => i.id_uuid !== item.id_uuid)
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作数据库异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Coupon list initializing and fetching data...')
|
||||
fetchCouponTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-marketing-coupon {
|
||||
padding: 0px;
|
||||
background-color: #f5f7f9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.border-shadow {
|
||||
@@ -186,6 +413,7 @@ const toggleStatus = (index: number) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 过滤栏 */
|
||||
@@ -208,7 +436,7 @@ const toggleStatus = (index: number) => {
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.search-input { flex: 1; height: 100%; font-size: 14px; }
|
||||
.search-input { flex: 1; height: 100%; font-size: 14px; background: none; border: none; outline: none; }
|
||||
.count-txt { font-size: 12px; color: #c0c4cc; }
|
||||
|
||||
.select-mock {
|
||||
@@ -239,7 +467,7 @@ const toggleStatus = (index: number) => {
|
||||
.query-txt { color: #fff; font-size: 14px; }
|
||||
|
||||
/* 表格区域 */
|
||||
.table-card { background-color: #fff; display: flex; flex-direction: column; }
|
||||
.table-card { background-color: #fff; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.card-header { padding: 24px; }
|
||||
|
||||
.btn-primary-blue {
|
||||
@@ -251,49 +479,150 @@ const toggleStatus = (index: number) => {
|
||||
}
|
||||
.btn-txt { color: #fff; font-size: 14px; }
|
||||
|
||||
.table-container { padding: 0 24px 24px; }
|
||||
.table-header-row { display: flex; flex-direction: row; background-color: #f8f8f9; border-bottom: 1px solid #e8eaec; }
|
||||
.th { padding: 12px 10px; font-size: 14px; color: #515a6e; font-weight: bold; }
|
||||
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; border-left: 1px solid transparent; }
|
||||
.table-row:hover { background-color: #ebf7ff; }
|
||||
.table-container {
|
||||
padding: 0 24px 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.td { padding: 12px 10px; display: flex; align-items: center; }
|
||||
.td-txt { font-size: 14px; color: #515a6e; }
|
||||
/* 🧱 Grid 核心布局系统 */
|
||||
.table-grid {
|
||||
display: grid;
|
||||
/* 列宽定义:ID, 名称, 类型, 面值/门槛, 领取/限制, 日期, 数量, 状态, 操作 */
|
||||
grid-template-columns: 60px 2.5fr 100px 150px 180px 160px 100px 80px 240px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.table-header-row {
|
||||
background-color: #f8f8f9;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 12px 10px;
|
||||
font-size: 14px;
|
||||
color: #515a6e;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
background-color: #fff;
|
||||
}
|
||||
.table-row:hover { background-color: #f0faff; }
|
||||
|
||||
.td {
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
/* 文本溢出策略:支持正常换行 */
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.name-box, .value-req, .limit-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.desc-tip {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
background-color: #f9f9f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
display: none; /* 默认隐藏,在 hover 状态显示(H5仿真) */
|
||||
}
|
||||
.table-row:hover .desc-tip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.td-txt { font-size: 14px; color: #515a6e; line-height: 1.5; }
|
||||
.name-bold { font-weight: 500; color: #333; }
|
||||
.price-txt { color: #515a6e; }
|
||||
.date-small { font-size: 12px; line-height: 1.4; color: #999; }
|
||||
.price-txt { color: #f56c6c; font-weight: bold; }
|
||||
.date-small { font-size: 12px; color: #999; }
|
||||
.danger { color: #ed4014; }
|
||||
|
||||
.pub-info { display: flex; flex-direction: column; }
|
||||
.pub-txt { font-size: 12px; color: #2d8cf0; }
|
||||
.pub-txt.danger { color: #ed4014; }
|
||||
.pub-txt.danger { color: #ed4014; font-size: 11px; margin-top: 2px; }
|
||||
|
||||
/* 🚀 Sticky 操作列实现 */
|
||||
.op-cell {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
background-color: #fff; /* 背景必须不透明 */
|
||||
z-index: 10;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 鼠标悬停时保持背景色同步 */
|
||||
.table-row:hover .op-cell {
|
||||
background-color: #f0faff;
|
||||
}
|
||||
|
||||
.shadow-left {
|
||||
box-shadow: -6px 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.op-links { display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
.op-link { color: #2d8cf0; font-size: 13px; cursor: pointer; margin: 2px 4px; white-space: nowrap; }
|
||||
.op-split { color: #e8eaec; margin: 0; font-size: 12px; }
|
||||
.text-danger { color: #ed4014; }
|
||||
|
||||
/* Switch 开关 */
|
||||
.switch-box {
|
||||
width: 44px;
|
||||
height: 22px;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background-color: #dcdfe6;
|
||||
border-radius: 11px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch-box.active { background-color: #2d8cf0; }
|
||||
.switch-box.active { background-color: #19be6b; }
|
||||
.switch-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #fff;
|
||||
border-radius: 9px;
|
||||
border-radius: 8px;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.switch-box.active .switch-dot { transform: translateX(22px); }
|
||||
.switch-box.active .switch-dot { transform: translateX(20px); }
|
||||
|
||||
.op-links { display: flex; flex-direction: row; align-items: center; }
|
||||
.op-link { color: #2d8cf0; font-size: 14px; cursor: pointer; margin: 0 5px; }
|
||||
.op-split { color: #e8eaec; margin: 0 5px; }
|
||||
.text-danger { color: #ed4014; }
|
||||
/* 📱 响应式媒体查询 (Priority 策略) */
|
||||
/* 平板:隐藏次要列 */
|
||||
@media screen and (max-width: 1450px) {
|
||||
.table-grid {
|
||||
grid-template-columns: 70px 2fr 90px 80px 0 150px 0 100px 90px 240px;
|
||||
}
|
||||
.p-3 { display: none; } /* 隐藏优先级 3:领取方式、使用时间 */
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.table-grid {
|
||||
grid-template-columns: 0 1.5fr 0 80px 0 140px 0 100px 0 220px;
|
||||
}
|
||||
.p-1:first-child, .p-2 { display: none; } /* 隐藏 ID, 类型, 状态 */
|
||||
}
|
||||
|
||||
/* 手机:极致压缩,浮动操作 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.table-grid {
|
||||
grid-template-columns: 1fr 100px 140px;
|
||||
}
|
||||
.p-1:not(.name-col):not(.op-cell), .p-2, .p-3 { display: none; }
|
||||
.op-cell { width: 140px; border-left: 1px solid #f0f0f0; }
|
||||
.op-split { display: none; }
|
||||
.op-links { flex-direction: column; align-items: center; }
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination-footer {
|
||||
@@ -306,17 +635,66 @@ const toggleStatus = (index: number) => {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.total-txt { font-size: 14px; color: #606266; }
|
||||
.page-val { font-size: 14px; color: #606266; border: 1px solid #dcdfe6; padding: 4px 10px; border-radius: 4px; }
|
||||
.page-btns { display: flex; flex-direction: row; gap: 8px; }
|
||||
.p-btn {
|
||||
width: 32px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px; color: #666;
|
||||
}
|
||||
.p-btn.active { background-color: #2d8cf0; border-color: #2d8cf0; color: #fff; }
|
||||
.p-btn.disabled { color: #c0c4cc; background-color: #f5f7fa; }
|
||||
|
||||
.page-jump { display: flex; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.jump-txt { font-size: 14px; color: #606266; }
|
||||
.jump-input { width: 40px; height: 32px; border: 1px solid #dcdfe6; text-align: center; border-radius: 4px; font-size: 14px; }
|
||||
/* 🎭 模态框样式 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-container {
|
||||
width: 90%;
|
||||
max-width: 760px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 85vh;
|
||||
box-shadow: 0 10px 50px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: bold; color: #1f2d3d; }
|
||||
.modal-close { font-size: 28px; color: #bfcbd9; cursor: pointer; line-height: 1; }
|
||||
.modal-close:hover { color: #ff4949; }
|
||||
|
||||
.modal-body { padding: 24px; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
.record-table { border: 1px solid #ebeef5; border-radius: 4px; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.record-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1.5fr 100px 180px;
|
||||
}
|
||||
.record-header { background-color: #f5f7fa; border-bottom: 1px solid #ebeef5; }
|
||||
.r-th, .r-td { padding: 12px 15px; font-size: 14px; color: #606266; display: flex; align-items: center; }
|
||||
.r-th { font-weight: bold; color: #909399; }
|
||||
.record-list { flex: 1; height: 300px; }
|
||||
.record-row { border-bottom: 1px solid #f0f2f5; transition: background 0.2s; }
|
||||
.record-row:hover { background-color: #f9fafc; }
|
||||
.avatar-img { width: 32px; height: 32px; border-radius: 4px; background-color: #eee; }
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.record-grid {
|
||||
grid-template-columns: 50px 1fr 0 140px;
|
||||
}
|
||||
.record-grid .r-th:nth-child(1), .record-grid .r-td:nth-child(1),
|
||||
.record-grid .r-th:nth-child(3), .record-grid .r-td:nth-child(3) { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -173,13 +173,16 @@ const codeCountdown = ref<number>(0)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (IS_TEST_MODE) return
|
||||
// 检查是否已有 Session
|
||||
const sessionInfo = supa.getSession()
|
||||
if (sessionInfo != null && sessionInfo.user != null) {
|
||||
// 在测试模式下,依然执行自动重定向,方便登录成功后的跳转逻辑
|
||||
const pages = getCurrentPages() as any[]
|
||||
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
||||
const opts = currentPage?.options as any
|
||||
const redirect = opts?.redirect as string | null
|
||||
|
||||
console.log('检测到已有会话, 执行重定向...')
|
||||
if (redirect != null && redirect.length > 0) {
|
||||
uni.redirectTo({ url: decodeURIComponent(redirect) })
|
||||
} else {
|
||||
@@ -348,7 +351,8 @@ const handleLogin = async () => {
|
||||
}
|
||||
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
if (!IS_TEST_MODE) {
|
||||
|
||||
// 即使在测试模式下,点击登录后也执行跳转,确保进入首页
|
||||
setTimeout(() => {
|
||||
const pages = getCurrentPages() as any[]
|
||||
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
|
||||
@@ -360,7 +364,6 @@ const handleLogin = async () => {
|
||||
uni.reLaunch({ url: '/pages/mall/admin/homePage/index' })
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('登录错误:', err)
|
||||
let msg = '登录失败,请重试'
|
||||
|
||||
@@ -118,7 +118,8 @@ export class AkReq {
|
||||
// 统一 header,自动带上 Authorization/Content-Type/Accept
|
||||
let headers = options.headers ?? ({} as UTSJSONObject);
|
||||
const token = this.getToken();
|
||||
if (token != null && token != "") {
|
||||
const existAuth = headers['Authorization'] ?? headers['authorization'];
|
||||
if ((token != null && token != "") && (existAuth == null)) {
|
||||
headers = Object.assign({}, headers, { Authorization: `Bearer ${token}` }) as UTSJSONObject;
|
||||
}
|
||||
let contentType = options.contentType ?? '';
|
||||
|
||||
Reference in New Issue
Block a user