feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
import { createPartnerTracker } from '@dukang/client-logging';
import { apiBase } from './api';
const tracker = createPartnerTracker({
apiBase,
clientApp: 'PARTNER_H5',
getToken: () => localStorage.getItem('partnerAccessToken'),
});
export function trackPartner(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPartnerPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
+272
View File
@@ -0,0 +1,272 @@
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
import { showPartnerToast } from './toast';
export const apiBase = '/api/v1';
const CLIENT_APP = 'PARTNER_H5';
const ACCESS_TOKEN = 'accessToken';
const REFRESH_TOKEN = 'refreshToken';
const LAST_PHONE = 'partnerLastPhone';
const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound';
/** 手机号或微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [
'/partner/auth/token/refresh',
'/partner/auth/sms/send',
'/partner/auth/login/sms',
'/partner/auth/login/wechat',
];
export type PartnerSessionProfile = Pick<
PartnerMe,
| 'id'
| 'name'
| 'phone'
| 'companyName'
| 'isPrimary'
| 'permissions'
| 'primaryAccountId'
| 'primaryPhone'
| 'primaryName'
| 'hasWechat'
| 'wxNickname'
| 'wxAvatarUrl'
| 'managedWarehouseId'
| 'hasWarehouseAccess'
> & {
staffRole?: PartnerStaffRole;
};
export type PartnerSessionPayload = {
accessToken: string;
refreshToken: string;
partner?: PartnerSessionProfile;
};
export type ApiRequestOptions = RequestInit & {
/** 保留字段,兼容旧调用;错误提示由页面自行处理 */
silent?: boolean;
};
export function getLastPhone() {
return localStorage.getItem(LAST_PHONE) ?? '';
}
export function getPartnerProfile(): PartnerSessionProfile | null {
try {
const raw = localStorage.getItem(PARTNER_PROFILE);
return raw ? (JSON.parse(raw) as PartnerSessionProfile) : null;
} catch {
return null;
}
}
export function hasPartnerWxSession() {
return localStorage.getItem(PARTNER_WX_BOUND) === '1';
}
export function isPartnerSessionExpired() {
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
if (!raw) return false;
return Date.now() > Number(raw);
}
export function touchPartnerSession() {
if (!localStorage.getItem(REFRESH_TOKEN)) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
export function saveAuth(data: PartnerSessionPayload) {
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
if (data.partner) {
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
localStorage.setItem(LAST_PHONE, data.partner.phone);
}
}
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: PartnerSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) {
saveRememberedSession(data);
localStorage.setItem(PARTNER_WX_BOUND, '1');
}
export function clearAuth(options?: { keepProfile?: boolean }) {
localStorage.removeItem(ACCESS_TOKEN);
localStorage.removeItem(REFRESH_TOKEN);
localStorage.removeItem(SESSION_EXPIRES_AT);
localStorage.removeItem(PARTNER_WX_BOUND);
if (!options?.keepProfile) {
localStorage.removeItem(PARTNER_PROFILE);
localStorage.removeItem(LAST_PHONE);
}
}
export function getToken() {
return localStorage.getItem(ACCESS_TOKEN);
}
export function isLoggedIn() {
return !!localStorage.getItem(ACCESS_TOKEN);
}
function profileFromMe(me: PartnerMe): PartnerSessionProfile {
return {
id: me.id,
name: me.name,
phone: me.phone,
companyName: me.companyName,
isPrimary: me.isPrimary,
staffRole: me.staffRole ?? undefined,
permissions: me.permissions,
primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
managedWarehouseId: me.managedWarehouseId,
hasWarehouseAccess: me.hasWarehouseAccess,
};
}
async function rawRequest<T>(
path: string,
options: RequestInit = {},
token?: string | null,
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
...(options.headers as Record<string, string>),
};
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code === 401 ? 401 : json.code;
throw err;
}
return json.data as T;
}
async function refreshSession(): Promise<PartnerSessionPayload | null> {
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
if (!refreshToken) return null;
try {
const data = await rawRequest<PartnerSessionPayload>(
'/partner/auth/token/refresh',
{
method: 'POST',
body: JSON.stringify({ refreshToken }),
},
null,
);
saveAuth(data);
touchPartnerSession();
return data;
} catch {
return null;
}
}
async function requestWithAuthRetry<T>(
path: string,
options: RequestInit = {},
retried = false,
): Promise<T> {
try {
return await rawRequest<T>(path, options);
} catch (e) {
const err = e as Error & { status?: number };
const canRecover =
err.status === 401 &&
!retried &&
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
if (!canRecover) throw e;
const refreshed = await refreshSession();
if (!refreshed) {
clearAuth({ keepProfile: true });
throw e;
}
return requestWithAuthRetry<T>(path, options, true);
}
}
export async function request<T>(
clientApp: string,
path: string,
options: ApiRequestOptions = {},
): Promise<T> {
void clientApp;
const { silent, ...fetchOptions } = options;
try {
return await requestWithAuthRetry<T>(path, fetchOptions);
} catch (e) {
const err = e as Error & { status?: number };
const message = err.message || '请求失败';
if (err.status === 401) {
if (localStorage.getItem(ACCESS_TOKEN)) {
clearAuth({ keepProfile: true });
if (!silent) showPartnerToast(message, 'error');
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login');
}
}
} else if (!silent) {
showPartnerToast(message, 'error');
}
throw e;
}
}
export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> {
if (!isLoggedIn()) {
return { authenticated: false, partner: null };
}
if (isPartnerSessionExpired()) {
clearAuth({ keepProfile: true });
return { authenticated: false, partner: getPartnerProfile() };
}
try {
const me = await rawRequest<PartnerMe>('/partner/me');
const partner = profileFromMe(me);
saveAuth({
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
partner,
});
touchPartnerSession();
return { authenticated: true, partner };
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
const refreshed = await refreshSession();
if (refreshed?.partner) {
return { authenticated: true, partner: refreshed.partner };
}
clearAuth({ keepProfile: true });
return { authenticated: false, partner: getPartnerProfile() };
}
const cached = getPartnerProfile();
if (cached) return { authenticated: true, partner: cached };
throw e;
}
}
/** @deprecated 使用 PartnerSessionPayload */
export type PartnerAuthPayload = PartnerSessionPayload;
+73
View File
@@ -0,0 +1,73 @@
import { codeToText, regionData } from 'element-china-area-data';
export { regionData as CHINA_REGION_OPTIONS };
export type OpenCityRef = {
id: string;
name: string;
code: string;
partnerId?: string | null;
};
export type ParsedChinaRegion = {
province: string;
city: string;
district: string;
provinceCode: string;
cityCode: string;
districtCode: string;
};
export function districtCodeToCityCode(districtCode: string): string {
if (districtCode.length < 6) return districtCode;
return `${districtCode.slice(0, 4)}00`;
}
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes;
const province = codeToText[provinceCode];
const city = codeToText[cityCode];
const district = codeToText[districtCode];
if (!province || !city || !district) return null;
return { province, city, district, provinceCode, cityCode, districtCode };
}
export function formatRegionLabel(region: ParsedChinaRegion): string {
return `${region.province} / ${region.city} / ${region.district}`;
}
/** 合伙人录店默认省市区 adcode:河南省 / 郑州市 / 金水区 */
export const DEFAULT_REGION_CODES = ['41', '4101', '410105'] as const;
export function getDefaultPartnerRegionForm() {
const regionCodes = [...DEFAULT_REGION_CODES];
const parsed = parseRegionCodes(regionCodes);
return {
regionCodes,
province: parsed?.province ?? '河南省',
city: parsed?.city ?? '郑州市',
district: parsed?.district ?? '金水区',
};
}
export function matchOpenCityId(cities: OpenCityRef[], districtCode: string): string | undefined {
const cityCode = districtCodeToCityCode(districtCode);
return (
cities.find((c) => c.code === cityCode)?.id
?? cities.find((c) => c.code === districtCode)?.id
?? cities.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
);
}
export function resolveRegionBinding(codes: string[], cities: OpenCityRef[]) {
const region = parseRegionCodes(codes);
if (!region) return null;
const cityId = matchOpenCityId(cities, region.districtCode);
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
return {
region,
cityId,
matchedCity,
cityCode: districtCodeToCityCode(region.districtCode),
};
}
+90
View File
@@ -0,0 +1,90 @@
import { apiBase, getToken } from './api';
const CLIENT_APP = 'PARTNER_H5';
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
export type ClientErrorCategory =
| 'js_error'
| 'unhandled_rejection'
| 'api_error'
| 'network'
| 'render'
| 'bridge'
| 'other';
export type ClientErrorPayload = {
level: ClientErrorLevel;
category: ClientErrorCategory;
message: string;
stack?: string;
pagePath?: string;
extra?: Record<string, unknown>;
};
function currentPagePath(): string | undefined {
try {
return typeof window !== 'undefined' ? window.location.pathname : undefined;
} catch {
return undefined;
}
}
/** 上报客户端错误(失败静默,避免递归) */
export function reportClientError(payload: ClientErrorPayload): void {
const body = {
level: payload.level,
category: payload.category,
message: String(payload.message || 'unknown').slice(0, 1000),
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
pagePath: (payload.pagePath || currentPagePath() || '').slice(0, 128) || undefined,
clientApp: CLIENT_APP,
extra: payload.extra,
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
void fetch(`${apiBase}/common/client-errors`, {
method: 'POST',
headers,
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {});
}
let installed = false;
/** 安装 H5 全局未捕获错误钩子(幂等) */
export function installClientErrorReporting(): void {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('error', (ev) => {
reportClientError({
level: 'fatal',
category: 'js_error',
message: ev.message || 'window.error',
stack: ev.error instanceof Error ? ev.error.stack : undefined,
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
});
});
window.addEventListener('unhandledrejection', (ev) => {
const reason = ev.reason;
reportClientError({
level: 'error',
category: 'unhandled_rejection',
message:
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: 'unhandledrejection',
stack: reason instanceof Error ? reason.stack : undefined,
});
});
}
+17
View File
@@ -0,0 +1,17 @@
/** 合伙人端客服热线(可后续改为环境变量) */
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
export function dialPhone(phone: string) {
const normalized = phone.replace(/\s+/g, '');
if (!normalized) return false;
window.location.href = `tel:${normalized}`;
return true;
}
export function contactSupport() {
return dialPhone(PARTNER_SUPPORT_PHONE);
}
export function contactPartnerPhone(phone?: string) {
return dialPhone(phone ?? '');
}
+13
View File
@@ -0,0 +1,13 @@
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
import { request } from './api';
export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'total') {
return request<PartnerLeaderboardResponse>(
'PARTNER_H5',
`/partner/dashboard/leaderboard?period=${period}`,
{ silent: true },
);
}
export type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse };
export type { PartnerLeaderboardEntry } from '@dukang/shared-types';
+106
View File
@@ -0,0 +1,106 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
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 hasPartnerPermission(
account: PartnerMe | null | undefined,
permission: PartnerPermissionKey,
): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
return account.permissions?.includes(permission) ?? false;
}
export function hasAnyPartnerPermission(
account: PartnerMe | null | undefined,
permissions: PartnerPermissionKey[],
): boolean {
return permissions.some((p) => hasPartnerPermission(account, p));
}
/** 与后端 GET /partner/dashboard 权限点一致 */
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, [
'store:create',
'store:manage',
'order:view',
'warehouse:manage',
]);
}
/** 门店列表/拓店统计所需权限 */
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
}
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 录入新店 */
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasPartnerPermission(account, 'store:create')) return true;
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 与后端 GET /partner/orders 权限点一致 */
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
}
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
if (!account || isPrimaryAccount(account)) return false;
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
const hasStore =
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
return false;
}
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
if (!account || isPrimaryAccount(account)) return 'primary';
if (isWarehouseStaff(account)) return 'warehouse_staff';
return 'store_staff';
}
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (account.hasWarehouseAccess === false) return false;
if (account.hasWarehouseAccess === true) return true;
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
return false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true;
const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
return prefixes.some(
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
);
}
@@ -0,0 +1,56 @@
import type { PartnerProxyDeliveryMode } from '@dukang/shared-types';
const PROXY_ORDER_DRAFT_KEY = 'partner_proxy_order_draft_v2';
export type ProxyOrderDraft = {
phone: string;
receiverName: string;
regionCodes: string[];
addressDetail: string;
productId: string;
quantity: number;
promoCodeId: string;
deliveryMode: PartnerProxyDeliveryMode;
autoReceive: boolean;
};
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
try {
sessionStorage.setItem(PROXY_ORDER_DRAFT_KEY, JSON.stringify(draft));
} catch {
/* ignore quota / private mode */
}
}
export function loadProxyOrderDraft(): ProxyOrderDraft | null {
try {
const raw = sessionStorage.getItem(PROXY_ORDER_DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<ProxyOrderDraft>;
if (!parsed || typeof parsed !== 'object') return null;
return {
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
receiverName: typeof parsed.receiverName === 'string' ? parsed.receiverName : '',
regionCodes: Array.isArray(parsed.regionCodes)
? parsed.regionCodes.filter((c): c is string => typeof c === 'string')
: [],
addressDetail: typeof parsed.addressDetail === 'string' ? parsed.addressDetail : '',
productId: typeof parsed.productId === 'string' ? parsed.productId : '',
quantity: typeof parsed.quantity === 'number' && parsed.quantity >= 1 ? parsed.quantity : 2,
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
autoReceive: parsed.autoReceive === true,
};
} catch {
return null;
}
}
export function clearProxyOrderDraft() {
try {
sessionStorage.removeItem(PROXY_ORDER_DRAFT_KEY);
sessionStorage.removeItem('partner_proxy_order_draft_v1');
} catch {
/* ignore */
}
}
@@ -0,0 +1,36 @@
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
const PAY_LABELS: Record<string, string> = {
UNPAID: '未支付',
PAYING: '支付中',
PAID: '已付款',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
export function proxyOrderStatusLabel(status?: string | null) {
if (!status) return '—';
return STATUS_LABELS[status] || status;
}
export function proxyOrderPayLabel(payStatus?: string | null) {
if (!payStatus) return '—';
return PAY_LABELS[payStatus] || payStatus;
}
export function proxyOrderStatusColor(status?: string | null) {
if (status === 'PENDING_PAY') return 'var(--color-warning, #d48806)';
if (status === 'COMPLETED') return 'var(--color-success-green)';
if (status === 'CANCELLED' || status === 'REFUNDED') return 'var(--color-text-muted, #999)';
return 'var(--color-primary, #8b1e1e)';
}
+43
View File
@@ -0,0 +1,43 @@
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
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 sendPartnerStaffPhoneSms(phone: string) {
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/staff/send-phone-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
}
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
method: 'POST',
body: JSON.stringify({
name: body.name,
phone: body.phone,
smsCode: body.smsCode,
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}),
});
}
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',
});
}
+249
View File
@@ -0,0 +1,249 @@
export type StoreDraftForm = {
regionCodes: string[];
cityId: string;
province: string;
city: string;
district: string;
name: string;
phone: string;
address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
longitude: string;
openTime: string;
closeTime: string;
/** 是否启用第二段营业时间 */
dualHours: boolean;
openTime2: string;
closeTime2: string;
/** 人均费用(选填) */
avgPrice: string;
categoryParentId: string;
categoryId: string;
intro: string;
/** 好客权益券使用规则 */
benefitUsageRule: string;
coverUrl: string;
envPhotoUrls: string[];
contractUrl: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
/** 门店套餐(拓店第 4 步,可空) */
packages: import('@dukang/shared-types').StorePackageItemDto[];
};
export type StoreDraft = {
step: number;
form: StoreDraftForm;
};
export const STORE_DRAFT_KEY = 'partner_store_draft_v2';
export function storeDraftKey(accountId?: string): string {
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
}
import { getDefaultPartnerRegionForm } from './china-region';
export const defaultStoreForm = (): StoreDraftForm => ({
...getDefaultPartnerRegionForm(),
cityId: '',
name: '',
phone: '',
address: '',
latitude: '',
longitude: '',
openTime: '10:00',
closeTime: '22:00',
dualHours: false,
openTime2: '17:00',
closeTime2: '21:00',
avgPrice: '',
categoryParentId: '',
categoryId: '',
intro: '',
benefitUsageRule: '',
coverUrl: '',
envPhotoUrls: ['', '', ''],
contractUrl: '',
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
packages: [],
});
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const PHONE_RE = /^1[3-9]\d{9}$/;
const BANK_RE = /^\d{16,19}$/;
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
export const MIN_ENV_PHOTO_COUNT = 3;
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
return arr;
}
export function addEnvPhotoSlot(urls: string[]): string[] {
return [...urls, ''];
}
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
const base = defaultStoreForm();
if (!raw) return base;
return {
...base,
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId),
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime),
dualHours: Boolean(raw.dualHours),
openTime2: String(raw.openTime2 ?? base.openTime2),
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
packages: Array.isArray(raw.packages)
? raw.packages.map((p, i) => ({
name: String((p as { name?: string }).name ?? ''),
price: String((p as { price?: string | number }).price ?? ''),
dishes: String((p as { dishes?: string }).dishes ?? ''),
usableTime: (p as { usableTime?: string }).usableTime ?? '',
otherNotes: (p as { otherNotes?: string }).otherNotes ?? '',
sortOrder: i,
}))
: [],
};
}
export function loadStoreDraft(accountId?: string): StoreDraft | null {
try {
const key = storeDraftKey(accountId);
let raw = localStorage.getItem(key);
if (!raw && accountId) {
raw = localStorage.getItem(STORE_DRAFT_KEY);
if (raw) {
localStorage.setItem(key, raw);
localStorage.removeItem(STORE_DRAFT_KEY);
}
}
if (!raw) return null;
const parsed = JSON.parse(raw) as StoreDraft;
return {
step: Number(parsed.step) || 0,
form: normalizeStoreDraftForm(parsed.form),
};
} catch {
return null;
}
}
export function saveStoreDraft(draft: StoreDraft, accountId?: string) {
localStorage.setItem(storeDraftKey(accountId), JSON.stringify(draft));
}
export function clearStoreDraft(accountId?: string) {
localStorage.removeItem(storeDraftKey(accountId));
}
/** 清除当前账号及遗留的全局录店草稿 */
export function clearAllStoreDrafts(accountId?: string) {
clearStoreDraft(accountId);
localStorage.removeItem(STORE_DRAFT_KEY);
}
export function validateStoreStep1(
form: Pick<
StoreDraftForm,
| 'regionCodes'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'dualHours'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'categoryId'
| 'intro'
| 'benefitUsageRule'
>,
): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
if (!form.name.trim()) return '请填写门店名称';
if (!form.address.trim()) return '请填写详细地址';
if (!form.openTime.trim()) return '请填写营业开始时间';
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
if (!form.closeTime.trim()) return '请填写营业结束时间';
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
return '营业结束时间须晚于开始时间';
}
if (form.dualHours) {
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
return '第二段时间格式须为 HH:MM';
}
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
return '第二段结束时间须晚于开始时间';
}
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
return '第二段开始时间须晚于第一段结束时间';
}
}
if (form.avgPrice.trim()) {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (!form.categoryId.trim()) return '请选择店铺类型';
if (form.intro.trim()) {
const len = form.intro.trim().length;
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
}
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 < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
if (!form.contractUrl.trim()) return '请上传签约合同';
return null;
}
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
const envPhotoUrls = [...urls];
while (envPhotoUrls.length <= index) envPhotoUrls.push('');
envPhotoUrls[index] = url;
return envPhotoUrls;
}
export function validateStoreStep3(
form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
return null;
}
+26
View File
@@ -0,0 +1,26 @@
import { weixinSdk } from './weixin';
export type StorePosition = {
latitude: number;
longitude: number;
};
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation */
export async function locateStorePosition(): Promise<StorePosition> {
const outcome = await weixinSdk.getLocationDetailed();
if (!outcome.location) {
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
}
const { latitude, longitude } = outcome.location;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error('定位结果无效');
}
return { latitude, longitude };
}
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
const a = lat != null && lat !== '' ? Number(lat) : NaN;
const b = lng != null && lng !== '' ? Number(lng) : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
}
+49
View File
@@ -0,0 +1,49 @@
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto;
export function emptyPackage(index = 0): PackageFormItem {
return {
name: '',
price: '',
dishes: '',
usableTime: '',
otherNotes: '',
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
sortOrder: index,
}))
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
}
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
const filled = normalizePackageFormItems(items);
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT}`;
}
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) return `${i + 1} 条套餐名称不能为空`;
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
}
return null;
}
export function formatPackagePrice(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return String(price);
return n % 1 === 0 ? String(n) : n.toFixed(2);
}
+11
View File
@@ -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 };
+37
View File
@@ -0,0 +1,37 @@
export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string;
export function storeAuditLabel(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return '待总部审核';
if (s === 'REJECTED') return '审核驳回';
if (s === 'APPROVED') return '审核通过';
return auditStatus || '—';
}
export function storeAuditPillClass(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return 'partner-status-pill--paused';
if (s === 'REJECTED') return 'partner-status-pill--closed';
return 'partner-status-pill--open';
}
export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '临时闭店';
if (s === 'CLOSED') return '永久关闭';
return status;
}
export function storeStatusPillClass(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return 'partner-status-pill--open';
if (s === 'PAUSED') return 'partner-status-pill--paused';
return 'partner-status-pill--closed';
}
export function canPartnerOpenStore(auditStatus?: string | null): boolean {
return String(auditStatus || '').toUpperCase() === 'APPROVED';
}
+101
View File
@@ -0,0 +1,101 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
type LocPickerMessage = {
module?: string;
latlng?: { lat?: number; lng?: number };
poiaddress?: string;
poiname?: string;
cityname?: string;
};
const REFERER = 'dukang';
function coerceMessageData(data: unknown): LocPickerMessage | null {
if (data == null) return null;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
try {
return JSON.parse(trimmed) as LocPickerMessage;
} catch {
return null;
}
}
if (typeof data === 'object') return data as LocPickerMessage;
return null;
}
/** 腾讯地图选点组件(iframe */
export function buildTencentLocPickerUrl(
key: string,
options?: { latitude?: number; longitude?: number },
): string {
const params = new URLSearchParams({
search: '1',
type: '1',
key,
referer: REFERER,
policy: '1',
total: '20',
radius: '2000',
mapdraggable: '1',
});
const lat = options?.latitude;
const lng = options?.longitude;
if (
lat != null &&
lng != null &&
Number.isFinite(lat) &&
Number.isFinite(lng) &&
!(lat === 0 && lng === 0)
) {
params.set('coord', `${lat},${lng}`);
params.set('coordtype', '5');
}
return `https://apis.map.qq.com/tools/locpicker?${params.toString()}`;
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = coerceMessageData(data);
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
let name = loc.poiname?.trim() || undefined;
const address = loc.poiaddress?.trim() || undefined;
if (name === '我的位置' && address) name = address;
return {
latitude: lat,
longitude: lng,
address,
name,
cityname: loc.cityname?.trim() || undefined,
};
}
+23
View File
@@ -0,0 +1,23 @@
export type PartnerToastVariant = 'success' | 'error';
type PartnerToastListener = (message: string, variant: PartnerToastVariant) => void;
let listener: PartnerToastListener | null = null;
export function registerPartnerToastListener(fn: PartnerToastListener | null) {
listener = fn;
}
export function showPartnerToast(message: string, variant: PartnerToastVariant = 'error') {
const text = message.trim();
if (!text || !listener) return;
listener(text, variant);
}
export function toastError(message: string) {
showPartnerToast(message, 'error');
}
export function toastSuccess(message: string) {
showPartnerToast(message, 'success');
}
+11
View File
@@ -0,0 +1,11 @@
/** 串行化上传,避免微信 JSSDK 并发选图/读图导致卡死 */
let uploadChain: Promise<unknown> = Promise.resolve();
export function enqueueUpload<T>(task: () => Promise<T>): Promise<T> {
const next = uploadChain.then(task, task);
uploadChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
+81
View File
@@ -0,0 +1,81 @@
import { apiBase, request } from './api';
const UPLOAD_TIMEOUT_MS = 120_000;
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
async function uploadFileToOssInner(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = { 'X-Client-App': 'PARTNER_H5' };
if (token) headers.Authorization = `Bearer ${token}`;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
signal: controller.signal,
});
const json = await res.json();
if (json.code === 401) {
localStorage.removeItem('accessToken');
throw new Error('未登录');
}
if (json.code !== 0) {
throw new Error(json.message || '上传失败');
}
const data = json.data as UploadFileResult;
return {
url: data.url,
ossKey: data.ossKey,
bucket: data.bucket,
mock: data.mock,
};
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('上传超时,请检查网络后重试');
}
throw e;
} finally {
window.clearTimeout(timeoutId);
}
}
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
return uploadFileToOssInner(file, options);
}
export type OpenCityOption = {
id: string;
name: string;
code: string;
province: string;
};
export async function fetchPartnerCities(): Promise<OpenCityOption[]> {
return request<OpenCityOption[]>('PARTNER_H5', '/partner/stores/cities');
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPartnerPageView } from './analytics';
export function usePartnerPageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPartnerPageView(eventName, params);
}, [eventName, params]);
}
+150
View File
@@ -0,0 +1,150 @@
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin';
import { getPartnerProfile, request, saveWechatSession, type PartnerSessionPayload, type PartnerSessionProfile } from './api';
export const PARTNER_WECHAT_LOGIN_HINT =
'请先使用手机验证码登录,登录后将自动关联微信';
export type PartnerProfile = {
id: string;
name: string;
phone: string;
companyName?: string;
hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean;
};
export function partnerHasWechatBinding(
profile: PartnerProfile | PartnerSessionProfile | null | undefined,
): boolean {
return !!profile?.hasWechat;
}
/** 微信一键登录前:账号须已绑定 wxOpenId(本地缓存或手机号校验) */
export async function canPartnerUseWechatLogin(options: {
profile?: PartnerProfile | PartnerSessionProfile | null;
phone?: string;
}): Promise<boolean> {
const cached = options.profile ?? getPartnerProfile();
if (partnerHasWechatBinding(cached)) return true;
const normalized = options.phone?.trim() ?? '';
if (!/^1\d{10}$/.test(normalized)) return false;
try {
const res = await request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
method: 'POST',
body: JSON.stringify({ phone: normalized }),
silent: true,
});
return !!res.hasWechat;
} catch {
return false;
}
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
}
export async function fetchPartnerProfile(): Promise<PartnerProfile> {
return request<PartnerProfile>('PARTNER_H5', '/partner/me');
}
/** 微信内上传照片前需完成公众号 OAuth 绑定 */
export function needsWechatAuth(
profile: PartnerProfile | null,
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
): boolean {
if (config && !isWxAuthorizeEnabled(config)) return false;
return isWechatEnv() && !!profile && !profile.hasWechat;
}
export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Promise<boolean> {
const config = await fetchClientConfig();
return needsWechatAuth(profile, config);
}
export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null {
if (!result.accessToken || !result.refreshToken) return null;
const partner = result.partner;
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
partner: partner
? {
id: String(partner.id ?? ''),
name: String(partner.name ?? ''),
phone: String(partner.phone ?? ''),
companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false,
hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
}
: undefined,
};
}
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null {
const session = sessionFromWechatLogin(result);
if (!session) return null;
saveWechatSession(session);
return session;
}
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return null;
return weixinSdk.handleOAuthCallback();
}
/**
* 微信一键登录(已绑定微信的合伙人账号免验证码)。
* 返回 session = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginPartnerWithWechat(): Promise<PartnerSessionPayload | null | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return null;
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
const result = await weixinSdk.login();
if (result) return handlePartnerWechatLoginResult(result);
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) return;
await weixinSdk.login();
}
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
return weixinSdk.login();
}
/** OAuth 回跳统一处理(登录页 / 录店页等) */
export async function processPartnerWechatOAuthCallback(): Promise<PartnerSessionPayload | null> {
const result = await handlePartnerWechatCallback();
if (!result) return null;
return handlePartnerWechatLoginResult(result);
}
/** @deprecated 使用 handlePartnerWechatLoginResult */
export function savePartnerWechatAuth(result: WechatLoginResult): PartnerSessionPayload | null {
return handlePartnerWechatLoginResult(result);
}
+9
View File
@@ -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 };
+10
View File
@@ -0,0 +1,10 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
export const weixinSdk = createWeixinSdk({
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
getAccessToken: () => localStorage.getItem('accessToken'),
wechatLoginPath: '/partner/auth/login/wechat',
});
export { isWechatEnv };