Merge commit 'ab9654564e2457db6d0c0972b1818b7b2061ac86' into dev_jacy
This commit is contained in:
@@ -1,21 +1,11 @@
|
|||||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import TabLayout from './layouts/TabLayout';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import HomePage from './pages/HomePage';
|
|
||||||
import StoreListPage from './pages/StoreListPage';
|
|
||||||
import StoreDetailPage from './pages/StoreDetailPage';
|
|
||||||
import StoreCreatePage from './pages/StoreCreatePage';
|
|
||||||
import OrderListPage from './pages/OrderListPage';
|
|
||||||
import OrderDetailPage from './pages/OrderDetailPage';
|
|
||||||
import CenterPage from './pages/CenterPage';
|
|
||||||
import BillsPage from './pages/BillsPage';
|
|
||||||
import ReshipPage from './pages/ReshipPage';
|
|
||||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
|
||||||
import PartnerAppRoutes from './PartnerAppRoutes';
|
import PartnerAppRoutes from './PartnerAppRoutes';
|
||||||
|
|
||||||
import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth';
|
import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth';
|
||||||
|
|
||||||
import { isLoggedIn } from './lib/api';
|
import { isLoggedIn } from './lib/api';
|
||||||
@@ -47,20 +37,7 @@ export default function App() {
|
|||||||
if (!result || !handlePartnerWechatLoginResult(result)) return;
|
if (!result || !handlePartnerWechatLoginResult(result)) return;
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
<Route element={<TabLayout />}>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/stores" element={<StoreListPage />} />
|
|
||||||
<Route path="/orders" element={<OrderListPage />} />
|
|
||||||
<Route path="/center" element={<CenterPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
|
||||||
<Route path="/center/bills" element={<BillsPage />} />
|
|
||||||
<Route path="/reshipments" element={<ReshipPage />} />
|
|
||||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
|
||||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
|
||||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
|
||||||
<Route path="*" element={<Navigate to={isLoggedIn() ? '/' : '/login'} replace />} />
|
|
||||||
params.delete('code');
|
params.delete('code');
|
||||||
|
|
||||||
params.delete('state');
|
params.delete('state');
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||||
|
import { isLoggedIn } from './lib/api';
|
||||||
|
import { isSubAccount } from './lib/partnerAccess';
|
||||||
|
import TabLayout from './layouts/TabLayout';
|
||||||
|
import SubAccountLayout from './layouts/SubAccountLayout';
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import StoreListPage from './pages/StoreListPage';
|
||||||
|
import StoreDetailPage from './pages/StoreDetailPage';
|
||||||
|
import StoreCreatePage from './pages/StoreCreatePage';
|
||||||
|
import OrderListPage from './pages/OrderListPage';
|
||||||
|
import OrderDetailPage from './pages/OrderDetailPage';
|
||||||
|
import CenterPage from './pages/CenterPage';
|
||||||
|
import BillsPage from './pages/BillsPage';
|
||||||
|
import SettlementPage from './pages/SettlementPage';
|
||||||
|
import ReshipPage from './pages/ReshipPage';
|
||||||
|
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||||
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
|
import StaffListPage from './pages/StaffListPage';
|
||||||
|
import StaffCreatePage from './pages/StaffCreatePage';
|
||||||
|
|
||||||
|
function SessionLoading() {
|
||||||
|
return <div className="empty">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<TabLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
|
<Route path="/orders" element={<OrderListPage />} />
|
||||||
|
<Route path="/center" element={<CenterPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
|
<Route path="/center/bills" element={<BillsPage />} />
|
||||||
|
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||||
|
<Route path="/center/staff" element={<StaffListPage />} />
|
||||||
|
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||||
|
<Route path="/reshipments" element={<ReshipPage />} />
|
||||||
|
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||||
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubAccountRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<SubAccountLayout />}>
|
||||||
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/stores/new" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PartnerAppRoutes() {
|
||||||
|
const { account, loading, loggedIn } = usePartnerSession();
|
||||||
|
|
||||||
|
if (!loggedIn && !isLoggedIn()) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return <SessionLoading />;
|
||||||
|
}
|
||||||
|
if (isSubAccount(account)) {
|
||||||
|
return <SubAccountRoutes />;
|
||||||
|
}
|
||||||
|
return <PrimaryRoutes />;
|
||||||
|
}
|
||||||
@@ -26,6 +26,17 @@ type OssUploadFieldProps = {
|
|||||||
|
|
||||||
const DEFAULT_MAX_MB = 10;
|
const DEFAULT_MAX_MB = 10;
|
||||||
|
|
||||||
|
function formatWechatUploadError(e: unknown): string {
|
||||||
|
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||||
|
if (/invalid signature/i.test(msg)) {
|
||||||
|
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
||||||
|
}
|
||||||
|
if (/permission|denied|拒绝/i.test(msg)) {
|
||||||
|
return '微信选图权限被拒绝,请在微信设置中允许相册/相机访问后重试';
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUploadField({
|
export default function OssUploadField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -48,7 +59,7 @@ export default function OssUploadField({
|
|||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||||
const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO';
|
const useWechatPicker = isWechatEnv() && mediaType === 'IMAGE';
|
||||||
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,6 +111,22 @@ export default function OssUploadField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNativeFilePicker() {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickWechatImage() {
|
||||||
|
const files = await weixinSdk.chooseImages({
|
||||||
|
count: 1,
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
});
|
||||||
|
if (files?.[0]) {
|
||||||
|
await uploadSelectedFile(files[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('未能获取图片,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
async function pickFile() {
|
async function pickFile() {
|
||||||
if (uploading || authorizing) return;
|
if (uploading || authorizing) return;
|
||||||
setError('');
|
setError('');
|
||||||
@@ -111,27 +138,17 @@ export default function OssUploadField({
|
|||||||
|
|
||||||
if (useWechatPicker) {
|
if (useWechatPicker) {
|
||||||
try {
|
try {
|
||||||
const files = await weixinSdk.chooseImages({
|
await pickWechatImage();
|
||||||
count: 1,
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
});
|
|
||||||
if (files?.[0]) {
|
|
||||||
await uploadSelectedFile(files[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError('未能获取图片,请重试');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||||
if (!/cancel/i.test(msg)) {
|
if (/cancel/i.test(msg)) return;
|
||||||
setError(/permission|auth|denied|授权|拒绝/i.test(msg)
|
setError(formatWechatUploadError(e));
|
||||||
? '微信选图授权失败,请刷新页面后重试'
|
openNativeFilePicker();
|
||||||
: msg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inputRef.current?.click();
|
openNativeFilePicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImage = mediaType === 'IMAGE' && value;
|
const isImage = mediaType === 'IMAGE' && value;
|
||||||
@@ -165,6 +182,7 @@ export default function OssUploadField({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
capture={mediaType === 'FILE' && isWechatEnv() ? 'environment' : undefined}
|
||||||
className="partner-oss-upload-input"
|
className="partner-oss-upload-input"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
|
|||||||
import { toAppPath } from '@dukang/weixin-sdk';
|
import { toAppPath } from '@dukang/weixin-sdk';
|
||||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
||||||
|
|
||||||
export type PartnerAccount = {
|
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
id: string;
|
|
||||||
name: string;
|
export type PartnerAccount = PartnerMe & {
|
||||||
phone: string;
|
staffRole?: PartnerStaffRole;
|
||||||
isPrimary?: boolean;
|
|
||||||
companyName?: string;
|
|
||||||
hasWechat?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerSessionValue = {
|
type PartnerSessionValue = {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ to: '/stores/new', icon: 'add_business', label: '录入新店' },
|
||||||
|
{ to: '/stores', end: true, icon: 'store', label: '我的门店' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function SubAccountLayout() {
|
||||||
|
const { account, logout } = usePartnerSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="header app-page-header partner-sub-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="app-page-title" style={{ fontSize: 18 }}>{account?.name || '拓店账号'}</h1>
|
||||||
|
<p className="label-md text-muted">{account?.companyName || '门店入驻'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="partner-menu-icon" onClick={logout} aria-label="退出登录">
|
||||||
|
<span className="material-symbols-outlined">logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Outlet />
|
||||||
|
<nav className="app-tabbar">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
end={'end' in tab ? tab.end : undefined}
|
||||||
|
className={({ isActive }) => `app-tabbar-item${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined app-tabbar-icon">{tab.icon}</span>
|
||||||
|
<span className="app-tabbar-label">{tab.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
export type LeaderboardEntry = {
|
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||||
rank: number;
|
import { request } from './api';
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
totalStores: number;
|
|
||||||
monthStores: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */
|
export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'total') {
|
||||||
export const LEADERBOARD_MOCK: LeaderboardEntry[] = [
|
return request<PartnerLeaderboardResponse>(
|
||||||
{ rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 },
|
'PARTNER_H5',
|
||||||
{ rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 },
|
`/partner/dashboard/leaderboard?period=${period}`,
|
||||||
{ rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 },
|
);
|
||||||
{ rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 },
|
}
|
||||||
];
|
|
||||||
|
export type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse };
|
||||||
|
export type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PartnerMe } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return account?.isPrimary !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubAccount(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return !!account && account.isPrimary === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||||
|
return isSubAccount(account) ? '/stores/new' : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/login'];
|
||||||
|
|
||||||
|
export function isSubAccountPath(pathname: string): boolean {
|
||||||
|
if (pathname === '/login') return true;
|
||||||
|
return SUB_ACCOUNT_ALLOWED_PREFIXES.some(
|
||||||
|
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type {
|
||||||
|
CreatePartnerStaffRequest,
|
||||||
|
PartnerStaffItem,
|
||||||
|
UpdatePartnerStaffRequest,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
export function listPartnerStaff() {
|
||||||
|
return request<PartnerStaffItem[]>('PARTNER_H5', '/partner/staff');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
||||||
|
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePartnerStaff(id: string, body: UpdatePartnerStaffRequest) {
|
||||||
|
return request<PartnerStaffItem>('PARTNER_H5', `/partner/staff/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePartnerStaff(id: string) {
|
||||||
|
return request<{ ok: boolean }>('PARTNER_H5', `/partner/staff/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendStaffAddSms(phone: string) {
|
||||||
|
return request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, scene: 'PARTNER_STAFF_ADD' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -116,6 +116,16 @@ export function validateStoreStep1(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateStoreStep2(
|
||||||
|
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
||||||
|
): string | null {
|
||||||
|
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||||
|
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||||
|
if (envCount < 3) return '请上传至少 3 张环境照片';
|
||||||
|
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateStoreStep3(
|
export function validateStoreStep3(
|
||||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PartnerStorePhoneAvailableResponse } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
export function checkStorePhoneAvailable(phone: string) {
|
||||||
|
return request<PartnerStorePhoneAvailableResponse>(
|
||||||
|
'PARTNER_H5',
|
||||||
|
`/partner/stores/phone-available?phone=${encodeURIComponent(phone.trim())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PartnerStorePhoneAvailableResponse };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
export function fetchPartnerWeeklyReport(startDate?: string) {
|
||||||
|
const query = startDate ? `?startDate=${encodeURIComponent(startDate)}` : '';
|
||||||
|
return request<PartnerWeeklyReportResponse>('PARTNER_H5', `/partner/reports/weekly${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PartnerWeeklyReportResponse };
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
|
||||||
export default function BillsPage() {
|
export default function BillsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -14,7 +15,10 @@ export default function BillsPage() {
|
|||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const bill = bills[0];
|
const billId = searchParams.get('id');
|
||||||
|
const bill = billId
|
||||||
|
? bills.find((b) => String(b.id) === billId) ?? bills[0]
|
||||||
|
: bills[0];
|
||||||
|
|
||||||
function confirmBill() {
|
function confirmBill() {
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
@@ -27,7 +31,7 @@ export default function BillsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-bills-page">
|
<div className="partner-bills-page">
|
||||||
<PageHeader title="待确认账单" onBack={() => navigate('/center')} />
|
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
|
||||||
|
|
||||||
<div className="partner-bill-stepper">
|
<div className="partner-bill-stepper">
|
||||||
<div className="partner-stepper-inner">
|
<div className="partner-stepper-inner">
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||||
|
|
||||||
export default function CenterPage() {
|
export default function CenterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account, logout } = usePartnerSession();
|
const { account, logout } = usePartnerSession();
|
||||||
|
const showPrimaryMenus = isPrimaryAccount(account);
|
||||||
const me = account as unknown as Record<string, unknown> | null;
|
const me = account as unknown as Record<string, unknown> | null;
|
||||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ export default function CenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/center/bills" className="partner-menu-item">
|
<Link to="/center/settlement" className="partner-menu-item">
|
||||||
<div className="partner-menu-item-left">
|
<div className="partner-menu-item-left">
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
||||||
@@ -132,6 +134,15 @@ export default function CenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{showPrimaryMenus && (
|
||||||
|
<Link to="/center/staff" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>子账号管理</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||||
import { storeStatusLabel } from '../lib/storeStatus';
|
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOME_STORE_PREVIEW_LIMIT = 6;
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
|
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||||
|
fetchPartnerLeaderboard('month')
|
||||||
|
.then((data) => setLeaderboardPreview(data.list.slice(0, 2)))
|
||||||
|
.catch(() => setLeaderboardPreview([]));
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||||
const orderCount = Number(dash?.orderCount || 0);
|
const orderCount = Number(dash?.orderCount || 0);
|
||||||
|
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
||||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||||
const revenue = orderCount * 128.45;
|
const revenue = orderCount * 128.45;
|
||||||
@@ -34,9 +42,7 @@ export default function HomePage() {
|
|||||||
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const recentFromDash = (dash?.recentStores as Array<Record<string, unknown>> | undefined) ?? [];
|
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT);
|
||||||
const displayStores = stores.length > 0 ? stores : recentFromDash;
|
|
||||||
const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page partner-home">
|
<div className="page partner-home">
|
||||||
@@ -107,7 +113,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="partner-quick-action-label">补发处理</span>
|
<span className="partner-quick-action-label">补发处理</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/center/bills" className="partner-quick-action">
|
<Link to="/center/settlement" className="partner-quick-action">
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||||
<span className="material-symbols-outlined">account_balance</span>
|
<span className="material-symbols-outlined">account_balance</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +152,35 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-home-store-section">
|
||||||
|
<div className="partner-home-store-header">
|
||||||
|
<h2 className="headline-md">辖区门店</h2>
|
||||||
|
<Link to="/stores" className="label-md text-primary">查看全部</Link>
|
||||||
|
</div>
|
||||||
|
{previewStores.length === 0 ? (
|
||||||
|
<div className="partner-home-store-empty">
|
||||||
|
<p className="label-md text-muted">暂无门店</p>
|
||||||
|
<Link to="/stores/new" className="label-md text-primary">录入新店</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
previewStores.map((s) => {
|
||||||
|
const storeId = String(s.id);
|
||||||
|
const status = String(s.status || 'PAUSED').toUpperCase();
|
||||||
|
return (
|
||||||
|
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
|
||||||
|
<div className="partner-home-store-info">
|
||||||
|
<p className="headline-md">{String(s.name || '未命名门店')}</p>
|
||||||
|
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
|
||||||
|
{storeStatusLabel(status)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="partner-expansion-card">
|
<section className="partner-expansion-card">
|
||||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||||
<div className="partner-expansion-split">
|
<div className="partner-expansion-split">
|
||||||
@@ -155,22 +190,11 @@ export default function HomePage() {
|
|||||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||||
|
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayStores.length > 0 && (
|
|
||||||
<ul className="partner-store-name-list">
|
|
||||||
<li className="partner-store-name-list-header">
|
|
||||||
<span className="label-md text-muted">门店名称</span>
|
|
||||||
<span className="label-md text-muted">状态</span>
|
|
||||||
</li>
|
|
||||||
{displayStores.slice(0, 5).map((s) => (
|
|
||||||
<li key={String(s.id)}>
|
|
||||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
|
||||||
<span className="label-md text-muted">{storeStatusLabel(String(s.status))}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="partner-leaderboard-section">
|
<div className="partner-leaderboard-section">
|
||||||
<div className="partner-leaderboard-header">
|
<div className="partner-leaderboard-header">
|
||||||
@@ -180,22 +204,26 @@ export default function HomePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
||||||
</div>
|
</div>
|
||||||
{previewLeaderboard.map((entry) => (
|
{leaderboardPreview.length === 0 ? (
|
||||||
<div key={entry.rank} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
<p className="label-md text-muted">暂无排行数据</p>
|
||||||
|
) : (
|
||||||
|
leaderboardPreview.map((entry) => (
|
||||||
|
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||||
<div className="partner-leaderboard-info">
|
<div className="partner-leaderboard-info">
|
||||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||||
{entry.name}
|
{entry.name}
|
||||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.role})</span>
|
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-leaderboard-stat">
|
<div className="partner-leaderboard-stat">
|
||||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||||
<p className="label-md text-muted">本月新增</p>
|
<p className="label-md text-muted">本月新增</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||||
|
|||||||
@@ -1,36 +1,129 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||||
|
|
||||||
|
const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [
|
||||||
|
{ key: 'month', label: '本月' },
|
||||||
|
{ key: 'lastMonth', label: '上月' },
|
||||||
|
{ key: 'total', label: '累计' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function periodStatLabel(period: PartnerLeaderboardPeriod): string {
|
||||||
|
if (period === 'month') return '本月拓店';
|
||||||
|
if (period === 'lastMonth') return '上月拓店';
|
||||||
|
return '累计拓店';
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodSubLabel(period: PartnerLeaderboardPeriod): string {
|
||||||
|
if (period === 'total') return '累计';
|
||||||
|
if (period === 'month') return '本月新增';
|
||||||
|
return '上月新增';
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeaderboardPage() {
|
export default function LeaderboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { account } = usePartnerSession();
|
||||||
|
const showStaffFab = isPrimaryAccount(account);
|
||||||
|
const [period, setPeriod] = useState<PartnerLeaderboardPeriod>('month');
|
||||||
|
const [data, setData] = useState<PartnerLeaderboardResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
fetchPartnerLeaderboard(period)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => {
|
||||||
|
setData(null);
|
||||||
|
setError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab">
|
<div className="page-no-tab">
|
||||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
|
<div className="partner-leaderboard-tabs">
|
||||||
|
{PERIOD_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
className={`partner-leaderboard-tab${period === tab.key ? ' active' : ''}`}
|
||||||
|
onClick={() => setPeriod(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
|
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
|
||||||
按拓店数排行,数据每周更新
|
按拓店数排行,数据实时更新
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ padding: '0 20px 24px' }}>
|
{loading && <div className="empty">加载中...</div>}
|
||||||
{LEADERBOARD_MOCK.map((entry) => (
|
{!loading && error && <div className="empty">{error}</div>}
|
||||||
<div key={entry.rank} className="partner-leaderboard-row">
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<div style={{ padding: '0 20px 96px' }}>
|
||||||
|
{data.self && (
|
||||||
|
<section className="partner-leaderboard-self-card">
|
||||||
|
<div className="partner-leaderboard-self-main">
|
||||||
|
<div className="partner-leaderboard-self-rank">第 {data.self.rank} 名</div>
|
||||||
|
<div>
|
||||||
|
<p className="headline-md" style={{ color: '#fff' }}>
|
||||||
|
{data.self.name}
|
||||||
|
{data.self.isSelf ? ' (我)' : ''}
|
||||||
|
</p>
|
||||||
|
<p className="label-md" style={{ color: 'rgba(255,255,255,0.8)' }}>{data.self.roleLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="partner-leaderboard-self-stats">
|
||||||
|
<span>{periodStatLabel(period)}: {data.self.periodStores} 间</span>
|
||||||
|
{data.self.beatPercent != null && (
|
||||||
|
<span>击败 {data.self.beatPercent}% 合伙人</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="headline-md" style={{ margin: '16px 0 12px' }}>实时排行</h2>
|
||||||
|
|
||||||
|
{data.list.length === 0 ? (
|
||||||
|
<div className="empty">暂无排行数据</div>
|
||||||
|
) : (
|
||||||
|
data.list.map((entry) => (
|
||||||
|
<div key={entry.accountId} className="partner-leaderboard-row">
|
||||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||||
<div className="partner-leaderboard-info">
|
<div className="partner-leaderboard-info">
|
||||||
<p className="headline-md">
|
<p className="headline-md">
|
||||||
{entry.name}
|
{entry.name}
|
||||||
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>({entry.role})</span>
|
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>
|
||||||
|
({entry.roleLabel})
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-leaderboard-stat">
|
<div className="partner-leaderboard-stat">
|
||||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||||
<p className="label-md text-muted">本月新增</p>
|
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStaffFab && (
|
||||||
|
<Link to="/center/staff" className="partner-fab-round" aria-label="子账号管理">
|
||||||
|
<span className="material-symbols-outlined">add</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { request, saveAuth } from '../lib/api';
|
import { request, saveAuth } from '../lib/api';
|
||||||
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
|
import type { PartnerMe } from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
bindPartnerWechatAfterSmsLogin,
|
bindPartnerWechatAfterSmsLogin,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
@@ -132,18 +134,20 @@ export default function LoginPage() {
|
|||||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
|
const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveAuth(data);
|
||||||
persistRememberAccount(phone);
|
persistRememberAccount(phone);
|
||||||
|
const home = partnerHomePath(data.partner);
|
||||||
if (isWechatEnv() && wxAuthorize) {
|
if (isWechatEnv() && wxAuthorize) {
|
||||||
setMsg('登录成功,正在关联微信…');
|
setMsg('登录成功,正在关联微信…');
|
||||||
await bindPartnerWechatAfterSmsLogin();
|
await bindPartnerWechatAfterSmsLogin();
|
||||||
|
navigate(home);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('/');
|
navigate(home);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(formatPartnerError(e));
|
setMsg(formatPartnerError(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||||
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function billStatusLabel(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT': return '待结算';
|
||||||
|
case 'CONFIRMED': return '审核中';
|
||||||
|
case 'PAID': return '已结算';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function billStatusClass(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT': return 'partner-settlement-status--pending';
|
||||||
|
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
||||||
|
case 'PAID': return 'partner-settlement-status--settled';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function billPeriodLabel(periodStart: string) {
|
||||||
|
const d = new Date(periodStart);
|
||||||
|
if (Number.isNaN(d.getTime())) return '—';
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function billMonthLabel(year: number, month: number) {
|
||||||
|
return `${year}年${month}月`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettlementPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||||
|
const now = new Date();
|
||||||
|
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
|
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const pending = bills.filter((b) => b.status === 'DRAFT');
|
||||||
|
const settled = bills.filter((b) => b.status === 'PAID');
|
||||||
|
const currentMonth = bills.filter((b) => {
|
||||||
|
const d = new Date(b.periodStart);
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||||
|
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||||
|
monthEstimate: currentMonth.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||||
|
};
|
||||||
|
}, [bills, now]);
|
||||||
|
|
||||||
|
const filteredBills = useMemo(() => {
|
||||||
|
return bills.filter((b) => {
|
||||||
|
const d = new Date(b.periodStart);
|
||||||
|
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
|
||||||
|
if (!matchMonth) return false;
|
||||||
|
if (statusFilter === 'all') return true;
|
||||||
|
if (statusFilter === 'pending') return b.status === 'DRAFT';
|
||||||
|
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||||
|
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [bills, month, statusFilter]);
|
||||||
|
|
||||||
|
const monthOptions = useMemo(() => {
|
||||||
|
const opts: Array<{ year: number; month: number }> = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
opts.push({ year: d.getFullYear(), month: d.getMonth() + 1 });
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}, [now]);
|
||||||
|
|
||||||
|
function openBill(bill: PartnerBillDto) {
|
||||||
|
navigate(bill.id ? `/center/bills?id=${bill.id}` : '/center/bills');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'pending', label: '待结算' },
|
||||||
|
{ key: 'settled', label: '已结算' },
|
||||||
|
{ key: 'reviewing', label: '审核中' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-no-tab partner-settlement-page">
|
||||||
|
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
|
<main className="partner-settlement-body">
|
||||||
|
<section className="partner-settlement-summary">
|
||||||
|
<div className="partner-settlement-summary-deco" aria-hidden>
|
||||||
|
<span className="material-symbols-outlined">account_balance_wallet</span>
|
||||||
|
</div>
|
||||||
|
<p className="partner-settlement-summary-label">待结算总额</p>
|
||||||
|
<div className="partner-settlement-summary-amount">
|
||||||
|
<span className="partner-settlement-currency">¥</span>
|
||||||
|
{fmtMoney(summary.pendingTotal)}
|
||||||
|
</div>
|
||||||
|
<div className="partner-settlement-summary-grid">
|
||||||
|
<div>
|
||||||
|
<p className="partner-settlement-summary-sub">累计已结算</p>
|
||||||
|
<p className="partner-settlement-summary-value">¥{fmtMoney(summary.settledTotal)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="partner-settlement-summary-divider" />
|
||||||
|
<div>
|
||||||
|
<p className="partner-settlement-summary-sub">本月预估收益</p>
|
||||||
|
<p className="partner-settlement-summary-value partner-settlement-summary-value--accent">
|
||||||
|
¥{fmtMoney(summary.monthEstimate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-settlement-filters">
|
||||||
|
<select
|
||||||
|
className="partner-settlement-month"
|
||||||
|
value={`${month.year}-${month.month}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [y, m] = e.target.value.split('-').map(Number);
|
||||||
|
setMonth({ year: y, month: m });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monthOptions.map((opt) => (
|
||||||
|
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
|
||||||
|
{billMonthLabel(opt.year, opt.month)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="partner-settlement-status-btn">
|
||||||
|
<span className="material-symbols-outlined">filter_list</span>
|
||||||
|
状态
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="partner-settlement-chips">
|
||||||
|
{statusTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
className={`partner-settlement-chip${statusFilter === tab.key ? ' partner-settlement-chip--active' : ''}`}
|
||||||
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="partner-settlement-list-section">
|
||||||
|
<h3 className="partner-settlement-list-title">对账明细记录</h3>
|
||||||
|
|
||||||
|
{filteredBills.length === 0 && (
|
||||||
|
<div className="empty" style={{ padding: '32px 0' }}>暂无对账记录</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="partner-settlement-list">
|
||||||
|
{filteredBills.map((bill) => (
|
||||||
|
<button
|
||||||
|
key={bill.id}
|
||||||
|
type="button"
|
||||||
|
className="partner-settlement-item"
|
||||||
|
onClick={() => openBill(bill)}
|
||||||
|
>
|
||||||
|
<div className="partner-settlement-item-main">
|
||||||
|
<div className="partner-settlement-item-head">
|
||||||
|
<span className="partner-settlement-item-no">{bill.billNo}</span>
|
||||||
|
<span className={`partner-settlement-status ${billStatusClass(bill.status)}`}>
|
||||||
|
{billStatusLabel(bill.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="partner-settlement-item-meta">
|
||||||
|
<span className="material-symbols-outlined">event</span>
|
||||||
|
{billPeriodLabel(bill.periodStart)}
|
||||||
|
<span className="partner-settlement-dot" />
|
||||||
|
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
|
||||||
|
</p>
|
||||||
|
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}>
|
||||||
|
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredBills.length > 0 && (
|
||||||
|
<p className="partner-settlement-footer-note">已展示全部记录</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
|
import { createPartnerStaff, sendStaffAddSms } from '../lib/staff';
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = Object.values(PartnerStaffRole);
|
||||||
|
|
||||||
|
export default function StaffCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [staffRole, setStaffRole] = useState<PartnerStaffRole>(PartnerStaffRole.INTERNAL);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
||||||
|
setMsg('请输入正确的11位手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
await sendStaffAddSms(phone.trim());
|
||||||
|
setMsg('验证码已发送');
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setMsg('请填写真实姓名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
||||||
|
setMsg('请输入正确的11位手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
setMsg('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
await createPartnerStaff({
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
staffRole,
|
||||||
|
code: code.trim(),
|
||||||
|
});
|
||||||
|
navigate('/center/staff');
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-no-tab partner-staff-create-page">
|
||||||
|
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
|
||||||
|
|
||||||
|
<main style={{ padding: '16px 20px 120px' }}>
|
||||||
|
{msg && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{msg}</p>}
|
||||||
|
|
||||||
|
<div className="partner-form-card">
|
||||||
|
<label className="partner-form-label">真实姓名 *</label>
|
||||||
|
<div className="partner-form-input-wrap">
|
||||||
|
<input
|
||||||
|
className="partner-form-input"
|
||||||
|
placeholder="请输入员工姓名"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="material-symbols-outlined">person</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-form-card">
|
||||||
|
<label className="partner-form-label">手机号码 *</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
className="partner-form-input"
|
||||||
|
placeholder="请输入11位手机号"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-outline" disabled={sending} onClick={() => void sendCode()}>
|
||||||
|
{sending ? '发送中' : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="partner-form-input"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
placeholder="验证码"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-form-card">
|
||||||
|
<label className="partner-form-label">分配角色 *</label>
|
||||||
|
<div className="partner-form-input-wrap">
|
||||||
|
<select
|
||||||
|
className="partner-form-input"
|
||||||
|
value={staffRole}
|
||||||
|
onChange={(e) => setStaffRole(e.target.value as PartnerStaffRole)}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role} value={role}>{PARTNER_STAFF_ROLE_LABELS[role]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="material-symbols-outlined">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||||
|
创建即代表同意合伙人管理协议。子账号默认禁用,需手动启用。
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="partner-sticky-footer">
|
||||||
|
<button type="button" className="partner-btn-primary" disabled={submitting} onClick={() => void submit()}>
|
||||||
|
<span className="material-symbols-outlined">person_add</span>
|
||||||
|
{submitting ? '创建中...' : '确认创建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import {
|
||||||
|
AccountStatus,
|
||||||
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
|
PartnerStaffRole,
|
||||||
|
type PartnerStaffItem,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
|
||||||
|
|
||||||
|
export default function StaffListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [staff, setStaff] = useState<PartnerStaffItem[]>([]);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadStaff = useCallback(() => {
|
||||||
|
return listPartnerStaff().then(setStaff).catch((e) => {
|
||||||
|
setError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStaff();
|
||||||
|
}, [loadStaff]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => staff.filter((item) => {
|
||||||
|
if (!q.trim()) return true;
|
||||||
|
const keyword = q.trim();
|
||||||
|
return item.name.includes(keyword) || item.phone.includes(keyword);
|
||||||
|
}), [staff, q]);
|
||||||
|
|
||||||
|
async function toggleStatus(item: PartnerStaffItem) {
|
||||||
|
const next = item.status === AccountStatus.ACTIVE ? AccountStatus.DISABLED : AccountStatus.ACTIVE;
|
||||||
|
setBusyId(item.id);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await updatePartnerStaff(item.id, { status: next });
|
||||||
|
await loadStaff();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStaff(item: PartnerStaffItem) {
|
||||||
|
const ok = window.confirm(`确认删除子账号「${item.name}」?`);
|
||||||
|
if (!ok) return;
|
||||||
|
setBusyId(item.id);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await deletePartnerStaff(item.id);
|
||||||
|
await loadStaff();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editStaff(item: PartnerStaffItem) {
|
||||||
|
const name = window.prompt('修改姓名', item.name);
|
||||||
|
if (name == null || !name.trim()) return;
|
||||||
|
const roleOptions = Object.values(PartnerStaffRole);
|
||||||
|
const roleLabels = roleOptions.map((r) => PARTNER_STAFF_ROLE_LABELS[r]).join(' / ');
|
||||||
|
const roleInput = window.prompt(`分配角色(${roleLabels})`, item.staffRole);
|
||||||
|
if (roleInput == null) return;
|
||||||
|
const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
|
||||||
|
if (!staffRole) {
|
||||||
|
setError('无效的角色');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyId(item.id);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
|
||||||
|
await loadStaff();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '编辑失败');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-no-tab partner-staff-page">
|
||||||
|
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px 16px' }}>
|
||||||
|
<div className="partner-search">
|
||||||
|
<span className="material-symbols-outlined">search</span>
|
||||||
|
<input placeholder="搜索姓名或手机号" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px 100px' }}>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="empty">
|
||||||
|
<p>暂无子账号</p>
|
||||||
|
<p className="label-md text-muted">点击下方按钮添加首个子账号</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const active = item.status === AccountStatus.ACTIVE;
|
||||||
|
const busy = busyId === item.id;
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="partner-staff-card">
|
||||||
|
<div className="partner-staff-avatar">{item.name.slice(0, 1)}</div>
|
||||||
|
<div className="partner-staff-info">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span className="headline-md">{item.name}</span>
|
||||||
|
<span className="partner-role-badge partner-role-badge--subtle">
|
||||||
|
{PARTNER_STAFF_ROLE_LABELS[item.staffRole]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted">{item.phone}</p>
|
||||||
|
</div>
|
||||||
|
<div className="partner-staff-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`partner-toggle${active ? ' partner-toggle--on' : ''}`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={active ? '禁用' : '启用'}
|
||||||
|
onClick={() => void toggleStatus(item)}
|
||||||
|
>
|
||||||
|
<span className="partner-toggle-thumb" />
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button type="button" className="partner-icon-btn" disabled={busy} onClick={() => void editStaff(item)}>
|
||||||
|
<span className="material-symbols-outlined">edit</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="partner-icon-btn partner-icon-btn--danger" disabled={busy} onClick={() => void removeStaff(item)}>
|
||||||
|
<span className="material-symbols-outlined">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-sticky-footer">
|
||||||
|
<Link to="/center/staff/new" className="partner-btn-primary" style={{ display: 'flex', textDecoration: 'none' }}>
|
||||||
|
<span className="material-symbols-outlined">person_add</span>
|
||||||
|
添加子账号
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { isSubAccount } from '../lib/partnerAccess';
|
||||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||||
|
|
||||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||||
@@ -10,6 +12,8 @@ const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
|||||||
export default function StoreDetailPage() {
|
export default function StoreDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { account } = usePartnerSession();
|
||||||
|
const subReadonly = isSubAccount(account);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||||
@@ -107,7 +111,7 @@ export default function StoreDetailPage() {
|
|||||||
const envPhotos = Array.isArray(store.media)
|
const envPhotos = Array.isArray(store.media)
|
||||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
||||||
: [];
|
: [];
|
||||||
const readOnly = status === 'CLOSED';
|
const readOnly = subReadonly || status === 'CLOSED';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page">
|
<div className="partner-detail-page">
|
||||||
@@ -116,6 +120,7 @@ export default function StoreDetailPage() {
|
|||||||
<main style={{ padding: '16px 20px' }}>
|
<main style={{ padding: '16px 20px' }}>
|
||||||
{error && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{error}</p>}
|
{error && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{error}</p>}
|
||||||
|
|
||||||
|
{!subReadonly && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
||||||
@@ -137,10 +142,11 @@ export default function StoreDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{readOnly && (
|
{status === 'CLOSED' && (
|
||||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>门店已关闭,不可再变更状态或编辑资料</p>
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>门店已关闭,不可再变更状态或编辑资料</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||||
@@ -204,10 +210,12 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||||
|
{!subReadonly && (
|
||||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">save</span>
|
<span className="material-symbols-outlined">save</span>
|
||||||
{saving ? '保存中…' : '保存修改'}
|
{saving ? '保存中…' : '保存修改'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{toast && <div className="partner-toast">{toast}</div>}
|
{toast && <div className="partner-toast">{toast}</div>}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { isSubAccount } from '../lib/partnerAccess';
|
||||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||||
|
|
||||||
type StatusFilter = 'ALL' | StoreStatusValue;
|
type StatusFilter = 'ALL' | StoreStatusValue;
|
||||||
@@ -14,6 +16,8 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
|
|||||||
|
|
||||||
export default function StoreListPage() {
|
export default function StoreListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { account } = usePartnerSession();
|
||||||
|
const readonly = isSubAccount(account);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [filter, setFilter] = useState<StatusFilter>('ALL');
|
const [filter, setFilter] = useState<StatusFilter>('ALL');
|
||||||
@@ -62,8 +66,8 @@ export default function StoreListPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="partner-page-title-block">
|
<div className="partner-page-title-block">
|
||||||
<h2>门店管理</h2>
|
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
|
||||||
<p className="text-muted body-md">管理您的合作门店及其运营状态</p>
|
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||||
@@ -111,6 +115,7 @@ export default function StoreListPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{!readonly && (
|
||||||
<div className="partner-store-card-actions">
|
<div className="partner-store-card-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -145,6 +150,7 @@ export default function StoreListPage() {
|
|||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,42 +1,215 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtGrowth(n: number) {
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
return `${sign}${n}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
|
|
||||||
export default function WeeklyReportPage() {
|
export default function WeeklyReportPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [selectedStart, setSelectedStart] = useState<string | undefined>();
|
||||||
|
const [data, setData] = useState<PartnerWeeklyReportResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
fetchPartnerWeeklyReport(selectedStart)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => {
|
||||||
|
setData(null);
|
||||||
|
setError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selectedStart]);
|
||||||
|
|
||||||
|
const maxDailyGmv = useMemo(() => {
|
||||||
|
if (!data?.dailyGmv.length) return 1;
|
||||||
|
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const topStores = data?.storeRanking.slice(0, 3) ?? [];
|
||||||
|
const summary = data?.summary;
|
||||||
|
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab">
|
<div className="page-no-tab">
|
||||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
<main style={{ padding: '0 20px 24px' }}>
|
<main className="partner-weekly-page">
|
||||||
<section className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
<section className="partner-weekly-title-section">
|
||||||
<p className="partner-revenue-label">近 7 日 GMV (CNY)</p>
|
<h2>经营数据周报</h2>
|
||||||
<div className="partner-revenue-amount">{fmtMoney(128450)}</div>
|
{data && (
|
||||||
|
<div className="partner-weekly-tabs">
|
||||||
|
{data.availablePeriods.map((period) => (
|
||||||
|
<button
|
||||||
|
key={period.startDate}
|
||||||
|
type="button"
|
||||||
|
className={`partner-weekly-tab${
|
||||||
|
period.startDate === data.period.startDate ? ' active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedStart(period.startDate)}
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-bento-card" style={{ marginBottom: 12 }}>
|
{loading && <p className="label-md text-muted partner-weekly-state">加载中…</p>}
|
||||||
<div className="partner-expansion-split">
|
{!loading && error && (
|
||||||
|
<p className="label-md text-error partner-weekly-state">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && data && summary && (
|
||||||
|
<>
|
||||||
|
<section className="partner-weekly-hero">
|
||||||
|
<div className="partner-weekly-hero-top">
|
||||||
<div>
|
<div>
|
||||||
<p className="label-md text-muted">购酒订单量</p>
|
<p className="partner-weekly-hero-label">累计成交额 (GMV)</p>
|
||||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>86</p>
|
<p className="partner-weekly-hero-gmv">¥{fmtMoney(summary.gmv)}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`partner-weekly-growth-badge${
|
||||||
|
growthPositive ? ' partner-weekly-growth-badge--up' : ' partner-weekly-growth-badge--down'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">
|
||||||
|
{growthPositive ? 'trending_up' : 'trending_down'}
|
||||||
|
</span>
|
||||||
|
<span>{fmtGrowth(summary.gmvGrowthPercent)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="partner-weekly-hero-grid">
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">活跃门店</p>
|
||||||
|
<p className="partner-weekly-stat-value">
|
||||||
|
{summary.activeStoreCount}
|
||||||
|
<span className="partner-weekly-stat-sub">/ {summary.totalStoreCount}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="label-md text-muted">活跃门店数</p>
|
<p className="label-md text-muted">订单总量</p>
|
||||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>12</p>
|
<p className="partner-weekly-stat-value">{summary.orderCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-bento-card">
|
<section className="partner-weekly-card">
|
||||||
<h2 className="headline-md" style={{ marginBottom: 12 }}>本周新签门店</h2>
|
<div className="partner-weekly-card-header">
|
||||||
<p className="headline-lg text-primary">3 家</p>
|
<div className="partner-weekly-card-title">
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>详细趋势图待 API 接入后展示</p>
|
<span className="material-symbols-outlined partner-weekly-icon-amber">storefront</span>
|
||||||
|
<h3 className="headline-md">本周新签约门店</h3>
|
||||||
|
</div>
|
||||||
|
<p className="partner-weekly-target">
|
||||||
|
{summary.newStoreCount}
|
||||||
|
<span className="partner-weekly-target-sub">/ {summary.newStoreTarget}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="partner-weekly-progress-track">
|
||||||
|
<div
|
||||||
|
className="partner-weekly-progress-bar"
|
||||||
|
style={{ width: `${summary.newStoreProgressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted partner-weekly-progress-hint">
|
||||||
|
已完成本周目标的 {summary.newStoreProgressPercent}%。
|
||||||
|
{summary.newStoreCount < summary.newStoreTarget
|
||||||
|
? `还差 ${summary.newStoreTarget - summary.newStoreCount} 家!`
|
||||||
|
: '目标已达成!'}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-weekly-card">
|
||||||
|
<div className="partner-weekly-card-header">
|
||||||
|
<h3 className="headline-md">每日成交趋势</h3>
|
||||||
|
<span className="label-md text-muted">过去 7 天</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-weekly-chart">
|
||||||
|
<div className="partner-weekly-chart-bars">
|
||||||
|
{data.dailyGmv.map((item) => {
|
||||||
|
const height = Math.max(8, Math.round((item.amount / maxDailyGmv) * 100));
|
||||||
|
const isMax = item.amount === maxDailyGmv && item.amount > 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.date}
|
||||||
|
className={`partner-weekly-chart-bar${isMax ? ' partner-weekly-chart-bar--peak' : ''}`}
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
title={`${item.weekdayLabel} ¥${fmtMoney(item.amount)}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="partner-weekly-chart-labels">
|
||||||
|
{data.dailyGmv.map((item) => (
|
||||||
|
<span key={item.date}>{item.weekdayLabel}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-weekly-rank-section">
|
||||||
|
<div className="partner-weekly-card-header">
|
||||||
|
<h3 className="headline-md">门店业绩排行</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-weekly-link"
|
||||||
|
onClick={() => navigate('/stores')}
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
<span className="material-symbols-outlined">arrow_forward</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{topStores.length === 0 ? (
|
||||||
|
<p className="label-md text-muted partner-weekly-empty">本周暂无核销排行数据</p>
|
||||||
|
) : (
|
||||||
|
<div className="partner-weekly-rank-list">
|
||||||
|
{topStores.map((store) => (
|
||||||
|
<div
|
||||||
|
key={store.storeId}
|
||||||
|
className={`partner-weekly-rank-row${
|
||||||
|
store.rank === 1 ? ' partner-weekly-rank-row--first' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="partner-weekly-rank-badge">
|
||||||
|
{store.rank === 1 ? (
|
||||||
|
<span className="material-symbols-outlined">workspace_premium</span>
|
||||||
|
) : (
|
||||||
|
<span>{store.rank}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="partner-weekly-rank-info">
|
||||||
|
<p className="partner-weekly-rank-name">{store.name}</p>
|
||||||
|
{store.subtitle && (
|
||||||
|
<p className="label-md text-muted">{store.subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="partner-weekly-rank-amount">¥{fmtMoney(store.redeemAmount)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-weekly-insight">
|
||||||
|
<span className="material-symbols-outlined">tips_and_updates</span>
|
||||||
|
<div>
|
||||||
|
<p className="headline-md">合伙人经营策略</p>
|
||||||
|
<p className="body-md text-muted">{data.insight}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+915
-16
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,17 @@ export enum StoreStatus {
|
|||||||
CLOSED = 'CLOSED',
|
CLOSED = 'CLOSED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PartnerStaffRole {
|
||||||
|
PARTNER = 'PARTNER',
|
||||||
|
INTERNAL = 'INTERNAL',
|
||||||
|
PROMOTER = 'PROMOTER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AccountStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
DISABLED = 'DISABLED',
|
||||||
|
}
|
||||||
|
|
||||||
export enum BenefitCouponStatus {
|
export enum BenefitCouponStatus {
|
||||||
ACTIVE = 'ACTIVE',
|
ACTIVE = 'ACTIVE',
|
||||||
USED_UP = 'USED_UP',
|
USED_UP = 'USED_UP',
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export * from './store-log';
|
|||||||
export * from './partner-log';
|
export * from './partner-log';
|
||||||
export * from './promo';
|
export * from './promo';
|
||||||
export * from './hq-permissions';
|
export * from './hq-permissions';
|
||||||
|
export * from './partner';
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { AccountStatus, PartnerStaffRole } from './enums';
|
||||||
|
|
||||||
|
export interface PartnerMe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
companyName?: string;
|
||||||
|
hasWechat?: boolean;
|
||||||
|
staffRole?: PartnerStaffRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerStaffItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
staffRole: PartnerStaffRole;
|
||||||
|
status: AccountStatus;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePartnerStaffRequest {
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
staffRole: PartnerStaffRole;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePartnerStaffRequest {
|
||||||
|
name?: string;
|
||||||
|
staffRole?: PartnerStaffRole;
|
||||||
|
status?: AccountStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PARTNER_STAFF_ROLE_LABELS: Record<PartnerStaffRole, string> = {
|
||||||
|
PARTNER: '城市合伙人',
|
||||||
|
INTERNAL: '内部员工',
|
||||||
|
PROMOTER: '推广员',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartnerLeaderboardPeriod = 'month' | 'lastMonth' | 'total';
|
||||||
|
|
||||||
|
export interface PartnerLeaderboardEntry {
|
||||||
|
rank: number;
|
||||||
|
accountId: string;
|
||||||
|
name: string;
|
||||||
|
staffRole?: PartnerStaffRole;
|
||||||
|
roleLabel: string;
|
||||||
|
totalStores: number;
|
||||||
|
periodStores: number;
|
||||||
|
isSelf?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerLeaderboardResponse {
|
||||||
|
period: PartnerLeaderboardPeriod;
|
||||||
|
list: PartnerLeaderboardEntry[];
|
||||||
|
self?: PartnerLeaderboardEntry & { beatPercent?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerStorePhoneAvailableResponse {
|
||||||
|
available: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerWeeklyReportPeriod {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerWeeklyReportDailyGmv {
|
||||||
|
date: string;
|
||||||
|
weekdayLabel: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerWeeklyReportStoreRank {
|
||||||
|
rank: number;
|
||||||
|
storeId: string;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
redeemAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerWeeklyReportResponse {
|
||||||
|
period: PartnerWeeklyReportPeriod;
|
||||||
|
availablePeriods: PartnerWeeklyReportPeriod[];
|
||||||
|
summary: {
|
||||||
|
gmv: number;
|
||||||
|
gmvGrowthPercent: number;
|
||||||
|
activeStoreCount: number;
|
||||||
|
totalStoreCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
newStoreCount: number;
|
||||||
|
newStoreTarget: number;
|
||||||
|
newStoreProgressPercent: number;
|
||||||
|
};
|
||||||
|
dailyGmv: PartnerWeeklyReportDailyGmv[];
|
||||||
|
storeRanking: PartnerWeeklyReportStoreRank[];
|
||||||
|
insight: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getRuntimePlatform } from './env';
|
import { getRuntimePlatform } from './env';
|
||||||
import { ensureJssdkReady } from './jssdk';
|
import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
|
|
||||||
export type ChooseWechatImageOptions = {
|
export type ChooseWechatImageOptions = {
|
||||||
@@ -32,7 +32,7 @@ async function reportChooseImageEvent(
|
|||||||
errMsg: payload.errMsg,
|
errMsg: payload.errMsg,
|
||||||
sourceType: payload.sourceType,
|
sourceType: payload.sourceType,
|
||||||
stage: payload.stage,
|
stage: payload.stage,
|
||||||
pageUrl: typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined,
|
pageUrl: typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -84,7 +84,7 @@ export async function chooseWechatImages(
|
|||||||
const sourceType = options.sourceType ?? ['album', 'camera'];
|
const sourceType = options.sourceType ?? ['album', 'camera'];
|
||||||
|
|
||||||
if (platform === 'wechat-h5') {
|
if (platform === 'wechat-h5') {
|
||||||
const pageUrl = typeof window !== 'undefined' ? window.location.href.split('#')[0] : '';
|
const pageUrl = typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : '';
|
||||||
const sourceTypeKey = sourceType.join(',');
|
const sourceTypeKey = sourceType.join(',');
|
||||||
try {
|
try {
|
||||||
await ensureJssdkReady({
|
await ensureJssdkReady({
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
| P1 | 合伙人订单中心页 | b7c630ed | `/orders` | partner/20_* | ui-aligned |
|
| P1 | 合伙人订单中心页 | b7c630ed | `/orders` | partner/20_* | ui-aligned |
|
||||||
| P1 | 城市合伙人订单详情页 | 423aac86 | `/orders/:id` | partner/18_* | ui-aligned |
|
| P1 | 城市合伙人订单详情页 | 423aac86 | `/orders/:id` | partner/18_* | ui-aligned |
|
||||||
| P1 | 合伙人中心 | c31c874b | `/center` | partner/04_* | ui-aligned |
|
| P1 | 合伙人中心 | c31c874b | `/center` | partner/04_* | ui-aligned |
|
||||||
|
| P1 | 财务对账中心 | 0e41c1b4 | `/center/settlement` | _archive/财务对账页_中文版 | ui-aligned |
|
||||||
| P1 | 合伙人待确认账单页 | f9b208b8 | `/center/bills` | partner/14_* | ui-aligned |
|
| P1 | 合伙人待确认账单页 | f9b208b8 | `/center/bills` | partner/14_* | ui-aligned |
|
||||||
|
|
||||||
## 总部管理端 mini-hq(Taro,先 H5 后小程序)
|
## 总部管理端 mini-hq(Taro,先 H5 后小程序)
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ packages:
|
|||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'server/*'
|
- 'server/*'
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@alicloud/openapi-core': set this to true or false
|
'@alicloud/openapi-core': true
|
||||||
'@nestjs/core': true
|
'@nestjs/core': true
|
||||||
'@parcel/watcher': true
|
'@parcel/watcher': true
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ model Partner {
|
|||||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||||
|
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ async function main() {
|
|||||||
bankAccountName: '郑州合伙人公司',
|
bankAccountName: '郑州合伙人公司',
|
||||||
bankAccountNo: '6222021234567890',
|
bankAccountNo: '6222021234567890',
|
||||||
bankBranch: '工商银行郑州分行',
|
bankBranch: '工商银行郑州分行',
|
||||||
|
weeklyStoreTarget: 20,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +151,22 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const primaryAccount = await prisma.partnerAccount.findUniqueOrThrow({
|
||||||
|
where: { phone: '13700000001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.partnerAccount.create({
|
||||||
|
data: {
|
||||||
|
partnerId: partner.id,
|
||||||
|
phone: '13700000002',
|
||||||
|
name: '拓店员小李',
|
||||||
|
isPrimary: 0,
|
||||||
|
parentAccountId: primaryAccount.id,
|
||||||
|
staffRole: 'INTERNAL',
|
||||||
|
status: 'DISABLED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const storeDefs = [
|
const storeDefs = [
|
||||||
{
|
{
|
||||||
name: '郑州老城店',
|
name: '郑州老城店',
|
||||||
@@ -173,6 +190,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const createdStores: { id: bigint; name: string }[] = [];
|
||||||
for (const def of storeDefs) {
|
for (const def of storeDefs) {
|
||||||
const store = await prisma.store.create({
|
const store = await prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -201,8 +219,42 @@ async function main() {
|
|||||||
data: { storeId: store.id, phone: def.phone, name: def.name },
|
data: { storeId: store.id, phone: def.phone, name: def.name },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
createdStores.push({ id: store.id, name: def.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weekStart = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(12, 0, 0, 0);
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = day === 0 ? 6 : day - 1;
|
||||||
|
d.setDate(d.getDate() - diff);
|
||||||
|
d.setHours(10, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const newWeekStore = await prisma.store.create({
|
||||||
|
data: {
|
||||||
|
cityId: city.id,
|
||||||
|
partnerId: partner.id,
|
||||||
|
categoryId: categories[0].id,
|
||||||
|
name: '本周新签体验店',
|
||||||
|
phone: '13910000003',
|
||||||
|
province: '河南省',
|
||||||
|
cityName: '郑州市',
|
||||||
|
district: '中原区',
|
||||||
|
address: '建设路88号',
|
||||||
|
intro: '本周新签约门店',
|
||||||
|
status: 'OPEN',
|
||||||
|
openTime: '10:00',
|
||||||
|
closeTime: '22:00',
|
||||||
|
bankAccountName: '本周新签体验店',
|
||||||
|
bankAccountNo: '6222029876543211',
|
||||||
|
bankBranch: '农业银行郑州分行',
|
||||||
|
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
userNo: 'DK88293401',
|
userNo: 'DK88293401',
|
||||||
@@ -244,6 +296,91 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
|
||||||
|
const product = products[0];
|
||||||
|
|
||||||
|
const orderDefs = [
|
||||||
|
{ dayOffset: 0, payAmount: 1198, quantity: 2 },
|
||||||
|
{ dayOffset: 1, payAmount: 599, quantity: 1 },
|
||||||
|
{ dayOffset: 2, payAmount: 1760, quantity: 2 },
|
||||||
|
{ dayOffset: 4, payAmount: 1299, quantity: 1 },
|
||||||
|
{ dayOffset: 5, payAmount: 880, quantity: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const coupons: { id: bigint; balance: number }[] = [];
|
||||||
|
for (const [index, def] of orderDefs.entries()) {
|
||||||
|
const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
|
||||||
|
const listAmount = def.payAmount;
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
orderNo: `WK${Date.now()}${index}`,
|
||||||
|
userId: user.id,
|
||||||
|
cityId: city.id,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
payStatus: 'PAID',
|
||||||
|
deliveryType: 'LOCAL',
|
||||||
|
productId: product.id,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
productName: product.name,
|
||||||
|
productSpec: product.spec,
|
||||||
|
quantity: def.quantity,
|
||||||
|
listUnitPrice: product.price,
|
||||||
|
listAmount,
|
||||||
|
productAmount: listAmount,
|
||||||
|
payAmount: listAmount,
|
||||||
|
benefitAmount: listAmount,
|
||||||
|
receiverName: '测试用户',
|
||||||
|
receiverPhone: user.phone,
|
||||||
|
receiverAddress: '郑州市金水区测试路1号',
|
||||||
|
receiverProvince: '河南省',
|
||||||
|
receiverCity: '郑州市',
|
||||||
|
receiverDistrict: '金水区',
|
||||||
|
paidAt,
|
||||||
|
completedAt: paidAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const coupon = await prisma.benefitCoupon.create({
|
||||||
|
data: {
|
||||||
|
couponNo: `CPN${Date.now()}${index}`,
|
||||||
|
userId: user.id,
|
||||||
|
orderId: order.id,
|
||||||
|
totalAmount: listAmount,
|
||||||
|
balance: listAmount,
|
||||||
|
sourceProduct: product.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
coupons.push({ id: coupon.id, balance: listAmount });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redeemDefs = [
|
||||||
|
{ storeIndex: 0, amount: 42800, dayOffset: 1 },
|
||||||
|
{ storeIndex: 1, amount: 38400, dayOffset: 2 },
|
||||||
|
{ storeIndex: 2, amount: 31200, dayOffset: 3 },
|
||||||
|
];
|
||||||
|
for (const [index, def] of redeemDefs.entries()) {
|
||||||
|
const coupon = coupons[index];
|
||||||
|
const store = createdStores[def.storeIndex];
|
||||||
|
const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000);
|
||||||
|
await prisma.redeemRecord.create({
|
||||||
|
data: {
|
||||||
|
redeemNo: `RD${Date.now()}${index}`,
|
||||||
|
userId: user.id,
|
||||||
|
couponId: coupon.id,
|
||||||
|
storeId: store.id,
|
||||||
|
amount: def.amount,
|
||||||
|
settleAmount: Math.round(def.amount * 0.6 * 100) / 100,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.benefitCoupon.update({
|
||||||
|
where: { id: coupon.id },
|
||||||
|
data: {
|
||||||
|
usedAmount: def.amount,
|
||||||
|
balance: Math.max(0, coupon.balance - def.amount),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.module';
|
||||||
|
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartnerPrimaryGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly jwtAuthGuard: JwtAuthGuard,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
this.jwtAuthGuard.canActivate(context);
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req.user as AuthUser;
|
||||||
|
if (user.actorType !== 'PARTNER') {
|
||||||
|
throw new ForbiddenException('仅合伙人主账号可操作');
|
||||||
|
}
|
||||||
|
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
|
where: { id: user.actorId },
|
||||||
|
});
|
||||||
|
if (account.isPrimary !== 1) {
|
||||||
|
throw new ForbiddenException('仅主账号可操作');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,8 +217,14 @@ export class AuthService {
|
|||||||
if (existing) throw new BadRequestException('该手机号已绑定门店');
|
if (existing) throw new BadRequestException('该手机号已绑定门店');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) {
|
if (scene === SmsScene.PARTNER_LOGIN) {
|
||||||
await this.assertPartnerAccountByPhone(phone);
|
await this.assertPartnerAccountByPhone(phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scene === SmsScene.PARTNER_STAFF_ADD) {
|
||||||
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||||
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,6 +608,7 @@ export class AuthService {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
phone: account.phone,
|
phone: account.phone,
|
||||||
isPrimary: account.isPrimary === 1,
|
isPrimary: account.isPrimary === 1,
|
||||||
|
staffRole: account.staffRole ?? undefined,
|
||||||
companyName: account.partner.companyName,
|
companyName: account.partner.companyName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1159,7 @@ export class AuthService {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
phone: account.phone,
|
phone: account.phone,
|
||||||
isPrimary: account.isPrimary === 1,
|
isPrimary: account.isPrimary === 1,
|
||||||
|
staffRole: account.staffRole ?? undefined,
|
||||||
companyName: account.partner.companyName,
|
companyName: account.partner.companyName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export class CreatePartnerStaffDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(Object.values(PartnerStaffRole))
|
||||||
|
staffRole: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePartnerStaffDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(Object.values(PartnerStaffRole))
|
||||||
|
@IsOptional()
|
||||||
|
staffRole?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(Object.values(AccountStatus))
|
||||||
|
@IsOptional()
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
@@ -11,11 +11,14 @@ import {
|
|||||||
} from './auth.controller';
|
} from './auth.controller';
|
||||||
import { UserAddressController } from './user-address.controller';
|
import { UserAddressController } from './user-address.controller';
|
||||||
import { UserAddressService } from './user-address.service';
|
import { UserAddressService } from './user-address.service';
|
||||||
|
import { PartnerStaffController } from './partner-staff.controller';
|
||||||
|
import { PartnerStaffService } from './partner-staff.service';
|
||||||
import { AdminAuthController } from './admin-auth.controller';
|
import { AdminAuthController } from './admin-auth.controller';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,11 +33,12 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
|||||||
UserAuthController,
|
UserAuthController,
|
||||||
ShopAuthController,
|
ShopAuthController,
|
||||||
PartnerAuthController,
|
PartnerAuthController,
|
||||||
|
PartnerStaffController,
|
||||||
UserProfileController,
|
UserProfileController,
|
||||||
UserAddressController,
|
UserAddressController,
|
||||||
AdminAuthController,
|
AdminAuthController,
|
||||||
],
|
],
|
||||||
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
|
providers: [AuthService, UserAddressService, PartnerStaffService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||||
exports: [AuthService, UserAddressService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
|
exports: [AuthService, UserAddressService, PartnerStaffService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||||
})
|
})
|
||||||
export class IamModule {}
|
export class IamModule {}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { PartnerStaffService } from './partner-staff.service';
|
||||||
|
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||||
|
|
||||||
|
@Controller('partner/staff')
|
||||||
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
|
export class PartnerStaffController {
|
||||||
|
constructor(private readonly staffService: PartnerStaffService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.staffService.listStaff(user.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
|
||||||
|
return this.staffService.createStaff(user.actorId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdatePartnerStaffDto,
|
||||||
|
) {
|
||||||
|
return this.staffService.updateStaff(user.actorId, BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.staffService.deleteStaff(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartnerStaffService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listStaff(parentAccountId: bigint) {
|
||||||
|
const rows = await this.prisma.partnerAccount.findMany({
|
||||||
|
where: { parentAccountId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => this.toStaffItem(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStaff(parentAccountId: bigint, dto: CreatePartnerStaffDto) {
|
||||||
|
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
|
where: { id: parentAccountId },
|
||||||
|
});
|
||||||
|
if (parent.isPrimary !== 1) {
|
||||||
|
throw new BadRequestException('仅主账号可添加子账号');
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = dto.phone.trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||||
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
|
await this.authService.verifySmsCode(phone, dto.code, SmsScene.PARTNER_STAFF_ADD);
|
||||||
|
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
|
||||||
|
const account = await this.prisma.partnerAccount.create({
|
||||||
|
data: {
|
||||||
|
partnerId: parent.partnerId,
|
||||||
|
phone,
|
||||||
|
name,
|
||||||
|
staffRole: dto.staffRole as PartnerStaffRole,
|
||||||
|
isPrimary: 0,
|
||||||
|
parentAccountId: parent.id,
|
||||||
|
status: 'DISABLED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toStaffItem(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStaff(parentAccountId: bigint, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||||
|
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
data.name = name;
|
||||||
|
}
|
||||||
|
if (dto.staffRole !== undefined) {
|
||||||
|
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||||
|
}
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
data.status = dto.status;
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.partnerAccount.update({
|
||||||
|
where: { id: staff.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return this.toStaffItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStaff(parentAccountId: bigint, staffId: bigint) {
|
||||||
|
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||||
|
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||||
|
const staff = await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { id: staffId, parentAccountId },
|
||||||
|
});
|
||||||
|
if (!staff) throw new NotFoundException('子账号不存在');
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStaffItem(row: {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
staffRole: string | null;
|
||||||
|
status: string;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
}) {
|
||||||
|
return serializeBigInt({
|
||||||
|
id: row.id.toString(),
|
||||||
|
name: row.name,
|
||||||
|
phone: this.maskPhone(row.phone),
|
||||||
|
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||||
|
status: row.status,
|
||||||
|
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskPhone(phone: string): string {
|
||||||
|
if (phone.length !== 11) return phone;
|
||||||
|
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
@@ -8,7 +9,7 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
|
|||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
|
||||||
@Controller('partner/settlement')
|
@Controller('partner/settlement')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
export class SettlementController {
|
export class SettlementController {
|
||||||
constructor(private readonly settlementService: SettlementService) {}
|
constructor(private readonly settlementService: SettlementService) {}
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ export class PartnerMeController {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
phone: account.phone,
|
phone: account.phone,
|
||||||
isPrimary: account.isPrimary === 1,
|
isPrimary: account.isPrimary === 1,
|
||||||
|
staffRole: account.staffRole ?? undefined,
|
||||||
companyName: account.partner.companyName,
|
companyName: account.partner.companyName,
|
||||||
hasWechat: !!account.wxOpenId,
|
hasWechat: !!account.wxOpenId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nest
|
|||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import { RedeemService } from '../redeem/redeem.service';
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
@Controller('stores')
|
@Controller('stores')
|
||||||
@@ -34,6 +35,11 @@ export class PartnerStoreController {
|
|||||||
return this.storeService.partnerListCities(user.actorId);
|
return this.storeService.partnerListCities(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('phone-available')
|
||||||
|
phoneAvailable(@Query('phone') phone?: string) {
|
||||||
|
return this.storeService.partnerCheckStorePhone(phone ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
||||||
@@ -64,7 +70,7 @@ export class PartnerStoreController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/dashboard')
|
@Controller('partner/dashboard')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
export class PartnerDashboardController {
|
export class PartnerDashboardController {
|
||||||
constructor(private readonly storeService: StoreService) {}
|
constructor(private readonly storeService: StoreService) {}
|
||||||
|
|
||||||
@@ -72,6 +78,30 @@ export class PartnerDashboardController {
|
|||||||
dashboard(@CurrentUser() user: AuthUser) {
|
dashboard(@CurrentUser() user: AuthUser) {
|
||||||
return this.storeService.partnerDashboard(user.actorId);
|
return this.storeService.partnerDashboard(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('leaderboard')
|
||||||
|
leaderboard(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('period') period?: string,
|
||||||
|
) {
|
||||||
|
const normalized =
|
||||||
|
period === 'month' || period === 'lastMonth' || period === 'total' ? period : 'total';
|
||||||
|
return this.storeService.partnerLeaderboard(user.actorId, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('partner/reports')
|
||||||
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
|
export class PartnerReportController {
|
||||||
|
constructor(private readonly storeService: StoreService) {}
|
||||||
|
|
||||||
|
@Get('weekly')
|
||||||
|
weekly(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
) {
|
||||||
|
return this.storeService.partnerWeeklyReport(user.actorId, startDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('shop/store')
|
@Controller('shop/store')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AnalyticsModule } from '../analytics/analytics.module';
|
|||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import {
|
import {
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
|
PartnerReportController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
|
PartnerReportController,
|
||||||
ShopStoreController,
|
ShopStoreController,
|
||||||
ShopDashboardController,
|
ShopDashboardController,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
|
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
@@ -47,8 +49,14 @@ export class StoreService {
|
|||||||
|
|
||||||
async partnerListStores(partnerAccountId: bigint) {
|
async partnerListStores(partnerAccountId: bigint) {
|
||||||
const account = await this.getPartnerAccount(partnerAccountId);
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId };
|
||||||
|
if (this.isSubAccount(account)) {
|
||||||
|
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
|
||||||
|
if (storeIds.length === 0) return [];
|
||||||
|
where.id = { in: storeIds };
|
||||||
|
}
|
||||||
const stores = await this.prisma.store.findMany({
|
const stores = await this.prisma.store.findMany({
|
||||||
where: { partnerId: account.partnerId },
|
where,
|
||||||
include: { category: true, coverResource: true },
|
include: { category: true, coverResource: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -62,6 +70,9 @@ export class StoreService {
|
|||||||
include: { category: true, coverResource: true },
|
include: { category: true, coverResource: true },
|
||||||
});
|
});
|
||||||
if (!store) throw new NotFoundException('门店不存在');
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
if (this.isSubAccount(account)) {
|
||||||
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
const media = await this.prisma.commonResource.findMany({
|
const media = await this.prisma.commonResource.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -85,16 +96,27 @@ export class StoreService {
|
|||||||
return serializeBigInt(cities);
|
return serializeBigInt(cities);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
async partnerCheckStorePhone(phone: string) {
|
||||||
const account = await this.getPartnerAccount(partnerAccountId);
|
const normalizedPhone = phone.trim();
|
||||||
const normalizedPhone = String(body.phone).trim();
|
if (!normalizedPhone) {
|
||||||
|
return { available: false, message: '请填写联系电话' };
|
||||||
|
}
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||||
throw new BadRequestException('联系电话须为11位手机号');
|
return { available: false, message: '联系电话须为11位手机号' };
|
||||||
}
|
}
|
||||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
});
|
});
|
||||||
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
if (existingAccount) {
|
||||||
|
return { available: false, message: '该手机号已绑定门店' };
|
||||||
|
}
|
||||||
|
return { available: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||||
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
const normalizedPhone = String(body.phone).trim();
|
||||||
|
await this.assertStorePhoneAvailable(normalizedPhone);
|
||||||
|
|
||||||
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
@@ -103,6 +125,10 @@ export class StoreService {
|
|||||||
: [];
|
: [];
|
||||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||||
|
|
||||||
|
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||||
|
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
||||||
|
if (!contractUrl) throw new BadRequestException('请上传签约合同');
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
@@ -209,6 +235,7 @@ export class StoreService {
|
|||||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||||
) {
|
) {
|
||||||
const account = await this.getPartnerAccount(partnerAccountId);
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
this.assertPrimaryAccount(account);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerId: account.partnerId },
|
where: { id: storeId, partnerId: account.partnerId },
|
||||||
});
|
});
|
||||||
@@ -244,6 +271,7 @@ export class StoreService {
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const account = await this.getPartnerAccount(partnerAccountId);
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
this.assertPrimaryAccount(account);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerId: account.partnerId },
|
where: { id: storeId, partnerId: account.partnerId },
|
||||||
});
|
});
|
||||||
@@ -310,7 +338,13 @@ export class StoreService {
|
|||||||
|
|
||||||
async partnerDashboard(partnerAccountId: bigint) {
|
async partnerDashboard(partnerAccountId: bigint) {
|
||||||
const account = await this.getPartnerAccount(partnerAccountId);
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
const [storeCount, orderCount, recentStores] = await Promise.all([
|
this.assertPrimaryAccount(account);
|
||||||
|
const partnerStoreIds = await this.prisma.store.findMany({
|
||||||
|
where: { partnerId: account.partnerId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const storeIds = partnerStoreIds.map((s) => s.id);
|
||||||
|
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
|
||||||
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
|
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
|
||||||
this.prisma.order.count({
|
this.prisma.order.count({
|
||||||
where: { city: { partnerId: account.partnerId } },
|
where: { city: { partnerId: account.partnerId } },
|
||||||
@@ -321,15 +355,322 @@ export class StoreService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
}),
|
}),
|
||||||
|
storeIds.length === 0
|
||||||
|
? Promise.resolve(0)
|
||||||
|
: this.prisma.commonEvent.count({
|
||||||
|
where: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
status: 'PENDING',
|
||||||
|
refType: 'STORE',
|
||||||
|
refId: { in: storeIds },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
storeCount,
|
storeCount,
|
||||||
orderCount,
|
orderCount,
|
||||||
companyName: account.partner.companyName,
|
companyName: account.partner.companyName,
|
||||||
recentStores: serializeBigInt(recentStores),
|
recentStores: serializeBigInt(recentStores),
|
||||||
|
pendingAuditCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
|
||||||
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
this.assertPrimaryAccount(account);
|
||||||
|
|
||||||
|
const accounts = await this.prisma.partnerAccount.findMany({
|
||||||
|
where: { partnerId: account.partnerId },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { periodStart, periodEnd } = this.resolveLeaderboardPeriodRange(period);
|
||||||
|
const entries = await Promise.all(
|
||||||
|
accounts.map(async (row) => {
|
||||||
|
const totalStores = await this.prisma.commonEvent.count({
|
||||||
|
where: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
param1: 'NEW',
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: row.id,
|
||||||
|
refType: 'STORE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const periodStores =
|
||||||
|
period === 'total'
|
||||||
|
? totalStores
|
||||||
|
: await this.prisma.commonEvent.count({
|
||||||
|
where: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
param1: 'NEW',
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: row.id,
|
||||||
|
refType: 'STORE',
|
||||||
|
createdAt: { gte: periodStart, lt: periodEnd },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const staffRole = row.staffRole as PartnerStaffRole | null;
|
||||||
|
const roleLabel =
|
||||||
|
staffRole != null
|
||||||
|
? PARTNER_STAFF_ROLE_LABELS[staffRole]
|
||||||
|
: row.isPrimary === 1
|
||||||
|
? PARTNER_STAFF_ROLE_LABELS.PARTNER
|
||||||
|
: PARTNER_STAFF_ROLE_LABELS.INTERNAL;
|
||||||
|
return {
|
||||||
|
accountId: row.id.toString(),
|
||||||
|
name: row.name,
|
||||||
|
staffRole: staffRole ?? undefined,
|
||||||
|
roleLabel,
|
||||||
|
totalStores,
|
||||||
|
periodStores,
|
||||||
|
isSelf: row.id === partnerAccountId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (b.periodStores !== a.periodStores) return b.periodStores - a.periodStores;
|
||||||
|
if (b.totalStores !== a.totalStores) return b.totalStores - a.totalStores;
|
||||||
|
return a.accountId.localeCompare(b.accountId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = entries.map((entry, index) => ({
|
||||||
|
...entry,
|
||||||
|
rank: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selfEntry = list.find((entry) => entry.isSelf);
|
||||||
|
let self: (typeof list)[number] & { beatPercent?: number } | undefined;
|
||||||
|
if (selfEntry) {
|
||||||
|
const below = list.filter((entry) => entry.rank > selfEntry.rank).length;
|
||||||
|
const beatPercent =
|
||||||
|
list.length <= 1 ? 0 : Math.round((below / (list.length - 1)) * 100);
|
||||||
|
self = { ...selfEntry, beatPercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { period, list, self };
|
||||||
|
}
|
||||||
|
|
||||||
|
async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) {
|
||||||
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
this.assertPrimaryAccount(account);
|
||||||
|
const partnerId = account.partnerId;
|
||||||
|
|
||||||
|
const currentWeekStart = this.startOfWeekMonday(new Date());
|
||||||
|
const periodStart =
|
||||||
|
startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate)
|
||||||
|
? this.parseLocalDate(startDate)
|
||||||
|
: currentWeekStart;
|
||||||
|
const periodEnd = this.addDays(periodStart, 7);
|
||||||
|
const prevPeriodStart = this.addDays(periodStart, -7);
|
||||||
|
const prevPeriodEnd = periodStart;
|
||||||
|
|
||||||
|
const newStoreTarget =
|
||||||
|
account.partner.weeklyStoreTarget ??
|
||||||
|
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
|
||||||
|
|
||||||
|
const orderWhere = {
|
||||||
|
city: { partnerId },
|
||||||
|
payStatus: 'PAID' as const,
|
||||||
|
paidAt: { gte: periodStart, lt: periodEnd },
|
||||||
|
};
|
||||||
|
const prevOrderWhere = {
|
||||||
|
city: { partnerId },
|
||||||
|
payStatus: 'PAID' as const,
|
||||||
|
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
|
||||||
|
};
|
||||||
|
|
||||||
|
const [
|
||||||
|
gmvAgg,
|
||||||
|
orderCount,
|
||||||
|
totalStoreCount,
|
||||||
|
newStoreCount,
|
||||||
|
prevGmvAgg,
|
||||||
|
redeemGroups,
|
||||||
|
activeRedeems,
|
||||||
|
paidOrders,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
|
||||||
|
this.prisma.order.count({ where: orderWhere }),
|
||||||
|
this.prisma.store.count({ where: { partnerId } }),
|
||||||
|
this.prisma.store.count({
|
||||||
|
where: { partnerId, createdAt: { gte: periodStart, lt: periodEnd } },
|
||||||
|
}),
|
||||||
|
this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
|
||||||
|
this.prisma.redeemRecord.groupBy({
|
||||||
|
by: ['storeId'],
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: periodStart, lt: periodEnd },
|
||||||
|
store: { partnerId },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
orderBy: { _sum: { amount: 'desc' } },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
this.prisma.redeemRecord.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: periodStart, lt: periodEnd },
|
||||||
|
store: { partnerId },
|
||||||
|
},
|
||||||
|
select: { storeId: true },
|
||||||
|
distinct: ['storeId'],
|
||||||
|
}),
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where: orderWhere,
|
||||||
|
select: { payAmount: true, paidAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const gmv = Number(gmvAgg._sum.payAmount ?? 0);
|
||||||
|
const prevGmv = Number(prevGmvAgg._sum.payAmount ?? 0);
|
||||||
|
const gmvGrowthPercent =
|
||||||
|
prevGmv === 0 ? 0 : Math.round(((gmv - prevGmv) / prevGmv) * 1000) / 10;
|
||||||
|
const newStoreProgressPercent =
|
||||||
|
newStoreTarget <= 0
|
||||||
|
? 0
|
||||||
|
: Math.min(100, Math.round((newStoreCount / newStoreTarget) * 100));
|
||||||
|
|
||||||
|
const dailyGmvMap = new Map<string, number>();
|
||||||
|
for (let i = 0; i < 7; i += 1) {
|
||||||
|
dailyGmvMap.set(this.formatDateKey(this.addDays(periodStart, i)), 0);
|
||||||
|
}
|
||||||
|
for (const order of paidOrders) {
|
||||||
|
if (!order.paidAt) continue;
|
||||||
|
const key = this.formatDateKey(order.paidAt);
|
||||||
|
if (dailyGmvMap.has(key)) {
|
||||||
|
dailyGmvMap.set(key, (dailyGmvMap.get(key) ?? 0) + Number(order.payAmount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dailyGmv = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const day = this.addDays(periodStart, i);
|
||||||
|
const date = this.formatDateKey(day);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
weekdayLabel: this.weekdayLabel(i),
|
||||||
|
amount: dailyGmvMap.get(date) ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankStoreIds = redeemGroups.map((group) => group.storeId);
|
||||||
|
const rankStores =
|
||||||
|
rankStoreIds.length > 0
|
||||||
|
? await this.prisma.store.findMany({
|
||||||
|
where: { id: { in: rankStoreIds } },
|
||||||
|
select: { id: true, name: true, intro: true, address: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const storeMap = new Map(rankStores.map((store) => [store.id.toString(), store]));
|
||||||
|
const storeRanking = redeemGroups.map((group, index) => {
|
||||||
|
const store = storeMap.get(group.storeId.toString());
|
||||||
|
const subtitleSource = store?.intro?.trim() || store?.address?.trim() || '';
|
||||||
|
const subtitle =
|
||||||
|
subtitleSource.length > 30 ? `${subtitleSource.slice(0, 30)}…` : subtitleSource;
|
||||||
|
return {
|
||||||
|
rank: index + 1,
|
||||||
|
storeId: group.storeId.toString(),
|
||||||
|
name: store?.name ?? '未知门店',
|
||||||
|
subtitle: subtitle || undefined,
|
||||||
|
redeemAmount: Number(group._sum.amount ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const topStore = storeRanking[0];
|
||||||
|
let insight: string;
|
||||||
|
if (topStore && topStore.redeemAmount > 0) {
|
||||||
|
if (gmvGrowthPercent > 0) {
|
||||||
|
insight = `本周 GMV 增长 ${gmvGrowthPercent}%,主要得益于「${topStore.name}」的核销表现。建议关注同类高潜力门店。`;
|
||||||
|
} else if (gmvGrowthPercent < 0) {
|
||||||
|
insight = `本周 GMV 较上期下降 ${Math.abs(gmvGrowthPercent)}%,「${topStore.name}」仍为核销领先门店。建议复盘低效门店并复制头部经验。`;
|
||||||
|
} else {
|
||||||
|
insight = `本周 GMV 与上期持平,「${topStore.name}」核销表现领先。建议持续推动门店活跃。`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
insight = '本周暂无核销数据,建议关注门店培训与权益推广,激活辖区门店。';
|
||||||
|
}
|
||||||
|
|
||||||
|
const availablePeriods = [0, 1, 2, 3].map((offset) => {
|
||||||
|
const start = this.addDays(currentWeekStart, -7 * offset);
|
||||||
|
const end = this.addDays(start, 7);
|
||||||
|
return {
|
||||||
|
startDate: this.formatDateKey(start),
|
||||||
|
endDate: this.formatDateKey(this.addDays(end, -1)),
|
||||||
|
label: this.formatPeriodLabel(start, end),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: {
|
||||||
|
startDate: this.formatDateKey(periodStart),
|
||||||
|
endDate: this.formatDateKey(this.addDays(periodEnd, -1)),
|
||||||
|
label: this.formatPeriodLabel(periodStart, periodEnd),
|
||||||
|
},
|
||||||
|
availablePeriods,
|
||||||
|
summary: {
|
||||||
|
gmv,
|
||||||
|
gmvGrowthPercent,
|
||||||
|
activeStoreCount: activeRedeems.length,
|
||||||
|
totalStoreCount,
|
||||||
|
orderCount,
|
||||||
|
newStoreCount,
|
||||||
|
newStoreTarget,
|
||||||
|
newStoreProgressPercent,
|
||||||
|
},
|
||||||
|
dailyGmv,
|
||||||
|
storeRanking,
|
||||||
|
insight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLeaderboardPeriodRange(period: PartnerLeaderboardPeriod) {
|
||||||
|
const now = new Date();
|
||||||
|
if (period === 'month') {
|
||||||
|
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return { periodStart, periodEnd: now };
|
||||||
|
}
|
||||||
|
if (period === 'lastMonth') {
|
||||||
|
const periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const periodEnd = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return { periodStart, periodEnd };
|
||||||
|
}
|
||||||
|
return { periodStart: new Date(0), periodEnd: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
private startOfWeekMonday(date: Date): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = day === 0 ? 6 : day - 1;
|
||||||
|
d.setDate(d.getDate() - diff);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addDays(date: Date, days: number): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLocalDate(dateKey: string): Date {
|
||||||
|
const [year, month, day] = dateKey.split('-').map(Number);
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateKey(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPeriodLabel(start: Date, endExclusive: Date): string {
|
||||||
|
const end = this.addDays(endExclusive, -1);
|
||||||
|
return `${start.getMonth() + 1}月${start.getDate()}日 - ${end.getMonth() + 1}月${end.getDate()}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private weekdayLabel(index: number): string {
|
||||||
|
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
private async getPartnerAccount(partnerAccountId: bigint) {
|
private async getPartnerAccount(partnerAccountId: bigint) {
|
||||||
return this.prisma.partnerAccount.findUniqueOrThrow({
|
return this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
where: { id: partnerAccountId },
|
where: { id: partnerAccountId },
|
||||||
@@ -349,4 +690,49 @@ export class StoreService {
|
|||||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||||
return city;
|
return city;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSubAccount(account: { isPrimary: number }) {
|
||||||
|
return account.isPrimary !== 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertPrimaryAccount(account: { isPrimary: number }) {
|
||||||
|
if (this.isSubAccount(account)) {
|
||||||
|
throw new ForbiddenException('子账号无权执行此操作');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStorePhoneAvailable(phone: string) {
|
||||||
|
const result = await this.partnerCheckStorePhone(phone);
|
||||||
|
if (!result.available) {
|
||||||
|
throw new BadRequestException(result.message ?? '该手机号已绑定门店');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStoreIdsCreatedByAccount(partnerAccountId: bigint): Promise<bigint[]> {
|
||||||
|
const events = await this.prisma.commonEvent.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
param1: 'NEW',
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: partnerAccountId,
|
||||||
|
refType: 'STORE',
|
||||||
|
},
|
||||||
|
select: { refId: true },
|
||||||
|
});
|
||||||
|
return events.map((event) => event.refId).filter((id): id is bigint => id != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreOwnedByAccount(partnerAccountId: bigint, storeId: bigint) {
|
||||||
|
const event = await this.prisma.commonEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
param1: 'NEW',
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: partnerAccountId,
|
||||||
|
refType: 'STORE',
|
||||||
|
refId: storeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!event) throw new ForbiddenException('无权查看该门店');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, Req, UseGuards } from '
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { TradeService } from './trade.service';
|
import { TradeService } from './trade.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export class TradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/orders')
|
@Controller('partner/orders')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
export class PartnerOrderController {
|
export class PartnerOrderController {
|
||||||
constructor(private readonly tradeService: TradeService) {}
|
constructor(private readonly tradeService: TradeService) {}
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ export class PartnerOrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/reshipments')
|
@Controller('partner/reshipments')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
export class PartnerReshipmentController {
|
export class PartnerReshipmentController {
|
||||||
constructor(private readonly tradeService: TradeService) {}
|
constructor(private readonly tradeService: TradeService) {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user