This commit is contained in:
ggyy
2026-04-25 22:14:19 +08:00
parent 21a4996e1a
commit f05e495fa9
8 changed files with 177 additions and 104 deletions

View File

@@ -95,6 +95,22 @@ export function toAppError(error: unknown, fallback?: AppErrorOptions) {
return error;
}
// Prisma 唯一约束冲突
if (error instanceof Error && (error as any).code === "P2002") {
return conflictError("数据已存在,请检查是否重复", "UNIQUE_CONSTRAINT");
}
// Telefunc 重新包装错误后 instanceof 失效,通过 name 识别
if (error instanceof Error && error.name === "AppError") {
const e = error as AppError;
return new AppError(e.message, {
code: e.code,
statusCode: e.statusCode,
expose: e.expose,
cause: e.cause,
});
}
const abortValue = getTelefuncAbortValue(error);
if (abortValue?.message && typeof abortValue.message === "string") {
return new AppError(abortValue.message, {
@@ -117,6 +133,7 @@ export function toAppError(error: unknown, fallback?: AppErrorOptions) {
}
export function normalizeTelefuncError(error: unknown, fallbackMessage: string) {
const abortValue = getTelefuncAbortValue(error);
if (abortValue?.message && typeof abortValue.message === "string") {
return abortValue.message;

View File

@@ -51,13 +51,19 @@ export async function saveCategory(input: {
throw badRequestError("分类 slug 不能为空", "CATEGORY_SLUG_REQUIRED");
}
const record = await upsertCategoryRecord(prisma, {
let record;
try {
record = await upsertCategoryRecord(prisma, {
id: input.id,
name,
slug,
description: input.description?.trim() || null,
sort: Number.isFinite(input.sort) ? Number(input.sort) : 0,
});
} catch (e: any) {
if (e?.code === "P2002") throw conflictError("分类名称或 Slug 已存在,请换一个", "CATEGORY_SLUG_CONFLICT");
throw e;
}
await logAdminOperation(
{

View File

@@ -40,7 +40,12 @@
{{ footerText ? footerText : "© 2026 designed" }} & developed by edgeKey
</a>
</span>
<span v-html="supportContact"></span>
<p v-if="supportContact" class="flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.079 6.839a3 3 0 0 0-4.255.1M13 20h1.083A3.916 3.916 0 0 0 18 16.083V9A6 6 0 1 0 6 9v7m7 4v-1a1 1 0 0 0-1-1h-1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1Zm-7-4v-6H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h1Zm12-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1v-6Z"/>
</svg>
<p>{{ supportContact }}</p>
</p>
</div>
</footer>
</div>

View File

@@ -1,13 +1,10 @@
<template>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex items-center justify-between gap-4 max-md:flex-col max-md:items-start">
<div>
<h1 class="text-2xl font-bold">分类管理</h1>
<p class="text-sm text-base-content/70">管理前台商品分类排序和启用状态</p>
</div>
<AppButton variant="primary" size="sm" @click="resetForm">新增分类</AppButton>
</div>
<div class="grid gap-6 lg:grid-cols-[1.2fr_2fr]">
<section class="rounded-box border border-base-300 p-4">
@@ -37,45 +34,32 @@
</div>
</section>
<section class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>Slug</th>
<th>排序</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-if="!categoryList.length">
<td colspan="6" class="text-center text-base-content/60">当前还没有分类先创建第一条</td>
</tr>
<tr v-for="category in categoryList" :key="category.id">
<td>{{ category.id }}</td>
<td>
<div class="font-medium">{{ category.name }}</div>
<div v-if="category.description" class="text-xs text-base-content/60">{{ category.description }}</div>
</td>
<td>{{ category.slug }}</td>
<td>{{ category.sort }}</td>
<td>
<StatusTag :type="category.status === 'ACTIVE' ? 'success' : 'default'">
{{ category.status === 'ACTIVE' ? '启用' : '停用' }}
<section>
<DataTable
:columns="columns"
:rows="categoryList"
:total="categoryList.length"
:page="1"
:page-size="categoryList.length || 1"
empty-text="当前还没有分类先创建第一条"
>
<template #name="{ row }">
<div class="font-medium">{{ row.name }}</div>
<div v-if="row.description" class="text-xs text-base-content/60">{{ row.description }}</div>
</template>
<template #status="{ row }">
<StatusTag :type="row.status === 'ACTIVE' ? 'success' : 'default'">
{{ row.status === 'ACTIVE' ? '启用' : '停用' }}
</StatusTag>
</td>
<td>
</template>
<template #actions="{ row }">
<div class="flex gap-2">
<AppButton size="xs" variant="outline" @click="startEdit(category)">编辑</AppButton>
<AppButton size="xs" variant="outline" @click="handleToggle(category)">{{ category.status === 'ACTIVE' ? '停用' : '启用' }}</AppButton>
<AppButton size="xs" variant="danger" @click="handleDelete(category)">删除</AppButton>
<AppButton size="xs" variant="outline" @click="startEdit(row)">编辑</AppButton>
<AppButton size="xs" variant="outline" @click="handleToggle(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</AppButton>
<AppButton size="xs" variant="danger" @click="handleDelete(row)">删除</AppButton>
</div>
</td>
</tr>
</tbody>
</table>
</template>
</DataTable>
</section>
</div>
</div>
@@ -88,6 +72,7 @@ import { normalizeTelefuncError } from "../../../lib/app-error";
import { reactive, ref, useTemplateRef } from "vue";
import ConfirmDialog from "../../../components/ConfirmDialog.vue";
import AppButton from "../../../components/AppButton.vue";
import DataTable from "../../../components/DataTable.vue";
import { useData } from "vike-vue/useData";
import StatusTag from "../../../components/StatusTag.vue";
import { onDeleteCategory } from "./deleteCategory.telefunc";
@@ -97,6 +82,15 @@ import type { Data } from "./+data";
const { categories } = useData<Data>();
const columns = [
{ key: "id", label: "ID" },
{ key: "name", label: "名称" },
{ key: "slug", label: "Slug" },
{ key: "sort", label: "排序" },
{ key: "status", label: "状态" },
{ key: "actions", label: "操作" },
];
const categoryList = ref([...categories]);
const saving = ref(false);
const errorMessage = ref("");

View File

@@ -7,7 +7,7 @@
</div>
</div>
<div role="tablist" class="tabs tabs-box">
<div role="tablist" class="tabs tabs-border">
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'stats' }" @click="activeTab = 'stats'">统计</a>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'config' }" @click="activeTab = 'config'">配置</a>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'list' }" @click="activeTab = 'list'">日志</a>

View File

@@ -9,47 +9,45 @@
<AppButton href="/admin/products/new" variant="primary" size="sm">新建商品</AppButton>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>ID</th>
<th>商品</th>
<th>分类</th>
<th>价格</th>
<th>限购</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-if="!productList.length">
<td colspan="7" class="text-center text-base-content/60">当前还没有商品请先创建第一个商品</td>
</tr>
<tr v-for="product in productList" :key="product.id">
<td>{{ product.id }}</td>
<td>
<div class="font-medium">{{ product.name }}</div>
<div class="text-xs text-base-content/60">{{ product.slug }}</div>
</td>
<td>{{ product.categoryName || "未分类" }}</td>
<td>{{ formatCents(product.price) }}</td>
<td>{{ product.minBuy }} - {{ product.maxBuy }}</td>
<td>
<StatusTag :type="product.status === 'ACTIVE' ? 'success' : 'default'">
{{ product.status === 'ACTIVE' ? '上架' : '下架' }}
<div class="flex flex-wrap gap-3 items-center">
<input v-model="filter.name" class="input input-sm input-bordered w-48" placeholder="商品名称" />
<select v-model="filter.status" class="select select-sm select-bordered w-32">
<option value="">全部状态</option>
<option value="ACTIVE">上架</option>
<option value="INACTIVE">下架</option><option value="DRAFT">草稿</option>
</select>
<AppButton size="sm" variant="primary" @click="handleSearch">搜索</AppButton>
<AppButton size="sm" variant="ghost" @click="handleReset">重置</AppButton>
</div>
<DataTable
:columns="columns"
:rows="pageData.items"
:total="pageData.total"
:page="currentPage"
:page-size="PAGE_SIZE"
empty-text="当前还没有商品请先创建第一个商品"
@update:page="fetchPage"
>
<template #name="{ row }">
<div class="font-medium">{{ row.name }}</div>
<div class="text-xs text-base-content/60">{{ row.slug }}</div>
</template>
<template #categoryName="{ value }">{{ value || '未分类' }}</template>
<template #price="{ value }">{{ formatCents(value) }}</template>
<template #buy="{ row }">{{ row.minBuy }} - {{ row.maxBuy }}</template>
<template #status="{ row }">
<StatusTag :type="row.status === 'ACTIVE' ? 'success' : 'default'">
{{ row.status === 'ACTIVE' ? '上架' : '下架' }}
</StatusTag>
</td>
<td>
</template>
<template #actions="{ row }">
<div class="flex gap-2">
<AppButton :href="`/admin/products/${product.id}/edit`" variant="outline" size="xs">编辑</AppButton>
<AppButton size="xs" variant="danger" @click="handleDelete(product)">删除</AppButton>
</div>
</td>
</tr>
</tbody>
</table>
<AppButton :href="`/admin/products/${row.id}/edit`" variant="outline" size="xs">编辑</AppButton>
<AppButton size="xs" variant="danger" @click="handleDelete(row)">删除</AppButton>
</div>
</template>
</DataTable>
</div>
</section>
<ConfirmDialog ref="confirmRef" />
@@ -57,27 +55,58 @@
<script setup lang="ts">
import { normalizeTelefuncError } from "../../../lib/app-error";
import { ref, useTemplateRef } from "vue";
import { ref, reactive, useTemplateRef } from "vue";
import AppButton from "../../../components/AppButton.vue";
import ConfirmDialog from "../../../components/ConfirmDialog.vue";
import DataTable from "../../../components/DataTable.vue";
import StatusTag from "../../../components/StatusTag.vue";
import { useData } from "vike-vue/useData";
import { formatCents } from "../../../lib/utils/money";
import StatusTag from "../../../components/StatusTag.vue";
import { onDeleteProduct } from "./deleteProduct.telefunc";
import { onQueryProducts } from "./queryProducts.telefunc";
import type { Data } from "./+data";
const { products } = useData<Data>();
const productList = ref([...products]);
const { products, total } = useData<Data>();
const PAGE_SIZE = 20;
const currentPage = ref(1);
const pageData = ref({ items: [...products], total });
const filter = reactive({ name: "", status: "" });
const confirmRef = useTemplateRef<InstanceType<typeof ConfirmDialog>>("confirmRef");
async function handleDelete(product: (typeof products)[number]) {
if (!await confirmRef.value?.confirm({ title: "删除商品", message: `确认删除商品"${product.name}"吗?`, confirmText: "删除", danger: true })) {
return;
}
const columns = [
{ key: "id", label: "ID" },
{ key: "name", label: "商品" },
{ key: "categoryName", label: "分类" },
{ key: "price", label: "价格" },
{ key: "buy", label: "限购" },
{ key: "status", label: "状态" },
{ key: "actions", label: "操作" },
];
async function fetchPage(page: number) {
pageData.value = await onQueryProducts({
name: filter.name || undefined,
status: filter.status || undefined,
page,
pageSize: PAGE_SIZE,
});
currentPage.value = page;
}
async function handleSearch() { await fetchPage(1); }
async function handleReset() {
filter.name = "";
filter.status = "";
await fetchPage(1);
}
async function handleDelete(product: (typeof products)[number]) {
if (!await confirmRef.value?.confirm({ title: "删除商品", message: `确认删除商品"${product.name}"吗?`, confirmText: "删除", danger: true })) return;
try {
await onDeleteProduct({ id: product.id });
productList.value = productList.value.filter((item) => item.id !== product.id);
await fetchPage(currentPage.value);
} catch (error) {
await confirmRef.value?.alert({ title: "错误", message: normalizeTelefuncError(error, "删除失败") });
}

View File

@@ -13,8 +13,10 @@ export async function data(pageContext: {
};
}
const products = await getAdminProducts(pageContext.prisma);
return {
products: await getAdminProducts(pageContext.prisma),
products,
total: products.length,
categories: await getAdminCategories(pageContext.prisma),
};
}

View File

@@ -0,0 +1,20 @@
import { listAdminProducts } from "../../../modules/catalog/queries";
import { getContext } from "telefunc";
export async function onQueryProducts(input: {
name?: string;
status?: string;
page: number;
pageSize: number;
}) {
const { prisma } = getContext() as any;
const all = await listAdminProducts(prisma);
let items = all;
if (input.name) items = items.filter(p => p.name.includes(input.name!));
if (input.status) items = items.filter(p => p.status === input.status);
const total = items.length;
const start = (input.page - 1) * input.pageSize;
return { items: items.slice(start, start + input.pageSize), total };
}