链接上数据库

This commit is contained in:
2026-02-27 12:02:15 +08:00
parent d3872250dd
commit ab0a5c53f7
5 changed files with 577 additions and 144 deletions

View File

@@ -4,12 +4,12 @@
// IP: 192.168.1.62 // IP: 192.168.1.62
// Kong HTTP Port: 8000 // Kong HTTP Port: 8000
//自己的配置自己解开即可 //自己的配置自己解开即可
//export const SUPA_URL: string = 'http://192.168.1.61:18000' export const SUPA_URL: string = 'http://192.168.1.61:18000'
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
//export const SUPA_URL: string = 'http://192.168.1.62:18000' //export const SUPA_URL: string = 'http://192.168.1.62:18000'
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' //export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
export const SUPA_URL: string = 'http://192.168.1.61:18000' // export const SUPA_URL: string = 'http://192.168.1.61:18000'
export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU' // export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
// WebSocket 实时连接(内网使用 ws:// 而非 wss:// // WebSocket 实时连接(内网使用 ws:// 而非 wss://
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket' // export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'

View File

@@ -1,6 +1,8 @@
// /components/supadb/aksupa.uts
import { AkReqResponse, AkReqUploadOptions, AkReq } from '@/uni_modules/ak-req/index.uts' import { AkReqResponse, AkReqUploadOptions, AkReq } from '@/uni_modules/ak-req/index.uts'
import type { AkReqOptions } from '@/uni_modules/ak-req/index.uts' import type { AkReqOptions } from '@/uni_modules/ak-req/index.uts'
import { toUniError } from '@/utils/utils.uts' import { toUniError } from '@/utils/utils.uts'
import { IS_TEST_MODE } from '@/ak/config.uts'
export type AkSupaSignInResult = { export type AkSupaSignInResult = {
access_token : string; access_token : string;
@@ -807,11 +809,19 @@ export class AkSupa {
*/ */
async select(table : string, filter ?: string | null, options ?: AkSupaSelectOptions) : Promise<AkReqResponse<any>> { async select(table : string, filter ?: string | null, options ?: AkSupaSelectOptions) : Promise<AkReqResponse<any>> {
let url = this.baseUrl + '/rest/v1/' + table; let url = this.baseUrl + '/rest/v1/' + table;
const token = AkReq.getToken()
let headers = { let headers = {
apikey: this.apikey, apikey: this.apikey,
'Content-Type': 'application/json', 'Content-Type': 'application/json'
Authorization: `Bearer ${AkReq.getToken() ?? ''}`
} as UTSJSONObject; } as UTSJSONObject;
// 只有在明确有用户 token 的情况下才发送 Authorization
// 否则只带 apikey这样 Kong 会自动映射到 anon 角色,避免 JWT 校验失败
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let params : string[] = []; let params : string[] = [];
if (options != null) { if (options != null) {
if (options.columns != null && !(options.columns == "")) params.push('select=' + encodeURIComponent(options.columns ?? "")); 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>> { async insert(table : string, row : UTSJSONObject | Array<UTSJSONObject>) : Promise<AkReqResponse<any>> {
const url = this.baseUrl + '/rest/v1/' + table; 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, apikey: this.apikey,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`, Authorization: authHeader,
Prefer: 'return=representation' Prefer: 'return=representation'
} as UTSJSONObject; } as UTSJSONObject;
@@ -910,7 +927,7 @@ async select_uts(table : string, filter ?: UTSJSONObject | null, options ?: AkSu
url, url,
method: 'POST', method: 'POST',
headers, headers,
data: row, // 可以是单个对象或数组 data: row,
contentType: 'application/json' contentType: 'application/json'
}; };
return await this.requestWithAutoRefresh(reqOptions); return await this.requestWithAutoRefresh(reqOptions);
@@ -928,12 +945,18 @@ async update(table : string, filter : string | null, values : UTSJSONObject) : P
if (filter!=null && filter !== "") { if (filter!=null && filter !== "") {
url += '?' + filter; url += '?' + filter;
} }
const headers = {
const token = AkReq.getToken()
let headers = {
apikey: this.apikey, apikey: this.apikey,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
Prefer: 'return=representation' Prefer: 'return=representation'
} as UTSJSONObject; } as UTSJSONObject;
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let reqOptions : AkReqOptions = { let reqOptions : AkReqOptions = {
url, url,
method: 'PATCH', method: 'PATCH',
@@ -955,12 +978,18 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
if (filter!=null && filter !== "") { if (filter!=null && filter !== "") {
url += '?' + filter; url += '?' + filter;
} }
const headers = {
const token = AkReq.getToken()
let headers = {
apikey: this.apikey, apikey: this.apikey,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
Prefer: 'return=representation' Prefer: 'return=representation'
} as UTSJSONObject; } as UTSJSONObject;
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let reqOptions : AkReqOptions = { let reqOptions : AkReqOptions = {
url, url,
method: 'DELETE', 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>> { async rpc(functionName : string, params ?: UTSJSONObject) : Promise<AkReqResponse<any>> {
const url = `${this.baseUrl}/rest/v1/rpc/${functionName}`; const url = `${this.baseUrl}/rest/v1/rpc/${functionName}`;
const headers = {
const token = AkReq.getToken()
let headers = {
apikey: this.apikey, apikey: this.apikey,
'Content-Type': 'application/json', 'Content-Type': 'application/json'
Authorization: `Bearer ${AkReq.getToken() ?? ''}`
} as UTSJSONObject; } as UTSJSONObject;
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let reqOptions : AkReqOptions = { let reqOptions : AkReqOptions = {
url, url,
method: 'POST', method: 'POST',
@@ -1042,24 +1077,34 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
// AkSupa类内新增自动刷新封装 // AkSupa类内新增自动刷新封装
async requestWithAutoRefresh(reqOptions : AkReqOptions, isRetry = false) : Promise<AkReqResponse<any>> { async requestWithAutoRefresh(reqOptions : AkReqOptions, isRetry = false) : Promise<AkReqResponse<any>> {
let res = await AkReq.request(reqOptions, false); let res = await AkReq.request(reqOptions, false);
// JWT过期Supabase风格 // JWT过期/401未授权
const isJwtExpired = (res.status == 401); //res != null && res.data != null && typeof res.data == 'object' && (res.data as UTSJSONObject)?.getString('code') == 'PGRST301'; const needsHandle = (res.status == 401);
// 401未授权
const isUnauthorized = (res.status == 401); if (needsHandle && !isRetry) {
if ((isJwtExpired || isUnauthorized) && !isRetry) {
const ok = await this.refreshSession(); const ok = await this.refreshSession();
if (ok) { if (ok) {
const newToken = AkReq.getToken() ?? ''
let headers = reqOptions.headers let headers = reqOptions.headers
if (headers == null) { if (headers == null) {
headers = new UTSJSONObject() headers = new UTSJSONObject()
} }
if (typeof headers.set == 'function') { if (typeof headers.set == 'function') {
headers.set('Authorization', `Bearer ${AkReq.getToken() ?? ''}`) headers.set('Authorization', `Bearer ${newToken}`)
reqOptions.headers = headers reqOptions.headers = headers
} }
res = await AkReq.request(reqOptions, false); res = await AkReq.request(reqOptions, false);
} else { } 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('user_id');
uni.removeStorageSync('token'); uni.removeStorageSync('token');
@@ -1067,6 +1112,12 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
throw toUniError('登录已过期,请重新登录', '用户认证失败'); throw toUniError('登录已过期,请重新登录', '用户认证失败');
} }
} }
// 额外检查:如果 status >= 400 且 res.error 为空,注入一个 error
if (res.status >= 400 && res.error == null) {
res.error = toUniError(`请求失败: ${res.status}`, 'HTTP_ERROR');
}
return res; return res;
} }
} }

View File

@@ -7,8 +7,8 @@
<view class="filter-item item-w"> <view class="filter-item item-w">
<text class="label-txt">优惠券名称:</text> <text class="label-txt">优惠券名称:</text>
<view class="input-wrap"> <view class="input-wrap">
<input class="search-input" placeholder="请输入优惠券名称" v-model="filter.name" /> <input class="search-input" v-model="filter.name" placeholder="请输入优惠券名称" />
<text class="count-txt">0/18</text> <text class="count-txt">{{ filter.name.length }}/18</text>
</view> </view>
</view> </view>
@@ -52,71 +52,86 @@
</view> </view>
</view> </view>
<!-- 表格主体 --> <!-- 表格主体 - 使用 CSS Grid 确保严格对齐 -->
<view class="table-container"> <view class="table-container">
<view class="table-header-row"> <!-- 表头 -->
<view class="th" style="width: 80px;">ID</view> <view class="table-header-row table-grid">
<view class="th" style="width: 180px;">优惠券名称</view> <view class="th p-1">ID</view>
<view class="th" style="width: 120px;">优惠券类型</view> <view class="th p-1 name-col">优惠券名称</view>
<view class="th" style="width: 100px;">面值</view> <view class="th p-2">优惠券类型</view>
<view class="th" style="width: 120px;">领取方式</view> <view class="th p-1">面值/门槛</view>
<view class="th" style="width: 150px;">领取日期</view> <view class="th p-3">领取/使用限制</view>
<view class="th" style="width: 120px;">使用时间</view> <view class="th p-3">领取日期</view>
<view class="th" style="width: 120px;">发布数量</view> <view class="th p-2">发布数量</view>
<view class="th" style="width: 100px;">是否开启</view> <view class="th p-2">是否开启</view>
<view class="th" style="flex: 1; min-width: 220px;">操作</view> <view class="th p-1 op-cell shadow-left">操作</view>
</view> </view>
<!-- 表身 -->
<scroll-view class="table-body-scroll" scroll-x="true">
<view class="table-body"> <view class="table-body">
<view v-for="(item, index) in dataList" :key="item.id" class="table-row"> <view v-for="(item, index) in dataList" :key="item.id" class="table-row table-grid">
<view class="td" style="width: 80px;"><text class="td-txt">{{ item.id }}</text></view> <view class="td p-1"><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 p-1 name-col">
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.type }}</text></view> <view class="name-box">
<view class="td" style="width: 100px;"><text class="td-txt price-txt">{{ item.value.toFixed(2) }}</text></view> <text class="td-txt name-bold" :title="item.description">{{ item.name }}</text>
<view class="td" style="width: 120px;"><text class="td-txt">{{ item.receiveType }}</text></view> <text class="date-small">{{ item.createdAt }} 创建</text>
<view class="td" style="width: 150px;"> <text v-if="item.description" class="desc-tip">{{ item.description }}</text>
<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> </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> </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>
<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-box', item.isOpen ? 'active' : '']" @click="toggleStatus(index)">
<view class="switch-dot"></view> <view class="switch-dot"></view>
</view> </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"> <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-split">|</text>
<text class="op-link">编辑</text> <text class="op-link" @click="handleEdit(item)">编辑</text>
<text class="op-split">|</text> <text class="op-split">|</text>
<text class="op-link">复制</text> <text class="op-link text-danger" @click="handleDelete(item)">删除</text>
<text class="op-split">|</text>
<text class="op-link text-danger">删除</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</scroll-view>
</view> </view>
<!-- 分页 --> <!-- 分页 -->
<view class="pagination-footer"> <view class="pagination-footer">
<text class="total-txt">共 16 条</text> <text class="total-txt">共 {{ dataList.length }} 条</text>
<view class="page-select"> <view class="page-select">
<text class="page-val">15条/页 ▼</text> <text class="page-val">10 条/页 ▼</text>
</view> </view>
<view class="page-btns"> <view class="page-btns">
<text class="p-btn disabled"><</text> <view class="p-btn disabled"><text></text></view>
<text class="p-btn active">1</text> <view class="p-btn active"><text>1</text></view>
<text class="p-btn">2</text> <view class="p-btn"><text>2</text></view>
<text class="p-btn">></text> <view class="p-btn"><text></text></view>
</view> </view>
<view class="page-jump"> <view class="page-jump">
<text class="jump-txt">前往</text> <text class="jump-txt">前往</text>
@@ -126,54 +141,266 @@
</view> </view>
</view> </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> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
interface CouponItem { interface CouponItem {
id: number id: number
id_uuid: string // 保持对 UUID 的引用以进行后续操作
name: string name: string
description: string
type: string type: string
value: number value: number
minOrderAmount: number
maxDiscountAmount: number | null
receiveType: string receiveType: string
receiveDate: string receiveDate: string
useTime: string useTime: string
publishTotal: number publishTotal: number
publishRemain: number publishRemain: number
perUserLimit: number
usageLimit: number
isOpen: boolean 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({ const filter = reactive({
name: '' name: ''
}) })
const dataList = ref<CouponItem[]>([ 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 handleQuery = () => { console.log('Querying...') } const fetchCouponTemplates = async () => {
const handleAdd = () => { console.log('Adding coupon...') } try {
const toggleStatus = (index: number) => { let query = supa.from('ml_coupon_templates').select('*')
dataList.value[index].isOpen = !dataList.value[index].isOpen
// 如果有名称筛选
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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.admin-marketing-coupon { .admin-marketing-coupon {
padding: 0px; padding: 0px;
background-color: #f5f7f9;
min-height: 100vh;
} }
.border-shadow { .border-shadow {
@@ -186,6 +413,7 @@ const toggleStatus = (index: number) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
padding: 20px;
} }
/* 过滤栏 */ /* 过滤栏 */
@@ -208,7 +436,7 @@ const toggleStatus = (index: number) => {
align-items: center; align-items: center;
padding: 0 10px; 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; } .count-txt { font-size: 12px; color: #c0c4cc; }
.select-mock { .select-mock {
@@ -239,7 +467,7 @@ const toggleStatus = (index: number) => {
.query-txt { color: #fff; font-size: 14px; } .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; } .card-header { padding: 24px; }
.btn-primary-blue { .btn-primary-blue {
@@ -251,49 +479,150 @@ const toggleStatus = (index: number) => {
} }
.btn-txt { color: #fff; font-size: 14px; } .btn-txt { color: #fff; font-size: 14px; }
.table-container { padding: 0 24px 24px; } .table-container {
.table-header-row { display: flex; flex-direction: row; background-color: #f8f8f9; border-bottom: 1px solid #e8eaec; } padding: 0 24px 24px;
.th { padding: 12px 10px; font-size: 14px; color: #515a6e; font-weight: bold; } overflow: hidden;
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; border-left: 1px solid transparent; } }
.table-row:hover { background-color: #ebf7ff; }
.td { padding: 12px 10px; display: flex; align-items: center; } /* 🧱 Grid 核心布局系统 */
.td-txt { font-size: 14px; color: #515a6e; } .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; } .name-bold { font-weight: 500; color: #333; }
.price-txt { color: #515a6e; } .price-txt { color: #f56c6c; font-weight: bold; }
.date-small { font-size: 12px; line-height: 1.4; color: #999; } .date-small { font-size: 12px; color: #999; }
.danger { color: #ed4014; }
.pub-info { display: flex; flex-direction: column; } .pub-info { display: flex; flex-direction: column; }
.pub-txt { font-size: 12px; color: #2d8cf0; } .pub-txt.danger { color: #ed4014; font-size: 11px; margin-top: 2px; }
.pub-txt.danger { color: #ed4014; }
/* 🚀 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 开关 */
.switch-box { .switch-box {
width: 44px; width: 40px;
height: 22px; height: 20px;
background-color: #dcdfe6; background-color: #dcdfe6;
border-radius: 11px; border-radius: 10px;
position: relative; position: relative;
transition: all 0.3s; transition: all 0.3s;
cursor: pointer; cursor: pointer;
} }
.switch-box.active { background-color: #2d8cf0; } .switch-box.active { background-color: #19be6b; }
.switch-dot { .switch-dot {
width: 18px; width: 16px;
height: 18px; height: 16px;
background-color: #fff; background-color: #fff;
border-radius: 9px; border-radius: 8px;
position: absolute; position: absolute;
top: 2px; top: 2px;
left: 2px; left: 2px;
transition: all 0.3s; 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; } /* 📱 响应式媒体查询 (Priority 策略) */
.op-link { color: #2d8cf0; font-size: 14px; cursor: pointer; margin: 0 5px; } /* 平板:隐藏次要列 */
.op-split { color: #e8eaec; margin: 0 5px; } @media screen and (max-width: 1450px) {
.text-danger { color: #ed4014; } .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 { .pagination-footer {
@@ -306,17 +635,66 @@ const toggleStatus = (index: number) => {
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
.total-txt { font-size: 14px; color: #606266; } .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; } .page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn { .p-btn {
width: 32px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; width: 32px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
display: flex; align-items: center; justify-content: center; font-size: 14px; color: #666; 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.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; } .modal-mask {
.jump-input { width: 40px; height: 32px; border: 1px solid #dcdfe6; text-align: center; border-radius: 4px; font-size: 14px; } 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> </style>

View File

@@ -173,13 +173,16 @@ const codeCountdown = ref<number>(0)
onMounted(() => { onMounted(() => {
try { try {
if (IS_TEST_MODE) return // 检查是否已有 Session
const sessionInfo = supa.getSession() const sessionInfo = supa.getSession()
if (sessionInfo != null && sessionInfo.user != null) { if (sessionInfo != null && sessionInfo.user != null) {
// 在测试模式下,依然执行自动重定向,方便登录成功后的跳转逻辑
const pages = getCurrentPages() as any[] const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null const redirect = opts?.redirect as string | null
console.log('检测到已有会话, 执行重定向...')
if (redirect != null && redirect.length > 0) { if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) }) uni.redirectTo({ url: decodeURIComponent(redirect) })
} else { } else {
@@ -348,7 +351,8 @@ const handleLogin = async () => {
} }
uni.showToast({ title: '登录成功', icon: 'success' }) uni.showToast({ title: '登录成功', icon: 'success' })
if (!IS_TEST_MODE) {
// 即使在测试模式下,点击登录后也执行跳转,确保进入首页
setTimeout(() => { setTimeout(() => {
const pages = getCurrentPages() as any[] const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null 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' }) uni.reLaunch({ url: '/pages/mall/admin/homePage/index' })
} }
}, 500) }, 500)
}
} catch (err) { } catch (err) {
console.error('登录错误:', err) console.error('登录错误:', err)
let msg = '登录失败,请重试' let msg = '登录失败,请重试'

View File

@@ -118,7 +118,8 @@ export class AkReq {
// 统一 header自动带上 Authorization/Content-Type/Accept // 统一 header自动带上 Authorization/Content-Type/Accept
let headers = options.headers ?? ({} as UTSJSONObject); let headers = options.headers ?? ({} as UTSJSONObject);
const token = this.getToken(); 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; headers = Object.assign({}, headers, { Authorization: `Bearer ${token}` }) as UTSJSONObject;
} }
let contentType = options.contentType ?? ''; let contentType = options.contentType ?? '';