You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

379 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"use client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorBanner } from "@/components/ui/error-banner";
import { SecondaryButton } from "@/components/ui/primary-button";
import { PrimaryButton } from "@/components/ui/primary-button";
import { InputField } from "@/components/ui/input-field";
import { SelectField } from "@/components/ui/select-field";
import { Card } from "@/components/ui/card";
import {
adminGetMarkupConfigs,
adminUpdateMarkupConfig,
} from "@/lib/frontend/api-client";
import { formatDateTime, formatPercent, formatUSD } from "@/lib/frontend/format";
import { useAuth } from "@/hooks/use-auth";
import type { DataPageStatus, MarkupConfig, MarkupType } from "@/lib/frontend/types";
const PAGE_SIZE = 10;
function formatMarkupSummary(row: MarkupConfig): string {
if (row.markup_type === "fixed") {
const amount = row.markup_fixed_amount ?? 0;
return amount > 0 ? `固定 ${formatUSD(amount)}` : "未配置";
}
return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%";
}
/** 业务客户展示:始终「客户代码 · 客户名称」(不展示表 UUID) */
function formatBusinessCustomerLabel(row: MarkupConfig): string {
const code = (row.business_customer_code ?? "").trim();
const name = (row.business_customer_name ?? "").trim();
if (code && name) {
return `${code} · ${name}`;
}
if (code) return code;
if (name) return name;
return row.business_customer_id || "—";
}
function AdminMarkupPageContent() {
const { token, user } = useAuth();
const searchParams = useSearchParams();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<MarkupConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keywordInput, setKeywordInput] = useState(
() => searchParams.get("keyword") ?? "",
);
const [keyword, setKeyword] = useState(
() => searchParams.get("keyword") ?? "",
);
const loadSeqRef = useRef(0);
const [editing, setEditing] = useState<MarkupConfig | null>(null);
const [markupType, setMarkupType] = useState<MarkupType>("percent");
const [percent, setPercent] = useState("");
const [fixedAmount, setFixedAmount] = useState("");
const [remark, setRemark] = useState("");
const [saving, setSaving] = useState(false);
const [fieldError, setFieldError] = useState<string | undefined>();
const load = useCallback(
async (p: number, kw: string) => {
if (!token) return;
const seq = ++loadSeqRef.current;
setStatus("loading");
const res = await adminGetMarkupConfigs("", token, p, PAGE_SIZE, kw);
if (seq !== loadSeqRef.current) return;
if (res.code !== 0) {
setStatus("error");
return;
}
setRows(res.data.list);
setTotal(res.data.total);
setStatus(res.data.total === 0 ? "empty" : "success");
},
[token],
);
useEffect(() => {
const t = window.setTimeout(() => {
const next = keywordInput.trim();
setKeyword((prev) => {
if (prev === next) return prev;
setPage(1);
return next;
});
}, 300);
return () => window.clearTimeout(t);
}, [keywordInput]);
useEffect(() => {
void load(page, keyword);
}, [load, page, keyword]);
const openEdit = (row: MarkupConfig) => {
setEditing(row);
setMarkupType(row.markup_type);
setPercent(String(row.markup_percent));
setFixedAmount(
row.markup_fixed_amount !== null ? String(row.markup_fixed_amount) : "",
);
setRemark(row.remark ?? "");
setFieldError(undefined);
};
const handleSave = async () => {
if (!editing || !token || !user) return;
if (markupType === "percent") {
const n = Number(percent);
if (Number.isNaN(n) || n < 0 || n > 30) {
setFieldError("加价比例须在 0~30% 之间");
return;
}
} else {
const n = Number(fixedAmount);
if (Number.isNaN(n) || n < 0) {
setFieldError("固定加价金额须 ≥ 0");
return;
}
}
setSaving(true);
const res = await adminUpdateMarkupConfig("", token, editing.customer_id, {
markup_type: markupType,
markup_percent:
markupType === "percent" ? Number(percent) : undefined,
markup_fixed_amount:
markupType === "fixed" ? Number(fixedAmount) : undefined,
business_customer_id: editing.business_customer_id ?? undefined,
remark,
});
setSaving(false);
if (res.code !== 0) {
setFieldError(res.message);
return;
}
setRows((list) =>
list.map((r) =>
r.customer_id === res.data.customer_id &&
(r.business_customer_id ?? null) ===
(res.data.business_customer_id ?? null)
? {
...res.data,
// 为何这样改:旧 PUT 响应可能无 name/code,保留原展示字段避免变成 UUID
business_customer_code:
res.data.business_customer_code ?? r.business_customer_code,
business_customer_name:
res.data.business_customer_name ?? r.business_customer_name,
}
: r,
),
);
setEditing(null);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<AdminLayout>
<PageHeader
title="加价配置"
subtitle="仅业务客户加价生效;未配置时按 0% 处理"
action={
<div className="w-64">
<InputField
label="搜索租户/客户"
placeholder="租户 / 客户代码 SP012 / 名称 / 账号 STARPOST"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
/>
</div>
}
/>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner
action={
<SecondaryButton onClick={() => void load(page, keyword)}>
重试
</SecondaryButton>
}
>
加载加价配置失败
</ErrorBanner>
)}
{status === "empty" && (
<Card className="p-8 text-center text-text-secondary">
{keyword
? `未找到匹配「${keyword}」的租户或业务客户。可试:客户代码 SP012、名称 STAR、登录账号 STARPOST(数字 0 勿输成字母 O)`
: "暂无业务客户,请先在「租户管理」同步或新增客户"}
</Card>
)}
{status === "success" && (
<div className="space-y-4">
<div className="overflow-x-auto rounded-md border border-border">
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium">租户 ID</th>
<th className="px-4 py-3 font-medium">业务客户</th>
<th className="px-4 py-3 font-medium">加价方式</th>
<th className="px-4 py-3 font-medium">加价规则</th>
<th className="px-4 py-3 font-medium">操作人</th>
<th className="px-4 py-3 font-medium">备注</th>
<th className="px-4 py-3 font-medium">更新时间</th>
<th className="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={`${row.customer_id}:${row.business_customer_id ?? "default"}`}
className="border-t border-border"
>
<td className="px-4 py-3 font-mono">{row.customer_id}</td>
<td className="px-4 py-3">
<div className="font-medium text-text">
{formatBusinessCustomerLabel(row)}
</div>
</td>
<td className="px-4 py-3">
{row.markup_type === "fixed" ? "固定金额" : "百分比"}
</td>
<td className="px-4 py-3 font-mono text-primary">
{formatMarkupSummary(row)}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.operator_id || "—"}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.remark || "—"}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.operator_id
? formatDateTime(row.updated_at)
: "—"}
</td>
<td className="px-4 py-3">
<SecondaryButton onClick={() => openEdit(row)}>
编辑
</SecondaryButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm text-text-secondary">
<span>
共 {total} 条,第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<SecondaryButton
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</SecondaryButton>
<SecondaryButton
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</SecondaryButton>
</div>
</div>
</div>
)}
{editing && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !saving && setEditing(null)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">
编辑加价 · {editing.customer_id}
{` / ${formatBusinessCustomerLabel(editing)}`}
</h3>
<p className="mt-1 text-sm text-text-secondary">
百分比仅对运费加价(上限 30%,步长 0.01%);固定金额为每档报价叠加固定金额。
</p>
<div className="mt-6 space-y-4">
<SelectField
label="加价方式"
value={markupType}
disabled={saving}
options={[
{ value: "percent", label: "按运费百分比" },
{ value: "fixed", label: "固定金额(USD)" },
]}
onChange={(e) =>
setMarkupType(e.target.value as MarkupType)
}
/>
{markupType === "percent" ? (
<InputField
label="加价比例 (%)"
type="number"
step="0.01"
min={0}
max={30}
value={percent}
disabled={saving}
error={fieldError}
onChange={(e) => setPercent(e.target.value)}
/>
) : (
<InputField
label="固定加价金额 (USD)"
type="number"
step="0.01"
min={0}
value={fixedAmount}
disabled={saving}
error={fieldError}
onChange={(e) => setFixedAmount(e.target.value)}
/>
)}
<InputField
label="备注"
value={remark}
disabled={saving}
onChange={(e) => setRemark(e.target.value)}
/>
</div>
<div className="mt-8 flex justify-end gap-3">
<SecondaryButton disabled={saving} onClick={() => setEditing(null)}>
取消
</SecondaryButton>
<PrimaryButton loading={saving} onClick={() => void handleSave()}>
保存
</PrimaryButton>
</div>
</div>
</div>
)}
</AdminLayout>
);
}
export default function AdminMarkupPage() {
return (
<Suspense
fallback={
<AdminLayout>
<PageHeader title="加价配置" subtitle="按客户设置报价加价" />
<Skeleton className="h-64 w-full" />
</AdminLayout>
}
>
<AdminMarkupPageContent />
</Suspense>
);
}