19 Commits

Author SHA1 Message Date
jacy 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 40e8a12596 Merge pull request 'Dev' (#47) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/47
2026-08-25 10:10:20 +08:00
jacy 3d819dc10b Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#45) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/45
2026-08-25 09:23:12 +08:00
jacy 8984465893 Merge pull request 'Dev' (#43) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/43
2026-08-23 19:27:28 +08:00
jacy 3f208d27a9 Merge pull request 'Dev' (#41) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/41
2026-08-23 17:34:40 +08:00
jacy fa69ced448 Merge pull request 'Dev v3.5.6' (#39) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/39
2026-08-23 16:43:51 +08:00
jacy 18c5abea79 Merge pull request 'v3.5.5版本合并' (#37) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/37
2026-08-23 12:30:04 +08:00
jacy 5d4ed566de merge(dev): HQ product list as SPU with expandable SKUs
CI / verify (push) Has been cancelled
2026-08-21 23:52:41 +08:00
jacy 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +08:00
jacy 22c0b03a47 Merge pull request 'Dev' (#35) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/35
2026-08-21 16:24:07 +08:00
jacy f2d03e2595 Merge pull request 'v3.5.4' (#33) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/33
2026-08-21 15:50:30 +08:00
jacy 0c28c204b2 Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
2026-08-20 20:23:20 +08:00
jacy e7f49a9639 Merge pull request 'v3.5.3版本更新1' (#28) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/28
2026-08-20 18:55:18 +08:00
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +08:00
jacy 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +08:00
jacy 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +08:00
jacy 1440a59fc8 Merge pull request 'Dev' (#21) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/21
2026-08-20 00:54:54 +08:00
jacy 11659484c6 Merge pull request 'v3.5.1 版本更新' (#19) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/19
2026-08-19 16:01:31 +08:00
jacy 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +08:00
118 changed files with 682 additions and 2354 deletions
-44
View File
@@ -74,50 +74,6 @@ body.admin-col-resizing * {
user-select: none !important; user-select: none !important;
} }
/* 列表页顶栏:标题左、主操作右、列设置最右 */
.admin-list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.admin-list-header-left {
min-width: 0;
}
.admin-list-header-desc {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.admin-list-header-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.admin-list-settings-slot {
display: inline-flex;
margin-left: auto;
order: 99;
flex-shrink: 0;
}
.admin-list-settings-btn.ant-btn {
color: rgba(0, 0, 0, 0.55);
padding-inline: 8px;
}
.admin-list-settings-btn.ant-btn:hover {
color: rgba(0, 0, 0, 0.88);
}
/* 操作列保持可见 */ /* 操作列保持可见 */
.admin-layout .ant-table-cell:has(.ant-btn), .admin-layout .ant-table-cell:has(.ant-btn),
.ant-drawer .ant-table-cell:has(.ant-btn), .ant-drawer .ant-table-cell:has(.ant-btn),
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react';
import { Space, Typography } from 'antd';
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
export function AdminListHeader({
title,
description,
actions,
settings,
}: {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
settings?: ReactNode;
}) {
return (
<div className="admin-list-header">
<div className="admin-list-header-left">
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
<Typography.Title level={4} style={{ margin: 0 }}>
{title}
</Typography.Title>
) : (
title
)}
{description ? (
<div className="admin-list-header-desc">{description}</div>
) : null}
</div>
<div className="admin-list-header-right">
{actions ? <Space wrap>{actions}</Space> : null}
{settings}
</div>
</div>
);
}
@@ -11,23 +11,10 @@ import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto; type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = { export type AdminStorePackagesHandle = {
/** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */ /** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>; saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
}; };
function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] {
return (live ?? []).map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
});
}
function emptyRow(index = 0): PackageRow { function emptyRow(index = 0): PackageRow {
return { return {
name: '', name: '',
@@ -50,7 +37,6 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null); const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items); const itemsRef = useRef(items);
const loadingRef = useRef(loading); const loadingRef = useRef(loading);
const dirtyRef = useRef(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -61,33 +47,36 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
loadingRef.current = loading; loadingRef.current = loading;
}, [loading]); }, [loading]);
function applyServerPackages(data: StorePackagesResponse) {
dirtyRef.current = false;
setPendingRequest(data.pendingRequest ?? null);
setItems(mapLiveRows(data.live));
}
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setPendingRequest(null); setPendingRequest(null);
dirtyRef.current = false;
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`) request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => applyServerPackages(data)) .then((data) => {
setPendingRequest(data.pendingRequest ?? null);
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [storeId]); }, [storeId]);
// 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐 // 审核页完成审核后,自动刷新本页「有待审核套餐」提醒
useEffect(() => { useEffect(() => {
const onChanged = () => { const onChanged = () => {
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`) request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => { .then((data) => setPendingRequest(data.pendingRequest ?? null))
setPendingRequest(data.pendingRequest ?? null);
if (!dirtyRef.current) {
dirtyRef.current = false;
setItems(mapLiveRows(data.live));
}
})
.catch(() => undefined); .catch(() => undefined);
}; };
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
@@ -119,19 +108,16 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
) : null; ) : null;
function updateAt(index: number, patch: Partial<PackageRow>) { function updateAt(index: number, patch: Partial<PackageRow>) {
dirtyRef.current = true;
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
} }
function addRow() { function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return; if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
dirtyRef.current = true;
setItems((prev) => [...prev, emptyRow(prev.length)]); setItems((prev) => [...prev, emptyRow(prev.length)]);
} }
function removeAt(index: number) { function removeAt(index: number) {
const run = () => { const run = () => {
dirtyRef.current = true;
setItems((prev) => { setItems((prev) => {
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
return next.length ? next : []; return next.length ? next : [];
@@ -217,8 +203,20 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
}), }),
}); });
if (!opts?.quiet) message.success('套餐已保存并生效'); if (!opts?.quiet) message.success('套餐已保存并生效');
dirtyRef.current = false; setItems(
setItems(mapLiveRows(data.live)); data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
} catch (e) { } catch (e) {
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败'); if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
throw e; throw e;
@@ -229,7 +227,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => { saveIfLoaded: async (opts) => {
if (loadingRef.current || !dirtyRef.current) return { skipped: true }; if (loadingRef.current) return { skipped: true };
await save(opts); await save(opts);
return { skipped: false }; return { skipped: false };
}, },
@@ -173,9 +173,7 @@ export default function OrderTrackDrawer({
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={ description={
track?.queryError track?.manualQueryUrl
? track.queryError
: track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接' ? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新' : '暂无路由信息,请稍后刷新'
} }
+2 -12
View File
@@ -20,11 +20,7 @@ import {
AccountBookOutlined, AccountBookOutlined,
ProjectOutlined, ProjectOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
hasAnySystemSettingsPermission,
hasAnyStoreMenuPermission,
HQ_ADMIN_ROLES,
} from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
@@ -32,10 +28,6 @@ import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
);
type MenuItem = NonNullable<MenuProps['items']>[number]; type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [ const MENU_ITEMS: MenuProps['items'] = [
@@ -413,9 +405,7 @@ export default function AdminLayout() {
</Typography.Title> </Typography.Title>
<Space> <Space>
<span>{profile?.name || '—'}</span> <span>{profile?.name || '—'}</span>
<span style={{ color: '#999' }}> <span style={{ color: '#999' }}>{profile?.adminRole}</span>
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
</span>
<Button type="text" icon={<LogoutOutlined />} onClick={logout}> <Button type="text" icon={<LogoutOutlined />} onClick={logout}>
退 退
</Button> </Button>
-2
View File
@@ -236,7 +236,5 @@ export type AdminOrderRow = {
providerOrderNo: string | null; providerOrderNo: string | null;
logisticsCompany?: string | null; logisticsCompany?: string | null;
manualQueryUrl?: string | null; manualQueryUrl?: string | null;
/** 当次应付物流费(按瓶当量 × 承运商计价) */
logisticsFee?: number | null;
}; };
}; };
@@ -153,11 +153,9 @@ export function useAdminListColumns<T>(
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]); }, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
const settingsButton = ( const settingsButton = (
<span className="admin-list-settings-slot"> <Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
</Button>
</Button>
</span>
); );
const settingsModal = ( const settingsModal = (
@@ -23,7 +23,6 @@ import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/cons
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions'; import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CouponOrder = { type CouponOrder = {
@@ -249,15 +248,17 @@ export default function BenefitCouponsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
title="好客权益券" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
actions={ </Typography.Title>
<Space>
{settingsButton}
<Button type="primary" onClick={() => setGrantOpen(true)}> <Button type="primary" onClick={() => setGrantOpen(true)}>
</Button> </Button>
} </Space>
/> </div>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="couponNo" label="券号"> <Form.Item name="couponNo" label="券号">
@@ -4,7 +4,6 @@ import type { ColumnsType } from 'antd/es/table';
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants'; import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type Row = { type Row = {
@@ -42,7 +41,8 @@ export default function BenefitLedgersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="权益流水" settings={settingsButton} /> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item> <Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item> <Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
+5 -6
View File
@@ -25,7 +25,6 @@ import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel'; import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader'; import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -327,11 +326,11 @@ export default function CitiesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="城市" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} {settingsButton}
actions={<Button type="primary" onClick={openCreateModal}></Button>} <Button type="primary" onClick={openCreateModal}></Button>
/> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item> <Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item> <Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
+23 -24
View File
@@ -37,7 +37,6 @@ import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -351,29 +350,29 @@ export default function CityPartnersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="城市合伙人" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
actions={ </Typography.Title>
<Button {settingsButton}
type="primary" <Button
onClick={() => { type="primary"
createForm.resetFields(); onClick={() => {
createForm.setFieldsValue({ createForm.resetFields();
orderCommissionRate: 0, createForm.setFieldsValue({
redeemCommissionRate: 3, orderCommissionRate: 0,
scopeType: CityPartnerScopeType.CITY_WIDE, redeemCommissionRate: 3,
}); scopeType: CityPartnerScopeType.CITY_WIDE,
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); });
setCreateMaxRate(0.05); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateCityCode(undefined); setCreateMaxRate(0.05);
setCreateOpen(true); setCreateCityCode(undefined);
}} setCreateOpen(true);
> }}
>
</Button>
} </Button>
/> </Space>
<Alert <Alert
type="info" type="info"
+22 -24
View File
@@ -14,6 +14,7 @@ import {
Switch, Switch,
Table, Table,
Tag, Tag,
Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -30,7 +31,6 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -316,29 +316,27 @@ export default function CityWarehousesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="仓库" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} {settingsButton}
actions={ <Button
<Button type="primary"
type="primary" onClick={() => {
onClick={() => { createForm.resetFields();
createForm.resetFields(); setCreateManagerType(WarehouseManagerType.HQ);
setCreateManagerType(WarehouseManagerType.HQ); setCreateCityId(undefined);
setCreateCityId(undefined); setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL); createForm.setFieldsValue({
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ,
managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE,
status: WarehouseStatus.ACTIVE, fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL, });
}); setCreateOpen(true);
setCreateOpen(true); }}
}} >
>
</Button>
</Button> </Space>
}
/>
<Alert <Alert
type="info" type="info"
+34 -217
View File
@@ -1,43 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message, Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer'; import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type DeliveryOrder = {
id: string;
orderNo: string;
status: string;
deliveryType: string;
productName: string;
productSpec?: string | null;
barcode69?: string | null;
quantity: number;
saleUnit?: string;
bottlesPerUnit?: number;
payAmount?: number;
receiverName: string;
receiverPhone: string;
receiverAddress?: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
imageResource?: { url: string } | null;
};
type Row = { type Row = {
id: string; id: string;
@@ -46,34 +18,17 @@ type Row = {
trackingNo: string | null; trackingNo: string | null;
providerOrderNo: string | null; providerOrderNo: string | null;
updatedAt: string; updatedAt: string;
logisticsFee?: number | null; order?: {
order?: DeliveryOrder; id: string;
orderNo: string;
status: string;
receiverName: string;
receiverPhone: string;
deliveryType: string;
};
}; };
function formatQty(order?: DeliveryOrder | null) {
if (!order || order.quantity == null) return '—';
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
const bottles =
order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1
? `${order.quantity * order.bottlesPerUnit}瓶)`
: '';
return `${order.quantity}${unit}${bottles}`;
}
function formatAddress(order?: DeliveryOrder | null) {
if (!order) return '—';
if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货';
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
const detail = (order.receiverAddress || '').trim();
if (!region) return detail || '—';
if (!detail || detail.startsWith(region)) return detail || region;
return `${region}${detail}`;
}
export default function DeliveriesPage() { export default function DeliveriesPage() {
const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -107,126 +62,41 @@ export default function DeliveriesPage() {
setTrackOpen(true); setTrackOpen(true);
} }
async function openDetail(row: Row) {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
provider: d.provider,
trackingNo: d.trackingNo,
providerOrderNo: d.providerOrderNo,
});
setDrawerOpen(true);
}
const baseColumns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '订单号', title: '订单号',
dataIndex: ['order', 'orderNo'], dataIndex: ['order', 'orderNo'],
width: 170, width: 170,
render: (v, row) => ( render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink> <AdminPrimaryLink
onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
), ),
}, },
{ { title: 'provider', dataIndex: 'provider', width: 90 },
title: '用户',
key: 'user',
width: 140,
render: (_, row) => {
const user = row.order?.user;
if (!user) return '—';
const label = user.userNo || user.phone || '—';
return (
<div>
{user.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
>
{label}
</AdminPrimaryLink>
) : (
label
)}
{user.phone && user.userNo ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{user.phone}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '商品',
key: 'product',
width: 200,
render: (_, row) => {
const order = row.order;
if (!order?.productName) return '—';
return (
<div>
<span>{order.productName}</span>
{order.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.productSpec}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '数量',
key: 'quantity',
width: 90,
render: (_, row) => formatQty(row.order),
},
{
title: '配送方式',
dataIndex: ['order', 'deliveryType'],
width: 90,
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
},
{
title: '承运商',
dataIndex: 'provider',
width: 90,
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
},
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' }, { title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
{
title: '运费',
dataIndex: 'logisticsFee',
width: 90,
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
},
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' }, { title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
{ { title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
title: '订单状态',
dataIndex: ['order', 'status'],
width: 100,
render: (s) => ORDER_STATUS_LABELS[s] || s,
},
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 }, { title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{
title: '收货电话',
dataIndex: ['order', 'receiverPhone'],
width: 120,
render: (v) => v || '—',
},
{
title: '配送地址',
key: 'address',
width: 280,
ellipsis: true,
render: (_, row) => formatAddress(row.order),
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 140, title: '操作', width: 140,
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => openTrack(row)}></Button> <Button type="link" size="small" onClick={() => openTrack(row)}></Button>
<Button type="link" size="small" onClick={() => void openDetail(row)}></Button> <Button type="link" size="small" onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
</Space> </Space>
), ),
}, },
@@ -234,21 +104,20 @@ export default function DeliveriesPage() {
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
const order = detail?.order;
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="快递/配送单" settings={settingsButton} /> <Typography.Title level={4}>/</Typography.Title>
{settingsButton}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item> <Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
<Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item> <Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }} <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={ extra={
<Space> <Space>
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}></Button>
@@ -265,63 +134,11 @@ export default function DeliveriesPage() {
{detail && ( {detail && (
<> <>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item> <Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
<Descriptions.Item label="用户"> <Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
{order?.user ? (
<Space size={8} wrap>
{order.user.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
>
{order.user.userNo}
</AdminPrimaryLink>
) : (
order.user.userNo || '—'
)}
<Typography.Text type="secondary">
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
</Typography.Text>
</Space>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="商品">
<Space align="start">
{order?.imageResource?.url ? (
<img
src={order.imageResource.url}
alt=""
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
/>
) : null}
<span>
{order?.productName || '—'}
{order?.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.productSpec}
</Typography.Text>
) : null}
{order?.barcode69 ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.barcode69}
</Typography.Text>
) : null}
</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
<Descriptions.Item label="配送方式">
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
</Descriptions.Item>
<Descriptions.Item label="收货人">
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
</Descriptions.Item>
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
<Descriptions.Item label="运费">
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
</Descriptions.Item>
</Descriptions> </Descriptions>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item> <Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form> </Form>
+34 -35
View File
@@ -31,7 +31,6 @@ import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -310,40 +309,40 @@ export default function DevPlanTasksPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
title="开发计划 · 任务列表" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton} ·
actions={ </Typography.Title>
<> {settingsButton}
<Select <Space wrap>
value={exportFormat} <Select
style={{ width: 120 }} value={exportFormat}
onChange={setExportFormat} style={{ width: 120 }}
options={[ onChange={setExportFormat}
{ value: 'markdown', label: 'Markdown' }, options={[
{ value: 'docx', label: 'Word' }, { value: 'markdown', label: 'Markdown' },
{ value: 'xlsx', label: 'Excel' }, { value: 'docx', label: 'Word' },
{ value: 'pdf', label: 'PDF' }, { value: 'xlsx', label: 'Excel' },
]} { value: 'pdf', label: 'PDF' },
/> ]}
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}> />
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
</Button>
<Button loading={exporting} onClick={() => void exportTasks('filter')}> </Button>
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}> </Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}> </Button>
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
</Button>
<Button type="primary" onClick={openCreate}> </Button>
<Button type="primary" onClick={openCreate}>
</Button>
</> </Button>
} </Space>
/> </div>
<Form <Form
layout="inline" layout="inline"
@@ -9,6 +9,7 @@ import {
Space, Space,
Table, Table,
Tag, Tag,
Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -22,7 +23,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -168,15 +168,15 @@ export default function DevPlanVersionsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
title="开发计划 · 版本列表" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton} ·
actions={ </Typography.Title>
<Button type="primary" onClick={openCreate}> {settingsButton}
<Button type="primary" onClick={openCreate}>
</Button>
} </Button>
/> </div>
<Form <Form
layout="inline" layout="inline"
@@ -7,11 +7,11 @@ import {
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Popconfirm,
Select, Select,
Space, Space,
Table, Table,
Tag, Tag,
Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -29,7 +29,6 @@ import {
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -69,10 +68,7 @@ export default function FulfillmentProvidersPage() {
setLoading(true); setLoading(true);
try { try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers'); const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(Array.isArray(res) ? res : []); setRows(res);
} catch (e) {
setRows([]);
message.error(e instanceof Error ? e.message : '加载承运商失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -98,7 +94,6 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee, extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles, boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee, boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: '',
}); });
setOpen(true); setOpen(true);
} }
@@ -127,7 +122,6 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee, extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles, boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee, boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: row.deliveryHintHtml || '',
}); });
setOpen(true); setOpen(true);
} }
@@ -150,7 +144,6 @@ export default function FulfillmentProvidersPage() {
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined, boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined, boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
}, },
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
}; };
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -183,16 +176,6 @@ export default function FulfillmentProvidersPage() {
void load(); void load();
} }
async function remove(row: FulfillmentProviderDto) {
try {
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
const baseColumns: ColumnsType<FulfillmentProviderDto> = [ const baseColumns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 }, { title: '编码', dataIndex: 'code', width: 100 },
{ {
@@ -247,23 +230,11 @@ export default function FulfillmentProvidersPage() {
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 140, width: 80,
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Button type="link" size="small" onClick={() => openEdit(row)}>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
</Button>
<Popconfirm
title={`确认删除承运商「${row.name}」?`}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => void remove(row)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
), ),
}, },
]; ];
@@ -276,16 +247,20 @@ export default function FulfillmentProvidersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="仓配管理" <div>
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
actions={ </Typography.Title>
<Button type="primary" onClick={openCreate}> {settingsButton}
<Typography.Text type="secondary">
</Button>
} </Typography.Text>
/> </div>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
<Alert <Alert
type="info" type="info"
@@ -317,17 +292,6 @@ export default function FulfillmentProvidersPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item
name="deliveryHintHtml"
label="配送信息提示"
extra="C 端同城送展示。支持 HTMLspan/p/br/b/strong/i/em/fontstyle 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
>
<Input.TextArea
rows={3}
maxLength={2000}
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
/>
</Form.Item>
<Divider orientation="left"></Divider> <Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}> <Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
+16 -19
View File
@@ -8,7 +8,6 @@ import { request, type HqProfile, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -146,24 +145,22 @@ export default function HqAccountsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="HQ 账户" <Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title>
settings={settingsButton} {settingsButton}
actions={ {isSuperAdmin && (
isSuperAdmin ? ( <Button
<Button type="primary"
type="primary" onClick={() => {
onClick={() => { createForm.resetFields();
createForm.resetFields(); createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }); setCreateOpen(true);
setCreateOpen(true); }}
}} >
>
</Button>
</Button> )}
) : null </Space>
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item> <Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="adminRole" label="角色"> <Form.Item name="adminRole" label="角色">
+5 -6
View File
@@ -10,7 +10,6 @@ import { fmtTime } from '../lib/constants';
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log'; import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -110,11 +109,11 @@ export default function HqLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Typography.Title level={4}>HQ </Typography.Title>
title="HQ 操作日志" {settingsButton}
settings={settingsButton} <Typography.Paragraph type="secondary">
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
/> </Typography.Paragraph>
<Form <Form
form={form} form={form}
+27 -20
View File
@@ -11,6 +11,7 @@ import {
Space, Space,
Table, Table,
Tag, Tag,
Typography,
Upload, Upload,
message, message,
} from 'antd'; } from 'antd';
@@ -30,7 +31,6 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload'; import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -241,25 +241,32 @@ export default function InvoicesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div
title="发票管理" style={{
settings={settingsButton} display: 'flex',
actions={ justifyContent: 'space-between',
<Button alignItems: 'center',
type="primary" marginBottom: 16,
onClick={() => { }}
createForm.setFieldsValue({ >
titleType: 'PERSONAL', <Typography.Title level={4} style={{ margin: 0 }}>
invoiceKind: 'NORMAL',
invoiceCategory: 'LIQUOR', </Typography.Title>
}); {settingsButton}
setCreateOpen(true); <Button
}} type="primary"
> onClick={() => {
createForm.setFieldsValue({
</Button> titleType: 'PERSONAL',
} invoiceKind: 'NORMAL',
/> invoiceCategory: 'LIQUOR',
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form <Form
form={filterForm} form={filterForm}
layout="inline" layout="inline"
@@ -26,7 +26,6 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload'; import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -265,11 +264,15 @@ export default function KnowledgeBasesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16 }} wrap>
title="知识库" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索" </Typography.Title>
/> {settingsButton}
<Typography.Text type="secondary">
.txt/.md AI
</Typography.Text>
</Space>
<Form <Form
form={filterForm} form={filterForm}
+9 -6
View File
@@ -26,7 +26,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -272,11 +271,15 @@ export default function LlmConfigsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16 }} wrap>
title="语言模型配置" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
description="非超管仅可见自己创建的配置,且创建后只能改是否生效" </Typography.Title>
/> {settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Form <Form
form={filterForm} form={filterForm}
@@ -30,7 +30,6 @@ import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillRow = { type BillRow = {
@@ -406,11 +405,15 @@ export default function LogisticsBillsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
title="物流对账" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。" </Typography.Title>
/> {settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Tabs <Tabs
items={[ items={[
+16 -40
View File
@@ -27,7 +27,6 @@ import dayjs, { type Dayjs } from 'dayjs';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types'; import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { downloadBase64File } from '../lib/exportExcel'; import { downloadBase64File } from '../lib/exportExcel';
import { import {
@@ -145,7 +144,7 @@ type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = { type OrderExportFilters = {
orderNo?: string; orderNo?: string;
status?: string | string[]; status?: string;
orderType?: string; orderType?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
@@ -220,12 +219,6 @@ function formatBenefitBrief(row: AdminOrderRow) {
return '—'; return '—';
} }
function selectedStatuses(status: unknown): string[] {
if (Array.isArray(status)) return status.filter((s): s is string => Boolean(s));
if (typeof status === 'string' && status) return [status];
return [];
}
function buildExportPayload( function buildExportPayload(
scope: OrderExportScope, scope: OrderExportScope,
format: OrderExportFormat, format: OrderExportFormat,
@@ -238,8 +231,7 @@ function buildExportPayload(
return payload; return payload;
} }
if (filters.orderNo) payload.orderNo = filters.orderNo; if (filters.orderNo) payload.orderNo = filters.orderNo;
const statuses = selectedStatuses(filters.status); if (filters.status) payload.status = filters.status;
if (statuses.length) payload.status = statuses;
if (filters.orderType) payload.orderType = filters.orderType; if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId; if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone; if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
@@ -365,7 +357,7 @@ export default function OrdersPage() {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo); if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.orderNo) qs.set('orderNo', values.orderNo);
for (const status of selectedStatuses(values.status)) qs.append('status', status); if (values.status) qs.set('status', values.status);
if (values.orderType) qs.set('orderType', values.orderType); if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
@@ -674,13 +666,6 @@ export default function OrdersPage() {
width: 90, width: 90,
render: (v: number) => `¥${v}`, render: (v: number) => `¥${v}`,
}, },
{
title: '运费',
key: 'logisticsFee',
width: 90,
render: (_, row) =>
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
},
{ {
title: '好客权益', title: '好客权益',
width: 200, width: 200,
@@ -748,20 +733,18 @@ export default function OrdersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
title="订单监控" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} <Space size={12}>
actions={ {settingsButton}
<> <Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button> {canProxyOrder ? (
{canProxyOrder ? ( <Button type="primary" onClick={() => setProxyOpen(true)}>
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
</Button> ) : null}
) : null} </Space>
</> </Space>
}
/>
{!canDeleteOrders && profile ? ( {!canDeleteOrders && profile ? (
<Alert <Alert
@@ -799,12 +782,10 @@ export default function OrdersPage() {
</Space> </Space>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} sm={12} md={8} lg={6}> <Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}> <Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Select <Select
mode="multiple"
allowClear allowClear
maxTagCount="responsive"
placeholder="全部" placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
@@ -1165,11 +1146,6 @@ export default function OrdersPage() {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item> <Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item> <Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
<Descriptions.Item label="运费">
{detail.delivery.logisticsFee == null
? '—'
: `¥${Number(detail.delivery.logisticsFee).toFixed(2)}`}
</Descriptions.Item>
{detail.delivery.manualQueryUrl && ( {detail.delivery.manualQueryUrl && (
<Descriptions.Item label="查询链接"> <Descriptions.Item label="查询链接">
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer"> <a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
@@ -8,7 +8,6 @@ import { request, type Paginated } from '../lib/api';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type PartnerOption = { id: string; companyName: string }; type PartnerOption = { id: string; companyName: string };
@@ -262,11 +261,15 @@ export default function PartnerAccountsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="合伙人子账号" <div>
settings={settingsButton} <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限" {settingsButton}
/> <Typography.Text type="secondary">
</Typography.Text>
</div>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -23,7 +23,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -350,11 +349,15 @@ export default function PartnerBillsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
title="合伙人账单" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
description="每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款" </Typography.Title>
/> {settingsButton}
<Typography.Text type="secondary">
1 8:00
</Typography.Text>
</Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
+4 -2
View File
@@ -15,7 +15,6 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -157,7 +156,10 @@ export default function PartnerLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="合伙人日志" settings={settingsButton} /> <Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title>
{settingsButton}
<Segmented <Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category} value={category}
+7 -10
View File
@@ -28,7 +28,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -219,15 +218,13 @@ export default function PartnersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="开城合伙人" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} {settingsButton}
actions={ <Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
</Button> </Space>
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item> <Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
@@ -24,7 +24,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -147,7 +146,8 @@ export default function PendingRedeemPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} /> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
+5 -6
View File
@@ -15,7 +15,6 @@ import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePick
import ProductSpecsEditor from '../components/ProductSpecsEditor'; import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form'; import type { FormInstance } from 'antd/es/form';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -511,11 +510,11 @@ export default function ProductsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="商品管理" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} {settingsButton}
actions={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>} <Button type="primary" onClick={() => setCreateOpen(true)}></Button>
/> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"> <Form.Item name="name" label="名称">
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} /> <Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
+5 -6
View File
@@ -15,7 +15,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -176,11 +175,11 @@ export default function PromoCodesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
title="推广码管理" <Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
settings={settingsButton} {settingsButton}
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>} <Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
/> </div>
<Form <Form
form={filterForm} form={filterForm}
@@ -8,7 +8,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -142,7 +141,8 @@ export default function RedeemRecordsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="核销记录" settings={settingsButton} /> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
+9 -6
View File
@@ -13,7 +13,6 @@ import {
} from '../lib/constants'; } from '../lib/constants';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type Row = { type Row = {
@@ -122,11 +121,15 @@ export default function ResourcesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="OSS 资源库" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton} OSS
actions={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>} </Typography.Title>
/> {settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
+18 -17
View File
@@ -7,7 +7,6 @@ import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -154,22 +153,24 @@ export default function StoreAccountsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="门店账户" <Space direction="vertical" size={0}>
settings={settingsButton} <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店" {settingsButton}
actions={ <Typography.Text type="secondary">
<Button
type="primary" </Typography.Text>
onClick={() => { </Space>
void loadStores(); <Button
setCreateOpen(true); type="primary"
}} onClick={() => {
> void loadStores();
setCreateOpen(true);
</Button> }}
} >
/>
</Button>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
+15 -18
View File
@@ -31,7 +31,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -382,23 +381,21 @@ export default function StoreBillsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
title="门店账单" <Typography.Title level={4} style={{ margin: 0 }}>
settings={settingsButton}
description={ </Typography.Title>
<> {settingsButton}
T+1 沿 <Typography.Text type="secondary">
= T+1 = T+1 沿
{overdueSummary && overdueSummary.overdueCount > 0 ? ( = T+1 =
<div> </Typography.Text>
<Typography.Text type="danger"> {overdueSummary && overdueSummary.overdueCount > 0 ? (
{overdueSummary.overdueCount} {overdueSummary.pendingCount} <Typography.Text type="danger">
</Typography.Text> {overdueSummary.overdueCount} {overdueSummary.pendingCount}
</div> </Typography.Text>
) : null} ) : null}
</> </Space>
}
/>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -5,7 +5,6 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -180,27 +179,27 @@ export default function StoreCategoriesPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="门店分类" <Space direction="vertical" size={0}>
settings={settingsButton} <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择" {settingsButton}
actions={ <Typography.Text type="secondary">/宿/ </Typography.Text>
<> </Space>
{canDelete ? ( <Space>
<Button {canDelete ? (
onClick={async () => { <Button
await request('/admin/store-categories/ensure-defaults', { method: 'POST' }); onClick={async () => {
message.success('已同步默认分类'); await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
void reload(); message.success('已同步默认分类');
}} void reload();
> }}
>
</Button>
) : null} </Button>
<Button type="primary" onClick={() => openCreate()}></Button> ) : null}
</> <Button type="primary" onClick={() => openCreate()}></Button>
} </Space>
/> </Space>
<Table <Table
rowKey="id" rowKey="id"
+5 -6
View File
@@ -13,7 +13,6 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -152,11 +151,11 @@ export default function StoreLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Typography.Title level={4}></Typography.Title>
title="商户日志" {settingsButton}
settings={settingsButton} <Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。" /
/> </Typography.Paragraph>
<Segmented <Segmented
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
+5 -6
View File
@@ -8,7 +8,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/cons
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -97,11 +96,11 @@ export default function StoreMediaPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="门店资源" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} {settingsButton}
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button>} <Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button>
/> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item> <Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
<Form.Item name="mediaType" label="类型"> <Form.Item name="mediaType" label="类型">
@@ -33,7 +33,6 @@ import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import StorePackageAuditPanel from '../components/StorePackageAuditPanel'; import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = { const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
@@ -546,7 +545,8 @@ export default function StorePackageAuditsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="审核通知" settings={settingsButton} /> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
@@ -6,7 +6,6 @@ import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type Row = { type Row = {
@@ -70,7 +69,10 @@ export default function StoreRatingsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="门店评价" settings={settingsButton} /> <Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -21,7 +21,6 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -238,7 +237,10 @@ export default function StoreWithdrawalsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="门店提现审" settings={settingsButton} /> <Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{settingsButton}
{overdueSummary ? ( {overdueSummary ? (
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
{overdueSummary.pendingCount} {overdueSummary.pendingCount}
+9 -8
View File
@@ -40,7 +40,6 @@ import {
} from '../lib/storeCreate'; } from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader'; import ChinaRegionCascader from '../components/ChinaRegionCascader';
@@ -874,14 +873,16 @@ export default function StoresPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="门店" <Space direction="vertical" size={0}>
settings={settingsButton} <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
description={`${data?.total ?? 0} 家门店(含合伙人录入)`} <Typography.Text type="secondary"> {data?.total ?? 0} </Typography.Text>
actions={ </Space>
<Space>
{settingsButton}
<Button type="primary" onClick={openCreateModal}></Button> <Button type="primary" onClick={openCreateModal}></Button>
} </Space>
/> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item> <Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
@@ -21,7 +21,6 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const; const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
@@ -411,7 +410,10 @@ export default function TestWhitelistPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="白名单管理" settings={settingsButton} /> <Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{settingsButton}
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}> <Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
@@ -5,7 +5,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -138,7 +137,8 @@ export default function ThirdPartyLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="第三方日志" settings={settingsButton} /> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form <Form
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
+2 -2
View File
@@ -8,7 +8,6 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -121,7 +120,8 @@ export default function UserLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader title="用户日志" settings={settingsButton} /> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Segmented <Segmented
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
+15 -16
View File
@@ -20,7 +20,6 @@ import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
@@ -482,21 +481,21 @@ export default function UsersPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
title="用户监控" <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
settings={settingsButton} <Space>
actions={ {settingsButton}
canDeleteUsers ? ( {canDeleteUsers ? (
<Button <Button
danger danger
disabled={!selectedRowKeys.length} disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()} onClick={() => void openBatchDeleteModal()}
> >
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button> </Button>
) : null ) : null}
} </Space>
/> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号"> <Form.Item name="phone" label="手机号">
<Input placeholder="模糊搜索" allowClear /> <Input placeholder="模糊搜索" allowClear />
@@ -8,7 +8,6 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime, maskPhone } from '../lib/constants'; import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -211,11 +210,11 @@ export default function WechatBindingsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Typography.Title level={4}></Typography.Title>
title="微信绑定总览" {settingsButton}
settings={settingsButton} <Typography.Paragraph type="secondary">
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。" unionId C HQ unionId
/> </Typography.Paragraph>
<Form <Form
form={form} form={form}
@@ -6,7 +6,6 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import type { WecomBotLogDto } from '@dukang/shared-types'; import type { WecomBotLogDto } from '@dukang/shared-types';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -65,11 +64,11 @@ export default function WecomBotLogsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <Typography.Title level={4}></Typography.Title>
title="企微机器人日志" {settingsButton}
settings={settingsButton} <Typography.Paragraph type="secondary">
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
/> </Typography.Paragraph>
<Form <Form
form={form} form={form}
@@ -35,7 +35,6 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -273,15 +272,9 @@ function PushRoutesTab() {
return ( return (
<> <>
{settingsModal} {settingsModal}
<AdminListHeader <Typography.Paragraph type="secondary">
settings={settingsButton} Webhook / .env Webhook URL
description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。" </Typography.Paragraph>
actions={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
<Form <Form
form={filterForm} form={filterForm}
@@ -316,6 +309,10 @@ function PushRoutesTab() {
> >
</Button> </Button>
<Button type="primary" onClick={openCreate}>
</Button>
{settingsButton}
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
+25 -13
View File
@@ -27,7 +27,6 @@ import { downloadExcelCsv } from '../lib/exportExcel';
import { request, type HqProfile } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -306,18 +305,31 @@ export default function WineryBillsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div
title="酒厂对账单" style={{
settings={settingsButton} display: 'flex',
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`} justifyContent: 'space-between',
actions={ alignItems: 'flex-start',
canEditWineryBank ? ( marginBottom: 16,
<Button type="default" onClick={() => void openBankModal()}> gap: 16,
}}
</Button> >
) : null <Space direction="vertical" size={0}>
} <Typography.Title level={4} style={{ margin: 0 }}>
/>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text>
</Space>
{canEditWineryBank ? (
<Button type="default" onClick={() => void openBankModal()}>
</Button>
) : null}
</div>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -11,7 +11,6 @@ import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import { useAdminListColumns } from '../../lib/useAdminListColumns'; import { useAdminListColumns } from '../../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
import { AdminListHeader } from '../../components/AdminListHeader';
export default function PromoCodeUsersPage() { export default function PromoCodeUsersPage() {
@@ -85,24 +84,20 @@ export default function PromoCodeUsersPage() {
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize }); const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
return ( return (
<> <Table
{settingsModal} rowKey="id"
<AdminListHeader settings={settingsButton} /> className="admin-table-nowrap"
<Table loading={loading}
rowKey="id" columns={columns}
className="admin-table-nowrap" dataSource={data?.items ?? []}
loading={loading} scroll={{ x: 'max-content' }}
columns={columns} pagination={{
dataSource={data?.items ?? []} current: page,
scroll={{ x: 'max-content' }} pageSize,
pagination={{ total: data?.total ?? 0,
current: page, showSizeChanger: true,
pageSize, onChange: (p, ps) => { setPage(p); setPageSize(ps); },
total: data?.total ?? 0, }}
showSizeChanger: true, />
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</>
); );
} }
+1 -5
View File
@@ -1,7 +1,6 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return';
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
const SELECT_STORE_PATH = '/select-store'; const SELECT_STORE_PATH = '/select-store';
@@ -19,17 +18,14 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (authenticated && location.pathname === '/login') { if (authenticated && location.pathname === '/login') {
const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/'); return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
return <Navigate to={next} replace />;
} }
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) { if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
rememberShopReturnPath(location);
return <Navigate to={SELECT_STORE_PATH} replace />; return <Navigate to={SELECT_STORE_PATH} replace />;
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
rememberShopReturnPath(location);
const profile = getStoreProfile(); const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') { if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />; return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
+22 -1
View File
@@ -1 +1,22 @@
export { parseRedeemTokenFromScan } from '@dukang/shared-types'; /** 从微信扫码结果解析核销 token32 位 hex 或带 token 参数的 URL */
export function parseRedeemTokenFromScan(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
const fromQuery = url.searchParams.get('token');
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
return fromQuery.toLowerCase();
}
} catch {
/* not a URL */
}
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
return hexMatch ? hexMatch[0].toLowerCase() : null;
}
-14
View File
@@ -1,14 +0,0 @@
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
/** iOS 微信内用整页跳转,避免 JSSDK 入场 URL 与 SPA 路径不一致 */
export function goShopPath(
path: string,
navigate: (path: string, opts?: { replace?: boolean }) => void,
opts?: { replace?: boolean },
): void {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return;
}
navigate(path, { replace: opts?.replace ?? true });
}
@@ -1,45 +0,0 @@
/** 扫核销码落地 /redeem?token= 后未登录或需选店,记下回跳路径(OAuth 整页跳转会丢掉 location.state */
export const SHOP_REDEEM_RETURN_KEY = 'shop_redeem_return';
function isRedeemReturnPath(path: string): boolean {
if (!path.startsWith('/redeem')) return false;
if (path.startsWith('/redeem/')) return false;
try {
const url = new URL(path, 'https://local.invalid');
return !!url.searchParams.get('token')?.trim();
} catch {
return false;
}
}
export function rememberShopReturnTo(path: string): void {
if (!isRedeemReturnPath(path)) return;
try {
sessionStorage.setItem(SHOP_REDEEM_RETURN_KEY, path);
} catch {
/* ignore */
}
}
export function rememberShopReturnPath(location: { pathname: string; search: string }): void {
rememberShopReturnTo(`${location.pathname}${location.search}`);
}
export function peekShopReturnTo(): string | null {
try {
const path = sessionStorage.getItem(SHOP_REDEEM_RETURN_KEY);
return path && isRedeemReturnPath(path) ? path : null;
} catch {
return null;
}
}
export function consumeShopReturnTo(): string | null {
const path = peekShopReturnTo();
try {
sessionStorage.removeItem(SHOP_REDEEM_RETURN_KEY);
} catch {
/* ignore */
}
return path;
}
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel'; import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { reportRedeemFailure } from '../lib/redeem-failure'; import { reportRedeemFailure } from '../lib/redeem-failure';
import { consumeShopReturnTo } from '../lib/shop-redeem-return';
import { toastError } from '../lib/toast'; import { toastError } from '../lib/toast';
import { useStorePageView } from '../lib/usePageView'; import { useStorePageView } from '../lib/usePageView';
@@ -48,7 +47,6 @@ export default function RedeemConfirmPage() {
navigate('/', { replace: true }); navigate('/', { replace: true });
return; return;
} }
consumeShopReturnTo();
setToken(scanned); setToken(scanned);
}, [searchParams, navigate]); }, [searchParams, navigate]);
+3 -6
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { goShopPath } from '../lib/shop-nav';
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus'; import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
import { formatMoney, toMoneyNumber } from '../lib/money'; import { formatMoney, toMoneyNumber } from '../lib/money';
@@ -36,12 +35,10 @@ export default function RedeemSuccessPage() {
}); });
}, [redeemNo, amount]); }, [redeemNo, amount]);
const goHome = () => goShopPath('/', navigate, { replace: true });
return ( return (
<div className="shop-success-page"> <div className="shop-success-page">
<header className="shop-success-header"> <header className="shop-success-header">
<button type="button" className="shop-redeem-back" onClick={goHome} aria-label="返回"> <button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span> <span className="material-symbols-outlined">arrow_back</span>
</button> </button>
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
@@ -88,11 +85,11 @@ export default function RedeemSuccessPage() {
</div> </div>
<div className="shop-success-actions"> <div className="shop-success-actions">
<button type="button" className="shop-success-primary-btn" onClick={goHome}> <button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
<span></span> <span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
</button> </button>
<button type="button" className="shop-success-outline-btn" onClick={goHome}> <button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
<span></span> <span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
</button> </button>
+15 -10
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -9,11 +10,13 @@ import {
type ShopSessionPayload, type ShopSessionPayload,
type ShopStoreOption, type ShopStoreOption,
} from '../lib/api'; } from '../lib/api';
import { goShopPath } from '../lib/shop-nav';
import { consumeShopReturnTo, peekShopReturnTo } from '../lib/shop-redeem-return';
function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) { function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
goShopPath(consumeShopReturnTo() || '/', navigate); if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
} }
export default function SelectStorePage() { export default function SelectStorePage() {
@@ -43,7 +46,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
if (storeId === currentStoreId) { if (storeId === currentStoreId) {
goAfterSelectStore(navigate); goShopHome(navigate);
return; return;
} }
setLoadingId(storeId); setLoadingId(storeId);
@@ -51,7 +54,7 @@ export default function SelectStorePage() {
try { try {
const session = await selectStore(storeId); const session = await selectStore(storeId);
applySession(session); applySession(session);
goAfterSelectStore(navigate); goShopHome(navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败'); setMsg(e instanceof Error ? e.message : '选店失败');
} finally { } finally {
@@ -167,14 +170,16 @@ export default function SelectStorePage() {
); );
} }
/** After login/wechat: route to select-store, redeem return, or home */ /** After login/wechat: route to select-store or home */
export function routeAfterShopLogin( export function routeAfterShopLogin(
session: ShopSessionPayload, session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void, navigate: (path: string, opts?: { replace?: boolean }) => void,
) { ) {
if (needsStoreSelection(session)) { const path = needsStoreSelection(session) ? '/select-store' : '/';
goShopPath('/select-store', navigate); // iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return; return;
} }
goShopPath(peekShopReturnTo() || '/', navigate); navigate(path, { replace: true });
} }
@@ -1,9 +1,5 @@
import { useEffect, useState } from 'react';
import { Button, Text } from '@tarojs/components'; import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { toast } from '../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets';
import { openWecomCustomerServiceChat } from '../lib/wecom-cs';
export type ContactCsSessionContext = { export type ContactCsSessionContext = {
orderId?: string; orderId?: string;
@@ -20,7 +16,7 @@ type ContactCsButtonProps = {
const isWeapp = process.env.TARO_ENV === 'weapp'; const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符;原生小程序客服兜底用 */ /** 组装 session-from(微信限制约 1000 字符) */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string { export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user'; if (!session) return 'dukang|from=mini-user';
const parts = ['dukang']; const parts = ['dukang'];
@@ -30,59 +26,16 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
return parts.join('|'); return parts.join('|');
} }
function canOpenWecom(url: string, corpId: string) {
return !!url.trim() && !!corpId.trim();
}
/** /**
* C 端在线客服: * 微信小程序客服入口(open-type=contact)。
* - weapp 且已配置 CorpID + kfid:调起企业微信「微信客服」 * weapp 环境不渲染,由调用方走电话等兜底。
* - weapp 未配 CorpID:回退 open-type=contact
* - H5:有 kfid 则打开企微客服链接
*/ */
export default function ContactCsButton({ export default function ContactCsButton({
className = '', className = '',
children = '联系在线客服', children = '联系在线客服',
session, session,
}: ContactCsButtonProps) { }: ContactCsButtonProps) {
const [cs, setCs] = useState(() => getBrandAssetsSync()); if (!isWeapp) return null;
useEffect(() => {
void loadBrandAssets().then(setCs);
}, []);
const wecomChatReady = canOpenWecom(cs.customerServiceWecomUrl, cs.wecomCorpId);
const wecomWebReady = !!cs.customerServiceWecomUrl.trim();
if (!isWeapp && !wecomWebReady) return null;
async function openWecom() {
try {
if (isWeapp && wecomChatReady) {
await openWecomCustomerServiceChat({
url: cs.customerServiceWecomUrl,
corpId: cs.wecomCorpId,
session,
});
return;
}
if (typeof window !== 'undefined' && cs.customerServiceWecomUrl) {
window.location.href = cs.customerServiceWecomUrl;
return;
}
toast('请在微信内打开后联系客服');
} catch (e) {
toast(e instanceof Error ? e.message : '无法打开客服');
}
}
if (wecomChatReady || (!isWeapp && wecomWebReady)) {
return (
<Button className={className} hoverClass="none" onClick={() => void openWecom()}>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
return ( return (
<Button <Button
@@ -1,22 +0,0 @@
import { RichText, Text } from '@tarojs/components';
import { deliveryHintHtmlToRichNodes } from '@dukang/shared-types';
import { DEFAULT_LOCAL_DELIVERY_HINT } from '../lib/local-delivery';
type DeliveryHintHtmlProps = {
html?: string | null;
className?: string;
};
/** 承运商配送提示:HTML 用 RichTexttextarea 里的换行转成 br */
export default function DeliveryHintHtml({
html,
className = '',
}: DeliveryHintHtmlProps) {
const raw = (html || '').trim() || DEFAULT_LOCAL_DELIVERY_HINT;
const nodes = deliveryHintHtmlToRichNodes(raw);
const looksHtml = /<[a-z][\s\S]*>/i.test(nodes);
if (!looksHtml) {
return <Text className={className}>{nodes}</Text>;
}
return <RichText className={className} nodes={nodes} />;
}
@@ -10,11 +10,10 @@ import {
const CANVAS_ID = 'redeem-qr-canvas'; const CANVAS_ID = 'redeem-qr-canvas';
type RedeemQrCodeProps = { type RedeemQrCodeProps = {
/** 二维码内容:门店 H5 落地 URL,缺省回退 token */ token: string;
payload: string;
}; };
function drawOnWeappCanvas(payload: string) { function drawOnWeappCanvas(token: string) {
const page = Taro.getCurrentInstance().page; const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery(); const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query query
@@ -34,33 +33,33 @@ function drawOnWeappCanvas(payload: string) {
canvas.width = layoutW * dpr; canvas.width = layoutW * dpr;
canvas.height = layoutH * dpr; canvas.height = layoutH * dpr;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
drawRedeemQrOnCanvas(ctx, payload, drawSize); drawRedeemQrOnCanvas(ctx, token, drawSize);
}); });
} }
export default function RedeemQrCode({ payload }: RedeemQrCodeProps) { export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
const [imgSrc, setImgSrc] = useState(''); const [imgSrc, setImgSrc] = useState('');
const isWeapp = process.env.TARO_ENV === 'weapp'; const isWeapp = process.env.TARO_ENV === 'weapp';
useEffect(() => { useEffect(() => {
if (!payload) { if (!token) {
setImgSrc(''); setImgSrc('');
return; return;
} }
if (isWeapp) { if (isWeapp) {
const timer = setTimeout(() => drawOnWeappCanvas(payload), 120); const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
let cancelled = false; let cancelled = false;
void buildRedeemQrDataUrl(payload).then((url) => { void buildRedeemQrDataUrl(token).then((url) => {
if (!cancelled) setImgSrc(url); if (!cancelled) setImgSrc(url);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [payload, isWeapp]); }, [token, isWeapp]);
return ( return (
<View className="redeem-qr-box"> <View className="redeem-qr-box">
-8
View File
@@ -3,7 +3,6 @@ import {
BRAND_LOGO_URL, BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL, BRAND_LOGO_WIDE_URL,
CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_PHONE,
CUSTOMER_SERVICE_WECOM_URL,
QUALIFICATION_DISCLOSURE_URL, QUALIFICATION_DISCLOSURE_URL,
type ClientRuntimeConfig, type ClientRuntimeConfig,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -15,8 +14,6 @@ export type BrandAssets = {
brandLogoMarkUrl: string; brandLogoMarkUrl: string;
qualificationDisclosureUrl: string; qualificationDisclosureUrl: string;
customerServicePhone: string; customerServicePhone: string;
customerServiceWecomUrl: string;
wecomCorpId: string;
}; };
const FALLBACK: BrandAssets = { const FALLBACK: BrandAssets = {
@@ -25,8 +22,6 @@ const FALLBACK: BrandAssets = {
brandLogoMarkUrl: BRAND_LOGO_MARK_URL, brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL, qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
customerServicePhone: CUSTOMER_SERVICE_PHONE, customerServicePhone: CUSTOMER_SERVICE_PHONE,
customerServiceWecomUrl: CUSTOMER_SERVICE_WECOM_URL,
wecomCorpId: '',
}; };
let cached: BrandAssets | null = null; let cached: BrandAssets | null = null;
@@ -40,9 +35,6 @@ function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets
qualificationDisclosureUrl: qualificationDisclosureUrl:
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl, config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone, customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
customerServiceWecomUrl:
config?.customerServiceWecomUrl?.trim() || FALLBACK.customerServiceWecomUrl,
wecomCorpId: config?.wecomCorpId?.trim() || FALLBACK.wecomCorpId,
}; };
} }
-57
View File
@@ -1,57 +0,0 @@
import type { LocalDeliveryDto } from '@dukang/shared-types';
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
import { request } from './api';
export { DEFAULT_LOCAL_DELIVERY_HINT };
const CACHE_TTL_MS = 60_000;
let cached: LocalDeliveryDto[] | null = null;
let cachedAt = 0;
let inflight: Promise<LocalDeliveryDto[]> | null = null;
function cityAliases(name: string): string[] {
const raw = name.trim();
if (!raw) return [];
const noSuffix = raw.replace(/市$/, '');
const withSuffix = raw.endsWith('市') ? raw : `${raw}`;
return [raw, noSuffix, withSuffix];
}
export async function loadLocalDeliveries(force = false): Promise<LocalDeliveryDto[]> {
if (!force && cached && Date.now() - cachedAt < CACHE_TTL_MS) return cached;
if (!force && inflight) return inflight;
inflight = request<LocalDeliveryDto[]>('/catalog/local-deliveries')
.then((list) => {
cached = Array.isArray(list) ? list : [];
cachedAt = Date.now();
return cached;
})
.catch(() => {
cached = cached ?? [];
return cached;
})
.finally(() => {
inflight = null;
});
return inflight;
}
export function matchLocalDelivery(
list: LocalDeliveryDto[],
opts: { cityCode?: string | null; cityName?: string | null },
): LocalDeliveryDto | null {
const code = String(opts.cityCode || '').trim();
if (code) {
const byCode = list.find((row) => row.city.code === code);
if (byCode) return byCode;
}
const names = new Set(cityAliases(String(opts.cityName || '')));
if (!names.size) return null;
return list.find((row) => cityAliases(row.city.name).some((n) => names.has(n))) ?? null;
}
export function resolveLocalDeliveryHintHtml(row: LocalDeliveryDto | null): string {
const html = row?.hintHtml?.trim();
if (html) return html;
return DEFAULT_LOCAL_DELIVERY_HINT;
}
@@ -1,8 +1,3 @@
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
/** @deprecated 使用 DEFAULT_LOCAL_DELIVERY_HINT;空配置回退 */
export const LOCAL_DELIVERY_ETA_HINT = DEFAULT_LOCAL_DELIVERY_HINT;
/** 商品履约能力(与 HQ / 交易硬闸一致) */ /** 商品履约能力(与 HQ / 交易硬闸一致) */
export type FulfillmentFlags = { export type FulfillmentFlags = {
+6 -6
View File
@@ -8,13 +8,13 @@ const QR_OPTIONS = {
color: { dark: '#1f1a17', light: '#ffffff' }, color: { dark: '#1f1a17', light: '#ffffff' },
} as const; } as const;
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用payload 为落地 URL 或纯 token */ /** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
export function drawRedeemQrOnCanvas( export function drawRedeemQrOnCanvas(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
payload: string, token: string,
sizePx = QR_SIZE, sizePx = QR_SIZE,
) { ) {
const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' }); const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
const count = qr.modules.size; const count = qr.modules.size;
const cell = sizePx / count; const cell = sizePx / count;
@@ -31,11 +31,11 @@ export function drawRedeemQrOnCanvas(
} }
/** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */ /** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
export async function buildRedeemQrDataUrl(payload: string): Promise<string> { export async function buildRedeemQrDataUrl(token: string): Promise<string> {
try { try {
return await QRCode.toDataURL(payload, QR_OPTIONS); return await QRCode.toDataURL(token, QR_OPTIONS);
} catch { } catch {
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(payload)}`; return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
} }
} }
-85
View File
@@ -1,85 +0,0 @@
import Taro from '@tarojs/taro';
export type WecomCsSessionContext = {
orderId?: string;
orderNo?: string;
from?: string;
};
type OpenCsChatOption = {
extInfo: { url: string };
corpId: string;
showMessageCard?: boolean;
sendMessageTitle?: string;
sendMessagePath?: string;
};
type OpenCsChatFn = (option: OpenCsChatOption) => Promise<unknown>;
function getTaroOpenCsChat(): OpenCsChatFn | null {
const api = (Taro as unknown as { openCustomerServiceChat?: OpenCsChatFn }).openCustomerServiceChat;
return typeof api === 'function' ? api : null;
}
function getWxOpenCsChat(): ((option: OpenCsChatOption & {
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => void) | null {
const wxApi = (
globalThis as {
wx?: {
openCustomerServiceChat?: (option: OpenCsChatOption & {
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => void;
};
}
).wx?.openCustomerServiceChat;
return typeof wxApi === 'function' ? wxApi : null;
}
/** 小程序调起企业微信「微信客服」会话 */
export async function openWecomCustomerServiceChat(params: {
url: string;
corpId: string;
session?: WecomCsSessionContext;
}): Promise<void> {
const url = params.url.trim();
const corpId = params.corpId.trim();
if (!url || !corpId) {
throw new Error('企微客服未配置');
}
const option: OpenCsChatOption = {
extInfo: { url },
corpId,
};
if (params.session?.orderNo || params.session?.orderId) {
option.showMessageCard = true;
option.sendMessageTitle = params.session.orderNo
? `订单 ${params.session.orderNo}`
: '订单咨询';
if (params.session.orderId) {
option.sendMessagePath = `pages/order-detail/index?id=${params.session.orderId}`;
}
}
const taroApi = getTaroOpenCsChat();
if (taroApi) {
await taroApi(option);
return;
}
const wxApi = getWxOpenCsChat();
if (!wxApi) {
throw new Error('当前微信版本不支持企业微信客服');
}
await new Promise<void>((resolve, reject) => {
wxApi({
...option,
success: () => resolve(),
fail: (err) => reject(new Error(err?.errMsg || '无法打开企业微信客服')),
});
});
}
@@ -16,43 +16,35 @@ function dialPhone(phone: string) {
} }
export default function CustomerServicePage() { export default function CustomerServicePage() {
const [brand, setBrand] = useState(() => getBrandAssetsSync()); const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
useEffect(() => { useEffect(() => {
void loadBrandAssets().then(setBrand); void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
}, []); }, []);
const phone = brand.customerServicePhone;
const wecomReady = !!brand.customerServiceWecomUrl.trim() && !!brand.wecomCorpId.trim();
const wecomUrlReady = !!brand.customerServiceWecomUrl.trim();
const hint = isWeapp
? wecomReady
? '点击下方按钮,进入企业微信客服会话'
: '点击下方按钮,进入在线客服会话'
: wecomUrlReady
? '点击下方按钮进入企业微信客服,或拨打客服电话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话';
return ( return (
<PageShell variant="sub" className="cs-page"> <PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" /> <SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body"> <View className="sub-page-body inset-page cs-body">
<Text className="cs-brand"></Text> <Text className="cs-brand"></Text>
<Text className="cs-hint">{hint}</Text> <Text className="cs-hint">
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hours">9:00 - 21:00</Text> <Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp || wecomUrlReady ? ( {isWeapp ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} /> <ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null} ) : null}
<View className={isWeapp || wecomUrlReady ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}> <View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
<Text> <Text>
{isWeapp || wecomUrlReady ? `或拨打客服电话 ${phone}` : '拨打客服电话'} {isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
</Text> </Text>
</View> </View>
{!isWeapp && !wecomUrlReady ? ( {!isWeapp ? (
<Text className="cs-phone-display">{phone}</Text> <Text className="cs-phone-display">{phone}</Text>
) : null} ) : null}
</View> </View>
@@ -11,9 +11,7 @@ import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment'; import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure'; import BenefitFigure from '../../components/BenefitFigure';
@@ -77,7 +75,6 @@ export default function OrderConfirmPage() {
const [addresses, setAddresses] = useState<Address[]>([]); const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || ''); const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null); const [preview, setPreview] = useState<OrderPreview | null>(null);
const [localHintHtml, setLocalHintHtml] = useState('');
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -154,18 +151,6 @@ export default function OrderConfirmPage() {
[addresses, addressId], [addresses, addressId],
); );
useEffect(() => {
const cityName = selectedAddress?.city;
if (!cityName) {
setLocalHintHtml('');
return;
}
void loadLocalDeliveries().then((list) => {
const row = matchLocalDelivery(list, { cityName });
setLocalHintHtml(row ? resolveLocalDeliveryHintHtml(row) : '');
});
}, [selectedAddress?.city]);
const allowCross = const allowCross =
preview?.allowCrossCityDelivery !== undefined preview?.allowCrossCityDelivery !== undefined
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery }) ? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
@@ -354,12 +339,6 @@ export default function OrderConfirmPage() {
</View> </View>
) : null} ) : null}
{!isCross && addressOk && addressId && localHintHtml ? (
<View className="order-card">
<DeliveryHintHtml className="u-muted" html={localHintHtml} />
</View>
) : null}
{preview ? ( {preview ? (
<> <>
<View className="order-card"> <View className="order-card">
@@ -10,8 +10,6 @@ import ContactCsButton from '../../components/ContactCsButton';
import LogisticsRichText from '../../components/LogisticsRichText'; import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { import {
formatEstimatedArrival, formatEstimatedArrival,
isLogisticsNotArrived, isLogisticsNotArrived,
@@ -85,22 +83,6 @@ const STATUS_LABELS: Record<string, string> = {
REFUNDED: '已退款', REFUNDED: '已退款',
}; };
function applyLocalDeliveryHint(
data: OrderDetail,
setLocalHintHtml: (html: string) => void,
) {
if (data.deliveryType !== 'LOCAL') {
setLocalHintHtml('');
return;
}
void loadLocalDeliveries().then((list) => {
const row = data.receiverCity
? matchLocalDelivery(list, { cityName: data.receiverCity })
: null;
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
});
}
function fullReceiverAddress(order: OrderDetail) { function fullReceiverAddress(order: OrderDetail) {
const detail = (order.receiverAddress || '').trim(); const detail = (order.receiverAddress || '').trim();
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict] const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
@@ -120,7 +102,6 @@ export default function OrderDetailPage() {
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null); const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null); const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
const [trackLoading, setTrackLoading] = useState(false); const [trackLoading, setTrackLoading] = useState(false);
const [localHintHtml, setLocalHintHtml] = useState('');
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) { async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
if (!orderId || !shouldLoadOrderTrack(delivery)) { if (!orderId || !shouldLoadOrderTrack(delivery)) {
@@ -152,7 +133,6 @@ export default function OrderDetailPage() {
.then((data) => { .then((data) => {
setOrder(data); setOrder(data);
void loadOrderTrack(data.delivery, data.status); void loadOrderTrack(data.delivery, data.status);
applyLocalDeliveryHint(data, setLocalHintHtml);
}) })
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
@@ -164,7 +144,6 @@ export default function OrderDetailPage() {
.then((data) => { .then((data) => {
setOrder(data); setOrder(data);
void loadOrderTrack(data.delivery, data.status); void loadOrderTrack(data.delivery, data.status);
applyLocalDeliveryHint(data, setLocalHintHtml);
}) })
.catch(() => {}); .catch(() => {});
}); });
@@ -382,12 +361,6 @@ export default function OrderDetailPage() {
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'} {order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text> </Text>
</View> </View>
{order.deliveryType === 'LOCAL' && localHintHtml ? (
<View className="order-row">
<Text className="order-row-label"></Text>
<DeliveryHintHtml className="order-row-value" html={localHintHtml} />
</View>
) : null}
</View> </View>
</> </>
)} )}
@@ -14,7 +14,6 @@ import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import BenefitFigure from '../../components/BenefitFigure'; import BenefitFigure from '../../components/BenefitFigure';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api'; import { isLoggedIn, request, toast } from '../../lib/api';
@@ -29,8 +28,6 @@ import {
canPickupOnSite, canPickupOnSite,
normalizeFulfillmentFlags, normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment'; } from '../../lib/product-fulfillment';
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import { import {
buildSceneSharePayload, buildSceneSharePayload,
toWeappShareMessage, toWeappShareMessage,
@@ -93,7 +90,6 @@ export default function ProductDetailPage() {
const [product, setProduct] = useState<Product | null>(null); const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
const [selected, setSelected] = useState<Record<string, string>>({}); const [selected, setSelected] = useState<Record<string, string>>({});
const [localHintHtml, setLocalHintHtml] = useState('');
usePageScroll(({ scrollTop }) => { usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100); setHeaderSolid(scrollTop > 100);
@@ -140,17 +136,6 @@ export default function ProductDetailPage() {
useDidShow(() => { useDidShow(() => {
loadProduct(); loadProduct();
void resolveUserCity()
.then((city) =>
loadLocalDeliveries().then((list) => {
const row = matchLocalDelivery(list, {
cityCode: getCityCodeForCatalog(city),
cityName: city.cityName || city.displayCity || city.city,
});
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
}),
)
.catch(() => setLocalHintHtml(resolveLocalDeliveryHintHtml(null)));
}); });
const attrs = product?.specAttrs ?? []; const attrs = product?.specAttrs ?? [];
@@ -332,10 +317,6 @@ export default function ProductDetailPage() {
</View> </View>
) : null} ) : null}
{allowOnline && localHintHtml ? (
<DeliveryHintHtml className="product-detail-fulfillment" html={localHintHtml} />
) : null}
<View className="product-detail-promo"> <View className="product-detail-promo">
<View className="product-detail-promo-glow" /> <View className="product-detail-promo-glow" />
<View className="product-detail-promo-head"> <View className="product-detail-promo-head">
@@ -39,10 +39,6 @@ export default function RedeemCodePage() {
const router = useRouter(); const router = useRouter();
const token = decodeURIComponent(router.params.token ?? ''); const token = decodeURIComponent(router.params.token ?? '');
const amount = Number(router.params.amount ?? 0); const amount = Number(router.params.amount ?? 0);
const landingUrl = router.params.landingUrl
? decodeURIComponent(router.params.landingUrl)
: '';
const qrPayload = landingUrl || token;
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -152,7 +148,7 @@ export default function RedeemCodePage() {
<View className="redeem-code-panel"> <View className="redeem-code-panel">
<Text className="redeem-code-head"></Text> <Text className="redeem-code-head"></Text>
<View className="redeem-qr-wrap"> <View className="redeem-qr-wrap">
<RedeemQrCode payload={qrPayload} /> <RedeemQrCode token={token} />
</View> </View>
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}> <View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text> <Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
+2 -5
View File
@@ -113,15 +113,12 @@ export default function RedeemPage() {
const body: { amount: number; couponId?: string } = { amount: value }; const body: { amount: number; couponId?: string } = { amount: value };
if (couponId) body.couponId = couponId; if (couponId) body.couponId = couponId;
const data = await request<{ token: string; amount: number; landingUrl?: string }>('/redeem/tokens', { const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
method: 'POST', method: 'POST',
data: body, data: body,
}); });
const landingQs = data.landingUrl
? `&landingUrl=${encodeURIComponent(data.landingUrl)}`
: '';
Taro.navigateTo({ Taro.navigateTo({
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}${landingQs}`, url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
}); });
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '生成失败'); toast(e instanceof Error ? e.message : '生成失败');
@@ -12,6 +12,7 @@ import Taro, {
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee'; import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
@@ -355,6 +356,7 @@ export default function StoreDetailPage() {
solid={headerSolid} solid={headerSolid}
titleVisible={headerSolid} titleVisible={headerSolid}
onBack={goBack} onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/> />
<View className="store-detail-hero full-bleed"> <View className="store-detail-hero full-bleed">
@@ -128,14 +128,6 @@
margin-bottom: 24px; margin-bottom: 24px;
} }
.product-detail-fulfillment {
display: block;
margin: 0 0 16px;
font-size: 13px;
line-height: 20px;
color: var(--color-on-surface-variant);
}
.product-detail-promo { .product-detail-promo {
position: relative; position: relative;
margin-top: 8px; margin-top: 8px;
+1 -2
View File
@@ -29,7 +29,6 @@
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) | | 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) | | 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) | | 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
--- ---
@@ -85,7 +84,7 @@
**HQ 订单导出**`admin-web` 订单监控): **HQ 订单导出**`admin-web` 订单监控):
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出 - 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
- 格式:Excel`.xlsx`/ PDF`.pdf` - 格式:Excel`.xlsx`/ PDF`.pdf`
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天 - 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
- 权限:`orders`;操作审计 `ORDER_EXPORT` - 权限:`orders`;操作审计 `ORDER_EXPORT`
+1 -3
View File
@@ -64,12 +64,10 @@
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 | | 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 | | 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 | | 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) | | 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 |
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
| 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | | 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 | | 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 | | 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
-121
View File
@@ -1,121 +0,0 @@
# 杜康好客 · v3.5.10 开发文档
> **2026-08-25** · mini-user / API
> **主题**:门店详情去掉分享按钮;同城配送提示 24 小时内送到;小程序客服接通企业微信
---
## 1. 版本目标
| # | 任务 | 类型 | 交付 |
|---|------|------|------|
| 1 | DPT-20260824-896 | BUG | C 端门店详情去掉顶栏「分享」按钮 |
| 2 | DPT-20260824-601 | 优化 | 同城送提示改承运商「配送信息提示」(HTML);C 端 `GET /catalog/local-deliveries` |
| 3 | DPT-20260822-651 | 需求 | 小程序在线客服调起企业微信「微信客服」 |
**不做**:改配送规则 / 改 SLA 计算;合伙人/门店端客服;禁用微信右上角「···」分享菜单。
---
## 2. 门店详情去掉分享按钮
`pages/store-detail` 顶栏不再渲染 `ShareNavButton`
右上角微信原生菜单仍可分享(`enableShareAppMessage` / `WechatShareReady` 保留)。只要去掉页面上的分享按钮。
---
## 3. 同城送提示(承运商 HTML)
不再写死文案。以用户**收货地址城市**为准:该市 `common_city.status=ACTIVE`(已开城)→ 同城,展示对应仓配承运商的「配送信息提示」;未开城 → 跨城,只展示「总部物流、运费到付」。
```
收货市 → 开城仓库(ACTIVE,优先 API_AUTO 且已绑承运商)→ 承运商.delivery_hint_html
```
空字段时 C 端回退纯文本 `同城配送,预计24小时内送到`。不改下单 / 推单 / 起购。
### 3.1 承运商字段
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。允许 `span/p/br/b/strong/i/em/font`style 仅 `color` / `font-weight` / `font-size` / `font-style`。保存与下发前消毒。HQ 文本框里的回车在 C 端转成换行(不必手写 `<br/>`)。
HQ「仓配管理 → 承运商」多行输入,例如:
```html
<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>
```
### 3.2 API
`GET /catalog/local-deliveries`(公开)。可选 `cityCode` / `cityName`
每项:`city` · `warehouse` · `provider` · `hintHtml`。未开城 / 无仓 / 无承运商返回空列表或 `hintHtml=null`,不报错。
### 3.3 C 端
| 页面 | 何时展示 |
|------|----------|
| 商品详情 | 可线上购;用当前选城预览 |
| 确认订单 | 收货市已开城且地址校验通过;`RichText` |
| 订单详情 | `deliveryType === LOCAL`;按收货市匹配 |
---
---
## 4. 小程序客服接通企业微信
原先 `open-type=contact` 进入**小程序原生客服**。本版在已配置企微参数时改为 `wx.openCustomerServiceChat`,进入企业微信「微信客服」(与 H5 kfid 同一套)。
### 配置(HQ → 系统设置 → 微信小程序配置)
| 键 | 说明 |
|----|------|
| `CUSTOMER_SERVICE_WECOM_URL` | 微信客服 kfid 链接;缺省用代码常量 |
| `WECOM_CORP_ID` | 企业 ID`ww` 开头)。企微「我的企业」可查 |
`GET /common/client-config` 下发 `customerServiceWecomUrl``wecomCorpId`
### 行为
| 环境 | 条件 | 行为 |
|------|------|------|
| 小程序 | 链接 + CorpID 都有 | `openCustomerServiceChat`;订单详情可带订单卡片 |
| 小程序 | 未填 CorpID | 回退 `open-type=contact` |
| H5 | 有 kfid 链接 | 打开企微客服网页 |
### 企微侧前置(运营)
1. 开通企业微信「微信客服」,拿到 kfid 链接。
2. 把 C 端小程序关联到该企业的微信客服。
3. 把 CorpID 填进系统设置。未填则用户仍走小程序原生客服。
---
## 4.1 Prisma
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。脚本:[`migrate-fulfillment-delivery-hint-v3510.sql`](../server/dukang-api/prisma/migrate-fulfillment-delivery-hint-v3510.sql)。
## 5. 验收清单
- [ ] 门店详情顶栏没有分享按钮;返回/导航/拨打不受影响
- [ ] HQ 承运商可编辑「配送信息提示」HTML(颜色/粗细/字号);非法标签被去掉
- [ ] `GET /catalog/local-deliveries` 按开城列出仓库+承运商+hintHtml;`cityName` 可筛收货市
- [ ] 商品详情(可线上购)按当前选城展示承运商提示(空则回退「同城配送,预计24小时内送到」)
- [ ] 确认订单:收货市已开城显示 HTML 提示;未开城只显示「总部物流、运费到付」
- [ ] 同城订单详情「配送时效」为该市承运商提示
- [ ] 系统设置可改客服链接与 CorpID;保存后 `client-config` 立即带出
- [ ] 小程序已填 CorpID:联系客服进入企微微信客服(非小程序原生客服后台)
- [ ] 未填 CorpID:小程序仍能打开原生客服,不白屏
---
## 6. 关键路径
| 域 | 路径 |
|----|------|
| 门店详情 | `apps/mini-user/src/pages/store-detail/index.tsx` |
| 同城提示 | `fulfillment-provider.service.ts` · `GET /catalog/local-deliveries` · `DeliveryHintHtml` |
| 客服按钮 | `apps/mini-user/src/components/ContactCsButton.tsx` · `lib/wecom-cs.ts` |
| 下发 | `client-config.controller.ts` · `packages/shared-types` `config.ts` / `wechat.ts` |
| HQ 配置 | `system-config.registry.ts` |
-7
View File
@@ -120,7 +120,6 @@
- [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `user_user.hq_remark` 已更新;C 端看不到该字段 - [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `user_user.hq_remark` 已更新;C 端看不到该字段
- [ ] 用户列表手机号完整可见(详情仍脱敏) - [ ] 用户列表手机号完整可见(详情仍脱敏)
- [ ] 权益券列表:用户编号、订单号可点进对应用户/订单详情;来源含商品名、规格、数量、配送方式、实付金额 - [ ] 权益券列表:用户编号、订单号可点进对应用户/订单详情;来源含商品名、规格、数量、配送方式、实付金额
- [ ] 订单列表「状态」可多选;导出按所选状态过滤;不选即全部
--- ---
@@ -129,9 +128,3 @@
- **用户**、**订单**列(及券详情)用主列下划线,分别打开用户详情抽屉、订单详情抽屉。 - **用户**、**订单**列(及券详情)用主列下划线,分别打开用户详情抽屉、订单详情抽屉。
- **来源**:关联订单时拼 `商品名 / 规格 / 数量(瓶或箱) / 配送方式 / ¥实付`;无订单仍用 `sourceProduct`(如总部手动发放)。 - **来源**:关联订单时拼 `商品名 / 规格 / 数量(瓶或箱) / 配送方式 / ¥实付`;无订单仍用 `sourceProduct`(如总部手动发放)。
- 列表接口 `order` 增补 `productName``productSpec``quantity``saleUnit``deliveryType``payAmount` - 列表接口 `order` 增补 `productName``productSpec``quantity``saleUnit``deliveryType``payAmount`
---
## 11. 订单列表状态多选
`GET /admin/orders``POST /admin/orders/export``status` 支持多值(重复 query、逗号串、JSON 数组均可)。列表筛选为多选;空 = 全部。单值旧链接仍可用。
-2
View File
@@ -45,8 +45,6 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 **HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html``GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat``CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
## 5. 验收用例(必过) ## 5. 验收用例(必过)
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
+3 -6
View File
@@ -22,11 +22,10 @@
- 四 Tab:首页/权益/门店/我的;微信登录+7天会话 - 四 Tab:首页/权益/门店/我的;微信登录+7天会话
- 下单:选城→商品→地址→起购校验→微信支付→权益1:1 - 下单:选城→商品→地址→起购校验→微信支付→权益1:1
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟。二维码内容为门店 H5 URL`{SHOP_H5_URL}/redeem?token=`),微信扫一扫直达核销确认页 - 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟
- 门店:仅 OPEN;详情含套餐/电话(脱敏可拨打)/两段营业时间 - 门店:仅 OPEN;详情含套餐/电话(脱敏可拨打)/两段营业时间
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA) - 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
- 售后:客服入口(小程序优先企微微信客服,未配 CorpID 回退原生客服);发票/四类型工单按 PRD Wave 进度 - 售后:客服入口;发票/四类型工单按 PRD Wave 进度
- 同城送:收货市已开城则展示承运商「配送信息提示」(HTML);未开城走跨城到付;门店详情无顶栏分享按钮
- 版本:`minClientVersion` 过低强制更新或退出 - 版本:`minClientVersion` 过低强制更新或退出
- 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」) - 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」)
@@ -48,7 +47,7 @@
## 3. 门店端(h5-shop ## 3. 门店端(h5-shop
- 登录绑定门店;首页扫码核销(微信 JSSDK);也可微信扫一扫用户核销码直达确认页(仍需点「确认核销」) - 登录绑定门店;首页扫码核销(微信 JSSDK)
- 核销记录;今日汇总;到账金额×60%展示 - 核销记录;今日汇总;到账金额×60%展示
- 营业状态开关;Mine 门店信息 - 营业状态开关;Mine 门店信息
- 套餐:列表编辑→提交 HQ 审核(v3.4.10) - 套餐:列表编辑→提交 HQ 审核(v3.4.10)
@@ -69,7 +68,6 @@
| 规则 | 说明 | | 规则 | 说明 |
|------|------| |------|------|
| iOS 登录/选店后 | 用 `location.replace(path)``hardNavigateInWechat`),禁止仅 React Router navigate | | iOS 登录/选店后 | 用 `location.replace(path)``hardNavigateInWechat`),禁止仅 React Router navigate |
| 微信扫一扫落地核销页 | 成功后须 `hardNavigateInWechat('/')` 回首页,否则入场 URL 仍是 `/redeem?token=`,下次首页扫码验签失败 |
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 | | iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) | | 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 | | 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
@@ -152,7 +150,6 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
| 智能机器人 | `/wecom/bots` 长连接指令 | | 智能机器人 | `/wecom/bots` 长连接指令 |
| 消息推送 | `/wecom/pushes` Webhook+eventKey | | 消息推送 | `/wecom/pushes` Webhook+eventKey |
| 日志 | `/logs/wecom-bots` | | 日志 | `/logs/wecom-bots` |
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
eventKey`alert.ops` · `support_ticket.created` · `dev_plan.task_dispatch` · 支付/核销/结算告警。 eventKey`alert.ops` · `support_ticket.created` · `dev_plan.task_dispatch` · 支付/核销/结算告警。
+1 -3
View File
@@ -19,11 +19,9 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "tsc --watch", "dev": "tsc --watch",
"test": "vitest run",
"lint": "eslint src" "lint": "eslint src"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.4.5", "typescript": "^5.4.5"
"vitest": "^1.6.1"
} }
} }
+2 -15
View File
@@ -30,16 +30,11 @@ export interface AppConfig {
tencentLbsSecretKey: string; tencentLbsSecretKey: string;
/** C 端 H5 落地页(推广码二维码链接前缀) */ /** C 端 H5 落地页(推广码二维码链接前缀) */
userH5Url: string; userH5Url: string;
/** 门店 H5 落地页(用户核销码二维码链接前缀) */
shopH5Url: string;
} }
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */ /** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user'; export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
/** 门店 H5 默认落地页(系统设置 SHOP_H5_URL 未配时回退;HQ 可改) */
export const DEFAULT_SHOP_H5_URL = 'https://shop.dukanghaoke.com';
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */ /** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/'; export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
@@ -63,15 +58,12 @@ export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualifi
export const CUSTOMER_SERVICE_PHONE = '13203801799'; export const CUSTOMER_SERVICE_PHONE = '13203801799';
/** /**
* C 线 / openCustomerServiceChat * C 线
* CUSTOMER_SERVICE_WECOM_URL * h5-user VITE_CS_WECOM_URL
*/ */
export const CUSTOMER_SERVICE_WECOM_URL = export const CUSTOMER_SERVICE_WECOM_URL =
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd'; 'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
/** 企业微信 CorpID(小程序 wx.openCustomerServiceChat 必填;系统设置 WECOM_CORP_ID */
export const WECOM_CORP_ID = '';
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */ /** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
export type ClientBrandRuntime = { export type ClientBrandRuntime = {
userH5Url: string; userH5Url: string;
@@ -82,8 +74,6 @@ export type ClientBrandRuntime = {
miniUserStaticOssBase: string; miniUserStaticOssBase: string;
qualificationDisclosureUrl: string; qualificationDisclosureUrl: string;
customerServicePhone: string; customerServicePhone: string;
customerServiceWecomUrl: string;
wecomCorpId: string;
}; };
export function resolveClientBrandRuntime( export function resolveClientBrandRuntime(
@@ -105,8 +95,6 @@ export function resolveClientBrandRuntime(
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() || (e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
`${staticBase}qualification-disclosure.png`, `${staticBase}qualification-disclosure.png`,
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(), customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
customerServiceWecomUrl: (e.CUSTOMER_SERVICE_WECOM_URL || CUSTOMER_SERVICE_WECOM_URL).trim(),
wecomCorpId: (e.WECOM_CORP_ID || WECOM_CORP_ID).trim(),
}; };
} }
@@ -277,7 +265,6 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
tencentLbsKey: e.TENCENT_LBS_KEY ?? '', tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '', tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''), userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
shopH5Url: (e.SHOP_H5_URL || DEFAULT_SHOP_H5_URL).replace(/\/$/, ''),
}; };
return { return {
...base, ...base,
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isXfxProviderCode } from './fulfillment-provider';
describe('isXfxProviderCode', () => {
it('匹配标准编码与城市前缀', () => {
expect(isXfxProviderCode('XFX')).toBe(true);
expect(isXfxProviderCode('xiaofeixia')).toBe(true);
expect(isXfxProviderCode('ZZXFX')).toBe(true);
expect(isXfxProviderCode('XFX_ZZ')).toBe(true);
});
it('不匹配普通承运商', () => {
expect(isXfxProviderCode('')).toBe(false);
expect(isXfxProviderCode('LOGISTICS')).toBe(false);
expect(isXfxProviderCode('MANUAL')).toBe(false);
expect(isXfxProviderCode('SF')).toBe(false);
});
});
@@ -55,8 +55,6 @@ export interface FulfillmentProviderDto {
settlementMethod: LogisticsSettlementMethod; settlementMethod: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null; pricingRules?: LogisticsPricingRuleDto | null;
prepaidBalance: number; prepaidBalance: number;
/** C 端同城配送提示(已消毒 HTML) */
deliveryHintHtml?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -76,7 +74,6 @@ export interface CreateFulfillmentProviderInput {
bankAccountNo?: string | null; bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod; settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null; pricingRules?: LogisticsPricingRuleDto | null;
deliveryHintHtml?: string | null;
} }
export interface UpdateFulfillmentProviderInput { export interface UpdateFulfillmentProviderInput {
@@ -92,7 +89,6 @@ export interface UpdateFulfillmentProviderInput {
bankAccountNo?: string | null; bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod; settlementMethod?: LogisticsSettlementMethod;
pricingRules?: LogisticsPricingRuleDto | null; pricingRules?: LogisticsPricingRuleDto | null;
deliveryHintHtml?: string | null;
} }
export { DEFAULT_XFX_LOGISTICS_PRICING }; export { DEFAULT_XFX_LOGISTICS_PRICING };
@@ -114,91 +110,6 @@ export interface WarehouseFulfillmentConfig {
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const; export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
/** 小飞侠承运商编码:精确 XFX/XIAOFEIXIA,以及城市前缀如 ZZXFX */
export function isXfxProviderCode(code: string): boolean { export function isXfxProviderCode(code: string): boolean {
const c = code.trim().toUpperCase(); return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
if (!c) return false;
if ((XFX_PROVIDER_CODES as readonly string[]).includes(c)) return true;
if (c.includes('XIAOFEIXIA')) return true;
return c.endsWith('XFX') || c.startsWith('XFX');
}
/** 承运商未配置提示时 C 端回退文案 */
export const DEFAULT_LOCAL_DELIVERY_HINT = '同城配送,预计24小时内送到';
export const LOCAL_DELIVERY_HINT_MAX_LEN = 2000;
const HINT_ALLOWED_TAGS = new Set(['span', 'p', 'br', 'b', 'strong', 'i', 'em', 'font']);
const HINT_ALLOWED_STYLES = new Set(['color', 'font-weight', 'font-size', 'font-style']);
export type LocalDeliveryDto = {
city: { id: string; code: string; name: string };
warehouse: { id: string; name: string; fulfillmentMode: string } | null;
provider: { id: string; code: string; name: string } | null;
hintHtml: string | null;
};
function sanitizeHintStyle(raw: string): string {
return raw
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const idx = part.indexOf(':');
if (idx <= 0) return '';
const key = part.slice(0, idx).trim().toLowerCase();
const value = part.slice(idx + 1).trim();
if (!HINT_ALLOWED_STYLES.has(key)) return '';
if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(value)) return '';
return `${key}:${value}`;
})
.filter(Boolean)
.join(';');
}
/** 文本换行转成 br,供 C 端 RichText 使用(不改 HQ 存盘原文) */
export function deliveryHintHtmlToRichNodes(html: string): string {
return html
.replace(/\r\n|\r|\n/g, '<br/>')
.replace(/(?:<br\s*\/?>){3,}/gi, '<br/><br/>');
}
/** 承运商配送提示 HTML:去掉脚本/事件,只保留字号颜色粗细 */
export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
if (raw == null) return null;
let html = String(raw).trim();
if (!html) return null;
if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) {
html = html.slice(0, LOCAL_DELIVERY_HINT_MAX_LEN);
}
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
html = html.replace(/javascript\s*:/gi, '');
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (_full, tag: string, attrs: string) => {
const name = String(tag).toLowerCase();
const closing = String(_full).startsWith('</');
if (!HINT_ALLOWED_TAGS.has(name)) return '';
if (name === 'br') return closing ? '' : '<br/>';
if (closing) return `</${name}>`;
let style = '';
const styleMatch = String(attrs).match(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/i);
if (styleMatch) {
style = sanitizeHintStyle(styleMatch[2] ?? styleMatch[3] ?? '');
}
let color = '';
let size = '';
if (name === 'font') {
const colorMatch = String(attrs).match(/\scolor\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (colorMatch) color = (colorMatch[2] ?? colorMatch[3] ?? colorMatch[4] ?? '').trim();
const sizeMatch = String(attrs).match(/\ssize\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
if (sizeMatch) size = (sizeMatch[2] ?? sizeMatch[3] ?? sizeMatch[4] ?? '').trim();
}
const extra: string[] = [];
if (style) extra.push(`style="${style}"`);
if (color) extra.push(`color="${color.replace(/"/g, '')}"`);
if (size) extra.push(`size="${size.replace(/"/g, '')}"`);
return extra.length ? `<${name} ${extra.join(' ')}>` : `<${name}>`;
});
const cleaned = html.replace(/&nbsp;/g, ' ').trim();
return cleaned || null;
} }
-15
View File
@@ -3,21 +3,6 @@ export interface AdminListQuery {
pageSize?: number; pageSize?: number;
} }
/** HQ 订单列表 / 导出筛选。`status` 可多选(单值仍兼容) */
export interface AdminOrdersListQuery extends AdminListQuery {
orderNo?: string;
status?: string | string[];
orderType?: string;
userId?: string;
cityId?: string;
receiverPhone?: string;
fulfillmentHold?: string | boolean;
createdFrom?: string;
createdTo?: string;
excludeTest?: boolean;
deliveryType?: string;
}
export interface UpdateAdminUserRequest { export interface UpdateAdminUserRequest {
hqRemark: string; hqRemark: string;
} }
-39
View File
@@ -1,39 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildShopRedeemLandingUrl, parseRedeemTokenFromScan } from './redeem';
const TOKEN = 'a1b2c3d4e5f6789012345678901234ab';
describe('buildShopRedeemLandingUrl', () => {
it('joins shop H5 origin with /redeem?token=', () => {
expect(buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN)).toBe(
`https://shop.dukanghaoke.com/redeem?token=${TOKEN}`,
);
});
it('strips trailing slash on the base', () => {
expect(buildShopRedeemLandingUrl('https://shop-test.dukanghaoke.com/', TOKEN)).toBe(
`https://shop-test.dukanghaoke.com/redeem?token=${TOKEN}`,
);
});
});
describe('parseRedeemTokenFromScan', () => {
it('accepts a raw 32-hex token', () => {
expect(parseRedeemTokenFromScan(TOKEN.toUpperCase())).toBe(TOKEN);
});
it('extracts token from shop landing URL', () => {
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
expect(parseRedeemTokenFromScan(url)).toBe(TOKEN);
});
it('strips WeChat QR_CODE prefix before parsing URL', () => {
const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN);
expect(parseRedeemTokenFromScan(`QR_CODE,${url}`)).toBe(TOKEN);
});
it('returns null for empty or unrelated content', () => {
expect(parseRedeemTokenFromScan('')).toBeNull();
expect(parseRedeemTokenFromScan('https://shop.dukanghaoke.com/records')).toBeNull();
});
});
-42
View File
@@ -9,48 +9,6 @@ export interface RedeemTokenResult {
expireAt: string; expireAt: string;
amount: number; amount: number;
boundStoreId?: string | null; boundStoreId?: string | null;
/** 门店 H5 核销确认页落地 URL(写入二维码,微信扫一扫可直达) */
landingUrl?: string;
}
const REDEEM_TOKEN_HEX = /^[a-f0-9]{32}$/i;
/** 门店核销确认页落地 URL,供用户端核销码使用 */
export function buildShopRedeemLandingUrl(shopH5Url: string, token: string): string {
const base = shopH5Url.replace(/\/$/, '');
return `${base}/redeem?token=${encodeURIComponent(token)}`;
}
/** 微信扫码偶发 `QR_CODE,payload` 前缀 */
function stripScanTypePrefix(raw: string): string {
const comma = raw.indexOf(',');
if (comma > 0 && comma < 24 && /^[A-Z0-9_]+$/.test(raw.slice(0, comma))) {
return raw.slice(comma + 1).trim();
}
return raw;
}
/** 从微信扫码结果解析核销 token32 位 hex 或带 token 参数的 URL */
export function parseRedeemTokenFromScan(raw: string): string | null {
const trimmed = stripScanTypePrefix(raw.trim());
if (!trimmed) return null;
if (REDEEM_TOKEN_HEX.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
const fromQuery = url.searchParams.get('token');
if (fromQuery && REDEEM_TOKEN_HEX.test(fromQuery)) {
return fromQuery.toLowerCase();
}
} catch {
/* not a URL */
}
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
return hexMatch ? hexMatch[0].toLowerCase() : null;
} }
export interface RedeemPreviewDto { export interface RedeemPreviewDto {
-2
View File
@@ -95,8 +95,6 @@ export interface OrderTrackDto {
provider?: string; provider?: string;
trackingNo?: string | null; trackingNo?: string | null;
logisticsCompany?: string | null; logisticsCompany?: string | null;
/** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */
queryError?: string | null;
} }
/** 大单拦截原因:≥10 箱不自动推小飞侠 */ /** 大单拦截原因:≥10 箱不自动推小飞侠 */
-4
View File
@@ -64,10 +64,6 @@ export type ClientRuntimeConfig = {
qualificationDisclosureUrl?: string; qualificationDisclosureUrl?: string;
/** 总部客服电话 */ /** 总部客服电话 */
customerServicePhone?: string; customerServicePhone?: string;
/** 企业微信「微信客服」链接(kfid) */
customerServiceWecomUrl?: string;
/** 企业微信 CorpID(小程序调起企微客服) */
wecomCorpId?: string;
/** 合伙人入驻:企微客服二维码图片 URL */ /** 合伙人入驻:企微客服二维码图片 URL */
partnerOnboardCsQrUrl?: string | null; partnerOnboardCsQrUrl?: string | null;
/** 合伙人入驻:企微客服提示文案 */ /** 合伙人入驻:企微客服提示文案 */
+1 -2
View File
@@ -9,6 +9,5 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["src/**/*.test.ts"]
} }
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
},
});
-3
View File
@@ -279,9 +279,6 @@ importers:
typescript: typescript:
specifier: ^5.4.5 specifier: ^5.4.5
version: 5.9.3 version: 5.9.3
vitest:
specifier: ^1.6.1
version: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
packages/shared-ui: packages/shared-ui:
devDependencies: devDependencies:
-3
View File
@@ -31,9 +31,6 @@ MOCK_WECHAT=true
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL # C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user # 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
# USER_H5_URL=https://user.runxian.top/user # USER_H5_URL=https://user.runxian.top/user
# 门店 H5 落地页(用户核销码二维码链接前缀,SHOP_H5_URL
# 未配置时默认 https://shop.dukanghaoke.com;本地开发可设为 http://localhost:5174
# SHOP_H5_URL=https://shop.dukanghaoke.com
# 反向代理后提取真实客户端 IP(下单 IP 定位) # 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true TRUST_PROXY=true
@@ -30,8 +30,6 @@ TRUST_PROXY=true
# C 端 H5 落地页(推广码二维码;生产统一入口) # C 端 H5 落地页(推广码二维码;生产统一入口)
USER_H5_URL=https://user.runxian.top/user USER_H5_URL=https://user.runxian.top/user
# 门店 H5 落地页(用户核销码二维码)
SHOP_H5_URL=https://shop.dukanghaoke.com
MOCK_WECHAT=false MOCK_WECHAT=false
WX_APP_ID= WX_APP_ID=
-2
View File
@@ -32,8 +32,6 @@ TRUST_PROXY=true
# C 端 H5(测试域) # C 端 H5(测试域)
USER_H5_URL=https://user-test.dukanghaoke.com/user USER_H5_URL=https://user-test.dukanghaoke.com/user
# 门店 H5(测试域,核销码落地页)
SHOP_H5_URL=https://shop-test.dukanghaoke.com
# 正式号配置可与生产相同,但 Mock 打开后不走真实支付 # 正式号配置可与生产相同,但 Mock 打开后不走真实支付
WX_APP_ID= WX_APP_ID=
@@ -1,3 +0,0 @@
-- v3.5.10:承运商「配送信息提示」(C 端同城送 HTML)
ALTER TABLE `common_fulfillment_provider`
ADD COLUMN `delivery_hint_html` TEXT NULL AFTER `prepaid_balance`;
-2
View File
@@ -1057,8 +1057,6 @@ model FulfillmentProvider {
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method") settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
pricingRulesJson String? @map("pricing_rules_json") @db.Text pricingRulesJson String? @map("pricing_rules_json") @db.Text
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2) prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
/// C 端同城配送提示(HTML,消毒后下发)
deliveryHintHtml String? @map("delivery_hint_html") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -47,7 +47,6 @@ const fixed = {
AUTO_APPROVE_STORE: 'true', AUTO_APPROVE_STORE: 'true',
TRUST_PROXY: 'true', TRUST_PROXY: 'true',
USER_H5_URL: 'https://user-test.dukanghaoke.com/user', USER_H5_URL: 'https://user-test.dukanghaoke.com/user',
SHOP_H5_URL: 'https://shop-test.dukanghaoke.com',
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay', WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
OSS_UPLOAD_PREFIX: 'staging/uploads', OSS_UPLOAD_PREFIX: 'staging/uploads',
WECOM_AIBOT_ENABLED: 'false', WECOM_AIBOT_ENABLED: 'false',
@@ -77,7 +77,6 @@ export const HqOperationAction = {
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM', LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM', LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE', LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
LOGISTICS_PROVIDER_DELETE: 'LOGISTICS_PROVIDER_DELETE',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN', REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM', REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
@@ -203,7 +202,6 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算', [HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算', [HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值', [HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
[HqOperationAction.LOGISTICS_PROVIDER_DELETE]: '删除物流承运商',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
@@ -5,7 +5,6 @@ import {
BRAND_LOGO_URL, BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL, BRAND_LOGO_WIDE_URL,
CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_PHONE,
CUSTOMER_SERVICE_WECOM_URL,
DEFAULT_SHARE_BENEFIT_TITLE, DEFAULT_SHARE_BENEFIT_TITLE,
DEFAULT_SHARE_DESC, DEFAULT_SHARE_DESC,
DEFAULT_SHARE_HINT, DEFAULT_SHARE_HINT,
@@ -13,7 +12,6 @@ import {
DEFAULT_SHARE_ORDER_TITLE, DEFAULT_SHARE_ORDER_TITLE,
DEFAULT_SHARE_STORES_TITLE, DEFAULT_SHARE_STORES_TITLE,
DEFAULT_SHARE_TITLE, DEFAULT_SHARE_TITLE,
DEFAULT_SHOP_H5_URL,
DEFAULT_USER_H5_URL, DEFAULT_USER_H5_URL,
MINI_USER_STATIC_OSS_BASE, MINI_USER_STATIC_OSS_BASE,
MOCK_SMS_FIXED_CODE, MOCK_SMS_FIXED_CODE,
@@ -156,15 +154,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
placeholder: 'https://user.example.com/user', placeholder: 'https://user.example.com/user',
description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)', description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)',
}, },
{
key: 'SHOP_H5_URL',
label: '门店 H5 落地页',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: 'https://shop.dukanghaoke.com',
description: '用户核销码二维码链接前缀;扫码直达门店核销确认页(无末尾斜杠)',
},
{ {
key: 'BRAND_LOGO_OSS_BASE', key: 'BRAND_LOGO_OSS_BASE',
label: '品牌 Logo OSS 根路径', label: '品牌 Logo OSS 根路径',
@@ -224,24 +213,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
placeholder: '13203801799', placeholder: '13203801799',
description: 'C 端联系客服拨号号码', description: 'C 端联系客服拨号号码',
}, },
{
key: 'CUSTOMER_SERVICE_WECOM_URL',
label: '企微微信客服链接',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: 'https://work.weixin.qq.com/kfid/kfc…',
description: 'C 端在线客服 kfid 链接;小程序需同时配置下方企业 ID',
},
{
key: 'WECOM_CORP_ID',
label: '企微企业 IDCorpID',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: 'wwxxxxxxxxxxxx',
description: '企业微信「我的企业」企业 ID。小程序须已关联该企业的微信客服;未填则回退小程序原生客服',
},
{ {
key: 'PARTNER_ONBOARD_CS_QR_URL', key: 'PARTNER_ONBOARD_CS_QR_URL',
label: '合伙人入驻 · 企微客服二维码', label: '合伙人入驻 · 企微客服二维码',
@@ -613,7 +584,6 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */ /** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = { export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''), USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE, BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
BRAND_LOGO_URL: BRAND_LOGO_URL, BRAND_LOGO_URL: BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL, BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL,
@@ -621,7 +591,6 @@ export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE, MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE,
QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL, QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL,
CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE,
CUSTOMER_SERVICE_WECOM_URL: CUSTOMER_SERVICE_WECOM_URL,
MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE, MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE,
SHARE_HINT: DEFAULT_SHARE_HINT, SHARE_HINT: DEFAULT_SHARE_HINT,
SHARE_DEFAULT_TITLE: DEFAULT_SHARE_TITLE, SHARE_DEFAULT_TITLE: DEFAULT_SHARE_TITLE,
@@ -37,8 +37,6 @@ export class ClientConfigController {
brandLogoMarkUrl: brand.brandLogoMarkUrl, brandLogoMarkUrl: brand.brandLogoMarkUrl,
qualificationDisclosureUrl: brand.qualificationDisclosureUrl, qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
customerServicePhone: brand.customerServicePhone, customerServicePhone: brand.customerServicePhone,
customerServiceWecomUrl: brand.customerServiceWecomUrl || null,
wecomCorpId: brand.wecomCorpId || null,
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null, partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
partnerOnboardCsHint: partnerOnboardCsHint:
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() || (env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||

Some files were not shown because too many files have changed in this diff Show More