Files
affiliate_dash/frontend/src/pages/merchantCenterTabs.tsx
T
yml2213 bab522bcb3 feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
2026-10-21 22:37:49 +08:00

1833 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from "antd";
import type { FormInstance } from "antd";
import {
ClockCircleOutlined,
CodeOutlined,
CopyOutlined,
DollarOutlined,
FileTextOutlined,
KeyOutlined,
LinkOutlined,
MobileOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SendOutlined,
WalletOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { merchantApi } from "../api";
import { formatDateTime } from "../utils/time";
import type {
ApiClient,
CallbackSubscription,
FulfillmentOrder,
MerchantMember,
MerchantRole,
MerchantProduct,
PageResult,
WalletAccount,
WalletLedgerEntry,
} from "../types";
import {
defaultCallbackFormValues,
eventOptions,
money,
orderStatusOptions,
orderStatusMap,
pageConfig,
rolePermissionText,
roleText,
scopeLabel,
} from "./merchantCenterUtils";
export function SecretBlock({
appKey,
secret,
}: {
appKey?: string;
secret: string;
}) {
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{appKey && (
<div>
<Typography.Text type="secondary">App Key</Typography.Text>
<Typography.Paragraph
copyable={{ text: appKey }}
code
style={{ marginTop: 8 }}
>
{appKey}
</Typography.Paragraph>
</div>
)}
<div>
<Typography.Text type="secondary">Secret</Typography.Text>
<Typography.Paragraph
copyable={{ text: secret }}
code
style={{ marginTop: 8 }}
>
{secret}
</Typography.Paragraph>
</div>
<Button
icon={<CopyOutlined />}
onClick={() =>
navigator.clipboard
.writeText(secret)
.then(() => message.success("已复制"))
}
>
复制 Secret
</Button>
</Space>
);
}
export function ProductsTab({
loading,
products,
canManage,
canEditPricing = false,
filters,
onToggleProduct,
onDeleteProduct,
onFilterChange,
onLoadProducts,
onUpdateProduct,
}: {
loading: boolean;
products: PageResult<MerchantProduct>;
canManage: boolean;
canEditPricing?: boolean;
filters: { keyword: string; status: string };
onToggleProduct: (record: MerchantProduct) => void;
onDeleteProduct: (record: MerchantProduct) => void;
onFilterChange: (filters: { keyword: string; status: string }) => void;
onLoadProducts: (page?: number, size?: number) => void;
onUpdateProduct?: (
id: number,
data: {
display_name?: string;
price_amount?: number;
cost_amount?: number;
stock?: number;
status?: "active" | "inactive";
},
) => Promise<void>;
}) {
const [keyword, setKeyword] = useState(filters.keyword);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<MerchantProduct | null>(
null,
);
const [editForm] = Form.useForm();
const [editSubmitting, setEditSubmitting] = useState(false);
const [stockMode, setStockMode] = useState<"unlimited" | "custom">(
"unlimited",
);
useEffect(() => {
setKeyword(filters.keyword);
}, [filters.keyword]);
const submitKeyword = () =>
onFilterChange({ ...filters, keyword: keyword.trim() });
const openEditModal = (record: MerchantProduct) => {
setEditingProduct(record);
const isUnlimited = record.stock < 0;
setStockMode(isUnlimited ? "unlimited" : "custom");
editForm.setFieldsValue({
display_name: record.display_name || record.product?.name || "",
price_amount: record.price_amount,
cost_amount: record.cost_amount,
stock_mode: isUnlimited ? "unlimited" : "custom",
stock_count: isUnlimited ? 100 : record.stock,
status: record.status,
});
setEditModalOpen(true);
};
const handleEditSubmit = async () => {
if (!editingProduct || !onUpdateProduct) return;
const values = await editForm.validateFields();
setEditSubmitting(true);
try {
const stock =
values.stock_mode === "unlimited"
? -1
: Number(values.stock_count ?? 0);
await onUpdateProduct(editingProduct.id, {
display_name: values.display_name?.trim(),
price_amount: Number(values.price_amount),
cost_amount: Number(values.cost_amount),
stock,
status: values.status,
});
setEditModalOpen(false);
} finally {
setEditSubmitting(false);
}
};
const productColumns: ColumnsType<MerchantProduct> = [
{
title: "商户 SKU",
dataIndex: "sku",
width: 180,
ellipsis: true,
render: (v) => (
<Typography.Text
code
copyable={{ tooltips: false }}
style={{ maxWidth: "100%" }}
>
{v}
</Typography.Text>
),
},
{
title: "商品名称",
dataIndex: "display_name",
width: 220,
ellipsis: true,
render: (_, r) => (
<div>
<div style={{ fontWeight: 600, color: "#0f172a" }}>
{r.display_name || r.product?.name || "-"}
</div>
{r.product?.category && (
<span style={{ fontSize: 11.5, color: "#64748b" }}>
{r.product.category}
</span>
)}
</div>
),
},
{
title: "目录编码",
dataIndex: ["product", "code"],
width: 150,
ellipsis: true,
render: (_, r) =>
r.product?.code ? (
<Typography.Text code>{r.product.code}</Typography.Text>
) : (
"-"
),
},
{
title: "对外售价",
dataIndex: "price_amount",
width: 120,
render: (v) => (
<span style={{ fontWeight: 700, color: "#2563eb" }}>{money(v)}</span>
),
},
{
title: "成本价",
dataIndex: "cost_amount",
width: 120,
render: (v) => <span style={{ color: "#64748b" }}>{money(v)}</span>,
},
{
title: "当前库存",
dataIndex: "stock",
width: 110,
render: (v) =>
v < 0 ? (
<Tag color="blue">无限库存</Tag>
) : v === 0 ? (
<Tag color="red">已缺货</Tag>
) : (
`${v} 件`
),
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: productStatusTag,
},
{
title: "操作",
key: "action",
width: 160,
fixed: "right",
render: (_, record) =>
canManage ? (
<Space size={4}>
{canEditPricing && (
<Button
type="link"
size="small"
onClick={() => openEditModal(record)}
>
编辑
</Button>
)}
<Popconfirm
title={
record.status === "active" ? "下架该商品?" : "上架该商品?"
}
description={
record.status === "active"
? "下架后商户将无法再创建该商品订单"
: "上架后商户可正常下单"
}
okText="确认"
onConfirm={() => onToggleProduct(record)}
>
<Button type="link" size="small">
{record.status === "active" ? "下架" : "上架"}
</Button>
</Popconfirm>
{record.status === "inactive" && (
<Popconfirm
title="删除该商品?"
description="仅从商品列表移除(软删除),历史订单不受影响。删除后不可在此恢复。"
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => onDeleteProduct(record)}
>
<Button type="link" size="small" danger>
删除
</Button>
</Popconfirm>
)}
</Space>
) : (
"-"
),
},
];
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Card
size="small"
style={{
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: 8,
}}
>
<div>
<span style={{ fontWeight: 600, color: "#0f172a", marginRight: 8 }}>
商品上架与售卖管理
</span>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
可编辑商品名称、对外积分售价、成本价及库存模式;已下架商品可删除。
</Typography.Text>
</div>
</Card>
<Space wrap>
<Input.Search
allowClear
placeholder="SKU / 商品名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={submitKeyword}
style={{ width: 240 }}
/>
<Select
allowClear
placeholder="状态"
value={filters.status || undefined}
style={{ width: 120 }}
options={[
{ value: "active", label: "已上架" },
{ value: "inactive", label: "已下架" },
]}
onChange={(status) =>
onFilterChange({ ...filters, status: status || "" })
}
/>
<Button
onClick={() => {
setKeyword("");
onFilterChange({ keyword: "", status: "" });
}}
>
重置
</Button>
</Space>
<Table
rowKey="id"
loading={loading}
columns={productColumns}
dataSource={products.list}
scroll={{ x: 1100 }}
pagination={pageConfig(products, onLoadProducts)}
/>
{/* 编辑商品售价与库存 Modal */}
<Modal
title={`编辑商品售价与库存 - [${editingProduct?.sku || ""}]`}
open={editModalOpen}
onOk={handleEditSubmit}
onCancel={() => setEditModalOpen(false)}
confirmLoading={editSubmitting}
destroyOnClose
width={560}
>
<Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item label="商品 SKU">
<Typography.Text code copyable>
{editingProduct?.sku}
</Typography.Text>
</Form.Item>
<Form.Item
name="display_name"
label="商品显示名称"
rules={[{ required: true, message: "请输入商品名称" }]}
>
<Input placeholder="展示给买家/商户的商品名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="price_amount"
label="对外售价 (积分)"
rules={[{ required: true, message: "请输入售价" }]}
tooltip="1 元 = 100 积分。例如 2500 积分对应 25.00 元"
>
<InputNumber
min={0}
step={100}
style={{ width: "100%" }}
addonAfter="积分"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="cost_amount"
label="成本价 (积分)"
rules={[{ required: true, message: "请输入成本价" }]}
tooltip="商户进货成本。例如 2100 积分对应 21.00 元"
>
<InputNumber
min={0}
step={100}
style={{ width: "100%" }}
addonAfter="积分"
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="库存设置">
<Space direction="vertical" style={{ width: "100%" }}>
<Form.Item name="stock_mode" noStyle>
<Radio.Group onChange={(e) => setStockMode(e.target.value)}>
<Radio value="unlimited">无限库存 (推荐)</Radio>
<Radio value="custom">指定有限库存</Radio>
</Radio.Group>
</Form.Item>
{stockMode === "custom" && (
<Form.Item
name="stock_count"
style={{ marginTop: 12, marginBottom: 0 }}
rules={[{ required: true, message: "请输入库存件数" }]}
>
<InputNumber min={0} style={{ width: 200 }} addonAfter="件" />
</Form.Item>
)}
</Space>
</Form.Item>
<Form.Item name="status" label="售卖状态">
<Radio.Group>
<Radio value="active">正常上架 (可售)</Radio>
<Radio value="inactive">立即下架 (不可售)</Radio>
</Radio.Group>
</Form.Item>
</Form>
</Modal>
</Space>
);
}
export function OrdersTab({
loading,
orders,
orderSource,
filters,
canManage,
onCreateManualOrder,
onOrderSourceChange,
onFilterChange,
onLoadOrders,
onCopyLink,
onOpenLink,
onRevokeLink,
onRestoreLink,
onCancelOrder,
}: {
loading: boolean;
orders: PageResult<FulfillmentOrder>;
orderSource: "api" | "manual";
filters: { keyword: string; order_status: string };
canManage: boolean;
onCreateManualOrder: () => void;
onOrderSourceChange: (source: "api" | "manual") => void;
onFilterChange: (filters: { keyword: string; order_status: string }) => void;
onLoadOrders: (page?: number, size?: number) => void;
onCopyLink: (orderNo: string) => void;
onOpenLink: (orderNo: string) => void;
onRevokeLink: (orderNo: string) => void;
onRestoreLink: (orderNo: string) => void;
onCancelOrder: (orderNo: string, reason: string) => Promise<void> | void;
}) {
const [keyword, setKeyword] = useState(filters.keyword);
const [cancelling, setCancelling] = useState<FulfillmentOrder | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelSubmitting, setCancelSubmitting] = useState(false);
useEffect(() => {
setKeyword(filters.keyword);
}, [filters.keyword]);
const submitKeyword = () =>
onFilterChange({ ...filters, keyword: keyword.trim() });
const submitCancel = async () => {
if (!cancelling) return;
if (!cancelReason.trim()) {
message.warning("请填写取消原因");
return;
}
setCancelSubmitting(true);
try {
await onCancelOrder(cancelling.order_no, cancelReason.trim());
setCancelling(null);
setCancelReason("");
} finally {
setCancelSubmitting(false);
}
};
const orderColumns: ColumnsType<FulfillmentOrder> = [
{
title: "平台订单号",
dataIndex: "order_no",
width: 190,
ellipsis: true,
render: (v) => (
<Typography.Text copyable ellipsis style={{ maxWidth: "100%" }}>
{v}
</Typography.Text>
),
},
{
title: "商户单号",
dataIndex: "client_order_no",
width: 140,
ellipsis: true,
},
{
title: "SKU",
dataIndex: "product_sku",
width: 130,
ellipsis: true,
render: (v) => (
<Typography.Text code ellipsis style={{ maxWidth: "100%" }}>
{v}
</Typography.Text>
),
},
{ title: "商品", dataIndex: "product_name", width: 220, ellipsis: true },
{ title: "基础金额", dataIndex: "base_amount", width: 88, render: money },
{
title: "手续费",
dataIndex: "service_fee_amount",
width: 82,
render: money,
},
{ title: "扣款合计", dataIndex: "amount", width: 88, render: money },
{
title: "状态",
dataIndex: "order_status",
width: 92,
render: orderStatusTag,
},
{
title: "链接有效期",
dataIndex: "delivery_link_expires_at",
width: 140,
render: (_, record) => {
if (record.delivery_link_revoked_at)
return <Tag color="red">已作废</Tag>;
if (!record.delivery_link_expires_at)
return <Tag color="green">永久有效</Tag>;
return formatDateTime(record.delivery_link_expires_at);
},
},
{
title: "时间",
dataIndex: "created_at",
width: 150,
render: formatDateTime,
},
{
title: "发货链接",
key: "delivery_link",
width: 160,
fixed: "right",
render: (_, record) => {
// 非可发货状态不再展示链接作废等操作;已交付订单保留复制/打开,便于查看兑换结果。
if (record.order_status === "cancelled") {
return <Typography.Text type="secondary">订单已取消</Typography.Text>;
}
if (record.order_status === "delivering") {
return <Typography.Text type="secondary">发货中</Typography.Text>;
}
if (record.order_status === "delivered") {
if (record.delivery_link_revoked_at) {
return <Tag color="red">已作废</Tag>;
}
// expires_at 为空表示永久有效,不视为过期
const expired =
!!record.delivery_link_expires_at &&
new Date(record.delivery_link_expires_at).getTime() < Date.now();
if (expired) {
return <Typography.Text type="secondary">已过期</Typography.Text>;
}
return (
<Space size={4} wrap>
<Button
type="link"
size="small"
onClick={() => onCopyLink(record.order_no)}
>
复制
</Button>
<Button
type="link"
size="small"
onClick={() => onOpenLink(record.order_no)}
>
打开
</Button>
</Space>
);
}
if (record.delivery_link_revoked_at) {
return (
<Button
type="link"
size="small"
onClick={() => onRestoreLink(record.order_no)}
>
恢复链接
</Button>
);
}
return (
<Space size={4} wrap>
<Button
type="link"
size="small"
onClick={() => onCopyLink(record.order_no)}
>
复制
</Button>
<Button
type="link"
size="small"
onClick={() => onOpenLink(record.order_no)}
>
打开
</Button>
<Button
type="link"
danger
size="small"
title="仅作废发货链接,不影响订单状态与积分"
onClick={() => onRevokeLink(record.order_no)}
>
链接作废
</Button>
</Space>
);
},
},
{
title: "操作",
key: "action",
width: 110,
fixed: "right",
render: (_, record) => {
if (!canManage) return "-";
if (
record.order_status === "paid" ||
record.order_status === "ship_failed"
) {
return (
<Button
type="link"
danger
size="small"
onClick={() => {
setCancelling(record);
setCancelReason("");
}}
>
取消订单
</Button>
);
}
return "-";
},
},
];
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<Tabs
activeKey={orderSource}
onChange={(key) => onOrderSourceChange(key as "api" | "manual")}
items={[
{ key: "api", label: "API 订单" },
{ key: "manual", label: "手动订单" },
]}
style={{ minWidth: 220 }}
/>
{canManage && (
<Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onCreateManualOrder}
>
创建手动订单
</Button>
</Space>
)}
</Space>
<Card size="small" style={{ borderRadius: 8 }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space wrap>
<Input.Search
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onSearch={submitKeyword}
placeholder="搜索平台订单号或商户单号"
allowClear
onClear={() => onFilterChange({ ...filters, keyword: "" })}
style={{ width: 300, maxWidth: "100%" }}
/>
</Space>
<Space wrap size={[8, 8]}>
{orderStatusOptions.map((option) => (
<Button
key={option.value || "all"}
type={
filters.order_status === option.value ? "primary" : "text"
}
onClick={() =>
onFilterChange({ ...filters, order_status: option.value })
}
style={{ minWidth: 76 }}
>
{option.label}
</Button>
))}
</Space>
</Space>
</Card>
<Table
rowKey="id"
loading={loading}
columns={orderColumns}
dataSource={orders.list}
tableLayout="fixed"
scroll={{ x: 1780 }}
pagination={pageConfig(orders, onLoadOrders)}
/>
<Modal
title={`取消订单:${cancelling?.order_no || ""}`}
open={!!cancelling}
confirmLoading={cancelSubmitting}
onOk={submitCancel}
onCancel={() => {
setCancelling(null);
setCancelReason("");
}}
okText="确认取消并退款"
okButtonProps={{ danger: true }}
destroyOnClose
>
<Alert
type="warning"
showIcon
message="取消后订单将不可再发货"
description="已扣积分将全额退还商户钱包,商品库存自动回补;已提交上游发货的失败订单不可取消。"
style={{ marginBottom: 16 }}
/>
<Input.TextArea
rows={3}
maxLength={512}
showCount
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="请填写取消原因(必填)"
/>
</Modal>
</Space>
);
}
export function WalletTab({
loading,
wallet,
canFinance,
ledger,
form,
onFilter,
onResetFilter,
onLoadLedger,
}: {
loading: boolean;
wallet: WalletAccount | null;
canFinance: boolean;
ledger: PageResult<WalletLedgerEntry>;
form: FormInstance;
onFilter: () => void;
onResetFilter: () => void;
onLoadLedger: (page: number, size: number) => void;
}) {
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
{
title: "流水号",
dataIndex: "entry_no",
width: 260,
render: (v) => (
<Typography.Text code copyable={{ tooltips: false }}>
{v}
</Typography.Text>
),
},
{ title: "类型", dataIndex: "type", width: 90, render: ledgerTypeTag },
{
title: "变动积分",
dataIndex: "amount",
width: 130,
render: moneyWithSign,
},
{
title: "结余积分",
dataIndex: "balance_after",
width: 130,
render: (v) => (
<span style={{ fontWeight: 600, color: "#0f172a" }}>{money(v)}</span>
),
},
{
title: "关联单号",
dataIndex: "reference_no",
width: 200,
ellipsis: true,
render: (v) =>
v ? (
<Typography.Text code ellipsis style={{ maxWidth: "100%" }}>
{v}
</Typography.Text>
) : (
"-"
),
},
{
title: "备注说明",
dataIndex: "note",
width: 220,
ellipsis: { showTitle: false },
render: (v) => (
<Typography.Text ellipsis title={v}>
{v || "-"}
</Typography.Text>
),
},
{
title: "变动时间",
dataIndex: "created_at",
width: 170,
render: formatDateTime,
},
];
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">可用余额</span>
<div className="metric-card-icon metric-card-icon--emerald">
<WalletOutlined />
</div>
</div>
<div className="metric-card-value" style={{ color: "#16a34a" }}>
{(wallet?.available_balance ?? 0).toLocaleString("zh-CN")}
<span
style={{
fontSize: 13,
fontWeight: 500,
marginLeft: 4,
color: "#64748b",
}}
>
积分
</span>
</div>
<div className="metric-card-footer">
<span>充值/调账</span>
<span style={{ fontWeight: 600, color: "#64748b" }}>
仅平台管理员可操作
</span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">冻结余额</span>
<div className="metric-card-icon">
<ClockCircleOutlined />
</div>
</div>
<div className="metric-card-value">
{(wallet?.frozen_balance ?? 0).toLocaleString("zh-CN")}
<span
style={{
fontSize: 13,
fontWeight: 500,
marginLeft: 4,
color: "#64748b",
}}
>
积分
</span>
</div>
<div className="metric-card-footer">
<span>提现/发货预扣</span>
<span style={{ fontWeight: 600, color: "#0f172a" }}>
0 笔预扣
</span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title">结算币种与状态</span>
<div className="metric-card-icon metric-card-icon--purple">
<DollarOutlined />
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 4,
}}
>
<Tag
color="blue"
style={{
fontSize: 13,
padding: "2px 8px",
borderRadius: 4,
margin: 0,
fontWeight: 600,
}}
>
{wallet?.currency || "POINT"}
</Tag>
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
账户正常
</span>
</div>
<div className="metric-card-footer">
<span>计费方式</span>
<span style={{ fontWeight: 600, color: "#0f172a" }}>
自动按单扣减
</span>
</div>
</div>
</Col>
</Row>
{canFinance && (
<Card
size="small"
style={{
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: 8,
}}
>
<Form form={form} layout="inline" onFinish={onFilter}>
<Form.Item name="reference_no" label="关联单号">
<Input
placeholder="店铺单号 / 充值单号"
allowClear
style={{ width: 220, borderRadius: 6 }}
/>
</Form.Item>
<Form.Item name="type" label="收支类型">
<Select placeholder="全部类型" allowClear style={{ width: 140 }}>
<Select.Option value="credit">入账 (Credit)</Select.Option>
<Select.Option value="debit">扣款 (Debit)</Select.Option>
<Select.Option value="refund">退款 (Refund)</Select.Option>
<Select.Option value="adjust">调账 (Adjust)</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button onClick={onResetFilter}>重置</Button>
<Button type="primary" htmlType="submit">
查询
</Button>
</Space>
</Form.Item>
</Form>
</Card>
)}
{canFinance ? (
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
scroll={{ x: 1300 }}
pagination={pageConfig(ledger, onLoadLedger)}
/>
) : (
<Typography.Text type="secondary">
当前角色可查看余额,钱包流水仅财务或负责人可见。
</Typography.Text>
)}
</Space>
);
}
export function ApiKeysTab({
loading,
clients,
clientMax,
isAdmin,
canManage,
form,
onOpenCreate,
onNavigateDocs,
onNavigateDebug,
onToggleClient,
onDeleteClient,
}: {
loading: boolean;
clients: ApiClient[];
clientMax: number;
isAdmin: boolean;
canManage: boolean;
form: FormInstance;
onOpenCreate: () => void;
onNavigateDocs: () => void;
onNavigateDebug: () => void;
onToggleClient: (record: ApiClient) => void;
onDeleteClient: (record: ApiClient) => void;
}) {
const apiClientColumns: ColumnsType<ApiClient> = [
{ title: "名称", dataIndex: "name", width: 140, ellipsis: true },
{
title: "App Key",
dataIndex: "app_key",
width: 230,
render: (v) => (
<Typography.Text code copyable>
{v}
</Typography.Text>
),
},
{
title: "签名",
dataIndex: "signature_version",
width: 80,
render: (v) => <Tag>{v}</Tag>,
},
{ title: "权限", dataIndex: "scopes", width: 250, render: scopesTag },
{ title: "状态", dataIndex: "status", width: 80, render: activeStatusTag },
{
title: "最后使用",
dataIndex: "last_used_at",
width: 170,
render: formatDateTime,
},
{
title: "操作",
key: "action",
width: 150,
render: (_, record) =>
canManage ? (
<Space size={0}>
<Button
type="link"
size="small"
onClick={() => onToggleClient(record)}
>
{record.status === "active" ? "禁用" : "启用"}
</Button>
<Popconfirm
title="删除该密钥?"
description={
record.status === "active"
? "请先停用该密钥再删除"
: "删除后不可恢复,使用该密钥的对接方将无法调用开放接口。"
}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => onDeleteClient(record)}
>
<Button
type="link"
size="small"
danger
disabled={record.status === "active"}
>
删除
</Button>
</Popconfirm>
</Space>
) : (
"-"
),
},
];
return (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Card
size="small"
style={{
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: 10,
}}
title={
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 12,
}}
>
<Space size={8}>
<KeyOutlined style={{ color: "#2563eb", fontSize: 16 }} />
<span style={{ fontWeight: 700, color: "#0f172a" }}>
API 密钥与开放接口鉴权
</span>
<Tag
color="blue"
style={{ borderRadius: 10, padding: "0 8px", fontWeight: 600 }}
>
已配额 {clients.length} / {clientMax}
</Tag>
</Space>
<Space size={8}>
<Button icon={<FileTextOutlined />} onClick={onNavigateDocs}>
对接文档
</Button>
{isAdmin && (
<Button icon={<CodeOutlined />} onClick={onNavigateDebug}>
接口调试
</Button>
)}
{canManage && (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={clients.length >= clientMax}
onClick={() => {
form.resetFields();
form.setFieldsValue({
signature_version: "v1",
scopes: ["products:read", "orders:read", "orders:write"],
});
onOpenCreate();
}}
>
新增 API 密钥
</Button>
)}
</Space>
</div>
}
>
<div
style={{
background: "#f8fafc",
padding: "10px 14px",
borderRadius: 8,
marginBottom: 16,
border: "1px solid #e2e8f0",
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
用于第三方分销系统或自建后台调用开放 API (商品查询、订单发货) 的
HMAC-SHA256 签名鉴权凭证。新生成的 App Secret
仅在创建成功时展示一次,请妥善保存。
</Typography.Text>
</div>
<Table
rowKey="id"
loading={loading}
columns={apiClientColumns}
dataSource={clients}
scroll={{ x: 1150 }}
pagination={false}
/>
</Card>
</Space>
);
}
export function CallbacksTab({
canManage,
callback,
form,
onSubmit,
onRotateSecret,
}: {
canManage: boolean;
callback: CallbackSubscription | null;
form: FormInstance;
onSubmit: () => void;
onRotateSecret: () => void;
}) {
return (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Card
size="small"
style={{
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: 10,
}}
title={
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 12,
}}
>
<Space size={8}>
<SendOutlined style={{ color: "#2563eb", fontSize: 16 }} />
<span style={{ fontWeight: 700, color: "#0f172a" }}>
Webhook 异步回调订阅设置
</span>
{callback?.status === "active" ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
推送已启用
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
推送已禁用
</span>
)}
</Space>
<Tag color="cyan" style={{ borderRadius: 6, fontWeight: 500 }}>
<SafetyCertificateOutlined style={{ marginRight: 4 }} />{" "}
HMAC-SHA256 签名保护
</Tag>
</div>
}
>
<div
style={{
background: "#f8fafc",
padding: "12px 16px",
borderRadius: 8,
marginBottom: 20,
border: "1px solid #e2e8f0",
}}
>
<Row gutter={[16, 8]}>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: "#64748b" }}>重试机制</div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#0f172a",
marginTop: 2,
}}
>
Outbox 队列 + 指数退避 (最多 16 次)
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: "#64748b" }}>
推送信道签名 Header
</div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#0f172a",
marginTop: 2,
}}
>
X-Signature (HMAC-SHA256)
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: "#64748b" }}>推送超时标准</div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#0f172a",
marginTop: 2,
}}
>
5000 ms 强制响应超时
</div>
</Col>
</Row>
</div>
<Form
form={form}
layout="vertical"
disabled={!canManage}
onFinish={onSubmit}
initialValues={defaultCallbackFormValues()}
>
<Form.Item
name="url"
label="回调接收端 URL"
rules={[
{ required: true, message: "请填写回调 URL" },
{ type: "url", message: "请填写正确的 HTTP(S) 地址" },
]}
>
<Input
prefix={<LinkOutlined style={{ color: "#94a3b8" }} />}
placeholder="https://your-domain.com/api/v1/webhook"
allowClear
/>
</Form.Item>
<Form.Item
name="events"
label="订阅事件类型"
rules={[{ required: true, message: "请选择至少一个回调事件" }]}
>
<Select
mode="multiple"
placeholder="请选择订阅的事件列表"
options={eventOptions}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item
name="status"
label="回调状态"
rules={[{ required: true }]}
>
<Select
options={[
{ value: "active", label: "启用回调推送" },
{ value: "disabled", label: "禁用回调推送" },
]}
style={{ width: 200 }}
/>
</Form.Item>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingTop: 16,
borderTop: "1px solid #f1f5f9",
marginTop: 20,
flexWrap: "wrap",
gap: 12,
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
{callback?.updated_at
? `上次保存:${formatDateTime(callback.updated_at)}`
: "尚未保存回调配置"}
</Typography.Text>
<Space size={12}>
{canManage && (
<Popconfirm
title="确认重置回调 Secret?"
description="重置后旧 Secret 立即失效,平台将使用新 Secret 进行 HMAC 签名推送,新 Secret 仅展示一次。"
okText="确认重置"
okButtonProps={{ danger: true }}
onConfirm={onRotateSecret}
>
<Button danger>重置 Secret</Button>
</Popconfirm>
)}
{canManage && (
<Button type="primary" htmlType="submit">
保存回调配置
</Button>
)}
</Space>
</div>
</Form>
</Card>
</Space>
);
}
export function MembersTab({
loading,
members,
roles,
canManage,
form,
onOpenAdd,
onOpenCreateRole,
onEditRole,
onEditMember,
onToggleMemberStatus,
onRemoveMember,
onReload,
currentUserID,
}: {
loading: boolean;
members: MerchantMember[];
roles: MerchantRole[];
canManage: boolean;
form: FormInstance;
onOpenAdd: () => void;
onOpenCreateRole: () => void;
onEditRole: (role: MerchantRole) => void;
onEditMember: (member: MerchantMember) => void;
onToggleMemberStatus: (member: MerchantMember) => void;
onRemoveMember: (member: MerchantMember) => void;
onReload: () => void;
currentUserID?: number;
}) {
const [phoneTarget, setPhoneTarget] = useState<MerchantMember | null>(null);
const [phoneValue, setPhoneValue] = useState("");
const [phoneSaving, setPhoneSaving] = useState(false);
// 负责人账号的手机号/设备只能由本人或平台管理员维护,避免成员改绑后接管账号。
const canEditSecurity = (member: MerchantMember) =>
canManage && (member.role !== "owner" || member.user_id === currentUserID);
const submitPhone = async () => {
if (!phoneTarget) return;
const phone = phoneValue.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
message.warning("请输入正确的 11 位手机号");
return;
}
setPhoneSaving(true);
try {
await merchantApi.bindMemberPhone(phoneTarget.id, phone);
message.success("手机号已更新");
setPhoneTarget(null);
setPhoneValue("");
onReload();
} catch (error) {
message.error(error instanceof Error ? error.message : "手机号绑定失败");
} finally {
setPhoneSaving(false);
}
};
const resetDevice = async (member: MerchantMember) => {
try {
await merchantApi.resetMemberDevice(member.id);
message.success("设备绑定已重置,该成员下次登录需重新绑定");
onReload();
} catch (error) {
message.error(error instanceof Error ? error.message : "设备重置失败");
}
};
const roleNames = new Map(roles.map((item) => [item.code, item.name]));
const memberColumns: ColumnsType<MerchantMember> = [
{
title: "用户",
dataIndex: ["user", "username"],
render: (_, r) => r.user?.username || `#${r.user_id}`,
},
{
title: "昵称",
dataIndex: ["user", "nickname"],
render: (_, r) => r.user?.nickname || "-",
},
{
title: "手机号",
width: 170,
render: (_, member) => {
const phone = member.user?.phone_masked || "";
return (
<Space size={4}>
{phone ? (
<span>{phone}</span>
) : (
<Typography.Text type="secondary">未绑定</Typography.Text>
)}
{canEditSecurity(member) && (
<Button
type="link"
size="small"
onClick={() => {
setPhoneTarget(member);
setPhoneValue("");
}}
>
{phone ? "修改" : "绑定"}
</Button>
)}
</Space>
);
},
},
{
title: "登录设备",
width: 210,
render: (_, member) => {
const bound = Boolean(member.user?.device_bound);
return (
<Space size={4}>
{bound ? (
<span>{member.user?.device_id_masked || "已绑定"}</span>
) : (
<Typography.Text type="secondary">未绑定</Typography.Text>
)}
{bound && canEditSecurity(member) && (
<Popconfirm
title="确认重置该成员绑定的设备?"
description="重置后该成员下次登录需要重新绑定本机 DCAuth。"
onConfirm={() => resetDevice(member)}
>
<Button type="link" size="small" danger>
重置
</Button>
</Popconfirm>
)}
</Space>
);
},
},
{
title: "角色",
dataIndex: "role",
width: 140,
render: (value) => (
<Tag color="blue">{roleNames.get(value) || roleText(value)}</Tag>
),
},
{
title: "默认",
dataIndex: "is_default",
width: 80,
render: (v) => (v ? <Tag color="blue">默认</Tag> : "-"),
},
{
title: "状态",
dataIndex: "status",
width: 90,
render: (v) =>
v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
},
{
title: "操作",
width: 210,
render: (_, member) => {
const protectedMember =
member.role === "owner" || member.user_id === currentUserID;
if (!canManage) return "-";
if (protectedMember)
return <Typography.Text type="secondary">受保护</Typography.Text>;
const action = member.status === 1 ? "停用" : "启用";
return (
<Space size={0}>
<Button
type="link"
size="small"
onClick={() => onEditMember(member)}
>
编辑
</Button>
<Popconfirm
title={`确认${action}该员工?`}
onConfirm={() => onToggleMemberStatus(member)}
>
<Button type="link" size="small">
{action}
</Button>
</Popconfirm>
<Popconfirm
title="确认移除该员工?"
description="若该账号没有其他商户,将一并停用并释放用户名;订单与审计记录仍会保留。"
onConfirm={() => onRemoveMember(member)}
okButtonProps={{ danger: true }}
>
<Button type="link" danger size="small">
移除
</Button>
</Popconfirm>
</Space>
);
},
},
];
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Typography.Text type="secondary">
成员仅属于当前商户;角色和权限也在当前商户内独立配置。
</Typography.Text>
{canManage && (
<Space>
<Button
onClick={() => {
form.resetFields();
onOpenCreateRole();
}}
>
新建角色
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
onOpenAdd();
}}
>
添加员工
</Button>
</Space>
)}
</Space>
<Table
rowKey="id"
loading={loading}
columns={memberColumns}
dataSource={members}
tableLayout="fixed"
/>
<Card size="small" title="团队角色">
<Table
rowKey={(role) => role.id || role.code}
size="small"
pagination={false}
dataSource={roles}
columns={[
{
title: "角色",
dataIndex: "name",
width: 160,
render: (name, role) => (
<Space>
<Tag color={role.code === "owner" ? "gold" : "blue"}>
{name}
</Tag>
{role.code === "owner" && (
<Typography.Text type="secondary">固定</Typography.Text>
)}
</Space>
),
},
{
title: "权限",
dataIndex: "permissions",
render: (permissions) =>
permissions
? permissions
.split(",")
.map((item: string) => (
<Tag key={item}>{rolePermissionText(item)}</Tag>
))
: "-",
},
{
title: "操作",
width: 90,
render: (_, role) =>
role.code === "owner" ? (
"-"
) : (
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() => onEditRole(role)}
>
编辑
</Button>
),
},
]}
/>
</Card>
<Modal
title={
phoneTarget
? `绑定手机号 · ${phoneTarget.user?.username || ""}`
: "绑定手机号"
}
open={Boolean(phoneTarget)}
onCancel={() => setPhoneTarget(null)}
onOk={() => void submitPhone()}
confirmLoading={phoneSaving}
okText="保存"
cancelText="取消"
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
绑定后该成员登录时需要短信验证码,并强制校验本机 DCAuth 设备。
</Typography.Paragraph>
<Input
value={phoneValue}
onChange={(event) => setPhoneValue(event.target.value)}
onPressEnter={() => void submitPhone()}
placeholder="请输入 11 位手机号"
maxLength={11}
inputMode="numeric"
prefix={<MobileOutlined />}
/>
</Modal>
</Space>
);
}
function moneyWithSign(value?: number | null) {
const amount = Number(value || 0);
if (amount > 0) {
return (
<span style={{ fontWeight: 700, color: "#16a34a" }}>
+{money(amount)}
</span>
);
}
if (amount < 0) {
return (
<span style={{ fontWeight: 700, color: "#dc2626" }}>{money(amount)}</span>
);
}
return (
<span style={{ fontWeight: 600, color: "#64748b" }}>{money(amount)}</span>
);
}
function productStatusTag(value: string) {
return value === "active" ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
已上架
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
已下架
</span>
);
}
function activeStatusTag(value: string) {
return value === "active" ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
已启用
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
已禁用
</span>
);
}
function orderStatusTag(value: string) {
const item = orderStatusMap[value] || { color: "default", text: value };
const tagClassMap: Record<string, string> = {
paid: "status-tag--blue",
delivering: "status-tag--cyan",
delivered: "status-tag--green",
ship_failed: "status-tag--red",
cancelled: "status-tag--gray",
};
const tagClass = tagClassMap[value] || "status-tag--gray";
return (
<span className={`status-tag ${tagClass}`}>
<span className="status-dot"></span>
{item.text}
</span>
);
}
function ledgerTypeTag(value: string) {
const map: Record<string, { color: string; text: string }> = {
credit: { color: "green", text: "入账" },
debit: { color: "red", text: "扣款" },
refund: { color: "purple", text: "退款" },
adjust: { color: "blue", text: "调整" },
};
const item = map[value] || { color: "default", text: value };
return <Tag color={item.color}>{item.text}</Tag>;
}
function scopesTag(value: string) {
const scopes = (value || "").split(",").filter(Boolean);
if (scopes.length === 0) {
return "-";
}
return (
<Space size={4} wrap>
{scopes.map((s) => (
<Tag key={s}>{scopeLabel(s)}</Tag>
))}
</Space>
);
}