mirror of
https://github.com/34892002/edgeKey.git
synced 2026-05-06 15:22:43 +08:00
fix: ui
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -51,13 +51,19 @@ export async function saveCategory(input: {
|
||||
throw badRequestError("分类 slug 不能为空", "CATEGORY_SLUG_REQUIRED");
|
||||
}
|
||||
|
||||
const record = await upsertCategoryRecord(prisma, {
|
||||
id: input.id,
|
||||
name,
|
||||
slug,
|
||||
description: input.description?.trim() || null,
|
||||
sort: Number.isFinite(input.sort) ? Number(input.sort) : 0,
|
||||
});
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<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>
|
||||
<h1 class="text-2xl font-bold">分类管理</h1>
|
||||
<p class="text-sm text-base-content/70">管理前台商品分类、排序和启用状态。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1.2fr_2fr]">
|
||||
@@ -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' ? '启用' : '停用' }}
|
||||
</StatusTag>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
</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("");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' ? '上架' : '下架' }}
|
||||
</StatusTag>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex gap-2">
|
||||
<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, "删除失败") });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
20
pages/admin/products/queryProducts.telefunc.ts
Normal file
20
pages/admin/products/queryProducts.telefunc.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user