diff --git a/.cursor/skills/dukang-v3/reference-acc.md b/.cursor/skills/dukang-v3/reference-acc.md index 7cc930e..0443082 100644 --- a/.cursor/skills/dukang-v3/reference-acc.md +++ b/.cursor/skills/dukang-v3/reference-acc.md @@ -51,7 +51,7 @@ |-----|------|----------| | ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 | | ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 | -| ACC-012 | 代下单 | W3:手机号建用户;权益自动发 | +| ACC-012 | 代下单 | W3:客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 | | ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 | | ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 | | ACC-018 | 话术 | OPT-012 3 日内对齐 | diff --git a/.gitignore b/.gitignore index 982a646..eb4afb0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ coverage/ server/dukang-api/prisma/migrations/ debug_v3.xlsx ~$debug_v3.xlsx +deploy/auto-release.env diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 7b80897..6d653a4 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage'; import CityWarehousesPage from './pages/CityWarehousesPage'; import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage'; import StoreMediaPage from './pages/StoreMediaPage'; +import StoreCategoriesPage from './pages/StoreCategoriesPage'; import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage'; @@ -30,7 +31,10 @@ import ResourcesPage from './pages/ResourcesPage'; import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import WineryBillsPage from './pages/WineryBillsPage'; +import LogisticsBillsPage from './pages/LogisticsBillsPage'; import TicketsPage from './pages/TicketsPage'; +import SupportTicketsPage from './pages/SupportTicketsPage'; +import InvoicesPage from './pages/InvoicesPage'; import UserLogsPage from './pages/UserLogsPage'; import HqLogsPage from './pages/HqLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; @@ -39,6 +43,9 @@ import PartnerLogsPage from './pages/PartnerLogsPage'; import WechatBindingsPage from './pages/WechatBindingsPage'; import HqPermissionsPage from './pages/HqPermissionsPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; +import WecomBotsPage from './pages/WecomBotsPage'; +import LlmConfigsPage from './pages/LlmConfigsPage'; +import KnowledgeBasesPage from './pages/KnowledgeBasesPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -65,9 +72,13 @@ export default function App() { } /> } /> + } /> + } /> + } /> } /> } /> } /> + } /> } /> } /> } /> @@ -85,10 +96,13 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/components/CityDistrictMultiSelect.tsx b/apps/admin-web/src/components/CityDistrictMultiSelect.tsx new file mode 100644 index 0000000..779671c --- /dev/null +++ b/apps/admin-web/src/components/CityDistrictMultiSelect.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import { Select } from 'antd'; +import { getDistrictOptionsByCityCode } from '../lib/china-region'; + +type Props = { + cityCode?: string | null; + value?: string[]; + onChange?: (value: string[]) => void; + placeholder?: string; + disabled?: boolean; +}; + +/** 已选开城城市后,仅多选该市下区县(不再选省/市) */ +export default function CityDistrictMultiSelect({ + cityCode, + value, + onChange, + placeholder, + disabled, +}: Props) { + const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]); + const ready = Boolean(cityCode) && options.length > 0; + + return ( + + - + setEditScopeType(v)} /> {editScopeType === CityPartnerScopeType.DISTRICT && ( - - + + )} @@ -316,42 +352,51 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0 setCreateOpen(false)} onOk={async () => { - const v = await createForm.validateFields(); - const err = commissionSumError( - Number(v.orderCommissionRate ?? 0), - Number(v.redeemCommissionRate ?? 3), - maxPartnerCommissionRate, - ); - if (err) { - message.error(err); - return; + try { + const v = await createForm.validateFields(); + const err = commissionSumError( + Number(v.orderCommissionRate ?? 0), + Number(v.redeemCommissionRate ?? 3), + maxPartnerCommissionRate, + ); + if (err) { + message.error(err); + return; + } + await request('/admin/partners', { + method: 'POST', + body: JSON.stringify({ + ...v, + cityId, + orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, + redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, + districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, + }), + }); + message.success('已创建'); + setCreateOpen(false); + void loadPartners(); + onChanged?.(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); } - await request('/admin/partners', { - method: 'POST', - body: JSON.stringify({ - ...v, - cityId, - orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, - redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, - districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, - }), - }); - message.success('已创建'); - setCreateOpen(false); - void loadPartners(); - onChanged?.(); }}>
- + - + setKeyword(e.target.value)} + onPressEnter={() => void runSearch(keyword)} + /> + + + + + {hint} + +
+ {loading && !items.length ? ( +
+ +
+ ) : items.length ? ( + { + const active = + pending?.latitude === item.latitude && pending?.longitude === item.longitude; + return ( + setPending(placeToPicked(item))} + > + + {item.address} +
+ + {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)} + + + } + /> +
+ ); + }} + /> + ) : ( + + )} +
+ + + ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index ea65028..726ac90 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Layout, Menu, Typography, Button, Space } from 'antd'; import type { MenuProps } from 'antd'; import { + RobotOutlined, DashboardOutlined, UserOutlined, ShoppingOutlined, @@ -18,11 +19,14 @@ import { SettingOutlined, AccountBookOutlined, } from '@ant-design/icons'; +import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; const { Header, Sider, Content } = Layout; +type MenuItem = NonNullable[number]; + const MENU_ITEMS: MenuProps['items'] = [ { key: '/', icon: , label: '概览' }, { key: '/users', icon: , label: '用户' }, @@ -38,12 +42,16 @@ const MENU_ITEMS: MenuProps['items'] = [ }, { key: '/orders', icon: , label: '订单' }, { key: '/promo-codes', icon: , label: '推广码' }, + { key: '/wecom-bots', icon: , label: '企微机器人' }, + { key: '/llm-configs', icon: , label: '语言模型' }, + { key: '/knowledge-bases', icon: , label: '知识库' }, { key: 'stores-group', icon: , label: '门店', children: [ { key: '/stores', label: '门店列表' }, + { key: '/store-categories', label: '门店分类' }, { key: '/store-accounts', label: '门店账户' }, { key: '/store-media', label: '门店资源' }, ], @@ -67,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/finance/store-bills', label: '门店账单' }, { key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/winery-bills', label: '酒厂账单' }, + { key: '/finance/logistics-bills', label: '物流对账' }, ], }, { @@ -90,7 +99,16 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, ], }, - { key: '/tickets', icon: , label: '工单中心' }, + { + key: 'tickets-group', + icon: , + label: '工单', + children: [ + { key: '/tickets', label: '工单中心' }, + { key: '/tickets/support', label: '技术支持' }, + ], + }, + { key: '/invoices', icon: , label: '发票管理' }, { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'logs-group', @@ -109,6 +127,83 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; +function menuAllowed(key: string, permissionKeys: string[]): boolean { + if (key === 'tickets-group') { + return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support'); + } + const map: Record = { + '/': 'dashboard', + '/users': 'users', + '/wechat-bindings': 'wechat_bindings', + 'products-group': 'products', + '/products': 'products', + '/product-detail-templates': 'products', + '/orders': 'orders', + '/promo-codes': 'promo_codes', + '/wecom-bots': 'wecom_bots', + '/llm-configs': 'llm_configs', + '/knowledge-bases': 'knowledge_bases', + 'stores-group': 'stores', + '/stores': 'stores', + '/store-categories': 'stores', + '/store-accounts': 'stores', + '/store-media': 'stores', + 'partners-group': 'partners', + '/cities': 'partners', + '/city-partners': 'partners', + '/city-warehouses': 'partners', + '/fulfillment-providers': 'partners', + 'finance-group': 'finance', + '/finance/store-bills': 'finance', + '/finance/partner-bills': 'finance', + '/finance/winery-bills': 'finance', + '/finance/logistics-bills': 'finance', + 'benefit-group': 'benefit', + '/benefit/coupons': 'benefit', + '/benefit/ledgers': 'benefit', + '/redeem-records': 'benefit', + '/redeem-pending': 'benefit', + '/redeem/debug': 'benefit', + 'deliveries-group': 'deliveries', + '/deliveries': 'deliveries', + '/deliveries/xiaofeixia': 'deliveries', + '/tickets': 'tickets', + '/tickets/support': 'tech_support', + '/invoices': 'invoices', + '/resources': 'resources', + 'logs-group': 'logs', + '/logs/users': 'logs', + '/logs/stores': 'logs', + '/logs/partners': 'logs', + '/logs/hq': 'logs', + '/logs/third-party': 'logs', + '/hq-permissions': 'hq_permissions', + '/system-settings': 'system_settings_any', + '/hq-accounts': 'hq_accounts', + }; + const need = map[key]; + if (!need) return true; + if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys); + return permissionKeys.includes(need); +} + +function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] { + if (!items) return items; + return items + .map((item) => { + if (!item || typeof item !== 'object' || !('key' in item)) return item; + const key = String(item.key); + if ('children' in item && Array.isArray(item.children)) { + if (!menuAllowed(key, permissionKeys)) return null; + const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys); + if (!children?.length) return null; + return { ...item, children } as MenuItem; + } + return menuAllowed(key, permissionKeys) ? item : null; + }) + .filter(Boolean) as MenuProps['items']; +} + export default function AdminLayout() { const navigate = useNavigate(); const location = useLocation(); @@ -136,6 +231,12 @@ export default function AdminLayout() { ? '/promo-codes' : location.pathname; + const menuItems = useMemo(() => { + if (!profile) return MENU_ITEMS; + if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS; + return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []); + }, [profile]); + return ( { if (key.startsWith('/')) navigate(key); }} diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 7950ddb..829be92 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -7,6 +7,7 @@ export type HqProfile = { name: string; adminRole: string; status: string; + permissionKeys?: string[]; }; export function getToken() { @@ -145,6 +146,8 @@ export type AdminOrderRow = { city?: { id: string; name: string; code: string }; fulfillmentWarehouseId?: string | null; fulfillmentWarehouse?: { id: string; name: string } | null; + fulfillmentHold?: boolean; + fulfillmentHoldReason?: string | null; user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; delivery?: { provider: string; diff --git a/apps/admin-web/src/lib/china-region.ts b/apps/admin-web/src/lib/china-region.ts index e6b3312..5bbc04e 100644 --- a/apps/admin-web/src/lib/china-region.ts +++ b/apps/admin-web/src/lib/china-region.ts @@ -79,6 +79,39 @@ export function districtCodeToCityCode(districtCode: string): string { return `${districtCode.slice(0, 4)}00`; } +export type DistrictOption = { value: string; label: string }; + +/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */ +export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] { + if (!cityCode?.trim()) return []; + const normalized = normalizeCityAdcode(cityCode.trim()); + const prefix4 = normalized.slice(0, 4); + + for (const province of regionData) { + for (const city of province.children ?? []) { + const cityValue = String(city.value); + const cityNorm = normalizeCityAdcode(cityValue); + if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) { + return (city.children ?? []).map((d) => ({ + value: String(d.value), + label: String(d.label), + })); + } + } + } + return []; +} + +export function districtCodeLabel(code: string): string { + return codeToText[code] || code; +} + +/** 列表展示:区县码 → 中文名,顿号拼接 */ +export function formatDistrictLabels(codes?: string[] | null): string { + if (!codes?.length) return '—'; + return codes.map((c) => districtCodeLabel(String(c))).join('、'); +} + export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null { if (!codes || codes.length < 3) return null; const [provinceCode, cityCode, districtCode] = codes; diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index bcaf6c4..48ec1ca 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record = { export const STORE_STATUS_LABELS: Record = { OPEN: '营业中', - PAUSED: '暂停', - CLOSED: '已关闭', + PAUSED: '临时闭店', + CLOSED: '永久关闭', }; export const STORE_AUDIT_STATUS_LABELS: Record = { @@ -62,9 +62,11 @@ export const CITY_STATUS_LABELS: Record = { }; export const PARTNER_BILL_STATUS_LABELS: Record = { - DRAFT: '草稿', - CONFIRMED: '已确认', - PAID: '已结算', + PENDING_REVIEW: '待审核', + AWAITING_CONFIRM: '待合伙人确认', + UNPAID: '未打款', + PAID: '已打款', + REJECTED: '已驳回', }; export const MEDIA_TYPE_LABELS: Record = { @@ -126,3 +128,18 @@ export const LEDGER_TYPE_LABELS: Record = { export function fmtTime(v?: string | null) { return v ? new Date(v).toLocaleString('zh-CN') : '—'; } + +/** 手机号脱敏展示:138****5678;空值返回 — */ +export function maskPhone(phone?: string | null): string { + const raw = String(phone ?? '').trim(); + if (!raw) return '—'; + const digits = raw.replace(/\D/g, ''); + if (digits.length >= 11) { + return `${digits.slice(0, 3)}****${digits.slice(-4)}`; + } + if (digits.length >= 7) { + return `${digits.slice(0, 3)}****${digits.slice(-2)}`; + } + if (digits.length > 0) return `${digits.slice(0, 1)}****`; + return '****'; +} diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index ff535d2..198485c 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -27,6 +27,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'STORE_AUDIT', label: '门店审核' }, { value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' }, { value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' }, + { value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' }, + { value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' }, + { value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' }, + { value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' }, + { value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' }, { value: 'PRODUCT_CREATE', label: '新增商品' }, { value: 'PRODUCT_UPDATE', label: '编辑商品' }, { value: 'PRODUCT_DELETE', label: '删除商品' }, @@ -34,11 +39,27 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'DELIVERY_UPDATE', label: '编辑配送单' }, { value: 'TICKET_APPROVE', label: '工单通过' }, { value: 'TICKET_REJECT', label: '工单驳回' }, + { value: 'TICKET_CREATE', label: '创建工单' }, + { value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' }, + { value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' }, + { value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' }, + { value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' }, + { value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' }, + { value: 'INVOICE_CREATE', label: '创建发票申请' }, + { value: 'INVOICE_ISSUE', label: '开具发票' }, + { value: 'INVOICE_REJECT', label: '驳回发票' }, { value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' }, { value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' }, + { value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' }, + { value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' }, { value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' }, + { value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' }, + { value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' }, { value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' }, { value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' }, + { value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' }, + { value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' }, + { value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' }, ] as const; export const HQ_OPERATION_ACTION_LABELS: Record = Object.fromEntries( diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index e21fd57..c7a363e 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -6,10 +6,20 @@ export type StoreCreateForm = { city?: string; district: string; districtCode?: string; + categoryParentId?: string; + categoryId: string; name: string; phone: string; address: string; + latitude?: number | null; + longitude?: number | null; intro?: string; + benefitUsageRule?: string; + openTime?: string; + closeTime?: string; + openTime2?: string; + closeTime2?: string; + avgPrice?: number | null; coverUrl?: string; envPhotoUrls?: string[]; contractUrl?: string; @@ -21,20 +31,67 @@ export type StoreCreateForm = { const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +function timeToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} export function validateStoreCreateStep1( - form: Pick, + form: Pick< + StoreCreateForm, + | 'partnerAccountId' + | 'cityId' + | 'regionCodes' + | 'categoryId' + | 'name' + | 'phone' + | 'address' + | 'intro' + | 'benefitUsageRule' + | 'openTime' + | 'closeTime' + | 'openTime2' + | 'closeTime2' + | 'avgPrice' + >, ): string | null { if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划'; + if (!form.categoryId?.trim()) return '请选择门店分类(细类)'; if (!form.name?.trim()) return '请填写门店名称'; if (!form.phone?.trim()) return '请填写门店手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!form.address?.trim()) return '请填写详细地址'; + + const openTime = String(form.openTime || '').trim(); + const closeTime = String(form.closeTime || '').trim(); + if (!openTime || !closeTime) return '请填写营业时间'; + if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM'; + if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间'; + + const openTime2 = String(form.openTime2 || '').trim(); + const closeTime2 = String(form.closeTime2 || '').trim(); + if (openTime2 || closeTime2) { + if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间'; + if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM'; + if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间'; + if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间'; + } + + if (form.avgPrice != null && String(form.avgPrice).trim() !== '') { + const n = Number(form.avgPrice); + if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字'; + } + if (form.intro?.trim()) { const len = form.intro.trim().length; - if (len < 10 || len > 500) return '门店简介须为 10~500 字'; + if (len < 2 || len > 500) return '门店简介须为 2~500 字'; + } + if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) { + return '好客权益券使用规则最多 1000 字'; } return null; } diff --git a/apps/admin-web/src/lib/tencentLocPicker.ts b/apps/admin-web/src/lib/tencentLocPicker.ts new file mode 100644 index 0000000..ffba081 --- /dev/null +++ b/apps/admin-web/src/lib/tencentLocPicker.ts @@ -0,0 +1,26 @@ +export type TencentPickedLocation = { + latitude: number; + longitude: number; + address?: string; + name?: string; + cityname?: string; +}; + +export type LbsPlaceItem = { + id: string; + title: string; + address: string; + latitude: number; + longitude: number; + city?: string; +}; + +export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation { + return { + latitude: item.latitude, + longitude: item.longitude, + address: item.address || undefined, + name: item.title || undefined, + cityname: item.city || undefined, + }; +} diff --git a/apps/admin-web/src/pages/BenefitCouponsPage.tsx b/apps/admin-web/src/pages/BenefitCouponsPage.tsx index 6f98e87..8ba2db3 100644 --- a/apps/admin-web/src/pages/BenefitCouponsPage.tsx +++ b/apps/admin-web/src/pages/BenefitCouponsPage.tsx @@ -33,6 +33,34 @@ type Row = { order?: { orderNo: string } | null; }; +type CouponRedeemRecord = { + id: string; + redeemNo: string; + amount: number; + settleAmount: number; + couponAmount?: number; + role?: 'PRIMARY' | 'SECONDARY'; + createdAt: string; + store?: { id: string; name: string; cityName?: string | null } | null; +}; + +type CouponRedeemSummary = { + couponNo: string; + totalAmount: number; + usedAmount: number; + balance: number; + status: string; + redeemCount: number; + redeemRecordSum: number; +}; + +type CouponDetail = Row & { + redeemSummary?: CouponRedeemSummary | null; + redeemRecords?: CouponRedeemRecord[]; + user?: { userNo?: string; phone?: string | null }; + order?: { orderNo?: string } | null; +}; + export default function BenefitCouponsPage() { const [form] = Form.useForm(); const [grantForm] = Form.useForm(); @@ -50,8 +78,25 @@ export default function BenefitCouponsPage() { }, [filters], ); - const [detail, setDetail] = useState | null>(null); + const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [redeemDetail, setRedeemDetail] = useState | null>(null); + const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); + const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); + + async function openRedeemDetail(redeemId: string) { + setRedeemDetailLoading(true); + setRedeemDrawerOpen(true); + try { + const res = await request>(`/admin/redeem-records/${redeemId}`); + setRedeemDetail(res); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载核销详情失败'); + setRedeemDrawerOpen(false); + } finally { + setRedeemDetailLoading(false); + } + } const columns: ColumnsType = [ { title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, @@ -197,7 +242,7 @@ export default function BenefitCouponsPage() { setDrawerOpen(false)} extra={ @@ -218,25 +263,202 @@ export default function BenefitCouponsPage() { } > {detail && ( + <> + + {detail.couponNo} + {detail.user?.userNo ?? '—'} + {detail.user?.phone ?? '—'} + {detail.order?.orderNo ?? '—'} + ¥{Number(detail.totalAmount).toFixed(2)} + ¥{Number(detail.balance).toFixed(2)} + + {COUPON_STATUS_LABELS[detail.status] || detail.status} + + {detail.sourceProduct} + + + + 权益核销 + + {detail.redeemSummary ? ( + <> + + {detail.redeemSummary.couponNo} + {detail.redeemSummary.status} + + ¥{Number(detail.redeemSummary.totalAmount).toFixed(2)} + + + + ¥{Number(detail.redeemSummary.usedAmount).toFixed(2)} + + + ({detail.redeemSummary.redeemCount} 笔核销单) + + + + ¥{Number(detail.redeemSummary.balance).toFixed(2)} + + + ¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)} + + (本券分摊合计) + + + + + v === 'SECONDARY' ? 次券 : 主券, + }, + { + title: '门店', + dataIndex: ['store', 'name'], + ellipsis: true, + render: (v: string | undefined, row: CouponRedeemRecord) => + v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—', + }, + { + title: '本券分摊', + dataIndex: 'couponAmount', + width: 95, + render: (v: number | undefined, row: CouponRedeemRecord) => + `¥${Number(v ?? row.amount).toFixed(2)}`, + }, + { + title: '核销总额', + dataIndex: 'amount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '结算额', + dataIndex: 'settleAmount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '时间', + dataIndex: 'createdAt', + width: 150, + render: (v: string) => fmtTime(v), + }, + { + title: '操作', + width: 70, + render: (_: unknown, row: CouponRedeemRecord) => ( + + ), + }, + ]} + /> + + ) : ( + 暂无核销摘要 + )} + + )} + + + { + setRedeemDrawerOpen(false); + setRedeemDetail(null); + }} + destroyOnClose + > + {redeemDetailLoading ? ( + 加载中… + ) : redeemDetail ? ( - {String(detail.couponNo)} - - {String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + {String(redeemDetail.redeemNo ?? '—')} + + ¥{Number(redeemDetail.amount ?? 0).toFixed(2)} - - {String((detail.user as { phone?: string } | undefined)?.phone ?? '—')} + + ¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)} + + {fmtTime(String(redeemDetail.createdAt ?? ''))} + + {String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + {(redeemDetail.user as { phone?: string | null } | undefined)?.phone + ? ` / ${(redeemDetail.user as { phone?: string | null }).phone}` + : ''} + + + {String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')} + {(redeemDetail.store as { cityName?: string } | undefined)?.cityName + ? `(${(redeemDetail.store as { cityName?: string }).cityName})` + : ''} + + + {String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')} + + + {String( + (redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined) + ?.partnerAccount?.companyName ?? '—', + )} + + + {String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')} - {String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')} + {String( + (redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ?? + '—', + )} - ¥{String(detail.totalAmount)} - ¥{String(detail.balance)} - - {COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)} - - {String(detail.sourceProduct)} + {Array.isArray(redeemDetail.allocations) && + (redeemDetail.allocations as unknown[]).length > 0 ? ( + + {( + redeemDetail.allocations as Array<{ + couponNo?: string; + orderNo?: string | null; + amount?: number; + sortOrder?: number; + }> + ) + .map((a, idx) => { + const role = idx === 0 ? '主' : '次'; + return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${ + a.orderNo ? `(订单 ${a.orderNo})` : '' + }`; + }) + .join(';')} + + ) : null} + {redeemDetail.payout ? ( + + ¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)} + {' / '} + {String((redeemDetail.payout as { status?: string }).status ?? '—')} + + ) : null} + {redeemDetail.rating ? ( + + 服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '} + {(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分 + + ) : null} - )} + ) : null} ); diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index 15721e7..5c6325e 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Button, Descriptions, @@ -17,7 +18,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; -import { request, type Paginated } from '../lib/api'; +import { request, type HqProfile, type Paginated } from '../lib/api'; import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; @@ -50,6 +51,40 @@ type Row = { type PartnerOption = { id: string; companyName: string; cityId?: string | null }; +type CityDeletePreview = { + city: { id: string; code: string; name: string; province: string; status: string }; + canDelete: boolean; + blockers: string[]; + warnings: string[]; + summary: { + primaryPartnerCount: number; + staffCount: number; + storeCount: number; + warehouseCount: number; + orderCount: number; + redeemCount: number; + partnerBillCount: number; + }; + partners: Array<{ + id: string; + phone: string; + name: string; + companyName?: string | null; + status: string; + staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>; + }>; + orphanStaff: Array<{ id: string; phone: string; name: string }>; + stores: Array<{ + id: string; + name: string; + phone: string; + status: string; + address: string; + partnerAccount?: { companyName?: string | null; phone?: string } | null; + }>; + warehouses: Array<{ id: string; name: string; status: string; address: string }>; +}; + const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); export default function CitiesPage() { @@ -82,6 +117,18 @@ export default function CitiesPage() { const createRegionCodes = Form.useWatch('regionCodes', createForm); const [warehouseManagerType, setWarehouseManagerType] = useState(WarehouseManagerType.HQ); const [editWarehouseManagerType, setEditWarehouseManagerType] = useState(WarehouseManagerType.HQ); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteSubmitting, setDeleteSubmitting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletePreview, setDeletePreview] = useState(null); + const [confirmName, setConfirmName] = useState(''); + const [profile, setProfile] = useState(null); + const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete'); + + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); const loadPartners = useCallback(async (cityId: string) => { const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); @@ -129,22 +176,85 @@ export default function CitiesPage() { setDrawerOpen(true); }; + const openDelete = async (row: Row) => { + setDeleteTarget(row); + setDeletePreview(null); + setConfirmName(''); + setDeleteOpen(true); + setDeleteLoading(true); + try { + const preview = await request(`/admin/cities/${row.id}/delete-preview`); + setDeletePreview(preview); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载删除预览失败'); + setDeleteOpen(false); + } finally { + setDeleteLoading(false); + } + }; + + const confirmDelete = async () => { + if (!deleteTarget || !deletePreview) return; + if (!deletePreview.canDelete) { + message.error(deletePreview.blockers.join(';') || '当前城市不可删除'); + return; + } + if (confirmName.trim() !== deletePreview.city.name) { + message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`); + return; + } + setDeleteSubmitting(true); + try { + await request(`/admin/cities/${deleteTarget.id}`, { + method: 'DELETE', + body: JSON.stringify({ confirmName: confirmName.trim() }), + }); + message.success(`已删除城市「${deletePreview.city.name}」`); + setDeleteOpen(false); + setDeleteTarget(null); + setDeletePreview(null); + setConfirmName(''); + if (detail?.id === deleteTarget.id) setDrawerOpen(false); + void reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } finally { + setDeleteSubmitting(false); + } + }; + const columns: ColumnsType = [ { title: '编码', dataIndex: 'code', width: 90 }, { title: '城市', dataIndex: 'name', width: 100 }, { title: '省份', dataIndex: 'province', width: 90 }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {CITY_STATUS_LABELS[s] || s} }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, - { title: '门店', dataIndex: 'storeCount', width: 70 }, + { + title: '门店', + dataIndex: 'storeCount', + width: 70, + render: (n: number, row) => ( + + {n ?? 0} + + ), + }, { title: '订单', dataIndex: 'orderCount', width: 70 }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作', - width: 80, + width: 140, render: (_, row) => ( - + + + {canDeleteCities ? ( + + ) : null} + ), }, ]; @@ -264,6 +374,26 @@ export default function CitiesPage() { setDetail(refreshed); void reload(); }}>保存 + {canDeleteCities ? ( + + ) : null} ), @@ -274,6 +404,7 @@ export default function CitiesPage() { children: detail?.id ? ( + + { + if (deleteSubmitting) return; + setDeleteOpen(false); + }} + okText="确认删除" + okButtonProps={{ + danger: true, + disabled: + !deletePreview?.canDelete || + !deletePreview || + confirmName.trim() !== (deletePreview?.city.name ?? ''), + loading: deleteSubmitting, + }} + confirmLoading={deleteSubmitting} + onOk={() => void confirmDelete()} + width={720} + destroyOnClose + > + {deleteLoading || !deletePreview ? ( + 正在加载关联数据… + ) : ( + + + {deletePreview.city.code} + {deletePreview.city.province} + {deletePreview.summary.primaryPartnerCount} + {deletePreview.summary.staffCount} + {deletePreview.summary.storeCount} + {deletePreview.summary.warehouseCount} + {deletePreview.summary.orderCount} + {deletePreview.summary.redeemCount} + + + {deletePreview.blockers.length > 0 && ( + + {deletePreview.blockers.map((b) => ( +
• {b}
+ ))} +
+ )} + {deletePreview.warnings.length > 0 && ( + + {deletePreview.warnings.map((w) => ( +
• {w}
+ ))} +
+ )} + +
+ 合伙人及子账号 +
[ + { + id: p.id, + kind: '主账号', + name: p.companyName || p.name, + phone: p.phone, + parent: '—', + }, + ...p.staff.map((s) => ({ + id: s.id, + kind: '子账号', + name: s.name, + phone: s.phone, + parent: p.companyName || p.name, + })), + ]).concat( + deletePreview.orphanStaff.map((s) => ({ + id: s.id, + kind: '子账号', + name: s.name, + phone: s.phone, + parent: '(无主账号)', + })), + )} + columns={[ + { title: '类型', dataIndex: 'kind', width: 80 }, + { title: '名称', dataIndex: 'name', ellipsis: true }, + { title: '手机号', dataIndex: 'phone', width: 120 }, + { title: '归属', dataIndex: 'parent', ellipsis: true }, + ]} + /> + + +
+ 门店 +
r.partnerAccount?.companyName || r.partnerAccount?.phone || '—', + }, + ]} + /> + + + {deletePreview.canDelete ? ( + + setConfirmName(e.target.value)} + disabled={deleteSubmitting} + /> + + ) : ( + 存在阻断项,无法删除。请先处理订单等关联数据。 + )} + + )} + ); } diff --git a/apps/admin-web/src/pages/CityPartnersPage.tsx b/apps/admin-web/src/pages/CityPartnersPage.tsx index 55c9914..bcf7ab1 100644 --- a/apps/admin-web/src/pages/CityPartnersPage.tsx +++ b/apps/admin-web/src/pages/CityPartnersPage.tsx @@ -3,7 +3,6 @@ import { Link } from 'react-router-dom'; import { Alert, Button, - Cascader, Checkbox, Descriptions, Drawer, @@ -32,9 +31,10 @@ import { type PartnerPermissionKey, } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; -import { CHINA_REGION_OPTIONS } from '../lib/china-region'; +import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; type SubRow = PartnerSubAccountRow; @@ -48,6 +48,7 @@ type Row = { cityId?: string | null; cityName?: string | null; scopeType?: string; + districtCodes?: string[] | null; orderCommissionRate?: number; redeemCommissionRate?: number; bindingStatus?: string; @@ -98,13 +99,25 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string } function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null { - const sum = orderPercent / 100 + redeemPercent / 100; - if (sum > maxRate + 1e-9) { - return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`; + // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 + const max = Number(maxRate); + const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; + if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效'; + if (sum > max + 1e-9) { + return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`; } return null; } +function formatApiError(err: unknown): string | null { + if (err && typeof err === 'object' && 'errorFields' in err) return null; + if (!(err instanceof Error)) return '操作失败'; + return err.message.replace(/\b(\d{6})\b/g, (code) => { + const label = districtCodeLabel(code); + return label !== code ? `${label}(${code})` : code; + }); +} + export default function CityPartnersPage() { const [filterForm] = Form.useForm(); const [editForm] = Form.useForm(); @@ -135,6 +148,7 @@ export default function CityPartnersPage() { const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); const [maxCommissionRate, setMaxCommissionRate] = useState(0.05); const [createMaxRate, setCreateMaxRate] = useState(0.05); + const [createCityCode, setCreateCityCode] = useState(); const loadCities = useCallback(async () => { const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); @@ -145,11 +159,15 @@ export default function CityPartnersPage() { void loadCities(); }, [loadCities]); + const editCityCode = detail?.cityId + ? cities.find((c) => c.id === detail.cityId)?.code + : undefined; + async function openPartner(id: string) { const d = await request(`/admin/partners/${id}`); setDetail(d); setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); - setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05); + setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05)); editForm.setFieldsValue({ name: d.name, phone: d.phone, @@ -171,28 +189,33 @@ export default function CityPartnersPage() { async function savePartner() { if (!detail) return; - const v = await editForm.validateFields(); - const err = commissionSumError( - Number(v.orderCommissionRate ?? 0), - Number(v.redeemCommissionRate ?? 0), - maxCommissionRate, - ); - if (err) { - message.error(err); - return; + try { + const v = await editForm.validateFields(); + const err = commissionSumError( + Number(v.orderCommissionRate ?? 0), + Number(v.redeemCommissionRate ?? 0), + maxCommissionRate, + ); + if (err) { + message.error(err); + return; + } + await request(`/admin/partners/${detail.id}`, { + method: 'PUT', + body: JSON.stringify({ + ...v, + orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, + redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, + districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, + }), + }); + message.success('已保存'); + setDrawerOpen(false); + void reload(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); } - await request(`/admin/partners/${detail.id}`, { - method: 'PUT', - body: JSON.stringify({ - ...v, - orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, - redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, - districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, - }), - }); - message.success('已保存'); - setDrawerOpen(false); - void reload(); } async function refreshPartnerContext(parentId: string) { @@ -229,13 +252,24 @@ export default function CityPartnersPage() { } async function onCreateCityChange(cityId: string) { + createForm.setFieldsValue({ districtCodes: undefined }); + const matched = cities.find((c) => c.id === cityId); + setCreateCityCode(matched?.code); const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`); - setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05); + setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); } const columns: ColumnsType = [ - { title: '公司名', dataIndex: 'companyName', ellipsis: true }, { title: '城市', dataIndex: 'cityName', width: 90 }, + { + title: '区县', + dataIndex: 'districtCodes', + width: 160, + ellipsis: true, + render: (codes: string[] | null | undefined, row) => + row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), + }, + { title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, { title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true }, { title: '登录手机', dataIndex: 'phone', width: 120 }, { @@ -267,7 +301,16 @@ export default function CityPartnersPage() { ellipsis: true, render: (v) => v || '—', }, - { title: '门店', dataIndex: 'storeCount', width: 60 }, + { + title: '门店', + dataIndex: 'storeCount', + width: 60, + render: (n: number, row) => ( + + {n ?? 0} + + ), + }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { @@ -299,6 +342,7 @@ export default function CityPartnersPage() { }); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateMaxRate(0.05); + setCreateCityCode(undefined); setCreateOpen(true); }} > @@ -327,12 +371,6 @@ export default function CityPartnersPage() { setPage(1); }} > - - - - - - + + + +
@@ -210,7 +260,7 @@ export default function FulfillmentProvidersPage() { open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} - width={560} + width={640} destroyOnClose > @@ -227,11 +277,58 @@ export default function FulfillmentProvidersPage() { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {showXfxFields && ( <> - - 小飞侠接口参数 - + 小飞侠接口参数 [r.value, r.label])); +const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))]; + +function groupColSpan(itemCount: number): number { + if (itemCount <= 2) return 12; + if (itemCount === 3) return 8; + if (itemCount === 4) return 6; + return 8; +} + function PermissionChecklist({ value, onChange, @@ -49,13 +58,44 @@ function PermissionChecklist({ disabled={disabled} onChange={(checked) => onChange(checked as string[])} > - - {HQ_PERMISSION_CATALOG.map((item) => ( - - {item.label} - - ))} - + {CATALOG_GROUPS.map((group) => { + const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group); + const span = groupColSpan(items.length); + const compact = items.length <= 3; + return ( +
+ + {group} + + {compact ? ( + + {items.map((item) => ( + + {item.label} + + ))} + + ) : ( + + {items.map((item) => ( +
+ {item.label} + + ))} + + )} + + ); + })} ); } @@ -74,6 +114,12 @@ export default function HqPermissionsPage() { const [accountLoading, setAccountLoading] = useState(false); const [accountSaving, setAccountSaving] = useState(false); + const selectedAccount = useMemo( + () => accounts.find((a) => a.id === accountId), + [accounts, accountId], + ); + const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN'; + const previewEffectiveKeys = useMemo( () => [...new Set([...roleInheritedKeys, ...accountKeys])], [roleInheritedKeys, accountKeys], @@ -158,7 +204,9 @@ export default function HqPermissionsPage() {
权限分配 - 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。 + 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。 + 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。 + 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。 {role === 'SUPER_ADMIN' ? ( - + ) : ( <> @@ -212,12 +264,10 @@ export default function HqPermissionsPage() { value={accountId} onChange={setAccountId} optionFilterProp="label" - options={accounts - .filter((a) => a.adminRole !== 'SUPER_ADMIN') - .map((a) => ({ - value: a.id, - label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, - }))} + options={accounts.map((a) => ({ + value: a.id, + label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, + }))} /> @@ -225,28 +275,57 @@ export default function HqPermissionsPage() { ) : ( <> - - 角色继承: - {roleInheritedKeys.map((key) => { - const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); - return ( - - {item?.label || key} - - ); - })} - +
+ 角色继承: + {selectedIsSuperAdmin ? ( + 超级管理员基础权限(不含危险操作) + ) : ( + + {roleInheritedKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); + return ( + + {item?.label || key} + + ); + })} + {!roleInheritedKeys.length ? ( + + ) : null} + + )} +
- 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。 + 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。 - - 合并生效: - {previewEffectiveKeys.map((key) => { - const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); - return {item?.label || key}; - })} - +
+ 合并生效: + {selectedIsSuperAdmin ? ( + + 基础权限(全部) + {accountKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); + return ( + + {item?.label || key} + + ); + })} + + ) : ( + + {previewEffectiveKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); + return ( + + {item?.label || key} + + ); + })} + + )} +
+ ), + }, + ]; + + return ( +
+
+ + 发票管理 + + +
+
{ + setFilters(v); + setPage(1); + }} + > + +
(r.overdue ? 'ant-table-row-overdue' : '')} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + setDrawerOpen(false)} + extra={ + detail?.status === 'PENDING' ? ( + + { + void issueWithFile(file); + return false; + }} + > + + + + + ) : null + } + > + {detail && ( + + {detail.invoiceNo} + {detail.orderNo ?? '—'} + {detail.payAmount ?? '—'} + {detail.userPhone ?? '—'} + + {INVOICE_TITLE_TYPE_LABELS[detail.titleType]} + + + {INVOICE_KIND_LABELS[detail.invoiceKind]} + + {detail.titleName} + {detail.taxNo ?? '—'} + {detail.addressPhone ?? '—'} + {detail.bankAccount ?? '—'} + {detail.email} + {detail.phone} + + {INVOICE_STATUS_LABELS[detail.status]} + {detail.overdue ? '(超 2 工作日)' : ''} + + + {detail.fileUrl ? ( + + 查看/下载 + + ) : ( + '—' + )} + + {detail.remark ?? '—'} + + )} + + + setCreateOpen(false)} + onOk={() => void submitCreate()} + confirmLoading={creating} + destroyOnClose + okText="提交" + width={520} + > + + + + + + ({ + value: t, + label: INVOICE_TITLE_TYPE_LABELS[t], + disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL', + }))} + /> + + + + + {(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && ( + + + + )} + {invoiceKind === 'SPECIAL' && ( + <> + + + + + + + + )} + + + + + + + + + + + + + ); +} diff --git a/apps/admin-web/src/pages/KnowledgeBasesPage.tsx b/apps/admin-web/src/pages/KnowledgeBasesPage.tsx new file mode 100644 index 0000000..b79de38 --- /dev/null +++ b/apps/admin-web/src/pages/KnowledgeBasesPage.tsx @@ -0,0 +1,403 @@ +import { useState } from 'react'; +import { + Button, + Drawer, + Form, + Input, + Modal, + Popconfirm, + Space, + Switch, + Table, + Tag, + Typography, + Upload, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { UploadOutlined } from '@ant-design/icons'; +import type { + KnowledgeBaseDto, + KnowledgeDocumentDto, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; +import { uploadFileToOss } from '../lib/upload'; + +type FormValues = { + name: string; + description?: string; + enabled: boolean; +}; + +type DocForm = { + title: string; + contentText?: string; +}; + +export default function KnowledgeBasesPage() { + const [filterForm] = Form.useForm(); + const [form] = Form.useForm(); + const [docForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [drawerKb, setDrawerKb] = useState(null); + const [docs, setDocs] = useState([]); + const [docsLoading, setDocsLoading] = useState(false); + const [docModalOpen, setDocModalOpen] = useState(false); + const [docSaving, setDocSaving] = useState(false); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = + useAdminList( + '/admin/knowledge-bases', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.enabled) qs.set('enabled', filters.enabled); + return qs; + }, + [filters], + ); + + const loadDocs = async (kb: KnowledgeBaseDto) => { + setDrawerKb(kb); + setDocsLoading(true); + try { + const res = await request<{ items: KnowledgeDocumentDto[] }>( + `/admin/knowledge-bases/${kb.id}/documents`, + ); + setDocs(res.items); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } finally { + setDocsLoading(false); + } + }; + + const openCreate = () => { + setEditing(null); + form.setFieldsValue({ name: '', description: '', enabled: true }); + setModalOpen(true); + }; + + const openEdit = (row: KnowledgeBaseDto) => { + setEditing(row); + form.setFieldsValue({ + name: row.name, + description: row.description || '', + enabled: row.enabled, + }); + setModalOpen(true); + }; + + const onSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + if (editing) { + await request(`/admin/knowledge-bases/${editing.id}`, { + method: 'PUT', + body: JSON.stringify(values), + }); + message.success('已保存'); + } else { + await request('/admin/knowledge-bases', { + method: 'POST', + body: JSON.stringify(values), + }); + message.success('已创建'); + } + setModalOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { title: '名称', dataIndex: 'name' }, + { title: '说明', dataIndex: 'description', ellipsis: true }, + { title: '文档数', dataIndex: 'documentCount', width: 90 }, + { + title: '启用', + dataIndex: 'enabled', + width: 90, + render: (v: boolean) => (v ? : ), + }, + { + title: '创建人', + dataIndex: 'createdByName', + width: 100, + render: (v: string | null, row) => v || row.createdByHqAccountId, + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 170, + render: (t: string) => fmtTime(t), + }, + { + title: '操作', + width: 240, + render: (_, row) => ( + + + {row.canEditFull ? ( + + ) : null} + {row.canEditFull ? ( + { + try { + await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' }); + message.success('已删除'); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } + }} + > + + + ) : null} + + ), + }, + ]; + + const docColumns: ColumnsType = [ + { title: '标题', dataIndex: 'title' }, + { title: '文件', dataIndex: 'fileName', ellipsis: true }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: string) => + s === 'READY' ? 可检索 : s === 'FAILED' ? 失败 : 无正文, + }, + { + title: '时间', + dataIndex: 'createdAt', + width: 170, + render: (t: string) => fmtTime(t), + }, + { + title: '操作', + width: 80, + render: (_, row) => + drawerKb?.canEditFull ? ( + { + try { + await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, { + method: 'DELETE', + }); + message.success('已删除'); + void loadDocs(drawerKb); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } + }} + > + + + ) : null, + }, + ]; + + return ( +
+ + + 知识库 + + + 支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索 + + + +
+ setFilters({ + name: v.name || '', + enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled), + }) + } + > + + + + + + + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setModalOpen(false)} + onOk={() => void onSave()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + + + + +
+ + setDrawerKb(null)} + extra={ + drawerKb?.canEditFull ? ( + + ) : null + } + > +
+ + + setDocModalOpen(false)} + confirmLoading={docSaving} + onOk={async () => { + if (!drawerKb) return; + const values = await docForm.validateFields(); + setDocSaving(true); + try { + await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, { + method: 'POST', + body: JSON.stringify({ + title: values.title, + contentText: values.contentText || null, + }), + }); + message.success('已添加'); + setDocModalOpen(false); + void loadDocs(drawerKb); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } finally { + setDocSaving(false); + } + }} + destroyOnClose + width={640} + > +
+ + + + + + + + { + if (!drawerKb) return; + try { + const raw = file as File; + const uploaded = await uploadFileToOss(raw, { + bizType: 'KB_DOCUMENT', + mediaType: 'FILE', + }); + const title = + docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, ''); + await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, { + method: 'POST', + body: JSON.stringify({ + title, + fileName: raw.name, + fileUrl: uploaded.url, + mimeType: raw.type || null, + sizeBytes: raw.size, + }), + }); + message.success('上传成功'); + setDocModalOpen(false); + void loadDocs(drawerKb); + reload(); + onSuccess?.(uploaded); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + message.error(err.message); + onError?.(err); + } + }} + showUploadList={false} + > + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/LlmConfigsPage.tsx b/apps/admin-web/src/pages/LlmConfigsPage.tsx new file mode 100644 index 0000000..071fd68 --- /dev/null +++ b/apps/admin-web/src/pages/LlmConfigsPage.tsx @@ -0,0 +1,380 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + LLM_PROVIDERS, + LLM_PROVIDER_LABELS, + LLM_PROVIDER_PRESETS, + type LlmApiConfigDto, + type LlmProvider, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type FormValues = { + name: string; + provider: LlmProvider; + baseUrl?: string; + apiKey?: string; + modelName?: string; + temperature?: number | null; + maxTokens?: number | null; + systemPrompt?: string; + enabled: boolean; +}; + +export default function LlmConfigsPage() { + const [filterForm] = Form.useForm(); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const providerWatch = Form.useWatch('provider', form); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/llm-configs', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.enabled) qs.set('enabled', filters.enabled); + return qs; + }, + [filters], + ); + + useEffect(() => { + if (!providerWatch || editing) return; + const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider]; + if (!preset) return; + form.setFieldsValue({ + baseUrl: preset.defaultBaseUrl || undefined, + modelName: preset.defaultModel || undefined, + }); + }, [providerWatch, editing, form]); + + const openCreate = () => { + setEditing(null); + form.setFieldsValue({ + name: '', + provider: 'DEEPSEEK', + baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl, + apiKey: '', + modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel, + temperature: 0.3, + maxTokens: 1024, + systemPrompt: '', + enabled: true, + }); + setModalOpen(true); + }; + + const openEdit = (row: LlmApiConfigDto) => { + setEditing(row); + form.setFieldsValue({ + name: row.name, + provider: row.provider, + baseUrl: row.baseUrl, + apiKey: '', + modelName: row.modelName, + temperature: row.temperature, + maxTokens: row.maxTokens, + systemPrompt: row.systemPrompt || '', + enabled: row.enabled, + }); + setModalOpen(true); + }; + + const onSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + if (editing) { + if (!editing.canEditFull) { + await request(`/admin/llm-configs/${editing.id}`, { + method: 'PUT', + body: JSON.stringify({ enabled: values.enabled }), + }); + } else { + await request(`/admin/llm-configs/${editing.id}`, { + method: 'PUT', + body: JSON.stringify({ + name: values.name, + provider: values.provider, + baseUrl: values.baseUrl, + apiKey: values.apiKey || undefined, + modelName: values.modelName, + temperature: values.temperature ?? null, + maxTokens: values.maxTokens ?? null, + systemPrompt: values.systemPrompt || null, + enabled: values.enabled, + }), + }); + } + message.success('已保存'); + } else { + await request('/admin/llm-configs', { + method: 'POST', + body: JSON.stringify({ + name: values.name, + provider: values.provider, + baseUrl: values.baseUrl, + apiKey: values.apiKey, + modelName: values.modelName, + temperature: values.temperature ?? null, + maxTokens: values.maxTokens ?? null, + systemPrompt: values.systemPrompt || null, + enabled: values.enabled, + }), + }); + message.success('已创建'); + } + setModalOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { title: '名称', dataIndex: 'name' }, + { + title: '提供商', + dataIndex: 'provider', + width: 120, + render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p, + }, + { title: '模型', dataIndex: 'modelName', ellipsis: true }, + { + title: 'Key', + dataIndex: 'apiKeyConfigured', + width: 80, + render: (v: boolean) => (v ? 已配 : ), + }, + { + title: '生效', + dataIndex: 'enabled', + width: 90, + render: (v: boolean, row) => ( + { + try { + await request(`/admin/llm-configs/${row.id}`, { + method: 'PUT', + body: JSON.stringify({ enabled: checked }), + }); + message.success(checked ? '已启用' : '已停用'); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } + }} + /> + ), + }, + { + title: '创建人', + dataIndex: 'createdByName', + width: 100, + render: (v: string | null, row) => v || row.createdByHqAccountId, + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 170, + render: (t: string) => fmtTime(t), + }, + { + title: '操作', + width: 220, + render: (_, row) => ( + + + + {row.canEditFull ? ( + { + try { + await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' }); + message.success('已删除'); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : String(e)); + } + }} + > + + + ) : null} + + ), + }, + ]; + + const fullEdit = !editing || editing.canEditFull; + + return ( +
+ + + 语言模型配置 + + + 非超管仅可见自己创建的配置,且创建后只能改是否生效 + + + +
+ setFilters({ + name: v.name || '', + enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled), + }) + } + > + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setModalOpen(false)} + onOk={() => void onSave()} + confirmLoading={saving} + destroyOnClose + width={640} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/admin-web/src/pages/LogisticsBillsPage.tsx b/apps/admin-web/src/pages/LogisticsBillsPage.tsx new file mode 100644 index 0000000..2232a1d --- /dev/null +++ b/apps/admin-web/src/pages/LogisticsBillsPage.tsx @@ -0,0 +1,625 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Card, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { + LOGISTICS_SETTLEMENT_METHOD_LABELS, + type LogisticsSettlementMethod, +} from '@dukang/shared-types'; +import { fmtTime } from '../lib/constants'; +import { downloadExcelCsv } from '../lib/exportExcel'; +import { request } from '../lib/api'; +import { useAdminList } from '../lib/useAdminList'; + +type BillRow = { + id: string; + billNo: string; + fulfillmentProviderId: string; + providerCode?: string; + providerName?: string; + periodStart: string; + periodEnd: string; + orderCount: number; + bottleCount: number; + logisticsAmount: number; + settlementMethod: string; + status: string; + paidAt?: string | null; +}; + +type BillItem = { + id: string; + orderNo: string; + quantity: number; + logisticsAmount: number; + shippedAt: string; +}; + +type ProviderSummary = { + providerId: string; + providerCode: string; + providerName: string; + settlementMethod: string; + prepaidBalance: number; + bankAccountName?: string | null; + bankName?: string | null; + bankAccountNo?: string | null; + orderCount: number; + bottleCount: number; + logisticsAmount: number; + billId?: string | null; + billStatus?: string | null; + pricingRules?: { + baseBottles: number; + baseFee: number; + extraBottleFee: number; + boxBottles?: number; + boxFee?: number; + } | null; +}; + +const STATUS_LABELS: Record = { + UNPAID: '未结算', + PAID: '已结算', +}; + +const STATUS_COLORS: Record = { + UNPAID: 'red', + PAID: 'green', +}; + +export default function LogisticsBillsPage() { + const [form] = Form.useForm(); + const [summaryForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/logistics-bills', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.providerId) qs.set('providerId', filters.providerId); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + return qs; + }, + [filters], + ); + const [exporting, setExporting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [providers, setProviders] = useState>([]); + const [summaryRows, setSummaryRows] = useState([]); + const [summaryLoading, setSummaryLoading] = useState(false); + const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month')); + const [genOpen, setGenOpen] = useState(false); + const [genForm] = Form.useForm(); + const [rechargeOpen, setRechargeOpen] = useState(false); + const [rechargeTarget, setRechargeTarget] = useState(null); + const [rechargeForm] = Form.useForm(); + + useEffect(() => { + request>('/admin/fulfillment-providers') + .then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name })))) + .catch(() => {}); + }, []); + + async function loadSummary(period: Dayjs) { + setSummaryLoading(true); + try { + const res = await request<{ items: ProviderSummary[] }>( + `/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`, + ); + setSummaryRows(res.items ?? []); + } finally { + setSummaryLoading(false); + } + } + + useEffect(() => { + void loadSummary(summaryPeriod); + }, [summaryPeriod]); + + function confirmPay(ids: string[], amountHint?: number) { + Modal.confirm({ + title: '确认结算?', + content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`, + okText: '确认结算', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' }); + } else { + await request('/admin/logistics-bills/batch-confirm', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已确认结算'); + setSelectedKeys([]); + reload(); + void loadSummary(summaryPeriod); + }, + }); + } + + async function openDetail(id: string) { + const d = await request(`/admin/logistics-bills/${id}`); + setDetail(d); + setDrawerOpen(true); + } + + async function exportExcel() { + setExporting(true); + try { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.providerId) qs.set('providerId', filters.providerId); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + const result = await request<{ csv: string; count: number }>( + `/admin/logistics-bills/export?${qs}`, + ); + const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; + downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`); + message.success(`已导出 ${result.count} 条`); + } finally { + setExporting(false); + } + } + + async function generateBills() { + const v = await genForm.validateFields(); + const month = v.month as Dayjs; + await request('/admin/logistics-bills/generate', { + method: 'POST', + body: JSON.stringify({ + year: month.year(), + month: month.month() + 1, + providerId: v.providerId || undefined, + }), + }); + message.success('已生成物流对账单'); + setGenOpen(false); + reload(); + void loadSummary(summaryPeriod); + } + + async function submitRecharge() { + if (!rechargeTarget) return; + const v = await rechargeForm.validateFields(); + await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, { + method: 'POST', + body: JSON.stringify({ amount: v.amount, remark: v.remark }), + }); + message.success('充值成功'); + setRechargeOpen(false); + void loadSummary(summaryPeriod); + } + + const summary = data?.summary; + const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); + const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0); + + const billColumns: ColumnsType = [ + { title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, + { + title: '承运商', + width: 140, + render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`, + }, + { + title: '账期', + width: 200, + render: (_, r) => + `${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`, + }, + { + title: '结算方式', + dataIndex: 'settlementMethod', + width: 100, + render: (v) => + LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v, + }, + { title: '订单数', dataIndex: 'orderCount', width: 80 }, + { title: '瓶数', dataIndex: 'bottleCount', width: 80 }, + { + title: '物流费', + dataIndex: 'logisticsAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => {STATUS_LABELS[s] || s}, + }, + { + title: '操作', + width: 160, + fixed: 'right', + render: (_, row) => ( + + + {row.status === 'UNPAID' && ( + + )} + + ), + }, + ]; + + const summaryColumns: ColumnsType = [ + { + title: '承运商', + render: (_, r) => `${r.providerName} (${r.providerCode})`, + }, + { + title: '结算方式', + dataIndex: 'settlementMethod', + width: 110, + render: (v) => + LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v, + }, + { + title: '计价标准', + width: 220, + render: (_, r) => { + const p = r.pricingRules; + if (!p) return '—'; + return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${ + p.boxBottles ? `,${p.boxBottles}瓶箱¥${p.boxFee}` : '' + }`; + }, + }, + { + title: '银行账户', + width: 180, + ellipsis: true, + render: (_, r) => + r.bankAccountName + ? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}` + : '—', + }, + { title: '订单数', dataIndex: 'orderCount', width: 80 }, + { title: '瓶数', dataIndex: 'bottleCount', width: 80 }, + { + title: '应付物流费', + dataIndex: 'logisticsAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '充值余额', + dataIndex: 'prepaidBalance', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '账单', + width: 100, + render: (_, r) => + r.billStatus ? ( + + {STATUS_LABELS[r.billStatus] || r.billStatus} + + ) : ( + '未生成' + ), + }, + { + title: '操作', + width: 100, + render: (_, r) => + r.settlementMethod === 'PREPAID' ? ( + + ) : null, + }, + ]; + + return ( +
+ + + 物流对账 + + + 按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。 + + + + +
{ + const m = v.month || dayjs().subtract(1, 'month'); + setSummaryPeriod(m); + }} + > + + + + + + + +
+ + ), + }, + { + key: 'bills', + label: '月账单', + children: ( + <> + {summary && ( + + + + + + + + )} + + { + setFilters({ + status: v.status || '', + providerId: v.providerId || '', + year: v.month ? String(v.month.year()) : '', + month: v.month ? String(v.month.month() + 1) : '', + }); + setPage(1); + }} + > + + ({ + value: p.id, + label: `${p.name} (${p.code})`, + }))} + /> + + + + + + + + + + + + + + +
({ disabled: r.status !== 'UNPAID' }), + }} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + scroll={{ x: 1100 }} + /> + + ), + }, + ]} + /> + + setDrawerOpen(false)} + > + {detail && ( + <> + + {detail.billNo} + + {detail.providerName} ({detail.providerCode}) + + + {String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)} + + + ¥{Number(detail.logisticsAmount).toFixed(2)} + + + {STATUS_LABELS[detail.status] || detail.status} + + + {detail.paidAt ? fmtTime(detail.paidAt) : '—'} + + +
`¥${Number(v).toFixed(2)}`, + }, + { + title: '发货时间', + dataIndex: 'shippedAt', + width: 160, + render: fmtTime, + }, + ]} + /> + + )} + + + setGenOpen(false)} + onOk={() => void generateBills()} + destroyOnClose + > +
+ + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index e4958e3..f077be4 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -18,7 +18,7 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api'; +import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, DELIVERY_PROVIDER_LABELS, @@ -26,6 +26,7 @@ import { ORDER_STATUS_LABELS, fmtTime, } from '../lib/constants'; +import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; type ShipDefaults = { provider: string; @@ -55,6 +56,28 @@ type WarehouseOption = { lat?: number | null; }; +type OrderRedeemRecord = { + id: string; + redeemNo: string; + amount: number; + settleAmount: number; + /** 本单权益券在该核销单中的分摊额 */ + couponAmount?: number; + role?: 'PRIMARY' | 'SECONDARY'; + createdAt: string; + store?: { id: string; name: string; cityName?: string | null } | null; +}; + +type OrderRedeemSummary = { + couponNo: string; + totalAmount: number; + usedAmount: number; + balance: number; + status: string; + redeemCount: number; + redeemRecordSum: number; +}; + type OrderDetail = AdminOrderRow & { receiverAddress?: string; receiverProvince?: string; @@ -81,6 +104,8 @@ type OrderDetail = AdminOrderRow & { payment?: Record | null; statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; benefitCoupons?: Array>; + redeemSummary?: OrderRedeemSummary | null; + redeemRecords?: OrderRedeemRecord[]; fulfillmentWarehouse?: { id: string; name: string; @@ -129,12 +154,16 @@ export default function OrdersPage() { const [form] = Form.useForm(); const [shipForm] = Form.useForm(); const [logisticsForm] = Form.useForm(); + const [profile, setProfile] = useState(null); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [redeemDetail, setRedeemDetail] = useState | null>(null); + const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); + const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); @@ -146,12 +175,31 @@ export default function OrdersPage() { const [shipTarget, setShipTarget] = useState(null); const [shipMode, setShipMode] = useState('EXPRESS'); const [warehouses, setWarehouses] = useState([]); + const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), [data?.items, selectedRowKeys], ); + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); + + async function openRedeemDetail(redeemId: string) { + setRedeemDetailLoading(true); + setRedeemDrawerOpen(true); + try { + const res = await request>(`/admin/redeem-records/${redeemId}`); + setRedeemDetail(res); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载核销详情失败'); + setRedeemDrawerOpen(false); + } finally { + setRedeemDetailLoading(false); + } + } + const load = useCallback(async () => { setLoading(true); try { @@ -161,6 +209,7 @@ export default function OrdersPage() { if (values.status) qs.set('status', values.status); if (values.cityId) qs.set('cityId', values.cityId); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); + if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); const res = await request>(`/admin/orders?${qs}`); setData(res); } finally { @@ -354,9 +403,12 @@ export default function OrdersPage() { { title: '状态', dataIndex: 'status', - width: 100, - render: (s) => ( - {ORDER_STATUS_LABELS[s] || s} + width: 140, + render: (s, row) => ( + + {ORDER_STATUS_LABELS[s] || s} + {row.fulfillmentHold ? 大单待确认 : null} + ), }, { @@ -408,13 +460,15 @@ export default function OrdersPage() {
订单监控 - + {canDeleteOrders ? ( + + ) : null}
{ setPage(1); void load(); }}> @@ -436,6 +490,14 @@ export default function OrdersPage() { + +
+ v === 'SECONDARY' ? 次券 : 主券, + }, + { + title: '门店', + dataIndex: ['store', 'name'], + ellipsis: true, + render: (v: string | undefined, row) => + v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—', + }, + { + title: '本券分摊', + dataIndex: 'couponAmount', + width: 95, + render: (v: number | undefined, row) => + `¥${Number(v ?? row.amount).toFixed(2)}`, + }, + { + title: '核销总额', + dataIndex: 'amount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '结算额', + dataIndex: 'settleAmount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '时间', + dataIndex: 'createdAt', + width: 150, + render: (v: string) => fmtTime(v), + }, + { + title: '操作', + width: 70, + render: (_, row) => ( + + ), + }, + ]} + /> + + ) : ( + 该订单尚未生成权益券,无核销记录 + )} + {detail.clientIp || '—'} {[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'} @@ -618,6 +780,95 @@ export default function OrdersPage() { )} + { + setRedeemDrawerOpen(false); + setRedeemDetail(null); + }} + destroyOnClose + > + {redeemDetailLoading ? ( + 加载中… + ) : redeemDetail ? ( + + {String(redeemDetail.redeemNo ?? '—')} + + ¥{Number(redeemDetail.amount ?? 0).toFixed(2)} + + + ¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)} + + {fmtTime(String(redeemDetail.createdAt ?? ''))} + + {String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + {(redeemDetail.user as { phone?: string | null } | undefined)?.phone + ? ` / ${(redeemDetail.user as { phone?: string | null }).phone}` + : ''} + + + {String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')} + {(redeemDetail.store as { cityName?: string } | undefined)?.cityName + ? `(${(redeemDetail.store as { cityName?: string }).cityName})` + : ''} + + + {String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')} + + + {String( + (redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined) + ?.partnerAccount?.companyName ?? '—', + )} + + + {String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')} + + + {String( + (redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ?? + '—', + )} + + {Array.isArray(redeemDetail.allocations) && + (redeemDetail.allocations as unknown[]).length > 0 ? ( + + {( + redeemDetail.allocations as Array<{ + couponNo?: string; + orderNo?: string | null; + amount?: number; + sortOrder?: number; + }> + ) + .map((a, idx) => { + const role = idx === 0 ? '主' : '次'; + return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${ + a.orderNo ? `(订单 ${a.orderNo})` : '' + }`; + }) + .join(';')} + + ) : null} + {redeemDetail.payout ? ( + + ¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)} + {' / '} + {String((redeemDetail.payout as { status?: string }).status ?? '—')} + + ) : null} + {redeemDetail.rating ? ( + + 服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '} + {(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分 + + ) : null} + + ) : null} + + {shipTarget && ( <> + {shipTarget.fulfillmentHold ? ( + + ) : null} {shipTarget.city?.name || '—'} diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index 8680957..40f1b74 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -38,15 +38,17 @@ type Row = { type PartnerOption = { id: string; companyName: string; phone?: string }; const STATUS_LABELS: Record = { - DRAFT: '待合伙人确认', - CONFIRMED: '待打款审核', + PENDING_REVIEW: '待审核', + AWAITING_CONFIRM: '待合伙人确认', + UNPAID: '未打款', PAID: '已打款', REJECTED: '已驳回', }; const STATUS_COLORS: Record = { - DRAFT: 'default', - CONFIRMED: 'orange', + PENDING_REVIEW: 'gold', + AWAITING_CONFIRM: 'blue', + UNPAID: 'red', PAID: 'green', REJECTED: 'red', }; @@ -74,6 +76,7 @@ export default function PartnerBillsPage() { const [rejectTarget, setRejectTarget] = useState(null); const [rejectForm] = Form.useForm(); const [rejecting, setRejecting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); useEffect(() => { void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -81,10 +84,10 @@ export default function PartnerBillsPage() { .catch(() => {}); }, []); - async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) { + async function generateBill(values: { partnerId?: string; month: Dayjs }) { const year = values.month.year(); const month = values.month.month() + 1; - if (values.all || !values.partnerId) { + if (!values.partnerId) { const result = await request<{ success: number; failed: number; total: number }>( '/admin/partner-bills/generate-all', { method: 'POST', body: JSON.stringify({ year, month }) }, @@ -95,31 +98,71 @@ export default function PartnerBillsPage() { method: 'POST', body: JSON.stringify({ partnerId: values.partnerId, year, month }), }); - message.success('账单已生成'); + message.success('账单已生成(待审核)'); } setGenOpen(false); reload(); } - async function confirmBill(id: string) { - await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); - message.success('已代确认,进入待打款审核'); - reload(); + function sendBills(ids: string[]) { + Modal.confirm({ + title: '发送给合伙人?', + content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`, + okText: '确认发送', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' }); + } else { + await request('/admin/partner-bills/batch-send', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已发送'); + setSelectedKeys([]); + reload(); + }, + }); } - async function markPaid(id: string) { - await request(`/admin/partner-bills/${id}/mark-paid`, { - method: 'POST', - body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), + function markPaid(ids: string[], amountHint?: number) { + Modal.confirm({ + title: '确认打款?', + content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`, + okText: '确认打款', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { + method: 'POST', + body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), + }); + } else { + await request('/admin/partner-bills/batch-mark-paid', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已标记打款'); + setSelectedKeys([]); + reload(); + }, }); - message.success('已通过并标记打款'); - reload(); } function openReject(row: Row) { - setRejectTarget(row); - rejectForm.resetFields(); - setRejectOpen(true); + Modal.confirm({ + title: '驳回该账单?', + content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。', + okText: '继续填写理由', + cancelText: '取消', + onOk: () => { + setRejectTarget(row); + rejectForm.resetFields(); + setRejectOpen(true); + }, + }); } async function submitReject(values: { reason: string }) { @@ -130,7 +173,7 @@ export default function PartnerBillsPage() { method: 'POST', body: JSON.stringify({ reason: values.reason.trim() }), }); - message.success('已驳回打款申请'); + message.success('已驳回'); setRejectOpen(false); setRejectTarget(null); reload(); @@ -158,6 +201,10 @@ export default function PartnerBillsPage() { const summary = data?.summary; const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; + const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); + const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED'); + const canPay = selectedRows.filter((r) => r.status === 'UNPAID'); + const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0); const columns: ColumnsType = [ { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, @@ -194,7 +241,7 @@ export default function PartnerBillsPage() { { title: '状态', dataIndex: 'status', - width: 120, + width: 130, render: (s, row) => ( {STATUS_LABELS[s] || s} @@ -212,21 +259,26 @@ export default function PartnerBillsPage() { fixed: 'right', render: (_, row) => ( - {row.status === 'DRAFT' && ( - )} - {row.status === 'CONFIRMED' && ( + {row.status === 'UNPAID' && ( <> - )} + {row.status === 'AWAITING_CONFIRM' && ( + + )} ), }, @@ -239,7 +291,7 @@ export default function PartnerBillsPage() { 合伙人账单 - T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由) + 每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款 @@ -287,7 +339,7 @@ export default function PartnerBillsPage() {
- setGenOpen(false)} footer={null}> + setGenOpen(false)} footer={null}> @@ -378,8 +448,6 @@ export default function PartnerBillsPage() { > 账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''} -
- 驳回理由将展示在合伙人 H5 账单页。
void submitReject(v)}> - + - - -
{ setPage(p); setPageSize(ps); }, - }} - /> - - setCreateOpen(false)} - footer={null} - destroyOnClose - > -
- - - - - - - - - - - - - - -
- - ); -} +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + PROMO_CODE_SCENE_LABELS, + PROMO_CODE_STATUS_LABELS, + promoConversion, + type PromoCodeItem, + type PromoCodeScene, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = PromoCodeItem; + +type SceneOption = { value: PromoCodeScene; label: string }; + +async function downloadQrcode(url: string, filename: string) { + try { + const res = await fetch(url); + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + a.click(); + URL.revokeObjectURL(objectUrl); + } catch { + window.open(url, '_blank'); + } +} + +export default function PromoCodesPage() { + const navigate = useNavigate(); + const [filterForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [scenes, setScenes] = useState( + Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ + value: value as PromoCodeScene, + label, + })), + ); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/promo-codes', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.code) qs.set('code', filters.code); + if (filters.status) qs.set('status', filters.status); + if (filters.scene) qs.set('scene', filters.scene); + return qs; + }, + [filters], + ); + + async function loadScenes() { + try { + const list = await request('/admin/promo-codes/scenes'); + if (list.length) setScenes(list); + } catch { + /* 使用本地默认场景 */ + } + } + + useEffect(() => { + void loadScenes(); + }, []); + + const columns: ColumnsType = [ + { title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '码值', dataIndex: 'code', width: 110 }, + { + title: '场景', + dataIndex: 'scene', + width: 110, + render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => ( + + {PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s} + + ), + }, + { title: '扫码', dataIndex: 'scanCount', width: 70 }, + { title: '订单', dataIndex: 'orderCount', width: 70 }, + { + title: '转化率', + width: 90, + render: (_, row) => promoConversion(row.scanCount, row.orderCount), + }, + { + title: '渠道负责人', + width: 120, + render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', + }, + { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 220, + fixed: 'right', + render: (_, row) => ( + + + + + + ), + }, + ]; + + async function handleCreate(values: Record) { + setCreating(true); + try { + const created = await request('/admin/promo-codes', { + method: 'POST', + body: JSON.stringify({ + name: values.name, + code: values.code?.trim() || undefined, + scene: values.scene, + ownerUserId: values.ownerUserId?.trim() || undefined, + remark: values.remark?.trim() || undefined, + page: values.page?.trim() || undefined, + }), + }); + message.success('推广码已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + navigate(`/promo-codes/${created.id}`); + } catch (e) { + message.error(e instanceof Error ? e.message : '创建失败'); + } finally { + setCreating(false); + } + } + + return ( +
+
+ 推广码管理 + +
+ +
{ setFilters(v); setPage(1); }} + > + + + + ({ value, label }))} + /> + + + + +
{ setPage(p); setPageSize(ps); }, + }} + /> + + setCreateOpen(false)} + footer={null} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index 5e4c10e..f7e22fc 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { - Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, + Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; @@ -45,12 +45,33 @@ export default function StoreAccountsPage() { const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [stores, setStores] = useState([]); + const [deletingStaffId, setDeletingStaffId] = useState(null); async function loadStores() { const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); } + async function refreshDetail(accountId: string) { + const d = await request(`/admin/store-accounts/${accountId}`); + setDetail(d); + void reload(); + } + + async function deleteStaff(staffId: string) { + if (!detail) return; + setDeletingStaffId(staffId); + try { + await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' }); + message.success('子账号已删除'); + await refreshDetail(detail.id); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } finally { + setDeletingStaffId(null); + } + } + const columns: ColumnsType = [ { title: '姓名', dataIndex: 'name', width: 100 }, { title: '手机', dataIndex: 'phone', width: 120 }, @@ -218,10 +239,32 @@ export default function StoreAccountsPage() { dataIndex: 'status', render: (s) => {ACCOUNT_STATUS_LABELS[s] || s}, }, + { + title: '操作', + width: 80, + render: (_, staff) => ( + void deleteStaff(staff.id)} + > + + + ), + }, ]} /> - ) : null} + ) : ( + + 暂无子账号 + + )} )} diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index 13b5ab2..d07b49e 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -6,6 +6,7 @@ import { Descriptions, Drawer, Form, + Modal, Select, Space, Statistic, @@ -23,26 +24,26 @@ import { useAdminList } from '../lib/useAdminList'; type Row = { id: string; + billNo: string; + billDate: string; + redeemCount: number; redeemAmount: number; - payoutAmount: number; settlementRate: number; + payoutAmount: number; status: string; - expectedPayAt: string; - paidAt?: string; - createdAt: string; + paidAt?: string | null; store?: { id: string; name: string; cityName: string; phone?: string }; - redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string }; }; type StoreOption = { id: string; name: string; phone: string }; -const PAYOUT_STATUS_LABELS: Record = { - PENDING: '待打款', +const STATUS_LABELS: Record = { + UNPAID: '未打款', PAID: '已打款', }; -const PAYOUT_STATUS_COLORS: Record = { - PENDING: 'orange', +const STATUS_COLORS: Record = { + UNPAID: 'red', PAID: 'green', }; @@ -51,7 +52,7 @@ export default function StoreBillsPage() { const [filters, setFilters] = useState>({}); const [stores, setStores] = useState([]); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/store-payouts', + '/admin/store-bills', () => { const qs = new URLSearchParams(); if (filters.status) qs.set('status', filters.status); @@ -65,6 +66,8 @@ export default function StoreBillsPage() { const [detail, setDetail] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); const [exporting, setExporting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [batchLoading, setBatchLoading] = useState(false); useEffect(() => { void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -72,13 +75,37 @@ export default function StoreBillsPage() { .catch(() => {}); }, []); - async function confirmPayout(id: string) { - await request(`/admin/store-payouts/${id}/confirm`, { - method: 'POST', - body: JSON.stringify({ remark: '财务确认打款' }), + function confirmPay(ids: string[], amountHint?: number) { + Modal.confirm({ + title: '确认打款?', + content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, + okText: '确认打款', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' }); + } else { + setBatchLoading(true); + try { + await request('/admin/store-bills/batch-confirm', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } finally { + setBatchLoading(false); + } + } + message.success('已确认打款'); + setSelectedKeys([]); + reload(); + }, }); - message.success('已确认打款'); - reload(); + } + + async function openDetail(id: string) { + const d = await request>(`/admin/store-bills/${id}`); + setDetail(d); + setDrawerOpen(true); } async function exportExcel() { @@ -89,10 +116,8 @@ export default function StoreBillsPage() { if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateTo) qs.set('dateTo', filters.dateTo); - const result = await request<{ csv: string; count: number }>( - `/admin/store-payouts/export?${qs}`, - ); - downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); + const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); + downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); message.success(`已导出 ${result.count} 条`); } finally { setExporting(false); @@ -100,62 +125,56 @@ export default function StoreBillsPage() { } const summary = data?.summary; + const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); + const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0); const columns: ColumnsType = [ + { title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, { - title: '核销日期', + title: '账单日', + dataIndex: 'billDate', width: 110, - render: (_, r) => - (r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—', + render: (v) => String(v || '').slice(0, 10), }, { title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, + { title: '核销笔数', dataIndex: 'redeemCount', width: 90 }, { - title: '核销单号', - dataIndex: ['redeemRecord', 'redeemNo'], - width: 160, - ellipsis: true, - render: (v) => v || '—', + title: '核销金额', + dataIndex: 'redeemAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, }, - { title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` }, { - title: '核销比例', + title: '结算比例', dataIndex: 'settlementRate', width: 90, - render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'), + render: (v) => `${Math.round(Number(v) * 100)}%`, }, { - title: '应付门店', + title: '应付金额', dataIndex: 'payoutAmount', - width: 100, - render: (v) => `¥${v}`, + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, }, { - title: '打款状态', + title: '状态', dataIndex: 'status', - width: 100, - render: (s) => {PAYOUT_STATUS_LABELS[s] || s}, + width: 90, + render: (s) => {STATUS_LABELS[s] || s}, }, - { title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime }, { title: '操作', - width: 140, + width: 160, fixed: 'right', render: (_, row) => ( - - {row.status === 'PENDING' && ( - )} @@ -168,19 +187,19 @@ export default function StoreBillsPage() {
- 门店账单 + 门店对账单 - T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例 + 按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色 {summary && ( - - - + + + )} @@ -189,16 +208,12 @@ export default function StoreBillsPage() { form={form} layout="inline" style={{ marginBottom: 16 }} - onFinish={(v: { - storeId?: string; - status?: string; - dateRange?: [Dayjs, Dayjs]; - }) => { + onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => { setFilters({ storeId: v.storeId || '', status: v.status || '', - dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '', - dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '', + dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', + dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', }); setPage(1); }} @@ -210,21 +225,17 @@ export default function StoreBillsPage() { placeholder="全部门店" style={{ width: 200 }} optionFilterProp="label" - options={stores.map((s) => ({ - value: s.id, - label: `${s.name}(${s.phone})`, - }))} + options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))} /> - +
({ disabled: r.status !== 'UNPAID' }), + }} + scroll={{ x: 1200 }} pagination={{ current: page, pageSize, @@ -269,28 +295,44 @@ export default function StoreBillsPage() { }} /> - setDrawerOpen(false)}> + setDrawerOpen(false)} width={520}> {detail && ( - - - {String((detail.store as { name?: string })?.name ?? '—')} - - - {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} - - ¥{String(detail.redeemAmount)} - ¥{String(detail.payoutAmount)} - - {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} - - - {PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)} - - {fmtTime(String(detail.expectedPayAt))} - - {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} - - + <> + + {String(detail.billNo)} + {String(detail.billDate || '').slice(0, 10)} + ¥{Number(detail.payoutAmount ?? 0).toFixed(2)} + {STATUS_LABELS[String(detail.status)] || String(detail.status)} + + {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} + + + + 核销明细 + +
>) || []} + columns={[ + { + title: '核销单号', + render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), + }, + { + title: '金额', + dataIndex: 'payoutAmount', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + render: (s) => (s === 'PAID' ? '已打款' : '未打款'), + }, + ]} + /> + )} diff --git a/apps/admin-web/src/pages/StoreCategoriesPage.tsx b/apps/admin-web/src/pages/StoreCategoriesPage.tsx new file mode 100644 index 0000000..7838554 --- /dev/null +++ b/apps/admin-web/src/pages/StoreCategoriesPage.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; + +type CategoryNode = { + id: string; + code: string; + name: string; + sort: number; + parentId: string | null; + status: string; + children?: CategoryNode[]; +}; + +type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string }; + +function flattenTree(tree: CategoryNode[]): FlatRow[] { + const rows: FlatRow[] = []; + for (const root of tree) { + rows.push({ ...root, level: 1, children: undefined }); + for (const child of root.children ?? []) { + rows.push({ + ...child, + level: 2, + parentName: root.name, + children: undefined, + }); + } + } + return rows; +} + +export default function StoreCategoriesPage() { + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const rows = useMemo(() => flattenTree(tree), [tree]); + const rootOptions = useMemo( + () => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })), + [tree], + ); + + async function reload() { + setLoading(true); + try { + const data = await request('/admin/store-categories'); + setTree(Array.isArray(data) ? data : []); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void reload(); + }, []); + + function openCreate(parentId?: string) { + setEditing(null); + form.setFieldsValue({ + code: '', + name: '', + sort: 0, + parentId: parentId || undefined, + status: 'ACTIVE', + }); + setModalOpen(true); + } + + function openEdit(row: FlatRow) { + setEditing(row); + form.setFieldsValue({ + code: row.code, + name: row.name, + sort: row.sort, + parentId: row.parentId || undefined, + status: row.status, + }); + setModalOpen(true); + } + + async function submit() { + const values = await form.validateFields(); + const payload = { + code: String(values.code).trim().toUpperCase(), + name: String(values.name).trim(), + sort: Number(values.sort ?? 0), + parentId: values.parentId || null, + status: values.status || 'ACTIVE', + }; + if (editing) { + await request(`/admin/store-categories/${editing.id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + message.success('已保存'); + } else { + await request('/admin/store-categories', { + method: 'POST', + body: JSON.stringify(payload), + }); + message.success('已创建'); + } + setModalOpen(false); + void reload(); + } + + const columns: ColumnsType = [ + { + title: '层级', + dataIndex: 'level', + width: 80, + render: (level) => (level === 1 ? 一级 : 二级), + }, + { + title: '名称', + dataIndex: 'name', + render: (name, row) => ( + + {row.level === 2 ? `${row.parentName || ''} / ` : ''} + {name} + + ), + }, + { title: '编码', dataIndex: 'code', width: 140 }, + { title: '排序', dataIndex: 'sort', width: 80 }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => ( + {s === 'ACTIVE' ? '启用' : '停用'} + ), + }, + { + title: '操作', + width: 220, + render: (_, row) => ( + + + {row.level === 1 ? ( + + ) : null} + { + await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); + message.success('已处理'); + void reload(); + }} + > + + + + ), + }, + ]; + + return ( +
+ + + 门店分类 + 两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择 + + + + + + + +
+ + setModalOpen(false)} + onOk={() => void submit()} + destroyOnClose + > +
+ + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index d4ab23d..128d7f1 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, Button, @@ -14,13 +14,21 @@ import { Space, Steps, Table, + Tabs, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons'; import { request, type Paginated } from '../lib/api'; -import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { + ADMIN_OPTIONS_PAGE_SIZE, + RESOURCE_BIZ_TYPE_LABELS, + STORE_AUDIT_STATUS_LABELS, + STORE_STATUS_LABELS, + fmtTime, +} from '../lib/constants'; import { validateStoreCreateStep1, validateStoreCreateStep3, @@ -30,6 +38,7 @@ import { useAdminList } from '../lib/useAdminList'; import { resolveRegionBinding } from '../lib/china-region'; import ChinaRegionCascader from '../components/ChinaRegionCascader'; import OssUpload from '../components/OssUpload'; +import TencentLocPickerModal from '../components/TencentLocPickerModal'; const CREATE_STEPS = [ { title: '基本信息' }, @@ -37,6 +46,234 @@ const CREATE_STEPS = [ { title: '结算资质' }, ]; +function fillGeolocation( + setCoords: (lat: number, lng: number) => void, + setLoading: (v: boolean) => void, +) { + if (!navigator.geolocation) { + message.error('当前浏览器不支持定位'); + return; + } + setLoading(true); + navigator.geolocation.getCurrentPosition( + (pos) => { + setCoords(pos.coords.latitude, pos.coords.longitude); + message.success( + `已获取坐标 ${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`, + ); + setLoading(false); + }, + (err) => { + message.error(err.message || '定位失败'); + setLoading(false); + }, + { enableHighAccuracy: true, timeout: 10000 }, + ); +} + +type StoreMediaItem = { + id?: string; + bizType?: string; + mediaType?: string; + url?: string | null; +}; + +function isImageMedia(url: string, mediaType?: string) { + if (mediaType === 'IMAGE') return true; + if (mediaType === 'VIDEO' || mediaType === 'FILE') { + return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url); + } + return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url); +} + +function isPdfUrl(url: string) { + return /\.pdf(\?|#|$)/i.test(url); +} + +function collectMediaUrls(detail: Record) { + const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : []; + const byType = (bizType: string) => + media + .filter((item) => String(item.bizType || '').toUpperCase() === bizType) + .map((item) => ({ + id: String(item.id || item.url || ''), + url: String(item.url || '').trim(), + mediaType: item.mediaType ? String(item.mediaType) : undefined, + })) + .filter((item) => item.url); + + const covers = byType('COVER'); + const coverUrl = detail.coverUrl ? String(detail.coverUrl).trim() : ''; + if (coverUrl && !covers.some((item) => item.url === coverUrl)) { + covers.unshift({ id: 'cover', url: coverUrl, mediaType: 'IMAGE' }); + } + + return { + covers, + envs: byType('ENV'), + contracts: byType('CONTRACT'), + }; +} + +function StoreAuditMediaSection({ detail }: { detail: Record }) { + const { covers, envs, contracts } = collectMediaUrls(detail); + const [pdfUrl, setPdfUrl] = useState(null); + const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType)); + + if (covers.length === 0 && envs.length === 0 && contracts.length === 0) { + return ( + + ); + } + + return ( +
+ + 审核材料 + + + {(covers.length > 0 || envs.length > 0) && ( +
+ + 门头照 / 环境照(点击可放大浏览) + + + + {gallery.map((item) => ( +
+ +
+ {covers.some((c) => c.id === item.id) ? '门头照' : '环境照'} +
+
+ ))} +
+
+
+ )} + + {contracts.length > 0 && ( +
+ + 签约合同 + + + {contracts.map((item, index) => { + const imageLike = isImageMedia(item.url, item.mediaType); + const pdf = isPdfUrl(item.url); + return ( +
+ {imageLike ? ( + + + + ) : ( +
+ +
+ )} + + + {RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'} + {contracts.length > 1 ? ` ${index + 1}` : ''} + + + {item.url} + + + {imageLike ? ( + 点击缩略图放大查看 + ) : null} + {pdf ? ( + + ) : null} + + + +
+ ); + })} +
+
+ )} + + setPdfUrl(null)} + width={900} + footer={[ + , + , + ]} + destroyOnClose + > + {pdfUrl ? ( +