41 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 30f8d649cb Merge pull request 'v3.5.9版本迭代列表优化' (#48) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/48
2026-08-25 12:39:30 +08:00
jacy f4da71e952 v3.5.9版本迭代列表优化
CI / verify (pull_request) Waiting to run
2026-08-25 12:38:21 +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 d7241d54b1 Merge pull request '给用户增加“备注”列' (#46) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/46
2026-08-25 10:10:07 +08:00
jacy 282da24bc4 给用户增加“备注”列
CI / verify (pull_request) Waiting to run
2026-08-25 10:09:05 +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 5e0691ab35 Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#44) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/44
2026-08-25 09:22:48 +08:00
jacy 5935024ea8 v3.5.8和v3.5.9版本更新
CI / verify (pull_request) Waiting to run
2026-08-25 09:20:32 +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 b90a4e9f8f Merge pull request 'v3.5.7版本迭代' (#42) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/42
2026-08-23 19:27:14 +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 6973e3d7f5 Merge pull request '修改订单导出时间错误' (#40) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/40
2026-08-23 17:34:21 +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 a144a35c3d Merge pull request 'Dev jacy v3.5.6 版本迭代' (#38) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/38
2026-08-23 16:43:07 +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 012626f6bd Merge pull request 'v3.5.5版本上传' (#36) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/36
2026-08-23 12:29:33 +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 3004a65b35 Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev 2026-08-21 23:52:37 +08:00
jacy 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +08:00
jacy 7d2e4ef57e merge(dev_jacy): v3.5.4 规格主图与门店使用规则 2026-08-21 17:37:09 +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 73fd27bdf6 Merge pull request 'v3.5.4版本提交,修改api' (#34) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/34
2026-08-21 16:23:54 +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 cd9696bc1c Merge pull request 'v3.5.4版本提交' (#32) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/32
2026-08-21 15:50:07 +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 b5d4f8b27c Merge pull request 'v3.5.3版本更新2' (#30) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/30
2026-08-20 20:23:03 +08:00
jacy 9a80f0f8d6 Merge pull request 'v3.5.3版本更新1' (#29) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/29
2026-08-20 18:55:43 +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 e8b585e623 Merge pull request '增加音效逻辑' (#26) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/26
2026-08-20 15:06:07 +08:00
jacy 5702266276 Merge pull request 'Dev jacy' (#25) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/25
2026-08-20 13:43:29 +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 a8a37fbc4a Merge pull request 'v3.5.2 大屏调整' (#22) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/22
2026-08-20 12:00:57 +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 e34416a9b3 Merge pull request 'Dev jacy mock 支付调整' (#20) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/20
2026-08-20 00:54:17 +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 6c44e9c56f Merge pull request 'v3.5.1 版本更新' (#18) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/18
2026-08-19 16:00:56 +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
jacy 905e145af3 Merge pull request 'v3.4.18' (#16) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/16
2026-08-17 23:45:00 +08:00
106 changed files with 4586 additions and 1463 deletions
+60 -16
View File
@@ -16,37 +16,65 @@ body,
} }
/* /*
* 后台内容行:不换行 + 超出省略 * 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表 * 描述列表仍省略,避免详情抽屉被长字段撑破
*/ */
.admin-layout .ant-table-cell, .admin-layout .ant-table-cell,
.ant-drawer .ant-table-cell, .ant-drawer .ant-table-cell,
.ant-modal .ant-table-cell, .ant-modal .ant-table-cell {
white-space: nowrap;
overflow: visible;
text-overflow: clip;
max-width: none;
}
.admin-layout .ant-descriptions-item-content, .admin-layout .ant-descriptions-item-content,
.ant-drawer .ant-descriptions-item-content, .ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content { .ant-modal .ant-descriptions-item-content {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 0;
}
.admin-layout .ant-descriptions-item-content,
.ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content {
max-width: 100%; max-width: 100%;
} }
/* 表头同样不换行 */ /* 表头不换行 */
.admin-layout .ant-table-thead > tr > th, .admin-layout .ant-table-thead > tr > th,
.ant-drawer .ant-table-thead > tr > th, .ant-drawer .ant-table-thead > tr > th,
.ant-modal .ant-table-thead > tr > th { .ant-modal .ant-table-thead > tr > th {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: visible;
text-overflow: ellipsis; text-overflow: clip;
} }
/* 操作列、含交互控件的不截断 */ /* 拖表头分割线改列宽 */
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
position: relative;
}
.admin-col-resize-handle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
cursor: col-resize;
z-index: 3;
user-select: none;
touch-action: none;
}
.admin-col-resize-handle:hover,
.admin-col-resizing .admin-col-resize-handle {
background: rgba(22, 119, 255, 0.18);
}
body.admin-col-resizing,
body.admin-col-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
/* 操作列保持可见 */
.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),
.ant-modal .ant-table-cell:has(.ant-btn), .ant-modal .ant-table-cell:has(.ant-btn),
@@ -71,10 +99,9 @@ body,
max-width: none; max-width: none;
} }
/* 表格单元格双行信息合并为单行省略 */ /* 表格单元格主/副信息同一行 */
.admin-cell-line { .admin-cell-line {
display: block; display: inline;
max-width: 100%;
} }
.admin-cell-line-secondary { .admin-cell-line-secondary {
@@ -82,6 +109,23 @@ body,
font-size: 12px; font-size: 12px;
} }
.admin-primary-link {
display: inline;
margin: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.admin-primary-link:hover {
color: #1677ff;
}
.admin-table-nowrap .ant-table-cell, .admin-table-nowrap .ant-table-cell,
.admin-table-nowrap .ant-table-cell-ellipsis { .admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap; white-space: nowrap;
@@ -1,12 +1,10 @@
import { Typography } from 'antd';
type AdminCellLineProps = { type AdminCellLineProps = {
primary?: string | null; primary?: string | null;
secondary?: string | null; secondary?: string | null;
separator?: string; separator?: string;
}; };
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */ /** 表格单元格:主/副信息同一行完整展示 */
export function AdminCellLine({ export function AdminCellLine({
primary, primary,
secondary, secondary,
@@ -14,14 +12,9 @@ export function AdminCellLine({
}: AdminCellLineProps) { }: AdminCellLineProps) {
const main = primary?.trim() || '—'; const main = primary?.trim() || '—';
const sub = secondary?.trim(); const sub = secondary?.trim();
const full = sub ? `${main}${separator}${sub}` : main;
return ( return (
<Typography.Text <span className="admin-cell-line">
className="admin-cell-line"
ellipsis={{ tooltip: full }}
style={{ maxWidth: '100%' }}
>
{main} {main}
{sub ? ( {sub ? (
<span className="admin-cell-line-secondary"> <span className="admin-cell-line-secondary">
@@ -29,6 +22,6 @@ export function AdminCellLine({
{sub} {sub}
</span> </span>
) : null} ) : null}
</Typography.Text> </span>
); );
} }
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { Button, Checkbox, Modal, Space, Typography } from 'antd';
import { HolderOutlined } from '@ant-design/icons';
import type { ListColumnSettingItem } from '../lib/list-column-prefs';
type Props = {
open: boolean;
items: ListColumnSettingItem[];
saving?: boolean;
onCancel: () => void;
onReset: () => void;
onSave: (items: ListColumnSettingItem[]) => void;
};
export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) {
const [draft, setDraft] = useState<ListColumnSettingItem[]>(items);
const [dragKey, setDragKey] = useState<string | null>(null);
useEffect(() => {
if (open) setDraft(items);
}, [open, items]);
function move(from: number, to: number) {
if (to < 0 || to >= draft.length) return;
const next = [...draft];
const [row] = next.splice(from, 1);
next.splice(to, 0, row);
setDraft(next);
}
return (
<Modal
title="列设置"
open={open}
onCancel={onCancel}
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button onClick={onReset}></Button>
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" loading={saving} onClick={() => onSave(draft)}>
</Button>
</Space>
</Space>
}
>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
</Typography.Paragraph>
<div>
{draft.map((item, index) => (
<div
key={item.key}
draggable
onDragStart={() => setDragKey(item.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (!dragKey || dragKey === item.key) return;
const from = draft.findIndex((r) => r.key === dragKey);
if (from >= 0) move(from, index);
setDragKey(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 0',
borderBottom: '1px solid #f0f0f0',
cursor: 'move',
}}
>
<HolderOutlined style={{ color: '#999' }} />
<Checkbox
checked={item.visible}
onChange={(e) => {
const next = [...draft];
next[index] = { ...item, visible: e.target.checked };
setDraft(next);
}}
>
{item.title}
</Checkbox>
<span style={{ marginLeft: 'auto' }}>
<Button type="link" size="small" disabled={index === 0} onClick={() => move(index, index - 1)}>
</Button>
<Button
type="link"
size="small"
disabled={index === draft.length - 1}
onClick={() => move(index, index + 1)}
>
</Button>
</span>
</div>
))}
</div>
</Modal>
);
}
@@ -0,0 +1,25 @@
import type { MouseEvent, ReactNode } from 'react';
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
export function AdminPrimaryLink({
children,
onClick,
}: {
children?: ReactNode;
onClick: (e?: MouseEvent) => void;
}) {
const empty = children == null || children === '';
return (
<button
type="button"
className="admin-primary-link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick(e);
}}
>
{empty ? '—' : children}
</button>
);
}
@@ -1,459 +1,458 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Checkbox, Checkbox,
Drawer, Drawer,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
Table, Table,
Tabs, Tabs,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
CITY_PARTNER_SCOPE_LABELS, CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS, CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType, CityPartnerScopeType,
CityPartnerStatus, CityPartnerStatus,
PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS, PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect'; import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList'; import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = { type PartnerRow = {
id: string; id: string;
companyName: string; companyName: string;
phone: string; phone: string;
name: string; name: string;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null; districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
accountCount: number; accountCount: number;
createdAt: string; createdAt: string;
}; };
type PartnerDetail = PartnerRow & { type PartnerDetail = PartnerRow & {
address?: string; address?: string;
districtCodes?: string[] | null; districtCodes?: string[] | null;
bindingStatus?: string; bindingStatus?: string;
bankAccountName?: string | null; bankAccountName?: string | null;
bankAccountNo?: string | null; bankAccountNo?: string | null;
bankBranch?: string | null; bankBranch?: string | null;
managedWarehouseId?: string | null; managedWarehouseId?: string | null;
managedWarehouseName?: string | null; managedWarehouseName?: string | null;
contactPhone?: string | null; contactPhone?: string | null;
children?: Array<{ children?: Array<{
id: string; id: string;
phone: string; phone: string;
name: string; name: string;
staffRole?: string; staffRole?: string;
permissions?: string[]; permissions?: string[];
status: string; status: string;
}>; }>;
}; };
type Props = { type Props = {
cityId: string; cityId: string;
cityCode?: string; cityCode?: string;
maxPartnerCommissionRate?: number; maxPartnerCommissionRate?: number;
onChanged?: () => void; onChanged?: () => void;
}; };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label })); const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k, value: k,
label: PARTNER_PERMISSION_LABELS[k], label: PARTNER_PERMISSION_LABELS[k],
})); }));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS) const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER') .filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label })); .map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return []; if (!values?.length) return [];
if (Array.isArray(values[0])) { if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
} }
return values as string[]; return values as string[];
} }
function commissionSumError( function commissionSumError(
orderPercent: number, orderPercent: number,
redeemPercent: number, redeemPercent: number,
maxRate: number, maxRate: number,
): string | null { ): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate); const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效'; if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) { if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`; return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
} }
return null; return null;
} }
function formatApiError(err: unknown): string | null { function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null; if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败'; if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => { return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code); const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code; return label !== code ? `${label}(${code})` : code;
}); });
} }
export default function CityPartnersPanel({ export default function CityPartnersPanel({
cityId, cityId,
cityCode, cityCode,
maxPartnerCommissionRate = 0.05, maxPartnerCommissionRate = 0.05,
onChanged, onChanged,
}: Props) { }: Props) {
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm(); const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]); const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null); const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false); const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false); const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null); const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => { const loadPartners = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`); const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items); setPartners(res.items);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [cityId]); }, [cityId]);
useEffect(() => { useEffect(() => {
void loadPartners(); void loadPartners();
}, [loadPartners]); }, [loadPartners]);
async function openPartner(id: string) { async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({ editForm.setFieldsValue({
name: d.name, name: d.name,
phone: d.phone, phone: d.phone,
companyName: d.companyName, companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone, contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '', address: d.address ?? '',
scopeType: d.scopeType, scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [], districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE, bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '', bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '', bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '', bankBranch: d.bankBranch ?? '',
}); });
setDrawerOpen(true); setDrawerOpen(true);
} }
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
try { try {
const v = await editForm.validateFields(); const v = await editForm.validateFields();
const err = commissionSumError( const err = commissionSumError(
Number(v.orderCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0), Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate, maxPartnerCommissionRate,
); );
if (err) { if (err) {
message.error(err); message.error(err);
return; return;
} }
await request(`/admin/partners/${detail.id}`, { await request(`/admin/partners/${detail.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
...v, ...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}), }),
}); });
message.success('已保存'); message.success('已保存');
setDrawerOpen(false); setDrawerOpen(false);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
const msg = formatApiError(e); const msg = formatApiError(e);
if (msg) message.error(msg); if (msg) message.error(msg);
} }
} }
async function deleteSubAccount(subId: string) { async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' }); await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除'); message.success('子账号已删除');
if (detail) void openPartner(detail.id); if (detail) void openPartner(detail.id);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
} }
const columns: ColumnsType<PartnerRow> = [ const columns: ColumnsType<PartnerRow> = [
{ {
title: '区县', title: '区县',
dataIndex: 'districtCodes', dataIndex: 'districtCodes',
width: 160, width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) => render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true }, { title: '公司名', dataIndex: 'companyName' },
{ title: '主账号', dataIndex: 'phone', width: 120 }, { title: '主账号', dataIndex: 'phone', width: 120 },
{ {
title: '管辖', title: '管辖',
dataIndex: 'scopeType', dataIndex: 'scopeType',
width: 100, width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
}, },
{ {
title: '佣金', title: '佣金',
width: 120, width: 120,
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`, render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
}, },
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 80,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}> <Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button> </Button>
), ),
}, },
]; ];
return ( return (
<> <>
<Button <Button
type="primary" type="primary"
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
onClick={() => { onClick={() => {
createForm.resetFields(); createForm.resetFields();
createForm.setFieldsValue({ createForm.setFieldsValue({
cityId, cityId,
orderCommissionRate: 0, orderCommissionRate: 0,
redeemCommissionRate: 3, redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE, scopeType: CityPartnerScopeType.CITY_WIDE,
}); });
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
</Button> </Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} /> <Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer <Drawer
title="城市合伙人" title="城市合伙人"
width={640} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>} extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
> >
{detail && ( {detail && (
<Tabs <Tabs
items={[ items={[
{ {
key: 'info', key: 'info',
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)" extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={cityCode} /> <CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space> </Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}% + {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text> </Typography.Text>
<Form.Item label="管仓仓库"> <Form.Item label="管仓仓库">
<Input <Input
disabled disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'} value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/> />
</Form.Item> </Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item> <Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item> <Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item> <Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form> </Form>
), ),
}, },
{ {
key: 'staff', key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`, label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: ( children: (
<PartnerSubAccountList <PartnerSubAccountList
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(row) => { onEdit={(row) => {
setSubEditId(row.id); setSubEditId(row.id);
subEditForm.setFieldsValue({ subEditForm.setFieldsValue({
name: row.name, name: row.name,
phone: row.phone, phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL', staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [], permissions: row.permissions ?? [],
status: row.status, status: row.status,
}); });
setSubEditOpen(true); setSubEditOpen(true);
}} }}
onDelete={(subId) => void deleteSubAccount(subId)} onDelete={(subId) => void deleteSubAccount(subId)}
/> />
), ),
}, },
]} ]}
/> />
)} )}
</Drawer> </Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try { try {
const v = await createForm.validateFields(); const v = await createForm.validateFields();
const err = commissionSumError( const err = commissionSumError(
Number(v.orderCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3), Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate, maxPartnerCommissionRate,
); );
if (err) { if (err) {
message.error(err); message.error(err);
return; return;
} }
await request('/admin/partners', { await request('/admin/partners', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
...v, ...v,
cityId, cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}), }),
}); });
message.success('已创建'); message.success('已创建');
setCreateOpen(false); setCreateOpen(false);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
const msg = formatApiError(e); const msg = formatApiError(e);
if (msg) message.error(msg); if (msg) message.error(msg);
} }
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)" extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={cityCode} /> <CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space> </Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}% + {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text> </Typography.Text>
</Form> </Form>
</Modal> </Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => { <Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return; if (!detail) return;
const v = await subForm.validateFields(); const v = await subForm.validateFields();
await request('/admin/partner-accounts', { await request('/admin/partner-accounts', {
method: 'POST', method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }), body: JSON.stringify({ ...v, parentAccountId: detail.id }),
}); });
message.success('已创建'); message.success('已创建');
setSubOpen(false); setSubOpen(false);
void openPartner(detail.id); void openPartner(detail.id);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
}}> }}>
<Form form={subForm} layout="vertical"> <Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL"> <Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} /> <Select options={STAFF_ROLE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="permissions" label="权限"> <Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} /> <Checkbox.Group options={PERM_OPTIONS} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => { <Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return; if (!subEditId || !detail) return;
const v = await subEditForm.validateFields(); const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) }); await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新'); message.success('已更新');
setSubEditOpen(false); setSubEditOpen(false);
void openPartner(detail.id); void openPartner(detail.id);
}}> }}>
<Form form={subEditForm} layout="vertical"> <Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item> <Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} /> <Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item> </Form.Item>
<Form.Item name="permissions" label="权限"> <Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} /> <Checkbox.Group options={PERM_OPTIONS} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
</> </>
); );
}
@@ -1,92 +1,91 @@
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd'; import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
PARTNER_PERMISSION_LABELS, PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
export type PartnerSubAccountRow = { export type PartnerSubAccountRow = {
id: string; id: string;
phone: string; phone: string;
name: string; name: string;
staffRole?: string; staffRole?: string;
permissions?: string[]; permissions?: string[];
status: string; status: string;
}; };
function formatPermissions(permissions?: string[]) { function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—'; return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
} }
type Props = { type Props = {
subs: PartnerSubAccountRow[]; subs: PartnerSubAccountRow[];
onAdd: () => void; onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void; onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void; onDelete: (subId: string) => void;
}; };
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) { export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [ const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '角色', title: '角色',
dataIndex: 'staffRole', dataIndex: 'staffRole',
width: 90, width: 90,
render: (v) => render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—', v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 80, width: 80,
render: (s) => ( render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag> <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
), ),
}, },
{ {
title: '权限', title: '权限',
dataIndex: 'permissions', dataIndex: 'permissions',
ellipsis: true,
render: (p: string[] | undefined) => formatPermissions(p), render: (p: string[] | undefined) => formatPermissions(p),
}, },
{ {
title: '操作', title: '操作',
width: 120, width: 120,
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}> <Button type="link" size="small" onClick={() => onEdit(row)}>
</Button> </Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}> <Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger> <Button type="link" size="small" danger>
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
}, },
]; ];
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}· {subs.length}·
</Typography.Text> </Typography.Text>
<Button size="small" type="primary" onClick={onAdd}> <Button size="small" type="primary" onClick={onAdd}>
</Button> </Button>
</div> </div>
<Table <Table
size="small" size="small"
rowKey="id" rowKey="id"
pagination={false} pagination={false}
columns={columns} columns={columns}
dataSource={subs} dataSource={subs}
locale={{ emptyText: '暂无子账号' }} locale={{ emptyText: '暂无子账号' }}
/> />
</> </>
); );
}
+13 -7
View File
@@ -20,10 +20,11 @@ import {
AccountBookOutlined, AccountBookOutlined,
ProjectOutlined, ProjectOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } 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';
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -156,6 +157,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
if (key === 'tickets-group') { if (key === 'tickets-group') {
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support'); return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
} }
if (key === 'stores-group') {
return hasAnyStoreMenuPermission(permissionKeys);
}
if (key === 'dev-plan-group') { if (key === 'dev-plan-group') {
return permissionKeys.includes('dev_plan'); return permissionKeys.includes('dev_plan');
} }
@@ -175,11 +179,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/knowledge-bases': 'knowledge_bases', '/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores', 'stores-group': 'stores',
'/stores': 'stores', '/stores': 'stores',
'/store-package-audits': 'stores', '/store-package-audits': 'store_audits',
'/store-ratings': 'stores', '/store-ratings': 'store_ratings',
'/store-categories': 'stores', '/store-categories': 'store_categories',
'/store-accounts': 'stores', '/store-accounts': 'store_accounts',
'/store-media': 'stores', '/store-media': 'store_media',
'partners-group': 'partners', 'partners-group': 'partners',
'/cities': 'partners', '/cities': 'partners',
'/city-partners': 'partners', '/city-partners': 'partners',
@@ -413,7 +417,9 @@ export default function AdminLayout() {
style={{ flex: 1, minHeight: 0, overflow: 'auto' }} style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
> >
<Content style={{ margin: 24 }}> <Content style={{ margin: 24 }}>
<Outlet /> <ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
<Outlet />
</ListColumnPrefsProvider>
</Content> </Content>
</div> </div>
</Layout> </Layout>
@@ -0,0 +1,54 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types';
import { request } from './api';
type Ctx = {
prefs: HqListColumnPrefsMap;
ready: boolean;
setPrefs: (next: HqListColumnPrefsMap) => void;
save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise<void>;
};
const ListColumnPrefsContext = createContext<Ctx>({
prefs: {},
ready: false,
setPrefs: () => {},
save: async () => {},
});
export function ListColumnPrefsProvider({
initial,
children,
}: {
initial?: HqListColumnPrefsMap;
children: ReactNode;
}) {
const [prefs, setPrefs] = useState<HqListColumnPrefsMap>(initial ?? {});
const [ready] = useState(true);
useEffect(() => {
if (initial) setPrefs(initial);
}, [initial]);
const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => {
const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>(
`/admin/me/list-columns/${listKey}`,
{
method: 'PUT',
body: JSON.stringify(
next
? { order: next.order, hidden: next.hidden, widths: next.widths }
: { reset: true },
),
},
);
setPrefs(res.listColumnPrefs ?? {});
}, []);
const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]);
return <ListColumnPrefsContext.Provider value={value}>{children}</ListColumnPrefsContext.Provider>;
}
export function useListColumnPrefs() {
return useContext(ListColumnPrefsContext);
}
+10
View File
@@ -1,3 +1,5 @@
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
export const apiBase = '/api/v1'; export const apiBase = '/api/v1';
export const CLIENT_APP = 'HQ_WEB'; export const CLIENT_APP = 'HQ_WEB';
@@ -8,6 +10,9 @@ export type HqProfile = {
adminRole: string; adminRole: string;
status: string; status: string;
permissionKeys?: string[]; permissionKeys?: string[];
cityIds?: string[];
cityScoped?: boolean;
listColumnPrefs?: HqListColumnPrefsMap;
}; };
export function getToken() { export function getToken() {
@@ -87,6 +92,9 @@ export type DashboardStats = {
openTickets?: number; openTickets?: number;
pendingStoreWithdrawals?: number; pendingStoreWithdrawals?: number;
overdueStoreWithdrawals?: number; overdueStoreWithdrawals?: number;
pendingStoreOnboard?: number;
pendingStorePackageAudits?: number;
pendingStoreInfoChanges?: number;
ordersByStatus: Array<{ status: string; count: number }>; ordersByStatus: Array<{ status: string; count: number }>;
}; };
@@ -161,6 +169,7 @@ export type AdminUserRow = {
wxOpenId: string | null; wxOpenId: string | null;
wechatVerified: boolean; wechatVerified: boolean;
nickname: string | null; nickname: string | null;
hqRemark: string | null;
status: number; status: number;
sourceType: string; sourceType: string;
sourceRefId: string | null; sourceRefId: string | null;
@@ -193,6 +202,7 @@ export type AdminOrderRow = {
productName?: string; productName?: string;
productSpec?: string; productSpec?: string;
quantity?: number; quantity?: number;
saleUnit?: string;
receiverName: string; receiverName: string;
receiverPhone: string; receiverPhone: string;
receiverProvince?: string; receiverProvince?: string;
+67
View File
@@ -0,0 +1,67 @@
import type { MouseEvent, ReactNode } from 'react';
import type { ColumnType } from 'antd/es/table';
export const MIN_COL_WIDTH = 48;
export const MAX_COL_WIDTH = 960;
export function clampColWidth(n: number): number {
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
}
export function beginColumnResize(
e: MouseEvent,
startWidth: number,
onMove: (width: number) => void,
onEnd: (width: number) => void,
) {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
let latest = clampColWidth(startWidth);
let moved = false;
document.body.classList.add('admin-col-resizing');
const onMouseMove = (ev: globalThis.MouseEvent) => {
moved = true;
latest = clampColWidth(startWidth + ev.clientX - startX);
onMove(latest);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.classList.remove('admin-col-resizing');
if (moved) onEnd(latest);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
export function withResizeTitle<T>(
title: ColumnType<T>['title'],
onMouseDown: (e: MouseEvent) => void,
): ColumnType<T>['title'] {
const handle: ReactNode = (
<span
role="separator"
aria-orientation="vertical"
aria-label="拖动调整列宽"
className="admin-col-resize-handle"
onClick={(ev) => ev.stopPropagation()}
onMouseDown={onMouseDown}
/>
);
if (typeof title === 'function') {
return (props) => (
<>
{title(props)}
{handle}
</>
);
}
return (
<>
{title}
{handle}
</>
);
}
+6
View File
@@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
MANUAL: '手动', MANUAL: '手动',
}; };
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
ON_SITE_PICKUP: '现场提货',
};
export const ORDER_STATUS_LABELS: Record<string, string> = { export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款', PENDING_PAY: '待付款',
PENDING_SHIP: '待发货', PENDING_SHIP: '待发货',
@@ -0,0 +1,97 @@
import type { ColumnType } from 'antd/es/table';
import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types';
export const SERIAL_COLUMN_KEY = '__serial';
export const ACTIONS_COLUMN_KEY = '__actions';
export function columnKey<T>(col: ColumnType<T>, index: number): string {
if (col.key != null && String(col.key).length) return String(col.key);
const dataIndex = col.dataIndex;
if (typeof dataIndex === 'string' && dataIndex) return dataIndex;
if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.');
if (typeof col.title === 'string' && col.title) return col.title;
return `col-${index}`;
}
export function isActionsColumn<T>(col: ColumnType<T>, key: string): boolean {
if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true;
return col.title === '操作';
}
export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] {
const known = new Set(defaultKeys);
const ordered = (pref?.order ?? []).filter((k) => known.has(k));
for (const key of defaultKeys) {
if (ordered.includes(key)) continue;
const defaultIdx = defaultKeys.indexOf(key);
let insertAt = ordered.length;
for (let i = 0; i < ordered.length; i += 1) {
if (defaultKeys.indexOf(ordered[i]) > defaultIdx) {
insertAt = i;
break;
}
}
ordered.splice(insertAt, 0, key);
}
return ordered;
}
export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] {
const hidden = new Set(pref?.hidden ?? []);
return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k));
}
export function applyColumnPrefs<T>(
columns: ColumnType<T>[],
pref: HqListColumnPref | undefined,
): ColumnType<T>[] {
const keyed = columns.map((col, i) => {
const key = columnKey(col, i);
return { col: { ...col, key }, key, actions: isActionsColumn(col, key) };
});
const configurable = keyed.filter((c) => !c.actions);
const actions = keyed.filter((c) => c.actions);
const defaultKeys = configurable.map((c) => c.key);
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
return [
...orderedKeys
.map((k) => {
const col = byKey.get(k);
if (!col) return null;
const w = pref?.widths?.[k];
return w != null ? { ...col, width: w } : col;
})
.filter((c): c is ColumnType<T> => !!c),
...actions.map((c) => {
const w = pref?.widths?.[c.key];
return w != null ? { ...c.col, width: w } : c.col;
}),
];
}
export type ListColumnSettingItem = {
key: string;
title: string;
visible: boolean;
};
export function settingItems<T>(
columns: ColumnType<T>[],
pref?: HqListColumnPref,
): ListColumnSettingItem[] {
const keyed = columns
.map((col, i) => ({ col, key: columnKey(col, i) }))
.filter(({ col, key }) => !isActionsColumn(col, key));
const hidden = new Set(pref?.hidden ?? []);
const order = mergeColumnOrder(keyed.map((c) => c.key), pref);
const byKey = new Map(keyed.map((c) => [c.key, c.col]));
return order.map((key) => {
const col = byKey.get(key);
const title = typeof col?.title === 'string' ? col.title : key;
return { key, title, visible: !hidden.has(key) };
});
}
export type { HqListColumnKey };
@@ -0,0 +1,173 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button, message } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import type { HqListColumnKey } from '@dukang/shared-types';
import { AdminListColumnsModal } from '../components/AdminListColumnsModal';
import { useListColumnPrefs } from './ListColumnPrefsContext';
import {
ACTIONS_COLUMN_KEY,
SERIAL_COLUMN_KEY,
applyColumnPrefs,
columnKey,
settingItems,
type ListColumnSettingItem,
} from './list-column-prefs';
import { beginColumnResize, withResizeTitle } from './column-resize';
type Options = {
page?: number;
pageSize?: number;
};
export function useAdminListColumns<T>(
listKey: HqListColumnKey,
allColumns: ColumnsType<T>,
options: Options = {},
) {
const { page = 1, pageSize = 20 } = options;
const { prefs, save } = useListColumnPrefs();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
const pref = prefs[listKey];
const prefRef = useRef(pref);
prefRef.current = pref;
const localWidthsRef = useRef(localWidths);
localWidthsRef.current = localWidths;
const itemsRef = useRef<ListColumnSettingItem[]>([]);
const configured = useMemo(
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
[allColumns, pref],
);
const items = useMemo(
() => settingItems(allColumns as ColumnType<T>[], pref),
[allColumns, pref],
);
itemsRef.current = items;
const serialCol: ColumnType<T> = useMemo(
() => ({
key: SERIAL_COLUMN_KEY,
title: '序号',
width: 64,
fixed: 'left' as const,
render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1,
}),
[page, pageSize],
);
const persistPref = useCallback(
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
if (!next) {
setLocalWidths({});
await save(listKey, null);
return;
}
await save(listKey, next);
},
[listKey, save],
);
const persist = useCallback(
async (next: ListColumnSettingItem[] | null) => {
setSaving(true);
try {
if (!next) {
await persistPref(null);
} else {
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
await persistPref({
order: next.map((i) => i.key),
hidden: next.filter((i) => !i.visible).map((i) => i.key),
...(Object.keys(widths).length ? { widths } : {}),
});
}
setOpen(false);
message.success(next ? '列设置已保存' : '已恢复默认列');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
},
[persistPref],
);
const persistWidth = useCallback(
async (key: string, width: number) => {
const current = prefRef.current;
const prevW = current?.widths?.[key];
if (prevW === width) return;
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
const hidden = current?.hidden ?? [];
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
try {
await persistPref({ order, hidden, widths });
} catch (e) {
message.error(e instanceof Error ? e.message : '列宽保存失败');
}
},
[persistPref],
);
const columns = useMemo<ColumnsType<T>>(() => {
const merged: ColumnType<T>[] = [
serialCol,
...configured.map((col) =>
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
: col,
),
];
const widths = { ...(pref?.widths ?? {}), ...localWidths };
return merged.map((col, i) => {
const key = columnKey(col, i);
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
const prevHeader = col.onHeaderCell;
return {
...col,
key,
width,
title: withResizeTitle(col.title, (e) => {
const th = (e.currentTarget as HTMLElement).closest('th');
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
beginColumnResize(
e,
startWidth,
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
(next) => void persistWidth(key, next),
);
}),
onHeaderCell: (column) => {
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
return {
...extra,
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
};
},
};
});
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
const settingsButton = (
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
</Button>
);
const settingsModal = (
<AdminListColumnsModal
open={open}
items={items}
saving={saving}
onCancel={() => setOpen(false)}
onReset={() => void persist(null)}
onSave={(draft) => void persist(draft)}
/>
);
return { columns, settingsButton, settingsModal };
}
+135 -19
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -9,6 +10,7 @@ import {
Modal, Modal,
Popconfirm, Popconfirm,
Select, Select,
Space,
Table, Table,
Tag, Tag,
Typography, Typography,
@@ -17,9 +19,22 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types'; import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants'; import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CouponOrder = {
id?: string;
orderNo?: string;
productName?: string | null;
productSpec?: string | null;
quantity?: number | null;
saleUnit?: string | null;
deliveryType?: string | null;
payAmount?: number | string | null;
};
type Row = { type Row = {
id: string; id: string;
@@ -30,10 +45,43 @@ type Row = {
status: string; status: string;
sourceProduct: string; sourceProduct: string;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; user?: { id?: string; userNo: string; phone: string | null };
order?: { orderNo: string } | null; order?: CouponOrder | null;
}; };
function saleUnitLabel(unit?: string | null) {
if (unit === 'BOX') return '箱';
if (unit === 'BOTTLE') return '瓶';
return '';
}
function formatPayAmount(v?: number | string | null) {
if (v == null || v === '') return '';
const n = Number(v);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
}
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
const order = row.order;
if (!order?.orderNo && !order?.productName) {
return row.sourceProduct || '—';
}
const unit = saleUnitLabel(order.saleUnit);
const qty =
order.quantity != null ? `${order.quantity}${unit}` : '';
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
const amount = formatPayAmount(order.payAmount);
const parts = [
order.productName || row.sourceProduct,
order.productSpec,
qty,
delivery,
amount,
].filter((p) => p != null && String(p).trim() !== '');
return parts.join(' / ') || row.sourceProduct || '—';
}
type CouponRedeemRecord = { type CouponRedeemRecord = {
id: string; id: string;
redeemNo: string; redeemNo: string;
@@ -58,11 +106,10 @@ type CouponRedeemSummary = {
type CouponDetail = Row & { type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null; redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[]; redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
}; };
export default function BenefitCouponsPage() { export default function BenefitCouponsPage() {
const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -99,21 +146,62 @@ export default function BenefitCouponsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, {
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, title: '券号',
dataIndex: 'couponNo',
width: 200,
ellipsis: false,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 120,
ellipsis: false,
render: (v: string | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{v}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' }, { title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
{ {
title: '订单', title: '订单',
dataIndex: ['order', 'orderNo'], dataIndex: ['order', 'orderNo'],
width: 180, width: 180,
ellipsis: false, ellipsis: false,
render: (v) => v || '—', render: (v: string | undefined) =>
v ? (
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
{v}
</AdminPrimaryLink>
) : (
'—'
),
}, },
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` }, { title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` }, { title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true }, {
title: '来源',
dataIndex: 'sourceProduct',
width: 360,
ellipsis: false,
render: (_: string, row) => formatCouponSource(row),
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
@@ -155,15 +243,21 @@ export default function BenefitCouponsPage() {
} }
} }
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Button type="primary" onClick={() => setGrantOpen(true)}> <Space>
{settingsButton}
</Button> <Button type="primary" onClick={() => setGrantOpen(true)}>
</Button>
</Space>
</div> </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); }}>
@@ -190,7 +284,7 @@ export default function BenefitCouponsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -268,15 +362,37 @@ export default function BenefitCouponsPage() {
<> <>
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item> <Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
>
{detail.user?.userNo}
</AdminPrimaryLink>
) : (
(detail.user?.userNo ?? '—')
)}
</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item> <Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="关联订单">
{detail.order?.orderNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
}
>
{detail.order.orderNo}
</AdminPrimaryLink>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status} {COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item> <Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}> <Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
@@ -316,7 +432,7 @@ export default function BenefitCouponsPage() {
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo' },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
@@ -327,7 +443,7 @@ export default function BenefitCouponsPage() {
{ {
title: '门店', title: '门店',
dataIndex: ['store', 'name'], dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) => render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—', v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
}, },
@@ -3,6 +3,8 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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';
type Row = { type Row = {
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string; id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
@@ -24,19 +26,23 @@ export default function BenefitLedgersPage() {
[filters], [filters],
); );
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> }, { title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, { title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false }, { title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` }, { title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 }, { title: '余额后', dataIndex: 'balanceAfter', width: 90 },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark' },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <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>
@@ -45,7 +51,7 @@ export default function BenefitLedgersPage() {
</Form.Item> </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" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }} <Table rowKey="id" className="admin-table-nowrap" 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); } }} />
</div> </div>
); );
+22 -9
View File
@@ -24,6 +24,9 @@ import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/con
import { useAdminList } from '../lib/useAdminList'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type WarehouseRow = { type WarehouseRow = {
id: string; id: string;
@@ -223,9 +226,16 @@ export default function CitiesPage() {
} }
}; };
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, {
title: '城市',
dataIndex: 'name',
width: 100,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
@@ -261,7 +271,7 @@ export default function CitiesPage() {
const warehouseColumns: ColumnsType<WarehouseRow> = [ const warehouseColumns: ColumnsType<WarehouseRow> = [
{ title: '仓库名', dataIndex: 'name' }, { title: '仓库名', dataIndex: 'name' },
{ title: '地址', dataIndex: 'address', ellipsis: true }, { title: '地址', dataIndex: 'address' },
{ title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 }, { title: '电话', dataIndex: 'contactPhone', width: 120 },
{ {
@@ -270,7 +280,7 @@ export default function CitiesPage() {
width: 100, width: 100,
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v, render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
}, },
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' }, { title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
{ {
title: '操作', title: '操作',
@@ -311,10 +321,14 @@ export default function CitiesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{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); }}>
@@ -325,7 +339,7 @@ export default function CitiesPage() {
</Form.Item> </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" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} <Table scroll={{ x: 'max-content' }} rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
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={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
@@ -623,9 +637,9 @@ export default function CitiesPage() {
)} )}
columns={[ columns={[
{ title: '类型', dataIndex: 'kind', width: 80 }, { title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true }, { title: '名称', dataIndex: 'name' },
{ title: '手机号', dataIndex: 'phone', width: 120 }, { title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true }, { title: '归属', dataIndex: 'parent' },
]} ]}
/> />
</div> </div>
@@ -640,13 +654,12 @@ export default function CitiesPage() {
locale={{ emptyText: '无门店' }} locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores} dataSource={deletePreview.stores}
columns={[ columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true }, { title: '门店名', dataIndex: 'name' },
{ title: '电话', dataIndex: 'phone', width: 120 }, { title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 }, { title: '状态', dataIndex: 'status', width: 90 },
{ {
title: '合伙人', title: '合伙人',
width: 140, width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—', render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
}, },
]} ]}
+17 -6
View File
@@ -36,6 +36,9 @@ 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 PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type SubRow = PartnerSubAccountRow; type SubRow = PartnerSubAccountRow;
@@ -261,22 +264,27 @@ export default function CityPartnersPage() {
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 90 }, { title: '城市', dataIndex: 'cityName', width: 90 },
{ {
title: '区县', title: '区县',
dataIndex: 'districtCodes', dataIndex: 'districtCodes',
width: 160, width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) => render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, {
title: '公司名',
dataIndex: 'companyName',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '主账号姓名', title: '主账号姓名',
dataIndex: 'name', dataIndex: 'name',
width: 120, width: 120,
ellipsis: true,
render: (v, row) => ( render: (v, row) => (
<Space size={4}> <Space size={4}>
<span>{v}</span> <span>{v}</span>
@@ -311,7 +319,6 @@ export default function CityPartnersPage() {
title: '管仓仓库', title: '管仓仓库',
dataIndex: 'managedWarehouseName', dataIndex: 'managedWarehouseName',
width: 110, width: 110,
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -338,12 +345,16 @@ export default function CityPartnersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
@@ -427,7 +438,7 @@ export default function CityPartnersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={toTableRows(data?.items ?? [])} dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
childrenColumnName="__noTreeChildren__" childrenColumnName="__noTreeChildren__"
expandable={{ expandable={{
expandedRowRender: (record) => ( expandedRowRender: (record) => (
@@ -30,6 +30,9 @@ import {
import { request, type Paginated } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -70,6 +73,7 @@ function FulfillmentFields({
onModeChange: (mode: WarehouseFulfillmentMode) => void; onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) { }) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO; const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return ( return (
<> <>
<Form.Item <Form.Item
@@ -219,7 +223,7 @@ export default function CityWarehousesPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '城市', title: '城市',
width: 100, width: 100,
@@ -229,8 +233,15 @@ export default function CityWarehousesPage() {
</span> </span>
), ),
}, },
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true }, {
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true }, title: '仓库',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '地址', dataIndex: 'address', width: 180 },
{ title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 }, { title: '电话', dataIndex: 'contactPhone', width: 120 },
{ {
@@ -242,7 +253,6 @@ export default function CityWarehousesPage() {
title: '合伙人', title: '合伙人',
dataIndex: 'partnerCompanyName', dataIndex: 'partnerCompanyName',
width: 120, width: 120,
ellipsis: true,
render: (v, row) => render: (v, row) =>
v && row.partnerAccountId ? ( v && row.partnerAccountId ? (
<Link to="/city-partners">{v}</Link> <Link to="/city-partners">{v}</Link>
@@ -298,10 +308,17 @@ export default function CityWarehousesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
@@ -367,7 +384,7 @@ export default function CityWarehousesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1400 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+322 -158
View File
@@ -64,6 +64,20 @@ export default function DashboardPage() {
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [deploying, setDeploying] = useState(false); const [deploying, setDeploying] = useState(false);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
const keys = profile?.permissionKeys ?? [];
const has = (k: string) => keys.includes(k);
const cityScoped = !!profile?.cityScoped;
const canUsers = has('users');
const canOrders = has('orders');
const canStores = has('stores');
const canStoreAudits = has('store_audits');
const canPartners = has('partners');
const canPromo = has('promo_codes');
const canFinance = has('finance');
const canBenefit = has('benefit');
const canDeliveries = has('deliveries');
const canTickets = has('tickets');
const permsReady = profile !== null;
const [filterForm] = Form.useForm<{ const [filterForm] = Form.useForm<{
range: [Dayjs, Dayjs]; range: [Dayjs, Dayjs];
@@ -115,18 +129,26 @@ export default function DashboardPage() {
.catch(() => { .catch(() => {
setVersionLoading(false); setVersionLoading(false);
}); });
}, [loadVersion]);
useEffect(() => {
if (!profile) return;
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? [])) .then((res) => setCities(res.items ?? []))
.catch(() => setCities([])); .catch(() => setCities([]));
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) if ((profile.permissionKeys ?? []).includes('promo_codes')) {
.then((res) => setPromos(res.items ?? [])) void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.catch(() => setPromos([])); .then((res) => setPromos(res.items ?? []))
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>( .catch(() => setPromos([]));
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`, }
) if ((profile.permissionKeys ?? []).includes('partners')) {
.then((res) => setPartners(res.items ?? [])) void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
.catch(() => setPartners([])); `/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
}, [loadVersion]); )
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}
}, [profile]);
useEffect(() => { useEffect(() => {
void loadAnalytics(filters); void loadAnalytics(filters);
@@ -206,9 +228,19 @@ export default function DashboardPage() {
const byDateOption = useMemo<EChartsOption>(() => { const byDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? []; const rows = analytics?.byDate ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('新增用户');
series.push({ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) });
}
if (canOrders) {
legend.push('订单数');
series.push({ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '订单数'] }, legend: { data: legend },
grid: { left: 40, right: 20, top: 40, bottom: 40 }, grid: { left: 40, right: 20, top: 40, bottom: 40 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -216,18 +248,25 @@ export default function DashboardPage() {
axisLabel: { rotate: rows.length > 14 ? 45 : 0 }, axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
],
}; };
}, [analytics]); }, [analytics, canUsers, canOrders]);
const byCityOption = useMemo<EChartsOption>(() => { const byCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? []; const rows = analytics?.byCity ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('用户');
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
}
if (canOrders) {
legend.push('订单');
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] }, legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 48 }, grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -235,18 +274,25 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
}; };
}, [analytics]); }, [analytics, canUsers, canOrders]);
const byPromoOption = useMemo<EChartsOption>(() => { const byPromoOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPromo ?? []; const rows = analytics?.byPromo ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('用户');
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
}
if (canOrders) {
legend.push('订单');
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] }, legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 64 }, grid: { left: 48, right: 20, top: 40, bottom: 64 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -254,18 +300,36 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 }, axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
}; };
}, [analytics]); }, [analytics, canUsers, canOrders]);
const opsByDateOption = useMemo<EChartsOption>(() => { const opsByDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? []; const rows = analytics?.byDate ?? [];
const legend: string[] = [];
const series: NonNullable<EChartsOption['series']> = [];
if (canPartners) {
legend.push('新增合伙人');
series.push({ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) });
}
if (canStores) {
legend.push('新签门店');
series.push({ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) });
}
if (canBenefit) {
legend.push('核销笔数', '核销金额');
series.push({ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) });
series.push({
name: '核销金额',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
});
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] }, legend: { data: legend },
grid: { left: 48, right: 48, top: 48, bottom: 40 }, grid: { left: 48, right: 48, top: 48, bottom: 40 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -276,26 +340,29 @@ export default function DashboardPage() {
{ type: 'value', name: '数量', minInterval: 1 }, { type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额', minInterval: 1 }, { type: 'value', name: '金额', minInterval: 1 },
], ],
series: [ series,
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
{
name: '核销金额',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
}; };
}, [analytics]); }, [analytics, canPartners, canStores, canBenefit]);
const opsByCityOption = useMemo<EChartsOption>(() => { const opsByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? []; const rows = analytics?.byCity ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canPartners) {
legend.push('合伙人');
series.push({ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 });
}
if (canStores) {
legend.push('门店');
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
}
if (canBenefit) {
legend.push('核销笔数');
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['合伙人', '门店', '核销笔数'] }, legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 48 }, grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -303,19 +370,31 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
],
}; };
}, [analytics]); }, [analytics, canPartners, canStores, canBenefit]);
const opsByPartnerOption = useMemo<EChartsOption>(() => { const opsByPartnerOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPartner ?? []; const rows = analytics?.byPartner ?? [];
const legend: string[] = [];
const series: NonNullable<EChartsOption['series']> = [];
if (canStores) {
legend.push('门店');
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
}
if (canBenefit) {
legend.push('核销笔数', '核销金额');
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
series.push({
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
});
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['门店', '核销笔数', '核销金额'] }, legend: { data: legend },
grid: { left: 48, right: 48, top: 40, bottom: 64 }, grid: { left: 48, right: 48, top: 40, bottom: 64 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -326,18 +405,9 @@ export default function DashboardPage() {
{ type: 'value', name: '数量', minInterval: 1 }, { type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额' }, { type: 'value', name: '金额' },
], ],
series: [ series,
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
{
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
}; };
}, [analytics]); }, [analytics, canStores, canBenefit]);
const redeemByCityOption = useMemo<EChartsOption>(() => { const redeemByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? []; const rows = analytics?.byCity ?? [];
@@ -368,7 +438,9 @@ export default function DashboardPage() {
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}>
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
</Typography.Title>
{isSuperAdmin ? ( {isSuperAdmin ? (
<Card <Card
@@ -411,46 +483,94 @@ export default function DashboardPage() {
) : null} ) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}> {!permsReady ? (
<Card loading={loading}> <Col xs={24} sm={12} lg={6}>
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} /> <Card loading />
</Card> </Col>
</Col> ) : null}
<Col xs={24} sm={12} lg={6}> {permsReady && canUsers ? (
<Card loading={loading}> <Col xs={24} sm={12} lg={6}>
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} /> <Card loading={loading}>
</Card> <Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
</Col> </Card>
<Col xs={24} sm={12} lg={6}> </Col>
<Card loading={loading}> ) : null}
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} /> {permsReady && canUsers ? (
</Card> <Col xs={24} sm={12} lg={6}>
</Col> <Card loading={loading}>
<Col xs={24} sm={12} lg={6}> <Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
<Card loading={loading}> </Card>
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} /> </Col>
</Card> ) : null}
</Col> {permsReady && canUsers ? (
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<Card loading={loading}> <Card loading={loading}>
<Statistic title="门店" value={stats?.storesTotal ?? 0} /> <Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}> ) : null}
<Card loading={loading}> {permsReady && canOrders ? (
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} /> <Col xs={24} sm={12} lg={6}>
</Card> <Card loading={loading}>
</Col> <Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
<Col xs={24} sm={12} lg={6}> </Card>
<Card loading={loading}> </Col>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} /> ) : null}
</Card> {permsReady && canStores ? (
</Col> <Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={6}> <Card loading={loading}>
<Card loading={loading}> <Statistic title="门店" value={stats?.storesTotal ?? 0} />
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} /> </Card>
</Card> </Col>
</Col> ) : null}
{permsReady && canStores ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading} extra={<Link to="/stores?auditStatus=PENDING"></Link>}>
<Statistic
title="待入驻审核"
value={stats?.pendingStoreOnboard ?? 0}
valueStyle={{ color: (stats?.pendingStoreOnboard ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
) : null}
{permsReady && canStoreAudits ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading} extra={<Link to="/store-package-audits"></Link>}>
<Statistic
title="待套餐/信息审核"
value={(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0)}
valueStyle={{
color:
(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0) > 0
? '#fa8c16'
: undefined,
}}
/>
</Card>
</Col>
) : null}
{permsReady && canPartners ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
</Card>
</Col>
) : null}
{permsReady && canBenefit ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
</Card>
</Col>
) : null}
{permsReady && canDeliveries ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
</Card>
</Col>
) : null}
</Row> </Row>
<Card <Card
@@ -478,62 +598,73 @@ export default function DashboardPage() {
<Form.Item name="cityId" label="城市"> <Form.Item name="cityId" label="城市">
<Select <Select
allowClear allowClear
placeholder="全部开城" placeholder={cityScoped ? '全部负责城市' : '全部开城'}
style={{ width: 160 }} style={{ width: 160 }}
options={[ options={[
{ value: 'none', label: '未选城' }, ...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
...cities.map((c) => ({ value: c.id, label: c.name })), ...cities.map((c) => ({ value: c.id, label: c.name })),
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="promoCodeId" label="推广码"> {canPromo ? (
<Select <Form.Item name="promoCodeId" label="推广码">
allowClear <Select
placeholder="全部来源" allowClear
style={{ width: 200 }} placeholder="全部来源"
options={[ style={{ width: 200 }}
{ value: 'none', label: '自然量 / 无推广码' }, options={[
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })), { value: 'none', label: '自然量 / 无推广码' },
]} ...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
/> ]}
</Form.Item> />
<Form.Item name="partnerAccountId" label="合伙人"> </Form.Item>
<Select ) : null}
allowClear {canPartners ? (
placeholder="全部合伙人" <Form.Item name="partnerAccountId" label="合伙人">
style={{ width: 200 }} <Select
options={partners.map((p) => ({ allowClear
value: p.id, placeholder="全部合伙人"
label: p.companyName || p.name, style={{ width: 200 }}
}))} options={partners.map((p) => ({
/> value: p.id,
</Form.Item> label: p.companyName || p.name,
}))}
/>
</Form.Item>
) : null}
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>
{canUsers || canOrders ? (
<Card <Card
title="用户 / 订单统计" title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
extra={ extra={
<Space> <Space>
<Link to={`/orders?${ordersDrillQs}`}></Link> {canOrders ? <Link to={`/orders?${ordersDrillQs}`}></Link> : null}
<Link to="/users"></Link> {canUsers ? <Link to="/users"></Link> : null}
</Space> </Space>
} }
> >
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}> {canUsers ? (
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} /> <Col xs={24} sm={8}>
</Col> <Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
<Col xs={24} sm={8}> </Col>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} /> ) : null}
</Col> {canOrders ? (
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} /> <Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col> </Col>
) : null}
{canOrders ? (
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
) : null}
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -542,7 +673,7 @@ export default function DashboardPage() {
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge /> <ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={canPromo ? 12 : 24}>
<Card <Card
type="inner" type="inner"
title="按城市(点击柱联动筛选)" title="按城市(点击柱联动筛选)"
@@ -562,6 +693,7 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
{canPromo ? (
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card <Card
type="inner" type="inner"
@@ -584,42 +716,53 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
</Card> </Card>
) : null}
{canPartners || canStores || canBenefit ? (
<Card <Card
title="合伙人 / 门店 / 核销统计" title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
extra={ extra={
<Space> <Space>
<Link to="/city-partners"></Link> {canPartners ? <Link to="/city-partners"></Link> : null}
<Link to="/stores"></Link> {canStores ? <Link to="/stores"></Link> : null}
<Link to="/redeem-records"></Link> {canBenefit ? <Link to="/redeem-records"></Link> : null}
</Space> </Space>
} }
> >
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={6}> {canPartners ? (
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} /> <Col xs={24} sm={6}>
</Col> <Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
<Col xs={24} sm={6}> </Col>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} /> ) : null}
</Col> {canStores ? (
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} /> <Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col> </Col>
<Col xs={24} sm={6}> ) : null}
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} /> {canBenefit ? (
</Col> <Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
</Col>
) : null}
{canBenefit ? (
<Col xs={24} sm={6}>
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
</Col>
) : null}
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24}> <Col xs={24}>
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small"> <Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge /> <ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={canBenefit ? 12 : 24}>
<Card <Card
type="inner" type="inner"
title="按城市(点击柱联动筛选)" title="按城市(点击柱联动筛选)"
@@ -639,6 +782,7 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
{canBenefit ? (
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card <Card
type="inner" type="inner"
@@ -659,6 +803,8 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
{canPartners && (canStores || canBenefit) ? (
<Col xs={24}> <Col xs={24}>
<Card <Card
type="inner" type="inner"
@@ -681,10 +827,14 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
</Card> </Card>
) : null}
{canFinance || canTickets ? (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{canFinance ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card <Card
loading={loading} loading={loading}
@@ -704,6 +854,8 @@ export default function DashboardPage() {
</Typography.Text> </Typography.Text>
</Card> </Card>
</Col> </Col>
) : null}
{canFinance ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card <Card
loading={loading} loading={loading}
@@ -717,6 +869,8 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
{canFinance ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card <Card
loading={loading} loading={loading}
@@ -737,20 +891,28 @@ export default function DashboardPage() {
</Typography.Text> </Typography.Text>
</Card> </Card>
</Col> </Col>
) : null}
{canTickets ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单"> <Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" /> <Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
) : null}
{canUsers || canOrders ? (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} lg={12}> {canUsers ? (
<Col xs={24} lg={canOrders ? 12 : 24}>
<Card title="已合并访客账号" loading={loading}> <Card title="已合并访客账号" loading={loading}>
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" /> <Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> ) : null}
{canOrders ? (
<Col xs={24} lg={canUsers ? 12 : 24}>
<Card title="订单状态分布" loading={loading}> <Card title="订单状态分布" loading={loading}>
<Table <Table
size="small" size="small"
@@ -768,7 +930,9 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
) : null}
</div> </div>
); );
} }
+26 -3
View File
@@ -7,6 +7,9 @@ import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -59,8 +62,24 @@ export default function DeliveriesPage() {
setTrackOpen(true); setTrackOpen(true);
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, {
title: '订单号',
dataIndex: ['order', 'orderNo'],
width: 170,
render: (v, row) => (
<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: 'provider', dataIndex: 'provider', width: 90 },
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' }, { title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' }, { title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
@@ -83,16 +102,20 @@ export default function DeliveriesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}>/</Typography.Title> <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="provider"><Input allowClear placeholder="MOCK" /></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: 1100 }} <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={480} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={ extra={
+18 -4
View File
@@ -30,6 +30,9 @@ import { fmtTime } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({ const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v, value: v,
@@ -240,8 +243,15 @@ export default function DevPlanTasksPage() {
} }
} }
const columns: ColumnsType<DevPlanTaskDto> = [ const baseColumns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 }, {
title: '任务号',
dataIndex: 'taskNo',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
@@ -256,7 +266,7 @@ export default function DevPlanTasksPage() {
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag> <Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
), ),
}, },
{ title: '内容', dataIndex: 'content', ellipsis: true }, { title: '内容', dataIndex: 'content' },
{ {
title: '附件', title: '附件',
width: 100, width: 100,
@@ -294,12 +304,16 @@ export default function DevPlanTasksPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
· ·
</Typography.Title> </Typography.Title>
{settingsButton}
<Space wrap> <Space wrap>
<Select <Select
value={exportFormat} value={exportFormat}
@@ -361,7 +375,7 @@ export default function DevPlanTasksPage() {
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} }}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -22,6 +22,9 @@ import {
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map( const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
@@ -119,8 +122,10 @@ export default function DevPlanVersionsPage() {
} }
} }
const columns: ColumnsType<DevPlanVersionDto> = [ const baseColumns: ColumnsType<DevPlanVersionDto> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120 }, { title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
) },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -129,7 +134,7 @@ export default function DevPlanVersionsPage() {
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag> <Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
), ),
}, },
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' }, { title: '内容', dataIndex: 'content', render: (v) => v || '—' },
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, { title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, { title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, { title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
@@ -158,12 +163,16 @@ export default function DevPlanVersionsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
· ·
</Typography.Title> </Typography.Title>
{settingsButton}
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
</Button> </Button>
@@ -190,7 +199,7 @@ export default function DevPlanVersionsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+24 -3
View File
@@ -4,6 +4,9 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -55,9 +58,22 @@ export default function DomainEventsPage() {
load(); load();
}, [load]); }, [load]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) }, { title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> }, {
title: '类型',
dataIndex: 'eventType',
width: 130,
render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
<Tag>{v}</Tag>
</AdminPrimaryLink>
),
},
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` }, { title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
{ title: '状态', dataIndex: 'status', width: 100 }, { title: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' }, { title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
@@ -76,8 +92,13 @@ export default function DomainEventsPage() {
}, },
]; ];
const page = Number(params.get('page') ?? 1);
const pageSize = data?.pageSize ?? 20;
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-domain-events', baseColumns, { page, pageSize });
return ( return (
<Card title="领域事件"> <Card title="领域事件" extra={settingsButton}>
{settingsModal}
<Space wrap style={{ marginBottom: 16 }}> <Space wrap style={{ marginBottom: 16 }}>
<Select <Select
style={{ width: 180 }} style={{ width: 180 }}
@@ -28,6 +28,9 @@ import {
} from '@dukang/shared-types'; } from '@dukang/shared-types';
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({ const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value, value,
@@ -173,9 +176,15 @@ export default function FulfillmentProvidersPage() {
void load(); void load();
} }
const columns: ColumnsType<FulfillmentProviderDto> = [ const baseColumns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 }, { title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' }, {
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
@@ -230,13 +239,20 @@ export default function FulfillmentProvidersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
page: 1,
pageSize: rows.length || 20,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
</Typography.Text> </Typography.Text>
@@ -253,7 +269,7 @@ export default function FulfillmentProvidersPage() {
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。" message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/> />
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} /> <Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal <Modal
title={editRow ? '编辑承运商' : '注册承运商'} title={editRow ? '编辑承运商' : '注册承运商'}
+116 -32
View File
@@ -3,9 +3,13 @@ import {
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message, Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api'; import { HQ_ADMIN_ROLES } from '@dukang/shared-types';
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { request, type HqProfile, type Paginated } from '../lib/api';
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -17,14 +21,13 @@ type Row = {
status: string; status: string;
lastLoginAt: string | null; lastLoginAt: string | null;
createdAt: string; createdAt: string;
cityIds?: string[];
}; };
const ROLE_LABELS: Record<string, string> = { type CityOption = { id: string; name: string };
SUPER_ADMIN: '超级管理员',
OPS: '运营', const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label }));
FINANCE: '财务', const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
CUSTOMER_SERVICE: '客服',
};
export default function HqAccountsPage() { export default function HqAccountsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
@@ -46,15 +49,52 @@ export default function HqAccountsPage() {
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone'; const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
const editRole = Form.useWatch('adminRole', editForm);
const createRole = Form.useWatch('adminRole', createForm);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
const columns: ColumnsType<Row> = [ useEffect(() => {
{ title: '姓名', dataIndex: 'name' }, if (!isSuperAdmin) return;
request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items))
.catch(() => {});
}, [isSuperAdmin]);
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
const baseColumns: ColumnsType<Row> = [
{
title: '姓名',
dataIndex: 'name',
render: (v, row) =>
isSuperAdmin ? (
<AdminPrimaryLink
onClick={() => {
setDetail(row);
editForm.setFieldsValue({
name: row.name,
phone: row.phone,
loginName: row.loginName,
adminRole: row.adminRole,
status: row.status,
cityIds: row.cityIds ?? [],
});
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
) : (
v
),
},
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' }, { title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 130 }, { title: '手机', dataIndex: 'phone', width: 130 },
{ {
@@ -67,7 +107,18 @@ export default function HqAccountsPage() {
</Space> </Space>
), ),
}, },
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r }, { title: '角色', dataIndex: 'adminRole', width: 130, render: (r) => ROLE_LABELS[r] || r },
{
title: '城市',
width: 160,
render: (_, r) => {
if (!r.cityIds?.length) return <Typography.Text type="secondary"></Typography.Text>;
const names = r.cityIds
.map((id) => cities.find((c) => c.id === id)?.name || id)
.join('、');
return names;
},
},
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime }, { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
{ {
@@ -81,6 +132,7 @@ export default function HqAccountsPage() {
loginName: row.loginName, loginName: row.loginName,
adminRole: row.adminRole, adminRole: row.adminRole,
status: row.status, status: row.status,
cityIds: row.cityIds ?? [],
}); });
setDrawerOpen(true); setDrawerOpen(true);
}}></Button> }}></Button>
@@ -88,16 +140,20 @@ export default function HqAccountsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title>
{settingsButton}
{isSuperAdmin && ( {isSuperAdmin && (
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
createForm.resetFields(); createForm.resetFields();
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' }); createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
@@ -108,23 +164,31 @@ export default function HqAccountsPage() {
<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="角色">
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select allowClear style={{ width: 160 }} options={ROLE_OPTIONS} />
</Form.Item> </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 ?? []} <Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
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="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="编辑 HQ 账户" width={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={ extra={
<Button type="primary" onClick={async () => { <Button type="primary" loading={saving} onClick={async () => {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const payload = { ...v }; const v = await editForm.validateFields();
if (!payload.password) delete payload.password; const payload: Record<string, unknown> = { ...v };
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) }); if (!payload.password) delete payload.password;
message.success('已保存'); if (!String(payload.loginName ?? '').trim()) delete payload.loginName;
setDrawerOpen(false); setSaving(true);
void reload(); await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
} finally {
setSaving(false);
}
}}></Button> }}></Button>
}> }>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
@@ -144,7 +208,15 @@ export default function HqAccountsPage() {
<Input.Password placeholder="至少 6 位" /> <Input.Password placeholder="至少 6 位" />
</Form.Item> </Form.Item>
<Form.Item name="adminRole" label="角色"> <Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={ROLE_OPTIONS} />
</Form.Item>
<Form.Item
name="cityIds"
label="负责城市"
extra={editRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
rules={editRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
>
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -156,15 +228,19 @@ export default function HqAccountsPage() {
open={createOpen} open={createOpen}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) }); const v = await createForm.validateFields();
message.success('已创建'); await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
setCreateOpen(false); message.success('已创建');
createForm.resetFields(); setCreateOpen(false);
void reload(); createForm.resetFields();
void reload();
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
}
}} }}
> >
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}> <Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }}>
<Form.Item name="credentialType" label="创建方式"> <Form.Item name="credentialType" label="创建方式">
<Radio.Group> <Radio.Group>
<Radio value="phone"></Radio> <Radio value="phone"></Radio>
@@ -173,7 +249,15 @@ export default function HqAccountsPage() {
</Form.Item> </Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="adminRole" label="角色"> <Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={ROLE_OPTIONS} />
</Form.Item>
<Form.Item
name="cityIds"
label="负责城市"
extra={createRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
rules={createRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
>
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
</Form.Item> </Form.Item>
{credentialType === 'phone' ? ( {credentialType === 'phone' ? (
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}> <Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
+20 -4
View File
@@ -9,6 +9,9 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -52,12 +55,11 @@ export default function HqLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作人', title: '操作人',
width: 200, width: 200,
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.hqName} primary={r.hqName}
@@ -69,7 +71,17 @@ export default function HqLogsPage() {
title: '行为', title: '行为',
dataIndex: 'actionLabel', dataIndex: 'actionLabel',
width: 160, width: 160,
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>, render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
</AdminPrimaryLink>
),
}, },
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' }, { title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' }, { title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
@@ -92,9 +104,13 @@ export default function HqLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}>HQ </Typography.Title> <Typography.Title level={4}>HQ </Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
</Typography.Paragraph> </Typography.Paragraph>
@@ -150,7 +166,7 @@ export default function HqLogsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 900 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+86 -54
View File
@@ -17,6 +17,7 @@ import {
import { import {
HQ_ADMIN_ROLES, HQ_ADMIN_ROLES,
HQ_PERMISSION_CATALOG, HQ_PERMISSION_CATALOG,
computeHqEffectivePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
@@ -27,6 +28,8 @@ type AccountPermRes = {
account: AccountOption; account: AccountOption;
permissionKeys: string[]; permissionKeys: string[];
rolePermissionKeys: string[]; rolePermissionKeys: string[];
grantKeys?: string[];
denyKeys?: string[];
userPermissionKeys: string[]; userPermissionKeys: string[];
effectivePermissionKeys: string[]; effectivePermissionKeys: string[];
}; };
@@ -46,11 +49,17 @@ function PermissionChecklist({
value, value,
onChange, onChange,
disabled, disabled,
roleKeys,
grantKeys,
}: { }: {
value: string[]; value: string[];
onChange: (keys: string[]) => void; onChange: (keys: string[]) => void;
disabled?: boolean; disabled?: boolean;
roleKeys?: string[];
grantKeys?: string[];
}) { }) {
const roleSet = new Set(roleKeys ?? []);
const grantSet = new Set(grantKeys ?? []);
return ( return (
<Checkbox.Group <Checkbox.Group
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -81,6 +90,13 @@ function PermissionChecklist({
{items.map((item) => ( {items.map((item) => (
<Checkbox key={item.key} value={item.key}> <Checkbox key={item.key} value={item.key}>
{item.label} {item.label}
{roleSet.has(item.key) && !value.includes(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="red"></Tag>
) : grantSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="orange"></Tag>
) : roleSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="blue"></Tag>
) : null}
</Checkbox> </Checkbox>
))} ))}
</Space> </Space>
@@ -88,7 +104,16 @@ function PermissionChecklist({
<Row gutter={[12, 10]}> <Row gutter={[12, 10]}>
{items.map((item) => ( {items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}> <Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox> <Checkbox value={item.key}>
{item.label}
{roleSet.has(item.key) && !value.includes(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="red"></Tag>
) : grantSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="orange"></Tag>
) : roleSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="blue"></Tag>
) : null}
</Checkbox>
</Col> </Col>
))} ))}
</Row> </Row>
@@ -100,6 +125,14 @@ function PermissionChecklist({
); );
} }
function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) {
const roleSet = new Set(roleKeys);
const effectiveSet = new Set(effectiveKeys);
const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k));
const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k));
return { grantKeys, denyKeys };
}
export default function HqPermissionsPage() { export default function HqPermissionsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [role, setRole] = useState<string>('OPS'); const [role, setRole] = useState<string>('OPS');
@@ -111,6 +144,8 @@ export default function HqPermissionsPage() {
const [accountId, setAccountId] = useState<string>(); const [accountId, setAccountId] = useState<string>();
const [accountKeys, setAccountKeys] = useState<string[]>([]); const [accountKeys, setAccountKeys] = useState<string[]>([]);
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]); const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
const [grantKeys, setGrantKeys] = useState<string[]>([]);
const [denyKeys, setDenyKeys] = useState<string[]>([]);
const [accountLoading, setAccountLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false); const [accountSaving, setAccountSaving] = useState(false);
@@ -121,8 +156,8 @@ export default function HqPermissionsPage() {
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN'; const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo( const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])], () => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
[roleInheritedKeys, accountKeys], [roleInheritedKeys, grantKeys, denyKeys],
); );
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
@@ -151,8 +186,10 @@ export default function HqPermissionsPage() {
setAccountLoading(true); setAccountLoading(true);
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`) request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
.then((res) => { .then((res) => {
setAccountKeys(res.userPermissionKeys); setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
setDenyKeys(res.denyKeys ?? []);
setRoleInheritedKeys(res.rolePermissionKeys); setRoleInheritedKeys(res.rolePermissionKeys);
setAccountKeys(res.effectivePermissionKeys);
}) })
.finally(() => setAccountLoading(false)); .finally(() => setAccountLoading(false));
}, [isSuperAdmin, accountId]); }, [isSuperAdmin, accountId]);
@@ -179,10 +216,12 @@ export default function HqPermissionsPage() {
try { try {
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, { const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ permissionKeys: accountKeys }), body: JSON.stringify({ grantKeys, denyKeys }),
}); });
setAccountKeys(res.userPermissionKeys); setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
setDenyKeys(res.denyKeys ?? []);
setRoleInheritedKeys(res.rolePermissionKeys); setRoleInheritedKeys(res.rolePermissionKeys);
setAccountKeys(res.effectivePermissionKeys);
message.success('用户权限已保存'); message.success('用户权限已保存');
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败'); message.error(e instanceof Error ? e.message : '保存失败');
@@ -204,8 +243,9 @@ export default function HqPermissionsPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
// //
/
// //
</Typography.Paragraph> </Typography.Paragraph>
@@ -275,59 +315,51 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" /> <Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : ( ) : (
<> <>
<div style={{ marginBottom: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Tag color="blue"></Tag>
) : (
<Space wrap size={[4, 4]}>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
//
/
</Typography.Paragraph> </Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} /> <PermissionChecklist
value={accountKeys}
roleKeys={roleInheritedKeys}
grantKeys={grantKeys}
disabled={selectedIsSuperAdmin}
onChange={(nextEffective) => {
if (selectedIsSuperAdmin) return;
const next = splitAccountOverrides(roleInheritedKeys, nextEffective);
setGrantKeys(next.grantKeys);
setDenyKeys(next.denyKeys);
setAccountKeys(nextEffective);
}}
/>
{selectedIsSuperAdmin ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
/>
) : null}
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span> <span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? ( <Space wrap size={[4, 4]}>
<Space wrap size={[4, 4]}> {previewEffectiveKeys.map((key) => {
<Tag></Tag> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
{accountKeys.map((key) => { return (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); <Tag key={key}>
return ( {item?.label || key}
<Tag key={key} color="orange"> </Tag>
{item?.label || key} );
</Tag> })}
); </Space>
})}
</Space>
) : (
<Space wrap size={[4, 4]}>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.label || key}
</Tag>
);
})}
</Space>
)}
</div> </div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}> <Button
type="primary"
loading={accountSaving}
disabled={selectedIsSuperAdmin}
onClick={() => void saveAccountPermissions()}
>
</Button> </Button>
</div> </div>
+16 -3
View File
@@ -30,6 +30,9 @@ 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 { uploadFileToOss } from '../lib/upload'; import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -181,7 +184,7 @@ export default function InvoicesPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 }, { title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 }, { title: '订单号', dataIndex: 'orderNo', width: 160 },
{ {
@@ -202,7 +205,13 @@ export default function InvoicesPage() {
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory ? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
: '—', : '—',
}, },
{ title: '名称', dataIndex: 'titleName', ellipsis: true }, {
title: '名称',
dataIndex: 'titleName',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '状态', title: '状态',
width: 110, width: 110,
@@ -227,8 +236,11 @@ export default function InvoicesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -240,6 +252,7 @@ export default function InvoicesPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
@@ -286,7 +299,7 @@ export default function InvoicesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1000 }} scroll={{ x: 'max-content' }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')} rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
pagination={{ pagination={{
current: page, current: page,
@@ -25,6 +25,9 @@ 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 { uploadFileToOss } from '../lib/upload'; import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -124,9 +127,15 @@ export default function KnowledgeBasesPage() {
} }
}; };
const columns: ColumnsType<KnowledgeBaseDto> = [ const baseColumns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' }, {
{ title: '说明', dataIndex: 'description', ellipsis: true }, title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description' },
{ title: '文档数', dataIndex: 'documentCount', width: 90 }, { title: '文档数', dataIndex: 'documentCount', width: 90 },
{ {
title: '启用', title: '启用',
@@ -184,7 +193,7 @@ export default function KnowledgeBasesPage() {
const docColumns: ColumnsType<KnowledgeDocumentDto> = [ const docColumns: ColumnsType<KnowledgeDocumentDto> = [
{ title: '标题', dataIndex: 'title' }, { title: '标题', dataIndex: 'title' },
{ title: '文件', dataIndex: 'fileName', ellipsis: true }, { title: '文件', dataIndex: 'fileName' },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -250,12 +259,16 @@ export default function KnowledgeBasesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16 }} wrap> <Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
.txt/.md AI .txt/.md AI
</Typography.Text> </Typography.Text>
+16 -3
View File
@@ -25,6 +25,9 @@ import {
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -153,15 +156,21 @@ export default function LlmConfigsPage() {
} }
}; };
const columns: ColumnsType<LlmApiConfigDto> = [ const baseColumns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' }, {
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '提供商', title: '提供商',
dataIndex: 'provider', dataIndex: 'provider',
width: 120, width: 120,
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p, render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
}, },
{ title: '模型', dataIndex: 'modelName', ellipsis: true }, { title: '模型', dataIndex: 'modelName' },
{ {
title: 'Key', title: 'Key',
dataIndex: 'apiKeyConfigured', dataIndex: 'apiKeyConfigured',
@@ -257,12 +266,16 @@ export default function LlmConfigsPage() {
const fullEdit = !editing || editing.canEditFull; const fullEdit = !editing || editing.canEditFull;
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16 }} wrap> <Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
</Typography.Text> </Typography.Text>
@@ -29,6 +29,8 @@ import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillRow = { type BillRow = {
id: string; id: string;
@@ -245,7 +247,14 @@ export default function LogisticsBillsPage() {
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [ const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, {
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '承运商', title: '承运商',
width: 140, width: 140,
@@ -275,13 +284,11 @@ export default function LogisticsBillsPage() {
{ {
title: '收款户名', title: '收款户名',
width: 100, width: 100,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountName || '—', render: (_, r) => providerBank(r)?.bankAccountName || '—',
}, },
{ {
title: '收款账号', title: '收款账号',
width: 140, width: 140,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountNo || '—', render: (_, r) => providerBank(r)?.bankAccountNo || '—',
}, },
{ {
@@ -339,7 +346,6 @@ export default function LogisticsBillsPage() {
{ {
title: '银行账户', title: '银行账户',
width: 180, width: 180,
ellipsis: true,
render: (_, r) => render: (_, r) =>
r.bankAccountName r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}` ? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
@@ -391,12 +397,19 @@ export default function LogisticsBillsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
</Typography.Text> </Typography.Text>
@@ -537,7 +550,7 @@ export default function LogisticsBillsPage() {
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
columns={billColumns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedKeys, selectedRowKeys: selectedKeys,
+95 -52
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -26,10 +26,13 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { downloadBase64File } from '../lib/exportExcel'; import { downloadBase64File } from '../lib/exportExcel';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS, DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_COLORS, ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
ORDER_STATUS_OPERATOR_LABELS, ORDER_STATUS_OPERATOR_LABELS,
@@ -272,6 +275,7 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
} }
export default function OrdersPage() { export default function OrdersPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || ''; const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -590,65 +594,100 @@ export default function OrdersPage() {
} }
} }
const columns: ColumnsType<AdminOrderRow> = [ const baseColumns: ColumnsType<AdminOrderRow> = [
{ {
title: '商品', title: '订单号',
width: 240, dataIndex: 'orderNo',
render: (_, row) => ( width: 180,
<div> render: (v, row) => (
<Space size={4} wrap> <Space size={4}>
<span>{row.productName || '—'}</span> <AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null} </Space>
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
.filter(Boolean)
.join(' ')}
</Typography.Text>
</div>
</div>
), ),
}, },
{ {
title: '状态 / 实付', title: '用户',
width: 130, key: 'user',
width: 120,
render: (_, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{row.user.userNo}
</AdminPrimaryLink>
) : (
(row.user?.userNo || '—')
),
},
{
title: '商品',
key: 'productName',
width: 180,
render: (_, row) => ( render: (_, row) => (
<div> <Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}> <span>{row.productName || '—'}</span>
{ORDER_STATUS_LABELS[row.status] || row.status} {row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
</Tag> {row.orderType === 'PROXY' || row.isProxyOrder ? (
<div>¥{row.payAmount}</div> <Tag color="purple"></Tag>
</div> ) : null}
</Space>
), ),
}, },
{
title: '规格',
dataIndex: 'productSpec',
width: 140,
render: (v: string | undefined) => v || '—',
},
{
title: '数量',
dataIndex: 'quantity',
width: 80,
render: (v: number | undefined, row) =>
v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`,
},
{
title: '配送方式',
dataIndex: 'deliveryType',
width: 100,
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: string) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v: number) => `¥${v}`,
},
{ {
title: '好客权益', title: '好客权益',
width: 200, width: 200,
render: (_, row) => formatBenefitBrief(row), render: (_, row) => formatBenefitBrief(row),
}, },
{ {
title: '收货信息', title: '收货',
width: 240, dataIndex: 'receiverName',
render: (_, row) => { width: 90,
const address = formatReceiverAddress(row); render: (v: string | undefined) => v || '—',
return ( },
<div> {
<div> title: '电话',
{row.receiverName || '—'} {row.receiverPhone || ''} dataIndex: 'receiverPhone',
</div> width: 120,
{address ? ( render: (v: string | undefined) => v || '—',
<Typography.Text type="secondary" style={{ fontSize: 12 }}> },
{address} {
</Typography.Text> title: '地址',
) : null} key: 'receiverAddress',
</div> width: 260,
); render: (_, row) => formatReceiverAddress(row) || '—',
},
}, },
{ {
title: '下单时间', title: '下单时间',
@@ -686,11 +725,18 @@ export default function OrdersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space size={12}> <Space size={12}>
{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)}>
@@ -830,7 +876,7 @@ export default function OrdersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
rowSelection={{ rowSelection={{
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
@@ -1014,7 +1060,7 @@ export default function OrdersPage() {
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo', width: 160 },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
@@ -1025,7 +1071,6 @@ export default function OrdersPage() {
{ {
title: '门店', title: '门店',
dataIndex: ['store', 'name'], dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row) => render: (v: string | undefined, row) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—', v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
}, },
@@ -1141,7 +1186,6 @@ export default function OrdersPage() {
{ {
title: '备注', title: '备注',
dataIndex: 'remark', dataIndex: 'remark',
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -1436,7 +1480,6 @@ export default function OrdersPage() {
{ {
title: '商品', title: '商品',
width: 160, width: 160,
ellipsis: true,
render: (_, row) => render: (_, row) =>
row.productName row.productName
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}` ? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
@@ -5,8 +5,10 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types'; import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; 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';
type PartnerOption = { id: string; companyName: string }; type PartnerOption = { id: string; companyName: string };
type ParentAccount = { id: string; name: string; phone: string }; type ParentAccount = { id: string; name: string; phone: string };
@@ -169,27 +171,24 @@ export default function PartnerAccountsPage() {
return keys; return keys;
} }
const columns: ColumnsType<AccountTreeRow> = [ const baseColumns: ColumnsType<AccountTreeRow> = [
{ {
title: '姓名 / 类型', title: '姓名 / 类型',
width: 200, width: 200,
ellipsis: true,
render: (_, row) => ( render: (_, row) => (
<AdminCellLine <span className="admin-cell-line">
primary={row.name} <AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
secondary={ <span className="admin-cell-line-secondary">
row.parentAccountId {' · '}
? `子账号 · ${staffRoleLabel(row.staffRole)}` {row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
: '主账号' </span>
} </span>
/>
), ),
}, },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '开城合伙人', title: '开城合伙人',
width: 140, width: 140,
ellipsis: true,
render: (_, row) => row.companyName || row.parent?.name || '—', render: (_, row) => row.companyName || row.parent?.name || '—',
}, },
{ {
@@ -254,11 +253,18 @@ export default function PartnerAccountsPage() {
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partner-accounts', baseColumns, {
page: 1,
pageSize: treeData.length || 20,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
</Typography.Text> </Typography.Text>
@@ -305,7 +311,7 @@ export default function PartnerAccountsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={treeData} dataSource={treeData}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={false} pagination={false}
expandable={{ expandable={{
expandedRowKeys, expandedRowKeys,
@@ -399,7 +405,7 @@ export default function PartnerAccountsPage() {
label: `账单 (${detail.bills?.length ?? 0})`, label: `账单 (${detail.bills?.length ?? 0})`,
children: ( children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns} <Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} /> dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 'max-content' }} />
), ),
}, },
{ {
@@ -407,7 +413,7 @@ export default function PartnerAccountsPage() {
label: `名下订单 (${detail.orders?.length ?? 0})`, label: `名下订单 (${detail.orders?.length ?? 0})`,
children: ( children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns} <Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} /> dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
), ),
}, },
]} /> ]} />
+17 -6
View File
@@ -22,6 +22,9 @@ 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 { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -244,12 +247,18 @@ export default function PartnerBillsPage() {
const canPay = selectedRows.filter((r) => r.status === 'UNPAID'); const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0); const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, {
title: '账单号',
dataIndex: 'billNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '合伙人', title: '合伙人',
width: 160, width: 160,
ellipsis: true,
render: (_, r) => partnerName(r), render: (_, r) => partnerName(r),
}, },
{ {
@@ -279,13 +288,11 @@ export default function PartnerBillsPage() {
{ {
title: '收款户名', title: '收款户名',
width: 100, width: 100,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountName || '—', render: (_, r) => partnerBank(r)?.bankAccountName || '—',
}, },
{ {
title: '收款账号', title: '收款账号',
width: 140, width: 140,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—', render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
}, },
{ {
@@ -337,12 +344,16 @@ export default function PartnerBillsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
1 8:00 1 8:00
</Typography.Text> </Typography.Text>
@@ -448,7 +459,7 @@ export default function PartnerBillsPage() {
selectedRowKeys: selectedKeys, selectedRowKeys: selectedKeys,
onChange: setSelectedKeys, onChange: setSelectedKeys,
}} }}
scroll={{ x: 1280 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+24 -6
View File
@@ -14,6 +14,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -71,12 +74,11 @@ export default function PartnerLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '合伙人', title: '合伙人',
width: 180, width: 180,
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
r.isSubAccount ? ( r.isSubAccount ? (
<Tag color="blue"></Tag> <Tag color="blue"></Tag>
@@ -88,7 +90,6 @@ export default function PartnerLogsPage() {
{ {
title: '账号', title: '账号',
width: 150, width: 150,
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.accountName} primary={r.accountName}
@@ -108,7 +109,21 @@ export default function PartnerLogsPage() {
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag> <Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 180 }, {
title: '事件',
dataIndex: 'eventName',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '关联', title: '关联',
width: 120, width: 120,
@@ -116,7 +131,6 @@ export default function PartnerLogsPage() {
}, },
{ {
title: '摘要', title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -137,11 +151,15 @@ export default function PartnerLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4} style={{ marginBottom: 16 }}> <Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title> </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}
@@ -216,7 +234,7 @@ export default function PartnerLogsPage() {
setPageSize(ps); setPageSize(ps);
}, },
}} }}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
/> />
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}> <Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && ( {detail && (
+15 -3
View File
@@ -27,6 +27,9 @@ import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
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 CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -173,17 +176,22 @@ export default function PartnersPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 100 }, { title: '城市', dataIndex: 'cityName', width: 100 },
{ {
title: '区县', title: '区县',
dataIndex: 'districtCodes', dataIndex: 'districtCodes',
width: 160, width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) => render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true }, {
title: '公司名',
dataIndex: 'companyName',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{ title: '主账号', dataIndex: 'phone', width: 130 }, { title: '主账号', dataIndex: 'phone', width: 130 },
{ {
title: '管辖', title: '管辖',
@@ -205,10 +213,14 @@ export default function PartnersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<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>
+17 -4
View File
@@ -23,6 +23,9 @@ import {
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = { const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange', PENDING: 'orange',
@@ -94,13 +97,19 @@ export default function PendingRedeemPage() {
} }
} }
const columns: ColumnsType<RedeemPendingItem> = [ const baseColumns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 }, {
title: '待处理单号',
dataIndex: 'pendingNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '核销码 ID', title: '核销码 ID',
dataIndex: 'redeemToken', dataIndex: 'redeemToken',
width: 160, width: 160,
ellipsis: true,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>, render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' }, { title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
@@ -132,9 +141,13 @@ export default function PendingRedeemPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -175,7 +188,7 @@ export default function PendingRedeemPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -13,6 +13,9 @@ import { useAdminList } from '../lib/useAdminList';
import DetailImageUrlList from '../components/DetailImageUrlList'; import DetailImageUrlList from '../components/DetailImageUrlList';
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates'; import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates'; import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = ProductDetailTemplateDto; type Row = ProductDetailTemplateDto;
@@ -136,10 +139,26 @@ export default function ProductDetailTemplatesPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 120 }, { title: '编码', dataIndex: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', width: 120 }, {
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true }, title: '名称',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapToForm(d));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description', width: 200 },
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') }, { title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 }, { title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '排序', dataIndex: 'sortOrder', width: 60 },
@@ -158,10 +177,17 @@ export default function ProductDetailTemplatesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('product-detail-templates', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}></Button> <Button type="primary" onClick={() => setCreateOpen(true)}></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); }}>
@@ -175,7 +201,7 @@ export default function ProductDetailTemplatesPage() {
</Form.Item> </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" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }} <Table rowKey="id" className="admin-table-nowrap" 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={640} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
+22 -6
View File
@@ -14,6 +14,9 @@ import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker'; import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ProductDetailContentDto = { type ProductDetailContentDto = {
storyTitle?: string; storyTitle?: string;
@@ -387,9 +390,16 @@ export default function ProductsPage() {
} }
} }
const columns: ColumnsType<Row> = useMemo(() => [ const baseColumns: ColumnsType<Row> = useMemo(() => [
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v }, { title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true }, { title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
<AdminPrimaryLink onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}>{v}</AdminPrimaryLink>
) },
{ {
title: '累计销售', title: '累计销售',
dataIndex: 'soldBottles', dataIndex: 'soldBottles',
@@ -458,7 +468,6 @@ export default function ProductsPage() {
title: '规格', title: '规格',
dataIndex: 'specText', dataIndex: 'specText',
width: 140, width: 140,
ellipsis: true,
render: (v: string, row) => ( render: (v: string, row) => (
<Space size={4}> <Space size={4}>
<span>{v || '默认'}</span> <span>{v || '默认'}</span>
@@ -468,7 +477,7 @@ export default function ProductsPage() {
), ),
}, },
{ title: 'SKU', dataIndex: 'skuCode', width: 110 }, { title: 'SKU', dataIndex: 'skuCode', width: 110 },
{ title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true }, { title: '69码', dataIndex: 'barcode69', width: 160 },
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` }, { title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` }, { title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
{ {
@@ -493,10 +502,17 @@ export default function ProductsPage() {
}, },
], []); ], []);
const { columns, settingsButton, settingsModal } = useAdminListColumns('products', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}></Button> <Button type="primary" onClick={() => setCreateOpen(true)}></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); }}>
@@ -517,7 +533,7 @@ export default function ProductsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1080 }} scroll={{ x: 'max-content' }}
expandable={{ expandable={{
rowExpandable: () => true, rowExpandable: () => true,
expandedRowRender: (row) => ( expandedRowRender: (row) => (
@@ -528,7 +544,7 @@ export default function ProductsPage() {
pagination={false} pagination={false}
columns={skuColumns} columns={skuColumns}
dataSource={row.skus ?? []} dataSource={row.skus ?? []}
scroll={{ x: 980 }} scroll={{ x: 'max-content' }}
style={{ background: 'transparent' }} style={{ background: 'transparent' }}
/> />
</div> </div>
+17 -3
View File
@@ -14,6 +14,9 @@ import {
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = PromoCodeItem; type Row = PromoCodeItem;
@@ -74,8 +77,15 @@ export default function PromoCodesPage() {
void loadScenes(); void loadScenes();
}, []); }, []);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, {
title: '名称',
dataIndex: 'name',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
),
},
{ title: '码值', dataIndex: 'code', width: 110 }, { title: '码值', dataIndex: 'code', width: 110 },
{ {
title: '场景', title: '场景',
@@ -160,10 +170,14 @@ export default function PromoCodesPage() {
} }
} }
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button> <Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div> </div>
@@ -194,7 +208,7 @@ export default function PromoCodesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+10 -3
View File
@@ -7,6 +7,9 @@ import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDesc
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -75,14 +78,14 @@ export default function RedeemRecordsPage() {
} }
}, [data, initialRedeemNo, loading]); }, [data, initialRedeemNo, loading]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '核销号', title: '核销号',
dataIndex: 'redeemNo', dataIndex: 'redeemNo',
width: 200, width: 200,
render: (v, row) => ( render: (v, row) => (
<span> <span>
{v} <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? ( {row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}> <Tag color="orange" style={{ marginLeft: 6 }}>
@@ -133,9 +136,13 @@ export default function RedeemRecordsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -175,7 +182,7 @@ export default function RedeemRecordsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1300 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+10 -4
View File
@@ -12,6 +12,8 @@ import {
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = { type Row = {
id: string; id: string;
@@ -58,7 +60,7 @@ export default function ResourcesPage() {
void reload(); void reload();
}, []); }, []);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 }, { title: 'ID', dataIndex: 'id', width: 80 },
{ {
title: '归属', title: '归属',
@@ -84,8 +86,8 @@ export default function ResourcesPage() {
</a> </a>
), ),
}, },
{ title: 'URL', dataIndex: 'url', ellipsis: true }, { title: 'URL', dataIndex: 'url' },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 }, { title: 'OSS Key', dataIndex: 'ossKey', width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '排序', dataIndex: 'sortOrder', width: 60 },
{ {
title: '状态', title: '状态',
@@ -114,12 +116,16 @@ export default function ResourcesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
OSS OSS
</Typography.Title> </Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}> <Button type="primary" onClick={() => setCreateOpen(true)}>
</Button> </Button>
@@ -170,7 +176,7 @@ export default function ResourcesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+17 -3
View File
@@ -6,6 +6,9 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type StoreBrief = { id: string; name: string; status: string; cityName?: string }; type StoreBrief = { id: string; name: string; status: string; cityName?: string };
@@ -74,14 +77,21 @@ export default function StoreAccountsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '姓名', title: '姓名',
dataIndex: 'name', dataIndex: 'name',
width: 120, width: 120,
render: (v, row) => ( render: (v, row) => (
<Space size={4}> <Space size={4}>
<span>{v}</span> <AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
</Space> </Space>
), ),
@@ -138,11 +148,15 @@ export default function StoreAccountsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
</Typography.Text> </Typography.Text>
@@ -185,7 +199,7 @@ export default function StoreAccountsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+11 -7
View File
@@ -30,6 +30,9 @@ 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 { 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
kind: StoreSettlementKind; kind: StoreSettlementKind;
@@ -267,7 +270,7 @@ export default function StoreBillsPage() {
); );
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '类型', title: '类型',
dataIndex: 'kind', dataIndex: 'kind',
@@ -282,10 +285,9 @@ export default function StoreBillsPage() {
title: '单号', title: '单号',
dataIndex: 'billNo', dataIndex: 'billNo',
width: 180, width: 180,
ellipsis: true,
render: (v, row) => ( render: (v, row) => (
<Space> <Space>
<span>{v}</span> <AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null} {row.overdue ? <Tag color="magenta"></Tag> : null}
</Space> </Space>
), ),
@@ -296,20 +298,18 @@ export default function StoreBillsPage() {
width: 160, width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, { title: '门店', dataIndex: ['store', 'name'], width: 140 },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ {
title: '收款户名', title: '收款户名',
width: 100, width: 100,
ellipsis: true,
render: (_, row) => render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—', row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
}, },
{ {
title: '收款账号', title: '收款账号',
width: 140, width: 140,
ellipsis: true,
render: (_, row) => render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—', row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
}, },
@@ -376,12 +376,16 @@ export default function StoreBillsPage() {
} }
| undefined; | undefined;
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+1 沿 T+1 沿
= T+1 = = T+1 =
@@ -513,7 +517,7 @@ export default function StoreBillsPage() {
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'), disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
}), }),
}} }}
scroll={{ x: 1300 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -3,7 +3,10 @@ import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CategoryNode = { type CategoryNode = {
id: string; id: string;
@@ -38,7 +41,9 @@ export default function StoreCategoriesPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null); const [editing, setEditing] = useState<FlatRow | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const canDelete = (profile?.permissionKeys ?? []).includes('store_categories_delete');
const rows = useMemo(() => flattenTree(tree), [tree]); const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo( const rootOptions = useMemo(
@@ -60,6 +65,7 @@ export default function StoreCategoriesPage() {
useEffect(() => { useEffect(() => {
void reload(); void reload();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
function openCreate(parentId?: string) { function openCreate(parentId?: string) {
@@ -112,7 +118,7 @@ export default function StoreCategoriesPage() {
void reload(); void reload();
} }
const columns: ColumnsType<FlatRow> = [ const baseColumns: ColumnsType<FlatRow> = [
{ {
title: '层级', title: '层级',
dataIndex: 'level', dataIndex: 'level',
@@ -125,7 +131,7 @@ export default function StoreCategoriesPage() {
render: (name, row) => ( render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}> <span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''} {row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name} <AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</span> </span>
), ),
}, },
@@ -141,45 +147,56 @@ export default function StoreCategoriesPage() {
}, },
{ {
title: '操作', title: '操作',
width: 220, width: canDelete ? 220 : 160,
render: (_, row) => ( render: (_, row) => (
<Space wrap> <Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button> <Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? ( {row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button> <Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null} ) : null}
<Popconfirm {canDelete ? (
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'} <Popconfirm
onConfirm={async () => { title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); onConfirm={async () => {
message.success('已处理'); await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
void reload(); message.success('已处理');
}} void reload();
> }}
<Button type="link" size="small" danger></Button> >
</Popconfirm> <Button type="link" size="small" danger></Button>
</Popconfirm>
) : null}
</Space> </Space>
), ),
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-categories', baseColumns, {
page: 1,
pageSize: rows.length || 20,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Typography.Text type="secondary">/宿/ </Typography.Text> <Typography.Text type="secondary">/宿/ </Typography.Text>
</Space> </Space>
<Space> <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>
</Button>
) : null}
<Button type="primary" onClick={() => openCreate()}></Button> <Button type="primary" onClick={() => openCreate()}></Button>
</Space> </Space>
</Space> </Space>
+29 -6
View File
@@ -12,6 +12,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -80,14 +83,14 @@ export default function StoreLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '门店', width: 180, ellipsis: true, title: '门店', width: 180,
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />, render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
}, },
{ {
title: '账号', width: 150, ellipsis: true, title: '账号', width: 150,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.accountName} primary={r.accountName}
@@ -101,7 +104,23 @@ export default function StoreLogsPage() {
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag> <Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 160 }, {
title: '事件',
dataIndex: 'eventName',
width: 160,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const parsed = parseCompositeId(row.id);
if (!parsed) return;
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '来源', dataIndex: 'source', width: 100, title: '来源', dataIndex: 'source', width: 100,
render: (v: Row['source']) => SOURCE_LABELS[v] || v, render: (v: Row['source']) => SOURCE_LABELS[v] || v,
@@ -111,7 +130,7 @@ export default function StoreLogsPage() {
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
}, },
{ {
title: '摘要', ellipsis: true, title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -127,9 +146,13 @@ export default function StoreLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}> <Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
/ /
</Typography.Paragraph> </Typography.Paragraph>
@@ -164,7 +187,7 @@ export default function StoreLogsPage() {
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item> <Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <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={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
+26 -4
View File
@@ -7,6 +7,9 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string; id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
@@ -40,8 +43,23 @@ export default function StoreMediaPage() {
setStores(res.items); setStores(res.items);
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'], width: 140 }, {
title: '门店',
dataIndex: ['store', 'name'],
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditing(row);
editForm.setFieldsValue(row);
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> }, { title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
{ {
title: '预览', dataIndex: 'url', width: 100, title: '预览', dataIndex: 'url', width: 100,
@@ -49,7 +67,7 @@ export default function StoreMediaPage() {
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} /> ? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
: <a href={url} target="_blank" rel="noreferrer"></a>, : <a href={url} target="_blank" rel="noreferrer"></a>,
}, },
{ title: 'URL', dataIndex: 'url', ellipsis: true }, { title: 'URL', dataIndex: 'url' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 }, { title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -73,10 +91,14 @@ export default function StoreMediaPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button> <Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></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); }}>
@@ -86,7 +108,7 @@ export default function StoreMediaPage() {
</Form.Item> </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" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }} <Table rowKey="id" className="admin-table-nowrap" 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); } }} />
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields(); const v = await createForm.validateFields();
@@ -32,6 +32,8 @@ import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = { const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核', PENDING: '待审核',
@@ -198,7 +200,15 @@ function InfoChangeAuditPanel({
} }
const columns: ColumnsType<StoreInfoChangeRequestDto> = [ const columns: ColumnsType<StoreInfoChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, {
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -469,8 +479,16 @@ export default function StorePackageAuditsPage() {
} }
} }
const columns: ColumnsType<StorePackageChangeRequestDto> = [ const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, {
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -519,9 +537,16 @@ export default function StorePackageAuditsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, {
page,
pageSize: 20,
});
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
+30 -4
View File
@@ -1,9 +1,12 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Form, Input, Table, Typography } from 'antd'; import { Button, Form, Input, Select, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { fmtTime } from '../lib/constants'; 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 { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = { type Row = {
id: string; id: string;
@@ -20,22 +23,30 @@ export default function StoreRatingsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialStoreId = searchParams.get('storeId') || ''; const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [cities, setCities] = useState<{ id: string; name: string }[]>([]);
const [filters, setFilters] = useState<Record<string, string>>({ const [filters, setFilters] = useState<Record<string, string>>({
...(initialStoreId ? { storeId: initialStoreId } : {}), ...(initialStoreId ? { storeId: initialStoreId } : {}),
}); });
useEffect(() => {
request<Paginated<{ id: string; name: string }>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items))
.catch(() => {});
}, []);
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/store-ratings', '/admin/store-ratings',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
return qs; return qs;
}, },
[filters], [filters],
); );
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 }, { title: '核销号', dataIndex: 'redeemNo', width: 180 },
{ {
title: '门店', title: '门店',
@@ -53,11 +64,15 @@ export default function StoreRatingsPage() {
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime }, { title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}> <Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -66,11 +81,22 @@ export default function StoreRatingsPage() {
onFinish={(v) => { onFinish={(v) => {
setFilters({ setFilters({
storeId: v.storeId || '', storeId: v.storeId || '',
cityId: v.cityId || '',
redeemNo: v.redeemNo || '', redeemNo: v.redeemNo || '',
}); });
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="storeId" label="门店ID"> <Form.Item name="storeId" label="门店ID">
<Input allowClear placeholder="storeId" /> <Input allowClear placeholder="storeId" />
</Form.Item> </Form.Item>
@@ -20,6 +20,9 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/
import { request, type Paginated } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -147,14 +150,14 @@ export default function StoreWithdrawalsPage() {
}); });
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '提现单号', title: '提现单号',
dataIndex: 'withdrawNo', dataIndex: 'withdrawNo',
width: 180, width: 180,
render: (v, row) => ( render: (v, row) => (
<Space> <Space>
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link> <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null} {row.overdue ? <Tag color="magenta"></Tag> : null}
</Space> </Space>
), ),
@@ -229,11 +232,15 @@ export default function StoreWithdrawalsPage() {
} }
| undefined; | undefined;
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}> <Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
{overdueSummary ? ( {overdueSummary ? (
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
{overdueSummary.pendingCount} {overdueSummary.pendingCount}
@@ -299,7 +306,7 @@ export default function StoreWithdrawalsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')} rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
pagination={{ pagination={{
current: page, current: page,
+47 -23
View File
@@ -39,6 +39,8 @@ import {
type StoreCreateForm, type StoreCreateForm,
} from '../lib/storeCreate'; } from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
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';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
@@ -198,6 +200,9 @@ type StoreRow = {
pendingPackageAuditId?: string | null; pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */ /** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null; pendingInfoChangeId?: string | null;
/** 累计核销好客权益(券面额合计) */
totalRedeemedBenefitAmount?: number;
contactPhone?: string | null;
isTest?: boolean; isTest?: boolean;
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -720,47 +725,60 @@ export default function StoresPage() {
} }
} }
const columns: ColumnsType<StoreRow> = [ const baseColumns: ColumnsType<StoreRow> = [
{ {
key: 'coverUrl',
title: '封面', dataIndex: 'coverUrl', width: 72, title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—', render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
}, },
{ {
key: 'name',
title: '门店名', title: '门店名',
dataIndex: 'name', dataIndex: 'name',
width: 180, width: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => { render: (v: string, row) => {
const name = v || '—'; const name = v || '—';
return ( return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}> <Space size={4} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}> <AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
{name}
</Typography.Text>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
</Space> </Space>
); );
}, },
}, },
{ {
key: 'category',
title: '分类', title: '分类',
width: 100, width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—', render: (_, row) => row.category?.name || '—',
}, },
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true }, { key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '登录号', dataIndex: 'phone', width: 120 }, { key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 },
{ {
key: 'contactPhone',
title: '联系电话', title: '联系电话',
dataIndex: 'contactPhone', dataIndex: 'contactPhone',
width: 120, width: 120,
render: (v: string | null | undefined, row) => v || row.phone, render: (v: string | null | undefined, row) => v || row.phone,
}, },
{ {
key: 'status',
title: '营业状态', dataIndex: 'status', width: 90, title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
}, },
{ {
key: 'totalRedeemedBenefitAmount',
title: '累计核销好客权益',
dataIndex: 'totalRedeemedBenefitAmount',
width: 150,
align: 'right',
render: (v: number | undefined) => {
const n = Number(v ?? 0);
return n > 0 ? `¥${n.toFixed(2)}` : '—';
},
},
{
key: 'auditStatus',
title: '审核', dataIndex: 'auditStatus', width: 100, title: '审核', dataIndex: 'auditStatus', width: 100,
render: (s, row) => { render: (s, row) => {
const status = s || 'APPROVED'; const status = s || 'APPROVED';
@@ -769,7 +787,7 @@ export default function StoresPage() {
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}> <Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag> <Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? ( {status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason} {row.rejectReason}
</Typography.Text> </Typography.Text>
) : null} ) : null}
@@ -778,32 +796,29 @@ export default function StoresPage() {
}, },
}, },
{ {
key: 'partner',
title: '开城合伙人', title: '开城合伙人',
dataIndex: 'partner', dataIndex: 'partner',
width: 140, width: 140,
ellipsis: { showTitle: false },
render: (partner: StoreRow['partner']) => { render: (partner: StoreRow['partner']) => {
if (!partner) return '—'; if (!partner) return '—';
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner }); return partnerOptionLabel({ id: partner.id ?? '', ...partner });
return (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
}, },
}, },
{ {
key: 'visibilityWhitelistEnabled',
title: '可见', title: '可见',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v) => render: (v) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { key: 'intro', title: '介绍', dataIndex: 'intro', width: 160, render: (v) => v || '—' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 }, { key: 'sortOrder', title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' }, { key: 'accountName', title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { key: 'createdAt', title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
key: 'actions',
title: '操作', title: '操作',
width: 280, width: 280,
fixed: 'right', fixed: 'right',
@@ -850,14 +865,23 @@ export default function StoresPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('stores', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"> {data?.total ?? 0} </Typography.Text> <Typography.Text type="secondary"> {data?.total ?? 0} </Typography.Text>
</Space> </Space>
<Button type="primary" onClick={openCreateModal}></Button> <Space>
{settingsButton}
<Button type="primary" onClick={openCreateModal}></Button>
</Space>
</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>
@@ -918,7 +942,7 @@ export default function StoresPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1720 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -46,6 +46,9 @@ import { request, type HqProfile } from '../lib/api';
import { fmtTime } from '../lib/constants'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程'; const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
@@ -470,7 +473,7 @@ export default function SupportTicketsPage() {
} }
} }
const columns: ColumnsType<TicketRow> = [ const baseColumns: ColumnsType<TicketRow> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ {
title: '类型', title: '类型',
@@ -503,7 +506,13 @@ export default function SupportTicketsPage() {
</Space> </Space>
), ),
}, },
{ title: '标题', dataIndex: 'title', ellipsis: true }, {
title: '标题',
dataIndex: 'title',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 100 }, { title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -546,8 +555,11 @@ export default function SupportTicketsPage() {
return null; return null;
})(); })();
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -559,6 +571,7 @@ export default function SupportTicketsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Space> <Space>
{isSuperAdmin ? ( {isSuperAdmin ? (
<> <>
@@ -619,7 +632,7 @@ export default function SupportTicketsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 900 }} scroll={{ x: 'max-content' }}
rowSelection={ rowSelection={
isSuperAdmin isSuperAdmin
? { ? {
+28 -5
View File
@@ -20,6 +20,8 @@ import {
import type { ColumnsType } from 'antd/es/table'; 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 { 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;
@@ -253,11 +255,25 @@ export default function TestWhitelistPage() {
} }
const phoneColumns: ColumnsType<PhoneRow> = [ const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 }, {
title: '手机号',
dataIndex: 'phone',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '备注', title: '备注',
dataIndex: 'note', dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -334,7 +350,7 @@ export default function TestWhitelistPage() {
if (accountType === 'partner') { if (accountType === 'partner') {
return [ return [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' }, { title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '标记', title: '标记',
@@ -347,7 +363,7 @@ export default function TestWhitelistPage() {
} }
if (accountType === 'store') { if (accountType === 'store') {
return [ return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true }, { title: '门店名', dataIndex: 'name', width: 160 },
{ title: '城市', dataIndex: 'cityName', width: 90 }, { title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 }, { title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 }, { title: '状态', dataIndex: 'status', width: 90 },
@@ -386,11 +402,18 @@ export default function TestWhitelistPage() {
]; ];
} }
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
page: phonePage,
pageSize: phonePageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}> <Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title> </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 }}>
@@ -472,7 +495,7 @@ export default function TestWhitelistPage() {
<Table <Table
rowKey="id" rowKey="id"
loading={phonesLoading} loading={phonesLoading}
columns={phoneColumns} columns={columns}
dataSource={phones?.items ?? []} dataSource={phones?.items ?? []}
pagination={{ pagination={{
current: phonePage, current: phonePage,
@@ -4,6 +4,9 @@ import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -69,7 +72,7 @@ export default function ThirdPartyLogsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: 'Provider', dataIndex: 'provider', width: 120 }, { title: 'Provider', dataIndex: 'provider', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 120 }, { title: '场景', dataIndex: 'scene', width: 120 },
@@ -87,15 +90,27 @@ export default function ThirdPartyLogsPage() {
{ {
title: '错误信息', title: '错误信息',
dataIndex: 'errorMessage', dataIndex: 'errorMessage',
ellipsis: true,
render: (v: string | null | undefined) => v || '—', render: (v: string | null | undefined) => v || '—',
}, },
{ {
title: '请求摘要', title: '请求摘要',
ellipsis: true,
render: (_, r) => summarizeJson(r.requestBody), render: (_, r) => summarizeJson(r.requestBody),
}, },
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' }, {
title: '外部单号',
dataIndex: 'externalNo',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/common/third-party-logs/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '操作', title: '操作',
width: 80, width: 80,
@@ -114,9 +129,16 @@ export default function ThirdPartyLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form <Form
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@@ -143,7 +165,7 @@ export default function ThirdPartyLogsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+25 -4
View File
@@ -25,6 +25,9 @@ import {
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -115,8 +118,22 @@ export default function TicketsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, {
title: '工单号',
dataIndex: 'ticketNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '类型', title: '类型',
dataIndex: 'ticketType', dataIndex: 'ticketType',
@@ -132,7 +149,7 @@ export default function TicketsPage() {
), ),
}, },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark' },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
@@ -154,8 +171,11 @@ export default function TicketsPage() {
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? []; const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
const { columns, settingsButton, settingsModal } = useAdminListColumns('tickets', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -167,6 +187,7 @@ export default function TicketsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}> <Button type="primary" onClick={() => setCreateOpen(true)}>
</Button> </Button>
@@ -205,7 +226,7 @@ export default function TicketsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 900 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+26 -5
View File
@@ -7,6 +7,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -61,10 +64,10 @@ export default function UserLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '用户', width: 180, ellipsis: true, title: '用户', width: 180,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.nickname || r.userNo} primary={r.nickname || r.userNo}
@@ -78,13 +81,27 @@ export default function UserLogsPage() {
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag> <Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 160 }, {
title: '事件',
dataIndex: 'eventName',
width: 160,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/users/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '关联', width: 120, title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
}, },
{ {
title: '摘要', ellipsis: true, title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -98,9 +115,13 @@ export default function UserLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <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 }))}
@@ -131,7 +152,7 @@ export default function UserLogsPage() {
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item> <Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }} <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={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
+114 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -19,6 +19,8 @@ import {
import type { ColumnsType } from 'antd/es/table'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = { type UserOrderRow = {
@@ -30,6 +32,77 @@ type UserOrderRow = {
createdAt: string; createdAt: string;
}; };
function HqRemarkCell({
id,
hqRemark,
onSaved,
}: {
id: string;
hqRemark: string | null;
onSaved: (id: string, hqRemark: string | null) => void;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(hqRemark ?? '');
const savingRef = useRef(false);
useEffect(() => {
if (!editing) setValue(hqRemark ?? '');
}, [hqRemark, editing]);
async function commit() {
if (savingRef.current) return;
const next = value.trim() || null;
const prev = hqRemark?.trim() || null;
setEditing(false);
if (next === prev) return;
savingRef.current = true;
try {
const res = await request<{ id: string; hqRemark: string | null }>(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ hqRemark: next ?? '' }),
});
onSaved(id, res.hqRemark);
message.success('备注已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
setValue(hqRemark ?? '');
} finally {
savingRef.current = false;
}
}
if (editing) {
return (
<Input
size="small"
autoFocus
maxLength={128}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => void commit()}
onPressEnter={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Escape') void commit();
}}
onClick={(e) => e.stopPropagation()}
/>
);
}
return (
<span
title="双击修改备注,离开后保存"
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
style={{ cursor: 'text', display: 'inline-block', minWidth: 48 }}
>
{hqRemark || '—'}
</span>
);
}
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */ /** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
function fmtBenefit(v: number | null | undefined) { function fmtBenefit(v: number | null | undefined) {
const n = Number(v ?? 0); const n = Number(v ?? 0);
@@ -244,7 +317,7 @@ export default function UsersPage() {
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
]; ];
const columns: ColumnsType<AdminUserRow> = [ const baseColumns: ColumnsType<AdminUserRow> = [
{ {
title: '用户编号', title: '用户编号',
dataIndex: 'userNo', dataIndex: 'userNo',
@@ -256,12 +329,38 @@ export default function UsersPage() {
</Space> </Space>
), ),
}, },
{ title: '昵称', dataIndex: 'nickname', width: 100 }, {
title: '昵称',
dataIndex: 'nickname',
width: 140,
render: (v: string | null, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '备注',
dataIndex: 'hqRemark',
width: 160,
render: (v: string | null, row) => (
<HqRemarkCell
id={row.id}
hqRemark={v}
onSaved={(id, hqRemark) => {
setData((prev) =>
prev
? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, hqRemark } : u)) }
: prev,
);
setDetail((d) => (d && d.id === id ? { ...d, hqRemark } : d));
}}
/>
),
},
{ {
title: '手机', title: '手机',
dataIndex: 'phone', dataIndex: 'phone',
width: 120, width: 120,
render: (v) => maskPhone(v), render: (v) => v || '—',
}, },
{ {
title: '验手机', title: '验手机',
@@ -289,7 +388,6 @@ export default function UsersPage() {
title: '来源 ID', title: '来源 ID',
dataIndex: 'sourceRefId', dataIndex: 'sourceRefId',
width: 100, width: 100,
ellipsis: true,
render: (v, row) => { render: (v, row) => {
if (!v) return '—'; if (!v) return '—';
if (row.sourceType === 'PROMO_CODE') { if (row.sourceType === 'PROMO_CODE') {
@@ -306,13 +404,11 @@ export default function UsersPage() {
title: '来源标签', title: '来源标签',
dataIndex: 'sourceLabel', dataIndex: 'sourceLabel',
width: 120, width: 120,
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
title: 'deviceKey', title: 'deviceKey',
dataIndex: 'deviceKey', dataIndex: 'deviceKey',
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -377,10 +473,18 @@ export default function UsersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
{settingsButton}
{canDeleteUsers ? ( {canDeleteUsers ? (
<Button <Button
danger danger
@@ -390,6 +494,7 @@ export default function UsersPage() {
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button> </Button>
) : null} ) : null}
</Space>
</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="手机号">
@@ -429,7 +534,7 @@ export default function UsersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1830 }} scroll={{ x: 'max-content' }}
rowSelection={canDeleteUsers ? { rowSelection={canDeleteUsers ? {
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
@@ -462,6 +567,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item> <Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
@@ -7,6 +7,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -90,12 +93,11 @@ export default function WechatBindingsPage() {
setDrawerOpen(true); setDrawerOpen(true);
} }
const columns: ColumnsType<GroupRow> = [ const baseColumns: ColumnsType<GroupRow> = [
{ {
title: 'unionId', title: 'unionId',
dataIndex: 'unionId', dataIndex: 'unionId',
width: 180, width: 180,
ellipsis: true,
render: (v) => v || <Tag> unionId</Tag>, render: (v) => v || <Tag> unionId</Tag>,
}, },
{ {
@@ -119,11 +121,12 @@ export default function WechatBindingsPage() {
title: '手机号', title: '手机号',
dataIndex: 'primaryPhone', dataIndex: 'primaryPhone',
width: 140, width: 140,
render: (v) => maskPhone(v), render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
),
}, },
{ {
title: '身份摘要', title: '身份摘要',
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
@@ -160,7 +163,6 @@ export default function WechatBindingsPage() {
}, },
{ {
title: '账号', title: '账号',
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.name || '—'} primary={r.name || '—'}
@@ -172,14 +174,12 @@ export default function WechatBindingsPage() {
title: '归属', title: '归属',
dataIndex: 'refLabel', dataIndex: 'refLabel',
width: 160, width: 160,
ellipsis: true,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
}, },
{ {
title: 'wxOpenId', title: 'wxOpenId',
dataIndex: 'wxOpenId', dataIndex: 'wxOpenId',
width: 160, width: 160,
ellipsis: true,
}, },
{ {
title: '手机验证', title: '手机验证',
@@ -205,9 +205,13 @@ export default function WechatBindingsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
unionId C HQ unionId unionId C HQ unionId
</Typography.Paragraph> </Typography.Paragraph>
+21 -7
View File
@@ -5,6 +5,9 @@ import { useAdminList } from '../lib/useAdminList';
import { request } from '../lib/api'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
export default function WecomBotLogsPage() { export default function WecomBotLogsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -24,12 +27,19 @@ export default function WecomBotLogsPage() {
[filters], [filters],
); );
const columns: ColumnsType<WecomBotLogDto> = [ const baseColumns: ColumnsType<WecomBotLogDto> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' }, {
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true }, title: '机器人',
{ title: '动作', dataIndex: 'action', width: 160, ellipsis: true }, dataIndex: 'botName',
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' }, width: 120,
render: (v, r) => (
<AdminPrimaryLink onClick={() => setDetail(r)}>{v || r.botKey || ''}</AdminPrimaryLink>
),
},
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
{ title: '动作', dataIndex: 'action', width: 160 },
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
{ {
title: '结果', title: '结果',
dataIndex: 'success', dataIndex: 'success',
@@ -37,7 +47,7 @@ export default function WecomBotLogsPage() {
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
}, },
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' }, { title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true }, { title: '输入摘要', dataIndex: 'inputSummary' },
{ {
title: '操作', title: '操作',
width: 80, width: 80,
@@ -49,9 +59,13 @@ export default function WecomBotLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
</Typography.Paragraph> </Typography.Paragraph>
@@ -96,7 +110,7 @@ export default function WecomBotLogsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+21 -5
View File
@@ -36,6 +36,9 @@ 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 OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -280,7 +283,7 @@ export default function WecomBotsPage() {
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b])); const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
const columns: ColumnsType<WecomBotDto> = [ const baseColumns: ColumnsType<WecomBotDto> = [
{ {
title: '头像', title: '头像',
dataIndex: 'avatarUrl', dataIndex: 'avatarUrl',
@@ -291,18 +294,24 @@ export default function WecomBotsPage() {
</Avatar> </Avatar>
), ),
}, },
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, {
title: '名称',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
width: 120, width: 120,
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r, render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
}, },
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true }, { title: 'BotID', dataIndex: 'botId', width: 160 },
{ {
title: '权限', title: '权限',
dataIndex: 'permissions', dataIndex: 'permissions',
ellipsis: true,
render: (perms: WecomBotPermission[]) => render: (perms: WecomBotPermission[]) =>
perms.map((p) => ( perms.map((p) => (
<Tag key={p} style={{ marginBottom: 2 }}> <Tag key={p} style={{ marginBottom: 2 }}>
@@ -367,13 +376,20 @@ export default function WecomBotsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-bots', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
BotID / Secret / / / BotID / Secret / / /
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''} {runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
@@ -428,7 +444,7 @@ export default function WecomBotsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -34,6 +34,9 @@ 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 OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -193,21 +196,20 @@ function PushRoutesTab() {
} }
} }
const columns: ColumnsType<WecomMessagePushDto> = [ const baseColumns: ColumnsType<WecomMessagePushDto> = [
{ {
title: '名称', title: '名称',
dataIndex: 'name', dataIndex: 'name',
render: (name: string, row) => ( render: (name: string, row) => (
<Space> <Space>
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar> <Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
<span>{name}</span> <AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</Space> </Space>
), ),
}, },
{ {
title: 'Webhook', title: 'Webhook',
dataIndex: 'webhookUrlMasked', dataIndex: 'webhookUrlMasked',
ellipsis: true,
}, },
{ {
title: '推送条件', title: '推送条件',
@@ -262,8 +264,14 @@ function PushRoutesTab() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, {
page,
pageSize,
});
return ( return (
<> <>
{settingsModal}
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
Webhook / .env Webhook URL Webhook / .env Webhook URL
</Typography.Paragraph> </Typography.Paragraph>
@@ -304,6 +312,7 @@ function PushRoutesTab() {
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
</Button> </Button>
{settingsButton}
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
+18 -4
View File
@@ -26,6 +26,9 @@ import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; 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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -235,8 +238,15 @@ export default function WineryBillsPage() {
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, {
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '账单日', title: '账单日',
dataIndex: 'billDate', dataIndex: 'billDate',
@@ -290,8 +300,11 @@ export default function WineryBillsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -305,6 +318,7 @@ export default function WineryBillsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿 0 T+3 8:00 3 / × {ratePct}%绿 0
@@ -398,7 +412,7 @@ export default function WineryBillsPage() {
onChange: setSelectedKeys, onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }), getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}} }}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -456,7 +470,7 @@ export default function WineryBillsPage() {
pagination={false} pagination={false}
dataSource={detail.items ?? []} dataSource={detail.items ?? []}
columns={[ columns={[
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true }, { title: '订单号', dataIndex: 'orderNo' },
{ {
title: '配送', title: '配送',
dataIndex: 'deliveryType', dataIndex: 'deliveryType',
@@ -9,6 +9,9 @@ import {
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import { useAdminList } from '../../lib/useAdminList'; import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import { useAdminListColumns } from '../../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
export default function PromoCodeUsersPage() { export default function PromoCodeUsersPage() {
const { id = '' } = useParams(); const { id = '' } = useParams();
@@ -21,9 +24,18 @@ export default function PromoCodeUsersPage() {
[id], [id],
); );
const columns: ColumnsType<PromoCodeAttributedUser> = [ const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, {
title: '昵称',
dataIndex: 'nickname',
width: 100,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
{v}
</AdminPrimaryLink>
),
},
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{ {
title: '验手机', title: '验手机',
@@ -69,6 +81,8 @@ export default function PromoCodeUsersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
return ( return (
<Table <Table
rowKey="id" rowKey="id"
@@ -76,7 +90,7 @@ export default function PromoCodeUsersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+15 -2
View File
@@ -27,6 +27,8 @@
| 3.5.5 | 08-23 | HQ 订单监控导出 Excel/PDF + 列表页布局优化 | [`v3.5.5`](./杜康好客-v3.5.5-开发文档.md) | | 3.5.5 | 08-23 | HQ 订单监控导出 Excel/PDF + 列表页布局优化 | [`v3.5.5`](./杜康好客-v3.5.5-开发文档.md) |
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) | | 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
| 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.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
--- ---
@@ -60,6 +62,14 @@
**权限**:合伙人平级隔离;推广员仅拓店+看自己店;手机号脱敏(门店/合伙人中间4位)。 **权限**:合伙人平级隔离;推广员仅拓店+看自己店;手机号脱敏(门店/合伙人中间4位)。
**HQ WebAdmin 权限(v3.5.8**
- 分组:超级管理员 / 运营 / 财务 / 客服 / **城市门店服务**
- 生效权限 =(角色权限 ∪ 账号追加)− 账号撤销
- 任意 HQ 账号可勾选负责城市;未勾选 = 全国。城市门店服务必须 ≥1 城,且只能看勾选城市的门店(列表、入驻审核、套餐/信息变更审核、评价、账户、资源)。分类为全国字典。
- 城市门店服务默认:概览、门店列表、审核通知、评价、分类(不含账户/资源)。分类可新增/编辑,不可删除。概览按权限裁剪,且只看负责城市。
- 超管始终全权限、全国可见
--- ---
## 3. 核心规则 ## 3. 核心规则
@@ -126,8 +136,11 @@
- 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。 - 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。
- **不**再因客户端 `validation_error` 自动创建技术支持工单。 - **不**再因客户端 `validation_error` 自动创建技术支持工单。
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。 - 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
- HQ 门店列表:店名截断、操作列右固定、表过宽可横向滚动。 - HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动v3.5.9 去掉省略号)
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) - HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
--- ---
+4
View File
@@ -62,9 +62,13 @@
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 | | 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
| 3.5.6 | [`HQ 任务导出/财务银行信息/工单图片/运营去调试/审核全屏`](./杜康好客-v3.5.6-开发文档.md) | ✅ 已交付 | | 3.5.6 | [`HQ 任务导出/财务银行信息/工单图片/运营去调试/审核全屏`](./杜康好客-v3.5.6-开发文档.md) | ✅ 已交付 |
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 | | 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 |
| 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:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF`POST /admin/orders/export` | | 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF`POST /admin/orders/export` |
+132
View File
@@ -0,0 +1,132 @@
# 杜康好客 · v3.5.8 开发文档
> **2026-08-24** · admin-web / API
> **主题**:HQ 单账号追加/撤销权限、运营改客服修复、城市门店服务角色与城市范围
---
## 1. 版本目标
1. **单账号权限** 在角色基础上可追加,也可撤销某项功能。
2. **运营 → 客服** 可保存成功,且生效权限跟随新角色(清空账号级追加/撤销)。
3. **新分组「城市门店服务」**:默认门店列表、审核通知、评价、分类;按账号勾选城市,非勾选城市的门店不可见。
4. 任意 HQ 账号都可绑城市;**未勾选 = 全国**。该新角色 **必须至少 1 个城市**
5. 超管始终全权限、全国可见,不受撤销/城市限制。
**不做**:C 端 / 合伙人 / 门店端;按区分权;小程序版本号。
---
## 2. 权限模型
公式:`生效权限 = (角色权限 ∪ 账号追加) − 账号撤销`
| 层 | 存储 | 说明 |
|----|------|------|
| 角色 | `hq_role_permission` | 分组默认能力,超管基础权限固定 |
| 账号追加 | `hq_account_permission.effect=GRANT` | 在角色之上加点权限 |
| 账号撤销 | `hq_account_permission.effect=DENY` | 去掉角色已有的某项 |
同一账号同一权限键只能 GRANT 或 DENY。切换角色时清空该账号 GRANT/DENY。
### 2.1 角色
| 值 | 标签 | 默认 |
|----|------|------|
| SUPER_ADMIN | 超级管理员 | 目录全部(含调试;危险操作需按用户勾选) |
| OPS | 运营 | 与现网一致,且含拆分后的门店子权限 |
| FINANCE | 财务 | 同上(含门店子权限) |
| CUSTOMER_SERVICE | 客服 | dashboard / users / orders / tickets / tech_support / invoices / logs |
| CITY_STORE_SERVICE | 城市门店服务 | dashboard、stores、store_audits、store_ratings、store_categories**无** store_categories_delete |
### 2.2 门店权限拆分
| 键 | 菜单 |
|----|------|
| `stores` | 门店列表(含入驻审核) |
| `store_audits` | 审核通知(套餐变更 / 信息变更) |
| `store_ratings` | 门店评价 |
| `store_categories` | 门店分类(全国字典,不按城过滤;可新增/编辑) |
| `store_categories_delete` | 删除门店分类 / 同步默认分类(危险操作;运营/财务默认有,城市门店服务无) |
| `store_accounts` | 门店账户 |
| `store_media` | 门店资源 |
存量角色/账号若已有 `stores`,迁移一次性补齐后 5 键(行为与拆分前一致)。新角色不补账户/资源。删除分类键另按「非城市门店服务 + 已有分类权限」补齐。
---
## 3. 城市范围
-`hq_account_city`;空列表且非「城市门店服务」= 全国。
- 「城市门店服务」保存时必须 ≥1 城;若数据异常无城市,则看不到任何门店。
- 有范围时:列表/详情/审核/评价/账户/资源均 `Store.cityId IN (...)`;越权 403「无权访问该城市的门店」。
- 概览 `GET /admin/dashboard/stats|analytics`:按生效权限裁剪指标;有城市范围时只统计负责城市(无「未选城」)。
- `GET /admin/auth/me` 返回 `cityIds``cityScoped`
---
## 4. 运营改客服修复
1. 短信账号 `loginName` 为空时,编辑提交空字符串不再报「用户名不能为空」(视为不改)。
2. 切换角色清空账号级权限,避免运营追加的门店等权限带到客服。
3. 改为「城市门店服务」时若未绑城市则拒绝。
---
## 5. Prisma / SQL
- `HqAdminRole` 增加 `CITY_STORE_SERVICE`
- `HqPermissionEffect``GRANT` / `DENY``hq_account_permission.effect`
- `hq_account_city`
脚本:[`server/dukang-api/prisma/migrate-hq-permissions-v358.sql`](../server/dukang-api/prisma/migrate-hq-permissions-v358.sql)
已跑过主脚本的库补删除分类键:[`migrate-hq-permissions-v358-categories-delete.sql`](../server/dukang-api/prisma/migrate-hq-permissions-v358-categories-delete.sql)
---
## 6. API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/PUT | `/admin/hq-permissions/accounts/:id` | 返回/保存 `grantKeys``denyKeys` |
| PUT | `/admin/hq-accounts/:id` | 可传 `cityIds`;空 loginName 忽略 |
| GET | `/admin/auth/me` | `permissionKeys` + `cityIds` + `cityScoped` |
门店相关 HQ 接口加 `HqPermissionGuard` 对应键。
---
## 7. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | 执行 `migrate-hq-permissions-v358.sql``prisma db push` |
| 2 | `pnpm build`shared-types + API + admin-web |
| 3 | 发 `dukang-api` + `admin-web` |
---
## 8. 关键路径
| 域 | 路径 |
|----|------|
| 权限目录/公式 | `packages/shared-types/src/hq-permissions.ts` |
| 生效解析 | `hq-permission.guard.ts` |
| 账号/城市 | `admin-hq-accounts.service.ts` |
| 权限页 | `HqPermissionsPage.tsx` |
| 账户页 | `HqAccountsPage.tsx` |
| 菜单 | `AdminLayout.tsx` |
| 门店范围 | `admin-stores.service.ts`、套餐/信息变更审核、评价 |
---
## 9. 验收清单
- [ ] 按用户分配:可追加非角色权限,可取消角色已有权限(撤销后菜单与 API 均不可用)
- [ ] 短信登录的运营账号改为客服:保存成功;刷新后无运营菜单
- [ ] 新建「城市门店服务」不选城市无法保存;选城后仅见这些城市的门店/审核/评价
- [ ] 直链其他城市门店详情/审核 403
- [ ] 该角色默认无「门店账户」「门店资源」;运营原菜单不变
- [ ] 超管仍全国、全权限
- [ ] 分类页可见但不按城过滤;城市门店服务可新增/编辑,无删除与「同步默认分类」
- [ ] 概览只显示有权限的卡片;城市范围账号仅统计负责城市
+130
View File
@@ -0,0 +1,130 @@
# 杜康好客 · v3.5.9 开发文档
> **2026-08-25** · admin-web / API
> **主题**:门店累计核销好客权益、HQ 表格去省略号、序号列与列设置;用户备注 / 手机号不脱敏
---
## 1. 版本目标
1. **门店列表**增加「累计核销好客权益」:该店历史核销券面额合计。
2. **全 HQ 表格**去掉 `...` 截断,长文本完整展示,横向滚动。
3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序;可拖表头分割线改列宽。偏好(含列宽)存当前 HQ 账号。主展示列(名称 / 单号 / 昵称等)带下划线,点击进入编辑或详情。
4. **用户列表**:昵称只读(用户自己的);新增「备注」列,双击编辑 HQ 标记,离开即写入 `user_user.hq_remark`;手机号列表明文,不脱敏。
**不做**:C 端 / 合伙人 / 门店端;详情 Drawer 内嵌表不加列设置。
---
## 2. 累计核销好客权益
口径:当前页每家店 `SUM(user_redeem_record.amount)`,含测试核销;无记录为 `—`。不是结算额 `settleAmount`
`GET /admin/stores` 行字段 `totalRedeemedBenefitAmount`(数字)。详情/导出不加。
---
## 3. 表格展示
- 全局单元格:`nowrap` + 不省略;描述列表仍省略。
- 列级 `ellipsis` 去掉;操作列仍可 `fixed: 'right'`
---
## 4. 序号与列设置
| 项 | 规则 |
|----|------|
| 序号 | `(page-1)*pageSize+index+1`,锁定最左,不可隐藏 |
| 操作列 | 锁定最右,不可隐藏 |
| 弹窗 | 勾选显示、拖拽/上下移排序、重置默认 |
| 主列 | 名称/单号/昵称等下划线,点击同「编辑」或「详情」 |
| 存储 | `hq_account.list_column_prefs` JSON,按 `listKey` |
```ts
{ stores: { order: ["name", "cityName"], hidden: ["intro"], widths: { name: 180 } } }
```
新列按默认位置插入且默认显示;未知 key 忽略。重置即删除该 `listKey`
### API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/auth/me` | `listColumnPrefs` |
| PUT | `/admin/me/list-columns/:listKey` | `{ order, hidden }``{ reset: true }`;只改当前账号 |
| PUT | `/admin/users/:id` | `{ hqRemark }`,空串存 `null`;不改 nickname |
`listKey` 白名单见 `packages/shared-types/src/hq-list-columns.ts`。无新权限键。
---
## 5. 用户列表:备注 / 手机号不脱敏
| 项 | 行为 |
|----|------|
| 昵称 | 只读,来自用户自己的 `nickname`HQ 不可改 |
| 备注 | 双击单元格进入输入;失焦、回车或 Esc 退出编辑即 `PUT /admin/users/:id` `{ hqRemark }`(空串写成 `null`);最长 128C 端不展示 |
| 手机号 | 列表列明文 `phone`,不再 `maskPhone`。详情抽屉仍脱敏 |
审计:`USER_UPDATE`(修改用户备注)。无新权限键,已登录 HQ 即可(与用户列表同入口)。
---
## 6. Prisma / SQL
`hq_account.list_column_prefs` JSON NULL。
`user_user.hq_remark` VARCHAR(128) NULL。
脚本:
- [`migrate-hq-list-column-prefs-v359.sql`](../server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql)
- [`migrate-user-hq-remark-v359.sql`](../server/dukang-api/prisma/migrate-user-hq-remark-v359.sql)
---
## 7. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | 执行 `migrate-hq-list-column-prefs-v359.sql``migrate-user-hq-remark-v359.sql``prisma db push` |
| 2 | `pnpm build`shared-types + API + admin-web |
| 3 | 发 `dukang-api` + `admin-web` |
---
## 8. 关键路径
| 域 | 路径 |
|----|------|
| 门店聚合 | `admin-stores.service.ts` |
| 列偏好 | `auth.service.ts` · `admin-me.controller.ts` |
| 类型 | `packages/shared-types/src/hq-list-columns.ts` |
| 表格 hook | `apps/admin-web/src/lib/useAdminListColumns.tsx` |
| 弹窗 | `AdminListColumnsModal.tsx` |
| 样式 | `admin.css` |
| 备注 | `admin-users.service.ts` · `UsersPage.tsx` |
---
## 9. 验收清单
- [ ] 门店列表「累计核销好客权益」= 该店核销记录金额合计;无核销为 `—`
- [ ] HQ 主表长字段不再 `...`,可横向滑完
- [ ] 主列表最左序号跨页连续
- [ ] 列设置可隐藏/排序;保存后刷新仍在;重置恢复默认
- [ ] 主展示列带下划线,点击进入对应编辑或详情
- [ ] 序号、操作列不能在弹窗关掉或拖走
- [ ] 详情描述列表仍可省略
- [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `user_user.hq_remark` 已更新;C 端看不到该字段
- [ ] 用户列表手机号完整可见(详情仍脱敏)
- [ ] 权益券列表:用户编号、订单号可点进对应用户/订单详情;来源含商品名、规格、数量、配送方式、实付金额
---
## 10. 权益券列表快链与来源
- **用户**、**订单**列(及券详情)用主列下划线,分别打开用户详情抽屉、订单详情抽屉。
- **来源**:关联订单时拼 `商品名 / 规格 / 数量(瓶或箱) / 配送方式 / ¥实付`;无订单仍用 `sourceProduct`(如总部手动发放)。
- 列表接口 `order` 增补 `productName``productSpec``quantity``saleUnit``deliveryType``payAmount`
+4
View File
@@ -41,6 +41,10 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
| 合伙人 | 拓店三步→HQ审核→辖区订单/账单 | | 合伙人 | 拓店三步→HQ审核→辖区订单/账单 |
| HQ | 开城/商品/审核/订单/权益/核销/结算/工单 | | HQ | 开城/商品/审核/订单/权益/核销/结算/工单 |
**HQ 权限(v3.5.8**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。
**HQ 列表(v3.5.9**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
## 5. 验收用例(必过) ## 5. 验收用例(必过)
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
+4 -3
View File
@@ -87,11 +87,12 @@
|--------|------| |--------|------|
| 开城 | 城市、合伙人(全城/区域+佣金)、仓库 | | 开城 | 城市、合伙人(全城/区域+佣金)、仓库 |
| 商品 | SKU、上下架、详情模板 | | 商品 | SKU、上下架、详情模板 |
| 门店 | 审核、套餐 Tab 直存/审核、合伙人列 | | 门店 | 审核、套餐 Tab 直存/审核、合伙人列、累计核销好客权益 |
| 用户 | 列表手机号明文;备注双击离开即保存(不改昵称) |
| 交易 | 订单、权益券、核销记录、推广码+metrics | | 交易 | 订单、权益券、核销记录、推广码+metrics |
| 财务 | 门店/合伙人/酒厂/物流账单;打款确认 | | 财务 | 门店/合伙人/酒厂/物流账单;打款确认 |
| 工单 | 售后四类型 + 技术支持(ST) + 开发计划 | | 工单 | 售后四类型 + 技术支持(ST) + 开发计划 |
| 系统 | 账号权限、客户端配置、企微机器人/消息推送 | | 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」与列宽按账号保存 |
| 日志 | HQ/用户/门店/合伙人/企微 | | 日志 | HQ/用户/门店/合伙人/企微 |
## 6. 商品与模板 ## 6. 商品与模板
@@ -140,7 +141,7 @@ HQ 推广码:场景/合伙人绑定/上下线;touch 归因;metrics 四指
## 16. 系统设置 ## 16. 系统设置
HQ 账号/角色(`hq-permissions`) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。 HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。
## 17. 企业微信 ## 17. 企业微信
@@ -0,0 +1,74 @@
/** HQ 主列表列偏好(按账号存储) */
export const HQ_LIST_COLUMN_KEYS = [
'users',
'wechat-bindings',
'products',
'product-detail-templates',
'orders',
'promo-codes',
'promo-code-users',
'wecom-bots',
'wecom-pushes',
'llm-configs',
'knowledge-bases',
'stores',
'store-package-audits',
'store-ratings',
'store-categories',
'store-accounts',
'store-media',
'cities',
'city-partners',
'city-warehouses',
'fulfillment-providers',
'partners',
'partner-accounts',
'finance-store-bills',
'finance-partner-bills',
'finance-winery-bills',
'finance-logistics-bills',
'finance-store-withdrawals',
'benefit-coupons',
'benefit-ledgers',
'redeem-records',
'redeem-pending',
'deliveries',
'tickets',
'support-tickets',
'dev-plan-versions',
'dev-plan-tasks',
'invoices',
'resources',
'logs-users',
'logs-stores',
'logs-partners',
'logs-hq',
'logs-third-party',
'logs-domain-events',
'logs-wecom-bots',
'hq-accounts',
'test-whitelist',
] as const;
export type HqListColumnKey = (typeof HQ_LIST_COLUMN_KEYS)[number];
export function isHqListColumnKey(value: string): value is HqListColumnKey {
return (HQ_LIST_COLUMN_KEYS as readonly string[]).includes(value);
}
export type HqListColumnPref = {
order: string[];
hidden: string[];
/** 列宽(px);未出现的 key 用列默认 width */
widths?: Record<string, number>;
};
export type HqListColumnPrefsMap = Partial<Record<HqListColumnKey, HqListColumnPref>>;
export type SaveHqListColumnPrefsRequest = {
reset?: boolean;
order?: string[];
hidden?: string[];
widths?: Record<string, number>;
};
+70 -6
View File
@@ -6,7 +6,12 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'products', label: '商品管理', group: '业务' }, { key: 'products', label: '商品管理', group: '业务' },
{ key: 'orders', label: '订单管理', group: '业务' }, { key: 'orders', label: '订单管理', group: '业务' },
{ key: 'promo_codes', label: '推广码', group: '业务' }, { key: 'promo_codes', label: '推广码', group: '业务' },
{ key: 'stores', label: '门店管理', group: '业务' }, { key: 'stores', label: '门店列表', group: '业务' },
{ key: 'store_audits', label: '门店审核', group: '业务' },
{ key: 'store_ratings', label: '门店评价', group: '业务' },
{ key: 'store_categories', label: '门店分类', group: '业务' },
{ key: 'store_accounts', label: '门店账户', group: '业务' },
{ key: 'store_media', label: '门店资源', group: '业务' },
{ key: 'partners', label: '开城管理', group: '业务' }, { key: 'partners', label: '开城管理', group: '业务' },
{ key: 'finance', label: '财务', group: '业务' }, { key: 'finance', label: '财务', group: '业务' },
{ key: 'benefit', label: '好客权益', group: '业务' }, { key: 'benefit', label: '好客权益', group: '业务' },
@@ -26,6 +31,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'users_delete', label: '删除用户', group: '危险操作' }, { key: 'users_delete', label: '删除用户', group: '危险操作' },
{ key: 'orders_delete', label: '删除订单', group: '危险操作' }, { key: 'orders_delete', label: '删除订单', group: '危险操作' },
{ key: 'cities_delete', label: '删除城市', group: '危险操作' }, { key: 'cities_delete', label: '删除城市', group: '危险操作' },
{ key: 'store_categories_delete', label: '删除门店分类', group: '危险操作' },
{ key: 'hq_permissions', label: '权限分配', group: '管理' }, { key: 'hq_permissions', label: '权限分配', group: '管理' },
{ key: 'hq_accounts', label: 'HQ 账户', group: '管理' }, { key: 'hq_accounts', label: 'HQ 账户', group: '管理' },
{ key: 'system_settings_feature', label: '功能开关', group: '系统设置' }, { key: 'system_settings_feature', label: '功能开关', group: '系统设置' },
@@ -42,6 +48,20 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 由原 `stores` 拆出的子权限;存量数据迁移时补齐 */
export const HQ_STORE_SUB_PERMISSION_KEYS = [
'store_audits',
'store_ratings',
'store_categories',
'store_accounts',
'store_media',
] as const satisfies readonly HqPermissionKey[];
export const HQ_STORE_MENU_PERMISSION_KEYS = [
'stores',
...HQ_STORE_SUB_PERMISSION_KEYS,
] as const satisfies readonly HqPermissionKey[];
/** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */ /** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */
export const HQ_DEBUG_PERMISSION_KEYS = [ export const HQ_DEBUG_PERMISSION_KEYS = [
'benefit_debug', 'benefit_debug',
@@ -52,6 +72,7 @@ export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete', 'users_delete',
'orders_delete', 'orders_delete',
'cities_delete', 'cities_delete',
'store_categories_delete',
] as const satisfies readonly HqPermissionKey[]; ] as const satisfies readonly HqPermissionKey[];
export function isHqDangerousPermission(key: string): boolean { export function isHqDangerousPermission(key: string): boolean {
@@ -100,19 +121,53 @@ export function expandHqPermissionKeys(keys: string[]): HqPermissionKey[] {
); );
} }
/** 生效 = (角色 ∪ 追加) − 撤销 */
export function computeHqEffectivePermissionKeys(
roleKeys: string[],
grantKeys: string[],
denyKeys: string[] = [],
): HqPermissionKey[] {
const denied = new Set(denyKeys);
return expandHqPermissionKeys([...roleKeys, ...grantKeys]).filter((k) => !denied.has(k));
}
export function hasAnySystemSettingsPermission(keys: string[]): boolean { export function hasAnySystemSettingsPermission(keys: string[]): boolean {
const expanded = expandHqPermissionKeys(keys); const expanded = expandHqPermissionKeys(keys);
return SYSTEM_SETTINGS_PERMISSION_KEYS.some((k) => expanded.includes(k)); return SYSTEM_SETTINGS_PERMISSION_KEYS.some((k) => expanded.includes(k));
} }
export const HQ_ADMIN_ROLES = [ export function hasAnyStoreMenuPermission(keys: string[]): boolean {
return HQ_STORE_MENU_PERMISSION_KEYS.some((k) => keys.includes(k));
}
export const HQ_ADMIN_ROLE_VALUES = [
'SUPER_ADMIN',
'OPS',
'FINANCE',
'CUSTOMER_SERVICE',
'CITY_STORE_SERVICE',
] as const;
export type HqAdminRoleValue = (typeof HQ_ADMIN_ROLE_VALUES)[number];
export const HQ_ADMIN_ROLES: { value: HqAdminRoleValue; label: string }[] = [
{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'SUPER_ADMIN', label: '超级管理员' },
{ value: 'OPS', label: '运营' }, { value: 'OPS', label: '运营' },
{ value: 'FINANCE', label: '财务' }, { value: 'FINANCE', label: '财务' },
{ value: 'CUSTOMER_SERVICE', label: '客服' }, { value: 'CUSTOMER_SERVICE', label: '客服' },
] as const; { value: 'CITY_STORE_SERVICE', label: '城市门店服务' },
];
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = { const OPS_STORE_KEYS: HqPermissionKey[] = [
'stores',
'store_audits',
'store_ratings',
'store_categories',
'store_accounts',
'store_media',
];
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<HqAdminRoleValue, HqPermissionKey[]> = {
SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS], SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS],
OPS: [ OPS: [
'dashboard', 'dashboard',
@@ -121,7 +176,8 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'products', 'products',
'orders', 'orders',
'promo_codes', 'promo_codes',
'stores', ...OPS_STORE_KEYS,
'store_categories_delete',
'partners', 'partners',
'benefit', 'benefit',
'deliveries', 'deliveries',
@@ -141,7 +197,8 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
FINANCE: [ FINANCE: [
'dashboard', 'dashboard',
'orders', 'orders',
'stores', ...OPS_STORE_KEYS,
'store_categories_delete',
'partners', 'partners',
'finance', 'finance',
'benefit', 'benefit',
@@ -160,4 +217,11 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'invoices', 'invoices',
'logs', 'logs',
], ],
CITY_STORE_SERVICE: [
'dashboard',
'stores',
'store_audits',
'store_ratings',
'store_categories',
],
}; };
+1
View File
@@ -19,6 +19,7 @@ export * from './store-info-change';
export * from './partner-log'; export * from './partner-log';
export * from './promo'; export * from './promo';
export * from './hq-permissions'; export * from './hq-permissions';
export * from './hq-list-columns';
export * from './partner'; export * from './partner';
export * from './shop'; export * from './shop';
export * from './city-partner'; export * from './city-partner';
+4
View File
@@ -3,6 +3,10 @@ export interface AdminListQuery {
pageSize?: number; pageSize?: number;
} }
export interface UpdateAdminUserRequest {
hqRemark: string;
}
export interface AdminPageResult<T> { export interface AdminPageResult<T> {
items: T[]; items: T[];
total: number; total: number;
@@ -0,0 +1,3 @@
-- v3.5.9: HQ 账号列表显示列 / 顺序偏好
ALTER TABLE `hq_account`
ADD COLUMN `list_column_prefs` JSON NULL;
@@ -0,0 +1,15 @@
-- v3.5.8 补丁:删除门店分类权限(可对已执行过 migrate-hq-permissions-v358.sql 的库单独跑)
-- 城市门店服务可新增分类,不可删除
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_categories_delete', NOW(3)
FROM `hq_role_permission`
WHERE `permission_key` = 'store_categories'
AND `admin_role` <> 'CITY_STORE_SERVICE';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT p.`hq_account_id`, 'store_categories_delete', p.`effect`, NOW(3)
FROM `hq_account_permission` p
INNER JOIN `hq_account` a ON a.`id` = p.`hq_account_id`
WHERE p.`permission_key` = 'store_categories'
AND a.`admin_role` <> 'CITY_STORE_SERVICE';
@@ -0,0 +1,71 @@
-- v3.5.8: HQ 权限追加/撤销、城市门店服务、账号城市范围
ALTER TABLE `hq_account`
MODIFY COLUMN `admin_role` ENUM(
'SUPER_ADMIN',
'OPS',
'FINANCE',
'CUSTOMER_SERVICE',
'CITY_STORE_SERVICE'
) NOT NULL DEFAULT 'OPS';
ALTER TABLE `hq_role_permission`
MODIFY COLUMN `admin_role` ENUM(
'SUPER_ADMIN',
'OPS',
'FINANCE',
'CUSTOMER_SERVICE',
'CITY_STORE_SERVICE'
) NOT NULL;
ALTER TABLE `hq_account_permission`
ADD COLUMN `effect` ENUM('GRANT', 'DENY') NOT NULL DEFAULT 'GRANT' AFTER `permission_key`;
CREATE TABLE IF NOT EXISTS `hq_account_city` (
`hq_account_id` BIGINT UNSIGNED NOT NULL,
`city_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`hq_account_id`, `city_id`),
INDEX `hq_account_city_city_id_idx` (`city_id`),
CONSTRAINT `hq_account_city_hq_account_id_fkey`
FOREIGN KEY (`hq_account_id`) REFERENCES `hq_account` (`id`) ON DELETE CASCADE,
CONSTRAINT `hq_account_city_city_id_fkey`
FOREIGN KEY (`city_id`) REFERENCES `common_city` (`id`) ON DELETE CASCADE
) DEFAULT CHARSET = utf8mb4;
-- 存量 stores 拆键:补齐审核/评价/分类/账户/资源,保持与拆分前菜单一致
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_audits', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_ratings', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_categories', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_accounts', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_media', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT `hq_account_id`, 'store_audits', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT `hq_account_id`, 'store_ratings', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT `hq_account_id`, 'store_categories', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT `hq_account_id`, 'store_accounts', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT `hq_account_id`, 'store_media', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
-- 删除分类:有分类权限的角色/账号补齐,城市门店服务除外(可新增、不可删除)
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
SELECT `admin_role`, 'store_categories_delete', NOW(3)
FROM `hq_role_permission`
WHERE `permission_key` = 'store_categories'
AND `admin_role` <> 'CITY_STORE_SERVICE';
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
SELECT p.`hq_account_id`, 'store_categories_delete', p.`effect`, NOW(3)
FROM `hq_account_permission` p
INNER JOIN `hq_account` a ON a.`id` = p.`hq_account_id`
WHERE p.`permission_key` = 'store_categories'
AND a.`admin_role` <> 'CITY_STORE_SERVICE';
@@ -0,0 +1,3 @@
-- v3.5.9 补丁:HQ 管理员对用户的备注(不改 nickname)
ALTER TABLE `user_user`
ADD COLUMN `hq_remark` VARCHAR(128) NULL AFTER `nickname`;
+29 -3
View File
@@ -263,6 +263,12 @@ enum HqAdminRole {
OPS OPS
FINANCE FINANCE
CUSTOMER_SERVICE CUSTOMER_SERVICE
CITY_STORE_SERVICE
}
enum HqPermissionEffect {
GRANT
DENY
} }
enum PartnerBillStatus { enum PartnerBillStatus {
@@ -1031,6 +1037,7 @@ model CommonCity {
partnerAccounts PartnerAccount[] @relation("PartnerAccountCity") partnerAccounts PartnerAccount[] @relation("PartnerAccountCity")
stores Store[] stores Store[]
orders Order[] orders Order[]
hqAccountCities HqAccountCity[]
@@map("common_city") @@map("common_city")
} }
@@ -1244,10 +1251,13 @@ model HqAccount {
wxUnionId String? @map("wx_union_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64)
status AccountStatus @default(ACTIVE) status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
/// HQ 各列表的显示列与顺序(按 listKey)
listColumnPrefs Json? @map("list_column_prefs")
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)
permissions HqAccountPermission[] permissions HqAccountPermission[]
cities HqAccountCity[]
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy") testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
@@map("hq_account") @@map("hq_account")
@@ -1263,9 +1273,10 @@ model HqRolePermission {
} }
model HqAccountPermission { model HqAccountPermission {
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
permissionKey String @map("permission_key") @db.VarChar(64) permissionKey String @map("permission_key") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) effect HqPermissionEffect @default(GRANT)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade) hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade)
@@ -1273,6 +1284,19 @@ model HqAccountPermission {
@@map("hq_account_permission") @@map("hq_account_permission")
} }
model HqAccountCity {
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
@@id([hqAccountId, cityId])
@@index([cityId])
@@map("hq_account_city")
}
// ─── USER ───────────────────────────────────────────── // ─── USER ─────────────────────────────────────────────
model User { model User {
@@ -1285,6 +1309,8 @@ model User {
wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64)
nickname String? @db.VarChar(64) nickname String? @db.VarChar(64)
/// HQ 管理员备注(C 端不展示、不可改)
hqRemark String? @map("hq_remark") @db.VarChar(128)
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
status Int @default(1) @db.TinyInt status Int @default(1) @db.TinyInt
/// 测试账号:命中全局测试白名单手机号 /// 测试账号:命中全局测试白名单手机号
@@ -3,16 +3,20 @@ import {
ExecutionContext, ExecutionContext,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException,
SetMetadata, SetMetadata,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { import {
HQ_PERMISSION_CATALOG, HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
computeHqEffectivePermissionKeys,
expandHqPermissionKeys, expandHqPermissionKeys,
hasAnySystemSettingsPermission, hasAnySystemSettingsPermission,
type HqAdminRoleValue,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.module'; import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard'; import type { AuthUser } from './jwt-auth.guard';
@@ -23,6 +27,37 @@ export const RequireHqPermissions = (...keys: string[]) =>
export const RequireAnySystemSettings = () => export const RequireAnySystemSettings = () =>
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']); SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
/** null = 全国;[] = 无可见城市 */
export type HqCityScope = bigint[] | null;
export function hqStoreCityWhere(scope: HqCityScope): Prisma.StoreWhereInput | undefined {
if (scope === null) return undefined;
if (!scope.length) return { id: { equals: BigInt(0) } };
return { cityId: { in: scope } };
}
export function mergeHqStoreCityWhere(
where: Prisma.StoreWhereInput,
scope: HqCityScope,
requestedCityId?: string,
): Prisma.StoreWhereInput {
if (requestedCityId) {
const cityId = BigInt(requestedCityId);
assertHqCityInScope(scope, cityId);
return { AND: [where, { cityId }] };
}
const scoped = hqStoreCityWhere(scope);
if (!scoped) return where;
return { AND: [where, scoped] };
}
export function assertHqCityInScope(scope: HqCityScope, cityId: bigint) {
if (scope === null) return;
if (!scope.some((id) => id === cityId)) {
throw new ForbiddenException('无权访问该城市的门店');
}
}
@Injectable() @Injectable()
export class HqPermissionsResolver { export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -41,39 +76,45 @@ export class HqPermissionsResolver {
async resolveAccess(actorId: bigint): Promise<{ async resolveAccess(actorId: bigint): Promise<{
keys: HqPermissionKey[]; keys: HqPermissionKey[];
isSuperAdmin: boolean; isSuperAdmin: boolean;
adminRole: HqAdminRoleValue;
}> { }> {
const account = await this.loadActiveAccount(actorId); const account = await this.loadActiveAccount(actorId);
const adminRole = account.adminRole as HqAdminRoleValue;
const userRows = await this.prisma.hqAccountPermission.findMany({ const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId }, where: { hqAccountId: actorId },
select: { permissionKey: true }, select: { permissionKey: true, effect: true },
}); });
const userKeys = userRows.map((r) => r.permissionKey); const grantKeys = userRows
.filter((r) => r.effect !== 'DENY')
.map((r) => r.permissionKey);
const denyKeys = userRows.filter((r) => r.effect === 'DENY').map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') { if (adminRole === 'SUPER_ADMIN') {
// 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
return { return {
isSuperAdmin: true, isSuperAdmin: true,
adminRole,
keys: expandHqPermissionKeys([ keys: expandHqPermissionKeys([
...HQ_PERMISSION_CATALOG.map((p) => p.key), ...HQ_PERMISSION_CATALOG.map((p) => p.key),
...userKeys, ...grantKeys,
]), ]),
}; };
} }
const roleRows = await this.prisma.hqRolePermission.findMany({ const roleRows = await this.prisma.hqRolePermission.findMany({
where: { adminRole: account.adminRole }, where: { adminRole },
select: { permissionKey: true }, select: { permissionKey: true },
}); });
const roleKeys = const roleKeys =
roleRows.length > 0 roleRows.length > 0
? roleRows.map((r) => r.permissionKey) ? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; : [...(HQ_ROLE_DEFAULT_PERMISSIONS[adminRole] ?? [])];
return { return {
isSuperAdmin: false, isSuperAdmin: false,
keys: expandHqPermissionKeys([...roleKeys, ...userKeys]), adminRole,
keys: computeHqEffectivePermissionKeys(roleKeys, grantKeys, denyKeys),
}; };
} }
@@ -81,6 +122,35 @@ export class HqPermissionsResolver {
const { keys } = await this.resolveAccess(actorId); const { keys } = await this.resolveAccess(actorId);
return keys; return keys;
} }
async resolveCityScope(actorId: bigint): Promise<HqCityScope> {
const account = await this.loadActiveAccount(actorId);
if (account.adminRole === 'SUPER_ADMIN') return null;
const rows = await this.prisma.hqAccountCity.findMany({
where: { hqAccountId: actorId },
select: { cityId: true },
});
if (!rows.length) {
return account.adminRole === 'CITY_STORE_SERVICE' ? [] : null;
}
return rows.map((r) => r.cityId);
}
async assertStoreCityInScope(actorId: bigint, cityId: bigint) {
const scope = await this.resolveCityScope(actorId);
assertHqCityInScope(scope, cityId);
}
async assertStoreIdInScope(actorId: bigint, storeId: bigint) {
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { cityId: true },
});
if (!store) {
throw new NotFoundException('门店不存在');
}
await this.assertStoreCityInScope(actorId, store.cityId);
}
} }
@Injectable() @Injectable()
@@ -17,6 +17,7 @@ export const HqOperationAction = {
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE', HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE', HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE', HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE', USER_DELETE: 'USER_DELETE',
USER_BATCH_DELETE: 'USER_BATCH_DELETE', USER_BATCH_DELETE: 'USER_BATCH_DELETE',
ORDER_SHIP: 'ORDER_SHIP', ORDER_SHIP: 'ORDER_SHIP',
@@ -141,6 +142,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员', [HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员', [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限', [HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
[HqOperationAction.USER_UPDATE]: '修改用户备注',
[HqOperationAction.USER_DELETE]: '删除用户', [HqOperationAction.USER_DELETE]: '删除用户',
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户', [HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
[HqOperationAction.ORDER_SHIP]: '订单发货', [HqOperationAction.ORDER_SHIP]: '订单发货',
@@ -87,7 +87,9 @@ export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
'运营:商品/开城/门店/订单/配送/权益。', '运营:商品/开城/门店/订单/配送/权益。',
'财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。', '财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。',
'客服:用户/订单、售后工单、发票协助。', '客服:用户/订单、售后工单、发票协助。',
'城市门店服务:门店列表/审核/评价/分类(可新增不可删除),且限制负责城市;概览只显示有权限的数据和负责城市。',
'超管:权限分配、技术支持评审、系统设置。', '超管:权限分配、技术支持评审、系统设置。',
'单账号可在角色之上追加或撤销权限;切换角色会清空账号级权限。',
].join('\n'), ].join('\n'),
}, },
{ {
@@ -0,0 +1,21 @@
import { Body, Controller, Param, Put, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { SaveHqListColumnPrefsDto } from './dto/auth.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
@Controller('admin/me')
@UseGuards(JwtAuthGuard)
export class AdminMeController {
constructor(private readonly authService: AuthService) {}
@Put('list-columns/:listKey')
saveListColumns(
@CurrentUser() user: AuthUser,
@Param('listKey') listKey: string,
@Body() dto: SaveHqListColumnPrefsDto,
) {
return this.authService.updateMyListColumnPrefs(user.actorType, user.actorId, listKey, dto);
}
}
@@ -10,7 +10,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ClientApp, SmsScene } from '@dukang/shared-types'; import { ClientApp, SmsScene, isHqListColumnKey, type HqListColumnPrefsMap } from '@dukang/shared-types';
import { generateUserNo } from '@dukang/domain'; import { generateUserNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service'; import { RedisService } from '../../common/redis/redis.service';
@@ -55,6 +55,34 @@ type UserRow = Pick<
avatar?: { url: string } | null; avatar?: { url: string } | null;
}; };
function parseColumnWidths(raw: unknown): Record<string, number> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const out: Record<string, number> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!key.trim()) continue;
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) continue;
out[key] = Math.min(960, Math.max(48, Math.round(n)));
}
return Object.keys(out).length ? out : undefined;
}
function parseListColumnPrefs(raw: unknown): HqListColumnPrefsMap {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out: HqListColumnPrefsMap = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!isHqListColumnKey(key) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
const row = value as { order?: unknown; hidden?: unknown; widths?: unknown };
const widths = parseColumnWidths(row.widths);
out[key] = {
order: Array.isArray(row.order) ? row.order.filter((v): v is string => typeof v === 'string') : [],
hidden: Array.isArray(row.hidden) ? row.hidden.filter((v): v is string => typeof v === 'string') : [],
...(widths ? { widths } : {}),
};
}
return out;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@@ -1207,11 +1235,58 @@ export class AuthService {
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } }); const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
if (!account) return null; if (!account) return null;
const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId); const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId);
return serializeBigInt({ ...account, permissionKeys }); const cityScope = await this.hqPermissions.resolveCityScope(actorId);
const { passwordHash: _passwordHash, ...safeAccount } = account;
return serializeBigInt({
...safeAccount,
listColumnPrefs: parseListColumnPrefs(account.listColumnPrefs),
permissionKeys,
cityIds: (cityScope ?? []).map((id) => id.toString()),
cityScoped: cityScope !== null,
});
} }
return null; return null;
} }
async updateMyListColumnPrefs(
actorType: string,
actorId: bigint,
listKey: string,
dto: { reset?: boolean; order?: string[]; hidden?: string[]; widths?: Record<string, number> },
) {
if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置');
if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表');
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { id: true, listColumnPrefs: true },
});
if (!account) throw new NotFoundException('账号不存在');
const current = parseListColumnPrefs(account.listColumnPrefs);
if (dto.reset) {
delete current[listKey];
} else {
const prev = current[listKey];
const widths =
dto.widths !== undefined ? parseColumnWidths(dto.widths) : prev?.widths;
current[listKey] = {
order:
dto.order !== undefined
? dto.order.filter((k) => typeof k === 'string' && k.trim())
: (prev?.order ?? []),
hidden:
dto.hidden !== undefined
? dto.hidden.filter((k) => typeof k === 'string' && k.trim())
: (prev?.hidden ?? []),
...(widths && Object.keys(widths).length ? { widths } : {}),
};
}
const updated = await this.prisma.hqAccount.update({
where: { id: actorId },
data: { listColumnPrefs: current },
});
return { listColumnPrefs: parseListColumnPrefs(updated.listColumnPrefs) };
}
wechatDisabled() { wechatDisabled() {
throw new NotImplementedException('FEATURE_DISABLED'); throw new NotImplementedException('FEATURE_DISABLED');
} }
@@ -1,4 +1,4 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
import { SmsScene } from '@dukang/shared-types'; import { SmsScene } from '@dukang/shared-types';
export class SendSmsDto { export class SendSmsDto {
@@ -129,3 +129,23 @@ export class CheckPartnerPhoneDto {
@IsNotEmpty() @IsNotEmpty()
phone: string; phone: string;
} }
export class SaveHqListColumnPrefsDto {
@IsOptional()
@IsBoolean()
reset?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
order?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
hidden?: string[];
@IsOptional()
@IsObject()
widths?: Record<string, number>;
}
@@ -16,6 +16,7 @@ import { PartnerStaffService } from './partner-staff.service';
import { StoreStaffController } from './store-staff.controller'; import { StoreStaffController } from './store-staff.controller';
import { StoreStaffService } from './store-staff.service'; import { StoreStaffService } from './store-staff.service';
import { AdminAuthController } from './admin-auth.controller'; import { AdminAuthController } from './admin-auth.controller';
import { AdminMeController } from './admin-me.controller';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard'; import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
@@ -50,6 +51,7 @@ import { CommonModule } from '../common/common.module';
UserProfileController, UserProfileController,
UserAddressController, UserAddressController,
AdminAuthController, AdminAuthController,
AdminMeController,
], ],
providers: [ providers: [
AuthService, AuthService,
@@ -33,7 +33,20 @@ export class AdminBenefitService {
take: pageSize, take: pageSize,
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true } }, order: {
select: {
id: true,
orderNo: true,
status: true,
productName: true,
productSpec: true,
quantity: true,
saleUnit: true,
deliveryType: true,
payAmount: true,
benefitAmount: true,
},
},
}, },
}), }),
this.prisma.benefitCoupon.count({ where }), this.prisma.benefitCoupon.count({ where }),
@@ -46,7 +59,20 @@ export class AdminBenefitService {
where: { id }, where: { id },
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true, payAmount: true } }, order: {
select: {
id: true,
orderNo: true,
status: true,
payAmount: true,
productName: true,
productSpec: true,
quantity: true,
saleUnit: true,
deliveryType: true,
benefitAmount: true,
},
},
}, },
}); });
if (!coupon) throw new NotFoundException('权益券不存在'); if (!coupon) throw new NotFoundException('权益券不存在');
@@ -7,6 +7,8 @@ import {
} from '../../common/guards/hq-permission.guard'; } from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AdminCitiesService } from './admin-cities.service'; import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto'; import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@@ -23,8 +25,8 @@ export class AdminCitiesController {
constructor(private readonly service: AdminCitiesService) {} constructor(private readonly service: AdminCitiesService) {}
@Get() @Get()
list(@Query() query: AdminCitiesQueryDto) { list(@CurrentUser() user: AuthUser, @Query() query: AdminCitiesQueryDto) {
return this.service.list(query); return this.service.list(query, user.actorId);
} }
@Get(':id/delete-preview') @Get(':id/delete-preview')
@@ -4,6 +4,7 @@ import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service'; import { PartnerCityService } from '../city-scope/partner-city.service';
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
import type { AdminCitiesQueryDto } from './dto/admin-query.dto'; import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@@ -12,9 +13,10 @@ export class AdminCitiesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService, private readonly partnerCityService: PartnerCityService,
private readonly hqPermissions: HqPermissionsResolver,
) {} ) {}
async list(query: AdminCitiesQueryDto) { async list(query: AdminCitiesQueryDto, actorId?: bigint) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonCityWhereInput = {}; const where: Prisma.CommonCityWhereInput = {};
@@ -24,6 +26,12 @@ export class AdminCitiesService {
if (query.partnerId) { if (query.partnerId) {
where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } }; where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } };
} }
if (actorId) {
const scope = await this.hqPermissions.resolveCityScope(actorId);
if (scope !== null) {
where.id = { in: scope.length ? scope : [BigInt(0)] };
}
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.commonCity.findMany({ this.prisma.commonCity.findMany({
@@ -1,22 +1,32 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AdminDashboardService } from './admin-dashboard.service'; import { AdminDashboardService } from './admin-dashboard.service';
import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto'; import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
@Controller('admin/dashboard') @Controller('admin/dashboard')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('dashboard')
export class AdminDashboardController { export class AdminDashboardController {
constructor(private readonly dashboardService: AdminDashboardService) {} constructor(private readonly dashboardService: AdminDashboardService) {}
@Get('stats') @Get('stats')
stats() { stats(@CurrentUser() user: AuthUser) {
return this.dashboardService.getStats(); return this.dashboardService.getStats(user.actorId);
} }
@Get('analytics') @Get('analytics')
analytics(@Query() query: AdminDashboardAnalyticsQueryDto) { analytics(
return this.dashboardService.getAnalytics(query); @CurrentUser() user: AuthUser,
@Query() query: AdminDashboardAnalyticsQueryDto,
) {
return this.dashboardService.getAnalytics(user.actorId, query);
} }
@Get('version') @Get('version')
@@ -1,6 +1,13 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { HqPermissionKey } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import {
HqPermissionsResolver,
assertHqCityInScope,
hqStoreCityWhere,
type HqCityScope,
} from '../../common/guards/hq-permission.guard';
import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto'; import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
function startOfDay(d: Date) { function startOfDay(d: Date) {
@@ -55,14 +62,63 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
return now.getTime() > deadline.getTime(); return now.getTime() > deadline.getTime();
} }
function cityIdFilter(scope: HqCityScope): Prisma.BigIntFilter | undefined {
if (scope === null) return undefined;
if (!scope.length) return { equals: BigInt(0) };
return { in: scope };
}
function emptyRows<T>(): Promise<T[]> {
return Promise.resolve([] as T[]);
}
@Injectable() @Injectable()
export class AdminDashboardService { export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly hqPermissions: HqPermissionsResolver,
) {}
async getStats() { async getStats(actorId: bigint) {
const todayStart = new Date(); const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0); todayStart.setHours(0, 0, 0, 0);
const [{ keys, isSuperAdmin }, scope] = await Promise.all([
this.hqPermissions.resolveAccess(actorId),
this.hqPermissions.resolveCityScope(actorId),
]);
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
const storeWhere = hqStoreCityWhere(scope) ?? {};
const cityFilter = cityIdFilter(scope);
const orderWhere: Prisma.OrderWhereInput = cityFilter ? { cityId: cityFilter } : {};
const partnerWhere: Prisma.PartnerAccountWhereInput = {
isPrimary: 1,
...(cityFilter ? { cityId: cityFilter } : {}),
};
let userWhere: Prisma.UserWhereInput = { status: 1, mergedIntoUserId: null };
if (scope !== null) {
if (!scope.length) {
userWhere = { id: { equals: BigInt(0) } };
} else {
const cities = await this.prisma.commonCity.findMany({
where: { id: { in: scope } },
select: { code: true },
});
const codes = cities.map((c) => c.code);
userWhere = {
status: 1,
mergedIntoUserId: null,
cityPreference: { selectedCityCode: { in: codes.length ? codes : [''] } },
};
}
}
const redeemTodayWhere: Prisma.RedeemRecordWhereInput = {
createdAt: { gte: todayStart },
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
};
const [ const [
usersTotal, usersTotal,
guestUsers, guestUsers,
@@ -79,32 +135,112 @@ export class AdminDashboardService {
pendingPartnerDraftBills, pendingPartnerDraftBills,
openTickets, openTickets,
pendingWithdrawRows, pendingWithdrawRows,
pendingStoreOnboard,
pendingStorePackageAudits,
pendingStoreInfoChanges,
] = await Promise.all([ ] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), can('users')
this.prisma.user.count({ ? this.prisma.user.count({ where: userWhere })
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null }, : Promise.resolve(0),
}), can('users')
this.prisma.user.count({ ? this.prisma.user.count({
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } }, where: { ...userWhere, phoneVerifiedAt: null },
}), })
this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }), : Promise.resolve(0),
this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }), can('users')
this.prisma.order.groupBy({ ? this.prisma.user.count({
by: ['status'], where: { ...userWhere, phoneVerifiedAt: { not: null } },
_count: { status: true }, })
}), : Promise.resolve(0),
this.prisma.store.count(), can('users')
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }), ? this.prisma.user.count({
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), where:
this.prisma.orderDelivery.count(), scope === null
this.prisma.storePayout.count({ where: { status: 'PENDING' } }), ? { mergedIntoUserId: { not: null } }
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }), : { id: { equals: BigInt(0) } },
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }), })
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), : Promise.resolve(0),
this.prisma.storeWithdrawRequest.findMany({ can('orders')
where: { status: 'PENDING_REVIEW' }, ? this.prisma.order.count({
select: { appliedAt: true }, where: { ...orderWhere, createdAt: { gte: todayStart } },
}), })
: Promise.resolve(0),
can('orders')
? this.prisma.order.groupBy({
by: ['status'],
where: orderWhere,
_count: { status: true },
})
: emptyRows<{ status: string; _count: { status: number } }>(),
can('stores') ? this.prisma.store.count({ where: storeWhere }) : Promise.resolve(0),
can('partners')
? this.prisma.partnerAccount.count({ where: partnerWhere })
: Promise.resolve(0),
can('benefit')
? this.prisma.redeemRecord.count({ where: redeemTodayWhere })
: Promise.resolve(0),
can('deliveries')
? this.prisma.orderDelivery.count({
where: cityFilter ? { order: { cityId: cityFilter } } : undefined,
})
: Promise.resolve(0),
can('finance')
? this.prisma.storePayout.count({
where: {
status: 'PENDING',
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
},
})
: Promise.resolve(0),
can('finance')
? this.prisma.partnerBill.count({
where: {
status: 'UNPAID',
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
},
})
: Promise.resolve(0),
can('finance')
? this.prisma.partnerBill.count({
where: {
status: 'PENDING_REVIEW',
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
},
})
: Promise.resolve(0),
can('tickets') && scope === null
? this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } })
: Promise.resolve(0),
can('finance')
? this.prisma.storeWithdrawRequest.findMany({
where: {
status: 'PENDING_REVIEW',
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
},
select: { appliedAt: true },
})
: emptyRows<{ appliedAt: Date }>(),
can('stores')
? this.prisma.store.count({
where: { auditStatus: 'PENDING', ...storeWhere },
})
: Promise.resolve(0),
can('store_audits')
? this.prisma.storePackageChangeRequest.count({
where: {
status: 'PENDING',
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
},
})
: Promise.resolve(0),
can('store_audits')
? this.prisma.storeInfoChangeRequest.count({
where: {
status: 'PENDING',
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
},
})
: Promise.resolve(0),
]); ]);
const now = new Date(); const now = new Date();
@@ -129,6 +265,9 @@ export class AdminDashboardService {
openTickets, openTickets,
pendingStoreWithdrawals, pendingStoreWithdrawals,
overdueStoreWithdrawals, overdueStoreWithdrawals,
pendingStoreOnboard,
pendingStorePackageAudits,
pendingStoreInfoChanges,
ordersByStatus: ordersByStatus.map((row) => ({ ordersByStatus: ordersByStatus.map((row) => ({
status: row.status, status: row.status,
count: row._count.status, count: row._count.status,
@@ -152,7 +291,7 @@ export class AdminDashboardService {
}; };
} }
async getAnalytics(query: AdminDashboardAnalyticsQueryDto) { async getAnalytics(actorId: bigint, query: AdminDashboardAnalyticsQueryDto) {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const defaultFrom = new Date(today); const defaultFrom = new Date(today);
defaultFrom.setDate(defaultFrom.getDate() - 29); defaultFrom.setDate(defaultFrom.getDate() - 29);
@@ -164,9 +303,18 @@ export class AdminDashboardService {
const rangeStart = startOfDay(from <= to ? from : to); const rangeStart = startOfDay(from <= to ? from : to);
const rangeEnd = endOfDay(from <= to ? to : from); const rangeEnd = endOfDay(from <= to ? to : from);
let filterCityCode: string | null | undefined; const [{ keys, isSuperAdmin }, scope] = await Promise.all([
let filterCityId: bigint | null | undefined; this.hqPermissions.resolveAccess(actorId),
this.hqPermissions.resolveCityScope(actorId),
]);
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
let filterCityCode: string | string[] | null | undefined;
let filterCityId: bigint | bigint[] | null | undefined;
if (query.cityId === 'none') { if (query.cityId === 'none') {
if (scope !== null) {
throw new ForbiddenException('无权按未选城筛选');
}
filterCityCode = null; filterCityCode = null;
filterCityId = null; filterCityId = null;
} else if (query.cityId) { } else if (query.cityId) {
@@ -174,20 +322,47 @@ export class AdminDashboardService {
where: { id: BigInt(query.cityId) }, where: { id: BigInt(query.cityId) },
select: { id: true, code: true }, select: { id: true, code: true },
}); });
if (city) { if (!city) {
filterCityCode = city.code; throw new ForbiddenException('无权访问该城市的门店');
filterCityId = city.id; }
assertHqCityInScope(scope, city.id);
filterCityCode = city.code;
filterCityId = city.id;
} else if (scope !== null) {
if (!scope.length) {
filterCityId = [];
filterCityCode = [];
} else {
const scopedCities = await this.prisma.commonCity.findMany({
where: { id: { in: scope } },
select: { id: true, code: true },
});
filterCityId = scope;
filterCityCode = scopedCities.map((c) => c.code);
} }
} }
const filterPromoNone = query.promoCodeId === 'none'; const filterPromoNone = query.promoCodeId === 'none';
const filterPromoId = const filterPromoId =
query.promoCodeId && query.promoCodeId !== 'none' can('promo_codes') && query.promoCodeId && query.promoCodeId !== 'none'
? BigInt(query.promoCodeId) ? BigInt(query.promoCodeId)
: undefined; : undefined;
const filterPartnerId = query.partnerAccountId const filterPartnerId =
? BigInt(query.partnerAccountId) can('partners') && query.partnerAccountId
: undefined; ? BigInt(query.partnerAccountId)
: undefined;
if (filterPartnerId !== undefined && scope !== null) {
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: filterPartnerId },
select: { cityId: true },
});
if (partner?.cityId) {
assertHqCityInScope(scope, partner.cityId);
} else if (scope !== null) {
throw new ForbiddenException('无权访问该城市的门店');
}
}
const userWhere: Prisma.UserWhereInput = { const userWhere: Prisma.UserWhereInput = {
status: 1, status: 1,
@@ -199,10 +374,14 @@ export class AdminDashboardService {
{ cityPreference: null }, { cityPreference: null },
{ cityPreference: { selectedCityCode: null } }, { cityPreference: { selectedCityCode: null } },
]; ];
} else if (Array.isArray(filterCityCode)) {
userWhere.cityPreference = {
selectedCityCode: { in: filterCityCode.length ? filterCityCode : [''] },
};
} else if (filterCityCode) { } else if (filterCityCode) {
userWhere.cityPreference = { selectedCityCode: filterCityCode }; userWhere.cityPreference = { selectedCityCode: filterCityCode };
} }
if (filterPromoNone) { if (can('promo_codes') && filterPromoNone) {
userWhere.promoTouch = { is: null }; userWhere.promoTouch = { is: null };
} else if (filterPromoId !== undefined) { } else if (filterPromoId !== undefined) {
userWhere.promoTouch = { promoCodeId: filterPromoId }; userWhere.promoTouch = { promoCodeId: filterPromoId };
@@ -211,10 +390,14 @@ export class AdminDashboardService {
const orderWhere: Prisma.OrderWhereInput = { const orderWhere: Prisma.OrderWhereInput = {
createdAt: { gte: rangeStart, lte: rangeEnd }, createdAt: { gte: rangeStart, lte: rangeEnd },
}; };
if (filterCityId !== undefined && filterCityId !== null) { if (filterCityId === null) {
orderWhere.id = { in: [] };
} else if (Array.isArray(filterCityId)) {
orderWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
} else if (filterCityId !== undefined) {
orderWhere.cityId = filterCityId; orderWhere.cityId = filterCityId;
} }
if (filterPromoNone) { if (can('promo_codes') && filterPromoNone) {
orderWhere.promoCodeId = null; orderWhere.promoCodeId = null;
} else if (filterPromoId !== undefined) { } else if (filterPromoId !== undefined) {
orderWhere.promoCodeId = filterPromoId; orderWhere.promoCodeId = filterPromoId;
@@ -226,6 +409,8 @@ export class AdminDashboardService {
}; };
if (filterCityId === null) { if (filterCityId === null) {
partnerWhere.cityId = null; partnerWhere.cityId = null;
} else if (Array.isArray(filterCityId)) {
partnerWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
} else if (filterCityId !== undefined) { } else if (filterCityId !== undefined) {
partnerWhere.cityId = filterCityId; partnerWhere.cityId = filterCityId;
} }
@@ -237,8 +422,9 @@ export class AdminDashboardService {
createdAt: { gte: rangeStart, lte: rangeEnd }, createdAt: { gte: rangeStart, lte: rangeEnd },
}; };
if (filterCityId === null) { if (filterCityId === null) {
// 门店必有 cityId
storeWhere.id = { in: [] }; storeWhere.id = { in: [] };
} else if (Array.isArray(filterCityId)) {
storeWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
} else if (filterCityId !== undefined) { } else if (filterCityId !== undefined) {
storeWhere.cityId = filterCityId; storeWhere.cityId = filterCityId;
} }
@@ -253,28 +439,56 @@ export class AdminDashboardService {
redeemWhere.id = { in: [] }; redeemWhere.id = { in: [] };
} else { } else {
const storeFilter: Prisma.StoreWhereInput = {}; const storeFilter: Prisma.StoreWhereInput = {};
if (filterCityId !== undefined) storeFilter.cityId = filterCityId; if (Array.isArray(filterCityId)) {
storeFilter.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
} else if (filterCityId !== undefined) {
storeFilter.cityId = filterCityId;
}
if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId; if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId;
if (Object.keys(storeFilter).length) { if (Object.keys(storeFilter).length) {
redeemWhere.store = storeFilter; redeemWhere.store = storeFilter;
} }
} }
const skipOrders = filterCityId === null; const skipOrders = filterCityId === null || !can('orders');
const cityListWhere: Prisma.CommonCityWhereInput =
scope === null ? {} : { id: { in: scope.length ? scope : [BigInt(0)] } };
const [users, orders, partners, stores, redeems, cities, promos, partnerNames] = const [users, orders, partners, stores, redeems, cities, promos, partnerNames] =
await Promise.all([ await Promise.all([
this.prisma.user.findMany({ can('users')
where: userWhere, ? this.prisma.user.findMany({
select: { where: userWhere,
id: true, select: {
createdAt: true, id: true,
cityPreference: { select: { selectedCityCode: true } }, createdAt: true,
promoTouch: { select: { promoCodeId: true } }, cityPreference: { select: { selectedCityCode: true } },
}, promoTouch: { select: { promoCodeId: true } },
}), },
})
: emptyRows<
Prisma.UserGetPayload<{
select: {
id: true;
createdAt: true;
cityPreference: { select: { selectedCityCode: true } };
promoTouch: { select: { promoCodeId: true } };
};
}>
>(),
skipOrders skipOrders
? Promise.resolve([]) ? emptyRows<
Prisma.OrderGetPayload<{
select: {
id: true;
userId: true;
createdAt: true;
cityId: true;
promoCodeId: true;
payStatus: true;
};
}>
>()
: this.prisma.order.findMany({ : this.prisma.order.findMany({
where: orderWhere, where: orderWhere,
select: { select: {
@@ -286,45 +500,81 @@ export class AdminDashboardService {
payStatus: true, payStatus: true,
}, },
}), }),
this.prisma.partnerAccount.findMany({ can('partners')
where: partnerWhere, ? this.prisma.partnerAccount.findMany({
select: { where: partnerWhere,
id: true, select: {
createdAt: true, id: true,
cityId: true, createdAt: true,
companyName: true, cityId: true,
name: true, companyName: true,
}, name: true,
}), },
this.prisma.store.findMany({ })
where: storeWhere, : emptyRows<
select: { Prisma.PartnerAccountGetPayload<{
id: true, select: { id: true; createdAt: true; cityId: true; companyName: true; name: true };
createdAt: true, }>
cityId: true, >(),
partnerAccountId: true, can('stores')
}, ? this.prisma.store.findMany({
}), where: storeWhere,
this.prisma.redeemRecord.findMany({ select: {
where: redeemWhere, id: true,
select: { createdAt: true,
id: true, cityId: true,
createdAt: true, partnerAccountId: true,
amount: true, },
settleAmount: true, })
store: { select: { cityId: true, partnerAccountId: true } }, : emptyRows<
}, Prisma.StoreGetPayload<{
}), select: { id: true; createdAt: true; cityId: true; partnerAccountId: true };
}>
>(),
can('benefit')
? this.prisma.redeemRecord.findMany({
where: redeemWhere,
select: {
id: true,
createdAt: true,
amount: true,
settleAmount: true,
store: { select: { cityId: true, partnerAccountId: true } },
},
})
: emptyRows<
Prisma.RedeemRecordGetPayload<{
select: {
id: true;
createdAt: true;
amount: true;
settleAmount: true;
store: { select: { cityId: true; partnerAccountId: true } };
};
}>
>(),
this.prisma.commonCity.findMany({ this.prisma.commonCity.findMany({
where: cityListWhere,
select: { id: true, code: true, name: true }, select: { id: true, code: true, name: true },
}), }),
this.prisma.commonPromoCode.findMany({ can('promo_codes')
select: { id: true, code: true, name: true }, ? this.prisma.commonPromoCode.findMany({
}), select: { id: true, code: true, name: true },
this.prisma.partnerAccount.findMany({ })
where: { isPrimary: 1 }, : emptyRows<Prisma.CommonPromoCodeGetPayload<{ select: { id: true; code: true; name: true } }>>(),
select: { id: true, companyName: true, name: true }, can('partners') || can('stores') || can('benefit')
}), ? this.prisma.partnerAccount.findMany({
where: {
isPrimary: 1,
...(cityIdFilter(scope) ? { cityId: cityIdFilter(scope) } : {}),
},
select: { id: true, companyName: true, name: true },
})
: emptyRows<
Prisma.PartnerAccountGetPayload<{
select: { id: true; companyName: true; name: true };
}>
>(),
]); ]);
const cityByCode = new Map(cities.map((c) => [c.code, c])); const cityByCode = new Map(cities.map((c) => [c.code, c]));
@@ -1,11 +1,25 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { HqAdminRoleValue } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto'; import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto'; import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
import { hashPassword } from '../../common/crypto/password.util'; import { hashPassword } from '../../common/crypto/password.util';
const HQ_ACCOUNT_SELECT = {
id: true,
phone: true,
loginName: true,
passwordHash: true,
name: true,
adminRole: true,
status: true,
lastLoginAt: true,
createdAt: true,
cities: { select: { cityId: true } },
} satisfies Prisma.HqAccountSelect;
function mapHqAccountRow(account: { function mapHqAccountRow(account: {
id: bigint; id: bigint;
phone: string; phone: string;
@@ -16,6 +30,7 @@ function mapHqAccountRow(account: {
status: string; status: string;
lastLoginAt: Date | null; lastLoginAt: Date | null;
createdAt: Date; createdAt: Date;
cities?: { cityId: bigint }[];
}) { }) {
return { return {
id: account.id, id: account.id,
@@ -27,6 +42,7 @@ function mapHqAccountRow(account: {
status: account.status, status: account.status,
lastLoginAt: account.lastLoginAt, lastLoginAt: account.lastLoginAt,
createdAt: account.createdAt, createdAt: account.createdAt,
cityIds: (account.cities ?? []).map((c) => c.cityId.toString()),
}; };
} }
@@ -34,6 +50,31 @@ function mapHqAccountRow(account: {
export class AdminHqAccountsService { export class AdminHqAccountsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
private async assertCityIds(cityIds: string[]) {
const unique = [...new Set(cityIds.map((id) => id.trim()).filter(Boolean))];
if (!unique.length) return [] as bigint[];
const ids = unique.map((id) => BigInt(id));
const count = await this.prisma.commonCity.count({ where: { id: { in: ids } } });
if (count !== ids.length) {
throw new BadRequestException('存在无效城市');
}
return ids;
}
private assertCityRequirement(adminRole: string, cityIds: bigint[]) {
if (adminRole === 'CITY_STORE_SERVICE' && !cityIds.length) {
throw new BadRequestException('城市门店服务须至少勾选一个负责城市');
}
}
private async replaceCities(tx: Prisma.TransactionClient, hqAccountId: bigint, cityIds: bigint[]) {
await tx.hqAccountCity.deleteMany({ where: { hqAccountId } });
if (!cityIds.length) return;
await tx.hqAccountCity.createMany({
data: cityIds.map((cityId) => ({ hqAccountId, cityId })),
});
}
async list(query: AdminHqAccountsQueryDto) { async list(query: AdminHqAccountsQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
@@ -48,17 +89,7 @@ export class AdminHqAccountsService {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
select: { select: HQ_ACCOUNT_SELECT,
id: true,
phone: true,
loginName: true,
passwordHash: true,
name: true,
adminRole: true,
status: true,
lastLoginAt: true,
createdAt: true,
},
}), }),
this.prisma.hqAccount.count({ where }), this.prisma.hqAccount.count({ where }),
]); ]);
@@ -73,34 +104,31 @@ export class AdminHqAccountsService {
async detail(id: bigint) { async detail(id: bigint) {
const account = await this.prisma.hqAccount.findUnique({ const account = await this.prisma.hqAccount.findUnique({
where: { id }, where: { id },
select: { select: HQ_ACCOUNT_SELECT,
id: true,
phone: true,
loginName: true,
passwordHash: true,
name: true,
adminRole: true,
status: true,
lastLoginAt: true,
createdAt: true,
},
}); });
if (!account) throw new NotFoundException('HQ 账号不存在'); if (!account) throw new NotFoundException('HQ 账号不存在');
return serializeBigInt(mapHqAccountRow(account)); return serializeBigInt(mapHqAccountRow(account));
} }
async create(dto: CreateHqAccountDto) { async create(dto: CreateHqAccountDto) {
const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; const adminRole = (dto.adminRole ?? 'OPS') as HqAdminRoleValue;
const cityIds = await this.assertCityIds(dto.cityIds ?? []);
this.assertCityRequirement(adminRole, cityIds);
if (dto.credentialType === 'phone') { if (dto.credentialType === 'phone') {
if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号'); if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号');
const phone = dto.phone.trim(); const phone = dto.phone.trim();
const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); const exists = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (exists) throw new BadRequestException('手机号已存在'); if (exists) throw new BadRequestException('手机号已存在');
const account = await this.prisma.hqAccount.create({ const account = await this.prisma.$transaction(async (tx) => {
data: { phone, name: dto.name, adminRole }, const created = await tx.hqAccount.create({
data: { phone, name: dto.name, adminRole },
select: HQ_ACCOUNT_SELECT,
});
await this.replaceCities(tx, created.id, cityIds);
return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT });
}); });
return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null })); return serializeBigInt(mapHqAccountRow(account));
} }
if (!dto.loginName?.trim() || !dto.password) { if (!dto.loginName?.trim() || !dto.password) {
@@ -114,27 +142,34 @@ export class AdminHqAccountsService {
const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } }); const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('手机号已存在'); if (phoneTaken) throw new BadRequestException('手机号已存在');
const account = await this.prisma.hqAccount.create({ const account = await this.prisma.$transaction(async (tx) => {
data: { const created = await tx.hqAccount.create({
phone, data: {
loginName, phone,
passwordHash: hashPassword(dto.password), loginName,
name: dto.name, passwordHash: hashPassword(dto.password!),
adminRole, name: dto.name,
}, adminRole,
},
select: HQ_ACCOUNT_SELECT,
});
await this.replaceCities(tx, created.id, cityIds);
return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT });
}); });
return serializeBigInt(mapHqAccountRow(account)); return serializeBigInt(mapHqAccountRow(account));
} }
async update(id: bigint, dto: UpdateHqAccountDto) { async update(id: bigint, dto: UpdateHqAccountDto) {
const current = await this.prisma.hqAccount.findUnique({ where: { id } }); const current = await this.prisma.hqAccount.findUnique({
where: { id },
include: { cities: { select: { cityId: true } } },
});
if (!current) throw new NotFoundException('HQ 账号不存在'); if (!current) throw new NotFoundException('HQ 账号不存在');
if (dto.loginName !== undefined) { const loginNameInput = dto.loginName === undefined ? undefined : dto.loginName.trim();
const loginName = dto.loginName.trim(); if (loginNameInput) {
if (!loginName) throw new BadRequestException('用户名不能为空');
const conflict = await this.prisma.hqAccount.findFirst({ const conflict = await this.prisma.hqAccount.findFirst({
where: { loginName, id: { not: id } }, where: { loginName: loginNameInput, id: { not: id } },
}); });
if (conflict) throw new BadRequestException('用户名已存在'); if (conflict) throw new BadRequestException('用户名已存在');
} }
@@ -150,29 +185,34 @@ export class AdminHqAccountsService {
} }
} }
const account = await this.prisma.hqAccount.update({ const nextRole = (dto.adminRole ?? current.adminRole) as HqAdminRoleValue;
where: { id }, const nextCityIds =
data: { dto.cityIds !== undefined
...(dto.name !== undefined ? { name: dto.name } : {}), ? await this.assertCityIds(dto.cityIds)
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}), : current.cities.map((c) => c.cityId);
...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}), this.assertCityRequirement(nextRole, nextCityIds);
...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}),
...(dto.adminRole !== undefined const roleChanged = dto.adminRole !== undefined && dto.adminRole !== current.adminRole;
? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }
: {}), const account = await this.prisma.$transaction(async (tx) => {
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), await tx.hqAccount.update({
}, where: { id },
select: { data: {
id: true, ...(dto.name !== undefined ? { name: dto.name } : {}),
phone: true, ...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
loginName: true, ...(loginNameInput ? { loginName: loginNameInput } : {}),
passwordHash: true, ...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}),
name: true, ...(dto.adminRole !== undefined ? { adminRole: nextRole } : {}),
adminRole: true, ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
status: true, },
lastLoginAt: true, });
createdAt: true, if (dto.cityIds !== undefined) {
}, await this.replaceCities(tx, id, nextCityIds);
}
if (roleChanged) {
await tx.hqAccountPermission.deleteMany({ where: { hqAccountId: id } });
}
return tx.hqAccount.findUniqueOrThrow({ where: { id }, select: HQ_ACCOUNT_SELECT });
}); });
return serializeBigInt(mapHqAccountRow(account)); return serializeBigInt(mapHqAccountRow(account));
} }
@@ -45,6 +45,7 @@ export class AdminHqPermissionsController {
includeBody: true, includeBody: true,
}) })
saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) { saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) {
return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys); const grantKeys = dto.grantKeys ?? dto.permissionKeys ?? [];
return this.service.saveAccountPermissions(BigInt(id), grantKeys, dto.denyKeys ?? []);
} }
} }
@@ -1,9 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { import {
HQ_ADMIN_ROLE_VALUES,
HQ_PERMISSION_CATALOG, HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY, LEGACY_SYSTEM_SETTINGS_KEY,
computeHqEffectivePermissionKeys,
expandHqPermissionKeys, expandHqPermissionKeys,
type HqAdminRoleValue,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
@@ -14,6 +17,10 @@ const VALID_PERMISSION_KEYS = new Set<string>([
LEGACY_SYSTEM_SETTINGS_KEY, LEGACY_SYSTEM_SETTINGS_KEY,
]); ]);
const EDITABLE_ROLES = new Set<HqAdminRoleValue>(
HQ_ADMIN_ROLE_VALUES.filter((r) => r !== 'SUPER_ADMIN'),
);
function assertPermissionKeys(keys: string[]) { function assertPermissionKeys(keys: string[]) {
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key)); const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
if (invalid.length) { if (invalid.length) {
@@ -21,6 +28,13 @@ function assertPermissionKeys(keys: string[]) {
} }
} }
function asAdminRole(role: string): HqAdminRoleValue {
if (!(HQ_ADMIN_ROLE_VALUES as readonly string[]).includes(role)) {
throw new BadRequestException(`无效角色: ${role}`);
}
return role as HqAdminRoleValue;
}
@Injectable() @Injectable()
export class AdminHqPermissionsService { export class AdminHqPermissionsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -28,32 +42,36 @@ export class AdminHqPermissionsService {
catalog() { catalog() {
return { return {
permissions: HQ_PERMISSION_CATALOG, permissions: HQ_PERMISSION_CATALOG,
roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({ roles: HQ_ADMIN_ROLE_VALUES.map((role) => ({
role, role,
permissionKeys, permissionKeys: HQ_ROLE_DEFAULT_PERMISSIONS[role],
})), })),
}; };
} }
async getRolePermissions(role: string) { async getRolePermissions(role: string) {
const adminRole = asAdminRole(role);
const rows = await this.prisma.hqRolePermission.findMany({ const rows = await this.prisma.hqRolePermission.findMany({
where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }, where: { adminRole },
select: { permissionKey: true }, select: { permissionKey: true },
}); });
const permissionKeys = const permissionKeys =
rows.length > 0 rows.length > 0
? expandHqPermissionKeys(rows.map((r) => r.permissionKey)) ? expandHqPermissionKeys(rows.map((r) => r.permissionKey))
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])]; : [...(HQ_ROLE_DEFAULT_PERMISSIONS[adminRole] ?? [])];
return { role, permissionKeys }; return { role: adminRole, permissionKeys };
} }
async saveRolePermissions(role: string, permissionKeys: string[]) { async saveRolePermissions(role: string, permissionKeys: string[]) {
if (role === 'SUPER_ADMIN') { const adminRole = asAdminRole(role);
if (adminRole === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权'); throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
} }
if (!EDITABLE_ROLES.has(adminRole)) {
throw new BadRequestException(`无效角色: ${role}`);
}
assertPermissionKeys(permissionKeys); assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys);
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }), this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
...(normalized.length ...(normalized.length
@@ -76,51 +94,84 @@ export class AdminHqPermissionsService {
const userPerms = await this.prisma.hqAccountPermission.findMany({ const userPerms = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId }, where: { hqAccountId: accountId },
select: { permissionKey: true }, select: { permissionKey: true, effect: true },
}); });
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey)); const grantKeys = expandHqPermissionKeys(
userPerms.filter((p) => p.effect !== 'DENY').map((p) => p.permissionKey),
);
const denyKeys = expandHqPermissionKeys(
userPerms.filter((p) => p.effect === 'DENY').map((p) => p.permissionKey),
);
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map( const rolePermissionKeys = HQ_PERMISSION_CATALOG.map((p) => p.key) as HqPermissionKey[];
(p) => p.key, const effectivePermissionKeys = computeHqEffectivePermissionKeys(
) as HqPermissionKey[]; rolePermissionKeys,
const effectivePermissionKeys = [ grantKeys,
...new Set([...rolePermissionKeys, ...userPermissionKeys]), [],
] as HqPermissionKey[]; );
return serializeBigInt({ return serializeBigInt({
account, account,
permissionKeys: userPermissionKeys, permissionKeys: grantKeys,
rolePermissionKeys, rolePermissionKeys,
userPermissionKeys, grantKeys,
denyKeys: [] as HqPermissionKey[],
userPermissionKeys: grantKeys,
effectivePermissionKeys, effectivePermissionKeys,
}); });
} }
const rolePerms = await this.getRolePermissions(account.adminRole); const rolePerms = await this.getRolePermissions(account.adminRole);
const effectivePermissionKeys = [ const effectivePermissionKeys = computeHqEffectivePermissionKeys(
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), rolePerms.permissionKeys,
] as HqPermissionKey[]; grantKeys,
denyKeys,
);
return serializeBigInt({ return serializeBigInt({
account, account,
permissionKeys: userPermissionKeys, permissionKeys: grantKeys,
rolePermissionKeys: rolePerms.permissionKeys, rolePermissionKeys: rolePerms.permissionKeys,
userPermissionKeys, grantKeys,
denyKeys,
userPermissionKeys: grantKeys,
effectivePermissionKeys, effectivePermissionKeys,
}); });
} }
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) { async saveAccountPermissions(
accountId: bigint,
grantKeysInput: string[],
denyKeysInput: string[] = [],
) {
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } }); const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('HQ 账号不存在'); if (!account) throw new NotFoundException('HQ 账号不存在');
assertPermissionKeys(permissionKeys); assertPermissionKeys(grantKeysInput);
const normalized = expandHqPermissionKeys(permissionKeys); assertPermissionKeys(denyKeysInput);
const grantKeys = expandHqPermissionKeys(grantKeysInput);
const denyKeys =
account.adminRole === 'SUPER_ADMIN' ? [] : expandHqPermissionKeys(denyKeysInput);
const overlap = grantKeys.filter((k) => denyKeys.includes(k));
if (overlap.length) {
throw new BadRequestException(`同一权限不能同时追加和撤销: ${overlap.join(', ')}`);
}
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }), this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
...(normalized.length ...(grantKeys.length || denyKeys.length
? [ ? [
this.prisma.hqAccountPermission.createMany({ this.prisma.hqAccountPermission.createMany({
data: normalized.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })), data: [
...grantKeys.map((permissionKey) => ({
hqAccountId: accountId,
permissionKey,
effect: 'GRANT' as const,
})),
...denyKeys.map((permissionKey) => ({
hqAccountId: accountId,
permissionKey,
effect: 'DENY' as const,
})),
],
}), }),
] ]
: []), : []),
@@ -1,5 +1,9 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { StoreCategoryService } from '../store/store-category.service'; import { StoreCategoryService } from '../store/store-category.service';
@@ -9,7 +13,8 @@ import {
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
@Controller('admin/store-categories') @Controller('admin/store-categories')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('store_categories')
export class AdminStoreCategoriesController { export class AdminStoreCategoriesController {
constructor(private readonly categories: StoreCategoryService) {} constructor(private readonly categories: StoreCategoryService) {}
@@ -24,6 +29,7 @@ export class AdminStoreCategoriesController {
} }
@Post('ensure-defaults') @Post('ensure-defaults')
@RequireHqPermissions('store_categories_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.STORE_CATEGORY_ENSURE, action: HqOperationAction.STORE_CATEGORY_ENSURE,
refType: 'STORE_CATEGORY', refType: 'STORE_CATEGORY',
@@ -57,6 +63,7 @@ export class AdminStoreCategoriesController {
} }
@Delete(':id') @Delete(':id')
@RequireHqPermissions('store_categories_delete')
@HqOperation({ @HqOperation({
action: HqOperationAction.STORE_CATEGORY_DELETE, action: HqOperationAction.STORE_CATEGORY_DELETE,
refType: 'STORE_CATEGORY', refType: 'STORE_CATEGORY',
@@ -1,15 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AdminStoreRatingsService } from './admin-store-ratings.service'; import { AdminStoreRatingsService } from './admin-store-ratings.service';
import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto'; import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
@Controller('admin/store-ratings') @Controller('admin/store-ratings')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('store_ratings')
export class AdminStoreRatingsController { export class AdminStoreRatingsController {
constructor(private readonly service: AdminStoreRatingsService) {} constructor(private readonly service: AdminStoreRatingsService) {}
@Get() @Get()
list(@Query() query: AdminStoreRatingsQueryDto) { list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreRatingsQueryDto) {
return this.service.list(query); return this.service.list(query, user.actorId);
} }
} }
@@ -3,15 +3,26 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto'; import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
import { HqPermissionsResolver, mergeHqStoreCityWhere } from '../../common/guards/hq-permission.guard';
@Injectable() @Injectable()
export class AdminStoreRatingsService { export class AdminStoreRatingsService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly hqPermissions: HqPermissionsResolver,
) {}
async list(query: AdminStoreRatingsQueryDto) { async list(query: AdminStoreRatingsQueryDto, actorId: bigint) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreRatingWhereInput = {}; const scope = await this.hqPermissions.resolveCityScope(actorId);
if (query.storeId) {
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
}
const storeWhere = mergeHqStoreCityWhere({}, scope, query.cityId);
const where: Prisma.StoreRatingWhereInput = {
store: storeWhere,
};
if (query.storeId) where.storeId = BigInt(query.storeId); if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.redeemNo) { if (query.redeemNo) {
where.redeemRecord = { redeemNo: { contains: query.redeemNo } }; where.redeemRecord = { redeemNo: { contains: query.redeemNo } };
@@ -1,7 +1,13 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AdminStoresService } from './admin-stores.service'; import { AdminStoresService } from './admin-stores.service';
import { import {
AdminStoreAccountsQueryDto, AdminStoreAccountsQueryDto,
@@ -19,58 +25,64 @@ import {
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
@Controller('admin/stores') @Controller('admin/stores')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('stores')
export class AdminStoresController { export class AdminStoresController {
constructor(private readonly service: AdminStoresService) {} constructor(private readonly service: AdminStoresService) {}
@Get() @Get()
list(@Query() query: AdminStoresQueryDto) { list(@CurrentUser() user: AuthUser, @Query() query: AdminStoresQueryDto) {
return this.service.listStores(query); return this.service.listStores(query, user.actorId);
} }
@Get(':id') @Get(':id')
detail(@Param('id') id: string) { detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.detailStore(BigInt(id)); return this.service.detailStore(BigInt(id), user.actorId);
} }
@Post() @Post()
@HqOperation({ action: HqOperationAction.STORE_CREATE, refType: 'STORE', refIdField: 'id', includeBody: true }) @HqOperation({ action: HqOperationAction.STORE_CREATE, refType: 'STORE', refIdField: 'id', includeBody: true })
create(@Body() dto: CreateStoreDto) { create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreDto) {
return this.service.createStore(dto); return this.service.createStore(dto, user.actorId);
} }
@Put(':id') @Put(':id')
@HqOperation({ action: HqOperationAction.STORE_UPDATE, refType: 'STORE', refIdParam: 'id', includeBody: true }) @HqOperation({ action: HqOperationAction.STORE_UPDATE, refType: 'STORE', refIdParam: 'id', includeBody: true })
update(@Param('id') id: string, @Body() dto: UpdateStoreDto) { update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreDto) {
return this.service.updateStore(BigInt(id), dto); return this.service.updateStore(BigInt(id), dto, user.actorId);
} }
@Put(':id/status') @Put(':id/status')
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true }) @HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) { updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
return this.service.updateStoreStatus(BigInt(id), dto); return this.service.updateStoreStatus(BigInt(id), dto, user.actorId);
} }
@Put(':id/audit') @Put(':id/audit')
@HqOperation({ action: HqOperationAction.STORE_AUDIT, refType: 'STORE', refIdParam: 'id', includeBody: true }) @HqOperation({ action: HqOperationAction.STORE_AUDIT, refType: 'STORE', refIdParam: 'id', includeBody: true })
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) { audit(
return this.service.auditStore(BigInt(id), body); @CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { approved: boolean; remark?: string },
) {
return this.service.auditStore(BigInt(id), body, user.actorId);
} }
} }
@Controller('admin/store-accounts') @Controller('admin/store-accounts')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('store_accounts')
export class AdminStoreAccountsController { export class AdminStoreAccountsController {
constructor(private readonly service: AdminStoresService) {} constructor(private readonly service: AdminStoresService) {}
@Get() @Get()
list(@Query() query: AdminStoreAccountsQueryDto) { list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreAccountsQueryDto) {
return this.service.listStoreAccounts(query); return this.service.listStoreAccounts(query, user.actorId);
} }
@Get(':id') @Get(':id')
detail(@Param('id') id: string) { detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.detailStoreAccount(BigInt(id)); return this.service.detailStoreAccount(BigInt(id), user.actorId);
} }
@Post() @Post()
@@ -80,8 +92,8 @@ export class AdminStoreAccountsController {
refIdField: 'id', refIdField: 'id',
includeBody: true, includeBody: true,
}) })
create(@Body() dto: CreateStoreAccountDto) { create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreAccountDto) {
return this.service.createStoreAccount(dto); return this.service.createStoreAccount(dto, user.actorId);
} }
@Put(':id') @Put(':id')
@@ -91,8 +103,8 @@ export class AdminStoreAccountsController {
refIdParam: 'id', refIdParam: 'id',
includeBody: true, includeBody: true,
}) })
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) { update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
return this.service.updateStoreAccount(BigInt(id), dto); return this.service.updateStoreAccount(BigInt(id), dto, user.actorId);
} }
@Delete(':id/staff/:staffId') @Delete(':id/staff/:staffId')
@@ -101,19 +113,20 @@ export class AdminStoreAccountsController {
refType: 'STORE_ACCOUNT', refType: 'STORE_ACCOUNT',
refIdParam: 'staffId', refIdParam: 'staffId',
}) })
deleteStaff(@Param('id') id: string, @Param('staffId') staffId: string) { deleteStaff(@CurrentUser() user: AuthUser, @Param('id') id: string, @Param('staffId') staffId: string) {
return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId)); return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId), user.actorId);
} }
} }
@Controller('admin/store-media') @Controller('admin/store-media')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('store_media')
export class AdminStoreMediaController { export class AdminStoreMediaController {
constructor(private readonly service: AdminStoresService) {} constructor(private readonly service: AdminStoresService) {}
@Get() @Get()
list(@Query() query: AdminStoreMediaQueryDto) { list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreMediaQueryDto) {
return this.service.listStoreMedia(query); return this.service.listStoreMedia(query, user.actorId);
} }
@Post() @Post()
@@ -123,8 +136,8 @@ export class AdminStoreMediaController {
refIdField: 'id', refIdField: 'id',
includeBody: true, includeBody: true,
}) })
create(@Body() dto: CreateStoreMediaDto) { create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreMediaDto) {
return this.service.createStoreMedia(dto); return this.service.createStoreMedia(dto, user.actorId);
} }
@Put(':id') @Put(':id')
@@ -132,15 +145,14 @@ export class AdminStoreMediaController {
action: HqOperationAction.STORE_MEDIA_UPDATE, action: HqOperationAction.STORE_MEDIA_UPDATE,
refType: 'STORE_MEDIA', refType: 'STORE_MEDIA',
refIdParam: 'id', refIdParam: 'id',
includeBody: true,
}) })
update(@Param('id') id: string, @Body() dto: UpdateStoreMediaDto) { update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreMediaDto) {
return this.service.updateStoreMedia(BigInt(id), dto); return this.service.updateStoreMedia(BigInt(id), dto, user.actorId);
} }
@Delete(':id') @Delete(':id')
@HqOperation({ action: HqOperationAction.STORE_MEDIA_DELETE, refType: 'STORE_MEDIA', refIdParam: 'id' }) @HqOperation({ action: HqOperationAction.STORE_MEDIA_DELETE, refType: 'STORE_MEDIA', refIdParam: 'id' })
remove(@Param('id') id: string) { remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.deleteStoreMedia(BigInt(id)); return this.service.deleteStoreMedia(BigInt(id), user.actorId);
} }
} }
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain'; import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
@@ -19,6 +19,7 @@ import type {
UpdateStoreStatusDto, UpdateStoreStatusDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */ /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null { function normalizeStoreOptionalText(value: unknown): string | null {
@@ -54,21 +55,54 @@ export class AdminStoresService {
private readonly storeCategoryService: StoreCategoryService, private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService, private readonly testWhitelist: TestWhitelistService,
private readonly hqPermissions: HqPermissionsResolver,
) {} ) {}
async listStores(query: AdminStoresQueryDto) { private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> {
const where = hqStoreCityWhere(scope);
if (!where) return null;
const rows = await this.prisma.store.findMany({ where, select: { id: true } });
return rows.length ? rows.map((r) => r.id) : [BigInt(0)];
}
private async assertStoreAccountInScope(actorId: bigint, accountId: bigint) {
const account = await this.prisma.storeAccount.findUnique({
where: { id: accountId },
include: { bindings: { include: { store: { select: { cityId: true } } } } },
});
if (!account) throw new NotFoundException('门店账号不存在');
const scope = await this.hqPermissions.resolveCityScope(actorId);
if (scope === null) return account;
const ok = account.bindings.some((b) => scope.some((id) => id === b.store.cityId));
if (!ok) throw new ForbiddenException('无权访问该城市的门店');
return account;
}
private async assertMediaInScope(actorId: bigint, mediaId: bigint) {
const media = await this.prisma.commonResource.findUnique({
where: { id: mediaId },
select: { ownerType: true, ownerId: true },
});
if (!media) throw new NotFoundException('资源不存在');
if (media.ownerType === 'STORE') {
await this.hqPermissions.assertStoreIdInScope(actorId, media.ownerId);
}
}
async listStores(query: AdminStoresQueryDto, actorId: bigint) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreWhereInput = {}; const scope = await this.hqPermissions.resolveCityScope(actorId);
let where: Prisma.StoreWhereInput = {};
if (query.name) where.name = { contains: query.name }; if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.auditStatus) { if (query.auditStatus) {
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals']; where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
} }
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone }; if (query.phone) where.phone = { contains: query.phone };
if (query.excludeTest) where.isTest = false; if (query.excludeTest) where.isTest = false;
where = mergeHqStoreCityWhere(where, scope, query.cityId);
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.store.findMany({ this.prisma.store.findMany({
@@ -98,9 +132,10 @@ export class AdminStoresService {
const pendingByStore = new Map<string, string>(); const pendingByStore = new Map<string, string>();
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用 // 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
const pendingInfoByStore = new Map<string, string>(); const pendingInfoByStore = new Map<string, string>();
const redeemedByStore = new Map<string, number>();
if (items.length) { if (items.length) {
const storeIds = items.map((s) => s.id); const storeIds = items.map((s) => s.id);
const [pendingReqs, pendingInfoReqs] = await Promise.all([ const [pendingReqs, pendingInfoReqs, redeemSums] = await Promise.all([
this.prisma.storePackageChangeRequest.findMany({ this.prisma.storePackageChangeRequest.findMany({
where: { storeId: { in: storeIds }, status: 'PENDING' }, where: { storeId: { in: storeIds }, status: 'PENDING' },
select: { id: true, storeId: true }, select: { id: true, storeId: true },
@@ -109,9 +144,17 @@ export class AdminStoresService {
where: { storeId: { in: storeIds }, status: 'PENDING' }, where: { storeId: { in: storeIds }, status: 'PENDING' },
select: { id: true, storeId: true }, select: { id: true, storeId: true },
}), }),
this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: { storeId: { in: storeIds } },
_sum: { amount: true },
}),
]); ]);
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString()); for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString()); for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
for (const r of redeemSums) {
redeemedByStore.set(r.storeId.toString(), r._sum.amount != null ? Number(r._sum.amount) : 0);
}
} }
return serializeBigInt({ return serializeBigInt({
@@ -124,6 +167,7 @@ export class AdminStoresService {
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃 // 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null, pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null, pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
partner: s.partnerAccount, partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null, account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined, bindings: undefined,
@@ -135,7 +179,8 @@ export class AdminStoresService {
}); });
} }
async detailStore(id: bigint) { async detailStore(id: bigint, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const store = await this.prisma.store.findUnique({ const store = await this.prisma.store.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -184,7 +229,8 @@ export class AdminStoresService {
})); }));
} }
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) { async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const store = await this.prisma.store.update({ const store = await this.prisma.store.update({
where: { id }, where: { id },
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' }, data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
@@ -192,7 +238,8 @@ export class AdminStoresService {
return serializeBigInt(store); return serializeBigInt(store);
} }
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) { async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const store = await this.prisma.store.findUnique({ where: { id } }); const store = await this.prisma.store.findUnique({ where: { id } });
if (!store) throw new NotFoundException('门店不存在'); if (!store) throw new NotFoundException('门店不存在');
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') { if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
@@ -258,7 +305,8 @@ export class AdminStoresService {
}); });
} }
async updateStore(id: bigint, dto: UpdateStoreDto) { async updateStore(id: bigint, dto: UpdateStoreDto, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const current = await this.prisma.store.findUnique({ where: { id } }); const current = await this.prisma.store.findUnique({ where: { id } });
if (!current) throw new NotFoundException('门店不存在'); if (!current) throw new NotFoundException('门店不存在');
@@ -524,10 +572,11 @@ export class AdminStoresService {
} }
}); });
return this.detailStore(id); return this.detailStore(id, actorId);
} }
async createStore(dto: CreateStoreDto) { async createStore(dto: CreateStoreDto, actorId: bigint) {
await this.hqPermissions.assertStoreCityInScope(actorId, BigInt(dto.cityId));
const normalizedPhone = dto.phone.trim(); const normalizedPhone = dto.phone.trim();
if (!isMobilePhone(normalizedPhone)) { if (!isMobilePhone(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号码'); throw new BadRequestException('请输入正确的手机号码');
@@ -727,10 +776,11 @@ export class AdminStoresService {
}); });
} }
return this.detailStore(store.id); return this.detailStore(store.id, actorId);
} }
async createStoreAccount(dto: CreateStoreAccountDto) { async createStoreAccount(dto: CreateStoreAccountDto, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId));
const store = await this.prisma.store.findUnique({ const store = await this.prisma.store.findUnique({
where: { id: BigInt(dto.storeId) }, where: { id: BigInt(dto.storeId) },
include: { bindings: true }, include: { bindings: true },
@@ -768,15 +818,24 @@ export class AdminStoresService {
return serializeBigInt(account); return serializeBigInt(account);
} }
async listStoreMedia(query: AdminStoreMediaQueryDto) { async listStoreMedia(query: AdminStoreMediaQueryDto, actorId: bigint) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const scope = await this.hqPermissions.resolveCityScope(actorId);
if (query.storeId) {
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
}
const where: Prisma.CommonResourceWhereInput = { const where: Prisma.CommonResourceWhereInput = {
ownerType: 'STORE', ownerType: 'STORE',
status: 'ACTIVE', status: 'ACTIVE',
}; };
if (query.storeId) where.ownerId = BigInt(query.storeId); if (query.storeId) where.ownerId = BigInt(query.storeId);
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals']; if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
const storeScope = hqStoreCityWhere(scope);
if (storeScope && !query.storeId) {
const ids = await this.storeIdsInScope(scope);
if (ids) where.ownerId = { in: ids };
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.commonResource.findMany({ this.prisma.commonResource.findMany({
@@ -790,7 +849,8 @@ export class AdminStoresService {
return serializeBigInt({ items, total, page, pageSize }); return serializeBigInt({ items, total, page, pageSize });
} }
async createStoreMedia(dto: CreateStoreMediaDto) { async createStoreMedia(dto: CreateStoreMediaDto, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId));
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } }); const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
if (!store) throw new BadRequestException('门店不存在'); if (!store) throw new BadRequestException('门店不存在');
const media = await this.prisma.commonResource.create({ const media = await this.prisma.commonResource.create({
@@ -808,7 +868,8 @@ export class AdminStoresService {
return serializeBigInt(media); return serializeBigInt(media);
} }
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) { async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto, actorId: bigint) {
await this.assertMediaInScope(actorId, id);
const media = await this.prisma.commonResource.update({ const media = await this.prisma.commonResource.update({
where: { id }, where: { id },
data: { data: {
@@ -820,7 +881,8 @@ export class AdminStoresService {
return serializeBigInt(media); return serializeBigInt(media);
} }
async deleteStoreMedia(id: bigint) { async deleteStoreMedia(id: bigint, actorId: bigint) {
await this.assertMediaInScope(actorId, id);
await this.prisma.commonResource.update({ await this.prisma.commonResource.update({
where: { id }, where: { id },
data: { status: 'DELETED' }, data: { status: 'DELETED' },
@@ -828,13 +890,20 @@ export class AdminStoresService {
return { ok: true }; return { ok: true };
} }
async listStoreAccounts(query: AdminStoreAccountsQueryDto) { async listStoreAccounts(query: AdminStoreAccountsQueryDto, actorId: bigint) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
if (query.storeId) {
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
}
const scope = await this.hqPermissions.resolveCityScope(actorId);
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 }; const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
if (query.phone) where.phone = { contains: query.phone }; if (query.phone) where.phone = { contains: query.phone };
if (query.storeId) { if (query.storeId) {
where.bindings = { some: { storeId: BigInt(query.storeId) } }; where.bindings = { some: { storeId: BigInt(query.storeId) } };
} else {
const storeWhere = hqStoreCityWhere(scope);
if (storeWhere) where.bindings = { some: { store: storeWhere } };
} }
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.excludeTest) where.isTest = false; if (query.excludeTest) where.isTest = false;
@@ -866,7 +935,8 @@ export class AdminStoresService {
return serializeBigInt({ items: mapped, total, page, pageSize }); return serializeBigInt({ items: mapped, total, page, pageSize });
} }
async detailStoreAccount(id: bigint) { async detailStoreAccount(id: bigint, actorId: bigint) {
await this.assertStoreAccountInScope(actorId, id);
const account = await this.prisma.storeAccount.findUnique({ const account = await this.prisma.storeAccount.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -895,7 +965,8 @@ export class AdminStoresService {
}); });
} }
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) { async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto, actorId: bigint) {
await this.assertStoreAccountInScope(actorId, id);
const account = await this.prisma.storeAccount.update({ const account = await this.prisma.storeAccount.update({
where: { id }, where: { id },
data: { data: {
@@ -908,7 +979,8 @@ export class AdminStoresService {
} }
/** HQ 删除门店子账号(非主账号) */ /** HQ 删除门店子账号(非主账号) */
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) { async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
await this.assertStoreAccountInScope(actorId, parentAccountId);
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } }); const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
if (!parent || parent.isPrimary !== 1) { if (!parent || parent.isPrimary !== 1) {
throw new BadRequestException('主账号不存在'); throw new BadRequestException('主账号不存在');
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { import {
HqPermissionGuard, HqPermissionGuard,
@@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminUsersService } from './admin-users.service'; import { AdminUsersService } from './admin-users.service';
import { AdminUsersQueryDto } from './dto/admin-query.dto'; import { AdminUsersQueryDto } from './dto/admin-query.dto';
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto'; import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto, UpdateAdminUserDto } from './dto/admin-mutate.dto';
@Controller('admin/users') @Controller('admin/users')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard)
@@ -48,6 +48,17 @@ export class AdminUsersController {
return this.usersService.detail(BigInt(id)); return this.usersService.detail(BigInt(id));
} }
@Put(':id')
@HqOperation({
action: HqOperationAction.USER_UPDATE,
refType: 'USER',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateAdminUserDto) {
return this.usersService.updateUser(BigInt(id), dto);
}
@Delete(':id') @Delete(':id')
@UseGuards(HqPermissionGuard) @UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_delete') @RequireHqPermissions('users_delete')
@@ -37,6 +37,7 @@ function mapAdminUserRow(u: {
mergedIntoUserId: bigint | null; mergedIntoUserId: bigint | null;
wxOpenId: string | null; wxOpenId: string | null;
nickname: string | null; nickname: string | null;
hqRemark: string | null;
status: number; status: number;
sourceType: string; sourceType: string;
sourceRefId: bigint | null; sourceRefId: bigint | null;
@@ -56,6 +57,7 @@ function mapAdminUserRow(u: {
wxOpenId: u.wxOpenId, wxOpenId: u.wxOpenId,
wechatVerified: !!u.wxOpenId, wechatVerified: !!u.wxOpenId,
nickname: u.nickname, nickname: u.nickname,
hqRemark: u.hqRemark,
status: u.status, status: u.status,
sourceType: u.sourceType, sourceType: u.sourceType,
sourceRefId: u.sourceRefId, sourceRefId: u.sourceRefId,
@@ -100,6 +102,7 @@ export class AdminUsersService {
mergedIntoUserId: true, mergedIntoUserId: true,
wxOpenId: true, wxOpenId: true,
nickname: true, nickname: true,
hqRemark: true,
status: true, status: true,
sourceType: true, sourceType: true,
sourceRefId: true, sourceRefId: true,
@@ -188,6 +191,18 @@ export class AdminUsersService {
}); });
} }
async updateUser(id: bigint, dto: { hqRemark: string }) {
const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
if (!user) throw new NotFoundException('用户不存在');
const hqRemark = dto.hqRemark.trim() || null;
const updated = await this.prisma.user.update({
where: { id },
data: { hqRemark },
select: { id: true, userNo: true, hqRemark: true },
});
return serializeBigInt(updated);
}
async previewBatchDelete(ids: bigint[]) { async previewBatchDelete(ids: bigint[]) {
const uniqueIds = [...new Set(ids)]; const uniqueIds = [...new Set(ids)];
if (!uniqueIds.length) { if (!uniqueIds.length) {

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