forked from wangziqi/gongxue-base
feat: add tenant content operations
This commit is contained in:
@@ -47,12 +47,12 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
|
||||
pages/tenant-admin/workbench/index 租户后台工作台
|
||||
pages/tenant-admin/dashboard/index 数据看板
|
||||
pages/tenant-admin/students/index 学生与班级
|
||||
pages/tenant-admin/content/index 内容入口、导入任务、公共题库
|
||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、复检、公共题库采纳/同步
|
||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
|
||||
```
|
||||
|
||||
当前后台页面以只读联调和运营扫描为主,少量写入动作后续会按权限、表单校验、审计结果继续补。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、字段映射预览、模板预览和导入后复检;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
## 当前平台后台页面
|
||||
|
||||
|
||||
@@ -167,6 +167,31 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.admin-row-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-mini-button {
|
||||
min-width: 112px;
|
||||
height: 54px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #1e3a8a;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
line-height: 54px;
|
||||
}
|
||||
|
||||
.admin-mini-button.primary {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import { loadContentEntriesAdmin, loadImportJobs, loadPublicQuestionBanks, type ContentEntryAdminItem, type ImportJobItem, type PublicQuestionBankItem } from '@/services/tenantAdmin';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import {
|
||||
adoptPublicQuestionBank,
|
||||
loadContentEntriesAdmin,
|
||||
loadImportFieldMapping,
|
||||
loadImportIssues,
|
||||
loadImportJobs,
|
||||
loadImportPostCheck,
|
||||
loadImportTemplate,
|
||||
loadPublicQuestionBankConflicts,
|
||||
loadPublicQuestionBanks,
|
||||
runImportPostCheck,
|
||||
syncPublicQuestionBank,
|
||||
type ContentEntryAdminItem,
|
||||
type ImportFieldMapping,
|
||||
type ImportIssueItem,
|
||||
type ImportJobItem,
|
||||
type ImportTemplateItem,
|
||||
type PublicQuestionBankItem,
|
||||
} from '@/services/tenantAdmin';
|
||||
import '../admin.css';
|
||||
|
||||
function displayBankName(item: PublicQuestionBankItem) {
|
||||
return item.sourceQuestionBankName || item.name || '公共题库';
|
||||
}
|
||||
|
||||
function displayRegionName(item: PublicQuestionBankItem) {
|
||||
return item.sourceRegionName || item.regionName || '通用';
|
||||
}
|
||||
|
||||
function adoptionIdOf(item: PublicQuestionBankItem) {
|
||||
return item.adoptedId || (item.adoption && String(item.adoption.id || '')) || '';
|
||||
}
|
||||
|
||||
export default function TenantContentPage() {
|
||||
const [entries, setEntries] = useState<ContentEntryAdminItem[]>([]);
|
||||
const [jobs, setJobs] = useState<ImportJobItem[]>([]);
|
||||
const [publicBanks, setPublicBanks] = useState<PublicQuestionBankItem[]>([]);
|
||||
const [selectedJobId, setSelectedJobId] = useState('');
|
||||
const [selectedImportType, setSelectedImportType] = useState('questions');
|
||||
const [copyLimit, setCopyLimit] = useState('200');
|
||||
const [issues, setIssues] = useState<ImportIssueItem[]>([]);
|
||||
const [mapping, setMapping] = useState<ImportFieldMapping | null>(null);
|
||||
const [template, setTemplate] = useState<ImportTemplateItem | null>(null);
|
||||
const [postCheck, setPostCheck] = useState<Record<string, unknown> | null>(null);
|
||||
const [conflicts, setConflicts] = useState<Record<string, unknown> | null>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
function reload() {
|
||||
Promise.all([
|
||||
loadContentEntriesAdmin().catch(() => ({ items: [] })),
|
||||
loadImportJobs(20).catch(() => ({ items: [] })),
|
||||
@@ -19,8 +59,145 @@ export default function TenantContentPage() {
|
||||
setJobs(jobPayload.items || []);
|
||||
setPublicBanks(bankPayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '内容数据加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
async function confirm(title: string, content: string) {
|
||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||
return result.confirm;
|
||||
}
|
||||
|
||||
async function selectJob(item: ImportJobItem) {
|
||||
const importType = item.importType || 'questions';
|
||||
setSelectedJobId(item.id);
|
||||
setSelectedImportType(importType);
|
||||
setPostCheck(null);
|
||||
setError('');
|
||||
try {
|
||||
const [issuePayload, mappingPayload, templatePayload, postCheckPayload] = await Promise.all([
|
||||
loadImportIssues(item.id).catch(() => ({ items: [] })),
|
||||
loadImportFieldMapping(importType).catch(() => ({ item: null })),
|
||||
loadImportTemplate(importType, 'json').catch(() => ({ item: null })),
|
||||
loadImportPostCheck(item.id).catch(() => ({ item: null })),
|
||||
]);
|
||||
setIssues(issuePayload.items || []);
|
||||
setMapping(mappingPayload.item || null);
|
||||
setTemplate(templatePayload.item || null);
|
||||
setPostCheck(postCheckPayload.item || null);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '导入任务详情加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPostCheck() {
|
||||
if (!selectedJobId) {
|
||||
setError('请先选择一个导入任务。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('导入后复检', '确认对该导入任务执行后端复检?复检结果会写入任务摘要和审计日志。');
|
||||
if (!ok) return;
|
||||
setBusy('post-check');
|
||||
setError('');
|
||||
try {
|
||||
const payload = await runImportPostCheck(selectedJobId);
|
||||
setPostCheck(payload.item || null);
|
||||
Taro.showToast({ title: '复检完成', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '导入复检失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function previewTemplate(type = selectedImportType) {
|
||||
setError('');
|
||||
try {
|
||||
const [mappingPayload, templatePayload] = await Promise.all([
|
||||
loadImportFieldMapping(type),
|
||||
loadImportTemplate(type, 'json'),
|
||||
]);
|
||||
setSelectedImportType(type);
|
||||
setMapping(mappingPayload.item || null);
|
||||
setTemplate(templatePayload.item || null);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '模板加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAdopt(item: PublicQuestionBankItem) {
|
||||
if (!item.grantId) {
|
||||
setError('该公共题库缺少授权 ID,无法采纳。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('采纳公共题库', `确认采纳 ${displayBankName(item)}?系统会复制题目快照并创建本租户入口和题目集合。`);
|
||||
if (!ok) return;
|
||||
setBusy(`adopt:${item.grantId}`);
|
||||
setError('');
|
||||
try {
|
||||
await adoptPublicQuestionBank({
|
||||
grantId: item.grantId,
|
||||
entryName: displayBankName(item),
|
||||
collectionName: `${displayBankName(item)}题目`,
|
||||
copyLimit: Math.max(1, Math.min(Number(copyLimit || 200), 1000)),
|
||||
});
|
||||
Taro.showToast({ title: '已采纳', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '公共题库采纳失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSync(item: PublicQuestionBankItem) {
|
||||
const adoptionId = adoptionIdOf(item);
|
||||
if (!adoptionId) {
|
||||
setError('请先采纳公共题库,再执行同步。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('同步公共题库', `确认同步 ${displayBankName(item)} 的平台新增/更新题目?租户自改题目会被冲突保护。`);
|
||||
if (!ok) return;
|
||||
setBusy(`sync:${adoptionId}`);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await syncPublicQuestionBank({
|
||||
adoptionId,
|
||||
copyLimit: Math.max(1, Math.min(Number(copyLimit || 1000), 1000)),
|
||||
});
|
||||
setConflicts(payload.sync || null);
|
||||
Taro.showToast({ title: '同步完成', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '公共题库同步失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function showConflicts(item: PublicQuestionBankItem) {
|
||||
const adoptionId = adoptionIdOf(item);
|
||||
if (!adoptionId) {
|
||||
setError('该公共题库尚未采纳,没有冲突记录。');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const payload = await loadPublicQuestionBankConflicts(adoptionId);
|
||||
setConflicts(payload.item || null);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '冲突记录加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
const postCheckSummary = postCheck?.importPostCheck && typeof postCheck.importPostCheck === 'object'
|
||||
? postCheck.importPostCheck as Record<string, unknown>
|
||||
: postCheck;
|
||||
const postCheckIssueCount = Array.isArray(postCheckSummary?.issues) ? postCheckSummary.issues.length : 0;
|
||||
|
||||
return (
|
||||
<View className='admin-page'>
|
||||
<View className='admin-shell'>
|
||||
@@ -43,6 +220,26 @@ export default function TenantContentPage() {
|
||||
{!entries.length ? <View className='admin-empty'>暂无内容入口。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>导入操作</Text>
|
||||
<View className='admin-actions'>
|
||||
<Input className='admin-input' placeholder='采纳/同步复制题量,默认 200' type='number' value={copyLimit} onInput={event => setCopyLimit(String(event.detail.value || ''))} />
|
||||
<Button className='admin-button primary' loading={busy === 'post-check'} onClick={submitPostCheck}>复检任务</Button>
|
||||
</View>
|
||||
<View className='admin-tabs'>
|
||||
{['questions', 'vocabulary', 'handbook', 'scoreline', 'videos'].map(type => (
|
||||
<Button key={type} className={`admin-button ${selectedImportType === type ? 'active' : ''}`} onClick={() => previewTemplate(type)}>{type}</Button>
|
||||
))}
|
||||
</View>
|
||||
<View className='admin-grid'>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>当前任务</Text><Text className='admin-metric-value'>{selectedJobId ? selectedJobId.slice(0, 8) : '-'}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>问题行</Text><Text className='admin-metric-value'>{String(issues.length)}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>字段数</Text><Text className='admin-metric-value'>{String(mapping?.fields?.length || 0)}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>复检状态</Text><Text className='admin-metric-value'>{String(postCheckSummary?.status || '-')}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>复检问题</Text><Text className='admin-metric-value'>{String(postCheckIssueCount)}</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>导入任务</Text>
|
||||
<View className='admin-list'>
|
||||
@@ -50,24 +247,74 @@ export default function TenantContentPage() {
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.importType || 'import'} · {item.status || 'unknown'}</Text>
|
||||
<Text className='admin-row-meta'>总数 {item.totalCount || 0} · 成功 {item.successCount || 0} · 错误 {item.errorCount || 0} · 警告 {item.warningCount || 0}</Text>
|
||||
<Text className='admin-row-meta'>任务 {item.id} · {item.updatedAt || item.createdAt || ''}</Text>
|
||||
<View className='admin-row-actions'>
|
||||
<Button className='admin-mini-button' onClick={() => selectJob(item)}>查看问题</Button>
|
||||
<Button className='admin-mini-button' onClick={() => {
|
||||
setSelectedJobId(item.id);
|
||||
setSelectedImportType(item.importType || 'questions');
|
||||
}}>选择复检</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!jobs.length ? <View className='admin-empty'>暂无导入任务。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>问题与模板</Text>
|
||||
<View className='admin-list'>
|
||||
{issues.slice(0, 8).map(item => (
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.severity || 'issue'} · {item.code || '-'}</Text>
|
||||
<Text className='admin-row-meta'>第 {item.rowNo || '-'} 行 · {item.fieldPath || '整行'} · {item.message || '-'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!issues.length ? <View className='admin-empty'>选择导入任务后可查看问题行。</View> : null}
|
||||
{mapping?.fields?.length ? (
|
||||
<View className='admin-list'>
|
||||
{mapping.fields.slice(0, 6).map(field => (
|
||||
<View className='admin-row' key={field.field || field.label}>
|
||||
<Text className='admin-row-main'>{field.label || field.field}{field.required ? ' · 必填' : ''}</Text>
|
||||
<Text className='admin-row-meta'>{field.field || '-'} · {(field.aliases || []).join(' / ') || '无别名'}</Text>
|
||||
<Text className='admin-row-meta'>{field.description || ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{template?.contentPreview ? <View className='admin-empty'>{template.fileName || '模板'}:{template.contentPreview.slice(0, 240)}</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>公共题库</Text>
|
||||
<View className='admin-list'>
|
||||
{publicBanks.map((item, index) => (
|
||||
<View className='admin-row' key={item.grantId || item.sourceQuestionBankId || index}>
|
||||
<Text className='admin-row-main'>{item.name || '公共题库'}</Text>
|
||||
<Text className='admin-row-meta'>{item.regionName || '通用'} · {item.grantScope || '授权'} · {item.adoption ? '已采纳' : '可采纳'}</Text>
|
||||
<Text className='admin-row-main'>{displayBankName(item)}</Text>
|
||||
<Text className='admin-row-meta'>{displayRegionName(item)} · {item.grantScope || '授权'} · {adoptionIdOf(item) ? '已采纳' : '可采纳'} · {item.questionCount || 0} 题</Text>
|
||||
<Text className='admin-row-meta'>同步 {item.syncStatus || '-'} · 已复制 {item.copiedQuestionCount || 0} · {item.lastSyncedAt ? String(item.lastSyncedAt).slice(0, 10) : '未同步'}</Text>
|
||||
<View className='admin-row-actions'>
|
||||
<Button className='admin-mini-button primary' loading={busy === `adopt:${item.grantId}`} onClick={() => submitAdopt(item)}>采纳</Button>
|
||||
<Button className='admin-mini-button' loading={busy === `sync:${adoptionIdOf(item)}`} onClick={() => submitSync(item)}>同步</Button>
|
||||
<Button className='admin-mini-button' onClick={() => showConflicts(item)}>冲突</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!publicBanks.length ? <View className='admin-empty'>暂无可见公共题库授权。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>同步冲突</Text>
|
||||
{conflicts ? (
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>状态 {String(conflicts.status || conflicts.syncStatus || '-')}</Text>
|
||||
<Text className='admin-row-meta'>冲突数 {String(conflicts.conflictCount || 0)} · 最近同步 {String(conflicts.lastSyncedAt || '')}</Text>
|
||||
<Text className='admin-row-meta'>{JSON.stringify(conflicts).slice(0, 300)}</Text>
|
||||
</View>
|
||||
) : <View className='admin-empty'>同步后或点击“冲突”可查看最近冲突摘要。</View>}
|
||||
</View>
|
||||
{error ? <Text className='admin-error'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface ImportJobItem {
|
||||
warningCount?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ContentEntryAdminItem {
|
||||
@@ -68,12 +69,59 @@ export interface ContentEntryAdminItem {
|
||||
export interface PublicQuestionBankItem {
|
||||
grantId?: string;
|
||||
sourceQuestionBankId?: string;
|
||||
sourceQuestionBankName?: string;
|
||||
sourceRegionName?: string | null;
|
||||
name?: string;
|
||||
regionName?: string | null;
|
||||
grantScope?: string;
|
||||
questionCount?: number;
|
||||
adoptedId?: string | null;
|
||||
adoptionStatus?: string | null;
|
||||
syncStatus?: string | null;
|
||||
targetEntryId?: string | null;
|
||||
targetCollectionId?: string | null;
|
||||
copiedQuestionCount?: number;
|
||||
lastSyncedAt?: string | null;
|
||||
adoption?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ImportIssueItem {
|
||||
id: string;
|
||||
rowNo?: number | null;
|
||||
severity?: string;
|
||||
code?: string;
|
||||
fieldPath?: string | null;
|
||||
message?: string;
|
||||
externalId?: string | null;
|
||||
itemStatus?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ImportFieldMapping {
|
||||
importType?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
fields?: Array<{
|
||||
field?: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
aliases?: string[];
|
||||
description?: string;
|
||||
example?: unknown;
|
||||
}>;
|
||||
requiredFields?: string[];
|
||||
}
|
||||
|
||||
export interface ImportTemplateItem {
|
||||
importType?: string;
|
||||
format?: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
contentBase64?: string;
|
||||
contentPreview?: string;
|
||||
fields?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface CouponItem {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -139,13 +187,58 @@ export async function loadImportJobs(limit = 20) {
|
||||
}
|
||||
|
||||
export async function loadImportIssues(jobId: string) {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-content/imports/issues', { query: { jobId } });
|
||||
return apiRequest<{ items?: ImportIssueItem[] }>('/api/tenant-content/imports/issues', { query: { jobId } });
|
||||
}
|
||||
|
||||
export async function loadPublicQuestionBanks() {
|
||||
return apiRequest<{ items?: PublicQuestionBankItem[] }>('/api/tenant-content/public-question-banks');
|
||||
}
|
||||
|
||||
export async function adoptPublicQuestionBank(input: { grantId: string; entryName?: string; collectionName?: string; copyLimit?: number }) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/adopt', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncPublicQuestionBank(input: { adoptionId: string; copyLimit?: number }) {
|
||||
return apiRequest<{ item?: Record<string, unknown>; sync?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/sync', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPublicQuestionBankConflicts(adoptionId: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/conflicts', {
|
||||
query: { adoptionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function runImportPostCheck(jobId: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/imports/post-check', {
|
||||
method: 'POST',
|
||||
body: { jobId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadImportPostCheck(jobId: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/imports/post-check', {
|
||||
query: { jobId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadImportFieldMapping(importType: string) {
|
||||
return apiRequest<{ item?: ImportFieldMapping }>('/api/tenant-content/imports/field-mapping', {
|
||||
query: { importType },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadImportTemplate(importType: string, format: 'json' | 'csv' = 'json') {
|
||||
return apiRequest<{ item?: ImportTemplateItem }>('/api/tenant-content/imports/templates', {
|
||||
query: { importType, format },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCoupons() {
|
||||
return apiRequest<{ items?: CouponItem[] }>('/api/tenant-admin/coupons');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user