feat: 卡密管理优化

This commit is contained in:
ggyy
2026-04-24 15:35:40 +08:00
parent bafd97795a
commit 8282053a34
8 changed files with 398 additions and 79 deletions

66
components/DataTable.vue Normal file
View File

@@ -0,0 +1,66 @@
<template>
<div class="space-y-3">
<div class="overflow-x-auto overflow-y-auto max-h-150">
<table class="table table-zebra w-full">
<thead class="sticky top-0 z-10 bg-base-200">
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!rows.length">
<td :colspan="columns.length" class="text-center text-base-content/60">{{ emptyText }}</td>
</tr>
<tr v-for="(row, i) in rows" :key="i">
<td v-for="col in columns" :key="col.key">
<slot :name="col.key" :row="row" :value="row[col.key]">{{ row[col.key] ?? '-' }}</slot>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="total > pageSize" class="flex items-center justify-between">
<span class="text-sm text-base-content/60"> {{ total }} {{ page }}/{{ totalPages }} </span>
<div class="join">
<button class="join-item btn btn-sm" :disabled="page <= 1" @click="$emit('update:page', page - 1)">«</button>
<button
v-for="p in pageNumbers"
:key="p"
class="join-item btn btn-sm"
:class="{ 'btn-active': p === page }"
@click="$emit('update:page', p)"
>{{ p }}</button>
<button class="join-item btn btn-sm" :disabled="page >= totalPages" @click="$emit('update:page', page + 1)">»</button>
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed } from "vue";
const props = withDefaults(defineProps<{
columns: { key: string; label: string }[];
rows: T[];
total: number;
page: number;
pageSize?: number;
emptyText?: string;
}>(), {
pageSize: 20,
emptyText: "暂无数据",
});
defineEmits<{ "update:page": [page: number] }>();
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const pageNumbers = computed(() => {
const pages: number[] = [];
const start = Math.max(1, props.page - 2);
const end = Math.min(totalPages.value, start + 4);
for (let i = start; i <= end; i++) pages.push(i);
return pages;
});
</script>

105
docs/components.md Normal file
View File

@@ -0,0 +1,105 @@
# 公共组件文档
## DataTable
通用带翻页的表格组件,基于 daisyUI `table` 样式。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `columns` | `{ key: string; label: string }[]` | — | 列定义 |
| `rows` | `T[]` | — | 当前页数据 |
| `total` | `number` | — | 总条数 |
| `page` | `number` | — | 当前页码(从 1 开始) |
| `pageSize` | `number` | `20` | 每页条数 |
| `emptyText` | `string` | `"暂无数据"` | 空状态文案 |
### Events
| 事件 | 参数 | 说明 |
|------|------|------|
| `update:page` | `page: number` | 用户切换页码时触发 |
### Slots
每一列都有一个以 `col.key` 命名的具名插槽,用于自定义单元格渲染。
插槽 props
| 名称 | 类型 | 说明 |
|------|------|------|
| `row` | `T` | 当前行完整数据 |
| `value` | `any` | 当前列的值,等同于 `row[col.key]` |
不提供插槽时,默认渲染 `value`,值为 `null`/`undefined` 时显示 `-`
### 基本用法
```components/DataTable.vue#L1-5
<DataTable
:columns="columns"
:rows="pageData.items"
:total="pageData.total"
:page="currentPage"
:page-size="20"
@update:page="handlePageChange"
/>
```
### 自定义列渲染
```components/DataTable.vue#L1-10
<DataTable :columns="columns" :rows="rows" :total="total" :page="page" @update:page="p => page = p">
<!-- 自定义状态列 -->
<template #status="{ value }">
<span class="badge badge-success">{{ value }}</span>
</template>
<!-- 自定义时间列 -->
<template #createdAt="{ value }">
{{ new Date(value).toLocaleString() }}
</template>
</DataTable>
```
### 完整示例
```pages/admin/cards/+Page.vue#L1-30
<script setup lang="ts">
import { ref, reactive } from "vue";
import DataTable from "../../../components/DataTable.vue";
import { onQueryCards } from "./queryCards.telefunc";
const PAGE_SIZE = 20;
const currentPage = ref(1);
const cardPage = ref({ items: [], total: 0 });
const columns = [
{ key: "id", label: "ID" },
{ key: "productName", label: "商品" },
{ key: "status", label: "状态" },
{ key: "createdAt", label: "创建时间" },
];
async function fetchPage(page: number) {
cardPage.value = await onQueryCards({ page, pageSize: PAGE_SIZE });
currentPage.value = page;
}
</script>
<template>
<DataTable
:columns="columns"
:rows="cardPage.items"
:total="cardPage.total"
:page="currentPage"
:page-size="PAGE_SIZE"
@update:page="fetchPage"
/>
</template>
```
### 分页说明
- 总条数 `total <= pageSize` 时,分页控件自动隐藏
- 页码按钮最多显示 5 个,以当前页为中心滑动

View File

@@ -63,3 +63,41 @@ export function countCardStats(prisma: PrismaClient) {
},
});
}
export function listCardRecordsPaged(
prisma: PrismaClient,
params: {
productId?: number;
batchNo?: string;
status?: string;
startDate?: string;
endDate?: string;
page: number;
pageSize: number;
},
) {
const where: import("../../generated/prisma/client").Prisma.CardWhereInput = {};
if (params.productId) where.productId = params.productId;
if (params.batchNo) where.batchNo = { contains: params.batchNo };
if (params.status) where.status = params.status as import("../../generated/prisma/client").CardStatus;
if (params.startDate || params.endDate) {
where.createdAt = {};
if (params.startDate) where.createdAt.gte = new Date(params.startDate);
if (params.endDate) {
const end = new Date(params.endDate);
end.setHours(23, 59, 59, 999);
where.createdAt.lte = end;
}
}
const skip = (params.page - 1) * params.pageSize;
return Promise.all([
prisma.card.findMany({ where, include: { product: true }, orderBy: [{ id: "desc" }], skip, take: params.pageSize }),
prisma.card.count({ where }),
]);
}
export function deleteCardById(prisma: PrismaClient, id: number) {
return prisma.card.deleteMany({
where: { id, status: "UNUSED" },
});
}

View File

@@ -3,7 +3,7 @@ import type { PrismaClient } from "../../generated/prisma/client";
import { badRequestError } from "../../lib/app-error";
import { getAdminContext, logAdminOperation } from "../auth/service";
import { parseCardLines } from "./importer";
import { countCardStats, createCardRecord, createManyCards, deleteUnusedCardsByProduct, listCardRecords } from "./repository";
import { countCardStats, createCardRecord, createManyCards, deleteCardById, deleteUnusedCardsByProduct, listCardRecords, listCardRecordsPaged } from "./repository";
function getInventoryContext() {
return getContext<{ prisma: PrismaClient }>();
@@ -155,3 +155,40 @@ export async function deleteUnusedCards(input: { productId: number }) {
count: result.count,
};
}
export async function deleteCard(input: { id: number }) {
const adminContext = getAdminContext();
const { prisma } = adminContext;
const adminId = Number(adminContext.session?.user?.id);
const result = await deleteCardById(prisma, input.id);
if (result.count === 0) throw badRequestError("卡密不存在或已售出,无法删除", "CARD_DELETE_FAILED");
await logAdminOperation({ action: "DELETE_CARD", targetType: "Card", targetId: String(input.id), detail: "" }, { prisma, adminId });
return { id: input.id };
}
export async function getAdminCardsPaged(params: {
productId?: number;
batchNo?: string;
status?: string;
startDate?: string;
endDate?: string;
page: number;
pageSize: number;
}) {
const { prisma } = getAdminContext();
const [cards, total] = await listCardRecordsPaged(prisma, params);
return {
total,
items: cards.map((item) => ({
id: item.id,
productId: item.productId,
productName: item.product.name,
status: item.status,
batchNo: item.batchNo,
orderId: item.orderId,
soldAt: item.soldAt ? item.soldAt.toISOString() : null,
createdAt: item.createdAt.toISOString(),
contentPreview: previewCard(item.content),
})),
};
}

View File

@@ -13,9 +13,7 @@
<h1 class="text-xl font-bold">单条新增</h1>
<select v-model="singleForm.productId" class="select select-bordered w-full">
<option value="">请选择商品</option>
<option v-for="product in products" :key="product.id" :value="String(product.id)">
{{ product.name }}
</option>
<option v-for="product in products" :key="product.id" :value="String(product.id)">{{ product.name }}</option>
</select>
<input v-model="singleForm.batchNo" class="input input-bordered w-full" placeholder="批次号(可选)" />
<textarea v-model="singleForm.content" class="textarea textarea-bordered w-full" rows="4" placeholder="输入卡密内容"></textarea>
@@ -26,9 +24,7 @@
<h2 class="text-xl font-bold">批量导入</h2>
<select v-model="importForm.productId" class="select select-bordered w-full">
<option value="">请选择商品</option>
<option v-for="product in products" :key="product.id" :value="String(product.id)">
{{ product.name }}
</option>
<option v-for="product in products" :key="product.id" :value="String(product.id)">{{ product.name }}</option>
</select>
<input v-model="importForm.batchNo" class="input input-bordered w-full" placeholder="批次号(可选)" />
<textarea v-model="importForm.lines" class="textarea textarea-bordered w-full" rows="8" placeholder="每行一条卡密"></textarea>
@@ -45,109 +41,154 @@
</section>
<section class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="mb-4 text-xl font-bold">库存列表</h2>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>ID</th>
<th>商品</th>
<th>卡密预览</th>
<th>批次</th>
<th>状态</th>
<th>订单</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
<tr v-if="!cardList.length">
<td colspan="7" class="text-center text-base-content/60">当前还没有库存卡密</td>
</tr>
<tr v-for="card in cardList" :key="card.id">
<td>{{ card.id }}</td>
<td>{{ card.productName }}</td>
<td><code>{{ card.contentPreview }}</code></td>
<td>{{ card.batchNo || '-' }}</td>
<td>
<span class="badge" :class="getStatusBadgeClass(card.status)">
{{ getStatusLabel(card.status) }}
</span>
</td>
<td>{{ card.orderId || '-' }}</td>
<td>{{ formatDate(card.createdAt) }}</td>
</tr>
</tbody>
</table>
<div class="card-body space-y-4">
<h2 class="text-xl font-bold">库存列表</h2>
<!-- 搜索筛选 -->
<div class="flex flex-wrap gap-3 items-center">
<select v-model="filter.productId" class="select select-sm select-bordered w-46">
<option value="">全部商品</option>
<option v-for="product in products" :key="product.id" :value="String(product.id)">{{ product.name }}</option>
</select>
<select v-model="filter.status" class="select select-sm select-bordered w-auto">
<option value="">全部状态</option>
<option value="UNUSED">未售出</option>
<option value="SOLD">已售出</option>
<option value="LOCKED">锁定中</option>
<option value="INVALID">已失效</option>
</select>
<input v-model="filter.batchNo" class="input input-sm input-bordered w-52" placeholder="批次号" />
<input v-model="filter.startDate" type="date" class="input input-sm input-bordered w-46" />
<input v-model="filter.endDate" type="date" class="input input-sm input-bordered w-46" />
</div>
<div class="flex gap-3">
<button class="btn btn-sm btn-primary" @click="handleSearch">搜索</button>
<button class="btn btn-sm btn-ghost" @click="handleReset">重置</button>
</div>
<DataTable
:columns="columns"
:rows="cardPage.items"
:total="cardPage.total"
:page="currentPage"
:page-size="PAGE_SIZE"
@update:page="handlePageChange"
>
<template #contentPreview="{ value }">
<code>{{ value }}</code>
</template>
<template #status="{ value }">
<span class="badge" :class="getStatusBadgeClass(value)">{{ getStatusLabel(value) }}</span>
</template>
<template #createdAt="{ value }">
{{ formatDate(value) }}
</template>
<template #actions="{ row }">
<button
v-if="row.status === 'UNUSED'"
class="btn btn-xs btn-error btn-outline"
@click="handleDeleteCard(row.id)"
>删除</button>
</template>
</DataTable>
</div>
</section>
</section>
</template>
<script setup lang="ts">
import { normalizeTelefuncError } from "../../../lib/app-error";
import { reactive, ref } from "vue";
import { useData } from "vike-vue/useData";
import { normalizeTelefuncError } from "../../../lib/app-error";
import { onCreateCard } from "./createCard.telefunc";
import { onDeleteUnusedCards } from "./deleteUnusedCards.telefunc";
import { onImportCards } from "./importCards.telefunc";
import { onQueryCards } from "./queryCards.telefunc";
import { onDeleteCard } from "./deleteCard.telefunc";
import DataTable from "../../../components/DataTable.vue";
import type { Data } from "./+data";
const { cards, products, overview } = useData<Data>();
const cardList = ref([...cards]);
const PAGE_SIZE = 20;
const currentPage = ref(1);
const cardPage = ref({ items: [...cards], total: cards.length });
const message = ref("");
const errorMessage = ref("");
const singleForm = reactive({
productId: "",
content: "",
batchNo: "",
});
const filter = reactive({ productId: "", batchNo: "", status: "", startDate: "", endDate: "" });
const importForm = reactive({
productId: "",
lines: "",
batchNo: "",
});
const singleForm = reactive({ productId: "", content: "", batchNo: "" });
const importForm = reactive({ productId: "", lines: "", batchNo: "" });
const columns = [
{ key: "id", label: "ID" },
{ key: "productName", label: "商品" },
{ key: "contentPreview", label: "卡密预览" },
{ key: "batchNo", label: "批次" },
{ key: "status", label: "状态" },
{ key: "orderId", label: "订单" },
{ key: "createdAt", label: "创建时间" },
{ key: "actions", label: "操作" },
];
function formatDate(value: string) {
return new Date(value).toLocaleString();
}
function getStatusLabel(status: string) {
return {
UNUSED: "未售出",
SOLD: "已售出",
LOCKED: "锁定中",
INVALID: "已失效"
}[status] || status;
return ({ UNUSED: "未售出", SOLD: "已售出", LOCKED: "锁定中", INVALID: "已失效" } as Record<string, string>)[status] || status;
}
function getStatusBadgeClass(status: string) {
return {
UNUSED: "badge-success",
SOLD: "badge-ghost",
LOCKED: "badge-warning",
INVALID: "badge-error"
}[status] || "badge-ghost";
return ({ UNUSED: "badge-success", SOLD: "badge-ghost", LOCKED: "badge-warning", INVALID: "badge-error" } as Record<string, string>)[status] || "badge-ghost";
}
async function fetchPage(page: number) {
const result = await onQueryCards({
productId: filter.productId ? Number(filter.productId) : undefined,
batchNo: filter.batchNo || undefined,
status: filter.status || undefined,
startDate: filter.startDate || undefined,
endDate: filter.endDate || undefined,
page,
pageSize: PAGE_SIZE,
});
cardPage.value = result;
currentPage.value = page;
}
async function handleSearch() {
await fetchPage(1);
}
async function handleReset() {
filter.productId = "";
filter.batchNo = "";
filter.status = "";
filter.startDate = "";
filter.endDate = "";
await fetchPage(1);
}
async function handlePageChange(page: number) {
await fetchPage(page);
}
async function handleCreateCard() {
message.value = "";
errorMessage.value = "";
try {
const result = await onCreateCard({
await onCreateCard({
productId: Number(singleForm.productId),
content: singleForm.content,
batchNo: singleForm.batchNo,
});
cardList.value.unshift(result);
singleForm.content = "";
singleForm.batchNo = "";
message.value = `已新增卡密 #${result.id}`;
message.value = "新增成功";
await fetchPage(1);
} catch (error) {
errorMessage.value = normalizeTelefuncError(error, "新增失败");
}
@@ -156,7 +197,6 @@ async function handleCreateCard() {
async function handleImportCards() {
message.value = "";
errorMessage.value = "";
try {
const result = await onImportCards({
productId: Number(importForm.productId),
@@ -165,24 +205,35 @@ async function handleImportCards() {
});
importForm.lines = "";
importForm.batchNo = "";
message.value = `已导入 ${result.count} 条卡密,请刷新页面查看最新列表。`;
message.value = `已导入 ${result.count} 条卡密`;
await fetchPage(1);
} catch (error) {
errorMessage.value = normalizeTelefuncError(error, "导入失败");
}
}
async function handleDeleteCard(id: number) {
if (!confirm(`确认删除卡密 #${id}?此操作不可撤销。`)) return;
message.value = "";
errorMessage.value = "";
try {
await onDeleteCard({ id });
message.value = `已删除卡密 #${id}`;
await fetchPage(currentPage.value);
} catch (error) {
errorMessage.value = normalizeTelefuncError(error, "删除失败");
}
}
async function handleDeleteUnused() {
message.value = "";
errorMessage.value = "";
try {
const result = await onDeleteUnusedCards({
productId: Number(importForm.productId),
});
cardList.value = cardList.value.filter((card) => !(card.productId === Number(importForm.productId) && card.status === 'UNUSED'));
message.value = `已删除 ${result.count} 条未售卡密。`;
const result = await onDeleteUnusedCards({ productId: Number(importForm.productId) });
message.value = `已删除 ${result.count} 条未售卡密`;
await fetchPage(currentPage.value);
} catch (error) {
errorMessage.value = normalizeTelefuncError(error, "删除失败");
}
}
</script>
</script>

View File

@@ -1,7 +1,7 @@
import { getAdminProducts } from "../../../modules/catalog/service";
import { getAdminCards, getInventoryOverview } from "../../../modules/inventory/service";
export type Data = ReturnType<typeof data>;
export type Data = Awaited<ReturnType<typeof data>>;
export async function data(pageContext: {
prisma: import("../../../generated/prisma/client").PrismaClient;

View File

@@ -0,0 +1,7 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { deleteCard } from "../../../modules/inventory/service";
export async function onDeleteCard(input: { id: number }) {
assertAdminAccess();
return deleteCard(input);
}

View File

@@ -0,0 +1,15 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { getAdminCardsPaged } from "../../../modules/inventory/service";
export async function onQueryCards(params: {
productId?: number;
batchNo?: string;
status?: string;
startDate?: string;
endDate?: string;
page: number;
pageSize: number;
}) {
assertAdminAccess();
return getAdminCardsPaged(params);
}