- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
1833 lines
52 KiB
TypeScript
1833 lines
52 KiB
TypeScript
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>
|
||
);
|
||
}
|