feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@dukang/client-logging",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { postJson, type PostJson } from './http';
|
||||
import { getSessionId } from './session';
|
||||
|
||||
export type TrackParams = Record<string, unknown>;
|
||||
|
||||
export type UserTrackerOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken?: () => string | null;
|
||||
postJson?: PostJson;
|
||||
getPagePath?: () => string | undefined;
|
||||
};
|
||||
|
||||
function resolvePagePath(getPagePath?: () => string | undefined): string | undefined {
|
||||
if (getPagePath) return getPagePath();
|
||||
if (typeof window !== 'undefined') return window.location.pathname;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createUserTracker(opts: UserTrackerOptions) {
|
||||
const send = opts.postJson ?? postJson;
|
||||
const getToken = opts.getToken ?? (() =>
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('accessToken') : null);
|
||||
|
||||
function track(eventName: string, params?: TrackParams) {
|
||||
const sessionId = getSessionId();
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': opts.clientApp,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
send({
|
||||
url: `${opts.apiBase}/analytics/events`,
|
||||
headers,
|
||||
body: {
|
||||
sessionId,
|
||||
clientApp: opts.clientApp,
|
||||
events: [{ eventName, params: { sessionId, ...params } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function trackPageView(eventName: string, params?: TrackParams) {
|
||||
track(eventName, {
|
||||
pagePath: resolvePagePath(opts.getPagePath),
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
function trackSessionStart() {
|
||||
track('session_start', {
|
||||
pagePath: resolvePagePath(opts.getPagePath),
|
||||
});
|
||||
}
|
||||
|
||||
return { track, trackPageView, trackSessionStart };
|
||||
}
|
||||
|
||||
export type StoreTrackerOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken: () => string | null;
|
||||
getStoreId: () => string | null;
|
||||
postJson?: PostJson;
|
||||
getPagePath?: () => string | undefined;
|
||||
};
|
||||
|
||||
export function createStoreTracker(opts: StoreTrackerOptions) {
|
||||
const send = opts.postJson ?? postJson;
|
||||
|
||||
function track(eventName: string, params?: TrackParams) {
|
||||
const token = opts.getToken();
|
||||
const storeId = opts.getStoreId();
|
||||
if (!token || !storeId) return;
|
||||
|
||||
send({
|
||||
url: `${opts.apiBase}/analytics/store-events`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': opts.clientApp,
|
||||
},
|
||||
body: {
|
||||
sessionId: getSessionId(),
|
||||
storeId,
|
||||
events: [{ eventName, params: { sessionId: getSessionId(), storeId, ...params } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function trackPageView(eventName: string, params?: TrackParams) {
|
||||
track(eventName, {
|
||||
pagePath: resolvePagePath(opts.getPagePath),
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
return { track, trackPageView };
|
||||
}
|
||||
|
||||
export type PartnerTrackerOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken: () => string | null;
|
||||
postJson?: PostJson;
|
||||
getPagePath?: () => string | undefined;
|
||||
};
|
||||
|
||||
export function createPartnerTracker(opts: PartnerTrackerOptions) {
|
||||
const send = opts.postJson ?? postJson;
|
||||
|
||||
function track(eventName: string, params?: TrackParams) {
|
||||
const token = opts.getToken();
|
||||
if (!token) return;
|
||||
|
||||
send({
|
||||
url: `${opts.apiBase}/analytics/partner-events`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': opts.clientApp,
|
||||
},
|
||||
body: {
|
||||
sessionId: getSessionId(),
|
||||
events: [{ eventName, params: { sessionId: getSessionId(), ...params } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function trackPageView(eventName: string, params?: TrackParams) {
|
||||
track(eventName, {
|
||||
pagePath: resolvePagePath(opts.getPagePath),
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
return { track, trackPageView };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { postJson } from './http';
|
||||
|
||||
export type ClientErrorLevel = 'fatal' | 'error' | 'warn' | 'info';
|
||||
export type ClientErrorCategory =
|
||||
| 'js_error'
|
||||
| 'unhandled_rejection'
|
||||
| 'api_error'
|
||||
| 'network'
|
||||
| 'render'
|
||||
| 'bridge'
|
||||
| 'other';
|
||||
|
||||
export type ClientErrorReporterOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken?: () => string | null;
|
||||
};
|
||||
|
||||
export function installClientErrorReporting(opts: ClientErrorReporterOptions) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const report = (payload: {
|
||||
level: ClientErrorLevel;
|
||||
category: ClientErrorCategory;
|
||||
message: string;
|
||||
stack?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}) => {
|
||||
const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': opts.clientApp,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
postJson({
|
||||
url: `${opts.apiBase}/common/client-errors`,
|
||||
headers,
|
||||
body: {
|
||||
level: payload.level,
|
||||
category: payload.category,
|
||||
message: payload.message,
|
||||
stack: payload.stack,
|
||||
pagePath: window.location.pathname,
|
||||
clientApp: opts.clientApp,
|
||||
extra: payload.extra,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('error', (ev) => {
|
||||
report({
|
||||
level: 'error',
|
||||
category: 'js_error',
|
||||
message: ev.message || 'Unknown error',
|
||||
stack: ev.error instanceof Error ? ev.error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
const reason = ev.reason;
|
||||
report({
|
||||
level: 'error',
|
||||
category: 'unhandled_rejection',
|
||||
message: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function reportApiError(
|
||||
opts: ClientErrorReporterOptions,
|
||||
input: { message: string; status?: number; url?: string; category?: ClientErrorCategory },
|
||||
) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': opts.clientApp,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
postJson({
|
||||
url: `${opts.apiBase}/common/client-errors`,
|
||||
headers,
|
||||
body: {
|
||||
level: 'warn',
|
||||
category: input.category ?? 'api_error',
|
||||
message: input.message.slice(0, 1000),
|
||||
pagePath: window.location.pathname,
|
||||
clientApp: opts.clientApp,
|
||||
extra: { status: input.status, url: input.url },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export type PostJsonInput = {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
export type PostJson = (input: PostJsonInput) => void;
|
||||
|
||||
type WxLike = {
|
||||
request?: (opts: {
|
||||
url: string;
|
||||
method?: string;
|
||||
header?: Record<string, string>;
|
||||
data?: unknown;
|
||||
fail?: () => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
function getWx(): WxLike | undefined {
|
||||
return (globalThis as { wx?: WxLike }).wx;
|
||||
}
|
||||
|
||||
/** fire-and-forget POST JSON;优先 fetch,小程序回退 wx.request */
|
||||
export function postJson(input: PostJsonInput): void {
|
||||
const headers = input.headers ?? { 'Content-Type': 'application/json' };
|
||||
|
||||
if (typeof fetch === 'function') {
|
||||
void fetch(input.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(input.body),
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const wxApi = getWx();
|
||||
if (wxApi?.request) {
|
||||
wxApi.request({
|
||||
url: input.url,
|
||||
method: 'POST',
|
||||
header: headers,
|
||||
data: input.body,
|
||||
fail: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { getSessionId, touchSession } from './session';
|
||||
export {
|
||||
createUserTracker,
|
||||
createStoreTracker,
|
||||
createPartnerTracker,
|
||||
type TrackParams,
|
||||
type UserTrackerOptions,
|
||||
type StoreTrackerOptions,
|
||||
type PartnerTrackerOptions,
|
||||
} from './analytics';
|
||||
export { postJson, type PostJson, type PostJsonInput } from './http';
|
||||
export {
|
||||
installClientErrorReporting,
|
||||
reportApiError,
|
||||
type ClientErrorLevel,
|
||||
type ClientErrorCategory,
|
||||
type ClientErrorReporterOptions,
|
||||
} from './client-error';
|
||||
@@ -0,0 +1,54 @@
|
||||
const SESSION_KEY = 'dukang_session_id';
|
||||
|
||||
type WxStorage = {
|
||||
getStorageSync?: (key: string) => unknown;
|
||||
setStorageSync?: (key: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
function getWxStorage(): WxStorage | undefined {
|
||||
return (globalThis as { wx?: WxStorage }).wx;
|
||||
}
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
try {
|
||||
const v = getWxStorage()?.getStorageSync?.(key);
|
||||
return v != null && v !== '' ? String(v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(key, value);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
getWxStorage()?.setStorageSync?.(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function newSessionId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `s_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function getSessionId(): string {
|
||||
let id = readStorage(SESSION_KEY);
|
||||
if (!id) {
|
||||
id = newSessionId();
|
||||
writeStorage(SESSION_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function touchSession(): string {
|
||||
return getSessionId();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../domain/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dukang/domain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resolveOrderCityPartner,
|
||||
validatePartnerCityBinding,
|
||||
validatePartnerCommissionRates,
|
||||
} from './city-partner';
|
||||
|
||||
describe('resolveOrderCityPartner', () => {
|
||||
const bindings = [
|
||||
{
|
||||
id: '1',
|
||||
partnerAccountId: '10',
|
||||
scopeType: 'CITY_WIDE' as const,
|
||||
districtCodes: null,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 0.03,
|
||||
bindingStatus: 'ACTIVE' as const,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
partnerAccountId: '20',
|
||||
scopeType: 'DISTRICT' as const,
|
||||
districtCodes: ['410105', '金水区'],
|
||||
orderCommissionRate: 0.01,
|
||||
redeemCommissionRate: 0.03,
|
||||
bindingStatus: 'ACTIVE' as const,
|
||||
},
|
||||
];
|
||||
|
||||
it('prefers district partner when district matches adcode', () => {
|
||||
const ref = resolveOrderCityPartner(bindings, '410105');
|
||||
expect(ref?.partnerAccountId).toBe('20');
|
||||
expect(ref?.scopeType).toBe('DISTRICT');
|
||||
});
|
||||
|
||||
it('falls back to city-wide partner', () => {
|
||||
const ref = resolveOrderCityPartner(bindings, '410102');
|
||||
expect(ref?.partnerAccountId).toBe('10');
|
||||
expect(ref?.scopeType).toBe('CITY_WIDE');
|
||||
});
|
||||
|
||||
it('returns null when no bindings', () => {
|
||||
expect(resolveOrderCityPartner([], '410105')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePartnerCityBinding', () => {
|
||||
it('rejects duplicate partner', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
|
||||
{ partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects second city-wide partner', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
|
||||
{ partnerAccountId: '11', scopeType: 'CITY_WIDE' },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('allows overlapping district codes as labels', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
partnerAccountId: '10',
|
||||
scopeType: 'DISTRICT',
|
||||
districtCodes: ['410105'],
|
||||
companyName: '甲公司',
|
||||
},
|
||||
],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('allows district partner without district codes', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: [] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('allows valid district binding', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410106'] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePartnerCommissionRates', () => {
|
||||
it('allows sum within default 5% cap', () => {
|
||||
expect(validatePartnerCommissionRates(0.02, 0.03).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects sum above cap', () => {
|
||||
const result = validatePartnerCommissionRates(0.03, 0.03, 0.05);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain('5.00%');
|
||||
});
|
||||
|
||||
it('respects custom city cap', () => {
|
||||
expect(validatePartnerCommissionRates(0.04, 0.04, 0.08).ok).toBe(true);
|
||||
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
export type CityPartnerScopeType = 'CITY_WIDE' | 'DISTRICT';
|
||||
export type CityPartnerStatus = 'ACTIVE' | 'PAUSED';
|
||||
|
||||
export interface PartnerCityBindingInput {
|
||||
id?: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
/** 仅用于重合报错文案 */
|
||||
companyName?: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerCityResolveRef {
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
}
|
||||
|
||||
export interface PartnerCityValidationResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function normalizeDistrictCodes(codes?: string[] | null): string[] {
|
||||
if (!codes?.length) return [];
|
||||
return [...new Set(codes.map((c) => String(c).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
/** 区县 adcode 或名称命中区域合伙;否则全城合伙 */
|
||||
export function resolveOrderCityPartner(
|
||||
bindings: Array<{
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
}>,
|
||||
receiverDistrict?: string | null,
|
||||
): PartnerCityResolveRef | null {
|
||||
const active = bindings.filter((b) => b.bindingStatus !== 'PAUSED');
|
||||
if (!active.length) return null;
|
||||
|
||||
const districtKey = receiverDistrict?.trim();
|
||||
if (districtKey) {
|
||||
const districtHit = active.find((b) => {
|
||||
if (b.scopeType !== 'DISTRICT') return false;
|
||||
const codes = normalizeDistrictCodes(b.districtCodes);
|
||||
return codes.some((code) => code === districtKey || districtKey.includes(code) || code.includes(districtKey));
|
||||
});
|
||||
if (districtHit) {
|
||||
return {
|
||||
id: districtHit.id,
|
||||
partnerAccountId: districtHit.partnerAccountId,
|
||||
scopeType: districtHit.scopeType,
|
||||
orderCommissionRate: districtHit.orderCommissionRate,
|
||||
redeemCommissionRate: districtHit.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cityWide = active.find((b) => b.scopeType === 'CITY_WIDE');
|
||||
if (cityWide) {
|
||||
return {
|
||||
id: cityWide.id,
|
||||
partnerAccountId: cityWide.partnerAccountId,
|
||||
scopeType: cityWide.scopeType,
|
||||
orderCommissionRate: cityWide.orderCommissionRate,
|
||||
redeemCommissionRate: cityWide.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validatePartnerCityBinding(
|
||||
existing: PartnerCityBindingInput[],
|
||||
input: PartnerCityBindingInput,
|
||||
excludeId?: string,
|
||||
): PartnerCityValidationResult {
|
||||
const others = existing.filter((b) => b.id !== excludeId);
|
||||
|
||||
if (others.some((b) => b.partnerAccountId === input.partnerAccountId)) {
|
||||
return { ok: false, message: '该合伙人已绑定此城市' };
|
||||
}
|
||||
|
||||
if (input.scopeType === 'CITY_WIDE') {
|
||||
if (others.some((b) => b.scopeType === 'CITY_WIDE')) {
|
||||
return { ok: false, message: '每城最多 1 名全城合伙人' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// 区域合伙人所选区县可为空(录入时可稍后补全)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** @deprecated use validatePartnerCityBinding */
|
||||
export const validateCityPartnerBinding = validatePartnerCityBinding;
|
||||
|
||||
export const DEFAULT_MAX_PARTNER_COMMISSION_RATE = 0.05;
|
||||
|
||||
export function resolveMaxPartnerCommissionRate(maxRate?: number | null): number {
|
||||
if (maxRate == null || Number.isNaN(Number(maxRate))) {
|
||||
return DEFAULT_MAX_PARTNER_COMMISSION_RATE;
|
||||
}
|
||||
return Number(maxRate);
|
||||
}
|
||||
|
||||
/** 订单佣金 + 核销佣金不得超过城市配置上限(默认 5%) */
|
||||
export function validatePartnerCommissionRates(
|
||||
orderCommissionRate: number,
|
||||
redeemCommissionRate: number,
|
||||
maxSumRate = DEFAULT_MAX_PARTNER_COMMISSION_RATE,
|
||||
): PartnerCityValidationResult {
|
||||
const sum = orderCommissionRate + redeemCommissionRate;
|
||||
const max = resolveMaxPartnerCommissionRate(maxSumRate);
|
||||
if (sum > max + 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type DevPlanTaskStatus = 'TODO' | 'DEVELOPED' | 'RELEASED';
|
||||
export type DevPlanVersionStatus = 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED';
|
||||
|
||||
export function computeDurationMinutes(
|
||||
devStartedAt: Date | null | undefined,
|
||||
devCompletedAt: Date | null | undefined,
|
||||
): number | null {
|
||||
if (!devStartedAt || !devCompletedAt) return null;
|
||||
const ms = devCompletedAt.getTime() - devStartedAt.getTime();
|
||||
if (ms < 0) return null;
|
||||
return Math.round(ms / 60000);
|
||||
}
|
||||
|
||||
export function versionStatusTimestamps(
|
||||
prev: DevPlanVersionStatus,
|
||||
next: DevPlanVersionStatus,
|
||||
current: {
|
||||
devStartedAt?: Date | null;
|
||||
devCompletedAt?: Date | null;
|
||||
releasedAt?: Date | null;
|
||||
},
|
||||
): {
|
||||
devStartedAt?: Date | null;
|
||||
devCompletedAt?: Date | null;
|
||||
releasedAt?: Date | null;
|
||||
durationMinutes?: number | null;
|
||||
} {
|
||||
const patch: {
|
||||
devStartedAt?: Date | null;
|
||||
devCompletedAt?: Date | null;
|
||||
releasedAt?: Date | null;
|
||||
durationMinutes?: number | null;
|
||||
} = {};
|
||||
const now = new Date();
|
||||
|
||||
if (next === 'IN_PROGRESS' && !current.devStartedAt) {
|
||||
patch.devStartedAt = now;
|
||||
}
|
||||
if (next === 'TESTING' && !current.devCompletedAt) {
|
||||
patch.devCompletedAt = now;
|
||||
}
|
||||
if (next === 'RELEASED' && !current.releasedAt) {
|
||||
patch.releasedAt = now;
|
||||
}
|
||||
|
||||
const started = patch.devStartedAt ?? current.devStartedAt ?? null;
|
||||
const completed = patch.devCompletedAt ?? current.devCompletedAt ?? null;
|
||||
const duration = computeDurationMinutes(started, completed);
|
||||
if (duration != null) patch.durationMinutes = duration;
|
||||
|
||||
void prev;
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function taskCompletedAtOnStatus(
|
||||
prev: DevPlanTaskStatus,
|
||||
next: DevPlanTaskStatus,
|
||||
currentCompletedAt?: Date | null,
|
||||
): Date | null | undefined {
|
||||
if (next === 'RELEASED' && !currentCompletedAt) return new Date();
|
||||
void prev;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calcBenefitAmount,
|
||||
calcRedeemSettleAmount,
|
||||
calcLogisticsFeeByBottles,
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
validateMinPurchase,
|
||||
validateBusinessHours,
|
||||
formatBusinessHours,
|
||||
validateRedeemAmount,
|
||||
allocateBenefitCoupons,
|
||||
calcBenefitSummary,
|
||||
orderTabToStatuses,
|
||||
classifyRedeemClientError,
|
||||
generateRedeemPendingNo,
|
||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
sumUnbilledPayoutAmount,
|
||||
validateStoreWithdraw,
|
||||
pickPayoutsForWithdrawAmount,
|
||||
} from './index';
|
||||
|
||||
describe('calcBenefitAmount', () => {
|
||||
it('uses benefitAmount when set', () => {
|
||||
expect(calcBenefitAmount({ price: 599, benefitAmount: 200 })).toBe(200);
|
||||
});
|
||||
|
||||
it('falls back to price', () => {
|
||||
expect(calcBenefitAmount({ price: 599 })).toBe(599);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMinPurchase', () => {
|
||||
it('local requires 2 bottles', () => {
|
||||
expect(validateMinPurchase('LOCAL', 1, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('LOCAL', 2, 2, 6).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('cross city requires 6 bottles', () => {
|
||||
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('on-site pickup requires at least local min (2 bottles)', () => {
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBusinessHours', () => {
|
||||
it('accepts one segment', () => {
|
||||
expect(validateBusinessHours([{ open: '09:00', close: '22:00' }]).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts two non-overlapping segments', () => {
|
||||
expect(
|
||||
validateBusinessHours([
|
||||
{ open: '09:00', close: '14:00' },
|
||||
{ open: '17:00', close: '21:00' },
|
||||
]).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects overlapping second segment', () => {
|
||||
expect(
|
||||
validateBusinessHours([
|
||||
{ open: '09:00', close: '14:00' },
|
||||
{ open: '13:00', close: '21:00' },
|
||||
]).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBusinessHours', () => {
|
||||
it('formats one or two segments', () => {
|
||||
expect(formatBusinessHours({ openTime: '9:00', closeTime: '22:00' })).toBe('9:00-22:00');
|
||||
expect(
|
||||
formatBusinessHours({
|
||||
openTime: '09:00',
|
||||
closeTime: '14:00',
|
||||
openTime2: '17:00',
|
||||
closeTime2: '21:00',
|
||||
}),
|
||||
).toBe('09:00-14:00,17:00-21:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRedeemAmount', () => {
|
||||
it('rejects over balance or non-positive amount', () => {
|
||||
expect(validateRedeemAmount(100, 50).ok).toBe(true);
|
||||
expect(validateRedeemAmount(100, 100).ok).toBe(true);
|
||||
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
||||
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('allows direct redeem up to total balance without a document cap', () => {
|
||||
expect(validateRedeemAmount(700, 650).ok).toBe(true);
|
||||
expect(validateRedeemAmount(700, 701).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('caps document-based redeem by document amount', () => {
|
||||
expect(validateRedeemAmount(700, 300, 300).ok).toBe(true);
|
||||
expect(validateRedeemAmount(700, 301, 300)).toEqual({
|
||||
ok: false,
|
||||
message: '核销金额不能超过该核销单可用金额',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcRedeemSettleAmount', () => {
|
||||
it('applies settlement rate', () => {
|
||||
expect(calcRedeemSettleAmount(100, 0.6)).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcLogisticsFeeByBottles', () => {
|
||||
const xfx = {
|
||||
baseBottles: 2,
|
||||
baseFee: 6,
|
||||
extraBottleFee: 2,
|
||||
boxBottles: 6,
|
||||
boxFee: 14,
|
||||
};
|
||||
|
||||
it('charges base for 1~2 bottles', () => {
|
||||
expect(calcLogisticsFeeByBottles(1, xfx)).toBe(6);
|
||||
expect(calcLogisticsFeeByBottles(2, xfx)).toBe(6);
|
||||
});
|
||||
|
||||
it('adds extra bottle fee', () => {
|
||||
expect(calcLogisticsFeeByBottles(3, xfx)).toBe(8);
|
||||
expect(calcLogisticsFeeByBottles(5, xfx)).toBe(12);
|
||||
});
|
||||
|
||||
it('uses box fee for full boxes', () => {
|
||||
expect(calcLogisticsFeeByBottles(6, xfx)).toBe(14);
|
||||
expect(calcLogisticsFeeByBottles(12, xfx)).toBe(28);
|
||||
});
|
||||
|
||||
it('combines boxes with remainder ladder', () => {
|
||||
expect(calcLogisticsFeeByBottles(7, xfx)).toBe(20);
|
||||
expect(calcLogisticsFeeByBottles(8, xfx)).toBe(20);
|
||||
expect(calcLogisticsFeeByBottles(9, xfx)).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldAutoCourierDispatch', () => {
|
||||
it('holds at 10 full boxes (60 bottles)', () => {
|
||||
expect(shouldHoldAutoCourierDispatch(59)).toBe(false);
|
||||
expect(shouldHoldAutoCourierDispatch(60)).toBe(true);
|
||||
expect(shouldHoldAutoCourierDispatch(66)).toBe(true);
|
||||
expect(calcOrderBoxCount(60)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allocateBenefitCoupons', () => {
|
||||
const coupons = [
|
||||
{ id: '1', balance: 300, createdAt: 1 },
|
||||
{ id: '2', balance: 400, createdAt: 2 },
|
||||
];
|
||||
|
||||
it('allocates FIFO across coupons', () => {
|
||||
const result = allocateBenefitCoupons(coupons, 500);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.allocations).toEqual([
|
||||
{ couponId: '1', amount: 300 },
|
||||
{ couponId: '2', amount: 200 },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when total balance insufficient', () => {
|
||||
expect(allocateBenefitCoupons(coupons, 800).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when a document cap is lower than requested amount', () => {
|
||||
expect(allocateBenefitCoupons(coupons, 301, 300)).toEqual({
|
||||
ok: false,
|
||||
message: '核销金额不能超过该核销单可用金额',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderTabToStatuses', () => {
|
||||
it('maps V3.0 three user tabs', () => {
|
||||
expect(orderTabToStatuses('pending_pay')).toEqual(['PENDING_PAY']);
|
||||
expect(orderTabToStatuses('paid')).toEqual([
|
||||
'PENDING_SHIP',
|
||||
'OUT_WAREHOUSE',
|
||||
'SHIPPING',
|
||||
'PENDING_RECEIVE',
|
||||
]);
|
||||
expect(orderTabToStatuses('completed')).toEqual(['COMPLETED']);
|
||||
});
|
||||
|
||||
it('keeps legacy tab keys mapped to paid', () => {
|
||||
expect(orderTabToStatuses('pending_ship')).toEqual(orderTabToStatuses('paid'));
|
||||
expect(orderTabToStatuses('pending_receive')).toEqual(orderTabToStatuses('paid'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcBenefitSummary', () => {
|
||||
it('max redeem equals total balance', () => {
|
||||
expect(calcBenefitSummary([300, 400])).toEqual({
|
||||
totalBalance: 700,
|
||||
maxRedeemAmount: 700,
|
||||
activeCouponCount: 2,
|
||||
});
|
||||
expect(calcBenefitSummary([100])).toEqual({
|
||||
totalBalance: 100,
|
||||
maxRedeemAmount: 100,
|
||||
activeCouponCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRedeemClientError', () => {
|
||||
it('treats 5xx and network messages as NETWORK', () => {
|
||||
expect(classifyRedeemClientError({ status: 502 })).toBe('NETWORK');
|
||||
expect(classifyRedeemClientError({ message: 'Failed to fetch' })).toBe('NETWORK');
|
||||
expect(classifyRedeemClientError({ message: '网络超时' })).toBe('NETWORK');
|
||||
});
|
||||
|
||||
it('treats business messages as BUSINESS', () => {
|
||||
expect(classifyRedeemClientError({ status: 400, message: '核销码无效或已过期' })).toBe('BUSINESS');
|
||||
expect(classifyRedeemClientError({ message: '余额不足' })).toBe('BUSINESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRedeemPendingNo', () => {
|
||||
it('uses RP prefix', () => {
|
||||
expect(generateRedeemPendingNo().startsWith('RP')).toBe(true);
|
||||
});
|
||||
|
||||
it('exports weaknet threshold of 5', () => {
|
||||
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumUnbilledPayoutAmount', () => {
|
||||
it('sums payout amounts', () => {
|
||||
expect(sumUnbilledPayoutAmount([{ payoutAmount: 60 }, { payoutAmount: 40.5 }])).toBe(100.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateStoreWithdraw', () => {
|
||||
const base = {
|
||||
availableAmount: 1000,
|
||||
requestAmount: 200,
|
||||
todayApplied: 0,
|
||||
dailyLimit: 5000,
|
||||
hasPendingRequest: false,
|
||||
hasBankAccount: true,
|
||||
};
|
||||
|
||||
it('rejects when daily limit exceeded', () => {
|
||||
expect(
|
||||
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects pending request / missing bank / over available', () => {
|
||||
expect(validateStoreWithdraw({ ...base, hasPendingRequest: true }).ok).toBe(false);
|
||||
expect(validateStoreWithdraw({ ...base, hasBankAccount: false }).ok).toBe(false);
|
||||
expect(validateStoreWithdraw({ ...base, requestAmount: 1001 }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid request', () => {
|
||||
expect(validateStoreWithdraw(base).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickPayoutsForWithdrawAmount', () => {
|
||||
it('picks FIFO exact match', () => {
|
||||
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }, { payoutAmount: 30 }];
|
||||
const r = pickPayoutsForWithdrawAmount(rows, 100);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.selected).toHaveLength(2);
|
||||
expect(r.amount).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-exact match', () => {
|
||||
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }];
|
||||
expect(pickPayoutsForWithdrawAmount(rows, 50).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
export interface ProductPricing {
|
||||
price: number;
|
||||
benefitAmount?: number | null;
|
||||
}
|
||||
|
||||
export function calcBenefitAmount(product: ProductPricing): number {
|
||||
return product.benefitAmount ?? product.price;
|
||||
}
|
||||
|
||||
export function validateMinPurchase(
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||
quantity: number,
|
||||
localMinQty: number,
|
||||
crossMinQty: number,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
const min = localMinQty > 0 ? localMinQty : 2;
|
||||
if (quantity < min) {
|
||||
return { ok: false, message: `现场提货至少购买 ${min} 瓶` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||||
if (quantity < min) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
deliveryType === 'LOCAL'
|
||||
? `同城配送至少购买 ${min} 瓶`
|
||||
: `跨城配送至少购买 ${min} 瓶(1箱)`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateRedeemAmount(
|
||||
balance: number,
|
||||
amount: number,
|
||||
documentAmount?: number | null,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
|
||||
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
|
||||
if (documentAmount != null && amount > documentAmount) {
|
||||
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface BenefitCouponBalance {
|
||||
id: string;
|
||||
balance: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** FIFO 跨多张权益券分配核销金额(购酒按单发券,展示为总余额) */
|
||||
export function allocateBenefitCoupons(
|
||||
coupons: BenefitCouponBalance[],
|
||||
amount: number,
|
||||
documentAmount?: number | null,
|
||||
): { ok: true; allocations: Array<{ couponId: string; amount: number }> } | { ok: false; message: string } {
|
||||
const active = coupons
|
||||
.filter((c) => c.balance > 0)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
const totalBalance = active.reduce((sum, c) => sum + c.balance, 0);
|
||||
const check = validateRedeemAmount(totalBalance, amount, documentAmount);
|
||||
if (!check.ok) return { ok: false, message: check.message! };
|
||||
|
||||
let remaining = amount;
|
||||
const allocations: Array<{ couponId: string; amount: number }> = [];
|
||||
for (const coupon of active) {
|
||||
if (remaining <= 0) break;
|
||||
const take = Math.min(coupon.balance, remaining);
|
||||
allocations.push({ couponId: coupon.id, amount: take });
|
||||
remaining = Math.round((remaining - take) * 100) / 100;
|
||||
}
|
||||
|
||||
if (remaining > 0) {
|
||||
return { ok: false, message: '权益余额不足' };
|
||||
}
|
||||
|
||||
return { ok: true, allocations };
|
||||
}
|
||||
|
||||
export function calcBenefitSummary(
|
||||
balances: number[],
|
||||
): { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number } {
|
||||
const totalBalance = Math.round(balances.reduce((sum, b) => sum + b, 0) * 100) / 100;
|
||||
return {
|
||||
totalBalance,
|
||||
maxRedeemAmount: totalBalance,
|
||||
activeCouponCount: balances.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function calcRedeemSettleAmount(amount: number, settlementRate: number): number {
|
||||
return Math.round(amount * settlementRate * 100) / 100;
|
||||
}
|
||||
|
||||
export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>): number {
|
||||
return Math.round(payouts.reduce((sum, p) => sum + Number(p.payoutAmount || 0), 0) * 100) / 100;
|
||||
}
|
||||
|
||||
export type ValidateStoreWithdrawInput = {
|
||||
availableAmount: number;
|
||||
requestAmount: number;
|
||||
todayApplied: number;
|
||||
dailyLimit: number;
|
||||
hasPendingRequest: boolean;
|
||||
hasBankAccount: boolean;
|
||||
};
|
||||
|
||||
/** 门店未出账提现护栏(FIN-002 单日上限 + 幂等/账户) */
|
||||
export function validateStoreWithdraw(
|
||||
input: ValidateStoreWithdrawInput,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (!input.hasBankAccount) {
|
||||
return { ok: false, message: '请先完善入驻收款账户后再提现' };
|
||||
}
|
||||
if (input.hasPendingRequest) {
|
||||
return { ok: false, message: '已有待审核提现申请,请等待处理完成' };
|
||||
}
|
||||
if (!(input.requestAmount > 0)) {
|
||||
return { ok: false, message: '提现金额必须大于 0' };
|
||||
}
|
||||
if (input.requestAmount > input.availableAmount + 1e-9) {
|
||||
return { ok: false, message: '提现金额不能超过可提未出账余额' };
|
||||
}
|
||||
const remaining = Math.round((input.dailyLimit - input.todayApplied) * 100) / 100;
|
||||
if (remaining <= 0) {
|
||||
return { ok: false, message: `已达单店单日提现上限 ¥${input.dailyLimit.toFixed(2)}` };
|
||||
}
|
||||
if (input.requestAmount > remaining + 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `超过单日剩余额度 ¥${remaining.toFixed(2)}(上限 ¥${input.dailyLimit.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 FIFO 选取未出账 payout,使合计尽量等于 targetAmount(不超过目标)。
|
||||
* 若无法精确凑齐,返回合计 ≤ target 的最长前缀子集。
|
||||
*/
|
||||
export function pickPayoutsForWithdrawAmount<T extends { payoutAmount: number }>(
|
||||
payoutsAsc: T[],
|
||||
targetAmount: number,
|
||||
): { ok: true; selected: T[]; amount: number } | { ok: false; message: string } {
|
||||
if (!(targetAmount > 0)) return { ok: false, message: '提现金额必须大于 0' };
|
||||
const selected: T[] = [];
|
||||
let sum = 0;
|
||||
for (const p of payoutsAsc) {
|
||||
const next = Math.round((sum + Number(p.payoutAmount)) * 100) / 100;
|
||||
if (next <= targetAmount + 1e-9) {
|
||||
selected.push(p);
|
||||
sum = next;
|
||||
if (Math.abs(sum - targetAmount) < 1e-9) break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!selected.length) {
|
||||
return { ok: false, message: '无可匹配的未出账结算明细,请调整提现金额' };
|
||||
}
|
||||
if (Math.abs(sum - targetAmount) > 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `无法按 ¥${targetAmount.toFixed(2)} 精确匹配明细,请按可提总额全额提现或调整金额`,
|
||||
};
|
||||
}
|
||||
return { ok: true, selected, amount: sum };
|
||||
}
|
||||
|
||||
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
export type BusinessHoursSegment = { open: string; close: string };
|
||||
|
||||
export function timeToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** 校验 1~2 段营业时间,例如 09:00-22:00 或 09:00-14:00 + 17:00-21:00 */
|
||||
export function validateBusinessHours(
|
||||
segments: BusinessHoursSegment[],
|
||||
): { ok: boolean; message?: string } {
|
||||
const cleaned = segments
|
||||
.map((s) => ({ open: String(s.open || '').trim(), close: String(s.close || '').trim() }))
|
||||
.filter((s) => s.open || s.close);
|
||||
if (cleaned.length < 1) return { ok: false, message: '请设置营业时间' };
|
||||
if (cleaned.length > 2) return { ok: false, message: '营业时间最多支持 2 段' };
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const s = cleaned[i];
|
||||
if (!TIME_HM_RE.test(s.open) || !TIME_HM_RE.test(s.close)) {
|
||||
return { ok: false, message: `第 ${i + 1} 段时间格式须为 HH:MM` };
|
||||
}
|
||||
if (timeToMinutes(s.open) >= timeToMinutes(s.close)) {
|
||||
return { ok: false, message: `第 ${i + 1} 段结束时间须晚于开始时间` };
|
||||
}
|
||||
}
|
||||
if (cleaned.length === 2 && timeToMinutes(cleaned[0].close) >= timeToMinutes(cleaned[1].open)) {
|
||||
return { ok: false, message: '第二段开始时间须晚于第一段结束时间' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function formatBusinessHours(store: {
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) {
|
||||
parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
}
|
||||
if (store.openTime2 && store.closeTime2) {
|
||||
parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
}
|
||||
return parts.length ? parts.join(',') : '10:00-22:00';
|
||||
}
|
||||
|
||||
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||||
export const BOTTLES_PER_BOX = 6;
|
||||
|
||||
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
|
||||
export const XFX_AUTO_DISPATCH_MAX_BOXES = 10;
|
||||
|
||||
/** 按箱规向上取整得到箱数 */
|
||||
export function calcOrderBoxCount(quantity: number, bottlesPerBox = BOTTLES_PER_BOX): number {
|
||||
const qty = Math.floor(Number(quantity) || 0);
|
||||
if (qty <= 0 || !(bottlesPerBox > 0)) return 0;
|
||||
return Math.ceil(qty / bottlesPerBox);
|
||||
}
|
||||
|
||||
/** 是否因大单拦截自动推承运商(默认 ≥10 箱 = 60 瓶) */
|
||||
export function shouldHoldAutoCourierDispatch(
|
||||
quantity: number,
|
||||
options?: { bottlesPerBox?: number; maxBoxes?: number },
|
||||
): boolean {
|
||||
const bottlesPerBox = options?.bottlesPerBox ?? BOTTLES_PER_BOX;
|
||||
const maxBoxes = options?.maxBoxes ?? XFX_AUTO_DISPATCH_MAX_BOXES;
|
||||
const qty = Math.floor(Number(quantity) || 0);
|
||||
return qty >= bottlesPerBox * maxBoxes;
|
||||
}
|
||||
|
||||
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||||
export type LogisticsPricingRule = {
|
||||
baseBottles: number;
|
||||
baseFee: number;
|
||||
extraBottleFee: number;
|
||||
boxBottles?: number;
|
||||
boxFee?: number;
|
||||
};
|
||||
|
||||
function roundMoney(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function calcLogisticsBottleLadder(quantity: number, rule: LogisticsPricingRule): number {
|
||||
const qty = Math.floor(quantity);
|
||||
if (qty <= 0) return 0;
|
||||
if (qty <= rule.baseBottles) return rule.baseFee;
|
||||
return rule.baseFee + (qty - rule.baseBottles) * rule.extraBottleFee;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按承运商计价标准计算单票物流费。
|
||||
* 配置了箱规时:整箱按 boxFee,余瓶按「起送瓶数/加瓶」阶梯。
|
||||
*/
|
||||
export function calcLogisticsFeeByBottles(quantity: number, rule: LogisticsPricingRule): number {
|
||||
const qty = Math.floor(Number(quantity) || 0);
|
||||
if (qty <= 0) return 0;
|
||||
if (!(rule.baseBottles > 0) || !(rule.baseFee >= 0) || !(rule.extraBottleFee >= 0)) {
|
||||
throw new Error('计价标准不完整');
|
||||
}
|
||||
|
||||
const boxBottles = rule.boxBottles && rule.boxBottles > 0 ? rule.boxBottles : 0;
|
||||
const boxFee = rule.boxFee != null && rule.boxFee >= 0 ? rule.boxFee : null;
|
||||
if (boxBottles > 0 && boxFee != null) {
|
||||
const boxes = Math.floor(qty / boxBottles);
|
||||
const rem = qty % boxBottles;
|
||||
return roundMoney(boxes * boxFee + calcLogisticsBottleLadder(rem, rule));
|
||||
}
|
||||
return roundMoney(calcLogisticsBottleLadder(qty, rule));
|
||||
}
|
||||
|
||||
export function generateUserNo(): string {
|
||||
const suffix = Math.floor(10000000 + Math.random() * 90000000);
|
||||
return `DK${suffix}`;
|
||||
}
|
||||
|
||||
export function generateOrderNo(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const rand = String(Math.floor(Math.random() * 100000)).padStart(5, '0');
|
||||
return `DK${y}${m}${d}${rand}`;
|
||||
}
|
||||
|
||||
export function generateCouponNo(): string {
|
||||
return `BC${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
export function generateRedeemNo(): string {
|
||||
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
export function generateRedeemPendingNo(): string {
|
||||
return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
/** 弱网核销:网络类失败达到该次数后触发兜底 */
|
||||
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||
|
||||
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||
|
||||
/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */
|
||||
export function classifyRedeemClientError(input: {
|
||||
status?: number | null;
|
||||
message?: string | null;
|
||||
}): RedeemErrorClass {
|
||||
const status = input.status;
|
||||
const message = (input.message ?? '').toLowerCase();
|
||||
if (status != null && status >= 500) return 'NETWORK';
|
||||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
if (
|
||||
/failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
return 'BUSINESS';
|
||||
}
|
||||
|
||||
export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||
switch (tab) {
|
||||
case 'pending_pay':
|
||||
return ['PENDING_PAY'];
|
||||
case 'paid':
|
||||
case 'pending_ship':
|
||||
case 'pending_receive':
|
||||
return ['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE'];
|
||||
case 'completed':
|
||||
return ['COMPLETED'];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './city-partner';
|
||||
export * from './dev-plan';
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@dukang/shared-types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./user-log": {
|
||||
"types": "./dist/user-log.d.ts",
|
||||
"import": "./src/user-log.ts",
|
||||
"default": "./dist/user-log.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
clientApp: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
user?: Record<string, unknown>;
|
||||
store?: Record<string, unknown>;
|
||||
partner?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface BenefitCouponDto {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
balance: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
sourceProduct?: string;
|
||||
}
|
||||
|
||||
export interface BenefitSummaryDto {
|
||||
totalBalance: number;
|
||||
couponCount: number;
|
||||
}
|
||||
|
||||
export interface BenefitLedgerDto {
|
||||
id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** HQ 手动发放权益 */
|
||||
export interface AdminBenefitGrantRequest {
|
||||
phone: string;
|
||||
amount: number;
|
||||
remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface CityDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
/** 订单+核销佣金合计上限(小数,默认 0.05) */
|
||||
maxPartnerCommissionRate?: number;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
benefitDisplay?: number;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
/** 封面图(common_resource COVER / cover_resource_id) */
|
||||
mainImageUrl?: string | null;
|
||||
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
||||
carouselUrls?: string[];
|
||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||
detailImageUrls?: string[];
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
/** 是否允许现场取货下单 */
|
||||
allowOnSitePickup?: boolean;
|
||||
/** 是否允许配送到址(同城线上购买) */
|
||||
allowOnlinePurchase?: boolean;
|
||||
/** 是否允许跨城配送 */
|
||||
allowCrossCityDelivery?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductDetailFeatureDto {
|
||||
icon: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface ProductDetailContentDto {
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: ProductDetailFeatureDto[];
|
||||
/** 兜底详情图,优先 DETAIL 资源 */
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface ProductListQuery {
|
||||
cityCode?: string;
|
||||
aromaType?: string;
|
||||
}
|
||||
|
||||
export interface ProductDetailTemplateDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
aromaType?: string | null;
|
||||
storyTitle?: string | null;
|
||||
storyText?: string | null;
|
||||
features?: ProductDetailFeatureDto[];
|
||||
detailImageUrls?: string[];
|
||||
suggestedDetailImageCount: number;
|
||||
sortOrder: number;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CityPartnerScopeType, CityPartnerStatus } from './enums';
|
||||
|
||||
/** @deprecated use PartnerPrimaryAccountDto */
|
||||
export type CityPartnerDto = PartnerPrimaryAccountDto;
|
||||
|
||||
export interface PartnerPrimaryAccountDto {
|
||||
id: string;
|
||||
cityId: string;
|
||||
cityName?: string | null;
|
||||
cityCode?: string | null;
|
||||
companyName: string;
|
||||
contactPhone?: string | null;
|
||||
address?: string | null;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus: CityPartnerStatus;
|
||||
managedWarehouseId?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePartnerPrimaryInput {
|
||||
cityId: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
companyName: string;
|
||||
address: string;
|
||||
contactPhone?: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[];
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
managedWarehouseId?: string;
|
||||
contractNo?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
weeklyStoreTarget?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePartnerPrimaryInput {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
companyName?: string;
|
||||
address?: string;
|
||||
contactPhone?: string;
|
||||
scopeType?: CityPartnerScopeType;
|
||||
districtCodes?: string[];
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
managedWarehouseId?: string | null;
|
||||
contractNo?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
weeklyStoreTarget?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PartnerCityBindingRef {
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus: CityPartnerStatus;
|
||||
}
|
||||
|
||||
export const PARTNER_PERMISSION_KEYS = [
|
||||
'warehouse:manage',
|
||||
'store:manage',
|
||||
'store:create',
|
||||
'order:view',
|
||||
] as const;
|
||||
|
||||
export type PartnerPermissionKey = (typeof PARTNER_PERMISSION_KEYS)[number];
|
||||
|
||||
export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
|
||||
'warehouse:manage': '仓库管理',
|
||||
'store:manage': '门店管理',
|
||||
'store:create': '开店管理',
|
||||
'order:view': '订单查看',
|
||||
};
|
||||
|
||||
/** 门店类子账号默认权限:录入、开闭店、维护资料 */
|
||||
export const DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS: PartnerPermissionKey[] = [
|
||||
'store:create',
|
||||
'store:manage',
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { WarehouseFulfillmentMode, WarehouseManagerType, WarehouseStatus } from './enums';
|
||||
|
||||
export interface CityWarehouseDto {
|
||||
id: string;
|
||||
cityId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: WarehouseStatus;
|
||||
fulfillmentMode: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId: string | null;
|
||||
fulfillmentProviderName?: string | null;
|
||||
fulfillmentProviderCode?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCityWarehouseInput {
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: string;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string;
|
||||
manualCarrierLabel?: string;
|
||||
manualQueryUrlTemplate?: string;
|
||||
lng?: number;
|
||||
lat?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCityWarehouseInput {
|
||||
name?: string;
|
||||
address?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
managerType?: WarehouseManagerType;
|
||||
partnerAccountId?: string | null;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export interface AppConfig {
|
||||
mockSms: boolean;
|
||||
mockPay: boolean;
|
||||
mockDeliveryAuto: boolean;
|
||||
autoApproveStore: boolean;
|
||||
/** Mock 微信 OAuth/登录/绑定;关闭且凭证齐全时走真实微信 */
|
||||
mockWechat: boolean;
|
||||
/** 前端是否展示微信授权入口(由 mockWechat 或真实凭证推导) */
|
||||
wxAuthorize: boolean;
|
||||
/** 是否可走真实 JSAPI 支付(由 MOCK_PAY 与商户凭证推导) */
|
||||
wechatPayEnabled: boolean;
|
||||
wxAppId: string;
|
||||
/** 小程序 AppID(code2session 与小程序支付;未配置时回退 wxAppId) */
|
||||
wxMiniAppId: string;
|
||||
wxMchId: string;
|
||||
aliyunSmsSignName: string;
|
||||
aliyunSmsTemplateCode: string;
|
||||
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);env: ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM */
|
||||
aliyunSmsRedeemConfirmTemplateCode: string;
|
||||
/** 合伙人代下单短信模板(PARTNER_PROXY_ORDER / PARTNER_PROXY_CUSTOMER);env: ALIYUN_SMS_TEMPLATE_PROXY_ORDER */
|
||||
aliyunSmsProxyOrderTemplateCode: string;
|
||||
aliyunSmsAccessKeyId: string;
|
||||
aliyunSmsAccessKeySecret: string;
|
||||
/** 腾讯位置服务 Key(逆地理编码 / 地点搜索) */
|
||||
tencentLbsKey: string;
|
||||
/**
|
||||
* 腾讯位置服务 SecretKey(SK)
|
||||
* 控制台开启 WebServiceAPI 签名校验后生成;仅服务端签名用,勿下发前端
|
||||
*/
|
||||
tencentLbsSecretKey: string;
|
||||
/** C 端 H5 落地页(推广码二维码链接前缀) */
|
||||
userH5Url: string;
|
||||
}
|
||||
|
||||
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
|
||||
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
||||
|
||||
/** 品牌 Logo OSS 根路径(改环境时只改此处) */
|
||||
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
||||
|
||||
/** 方形 Logo(首页等品牌展示;商品列表顶栏仍用文字标题) */
|
||||
export const BRAND_LOGO_URL = `${BRAND_LOGO_OSS_BASE}logo.png`;
|
||||
|
||||
/** 长方形 Logo(含文字,登录等场景) */
|
||||
export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
|
||||
|
||||
/** 仅图标 Logo(默认头像:未微信授权时) */
|
||||
export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
|
||||
|
||||
/** 小程序静态资源(资质公示等) */
|
||||
export const MINI_USER_STATIC_OSS_BASE =
|
||||
'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/';
|
||||
|
||||
/** 「我的」页资质公示长图 */
|
||||
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
||||
|
||||
/** 总部客服电话(C 端联系客服) */
|
||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||
|
||||
/**
|
||||
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
|
||||
* 可在 h5-user 用 VITE_CS_WECOM_URL 覆盖
|
||||
*/
|
||||
export const CUSTOMER_SERVICE_WECOM_URL =
|
||||
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
||||
|
||||
function readEnv(env?: Record<string, string | undefined>) {
|
||||
return (
|
||||
env ??
|
||||
(globalThis as { process?: { env: Record<string, string | undefined> } }).process?.env ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
export function hasWechatAuthCredentials(env?: Record<string, string | undefined>): boolean {
|
||||
const e = readEnv(env);
|
||||
return !!(e.WX_APP_ID?.trim() && e.WX_APP_SECRET?.trim());
|
||||
}
|
||||
|
||||
export function hasWechatPayCredentials(env?: Record<string, string | undefined>): boolean {
|
||||
const e = readEnv(env);
|
||||
const appId = e.WX_MINI_APP_ID?.trim() || e.WX_APP_ID?.trim();
|
||||
return !!(
|
||||
appId &&
|
||||
e.WX_MCH_ID?.trim() &&
|
||||
e.WX_MCH_SERIAL_NO?.trim() &&
|
||||
e.WX_MCH_PRIVATE_KEY?.trim() &&
|
||||
e.WX_API_V3_KEY?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否应使用真实微信 API(授权或支付任一需要) */
|
||||
export function needsRealWechatApi(cfg: Pick<AppConfig, 'mockWechat' | 'mockPay'>, env?: Record<string, string | undefined>): boolean {
|
||||
return (
|
||||
(!cfg.mockWechat && hasWechatAuthCredentials(env)) ||
|
||||
(!cfg.mockPay && hasWechatPayCredentials(env))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWxAuthorize(cfg: Pick<AppConfig, 'mockWechat'>, env?: Record<string, string | undefined>): boolean {
|
||||
return cfg.mockWechat || hasWechatAuthCredentials(env);
|
||||
}
|
||||
|
||||
export function resolveWechatPayEnabled(cfg: Pick<AppConfig, 'mockPay'>, env?: Record<string, string | undefined>): boolean {
|
||||
return !cfg.mockPay && hasWechatPayCredentials(env);
|
||||
}
|
||||
|
||||
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
|
||||
const e = readEnv(env);
|
||||
const mockSms = e.MOCK_SMS !== 'false';
|
||||
const mockPay = e.MOCK_PAY !== 'false';
|
||||
const mockWechat = e.MOCK_WECHAT === 'true';
|
||||
const base = {
|
||||
mockSms,
|
||||
mockPay,
|
||||
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
|
||||
autoApproveStore: e.AUTO_APPROVE_STORE !== 'false',
|
||||
mockWechat,
|
||||
wxAppId: e.WX_APP_ID ?? '',
|
||||
wxMiniAppId: e.WX_MINI_APP_ID ?? e.WX_APP_ID ?? '',
|
||||
wxMchId: e.WX_MCH_ID ?? '',
|
||||
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
|
||||
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
|
||||
aliyunSmsRedeemConfirmTemplateCode:
|
||||
e.ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM ??
|
||||
e.ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE ??
|
||||
'',
|
||||
aliyunSmsProxyOrderTemplateCode:
|
||||
e.ALIYUN_SMS_TEMPLATE_PROXY_ORDER ??
|
||||
e.ALIYUN_SMS_PROXY_ORDER_TEMPLATE_CODE ??
|
||||
'',
|
||||
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
|
||||
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
|
||||
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
||||
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
|
||||
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
wxAuthorize: resolveWxAuthorize(base, e),
|
||||
wechatPayEnabled: resolveWechatPayEnabled(base, e),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 短信环境固定验证码 */
|
||||
export const MOCK_SMS_FIXED_CODE = '999888';
|
||||
@@ -0,0 +1,235 @@
|
||||
/** 开发计划任务类型 */
|
||||
|
||||
export type DevPlanTaskTypeDto = 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
|
||||
|
||||
|
||||
|
||||
export const DEV_PLAN_TASK_TYPES = ['BUG', 'REQUIREMENT', 'OPTIMIZATION'] as const;
|
||||
|
||||
|
||||
|
||||
export const DEV_PLAN_TASK_TYPE_LABELS: Record<DevPlanTaskTypeDto, string> = {
|
||||
|
||||
BUG: 'BUG',
|
||||
|
||||
REQUIREMENT: '需求',
|
||||
|
||||
OPTIMIZATION: '优化',
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
/** 开发计划任务状态 */
|
||||
|
||||
export type DevPlanTaskStatusDto = 'TODO' | 'DEVELOPED' | 'RELEASED';
|
||||
|
||||
|
||||
|
||||
export const DEV_PLAN_TASK_STATUSES = ['TODO', 'DEVELOPED', 'RELEASED'] as const;
|
||||
|
||||
|
||||
|
||||
export const DEV_PLAN_TASK_STATUS_LABELS: Record<DevPlanTaskStatusDto, string> = {
|
||||
|
||||
TODO: '待开发',
|
||||
|
||||
DEVELOPED: '已开发',
|
||||
|
||||
RELEASED: '已上线',
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
/** 开发计划版本状态 */
|
||||
|
||||
export type DevPlanVersionStatusDto = 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED';
|
||||
|
||||
|
||||
|
||||
export const DEV_PLAN_VERSION_STATUSES = ['PENDING', 'IN_PROGRESS', 'TESTING', 'RELEASED'] as const;
|
||||
|
||||
|
||||
|
||||
export const DEV_PLAN_VERSION_STATUS_LABELS: Record<DevPlanVersionStatusDto, string> = {
|
||||
|
||||
PENDING: '待启动',
|
||||
|
||||
IN_PROGRESS: '开发中',
|
||||
|
||||
TESTING: '测试',
|
||||
|
||||
RELEASED: '已上线',
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
export interface DevPlanTaskDto {
|
||||
|
||||
id: string;
|
||||
|
||||
taskNo: string;
|
||||
|
||||
content: string;
|
||||
|
||||
type: DevPlanTaskTypeDto;
|
||||
|
||||
status: DevPlanTaskStatusDto;
|
||||
|
||||
creatorHqAccountId: string;
|
||||
|
||||
creatorName?: string | null;
|
||||
|
||||
supportTicketId?: string | null;
|
||||
|
||||
supportTicketNo?: string | null;
|
||||
|
||||
lastDispatchedAt?: string | null;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
completedAt?: string | null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DevPlanVersionDto {
|
||||
|
||||
id: string;
|
||||
|
||||
versionNo: string;
|
||||
|
||||
content?: string | null;
|
||||
|
||||
status: DevPlanVersionStatusDto;
|
||||
|
||||
assigneeHqAccountId?: string | null;
|
||||
|
||||
assigneeName?: string | null;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
devStartedAt?: string | null;
|
||||
|
||||
devCompletedAt?: string | null;
|
||||
|
||||
releasedAt?: string | null;
|
||||
|
||||
durationMinutes?: number | null;
|
||||
|
||||
tasks?: DevPlanTaskDto[];
|
||||
|
||||
taskIds?: string[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DevPlanSettingsDto {
|
||||
|
||||
reviewAssistantLlmConfigId?: string | null;
|
||||
|
||||
reviewAssistantKnowledgeBaseId?: string | null;
|
||||
|
||||
reviewAssistantPrompt?: string | null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CreateDevPlanTaskInput {
|
||||
|
||||
content: string;
|
||||
|
||||
type: DevPlanTaskTypeDto;
|
||||
|
||||
supportTicketId?: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface UpdateDevPlanTaskInput {
|
||||
|
||||
content?: string;
|
||||
|
||||
type?: DevPlanTaskTypeDto;
|
||||
|
||||
status?: DevPlanTaskStatusDto;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CreateDevPlanVersionInput {
|
||||
|
||||
versionNo: string;
|
||||
|
||||
content?: string;
|
||||
|
||||
status?: DevPlanVersionStatusDto;
|
||||
|
||||
taskIds?: string[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface UpdateDevPlanVersionInput {
|
||||
|
||||
versionNo?: string;
|
||||
|
||||
content?: string;
|
||||
|
||||
status?: DevPlanVersionStatusDto;
|
||||
|
||||
taskIds?: string[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DevPlanTaskDispatchInput {
|
||||
|
||||
taskIds: string[];
|
||||
|
||||
supplement?: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DevPlanLinkedTaskSummary {
|
||||
|
||||
id: string;
|
||||
|
||||
taskNo: string;
|
||||
|
||||
content: string;
|
||||
|
||||
status: DevPlanTaskStatusDto;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 技术支持工单类型 → 开发任务类型 */
|
||||
|
||||
export function mapSupportTicketTypeToDevPlanTask(
|
||||
|
||||
ticketType: 'BUG' | 'SUGGESTION' | 'OTHER',
|
||||
|
||||
): DevPlanTaskTypeDto {
|
||||
|
||||
if (ticketType === 'BUG') return 'BUG';
|
||||
|
||||
if (ticketType === 'SUGGESTION') return 'REQUIREMENT';
|
||||
|
||||
return 'OPTIMIZATION';
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
export enum ClientApp {
|
||||
USER_MINI = 'USER_MINI',
|
||||
USER_H5 = 'USER_H5',
|
||||
PARTNER_MINI = 'PARTNER_MINI',
|
||||
PARTNER_H5 = 'PARTNER_H5',
|
||||
HQ_MINI = 'HQ_MINI',
|
||||
HQ_WEB = 'HQ_WEB',
|
||||
SHOP_H5 = 'SHOP_H5',
|
||||
}
|
||||
|
||||
export enum ActorType {
|
||||
USER = 'USER',
|
||||
STORE = 'STORE',
|
||||
PARTNER = 'PARTNER',
|
||||
HQ = 'HQ',
|
||||
}
|
||||
|
||||
/** 用户注册/首次归因来源(对应 user_user.source_type) */
|
||||
export enum UserSourceType {
|
||||
ORGANIC = 'ORGANIC',
|
||||
PROMO_CODE = 'PROMO_CODE',
|
||||
SHARE_LINK = 'SHARE_LINK',
|
||||
FRIEND_REFERRAL = 'FRIEND_REFERRAL',
|
||||
OFFLINE_EVENT = 'OFFLINE_EVENT',
|
||||
PARTNER_PROXY = 'PARTNER_PROXY',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export const USER_SOURCE_TYPE_LABELS: Record<UserSourceType, string> = {
|
||||
[UserSourceType.ORGANIC]: '自然流量',
|
||||
[UserSourceType.PROMO_CODE]: '推广码',
|
||||
[UserSourceType.SHARE_LINK]: '分享链接',
|
||||
[UserSourceType.FRIEND_REFERRAL]: '好友推荐',
|
||||
[UserSourceType.OFFLINE_EVENT]: '线下活动',
|
||||
[UserSourceType.PARTNER_PROXY]: '代下单',
|
||||
[UserSourceType.OTHER]: '其他',
|
||||
};
|
||||
|
||||
export enum SmsScene {
|
||||
USER_LOGIN = 'USER_LOGIN',
|
||||
STORE_LOGIN = 'STORE_LOGIN',
|
||||
STORE_ACCOUNT_OPEN = 'STORE_ACCOUNT_OPEN',
|
||||
PARTNER_LOGIN = 'PARTNER_LOGIN',
|
||||
HQ_LOGIN = 'HQ_LOGIN',
|
||||
BIND_PHONE = 'BIND_PHONE',
|
||||
PARTNER_STAFF_ADD = 'PARTNER_STAFF_ADD',
|
||||
/** 门店手机号核销:查询用户权益前验证码(发至用户手机) */
|
||||
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
|
||||
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
|
||||
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
|
||||
/** 合伙人代下单:客户手机号归属验证码(发至客户手机) */
|
||||
PARTNER_PROXY_CUSTOMER = 'PARTNER_PROXY_CUSTOMER',
|
||||
/** 合伙人代下单:线下确认验证码(发至合伙人手机) */
|
||||
PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER',
|
||||
/** 合伙人录店:门店登录手机号验证码(发至门店负责人手机) */
|
||||
PARTNER_STORE_OPEN = 'PARTNER_STORE_OPEN',
|
||||
}
|
||||
|
||||
export enum OrderType {
|
||||
NORMAL = 'NORMAL',
|
||||
RESHIPMENT = 'RESHIPMENT',
|
||||
PROXY = 'PROXY',
|
||||
}
|
||||
|
||||
export const ORDER_TYPE_LABELS: Record<OrderType, string> = {
|
||||
[OrderType.NORMAL]: '普通订单',
|
||||
[OrderType.RESHIPMENT]: '补发订单',
|
||||
[OrderType.PROXY]: '线下代下单',
|
||||
};
|
||||
|
||||
export enum OrderStatus {
|
||||
PENDING_PAY = 'PENDING_PAY',
|
||||
PENDING_SHIP = 'PENDING_SHIP',
|
||||
OUT_WAREHOUSE = 'OUT_WAREHOUSE',
|
||||
SHIPPING = 'SHIPPING',
|
||||
PENDING_RECEIVE = 'PENDING_RECEIVE',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
REFUNDING = 'REFUNDING',
|
||||
REFUNDED = 'REFUNDED',
|
||||
}
|
||||
|
||||
export enum OrderTab {
|
||||
ALL = 'all',
|
||||
PENDING_PAY = 'pending_pay',
|
||||
/** V3.0 PRD:已付款(含待发货/配送中,内部多状态聚合) */
|
||||
PAID = 'paid',
|
||||
COMPLETED = 'completed',
|
||||
/** @deprecated 兼容旧链接,映射同 PAID */
|
||||
PENDING_SHIP = 'pending_ship',
|
||||
/** @deprecated 兼容旧链接,映射同 PAID */
|
||||
PENDING_RECEIVE = 'pending_receive',
|
||||
}
|
||||
|
||||
export enum DeliveryType {
|
||||
LOCAL = 'LOCAL',
|
||||
CROSS_CITY = 'CROSS_CITY',
|
||||
ON_SITE_PICKUP = 'ON_SITE_PICKUP',
|
||||
}
|
||||
|
||||
export enum AromaType {
|
||||
QINGXIANG = 'QINGXIANG',
|
||||
JIANGXIANG = 'JIANGXIANG',
|
||||
NONGXIANG = 'NONGXIANG',
|
||||
}
|
||||
|
||||
export enum StoreStatus {
|
||||
OPEN = 'OPEN',
|
||||
PAUSED = 'PAUSED',
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
|
||||
export const STORE_STATUS_LABELS: Record<StoreStatus, string> = {
|
||||
[StoreStatus.OPEN]: '营业中',
|
||||
[StoreStatus.PAUSED]: '临时闭店',
|
||||
[StoreStatus.CLOSED]: '永久关闭',
|
||||
};
|
||||
|
||||
/** C 端订单列表展示用(细粒度中文) */
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
export enum StoreAuditStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
export const STORE_AUDIT_STATUS_LABELS: Record<StoreAuditStatus, string> = {
|
||||
[StoreAuditStatus.PENDING]: '待审核',
|
||||
[StoreAuditStatus.APPROVED]: '已通过',
|
||||
[StoreAuditStatus.REJECTED]: '已驳回',
|
||||
};
|
||||
|
||||
export enum PartnerStaffRole {
|
||||
PARTNER = 'PARTNER',
|
||||
INTERNAL = 'INTERNAL',
|
||||
PROMOTER = 'PROMOTER',
|
||||
}
|
||||
|
||||
export enum StoreStaffRole {
|
||||
MANAGER = 'MANAGER',
|
||||
CASHIER = 'CASHIER',
|
||||
}
|
||||
|
||||
export enum CityPartnerScopeType {
|
||||
CITY_WIDE = 'CITY_WIDE',
|
||||
DISTRICT = 'DISTRICT',
|
||||
}
|
||||
|
||||
export enum CityPartnerStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
export enum WarehouseManagerType {
|
||||
HQ = 'HQ',
|
||||
PARTNER = 'PARTNER',
|
||||
}
|
||||
|
||||
export enum WarehouseStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
export const CITY_PARTNER_SCOPE_LABELS: Record<CityPartnerScopeType, string> = {
|
||||
[CityPartnerScopeType.CITY_WIDE]: '全城合伙人',
|
||||
[CityPartnerScopeType.DISTRICT]: '区域合伙人',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_MANAGER_LABELS: Record<WarehouseManagerType, string> = {
|
||||
[WarehouseManagerType.HQ]: '总部直管',
|
||||
[WarehouseManagerType.PARTNER]: '合伙人管仓',
|
||||
};
|
||||
|
||||
export const CITY_PARTNER_STATUS_LABELS: Record<CityPartnerStatus, string> = {
|
||||
[CityPartnerStatus.ACTIVE]: '启用',
|
||||
[CityPartnerStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
|
||||
[WarehouseStatus.ACTIVE]: '启用',
|
||||
[WarehouseStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export enum FulfillmentProviderType {
|
||||
API = 'API',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export enum FulfillmentProviderStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
}
|
||||
|
||||
/** 物流承运商结算方式:前期充值,后期挂账月结 */
|
||||
export enum LogisticsSettlementMethod {
|
||||
PREPAID = 'PREPAID',
|
||||
MONTHLY_CREDIT = 'MONTHLY_CREDIT',
|
||||
}
|
||||
|
||||
export const LOGISTICS_SETTLEMENT_METHOD_LABELS: Record<LogisticsSettlementMethod, string> = {
|
||||
[LogisticsSettlementMethod.PREPAID]: '充值扣款',
|
||||
[LogisticsSettlementMethod.MONTHLY_CREDIT]: '挂账月结',
|
||||
};
|
||||
|
||||
export enum WarehouseFulfillmentMode {
|
||||
API_AUTO = 'API_AUTO',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export const FULFILLMENT_PROVIDER_TYPE_LABELS: Record<FulfillmentProviderType, string> = {
|
||||
[FulfillmentProviderType.API]: 'API 对接',
|
||||
[FulfillmentProviderType.MANUAL]: '自管',
|
||||
};
|
||||
|
||||
export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record<FulfillmentProviderStatus, string> = {
|
||||
[FulfillmentProviderStatus.ACTIVE]: '启用',
|
||||
[FulfillmentProviderStatus.DISABLED]: '停用',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record<WarehouseFulfillmentMode, string> = {
|
||||
[WarehouseFulfillmentMode.API_AUTO]: '自动发货',
|
||||
[WarehouseFulfillmentMode.MANUAL]: '关闭(手工填单)',
|
||||
};
|
||||
|
||||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
}
|
||||
|
||||
export enum BenefitCouponStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
USED_UP = 'USED_UP',
|
||||
VOID = 'VOID',
|
||||
}
|
||||
|
||||
export const CLIENT_APP_ACTOR_MAP: Record<ClientApp, ActorType> = {
|
||||
[ClientApp.USER_MINI]: ActorType.USER,
|
||||
[ClientApp.USER_H5]: ActorType.USER,
|
||||
[ClientApp.PARTNER_MINI]: ActorType.PARTNER,
|
||||
[ClientApp.PARTNER_H5]: ActorType.PARTNER,
|
||||
[ClientApp.HQ_MINI]: ActorType.HQ,
|
||||
[ClientApp.HQ_WEB]: ActorType.HQ,
|
||||
[ClientApp.SHOP_H5]: ActorType.STORE,
|
||||
};
|
||||
|
||||
export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE';
|
||||
export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE';
|
||||
/** V3.0 PRD:核销码 3 分钟有效 */
|
||||
export const REDEEM_TOKEN_TTL_SECONDS = 180;
|
||||
/** 短信/核销验证码 3 分钟有效(NFR-003) */
|
||||
export const SMS_CODE_TTL_SECONDS = 180;
|
||||
/** 核销成功后供用户端轮询结果,略长于 token TTL */
|
||||
export const REDEEM_RESULT_TTL_SECONDS = 360;
|
||||
/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */
|
||||
export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600;
|
||||
/** 弱网兜底:preview 快照 TTL(token 失效后仍可提交待处理单) */
|
||||
export const REDEEM_PENDING_SNAPSHOT_TTL_SECONDS = 600;
|
||||
/** 弱网核销:网络类失败阈值 */
|
||||
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
LogisticsSettlementMethod,
|
||||
} from './enums';
|
||||
import type { WarehouseFulfillmentMode } from './enums';
|
||||
import type { LogisticsPricingRuleDto } from './settlement';
|
||||
import { DEFAULT_XFX_LOGISTICS_PRICING } from './settlement';
|
||||
|
||||
/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson) */
|
||||
export interface XiaofeixiaProviderConfig {
|
||||
apiUrl: string;
|
||||
mchId: string;
|
||||
apiKey: string;
|
||||
signType?: 'MD5' | 'HMAC-SHA256';
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
/** 回显用(不含明文 apiKey) */
|
||||
export interface XiaofeixiaProviderConfigPublic {
|
||||
apiUrl: string;
|
||||
mchId: string;
|
||||
signType: 'MD5' | 'HMAC-SHA256';
|
||||
appId?: string;
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
export interface FulfillmentProviderBankDto {
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankBranch?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
}
|
||||
|
||||
export interface FulfillmentProviderDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status: FulfillmentProviderStatus;
|
||||
capabilities?: {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
} | null;
|
||||
hasConfig: boolean;
|
||||
/** 小飞侠等承运商结构化配置(脱敏) */
|
||||
xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null;
|
||||
/** 结算银行账户 */
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankBranch?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod: LogisticsSettlementMethod;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
prepaidBalance: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateFulfillmentProviderInput {
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
/** 结构化小飞侠配置;有则覆盖写入 configJson */
|
||||
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankBranch?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod?: LogisticsSettlementMethod;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
}
|
||||
|
||||
export interface UpdateFulfillmentProviderInput {
|
||||
name?: string;
|
||||
type?: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankBranch?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod?: LogisticsSettlementMethod;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
}
|
||||
|
||||
export { DEFAULT_XFX_LOGISTICS_PRICING };
|
||||
|
||||
export interface ManualShipOrderInput {
|
||||
logisticsCompany: string;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseFulfillmentConfig {
|
||||
fulfillmentMode: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
}
|
||||
|
||||
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
|
||||
|
||||
export function isXfxProviderCode(code: string): boolean {
|
||||
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/** HQ 权限目录(权限分配页勾选源) */
|
||||
export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'dashboard', label: '概览', group: '业务' },
|
||||
{ key: 'users', label: '用户管理', group: '业务' },
|
||||
{ key: 'wechat_bindings', label: '微信绑定', group: '业务' },
|
||||
{ key: 'products', label: '商品管理', group: '业务' },
|
||||
{ key: 'orders', label: '订单管理', group: '业务' },
|
||||
{ key: 'promo_codes', label: '推广码', group: '业务' },
|
||||
{ key: 'stores', label: '门店管理', group: '业务' },
|
||||
{ key: 'partners', label: '开城管理', group: '业务' },
|
||||
{ key: 'finance', label: '财务', group: '业务' },
|
||||
{ key: 'benefit', label: '好客权益', group: '业务' },
|
||||
{ key: 'deliveries', label: '配送单', group: '业务' },
|
||||
{ key: 'tickets', label: '工单中心', group: '业务' },
|
||||
{ key: 'tech_support', label: '技术支持', group: '业务' },
|
||||
{ key: 'invoices', label: '发票管理', group: '业务' },
|
||||
{ key: 'wecom_bots', label: '企微机器人', group: '业务' },
|
||||
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||
{ key: 'dev_plan', label: '开发计划', group: '业务' },
|
||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||
{ key: 'logs', label: '日志', group: '业务' },
|
||||
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
|
||||
{ key: 'orders_delete', label: '删除订单', group: '危险操作' },
|
||||
{ key: 'cities_delete', label: '删除城市', group: '危险操作' },
|
||||
{ key: 'hq_permissions', label: '权限分配', group: '管理' },
|
||||
{ key: 'hq_accounts', label: 'HQ 账户', group: '管理' },
|
||||
{ key: 'system_settings_feature', label: '功能开关', group: '系统设置' },
|
||||
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
|
||||
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
|
||||
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
|
||||
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
|
||||
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
||||
{ key: 'system_settings_finance', label: '财务结算', group: '系统设置' },
|
||||
] as const;
|
||||
|
||||
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||
|
||||
/** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */
|
||||
export const HQ_DANGEROUS_PERMISSION_KEYS = [
|
||||
'users_delete',
|
||||
'orders_delete',
|
||||
'cities_delete',
|
||||
] as const satisfies readonly HqPermissionKey[];
|
||||
|
||||
export function isHqDangerousPermission(key: string): boolean {
|
||||
return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function hqBasePermissionKeys(): HqPermissionKey[] {
|
||||
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k));
|
||||
}
|
||||
|
||||
/** 系统配置 registry group → 权限 key */
|
||||
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
||||
feature: 'system_settings_feature',
|
||||
sms: 'system_settings_sms',
|
||||
wechat: 'system_settings_wechat',
|
||||
wechat_mini: 'system_settings_wechat_mini',
|
||||
oss: 'system_settings_oss',
|
||||
app: 'system_settings_app',
|
||||
deploy: 'system_settings_deploy',
|
||||
winery_bank: 'system_settings_winery_bank',
|
||||
finance: 'system_settings_finance',
|
||||
};
|
||||
|
||||
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
|
||||
SYSTEM_CONFIG_GROUP_PERMISSION,
|
||||
) as HqPermissionKey[];
|
||||
|
||||
/** 旧版单一 system_settings 权限:视为拥有全部系统设置分组(兼容存量角色配置) */
|
||||
export const LEGACY_SYSTEM_SETTINGS_KEY = 'system_settings';
|
||||
|
||||
export function expandHqPermissionKeys(keys: string[]): HqPermissionKey[] {
|
||||
const set = new Set<string>(keys);
|
||||
if (set.has(LEGACY_SYSTEM_SETTINGS_KEY)) {
|
||||
for (const k of SYSTEM_SETTINGS_PERMISSION_KEYS) set.add(k);
|
||||
set.delete(LEGACY_SYSTEM_SETTINGS_KEY);
|
||||
}
|
||||
return [...set].filter((k): k is HqPermissionKey =>
|
||||
HQ_PERMISSION_CATALOG.some((p) => p.key === k),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnySystemSettingsPermission(keys: string[]): boolean {
|
||||
const expanded = expandHqPermissionKeys(keys);
|
||||
return SYSTEM_SETTINGS_PERMISSION_KEYS.some((k) => expanded.includes(k));
|
||||
}
|
||||
|
||||
export const HQ_ADMIN_ROLES = [
|
||||
{ value: 'SUPER_ADMIN', label: '超级管理员' },
|
||||
{ value: 'OPS', label: '运营' },
|
||||
{ value: 'FINANCE', label: '财务' },
|
||||
{ value: 'CUSTOMER_SERVICE', label: '客服' },
|
||||
] as const;
|
||||
|
||||
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
SUPER_ADMIN: hqBasePermissionKeys(),
|
||||
OPS: [
|
||||
'dashboard',
|
||||
'users',
|
||||
'wechat_bindings',
|
||||
'products',
|
||||
'orders',
|
||||
'promo_codes',
|
||||
'stores',
|
||||
'partners',
|
||||
'benefit',
|
||||
'deliveries',
|
||||
'tickets',
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'wecom_bots',
|
||||
'llm_configs',
|
||||
'knowledge_bases',
|
||||
'dev_plan',
|
||||
'resources',
|
||||
'logs',
|
||||
'system_settings_wechat_mini',
|
||||
],
|
||||
FINANCE: [
|
||||
'dashboard',
|
||||
'orders',
|
||||
'stores',
|
||||
'partners',
|
||||
'finance',
|
||||
'benefit',
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'logs',
|
||||
'system_settings_winery_bank',
|
||||
'system_settings_finance',
|
||||
],
|
||||
CUSTOMER_SERVICE: [
|
||||
'dashboard',
|
||||
'users',
|
||||
'orders',
|
||||
'tickets',
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'logs',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export * from './enums';
|
||||
export * from './api';
|
||||
export * from './config';
|
||||
export * from './wechat';
|
||||
export * from './catalog';
|
||||
export * from './trade';
|
||||
export * from './benefit';
|
||||
export * from './redeem';
|
||||
export * from './settlement';
|
||||
export * from './ops';
|
||||
export * from './ticket';
|
||||
export * from './support-ticket';
|
||||
export * from './invoice';
|
||||
export * from './user-log';
|
||||
|
||||
export * from './store-log';
|
||||
export * from './store-package';
|
||||
export * from './partner-log';
|
||||
export * from './promo';
|
||||
export * from './hq-permissions';
|
||||
export * from './partner';
|
||||
export * from './shop';
|
||||
export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
export * from './fulfillment-provider';
|
||||
export * from './system-config';
|
||||
export * from './wecom-bot';
|
||||
export * from './wecom-message-push';
|
||||
export * from './llm-config';
|
||||
export * from './knowledge-base';
|
||||
export * from './legal';
|
||||
export * from './dev-plan';
|
||||
@@ -0,0 +1,54 @@
|
||||
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
|
||||
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
|
||||
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
|
||||
|
||||
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
|
||||
PERSONAL: '个人',
|
||||
ENTERPRISE: '企业',
|
||||
};
|
||||
|
||||
export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
|
||||
NORMAL: '增值税普通发票',
|
||||
SPECIAL: '增值税专用发票',
|
||||
};
|
||||
|
||||
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
||||
PENDING: '待开票',
|
||||
ISSUED: '已开票',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export interface CreateInvoiceRequest {
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceDto {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
orderId: string;
|
||||
userId: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
taxNo?: string | null;
|
||||
addressPhone?: string | null;
|
||||
bankAccount?: string | null;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: InvoiceStatus;
|
||||
fileUrl?: string | null;
|
||||
remark?: string | null;
|
||||
issuedAt?: string | null;
|
||||
createdAt: string;
|
||||
orderNo?: string;
|
||||
/** 待开票超过 2 个工作日(总部列表标红用) */
|
||||
overdue?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export type KnowledgeBaseDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
documentCount: number;
|
||||
createdByHqAccountId: string;
|
||||
createdByName: string | null;
|
||||
isOwner: boolean;
|
||||
canEditFull: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeBaseRequest = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeBaseRequest = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type KnowledgeDocumentDto = {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
title: string;
|
||||
fileName: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
/** 是否已抽取可检索正文 */
|
||||
hasContent: boolean;
|
||||
status: 'READY' | 'EMPTY' | 'FAILED';
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeDocumentRequest = {
|
||||
title: string;
|
||||
/** 直贴正文(优先) */
|
||||
contentText?: string | null;
|
||||
fileName?: string | null;
|
||||
fileUrl?: string | null;
|
||||
mimeType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
};
|
||||
|
||||
export type KnowledgeBaseOptionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
documentCount: number;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { CUSTOMER_SERVICE_PHONE } from './config';
|
||||
|
||||
export type LegalSection = {
|
||||
heading: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type LegalDocument = {
|
||||
id: 'user-agreement' | 'privacy-policy';
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
intro: string;
|
||||
sections: LegalSection[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 杜康好客 · 用户协议 / 隐私政策(多端共用)。
|
||||
* 结构对齐《个人信息保护法》及 App 合规常见披露要点。
|
||||
* 参考:小米开发者「隐私政策不合规修改指引」、FreeBuf APP 隐私合规规范(禁止默认勾选等)。
|
||||
* 正式上线前请法务审定主体名称与联系方式。
|
||||
*/
|
||||
export const USER_AGREEMENT: LegalDocument = {
|
||||
id: 'user-agreement',
|
||||
title: '用户服务协议',
|
||||
updatedAt: '2026-07-21',
|
||||
intro:
|
||||
'欢迎使用「杜康好客」平台(含小程序、移动网页及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您须主动勾选同意后继续使用,不得默认强制同意;勾选即视为已阅读并接受本协议全部内容。',
|
||||
sections: [
|
||||
{
|
||||
heading: '一、服务说明',
|
||||
paragraphs: [
|
||||
'杜康好客是杜康酒业 O2O 消费服务平台:用户可在线浏览与购买酒类商品,获得对应「好客权益」并在合作门店核销;门店与城市合伙人可使用管理端完成核销、门店与订单相关履约操作。',
|
||||
'我们有权根据业务需要调整服务内容、功能或规则,并在合理范围内通过平台公告、页面提示等方式通知您。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '二、账号注册与安全',
|
||||
paragraphs: [
|
||||
'您可通过手机号快捷登录、手机号验证码等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
|
||||
'您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。',
|
||||
'您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '三、订单、支付与权益',
|
||||
paragraphs: [
|
||||
'下单、支付、配送及「好客权益」发放/核销规则以平台页面展示及相关业务规则为准。支付成功后产生的权益额度按产品说明计入您的账户。',
|
||||
'核销须在合作门店按规则完成;超出可用余额、已失效或违反使用规则的核销请求将被拒绝。',
|
||||
'如发生退款、补发等售后事宜,将按平台售后规则及客服处理结果执行。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '四、用户行为规范',
|
||||
paragraphs: [
|
||||
'您承诺依法使用本平台,不得利用技术手段恶意刷单、伪造核销、攻击系统或传播违法信息。',
|
||||
'您理解并同意:酒类商品及相关服务可能仅面向符合法律法规要求的主体;若您不具备相应资格,请勿使用购买或核销功能。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '五、知识产权',
|
||||
paragraphs: [
|
||||
'本平台中的文字、图片、标识、界面设计、软件等知识产权归平台运营方或合法权利人所有。未经许可,您不得擅自复制、传播或用于商业目的。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '六、免责与责任限制',
|
||||
paragraphs: [
|
||||
'因不可抗力、网络故障、第三方支付或配送服务异常等非我们可控因素导致的服务中断或延误,我们将在合理范围内协助处理,但不承担因此产生的间接损失。',
|
||||
'在法律允许的范围内,我们对因使用或无法使用本服务所产生的损害责任以您就相关服务实际支付的费用为限(免费服务除外另有约定)。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '七、协议变更与终止',
|
||||
paragraphs: [
|
||||
'我们可能适时修订本协议,修订后的协议将在平台公布并自公布之日起生效(法律法规另有要求的除外)。若您继续使用服务,视为接受修订后的协议。',
|
||||
'您可停止使用并申请注销账号;我们亦可在您严重违反本协议时中止或终止向您提供服务。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '八、联系我们',
|
||||
paragraphs: [
|
||||
`如对本协议有任何疑问,请通过客服热线 ${CUSTOMER_SERVICE_PHONE}(工作时间 9:00–21:00)与我们联系。`,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const PRIVACY_POLICY: LegalDocument = {
|
||||
id: 'privacy-policy',
|
||||
title: '隐私政策',
|
||||
updatedAt: '2026-07-21',
|
||||
intro:
|
||||
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您须主动勾选同意后,我们才会按本政策处理相关个人信息;我们不会默认勾选或强制同意。',
|
||||
sections: [
|
||||
{
|
||||
heading: '一、我们如何收集与使用个人信息',
|
||||
paragraphs: [
|
||||
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能在取得您授权同意后收集并使用下列信息:',
|
||||
'1)账号信息:手机号码、验证码、开放平台账号标识(OpenID/UnionID,若您授权)、昵称与头像(若您主动授权);用于注册登录、账号绑定与安全保障。',
|
||||
'2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。',
|
||||
'3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。',
|
||||
'4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。',
|
||||
'5)您主动提供的其他信息:如客服沟通内容、反馈建议等。',
|
||||
'收集目的与方式:仅在实现上述业务功能所必需的范围内,通过您主动填写、授权组件或系统必要日志收集;未征得同意前,我们不会超范围收集与业务无关的个人信息。',
|
||||
'我们不会以默认勾选等方式强制您同意本政策。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '二、我们如何共享、转让、公开披露',
|
||||
paragraphs: [
|
||||
'我们不会向第三方出售您的个人信息。仅在以下情形共享:',
|
||||
'1)获得您的明确同意;',
|
||||
'2)为实现支付、短信、配送、地图/定位、账号登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
|
||||
'3)根据法律法规、行政或司法机关要求;',
|
||||
'4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '三、我们如何存储与保护',
|
||||
paragraphs: [
|
||||
'您的个人信息存储于中华人民共和国境内。我们仅在实现本政策所述目的所必需的期限内保存;超出期限后将删除或匿名化处理(法律法规另有规定的除外)。',
|
||||
'我们采取合理的技术与管理措施保护信息安全,防止未经授权的访问、披露、篡改或丢失。如发生安全事件,我们将按法规要求及时告知并采取补救措施。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '四、第三方 SDK / 服务说明',
|
||||
paragraphs: [
|
||||
'为实现登录、支付、分享、定位等功能,本平台可能接入微信开放平台、支付、短信、地图定位等第三方服务。该类服务会按其自身隐私政策处理相关信息。我们仅在实现功能所必需的范围内启用,并尽量采用最小化授权。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '五、您的权利',
|
||||
paragraphs: [
|
||||
'您有权查阅、复制、更正、补充、删除您的个人信息,有权撤回同意、注销账号,以及在符合条件时限制或拒绝我们处理您的个人信息。',
|
||||
'您可通过「我的」相关功能或联系客服行使上述权利。为保障安全,我们可能需要先验证您的身份。我们将在合理期限内答复。',
|
||||
'您撤回同意后,我们将停止基于相应同意的处理活动,但不影响此前基于同意已进行的处理。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '六、未成年人保护',
|
||||
paragraphs: [
|
||||
'本平台主要面向成年人。若您为未成年人,请在监护人陪同下阅读本政策,并在监护人同意后使用服务。我们不会主动收集未成年人的个人信息;如发现误收集,将尽快删除。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '七、本政策的更新',
|
||||
paragraphs: [
|
||||
'我们可能适时更新本政策,并通过平台公告、弹窗或页面提示等方式告知。重大变更时,我们会提供更显著的通知,并在必要时重新征得您的同意。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '八、联系我们',
|
||||
paragraphs: [
|
||||
`个人信息保护相关事宜,请拨打客服热线 ${CUSTOMER_SERVICE_PHONE}(工作时间 9:00–21:00)。我们将尽快处理您的请求。`,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function getLegalDocument(id: LegalDocument['id']): LegalDocument {
|
||||
return id === 'privacy-policy' ? PRIVACY_POLICY : USER_AGREEMENT;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/** 语言模型 API 提供商(OpenAI 兼容 Chat Completions) */
|
||||
export const LLM_PROVIDERS = [
|
||||
'DEEPSEEK',
|
||||
'OPENAI',
|
||||
'QWEN',
|
||||
'CUSTOM',
|
||||
] as const;
|
||||
|
||||
export type LlmProvider = (typeof LLM_PROVIDERS)[number];
|
||||
|
||||
export const LLM_PROVIDER_LABELS: Record<LlmProvider, string> = {
|
||||
DEEPSEEK: 'DeepSeek',
|
||||
OPENAI: 'OpenAI',
|
||||
QWEN: '通义千问',
|
||||
CUSTOM: '自定义(OpenAI 兼容)',
|
||||
};
|
||||
|
||||
export const LLM_PROVIDER_PRESETS: Record<
|
||||
LlmProvider,
|
||||
{ defaultBaseUrl: string; defaultModel: string }
|
||||
> = {
|
||||
DEEPSEEK: {
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
defaultModel: 'deepseek-chat',
|
||||
},
|
||||
OPENAI: {
|
||||
defaultBaseUrl: 'https://api.openai.com',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
},
|
||||
QWEN: {
|
||||
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode',
|
||||
defaultModel: 'qwen-plus',
|
||||
},
|
||||
CUSTOM: {
|
||||
defaultBaseUrl: '',
|
||||
defaultModel: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type LlmApiConfigDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
/** 列表/详情不回明文 */
|
||||
apiKeyConfigured: boolean;
|
||||
temperature: number | null;
|
||||
maxTokens: number | null;
|
||||
systemPrompt: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: string;
|
||||
createdByName: string | null;
|
||||
/** 当前登录账号是否为创建人 */
|
||||
isOwner: boolean;
|
||||
/** 非超管仅可改 enabled;超管可改全部 */
|
||||
canEditFull: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateLlmApiConfigRequest = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateLlmApiConfigRequest = {
|
||||
/** 非超管仅允许传 enabled */
|
||||
name?: string;
|
||||
provider?: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type LlmApiConfigOptionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
modelName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface AdminListQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminPageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
format?: 'csv';
|
||||
}
|
||||
|
||||
export interface SystemVersionDto {
|
||||
id: string;
|
||||
gitTag: string | null;
|
||||
commitId: string;
|
||||
commitMessage: string;
|
||||
branch: string | null;
|
||||
deployedBy: string | null;
|
||||
deployedAt: string;
|
||||
}
|
||||
|
||||
export interface DeployTriggerResult {
|
||||
accepted: boolean;
|
||||
started?: boolean;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export type PartnerLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'page_view'
|
||||
| 'account_ops'
|
||||
| 'store_ops'
|
||||
| 'shipping'
|
||||
| 'settlement'
|
||||
| 'warehouse_ops';
|
||||
|
||||
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
|
||||
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
|
||||
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
||||
page_view: [
|
||||
'partner_home_view',
|
||||
'partner_store_list_view',
|
||||
'partner_store_detail_view',
|
||||
'partner_store_create_view',
|
||||
'partner_order_list_view',
|
||||
'partner_order_detail_view',
|
||||
'partner_proxy_order_view',
|
||||
'partner_bills_view',
|
||||
'partner_bill_detail_view',
|
||||
'partner_staff_list_view',
|
||||
'partner_leaderboard_view',
|
||||
'partner_weekly_report_view',
|
||||
],
|
||||
account_ops: [
|
||||
'partner_staff_create',
|
||||
'partner_staff_sms_send',
|
||||
'partner_staff_sms_verify_fail',
|
||||
'partner_staff_update',
|
||||
'partner_staff_delete',
|
||||
'partner_staff_permission_update',
|
||||
],
|
||||
store_ops: [
|
||||
'partner_store_create',
|
||||
'partner_store_status_change',
|
||||
'partner_store_audit_approved',
|
||||
'partner_store_audit_rejected',
|
||||
],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance', 'partner_proxy_order_create'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view', 'partner_bill_confirm'],
|
||||
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
||||
};
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'page_view', label: '页面浏览' },
|
||||
{ value: 'account_ops', label: '子账号管理' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'shipping', label: '发货/配送' },
|
||||
{ value: 'settlement', label: '结算' },
|
||||
{ value: 'warehouse_ops', label: '仓库操作' },
|
||||
];
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '微信授权',
|
||||
page_view: '页面浏览',
|
||||
account_ops: '子账号管理',
|
||||
store_ops: '门店操作',
|
||||
shipping: '发货/配送',
|
||||
settlement: '结算',
|
||||
warehouse_ops: '仓库操作',
|
||||
};
|
||||
|
||||
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
|
||||
for (const [category, events] of Object.entries(PARTNER_LOG_EVENT_CATEGORIES) as Array<
|
||||
[PartnerLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function eventNamesForPartnerLogCategory(category: string): string[] | undefined {
|
||||
if (!category) return undefined;
|
||||
return [...(PARTNER_LOG_EVENT_CATEGORIES[category as PartnerLogCategory] ?? [])];
|
||||
}
|
||||
|
||||
export interface PartnerLogRowDto {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
isSubAccount?: boolean;
|
||||
staffRole?: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type PartnerAnalyticsExtra = {
|
||||
sessionId?: string;
|
||||
partnerAccountId?: string;
|
||||
storeId?: string;
|
||||
orderId?: string;
|
||||
billId?: string;
|
||||
cityCode?: string;
|
||||
pagePath?: string;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AccountStatus, PartnerStaffRole } from './enums';
|
||||
|
||||
export interface PartnerMe {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
/** 微信授权后的昵称 */
|
||||
wxNickname?: string | null;
|
||||
/** 微信授权后的头像 URL */
|
||||
wxAvatarUrl?: string | null;
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
primaryAccountId?: string;
|
||||
/** 子账号联系主账号用 */
|
||||
primaryPhone?: string;
|
||||
primaryName?: string;
|
||||
/** 主账号绑定的管仓 ID(有则本城酒单推送至该仓) */
|
||||
managedWarehouseId?: string | null;
|
||||
/** 是否已配置仓库管理(无则不展示/不推送酒订单) */
|
||||
hasWarehouseAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePartnerMeRequest {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface PartnerStaffItem {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
status: AccountStatus;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface CreatePartnerStaffRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
smsCode: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdatePartnerStaffRequest {
|
||||
name?: string;
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
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;
|
||||
/** 该手机号已是主账号时绑定的门店数;>0 时拓店需二次确认 */
|
||||
existingStoreCount?: number;
|
||||
needConfirm?: boolean;
|
||||
}
|
||||
|
||||
/** 合伙人登录前手机号校验 */
|
||||
export interface PartnerPhoneCheckResponse {
|
||||
ok: boolean;
|
||||
maskedPhone: string;
|
||||
name: string;
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export type PromoCodeStatus = 'ACTIVE' | 'DISABLED';
|
||||
|
||||
export type PromoCodeScene =
|
||||
| 'ONLINE_LINK'
|
||||
| 'OFFLINE_PICKUP'
|
||||
| 'PARTNER_CHANNEL'
|
||||
| 'EVENT'
|
||||
| 'OTHER';
|
||||
|
||||
export const PROMO_CODE_SCENE_LABELS: Record<PromoCodeScene, string> = {
|
||||
ONLINE_LINK: '线上链接',
|
||||
OFFLINE_PICKUP: '现场提货',
|
||||
PARTNER_CHANNEL: '合伙人渠道',
|
||||
EVENT: '活动品鉴',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export const PROMO_CODE_STATUS_LABELS: Record<PromoCodeStatus, string> = {
|
||||
ACTIVE: '启用',
|
||||
DISABLED: '已关闭',
|
||||
};
|
||||
|
||||
export type PromoCodeOwnerUser = {
|
||||
id: string;
|
||||
userNo?: string | null;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
|
||||
export type PromoCodeItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
scene: PromoCodeScene;
|
||||
qrcodeId: string;
|
||||
status: PromoCodeStatus;
|
||||
scanCount: number;
|
||||
orderCount: number;
|
||||
landingUrl: string;
|
||||
qrcodeUrl?: string | null;
|
||||
remark?: string | null;
|
||||
ownerUser?: PromoCodeOwnerUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PromoCodeStats = {
|
||||
/** 扫码进入次数 */
|
||||
scanCount: number;
|
||||
orderCount: number;
|
||||
conversionRate: number;
|
||||
/** 归因用户数(user_promo_attribution) */
|
||||
attributionCount?: number;
|
||||
/** 扫码注册用户数:用户来源标记为本推广码 */
|
||||
sourceMarkedCount?: number;
|
||||
/** @deprecated 同 sourceMarkedCount,兼容旧字段名 */
|
||||
registerCount?: number;
|
||||
};
|
||||
|
||||
export type PromoCodeAttributedUser = {
|
||||
id: string;
|
||||
userNo: string;
|
||||
nickname: string | null;
|
||||
phone: string | null;
|
||||
phoneVerifiedAt: string | null;
|
||||
sourceType: string;
|
||||
sourceRefId: string | null;
|
||||
firstTouchAt: string | null;
|
||||
orderCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PromoTouchResult = {
|
||||
promoCode: string;
|
||||
promoCodeId: string;
|
||||
channelName: string;
|
||||
/** 是否首次写入 user_promo_attribution */
|
||||
attributed: boolean;
|
||||
/** 是否已将用户来源标记为 PROMO_CODE */
|
||||
sourceApplied: boolean;
|
||||
};
|
||||
|
||||
export function promoConversion(scan: number, orders: number): string {
|
||||
if (scan <= 0) return '0%';
|
||||
return `${Math.round((orders / scan) * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
|
||||
const base = baseUrl.replace(/\/$/, '');
|
||||
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export interface RedeemTokenRequest {
|
||||
amount: number;
|
||||
couponId?: string;
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
export interface RedeemTokenResult {
|
||||
token: string;
|
||||
expireAt: string;
|
||||
amount: number;
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
export interface RedeemPreviewDto {
|
||||
token: string;
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
redeemType?: 'DIRECT' | 'COUPON';
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
/** 门店核销方式 */
|
||||
export type RedeemChannel = 'SCAN' | 'PHONE';
|
||||
|
||||
export const REDEEM_CHANNEL_LABELS: Record<RedeemChannel, string> = {
|
||||
SCAN: '扫码核销',
|
||||
PHONE: '手机号核销',
|
||||
};
|
||||
|
||||
export interface RedeemRecordDto {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
/** 核销方式:扫码 SCAN / 手机号 PHONE */
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RedeemChannelStatsBucket {
|
||||
channel: RedeemChannel;
|
||||
count: number;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
}
|
||||
|
||||
export interface RedeemStatsDto {
|
||||
range: 'today' | '7d' | '30d';
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
totalSettleAmount: number;
|
||||
byChannel: RedeemChannelStatsBucket[];
|
||||
}
|
||||
|
||||
export interface RedeemPhoneBalanceDto {
|
||||
sessionId: string;
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user: {
|
||||
id: string;
|
||||
userNo?: string | null;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RedeemPhonePrepareDto {
|
||||
sessionId: string;
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
}
|
||||
|
||||
export interface RedeemPhoneDirectPrepareRequest {
|
||||
phone: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface RedeemPhoneDirectPrepareResult extends RedeemPhonePrepareDto {
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user: {
|
||||
id: string;
|
||||
userNo?: string | null;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||
|
||||
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: '待处理',
|
||||
COMPLETED: '已补核销',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||
|
||||
export type RedeemFailureReportResult = {
|
||||
failCount: number;
|
||||
thresholdReached: boolean;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export type RedeemPendingSubmitResult = {
|
||||
pendingId: string;
|
||||
pendingNo: string;
|
||||
redeemToken: string;
|
||||
failCount: number;
|
||||
};
|
||||
|
||||
export type RedeemPendingItem = {
|
||||
id: string;
|
||||
pendingNo: string;
|
||||
redeemToken: string;
|
||||
amount: number;
|
||||
redeemType: string;
|
||||
failCount: number;
|
||||
status: RedeemPendingStatus;
|
||||
remark?: string | null;
|
||||
rejectReason?: string | null;
|
||||
processedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
store?: { id: string; name: string; cityName?: string } | null;
|
||||
user?: { id: string; userNo?: string | null; phone?: string | null; nickname?: string | null } | null;
|
||||
photoUrl?: string | null;
|
||||
redeemRecordId?: string | null;
|
||||
redeemRecord?: { id: string; redeemNo: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
export type FinancePayStatus = 'UNPAID' | 'PAID';
|
||||
|
||||
/** 门店未出账提现单日上限默认值(FIN-002,可配置) */
|
||||
export const DEFAULT_STORE_WITHDRAW_DAILY_LIMIT = 5000;
|
||||
|
||||
export type StoreWithdrawStatus = 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
|
||||
|
||||
export const STORE_WITHDRAW_STATUS_LABELS: Record<StoreWithdrawStatus, string> = {
|
||||
PENDING_REVIEW: '待审核',
|
||||
REJECTED: '已驳回',
|
||||
PAID: '已结算',
|
||||
};
|
||||
|
||||
/** 总部「门店账单」统一列表:T+1 出账 / 手动提现 */
|
||||
export type StoreSettlementKind = 'T1_BILL' | 'WITHDRAW';
|
||||
|
||||
export const STORE_SETTLEMENT_KIND_LABELS: Record<StoreSettlementKind, string> = {
|
||||
T1_BILL: 'T+1出账',
|
||||
WITHDRAW: '手动提现',
|
||||
};
|
||||
|
||||
export type StoreSettlementStatus = FinancePayStatus | StoreWithdrawStatus;
|
||||
|
||||
export const STORE_SETTLEMENT_STATUS_LABELS: Record<StoreSettlementStatus, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
PENDING_REVIEW: '待审核',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export interface StoreSettlementRowDto {
|
||||
kind: StoreSettlementKind;
|
||||
id: string;
|
||||
billNo: string;
|
||||
storeId: string;
|
||||
amount: number;
|
||||
status: StoreSettlementStatus;
|
||||
date: string;
|
||||
overdue?: boolean;
|
||||
redeemCount?: number | null;
|
||||
redeemAmount?: number | null;
|
||||
settlementRate?: number | null;
|
||||
payoutCount?: number | null;
|
||||
store?: {
|
||||
id: string;
|
||||
name: string;
|
||||
cityName: string;
|
||||
phone?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoreWithdrawBankAccountDto {
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
}
|
||||
|
||||
export interface StoreWithdrawSummaryDto {
|
||||
availableAmount: number;
|
||||
pendingReviewAmount: number;
|
||||
todayAppliedAmount: number;
|
||||
dailyLimit: number;
|
||||
remainingDailyLimit: number;
|
||||
isPrimary: boolean;
|
||||
hasBankAccount: boolean;
|
||||
hasPendingRequest: boolean;
|
||||
bankAccount?: StoreWithdrawBankAccountDto | null;
|
||||
}
|
||||
|
||||
export interface StoreWithdrawRequestDto {
|
||||
id: string;
|
||||
withdrawNo: string;
|
||||
storeId: string;
|
||||
amount: number;
|
||||
payoutCount: number;
|
||||
status: StoreWithdrawStatus;
|
||||
rejectReason?: string | null;
|
||||
appliedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
paymentRef?: string | null;
|
||||
}
|
||||
|
||||
export interface StorePayoutDto {
|
||||
id: string;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
status: 'PENDING' | 'PAID';
|
||||
expectedPayAt: string;
|
||||
paidAt?: string | null;
|
||||
storeBillId?: string | null;
|
||||
}
|
||||
|
||||
export type PartnerBillStatus =
|
||||
| 'PENDING_REVIEW'
|
||||
| 'AWAITING_CONFIRM'
|
||||
| 'UNPAID'
|
||||
| 'PAID'
|
||||
| 'REJECTED';
|
||||
|
||||
export const PARTNER_BILL_STATUS_LABELS: Record<PartnerBillStatus, string> = {
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const FINANCE_PAY_STATUS_LABELS: Record<FinancePayStatus, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
export function storeSettlementStatusLabel(
|
||||
kind: StoreSettlementKind,
|
||||
status: string,
|
||||
): string {
|
||||
if (kind === 'WITHDRAW' && status in STORE_WITHDRAW_STATUS_LABELS) {
|
||||
return STORE_WITHDRAW_STATUS_LABELS[status as StoreWithdrawStatus];
|
||||
}
|
||||
if (status in FINANCE_PAY_STATUS_LABELS) {
|
||||
return FINANCE_PAY_STATUS_LABELS[status as FinancePayStatus];
|
||||
}
|
||||
return STORE_SETTLEMENT_STATUS_LABELS[status as StoreSettlementStatus] ?? status;
|
||||
}
|
||||
|
||||
export interface PartnerBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: PartnerBillStatus;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
confirmedAt?: string | null;
|
||||
sentAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
rejectReason?: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerBillDetailDto extends PartnerBillDto {
|
||||
partnerId: string;
|
||||
}
|
||||
|
||||
export interface StoreBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
storeId: string;
|
||||
billDate: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
status: FinancePayStatus;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface WineryBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
orderCount: number;
|
||||
orderAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
status: FinancePayStatus;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface WineryBillItemDto {
|
||||
id: string;
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/** 门店结算默认比例(核销金额 × 比例) */
|
||||
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
|
||||
|
||||
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
|
||||
export const WINERY_SETTLEMENT_RATE = 0.3;
|
||||
|
||||
export type { LogisticsSettlementMethod } from './enums';
|
||||
export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums';
|
||||
import type { LogisticsSettlementMethod } from './enums';
|
||||
|
||||
/** 小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元 */
|
||||
export const DEFAULT_XFX_LOGISTICS_PRICING = {
|
||||
baseBottles: 2,
|
||||
baseFee: 6,
|
||||
extraBottleFee: 2,
|
||||
boxBottles: 6,
|
||||
boxFee: 14,
|
||||
} as const;
|
||||
|
||||
export type LogisticsPricingRuleDto = {
|
||||
baseBottles: number;
|
||||
baseFee: number;
|
||||
extraBottleFee: number;
|
||||
boxBottles?: number;
|
||||
boxFee?: number;
|
||||
};
|
||||
|
||||
export interface LogisticsBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
fulfillmentProviderId: string;
|
||||
providerCode?: string;
|
||||
providerName?: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
orderCount: number;
|
||||
bottleCount: number;
|
||||
logisticsAmount: number;
|
||||
settlementMethod: LogisticsSettlementMethod;
|
||||
status: FinancePayStatus;
|
||||
paidAt?: string | null;
|
||||
pricingSnapshot?: LogisticsPricingRuleDto | null;
|
||||
}
|
||||
|
||||
export interface LogisticsBillItemDto {
|
||||
id: string;
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
quantity: number;
|
||||
logisticsAmount: number;
|
||||
shippedAt: string;
|
||||
}
|
||||
|
||||
export type FinanceBillSummary = {
|
||||
count: number;
|
||||
redeemAmount?: number;
|
||||
payoutAmount?: number;
|
||||
orderAmount?: number;
|
||||
orderCommission?: number;
|
||||
redeemCommission?: number;
|
||||
wineryAmount?: number;
|
||||
logisticsAmount?: number;
|
||||
bottleCount?: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
|
||||
export type StoreDailyBillRow = {
|
||||
billDate: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
storePhone?: string;
|
||||
cityName?: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
pendingCount: number;
|
||||
paidCount: number;
|
||||
};
|
||||
|
||||
export type WineryOrderBillRow = {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
cityName?: string;
|
||||
payAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
receiverCity?: string;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AccountStatus, StoreStaffRole } from './enums';
|
||||
|
||||
export interface ShopStoreOption {
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface ShopAccountMe {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
staffRole?: StoreStaffRole | null;
|
||||
permissions?: string[];
|
||||
primaryAccountId?: string;
|
||||
hasWechat?: boolean;
|
||||
}
|
||||
|
||||
export interface ShopMe {
|
||||
account: ShopAccountMe;
|
||||
store: ShopStoreOption | null;
|
||||
stores: ShopStoreOption[];
|
||||
/** @deprecated use account + store; kept for older H5 clients during rollout */
|
||||
id?: string;
|
||||
storeId?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface ShopLoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
phoneVerified: boolean;
|
||||
account: ShopAccountMe;
|
||||
stores: ShopStoreOption[];
|
||||
store?: ShopStoreOption | null;
|
||||
/** populated after select-store (or auto when single store) */
|
||||
selectedStoreId?: string;
|
||||
}
|
||||
|
||||
export interface SelectShopStoreRequest {
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
export interface ShopStaffItem {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: StoreStaffRole;
|
||||
permissions?: string[];
|
||||
status: AccountStatus;
|
||||
storeIds: string[];
|
||||
stores: ShopStoreOption[];
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateShopStaffRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
storeIds: string[];
|
||||
staffRole?: StoreStaffRole;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateShopStaffRequest {
|
||||
name?: string;
|
||||
staffRole?: StoreStaffRole;
|
||||
permissions?: string[];
|
||||
status?: AccountStatus;
|
||||
storeIds?: string[];
|
||||
}
|
||||
|
||||
export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
|
||||
MANAGER: '店长',
|
||||
CASHIER: '收银员',
|
||||
};
|
||||
|
||||
/** Default permissions for store sub-accounts (首版). */
|
||||
export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const;
|
||||
@@ -0,0 +1,116 @@
|
||||
export type StoreLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'page_view'
|
||||
| 'redeem'
|
||||
| 'payout'
|
||||
| 'store_ops'
|
||||
| 'package';
|
||||
|
||||
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
||||
login: [
|
||||
'store_sms_send',
|
||||
'store_sms_login',
|
||||
'store_sms_verify_fail',
|
||||
'store_login_success',
|
||||
'store_select',
|
||||
],
|
||||
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
|
||||
page_view: [
|
||||
'store_home_view',
|
||||
'store_redeem_entry_view',
|
||||
'store_redeem_confirm_view',
|
||||
'store_phone_redeem_view',
|
||||
'store_records_view',
|
||||
'store_withdraw_view',
|
||||
'store_packages_view',
|
||||
'store_mine_view',
|
||||
'store_staff_view',
|
||||
],
|
||||
redeem: [
|
||||
'store_redeem_preview',
|
||||
'store_redeem_confirm',
|
||||
'store_redeem_confirm_fail',
|
||||
'store_redeem_weaknet_threshold',
|
||||
'store_redeem_pending_submit',
|
||||
'store_redeem_pending_complete',
|
||||
'store_redeem_pending_reject',
|
||||
'store_redeem_phone_lookup_sms',
|
||||
'store_redeem_phone_balance',
|
||||
'store_redeem_phone_prepare',
|
||||
'store_redeem_scan_start',
|
||||
],
|
||||
payout: ['store_payout_created', 'store_payout_paid', 'store_withdraw_applied', 'store_withdraw_paid'],
|
||||
store_ops: [
|
||||
'store_status_change',
|
||||
'store_staff_create',
|
||||
'store_staff_update',
|
||||
'store_staff_delete',
|
||||
'store_staff_permission_update',
|
||||
],
|
||||
package: ['store_package_apply', 'store_package_audit_view'],
|
||||
};
|
||||
|
||||
export const STORE_LOG_CATEGORY_OPTIONS: Array<{ value: StoreLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '授权' },
|
||||
{ value: 'page_view', label: '页面浏览' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'payout', label: '提现/打款' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'package', label: '套餐' },
|
||||
];
|
||||
|
||||
export const STORE_LOG_CATEGORY_LABELS: Record<StoreLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '授权',
|
||||
page_view: '页面浏览',
|
||||
redeem: '核销',
|
||||
payout: '提现/打款',
|
||||
store_ops: '门店操作',
|
||||
package: '套餐',
|
||||
};
|
||||
|
||||
export function resolveStoreLogCategory(eventName: string): StoreLogCategory | null {
|
||||
for (const [category, events] of Object.entries(STORE_LOG_EVENT_CATEGORIES) as Array<
|
||||
[StoreLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function eventNamesForStoreLogCategory(category: string): string[] | undefined {
|
||||
if (!category) return undefined;
|
||||
return [...(STORE_LOG_EVENT_CATEGORIES[category as StoreLogCategory] ?? [])];
|
||||
}
|
||||
|
||||
export interface StoreLogRowDto {
|
||||
id: string;
|
||||
source: 'analytics' | 'redeem_record' | 'store_payout';
|
||||
storeId: string;
|
||||
storeAccountId: string | null;
|
||||
storeName: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
category: StoreLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type StoreAnalyticsExtra = {
|
||||
sessionId?: string;
|
||||
storeId?: string;
|
||||
storeAccountId?: string;
|
||||
redeemChannel?: 'scan' | 'phone' | 'pending';
|
||||
amount?: number;
|
||||
failReason?: string;
|
||||
durationMs?: number;
|
||||
pagePath?: string;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/** 门店套餐(v3.4.10) */
|
||||
export interface StorePackageItemDto {
|
||||
name: string;
|
||||
price: string;
|
||||
dishes: string;
|
||||
usableTime?: string | null;
|
||||
otherNotes?: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface StorePackageViewDto extends StorePackageItemDto {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export type StorePackageChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
|
||||
export type StorePackageSubmitterType = 'PARTNER' | 'SHOP';
|
||||
|
||||
export const STORE_PACKAGE_CHANGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '审核中',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const STORE_PACKAGE_MAX_COUNT = 10;
|
||||
|
||||
export interface StorePackagesResponse {
|
||||
live: StorePackageViewDto[];
|
||||
pendingRequest?: {
|
||||
id: string;
|
||||
status: StorePackageChangeStatus;
|
||||
packages: StorePackageItemDto[];
|
||||
rejectReason?: string | null;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface StorePackageChangeRequestDto {
|
||||
id: string;
|
||||
storeId: string;
|
||||
storeName?: string;
|
||||
status: StorePackageChangeStatus;
|
||||
packages: StorePackageItemDto[];
|
||||
submitterType: StorePackageSubmitterType;
|
||||
submitterId: string;
|
||||
rejectReason?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 套餐变更审核详情(含线上现行套餐,供对比) */
|
||||
export interface StorePackageAuditDetailDto extends StorePackageChangeRequestDto {
|
||||
livePackages: StorePackageViewDto[];
|
||||
}
|
||||
|
||||
export interface StorePackageAuditAction {
|
||||
action: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
export interface CreatePackageDisputeRequest {
|
||||
storeId: string;
|
||||
remark?: string;
|
||||
redeemRecordId?: string;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** HQ 技术支持工单类型 */
|
||||
export type SupportTicketTypeDto = 'BUG' | 'SUGGESTION' | 'OTHER';
|
||||
|
||||
export const SUPPORT_TICKET_TYPES = ['BUG', 'SUGGESTION', 'OTHER'] as const;
|
||||
|
||||
export const SUPPORT_TICKET_TYPE_LABELS: Record<SupportTicketTypeDto, string> = {
|
||||
BUG: 'BUG',
|
||||
SUGGESTION: '建议',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
/** 技术支持工单状态机 */
|
||||
export type SupportTicketStatusDto =
|
||||
| 'PENDING_REVIEW'
|
||||
| 'REJECTED'
|
||||
| 'DEVELOPING'
|
||||
| 'TESTING'
|
||||
| 'PASSED';
|
||||
|
||||
export const SUPPORT_TICKET_STATUSES = [
|
||||
'PENDING_REVIEW',
|
||||
'REJECTED',
|
||||
'DEVELOPING',
|
||||
'TESTING',
|
||||
'PASSED',
|
||||
] as const;
|
||||
|
||||
export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = {
|
||||
PENDING_REVIEW: '待评审',
|
||||
REJECTED: '已驳回',
|
||||
DEVELOPING: '开发',
|
||||
TESTING: '测试',
|
||||
PASSED: '通过',
|
||||
};
|
||||
|
||||
export interface SupportTicketDto {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: SupportTicketTypeDto;
|
||||
status: SupportTicketStatusDto;
|
||||
title: string;
|
||||
content?: string | null;
|
||||
rejectReason?: string | null;
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
reviewerId?: string | null;
|
||||
reviewerName?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSupportTicketRequest {
|
||||
ticketType: SupportTicketTypeDto;
|
||||
title: string;
|
||||
content?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface RejectSupportTicketRequest {
|
||||
rejectReason: string;
|
||||
}
|
||||
|
||||
export interface CreateDevPlanTaskFromTicketInput {
|
||||
content: string;
|
||||
type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
|
||||
}
|
||||
|
||||
export interface ReviewSupportTicketRequest {
|
||||
decision: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
tasks?: CreateDevPlanTaskFromTicketInput[];
|
||||
}
|
||||
|
||||
export interface SupportTicketLinkedTaskDto {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
content: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BatchReviewPreviewItem {
|
||||
ticketId: string;
|
||||
ticketNo: string;
|
||||
title: string;
|
||||
decision: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
reportMarkdown: string;
|
||||
suggestedTasks: CreateDevPlanTaskFromTicketInput[];
|
||||
}
|
||||
|
||||
export interface BatchReviewPreviewResponse {
|
||||
items: BatchReviewPreviewItem[];
|
||||
}
|
||||
|
||||
export interface BatchReviewConfirmItem {
|
||||
ticketId: string;
|
||||
decision: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
tasks?: CreateDevPlanTaskFromTicketInput[];
|
||||
}
|
||||
|
||||
export interface BatchReviewConfirmRequest {
|
||||
items: BatchReviewConfirmItem[];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type SystemConfigFieldType =
|
||||
| 'string'
|
||||
| 'boolean'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'textarea'
|
||||
| 'image'
|
||||
| 'imageList';
|
||||
|
||||
export interface SystemConfigFieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: SystemConfigFieldType;
|
||||
secret?: boolean;
|
||||
requiresRestart: boolean;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigGroupMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigFormField extends SystemConfigFieldMeta {
|
||||
value: string;
|
||||
/** 密钥类字段:是否已配置(不返回明文) */
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemConfigFormResponse {
|
||||
groups: SystemConfigGroupMeta[];
|
||||
fields: SystemConfigFieldMeta[];
|
||||
values: Record<string, string>;
|
||||
configuredSecrets: string[];
|
||||
envFilePath: string;
|
||||
updatedAt: string | null;
|
||||
/** Mock 短信最近生成的验证码(仅 HQ 系统设置展示) */
|
||||
mockSmsCodes: MockSmsCodeItem[];
|
||||
}
|
||||
|
||||
export interface MockSmsCodeItem {
|
||||
id: string;
|
||||
phone: string;
|
||||
scene: string;
|
||||
code: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigUpdateRequest {
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SystemConfigSyncResult {
|
||||
envFilePath: string;
|
||||
writtenKeys: number;
|
||||
requiresRestartKeys: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 解析小程序首页轮播 JSON(最多 8 张) */
|
||||
export function parseMiniHomeBanners(raw?: string | null): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
const text = raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((u): u is string => typeof u === 'string' && !!u.trim())
|
||||
.map((u) => u.trim())
|
||||
.slice(0, 8);
|
||||
} catch {
|
||||
if (/^https?:\/\//i.test(text)) return [text];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeMiniHomeBanners(urls: string[]): string {
|
||||
const cleaned = urls.filter((u) => !!u?.trim()).map((u) => u.trim()).slice(0, 8);
|
||||
return JSON.stringify(cleaned);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 售后工单类型(PRD 四类型 + 系统 ALERT + 套餐异议) */
|
||||
export type TicketTypeDto =
|
||||
| 'REFUND'
|
||||
| 'RESHIPMENT'
|
||||
| 'ALERT'
|
||||
| 'DAMAGE_RETURN'
|
||||
| 'RETURN_REFUND'
|
||||
| 'PACKAGE_DISPUTE';
|
||||
|
||||
/** 用户可发起的售后类型 */
|
||||
export const AFTER_SALE_TICKET_TYPES = [
|
||||
'REFUND',
|
||||
'RESHIPMENT',
|
||||
'DAMAGE_RETURN',
|
||||
'RETURN_REFUND',
|
||||
'PACKAGE_DISPUTE',
|
||||
] as const;
|
||||
|
||||
export type AfterSaleTicketType = (typeof AFTER_SALE_TICKET_TYPES)[number];
|
||||
|
||||
export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
|
||||
REFUND: '仅退款',
|
||||
RESHIPMENT: '破损补发',
|
||||
ALERT: '异常',
|
||||
DAMAGE_RETURN: '破损退货',
|
||||
RETURN_REFUND: '退货退款',
|
||||
PACKAGE_DISPUTE: '套餐异议',
|
||||
};
|
||||
|
||||
export interface TicketDto {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: TicketTypeDto;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string | null;
|
||||
extraJson?: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TicketActionRequest {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface CreateAfterSaleTicketRequest {
|
||||
ticketType: AfterSaleTicketType;
|
||||
remark?: string;
|
||||
evidenceUrls?: string[];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { WechatJsapiPrepayParams } from './wechat';
|
||||
|
||||
export interface OrderDto {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payStatus: string;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
quantity: number;
|
||||
productName: string;
|
||||
createdAt: string;
|
||||
orderType?: string;
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
}
|
||||
|
||||
export interface OrderPreviewRequest {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId?: string;
|
||||
}
|
||||
|
||||
export interface OrderPreviewResult {
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
}
|
||||
|
||||
export type ProxyPayMethod = 'NATIVE' | 'JSAPI';
|
||||
|
||||
export interface PayOrderResult {
|
||||
mode?: 'jsapi' | 'mock' | 'native';
|
||||
orderId?: string;
|
||||
codeUrl?: string;
|
||||
prepay?: WechatJsapiPrepayParams;
|
||||
payExpireAt?: string | null;
|
||||
}
|
||||
|
||||
export type ProxyOrderPayRequest = {
|
||||
payMethod: ProxyPayMethod;
|
||||
};
|
||||
|
||||
export type ProxyOrderPayResponse = {
|
||||
mode: 'jsapi' | 'mock' | 'native';
|
||||
orderId: string;
|
||||
orderNo?: string;
|
||||
codeUrl?: string;
|
||||
prepay?: WechatJsapiPrepayParams;
|
||||
payExpireAt?: string | null;
|
||||
};
|
||||
|
||||
export type ProxyOrderCreateResponse = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payStatus: string;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
deliveryType: string;
|
||||
payExpireAt: string | null;
|
||||
proxyPartnerName?: string | null;
|
||||
};
|
||||
|
||||
export interface DeliveryDto {
|
||||
provider?: string;
|
||||
shippingAt?: string;
|
||||
deliveredAt?: string;
|
||||
}
|
||||
|
||||
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
||||
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
|
||||
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
|
||||
};
|
||||
|
||||
export type PartnerProxyDeliveryMode = 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
export type PartnerProxyOrderProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount: number | null;
|
||||
coverUrl?: string | null;
|
||||
allowOnSitePickup?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderPromoOption = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderStoreOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
province?: string | null;
|
||||
cityName?: string | null;
|
||||
district?: string | null;
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderOptions = {
|
||||
products: PartnerProxyOrderProductOption[];
|
||||
promoCodes: PartnerProxyOrderPromoOption[];
|
||||
stores: PartnerProxyOrderStoreOption[];
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderPreviewRequest = {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
deliveryMode?: PartnerProxyDeliveryMode;
|
||||
storeId?: string;
|
||||
receiverCity?: string;
|
||||
receiverDistrict?: string;
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderPreviewResult = {
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderCreateRequest = {
|
||||
phone: string;
|
||||
deliveryMode: PartnerProxyDeliveryMode;
|
||||
/** 地址配送时必须为 true */
|
||||
autoReceive?: boolean;
|
||||
storeId?: string;
|
||||
receiverName?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
addressDetail?: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
};
|
||||
|
||||
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
|
||||
export type PartnerProxyOrderListItem = OrderDto & {
|
||||
receiverName?: string | null;
|
||||
receiverPhone?: string | null;
|
||||
receiverAddress?: string | null;
|
||||
deliveryType?: string | null;
|
||||
productSpec?: string | null;
|
||||
imageResource?: { url?: string } | null;
|
||||
/** 已锁定的支付方式(首次拉起支付后不可切换,须取消后重新下单) */
|
||||
proxyPayMethod?: ProxyPayMethod | null;
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderListResponse = {
|
||||
list: PartnerProxyOrderListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
/** 总部代下单(在线支付后发权益) */
|
||||
export type HqProxyOrderCreateRequest = {
|
||||
phone: string;
|
||||
deliveryMode: PartnerProxyDeliveryMode;
|
||||
autoReceive?: boolean;
|
||||
receiverName?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
addressDetail?: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
export type UserLogCategory =
|
||||
| 'login'
|
||||
| 'browse_product'
|
||||
| 'order'
|
||||
| 'pay'
|
||||
| 'wechat_auth'
|
||||
| 'browse_store'
|
||||
| 'redeem'
|
||||
| 'profile'
|
||||
| 'promo'
|
||||
| 'customer_service'
|
||||
| 'after_sale'
|
||||
| 'error';
|
||||
|
||||
export const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail', 'session_start'],
|
||||
browse_product: ['home_view', 'product_list_view', 'product_click', 'product_detail_view'],
|
||||
order: ['order_confirm_view', 'order_submit', 'order_list_view', 'order_detail_view', 'order_cancel'],
|
||||
pay: ['pay_page_view', 'pay_success', 'pay_fail'],
|
||||
wechat_auth: [
|
||||
'wechat_login',
|
||||
'wechat_phone',
|
||||
'wechat_phone_login',
|
||||
'wechat_location',
|
||||
'wechat_album',
|
||||
'wechat_bind',
|
||||
],
|
||||
browse_store: ['store_list_view', 'store_detail_view'],
|
||||
redeem: [
|
||||
'benefit_page_view',
|
||||
'benefit_detail_view',
|
||||
'benefit_redeem_start',
|
||||
'benefit_redeem_success',
|
||||
'redeem_code_view',
|
||||
'redeem_success_view',
|
||||
],
|
||||
profile: ['profile_update', 'bind_phone', 'address_list_view', 'address_edit'],
|
||||
promo: ['promo_touch', 'promo_share'],
|
||||
customer_service: ['cs_contact'],
|
||||
after_sale: ['after_sale_list_view', 'after_sale_apply'],
|
||||
error: ['client_error'],
|
||||
};
|
||||
|
||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'browse_product', label: '浏览商品' },
|
||||
{ value: 'order', label: '下单' },
|
||||
{ value: 'pay', label: '支付' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'browse_store', label: '浏览门店' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'profile', label: '信息修改' },
|
||||
{ value: 'promo', label: '推广' },
|
||||
{ value: 'customer_service', label: '客服' },
|
||||
{ value: 'after_sale', label: '售后' },
|
||||
{ value: 'error', label: '前端异常' },
|
||||
];
|
||||
|
||||
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
|
||||
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
|
||||
[UserLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function eventNamesForUserLogCategory(category: string): string[] | undefined {
|
||||
if (!category) return undefined;
|
||||
return [...(USER_LOG_EVENT_CATEGORIES[category as UserLogCategory] ?? [])];
|
||||
}
|
||||
|
||||
export interface UserLogRowDto {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
userNo: string | null;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
category: UserLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** C 端埋点 extraJson 推荐字段(画像分析) */
|
||||
export type UserAnalyticsExtra = {
|
||||
sessionId?: string;
|
||||
cityCode?: string;
|
||||
productId?: string;
|
||||
skuId?: string;
|
||||
storeId?: string;
|
||||
orderId?: string;
|
||||
amount?: number;
|
||||
quantity?: number;
|
||||
sourceType?: string;
|
||||
sourceRefId?: string;
|
||||
failReason?: string;
|
||||
pagePath?: string;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 微信 JSSDK 初始化参数(后端签名下发) */
|
||||
export interface WechatJssdkConfig {
|
||||
appId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
jsApiList: string[];
|
||||
}
|
||||
|
||||
/** JSAPI 调起支付参数 */
|
||||
export interface WechatJsapiPrepayParams {
|
||||
appId: string;
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: 'RSA' | 'MD5';
|
||||
paySign: string;
|
||||
}
|
||||
|
||||
export type WechatPayOrderResult =
|
||||
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
|
||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
|
||||
|
||||
/** 微信退款回调解密结果 */
|
||||
export type WechatRefundNotifyResult = {
|
||||
outRefundNo: string;
|
||||
refundId: string;
|
||||
status: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||||
amountFen: number;
|
||||
outTradeNo?: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
/** 业务错误码:微信支付前需完成微信授权 */
|
||||
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||
|
||||
export type ClientRuntimeConfig = {
|
||||
mockPay: boolean;
|
||||
wechatPayEnabled: boolean;
|
||||
mockSms: boolean;
|
||||
mockWechat?: boolean;
|
||||
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
|
||||
wxAuthorize?: boolean;
|
||||
/** 腾讯位置服务 Key(地图选点组件,可按域名限制) */
|
||||
tencentLbsKey?: string;
|
||||
/** 小程序首页轮播 / 底部图 */
|
||||
miniHome?: {
|
||||
banners: string[];
|
||||
footerUrl: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/** 是否展示微信授权入口 */
|
||||
export function isWxAuthorizeEnabled(config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null): boolean {
|
||||
return config?.wxAuthorize !== false;
|
||||
}
|
||||
|
||||
export interface WechatLoginResult {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
deviceKey?: string;
|
||||
actorType?: string;
|
||||
actorId?: string;
|
||||
phoneVerified?: boolean;
|
||||
/** 本次登录/绑定触发了账号合并,客户端应强制刷新页面 */
|
||||
accountMerged?: boolean;
|
||||
needBindPhone?: boolean;
|
||||
wxSessionKey?: string;
|
||||
user?: Record<string, unknown>;
|
||||
store?: Record<string, unknown>;
|
||||
partner?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WechatLoginPlatform = 'h5' | 'mini';
|
||||
|
||||
export interface WechatGpsLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
speed?: number;
|
||||
accuracy?: number;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/** 企业微信智能机器人 · 模块化能力权限(v3.4.11 重构) */
|
||||
export const WECOM_BOT_PERMISSIONS = [
|
||||
'order.read',
|
||||
'delivery.read',
|
||||
'store.read',
|
||||
'redeem.read',
|
||||
'ticket.read',
|
||||
'ticket.create',
|
||||
'user.read',
|
||||
'user.read_sms',
|
||||
'finance.store_bill.read',
|
||||
'finance.partner_bill.read',
|
||||
'finance.winery_bill.read',
|
||||
'finance.logistics_bill.read',
|
||||
'finance.payout.read',
|
||||
'finance.withdrawal.read',
|
||||
'support_ticket.read',
|
||||
'support_ticket.create',
|
||||
'support_ticket.review',
|
||||
'support_ticket.approve',
|
||||
'support_ticket.reject',
|
||||
'dev_plan.task.read',
|
||||
'dev_plan.task.create',
|
||||
'dev_plan.task.update_status',
|
||||
'dev_plan.version.read',
|
||||
'dev_plan.version.create',
|
||||
'dev_plan.version.update_status',
|
||||
'dev_plan.version.link_tasks',
|
||||
'server_log.read',
|
||||
'handbook.read',
|
||||
] as const;
|
||||
|
||||
export type WecomBotPermission = (typeof WECOM_BOT_PERMISSIONS)[number];
|
||||
|
||||
export const WECOM_BOT_PERMISSION_LABELS: Record<WecomBotPermission, string> = {
|
||||
'order.read': '查询订单',
|
||||
'delivery.read': '查询配送/物流',
|
||||
'store.read': '查询门店',
|
||||
'redeem.read': '查询核销记录',
|
||||
'ticket.read': '查询售后工单',
|
||||
'ticket.create': '创建售后工单',
|
||||
'user.read': '查询用户(脱敏)',
|
||||
'user.read_sms': '查用户(短信验证)',
|
||||
'finance.store_bill.read': '查询门店账单',
|
||||
'finance.partner_bill.read': '查询合伙人账单',
|
||||
'finance.winery_bill.read': '查询酒厂账单',
|
||||
'finance.logistics_bill.read': '查询物流账单',
|
||||
'finance.payout.read': '查询门店打款',
|
||||
'finance.withdrawal.read': '查询门店提现',
|
||||
'support_ticket.read': '查询技术支持工单',
|
||||
'support_ticket.create': '创建技术支持工单',
|
||||
'support_ticket.review': '审批技术支持工单(通过/驳回)',
|
||||
'support_ticket.approve': '通过技术支持工单',
|
||||
'support_ticket.reject': '驳回技术支持工单',
|
||||
'dev_plan.task.read': '查询开发任务',
|
||||
'dev_plan.task.create': '新建开发任务',
|
||||
'dev_plan.task.update_status': '修改任务状态',
|
||||
'dev_plan.version.read': '查询开发版本',
|
||||
'dev_plan.version.create': '新建开发版本',
|
||||
'dev_plan.version.update_status': '修改版本状态',
|
||||
'dev_plan.version.link_tasks': '关联任务到版本',
|
||||
'server_log.read': '查看服务器日志',
|
||||
'handbook.read': '查询使用手册',
|
||||
};
|
||||
|
||||
/** Admin UI 权限分组 */
|
||||
export const WECOM_BOT_PERMISSION_GROUPS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
permissions: WecomBotPermission[];
|
||||
}> = [
|
||||
{
|
||||
key: 'order',
|
||||
label: '订单与配送',
|
||||
permissions: ['order.read', 'delivery.read'],
|
||||
},
|
||||
{
|
||||
key: 'store_redeem',
|
||||
label: '门店与核销',
|
||||
permissions: ['store.read', 'redeem.read'],
|
||||
},
|
||||
{
|
||||
key: 'ticket',
|
||||
label: '售后工单',
|
||||
permissions: ['ticket.read', 'ticket.create'],
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: '用户',
|
||||
permissions: ['user.read', 'user.read_sms'],
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
label: '财务',
|
||||
permissions: [
|
||||
'finance.store_bill.read',
|
||||
'finance.partner_bill.read',
|
||||
'finance.winery_bill.read',
|
||||
'finance.logistics_bill.read',
|
||||
'finance.payout.read',
|
||||
'finance.withdrawal.read',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'support',
|
||||
label: '技术支持',
|
||||
permissions: ['support_ticket.read', 'support_ticket.create', 'support_ticket.review', 'support_ticket.approve', 'support_ticket.reject'],
|
||||
},
|
||||
{
|
||||
key: 'dev_plan',
|
||||
label: '开发计划',
|
||||
permissions: ['dev_plan.task.read', 'dev_plan.task.create', 'dev_plan.task.update_status', 'dev_plan.version.read', 'dev_plan.version.create', 'dev_plan.version.update_status', 'dev_plan.version.link_tasks'],
|
||||
},
|
||||
{
|
||||
key: 'ops',
|
||||
label: '运维与手册',
|
||||
permissions: ['server_log.read', 'handbook.read'],
|
||||
},
|
||||
];
|
||||
|
||||
/** 预置机器人角色 */
|
||||
export const WECOM_BOT_ROLES = [
|
||||
'CUSTOMER_SERVICE',
|
||||
'FINANCE',
|
||||
'OPERATIONS',
|
||||
'TECH_SUPPORT',
|
||||
'CUSTOM',
|
||||
] as const;
|
||||
|
||||
export type WecomBotRole = (typeof WECOM_BOT_ROLES)[number];
|
||||
|
||||
/** 历史角色名 → 现行角色 */
|
||||
export function normalizeWecomBotRole(raw: string): WecomBotRole {
|
||||
if (raw === 'TEAM_ASSISTANT') return 'OPERATIONS';
|
||||
if ((WECOM_BOT_ROLES as readonly string[]).includes(raw)) return raw as WecomBotRole;
|
||||
return 'CUSTOM';
|
||||
}
|
||||
|
||||
export const WECOM_BOT_ROLE_LABELS: Record<WecomBotRole, string> = {
|
||||
CUSTOMER_SERVICE: '客服助手',
|
||||
FINANCE: '财务助手',
|
||||
OPERATIONS: '运营助手',
|
||||
TECH_SUPPORT: '技术支持',
|
||||
CUSTOM: '自定义',
|
||||
};
|
||||
|
||||
export const WECOM_BOT_ROLE_DEFAULT_PERMISSIONS: Record<WecomBotRole, WecomBotPermission[]> = {
|
||||
CUSTOMER_SERVICE: [
|
||||
'order.read',
|
||||
'delivery.read',
|
||||
'store.read',
|
||||
'redeem.read',
|
||||
'ticket.read',
|
||||
'ticket.create',
|
||||
'user.read_sms',
|
||||
],
|
||||
FINANCE: [
|
||||
'finance.store_bill.read',
|
||||
'finance.partner_bill.read',
|
||||
'finance.winery_bill.read',
|
||||
'finance.logistics_bill.read',
|
||||
'finance.payout.read',
|
||||
'finance.withdrawal.read',
|
||||
'order.read',
|
||||
],
|
||||
OPERATIONS: [
|
||||
'order.read',
|
||||
'store.read',
|
||||
'user.read',
|
||||
'redeem.read',
|
||||
'delivery.read',
|
||||
'handbook.read',
|
||||
],
|
||||
TECH_SUPPORT: [
|
||||
'support_ticket.read',
|
||||
'support_ticket.create',
|
||||
'support_ticket.review',
|
||||
'dev_plan.task.read',
|
||||
'dev_plan.task.create',
|
||||
'dev_plan.task.update_status',
|
||||
'dev_plan.version.read',
|
||||
'dev_plan.version.create',
|
||||
'dev_plan.version.update_status',
|
||||
'dev_plan.version.link_tasks',
|
||||
'server_log.read',
|
||||
],
|
||||
CUSTOM: [],
|
||||
};
|
||||
|
||||
/** 旧版权限 → 新版权限(读取 DB 时迁移) */
|
||||
const LEGACY_PERMISSION_MAP: Record<string, WecomBotPermission[]> = {
|
||||
'ticket.create': ['ticket.create'],
|
||||
'user.view_sms': ['user.read_sms'],
|
||||
'delivery.view': ['delivery.read'],
|
||||
'support_ticket.create': ['support_ticket.create'],
|
||||
'support_ticket.progress': ['support_ticket.read', 'dev_plan.task.read'],
|
||||
'handbook.query': ['handbook.read'],
|
||||
'server_log.view': ['server_log.read'],
|
||||
'api.query': ['order.read'],
|
||||
'api.read.all': [
|
||||
'order.read',
|
||||
'delivery.read',
|
||||
'store.read',
|
||||
'redeem.read',
|
||||
'support_ticket.read',
|
||||
'dev_plan.task.read',
|
||||
'dev_plan.version.read',
|
||||
'user.read',
|
||||
],
|
||||
'db.read': ['support_ticket.read', 'order.read'],
|
||||
};
|
||||
|
||||
export function migrateWecomBotPermissions(raw: string[]): WecomBotPermission[] {
|
||||
const set = new Set<WecomBotPermission>();
|
||||
const valid = new Set<string>(WECOM_BOT_PERMISSIONS);
|
||||
for (const p of raw) {
|
||||
if (valid.has(p)) {
|
||||
set.add(p as WecomBotPermission);
|
||||
continue;
|
||||
}
|
||||
const mapped = LEGACY_PERMISSION_MAP[p];
|
||||
if (mapped) mapped.forEach((m) => set.add(m));
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
export function parseWecomBotPermissions(
|
||||
raw?: string | string[] | null,
|
||||
): WecomBotPermission[] {
|
||||
const valid = new Set<string>(WECOM_BOT_PERMISSIONS);
|
||||
let list: string[];
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw.map(String);
|
||||
} else {
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) {
|
||||
list = [];
|
||||
} else if (text.startsWith('[')) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
list = Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
list = text
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
list = text
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
const migrated = migrateWecomBotPermissions(list);
|
||||
if (migrated.length) return migrated;
|
||||
return [...new Set(list.filter((s): s is WecomBotPermission => valid.has(s)))];
|
||||
}
|
||||
|
||||
export function resolveWecomBotPermissions(
|
||||
role: WecomBotRole,
|
||||
override?: string | string[] | null,
|
||||
): WecomBotPermission[] {
|
||||
const parsed = parseWecomBotPermissions(override);
|
||||
if (parsed.length) return parsed;
|
||||
return [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]];
|
||||
}
|
||||
|
||||
export function parseWecomUserIdList(raw?: string | string[] | null): string[] {
|
||||
if (Array.isArray(raw)) return [...new Set(raw.map(String).filter(Boolean))];
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return [];
|
||||
if (text.startsWith('[')) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return Array.isArray(parsed) ? [...new Set(parsed.map(String).filter(Boolean))] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [...new Set(text.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
export type WecomBotDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: WecomBotRole;
|
||||
botId: string;
|
||||
secretConfigured: boolean;
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: WecomBotPermission[];
|
||||
/** 可执行 support_ticket.review 的企微 userid 白名单 */
|
||||
reviewSuperAdminWecomUserIds: string[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
llmConfigName: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
knowledgeBaseName: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateWecomBotRequest = {
|
||||
name: string;
|
||||
role: WecomBotRole;
|
||||
botId: string;
|
||||
secret: string;
|
||||
avatarUrl?: string | null;
|
||||
welcome?: string | null;
|
||||
permissions?: WecomBotPermission[];
|
||||
reviewSuperAdminWecomUserIds?: string[];
|
||||
aiEnabled?: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type UpdateWecomBotRequest = {
|
||||
name?: string;
|
||||
role?: WecomBotRole;
|
||||
botId?: string;
|
||||
secret?: string;
|
||||
avatarUrl?: string | null;
|
||||
welcome?: string | null;
|
||||
permissions?: WecomBotPermission[];
|
||||
reviewSuperAdminWecomUserIds?: string[];
|
||||
aiEnabled?: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
/** 长连接运行时状态(Admin GET /admin/wecom-bots/runtime) */
|
||||
export type WecomAibotRuntimeDto = {
|
||||
masterEnabled: boolean;
|
||||
bots: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
key: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
botIdMasked: string | null;
|
||||
avatarUrl: string | null;
|
||||
permissions: WecomBotPermission[];
|
||||
lastError: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Admin 表单 permissions 与 UI 分组/catalog 对齐校验 */
|
||||
export function assertWecomBotPermissionCatalogConsistent(): void {
|
||||
const fromGroups = WECOM_BOT_PERMISSION_GROUPS.flatMap((g) => g.permissions);
|
||||
const catalog = new Set<string>(WECOM_BOT_PERMISSIONS);
|
||||
const groupSet = new Set(fromGroups);
|
||||
for (const p of WECOM_BOT_PERMISSIONS) {
|
||||
if (!groupSet.has(p)) throw new Error(`permission missing in UI groups: ${p}`);
|
||||
if (!WECOM_BOT_PERMISSION_LABELS[p]) throw new Error(`permission missing label: ${p}`);
|
||||
}
|
||||
for (const p of fromGroups) {
|
||||
if (!catalog.has(p)) throw new Error(`unknown permission in UI groups: ${p}`);
|
||||
}
|
||||
if (fromGroups.length !== groupSet.size) {
|
||||
throw new Error('duplicate permission in UI groups');
|
||||
}
|
||||
}
|
||||
|
||||
export type WecomBotLogDto = {
|
||||
id: string;
|
||||
botId: string | null;
|
||||
botName: string | null;
|
||||
botKey: string | null;
|
||||
wecomUserId: string;
|
||||
action: string;
|
||||
permission: string | null;
|
||||
inputSummary: string | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
latencyMs: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/** 企微群机器人 Webhook · 推送条件(v3.4.11) */
|
||||
export const WECOM_PUSH_CONDITIONS = [
|
||||
'alert.ops',
|
||||
'support_ticket.created',
|
||||
'alert.pay',
|
||||
'alert.redeem',
|
||||
'alert.system',
|
||||
'alert.settlement',
|
||||
'dev_plan.task_dispatch',
|
||||
] as const;
|
||||
|
||||
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
|
||||
|
||||
export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
|
||||
'alert.ops': '运营告警(售后工单、客户端错误等)',
|
||||
'support_ticket.created': '新建技术支持工单',
|
||||
'alert.pay': '支付异常',
|
||||
'alert.redeem': '核销异常',
|
||||
'alert.system': '系统监控(5xx、回调、定时任务)',
|
||||
'alert.settlement': '结算任务告警',
|
||||
'dev_plan.task_dispatch': '开发任务评审派发',
|
||||
};
|
||||
|
||||
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
conditions: WecomPushCondition[];
|
||||
}> = [
|
||||
{
|
||||
key: 'ops',
|
||||
label: '工单与运营',
|
||||
conditions: ['alert.ops', 'support_ticket.created'],
|
||||
},
|
||||
{
|
||||
key: 'pay_redeem',
|
||||
label: '支付与核销',
|
||||
conditions: ['alert.pay', 'alert.redeem'],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统与结算',
|
||||
conditions: ['alert.system', 'alert.settlement'],
|
||||
},
|
||||
{
|
||||
key: 'dev_plan',
|
||||
label: '开发计划',
|
||||
conditions: ['dev_plan.task_dispatch'],
|
||||
},
|
||||
];
|
||||
|
||||
/** 默认「运营告警」推送条件 */
|
||||
export const WECOM_PUSH_DEFAULT_ALERT_CONDITIONS: WecomPushCondition[] = [
|
||||
'alert.ops',
|
||||
'support_ticket.created',
|
||||
'alert.pay',
|
||||
'alert.redeem',
|
||||
'alert.system',
|
||||
'alert.settlement',
|
||||
];
|
||||
|
||||
/** 默认「开发任务派发」推送条件 */
|
||||
export const WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS: WecomPushCondition[] = [
|
||||
'dev_plan.task_dispatch',
|
||||
'support_ticket.created',
|
||||
];
|
||||
|
||||
export function parseWecomPushConditions(
|
||||
raw?: string | string[] | null,
|
||||
): WecomPushCondition[] {
|
||||
const valid = new Set<string>(WECOM_PUSH_CONDITIONS);
|
||||
let list: string[];
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw.map(String);
|
||||
} else {
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) {
|
||||
list = [];
|
||||
} else if (text.startsWith('[')) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
list = Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
list = text
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
list = text
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(list.filter((s): s is WecomPushCondition => valid.has(s)))];
|
||||
}
|
||||
|
||||
export type WecomMessagePushDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
webhookUrl: string;
|
||||
webhookUrlMasked: string;
|
||||
enabled: boolean;
|
||||
mentionWecomUserId: string | null;
|
||||
pushConditions: WecomPushCondition[];
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateWecomMessagePushRequest = {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
webhookUrl: string;
|
||||
enabled?: boolean;
|
||||
mentionWecomUserId?: string | null;
|
||||
pushConditions: WecomPushCondition[];
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type UpdateWecomMessagePushRequest = {
|
||||
name?: string;
|
||||
avatarUrl?: string | null;
|
||||
webhookUrl?: string;
|
||||
enabled?: boolean;
|
||||
mentionWecomUserId?: string | null;
|
||||
pushConditions?: WecomPushCondition[];
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export function maskWecomWebhookUrl(url: string): string {
|
||||
const u = url.trim();
|
||||
if (u.length <= 24) return `${u.slice(0, 8)}…`;
|
||||
return `${u.slice(0, 20)}…${u.slice(-8)}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@dukang/shared-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./tokens.css": "./src/tokens.css",
|
||||
"./base.css": "./src/base.css",
|
||||
"./BrandLogo": "./src/BrandLogo.tsx",
|
||||
"./PageHeader": "./src/PageHeader.tsx",
|
||||
"./CouponBadge": "./src/CouponBadge.tsx",
|
||||
"./OrderStatusTabs": "./src/OrderStatusTabs.tsx",
|
||||
"./AppImage": "./src/AppImage.tsx",
|
||||
"./PullToRefresh": "./src/PullToRefresh.tsx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"react": "^18.3.1",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState, type ImgHTMLAttributes } from 'react';
|
||||
|
||||
type AppImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {
|
||||
src?: string | null;
|
||||
wrapperClassName?: string;
|
||||
fit?: 'cover' | 'contain';
|
||||
};
|
||||
|
||||
export default function AppImage({
|
||||
src,
|
||||
alt = '',
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
fit = 'cover',
|
||||
...rest
|
||||
}: AppImageProps) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const hasSrc = Boolean(src);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setFailed(!src);
|
||||
}, [src]);
|
||||
|
||||
const showPlaceholder = !hasSrc || failed || !loaded;
|
||||
|
||||
return (
|
||||
<div className={`app-image${wrapperClassName ? ` ${wrapperClassName}` : ''}`}>
|
||||
{showPlaceholder && (
|
||||
<div className="app-image-placeholder" aria-hidden>
|
||||
{hasSrc && !failed ? (
|
||||
<span className="app-image-spinner" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined app-image-fallback-icon">image</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasSrc && !failed && (
|
||||
<img
|
||||
{...rest}
|
||||
src={src!}
|
||||
alt={alt}
|
||||
className={`app-image-img app-image-img--${fit}${loaded ? ' is-loaded' : ''}${className ? ` ${className}` : ''}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => {
|
||||
setFailed(true);
|
||||
setLoaded(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import AppImage from './AppImage';
|
||||
|
||||
type BrandLogoProps = {
|
||||
className?: string;
|
||||
size?: 'default' | 'lg';
|
||||
};
|
||||
|
||||
export default function BrandLogo({ className = '', size = 'default' }: BrandLogoProps) {
|
||||
const sizeClass = size === 'lg' ? 'brand-logo--lg' : '';
|
||||
return (
|
||||
<AppImage
|
||||
src="/logo.png"
|
||||
alt="杜康好客"
|
||||
wrapperClassName={`brand-logo ${sizeClass} ${className}`.trim()}
|
||||
fit="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
type CouponBadgeProps = {
|
||||
amount: number | string;
|
||||
className?: string;
|
||||
/** 权益标签文案,默认「餐券」;首页使用「好客权益」 */
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export default function CouponBadge({ amount, className = '', label = '餐券' }: CouponBadgeProps) {
|
||||
return (
|
||||
<span className={`coupon-badge ${className}`.trim()}>
|
||||
¥{amount}{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
type Tab = { key: string; label: string };
|
||||
|
||||
type OrderStatusTabsProps = {
|
||||
tabs: Tab[];
|
||||
active: string;
|
||||
onChange: (key: string) => void;
|
||||
};
|
||||
|
||||
export default function OrderStatusTabs({ tabs, active, onChange }: OrderStatusTabsProps) {
|
||||
return (
|
||||
<nav className="order-status-tabs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={`order-status-tab${active === t.key ? ' active' : ''}`}
|
||||
onClick={() => onChange(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
right?: React.ReactNode;
|
||||
/** 是否展示居中标题;H5 微信内默认隐藏,避免与系统标题栏重复 */
|
||||
showTitle?: boolean;
|
||||
};
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
onBack,
|
||||
backLabel = '←',
|
||||
right,
|
||||
showTitle = false,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="page-header" aria-label={title}>
|
||||
<div className="page-header-side">
|
||||
{onBack ? (
|
||||
<button type="button" className="back-btn" onClick={onBack} aria-label="返回">
|
||||
{backLabel}
|
||||
</button>
|
||||
) : (
|
||||
<span className="page-header-spacer" />
|
||||
)}
|
||||
</div>
|
||||
{showTitle ? (
|
||||
<div className="app-page-title page-header-title">{title}</div>
|
||||
) : (
|
||||
<div className="page-header-title page-header-title--spacer" aria-hidden="true" />
|
||||
)}
|
||||
<div className="page-header-side page-header-side--right">{right ?? <span className="page-header-spacer" />}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import './pull-to-refresh.css';
|
||||
|
||||
const PULL_THRESHOLD = 64;
|
||||
const MAX_PULL = 96;
|
||||
|
||||
type PullToRefreshProps = {
|
||||
onRefresh: () => void | Promise<void>;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function getScrollTop() {
|
||||
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
|
||||
}
|
||||
|
||||
export default function PullToRefresh({
|
||||
onRefresh,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
disabled = false,
|
||||
}: PullToRefreshProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [pull, setPull] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const startY = useRef(0);
|
||||
const pulling = useRef(false);
|
||||
const busy = useRef(false);
|
||||
const pullRef = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
|
||||
disabledRef.current = disabled;
|
||||
onRefreshRef.current = onRefresh;
|
||||
pullRef.current = pull;
|
||||
|
||||
const finishRefresh = useCallback(async () => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setRefreshing(true);
|
||||
setPull(40);
|
||||
pullRef.current = 40;
|
||||
try {
|
||||
await onRefreshRef.current();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
busy.current = false;
|
||||
pulling.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (disabledRef.current || busy.current) return;
|
||||
if (getScrollTop() > 2) return;
|
||||
startY.current = e.touches[0]?.clientY ?? 0;
|
||||
pulling.current = true;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!pulling.current || disabledRef.current || busy.current) return;
|
||||
if (getScrollTop() > 2) {
|
||||
pulling.current = false;
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const y = e.touches[0]?.clientY ?? 0;
|
||||
const delta = y - startY.current;
|
||||
if (delta <= 0) {
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const next = Math.min(MAX_PULL, delta * 0.45);
|
||||
setPull(next);
|
||||
pullRef.current = next;
|
||||
if (next > 8 && e.cancelable) e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!pulling.current || disabledRef.current) return;
|
||||
pulling.current = false;
|
||||
if (pullRef.current >= PULL_THRESHOLD) {
|
||||
void finishRefresh();
|
||||
return;
|
||||
}
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
};
|
||||
|
||||
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
el.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
el.addEventListener('touchend', onTouchEnd);
|
||||
el.addEventListener('touchcancel', onTouchEnd);
|
||||
return () => {
|
||||
el.removeEventListener('touchstart', onTouchStart);
|
||||
el.removeEventListener('touchmove', onTouchMove);
|
||||
el.removeEventListener('touchend', onTouchEnd);
|
||||
el.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
}, [finishRefresh]);
|
||||
|
||||
const showHint = pull > 8 || refreshing;
|
||||
const ready = pull >= PULL_THRESHOLD;
|
||||
const pad = showHint ? Math.max(pull, refreshing ? 40 : 0) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['dukang-ptr', className].filter(Boolean).join(' ')}
|
||||
style={{ ...style, paddingTop: pad || undefined }}
|
||||
>
|
||||
<div
|
||||
className={`dukang-ptr-indicator${showHint ? ' dukang-ptr-indicator--visible' : ''}`}
|
||||
style={{ height: pad }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="dukang-ptr-text">
|
||||
{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
@import './tokens.css';
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.headline-lg {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.headline-md {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.body-md {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.label-md {
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text-muted { color: var(--color-subtle-gray); }
|
||||
.text-variant { color: var(--color-on-surface-variant); }
|
||||
.text-primary { color: var(--color-heritage-red); }
|
||||
.text-success { color: var(--color-success-green); }
|
||||
|
||||
.amount-lg {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.amount-xl {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.page { min-height: 100vh; padding-bottom: 80px; background: var(--color-background); }
|
||||
.page-no-tab { min-height: 100vh; background: var(--color-background); }
|
||||
|
||||
/* Brand logo */
|
||||
.brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
min-width: 36px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.brand-logo .app-image-img.is-loaded {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-logo--center {
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md) var(--space-page);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 三端统一页面顶栏标题 */
|
||||
.app-page-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
height: 56px;
|
||||
padding: 0 var(--space-page);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-background);
|
||||
box-shadow: 0 1px 0 rgba(166, 29, 36, 0.05);
|
||||
}
|
||||
|
||||
.app-page-header--fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.app-page-header-action {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: var(--space-page);
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-heritage-red);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.app-page-header-action--end {
|
||||
left: auto;
|
||||
right: var(--space-page);
|
||||
}
|
||||
|
||||
.app-page-header-action:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.header-city { color: var(--color-heritage-red); font-size: 14px; }
|
||||
|
||||
.back-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px;
|
||||
color: var(--color-heritage-red);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--space-gutter) var(--space-page);
|
||||
padding: var(--space-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
border: var(--border-card);
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-label);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.05em;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:active { transform: scale(0.98); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-heritage-red);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-heritage-red);
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.btn-block { width: 100%; display: block; text-align: center; }
|
||||
|
||||
.btn-pill {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: var(--space-md); }
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-card);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px rgba(130, 0, 18, 0.2);
|
||||
}
|
||||
|
||||
/* Badges & status */
|
||||
.benefit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--color-aged-amber);
|
||||
color: var(--color-on-secondary-container);
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(45, 106, 79, 0.1);
|
||||
color: var(--color-success-green);
|
||||
font-family: var(--font-label);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: var(--color-card);
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 4px;
|
||||
font-size: 14px;
|
||||
color: var(--color-subtle-gray);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--color-heritage-red);
|
||||
border-bottom-color: var(--color-heritage-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tabbar (legacy) */
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid rgba(141, 112, 110, 0.3);
|
||||
box-shadow: var(--shadow-tabbar);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tabbar-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
}
|
||||
|
||||
.tabbar-item.active { color: var(--color-heritage-red); }
|
||||
|
||||
/* App tabbar (Material icons) */
|
||||
.app-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid rgba(141, 112, 110, 0.3);
|
||||
box-shadow: var(--shadow-tabbar);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.app-tabbar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-subtle-gray);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.app-tabbar-item.active { color: var(--color-heritage-red); }
|
||||
|
||||
.app-tabbar-icon { font-size: 24px; line-height: 1; }
|
||||
|
||||
.app-tabbar-label {
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Auth page */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
padding: 48px var(--space-page) var(--space-page);
|
||||
background: var(--color-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
color: var(--color-subtle-gray);
|
||||
margin-bottom: var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-msg {
|
||||
color: var(--color-heritage-red);
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Mine profile */
|
||||
.profile-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-heritage-red);
|
||||
color: var(--color-on-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-family: var(--font-headline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-link {
|
||||
display: block;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
}
|
||||
|
||||
.menu-link:last-child { border-bottom: none; }
|
||||
|
||||
.page-actions { padding: var(--space-md) var(--space-page); }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 48px var(--space-md);
|
||||
color: var(--color-subtle-gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Shop-specific */
|
||||
.shop-header-card {
|
||||
background: var(--color-heritage-red);
|
||||
color: var(--color-on-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--space-page);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.scan-btn {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-aged-amber);
|
||||
border: 4px solid var(--color-card);
|
||||
margin: 24px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.coupon-highlight {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-secondary-container);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-on-secondary-container);
|
||||
}
|
||||
|
||||
.code-box {
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 18px;
|
||||
background: var(--color-surface-container-low);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.line-2-clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 var(--space-page);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.page-header-side {
|
||||
width: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header-side--right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page-header-spacer {
|
||||
width: 24px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.page-header-title {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-header-title--spacer {
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Coupon badge (ticket notch) */
|
||||
.coupon-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--color-aged-amber);
|
||||
color: var(--color-on-secondary-container);
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.coupon-badge::before,
|
||||
.coupon-badge::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--color-card);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.coupon-badge::before { left: -4px; }
|
||||
.coupon-badge::after { right: -4px; }
|
||||
|
||||
/* Order status tabs */
|
||||
.order-status-tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
background: var(--color-card);
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.order-status-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.order-status-tab {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-label);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-subtle-gray);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-status-tab.active {
|
||||
color: var(--color-heritage-red);
|
||||
border-bottom-color: var(--color-heritage-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
padding: var(--space-page);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-grabber {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: var(--color-surface-container-highest);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.tag-reship {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(42, 110, 187, 0.1);
|
||||
color: var(--color-status-blue);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* App image loading / placeholder */
|
||||
.app-image {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-container-low);
|
||||
}
|
||||
|
||||
.app-image--fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-image-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.app-image-img--cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-image-img--contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-image-img.is-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-image-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-container-low);
|
||||
color: var(--color-outline);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app-image-fallback-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.app-image-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--color-outline-variant);
|
||||
border-top-color: var(--color-heritage-red);
|
||||
border-radius: 50%;
|
||||
animation: app-image-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes app-image-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.dukang-ptr {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dukang-ptr-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.dukang-ptr-text {
|
||||
padding-bottom: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: #8a8580;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Dukang Hospitality Heritage — design tokens from pages/stitch_4_1.0/DESIGN.md */
|
||||
:root {
|
||||
--color-surface: #faf9f7;
|
||||
--color-surface-dim: #dadad8;
|
||||
--color-surface-container-low: #f4f3f1;
|
||||
--color-surface-container: #efeeec;
|
||||
--color-surface-container-highest: #e3e2e0;
|
||||
--color-on-surface: #1a1c1b;
|
||||
--color-on-surface-variant: #5a413f;
|
||||
--color-outline: #8d706e;
|
||||
--color-outline-variant: #e2bebc;
|
||||
|
||||
--color-primary: #820012;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-heritage-red: #a61d24;
|
||||
--color-on-primary-container: #ffb9b4;
|
||||
|
||||
--color-secondary: #735c00;
|
||||
--color-secondary-container: #fed65b;
|
||||
--color-on-secondary-container: #745c00;
|
||||
--color-aged-amber: #ffbf00;
|
||||
|
||||
--color-success-green: #2d6a4f;
|
||||
--color-status-blue: #2a6ebb;
|
||||
--color-subtle-gray: #999999;
|
||||
--color-ink-black: #1a1a1a;
|
||||
|
||||
--color-error: #ba1a1a;
|
||||
--color-background: #faf9f7;
|
||||
--color-card: #ffffff;
|
||||
|
||||
--font-headline: 'Manrope', 'PingFang SC', sans-serif;
|
||||
--font-body: 'Be Vietnam Pro', 'PingFang SC', sans-serif;
|
||||
--font-label: 'Inter', 'PingFang SC', sans-serif;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--space-page: 20px;
|
||||
--space-gutter: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 32px;
|
||||
|
||||
--shadow-card: 0 4px 20px rgba(166, 29, 36, 0.05);
|
||||
--shadow-tabbar: 0 -4px 20px rgba(0, 0, 0, 0.06);
|
||||
--border-card: 1px solid rgba(166, 29, 36, 0.05);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@dukang/weixin-sdk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
function normalizeBase(base?: string): string {
|
||||
if (!base || base === '/') return '';
|
||||
return base.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** React Router basename(来自 Vite `base`) */
|
||||
export function getRouterBasename(baseUrl?: string): string | undefined {
|
||||
const base = normalizeBase(baseUrl ?? import.meta.env?.BASE_URL);
|
||||
return base || undefined;
|
||||
}
|
||||
|
||||
/** 拼接带 Vite base 前缀的应用内路径(用于 window.location 跳转) */
|
||||
export function toAppPath(path: string, baseUrl?: string): string {
|
||||
const base = normalizeBase(baseUrl ?? import.meta.env?.BASE_URL);
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${base}${normalized}`;
|
||||
}
|
||||
|
||||
export function isOnAppPath(path: string, baseUrl?: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const target = toAppPath(path, baseUrl);
|
||||
return window.location.pathname === target || window.location.pathname.startsWith(`${target}/`);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
/** 非微信内置浏览器时提示(与 C 端 LoginPage 文案一致) */
|
||||
export const WECHAT_INAPP_REQUIRED_MSG = '请在微信内打开以使用微信一键授权';
|
||||
|
||||
const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
|
||||
|
||||
function randomState() {
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
config: WeixinSdkConfig,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': config.clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const token = config.getAccessToken?.();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const apiBase = config.apiBase ?? '/api/v1';
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
/** 获取公众号 OAuth 授权跳转 URL */
|
||||
export async function getWechatOAuthUrl(
|
||||
config: WeixinSdkConfig,
|
||||
redirectUri: string,
|
||||
scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo',
|
||||
): Promise<string> {
|
||||
const state = randomState();
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
const qs = new URLSearchParams({ redirectUri, scope, state });
|
||||
const data = await apiRequest<{ url: string }>(config, `/common/wechat/oauth-url?${qs}`);
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
|
||||
export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise<void> {
|
||||
if (!isWechatBrowser()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const uri = redirectUri ?? window.location.href.split('#')[0];
|
||||
const url = await getWechatOAuthUrl(config, uri);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
/** 小程序 wx.login 获取 code */
|
||||
export function getMiniProgramLoginCode(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.wx?.login) {
|
||||
reject(new Error('wx.login 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.login({
|
||||
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到 code'))),
|
||||
fail: (err) => reject(new Error(err.errMsg || 'wx.login 失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 用 code 完成微信登录(自动识别 H5 / 小程序) */
|
||||
export async function loginWithWechatCode(
|
||||
config: WeixinSdkConfig,
|
||||
code: string,
|
||||
platform?: WechatLoginPlatform,
|
||||
): Promise<WechatLoginResult> {
|
||||
const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5');
|
||||
const loginPath = config.wechatLoginPath ?? '/auth/login/wechat';
|
||||
return apiRequest<WechatLoginResult>(config, loginPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code, platform: resolvedPlatform }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 处理 OAuth 回调 URL 中的 code 参数并登录 */
|
||||
export async function handleWechatOAuthCallback(
|
||||
config: WeixinSdkConfig,
|
||||
searchParams?: URLSearchParams,
|
||||
): Promise<WechatLoginResult | null> {
|
||||
const params = searchParams ?? new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
|
||||
const state = params.get('state');
|
||||
const saved = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (saved && state && saved !== state) {
|
||||
throw new Error('OAuth state 校验失败');
|
||||
}
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
|
||||
return loginWithWechatCode(config, code, 'h5');
|
||||
}
|
||||
|
||||
/** 微信首登绑定手机号 */
|
||||
export async function bindWechatPhone(
|
||||
config: WeixinSdkConfig,
|
||||
payload: { wxSessionKey: string; phone: string; code: string },
|
||||
): Promise<WechatLoginResult> {
|
||||
return apiRequest<WechatLoginResult>(config, '/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */
|
||||
export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise<string> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform !== 'mini') {
|
||||
throw new Error('H5 环境请使用短信验证码绑定手机号');
|
||||
}
|
||||
const phoneCode = await new Promise<string>((resolve, reject) => {
|
||||
if (!window.wx?.getPhoneNumber) {
|
||||
reject(new Error('getPhoneNumber 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.getPhoneNumber({
|
||||
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到手机号 code'))),
|
||||
fail: (err) => reject(new Error(err.errMsg || '获取手机号失败')),
|
||||
});
|
||||
});
|
||||
const data = await apiRequest<{ phone: string }>(config, '/common/wechat/phone-number', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: phoneCode, platform: 'mini' }),
|
||||
});
|
||||
return data.phone;
|
||||
}
|
||||
|
||||
/** 一键微信登录(自动选择 OAuth 或 wx.login) */
|
||||
export async function wechatLogin(config: WeixinSdkConfig): Promise<WechatLoginResult | void> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform === 'mini') {
|
||||
const code = await getMiniProgramLoginCode();
|
||||
return loginWithWechatCode(config, code, 'mini');
|
||||
}
|
||||
if (platform === 'wechat-h5') {
|
||||
await startWechatOAuthLogin(config);
|
||||
return;
|
||||
}
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||
import {
|
||||
ensureJssdkReady,
|
||||
getJssdkSignUrl,
|
||||
initWechatJssdk,
|
||||
normalizeJssdkPageUrl,
|
||||
resetJssdkConfig,
|
||||
} from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type ChooseWechatImageOptions = {
|
||||
count?: number;
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
};
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isChooseImageCancelMessage(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg.trim());
|
||||
}
|
||||
|
||||
/** 将微信 chooseImage / getLocalImgData fail 的 errMsg 转为用户可读文案 */
|
||||
export function formatChooseImageFailMessage(errMsg: string): string {
|
||||
const msg = errMsg.trim() || '无法打开相册';
|
||||
if (isChooseImageCancelMessage(msg)) return '';
|
||||
|
||||
if (/offline verifying|permission value is offline/i.test(msg)) {
|
||||
return '微信权限验证中,请稍候再试或刷新页面后重新选择图片';
|
||||
}
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败,请刷新页面后重试';
|
||||
}
|
||||
|
||||
if (/photo.*denied|photos.*denied|相册.*权限|无法访问相册|无相册权限/i.test(msg)) {
|
||||
return '相册权限未开启,请在 iPhone「设置 → 隐私与安全性 → 照片」中允许微信访问相册';
|
||||
}
|
||||
|
||||
if (/system.*camera|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) {
|
||||
return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头';
|
||||
}
|
||||
|
||||
if (/permission|auth|denied|授权|拒绝/i.test(msg)) {
|
||||
return `微信选图接口未就绪(${msg}),请刷新页面后重试`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function reportChooseImageEvent(
|
||||
config: WeixinSdkConfig,
|
||||
payload: {
|
||||
status: 'fail';
|
||||
errMsg: string;
|
||||
sourceType?: string;
|
||||
stage: 'jssdk' | 'choose' | 'read' | 'empty';
|
||||
},
|
||||
) {
|
||||
if (isChooseImageCancelMessage(payload.errMsg)) return;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': config.clientApp,
|
||||
};
|
||||
const token = config.getAccessToken?.();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
await fetch(`${config.apiBase ?? '/api/v1'}/common/wechat/choose-image`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
status: 'fail',
|
||||
errMsg: payload.errMsg,
|
||||
sourceType: payload.sourceType,
|
||||
stage: payload.stage,
|
||||
pageUrl: typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : undefined,
|
||||
signUrl: typeof window !== 'undefined' ? getJssdkSignUrl() : undefined,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* 上报失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
function base64ToFile(base64: string, fileName: string): File {
|
||||
const normalized = base64.startsWith('data:')
|
||||
? base64
|
||||
: `data:image/jpeg;base64,${base64.replace(/\s/g, '')}`;
|
||||
const [header, body] = normalized.split(',');
|
||||
const mime = header.match(/:(.*?);/)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(body);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new File([bytes], fileName, { type: mime });
|
||||
}
|
||||
|
||||
function localIdToFile(localId: string): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.wx?.getLocalImgData) {
|
||||
reject(new Error('微信 getLocalImgData 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.getLocalImgData({
|
||||
localId,
|
||||
success: (res) => {
|
||||
try {
|
||||
resolve(base64ToFile(res.localData, `wx-${Date.now()}.jpg`));
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error('图片解析失败'));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
const raw = err.errMsg || '读取图片失败';
|
||||
const formatted = formatChooseImageFailMessage(raw);
|
||||
reject(new Error(formatted || raw));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkJsApi(apiName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.wx?.checkJsApi) {
|
||||
resolve(!!window.wx?.chooseImage);
|
||||
return;
|
||||
}
|
||||
window.wx.checkJsApi({
|
||||
jsApiList: [apiName],
|
||||
success: (res) => resolve(!!res.checkResult?.[apiName]),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureChooseImageJssdk(config: WeixinSdkConfig): Promise<void> {
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
|
||||
await ensureJssdkReady(jssdkOptions);
|
||||
|
||||
let ready = await checkJsApi('chooseImage');
|
||||
if (!ready) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
ready = await checkJsApi('chooseImage');
|
||||
}
|
||||
if (!ready) {
|
||||
throw new Error('微信选图接口未授权,请刷新页面后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function isPermissionDeniedError(msg: string): boolean {
|
||||
return /permission|denied|invalid signature|config:fail|signature|接口未就绪|offline verifying/i.test(msg);
|
||||
}
|
||||
|
||||
function invokeChooseImage(
|
||||
count: number,
|
||||
sourceType: Array<'album' | 'camera'>,
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(hangTimer);
|
||||
fn();
|
||||
};
|
||||
|
||||
// 极端机型取消后无回调:最长等待后按取消解锁,避免业务侧 uploading 永久卡住
|
||||
const hangTimer = window.setTimeout(() => {
|
||||
finish(() => resolve([]));
|
||||
}, 180_000);
|
||||
|
||||
window.wx!.chooseImage!({
|
||||
count,
|
||||
sizeType: ['compressed'],
|
||||
sourceType,
|
||||
success: (res) => finish(() => resolve(res.localIds ?? [])),
|
||||
fail: (err) => {
|
||||
const raw = err.errMsg || '无法打开相册';
|
||||
if (isChooseImageCancelMessage(raw)) {
|
||||
finish(() => resolve([]));
|
||||
return;
|
||||
}
|
||||
const formatted = formatChooseImageFailMessage(raw);
|
||||
finish(() => reject(new Error(formatted || raw)));
|
||||
},
|
||||
// 部分微信版本取消只走 complete
|
||||
complete: () => {
|
||||
window.setTimeout(() => {
|
||||
finish(() => resolve([]));
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 微信内选图(相册/拍照),返回 File 列表;非微信环境返回 null */
|
||||
export async function chooseWechatImages(
|
||||
config: WeixinSdkConfig,
|
||||
options: ChooseWechatImageOptions = {},
|
||||
): Promise<File[] | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform === 'browser') return null;
|
||||
|
||||
const count = options.count ?? 1;
|
||||
const sourceType = options.sourceType ?? ['album', 'camera'];
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
const sourceTypeKey = sourceType.join(',');
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
try {
|
||||
await ensureChooseImageJssdk(config);
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : 'JSSDK 初始化失败';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' });
|
||||
throw e;
|
||||
}
|
||||
if (!window.wx?.chooseImage) {
|
||||
const errMsg = '微信选图接口不可用,请刷新页面后重试';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' });
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
if (isIosDevice() && !isWechatDevTools()) {
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
let localIds: string[] = [];
|
||||
const maxAttempts = 2;
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
if (lastError && isPermissionDeniedError(lastError.message)) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
}
|
||||
await delay(800);
|
||||
}
|
||||
try {
|
||||
localIds = await invokeChooseImage(count, sourceType);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e : new Error('无法打开相册');
|
||||
const raw = lastError.message;
|
||||
const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw) || isPermissionDeniedError(raw);
|
||||
if (attempt < maxAttempts - 1 && retryable) continue;
|
||||
void reportChooseImageEvent(config, {
|
||||
status: 'fail',
|
||||
errMsg: raw,
|
||||
sourceType: sourceTypeKey,
|
||||
stage: 'choose',
|
||||
});
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
|
||||
if (!localIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: File[] = [];
|
||||
for (const localId of localIds) {
|
||||
try {
|
||||
files.push(await localIdToFile(localId));
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : '读取图片失败';
|
||||
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'read' });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canUseWechatChooseImage(): boolean {
|
||||
return getRuntimePlatform() !== 'browser';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** 是否 iOS 设备(不含微信开发者工具模拟器特殊处理) */
|
||||
export function isIosDevice(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as Window & { MSStream?: unknown }).MSStream;
|
||||
}
|
||||
|
||||
/** 是否微信开发者工具 */
|
||||
export function isWechatDevTools(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /wechatdevtools/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/** 是否微信内置浏览器 */
|
||||
export function isWechatBrowser(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/** 是否微信小程序 web-view 或独立小程序环境 */
|
||||
export function isMiniProgram(): boolean {
|
||||
const g = globalThis as typeof globalThis & { wx?: { requestPayment?: unknown } };
|
||||
if (g.wx?.requestPayment) return true;
|
||||
if (typeof window === 'undefined') return false;
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
return ua.includes('miniprogram') || (window as Window & { __wxjs_environment?: string }).__wxjs_environment === 'miniprogram';
|
||||
}
|
||||
|
||||
export function getRuntimePlatform(): 'mini' | 'wechat-h5' | 'browser' {
|
||||
if (isMiniProgram()) return 'mini';
|
||||
if (isWechatBrowser()) return 'wechat-h5';
|
||||
return 'browser';
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
||||
export { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
|
||||
export {
|
||||
initWechatJssdk,
|
||||
ensureJssdkReady,
|
||||
isJssdkReady,
|
||||
normalizeJssdkPageUrl,
|
||||
getJssdkSignUrl,
|
||||
captureIosJssdkEntryUrl,
|
||||
resetJssdkConfig,
|
||||
stripOAuthParamsFromLocation,
|
||||
} from './jssdk';
|
||||
export { formatScanFailMessage } from './scan';
|
||||
export {
|
||||
getWechatLocation,
|
||||
getWechatLocationDetailed,
|
||||
canUseWechatLocation,
|
||||
isWechatEnv,
|
||||
} from './location';
|
||||
export type { WechatLocationOutcome } from './location';
|
||||
export { scanQrCode } from './scan';
|
||||
export { invokeWechatPay } from './pay';
|
||||
export { chooseWechatImages, canUseWechatChooseImage, formatChooseImageFailMessage } from './chooseImage';
|
||||
export type { ChooseWechatImageOptions } from './chooseImage';
|
||||
export {
|
||||
setWechatShareData,
|
||||
canUseWechatShare,
|
||||
getWechatShareLink,
|
||||
shareViaWechatSdk,
|
||||
tryInvokeSharePanel,
|
||||
} from './share';
|
||||
export type { WechatShareData } from './share';
|
||||
export {
|
||||
getWechatOAuthUrl,
|
||||
startWechatOAuthLogin,
|
||||
getMiniProgramLoginCode,
|
||||
loginWithWechatCode,
|
||||
handleWechatOAuthCallback,
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
wechatLogin,
|
||||
WECHAT_INAPP_REQUIRED_MSG,
|
||||
} from './auth';
|
||||
export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
|
||||
export { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
import { initWechatJssdk, resetJssdkConfig } from './jssdk';
|
||||
import { getWechatLocation, getWechatLocationDetailed } from './location';
|
||||
import { scanQrCode } from './scan';
|
||||
import { invokeWechatPay } from './pay';
|
||||
import { chooseWechatImages } from './chooseImage';
|
||||
import { setWechatShareData, shareViaWechatSdk } from './share';
|
||||
import {
|
||||
wechatLogin,
|
||||
handleWechatOAuthCallback,
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
} from './auth';
|
||||
|
||||
/** 微信 SDK 门面(按端注入 apiBase / clientApp) */
|
||||
export function createWeixinSdk(config: WeixinSdkConfig) {
|
||||
return {
|
||||
init: () => initWechatJssdk({ ...config, apiBase: config.apiBase ?? '/api/v1' }),
|
||||
reset: () => resetJssdkConfig(),
|
||||
login: () => wechatLogin(config),
|
||||
handleOAuthCallback: (params?: URLSearchParams) => handleWechatOAuthCallback(config, params),
|
||||
bindPhone: (payload: { wxSessionKey: string; phone: string; code: string }) =>
|
||||
bindWechatPhone(config, payload),
|
||||
getPhoneNumber: () => getWechatPhoneNumber(config),
|
||||
getLocation: () => getWechatLocation(config),
|
||||
getLocationDetailed: () => getWechatLocationDetailed(config),
|
||||
scanQrCode: (options?: Parameters<typeof scanQrCode>[1]) => scanQrCode(config, options),
|
||||
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
||||
chooseWechatImages(config, options),
|
||||
pay: (prepay: Parameters<typeof invokeWechatPay>[0]) =>
|
||||
invokeWechatPay(prepay, {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
}),
|
||||
setShare: (data: Parameters<typeof setWechatShareData>[1]) =>
|
||||
setWechatShareData(config, data),
|
||||
share: (data: Parameters<typeof shareViaWechatSdk>[1]) => shareViaWechatSdk(config, data),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
||||
import { isWechatBrowser, isWechatDevTools } from './env';
|
||||
import { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let configured = false;
|
||||
let configuredUrl: string | null = null;
|
||||
|
||||
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
|
||||
export function resetJssdkConfig(): void {
|
||||
configured = false;
|
||||
configuredUrl = null;
|
||||
}
|
||||
|
||||
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
|
||||
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
url.hash = '';
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
const noHash = rawUrl.split('#')[0];
|
||||
try {
|
||||
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
return noHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function signUrlChanged(prev: string | null, current: string): boolean {
|
||||
return prev !== current;
|
||||
}
|
||||
|
||||
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isWechatBrowser() || isWechatDevTools()) return;
|
||||
const current = normalizeJssdkPageUrl(window.location.href);
|
||||
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
|
||||
if (!existing) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
return;
|
||||
}
|
||||
if (signUrlChanged(existing, current)) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
resetJssdkConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */
|
||||
export function getJssdkSignUrl(rawUrl?: string): string {
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
|
||||
}
|
||||
|
||||
function isJssdkDebugEnabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('wxdebug') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function stripOAuthParamsFromLocation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
const next = `${url.pathname}${query ? `?${query}` : ''}${url.hash}`;
|
||||
window.history.replaceState({}, '', next);
|
||||
}
|
||||
|
||||
function loadScript(): Promise<void> {
|
||||
if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境'));
|
||||
if (window.wx) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[data-dukang-wx-jssdk]');
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败')));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = JSSDK_URL;
|
||||
script.async = true;
|
||||
script.dataset.dukangWxJssdk = '1';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('微信 JSSDK 加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) {
|
||||
const headers: Record<string, string> = { 'X-Client-App': clientApp };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const qs = new URLSearchParams({ url });
|
||||
const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败');
|
||||
return json.data as WechatJssdkConfig;
|
||||
}
|
||||
|
||||
/** 初始化微信 JSSDK(H5 公众号内) */
|
||||
export async function initWechatJssdk(options: {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
url?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const { apiBase, clientApp, getAccessToken } = options;
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
await loadScript();
|
||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||
|
||||
const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.());
|
||||
const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST];
|
||||
|
||||
configured = false;
|
||||
configuredUrl = null;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
window.wx!.config({
|
||||
...config,
|
||||
jsApiList,
|
||||
debug: isJssdkDebugEnabled(),
|
||||
});
|
||||
window.wx!.ready(() => {
|
||||
configured = true;
|
||||
configuredUrl = pageUrl;
|
||||
resolve();
|
||||
});
|
||||
window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败')));
|
||||
});
|
||||
}
|
||||
|
||||
export function isJssdkReady(): boolean {
|
||||
const signUrl = getJssdkSignUrl();
|
||||
return configured && !!window.wx && configuredUrl === signUrl;
|
||||
}
|
||||
|
||||
export async function ensureJssdkReady(options: {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
url?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
|
||||
resetJssdkConfig();
|
||||
}
|
||||
if (!isJssdkReady()) {
|
||||
await initWechatJssdk({ ...options, url: pageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
captureIosJssdkEntryUrl();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { WechatGpsLocation } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type WechatLocationOutcome = {
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
location: WechatGpsLocation | null;
|
||||
errMsg?: string;
|
||||
};
|
||||
|
||||
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
||||
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
|
||||
const outcome = await getWechatLocationDetailed(config);
|
||||
return outcome.location;
|
||||
}
|
||||
|
||||
export async function getWechatLocationDetailed(
|
||||
config?: WeixinSdkConfig,
|
||||
): Promise<WechatLocationOutcome> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.getLocation) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5' && config) {
|
||||
try {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
if (window.wx?.getLocation) {
|
||||
const jsApiOk = await new Promise<boolean>((resolve) => {
|
||||
window.wx!.checkJsApi?.({
|
||||
jsApiList: ['getLocation'],
|
||||
success: (res) => resolve(!!res.checkResult?.getLocation),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!jsApiOk) {
|
||||
return { sdk: 'jssdk', location: null, errMsg: 'getLocation 未授权或不可用' };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||
});
|
||||
});
|
||||
}
|
||||
return { sdk: 'jssdk', location: null, errMsg: '微信 JSSDK getLocation 不可用' };
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return { sdk: 'jssdk', location: null, errMsg };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
return { sdk: 'geolocation', location: null, errMsg: '浏览器不支持定位' };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({
|
||||
sdk: 'geolocation',
|
||||
location: {
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
},
|
||||
}),
|
||||
(err) =>
|
||||
resolve({
|
||||
sdk: 'geolocation',
|
||||
location: null,
|
||||
errMsg: err.message || '定位失败',
|
||||
}),
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function canUseWechatLocation(): boolean {
|
||||
return getRuntimePlatform() !== 'browser' || typeof navigator?.geolocation !== 'undefined';
|
||||
}
|
||||
|
||||
export function isWechatEnv(): boolean {
|
||||
return isWechatBrowser() || getRuntimePlatform() === 'mini';
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { MiniProgramWx } from './types';
|
||||
|
||||
export type WechatPayInvokeConfig = {
|
||||
apiBase?: string;
|
||||
clientApp?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
/** 显式指定运行端;Taro weapp 须传 mini */
|
||||
platform?: 'mini' | 'wechat-h5' | 'browser';
|
||||
};
|
||||
|
||||
/** wx.chooseWXPay 要求 timestamp 小写;服务端签名与 Bridge 使用 timeStamp */
|
||||
function toChooseWxPayOptions(prepay: WechatJsapiPrepayParams) {
|
||||
return {
|
||||
timestamp: prepay.timeStamp,
|
||||
nonceStr: prepay.nonceStr,
|
||||
package: prepay.package,
|
||||
signType: prepay.signType,
|
||||
paySign: prepay.paySign,
|
||||
};
|
||||
}
|
||||
|
||||
function getMiniProgramWx(): MiniProgramWx | undefined {
|
||||
const g = globalThis as typeof globalThis & { wx?: MiniProgramWx };
|
||||
if (g.wx?.requestPayment) return g.wx;
|
||||
if (typeof window !== 'undefined' && window.wx?.requestPayment) return window.wx;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function invokeBridgePay(params: WechatJsapiPrepayParams): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bridge = window.WeixinJSBridge;
|
||||
if (!bridge) {
|
||||
reject(new Error('WeixinJSBridge 不可用'));
|
||||
return;
|
||||
}
|
||||
bridge.invoke(
|
||||
'getBrandWCPayRequest',
|
||||
{
|
||||
appId: params.appId,
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign,
|
||||
},
|
||||
(res) => {
|
||||
const msg = res.err_msg ?? '';
|
||||
if (msg.includes('ok')) resolve();
|
||||
else if (msg.includes('cancel')) reject(new Error('用户取消支付'));
|
||||
else reject(new Error(msg || '支付失败'));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWeixinBridge(): Promise<void> {
|
||||
if (window.WeixinJSBridge) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (window.WeixinJSBridge) resolve();
|
||||
else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function invokeMiniProgramPay(prepay: WechatJsapiPrepayParams): Promise<void> {
|
||||
const wxApi = getMiniProgramWx();
|
||||
if (!wxApi?.requestPayment) {
|
||||
return Promise.reject(new Error('当前环境不支持微信支付'));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
wxApi.requestPayment!({
|
||||
timeStamp: prepay.timeStamp,
|
||||
nonceStr: prepay.nonceStr,
|
||||
package: prepay.package,
|
||||
signType: prepay.signType,
|
||||
paySign: prepay.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (err) => {
|
||||
const msg = err.errMsg || '支付失败';
|
||||
if (msg.includes('cancel')) reject(new Error('用户取消支付'));
|
||||
else reject(new Error(msg));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 调起微信支付 */
|
||||
export async function invokeWechatPay(
|
||||
prepay: WechatJsapiPrepayParams,
|
||||
config?: WechatPayInvokeConfig,
|
||||
): Promise<void> {
|
||||
const platform = config?.platform ?? getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini') {
|
||||
return invokeMiniProgramPay(prepay);
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
if (config?.clientApp) {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
url: typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined,
|
||||
jsApiList: ['chooseWXPay'],
|
||||
});
|
||||
}
|
||||
|
||||
// Bridge 参数与商户签名一致,优先于 chooseWXPay
|
||||
await waitForWeixinBridge();
|
||||
if (window.WeixinJSBridge) {
|
||||
return invokeBridgePay(prepay);
|
||||
}
|
||||
|
||||
if (window.wx?.chooseWXPay) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.chooseWXPay!({
|
||||
...toChooseWxPayOptions(prepay),
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(new Error(err.errMsg || '支付失败')),
|
||||
cancel: () => reject(new Error('用户取消支付')),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('当前环境不支持微信支付');
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||
import {
|
||||
ensureJssdkReady,
|
||||
getJssdkSignUrl,
|
||||
initWechatJssdk,
|
||||
resetJssdkConfig,
|
||||
} from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 将微信 scanQRCode fail 的 errMsg 转为用户可读文案(避免误判为系统相机权限) */
|
||||
export function formatScanFailMessage(errMsg: string): string {
|
||||
const msg = errMsg.trim() || '扫码失败';
|
||||
if (/cancel/i.test(msg)) return '';
|
||||
|
||||
// iOS / 授权回调后常见:JSSDK 权限离线校验尚未完成,并非系统相机权限
|
||||
if (/offline verifying|permission value is offline/i.test(msg)) {
|
||||
return '微信权限验证中,请稍候再试或刷新页面后重新扫码';
|
||||
}
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败,请刷新页面后重试';
|
||||
}
|
||||
|
||||
// 系统级相机权限(iPhone 设置 → 隐私 → 相机 → 微信)
|
||||
if (/system.*camera|NotAllowedError|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) {
|
||||
return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头';
|
||||
}
|
||||
|
||||
// JSSDK 接口授权失败(含 permission,但不等于相机权限)
|
||||
if (/permission|auth|denied|授权|拒绝/i.test(msg)) {
|
||||
return `微信扫码接口未就绪(${msg}),请刷新页面后重试`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
function isScanPermissionWarmupError(msg: string): boolean {
|
||||
return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
|
||||
msg,
|
||||
);
|
||||
}
|
||||
|
||||
function checkJsApi(apiName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.wx?.checkJsApi) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
window.wx.checkJsApi({
|
||||
jsApiList: [apiName],
|
||||
success: (res) => resolve(!!res.checkResult?.[apiName]),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureScanJssdk(config: WeixinSdkConfig): Promise<void> {
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['scanQRCode', 'checkJsApi'],
|
||||
};
|
||||
|
||||
await ensureJssdkReady(jssdkOptions);
|
||||
|
||||
let ready = await checkJsApi('scanQRCode');
|
||||
if (!ready) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
ready = await checkJsApi('scanQRCode');
|
||||
}
|
||||
if (!ready) {
|
||||
throw new Error('微信扫码接口未授权,请刷新页面后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function invokeScanQrCode(): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.scanQRCode!({
|
||||
needResult: 1,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => resolve(res.resultStr || null),
|
||||
fail: (res) => {
|
||||
const msg = res.errMsg || '扫码失败';
|
||||
if (/cancel/i.test(msg)) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const formatted = formatScanFailMessage(msg);
|
||||
reject(new Error(formatted || msg));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type ScanQrCodeOptions = {
|
||||
/** OAuth / 首次绑定后权限离线校验更慢,加长预热 */
|
||||
postAuthWarmup?: boolean;
|
||||
};
|
||||
|
||||
/** 调起扫码(返回二维码/条码内容,取消返回 null) */
|
||||
export async function scanQrCode(
|
||||
config: WeixinSdkConfig,
|
||||
options: ScanQrCodeOptions = {},
|
||||
): Promise<string | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.scanCode) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.scanCode!({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => resolve(res.result || null),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
const jssdkOptions = {
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
jsApiList: ['scanQRCode', 'checkJsApi'],
|
||||
};
|
||||
|
||||
await ensureScanJssdk(config);
|
||||
if (!window.wx?.scanQRCode) {
|
||||
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
||||
}
|
||||
|
||||
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显
|
||||
const warmupMs = options.postAuthWarmup
|
||||
? 1200
|
||||
: isIosDevice() && !isWechatDevTools()
|
||||
? 800
|
||||
: 300;
|
||||
await delay(warmupMs);
|
||||
|
||||
const maxAttempts = options.postAuthWarmup ? 3 : 2;
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
if (lastError && isScanPermissionWarmupError(lastError.message)) {
|
||||
resetJssdkConfig();
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
await checkJsApi('scanQRCode');
|
||||
}
|
||||
await delay(700 + attempt * 500);
|
||||
}
|
||||
try {
|
||||
return await invokeScanQrCode();
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e : new Error('扫码失败');
|
||||
const retryable = isScanPermissionWarmupError(lastError.message);
|
||||
if (attempt < maxAttempts - 1 && retryable) continue;
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('扫码失败');
|
||||
}
|
||||
|
||||
throw new Error('请在微信内打开以使用扫码核销');
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
export type WechatShareData = {
|
||||
title: string;
|
||||
desc: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
};
|
||||
|
||||
export function canUseWechatShare(): boolean {
|
||||
return isWechatBrowser() || getRuntimePlatform() === 'mini';
|
||||
}
|
||||
|
||||
function applyShareData(data: WechatShareData): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wx = window.wx as
|
||||
| (NonNullable<typeof window.wx> & {
|
||||
onMenuShareAppMessage?: (opts: Record<string, unknown>) => void;
|
||||
onMenuShareTimeline?: (opts: Record<string, unknown>) => void;
|
||||
invoke?: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb?: (res: { err_msg?: string; errMsg?: string }) => void,
|
||||
) => void;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
if (!wx) {
|
||||
reject(new Error('微信 JSSDK 不可用'));
|
||||
return;
|
||||
}
|
||||
|
||||
let pending = 0;
|
||||
let failed = false;
|
||||
const done = (err?: Error) => {
|
||||
if (err && !failed) {
|
||||
failed = true;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
pending -= 1;
|
||||
if (pending === 0 && !failed) resolve();
|
||||
};
|
||||
|
||||
const payload = {
|
||||
title: data.title,
|
||||
desc: data.desc,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
};
|
||||
|
||||
if (wx.updateAppMessageShareData) {
|
||||
pending += 1;
|
||||
wx.updateAppMessageShareData({
|
||||
...payload,
|
||||
success: () => done(),
|
||||
fail: (res) => done(new Error(res.errMsg || '设置分享给朋友失败')),
|
||||
});
|
||||
}
|
||||
|
||||
if (wx.updateTimelineShareData) {
|
||||
pending += 1;
|
||||
wx.updateTimelineShareData({
|
||||
title: data.title,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
success: () => done(),
|
||||
fail: (res) => done(new Error(res.errMsg || '设置分享到朋友圈失败')),
|
||||
});
|
||||
}
|
||||
|
||||
// 兼容旧微信:同时挂旧版菜单分享(设置即生效,无 success 回调也可继续)
|
||||
if (wx.onMenuShareAppMessage) {
|
||||
wx.onMenuShareAppMessage({
|
||||
...payload,
|
||||
success: () => {},
|
||||
cancel: () => {},
|
||||
});
|
||||
}
|
||||
if (wx.onMenuShareTimeline) {
|
||||
wx.onMenuShareTimeline({
|
||||
title: data.title,
|
||||
link: data.link,
|
||||
imgUrl: data.imgUrl,
|
||||
success: () => {},
|
||||
cancel: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
if (pending === 0) {
|
||||
if (wx.onMenuShareAppMessage || wx.onMenuShareTimeline) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error('当前微信版本不支持分享,请升级微信后重试'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 尽量调起系统分享面板(部分微信版本支持;失败则仅完成卡片配置) */
|
||||
export function tryInvokeSharePanel(data: WechatShareData): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const wx = window.wx as
|
||||
| {
|
||||
invoke?: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb?: (res: { err_msg?: string; errMsg?: string }) => void,
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
if (!wx?.invoke) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wx.invoke(
|
||||
'shareAppMessage',
|
||||
{
|
||||
title: data.title,
|
||||
desc: data.desc,
|
||||
link: data.link,
|
||||
img_url: data.imgUrl,
|
||||
imgUrl: data.imgUrl,
|
||||
},
|
||||
(res) => {
|
||||
const msg = `${res?.err_msg || res?.errMsg || ''}`;
|
||||
resolve(/:ok\b/i.test(msg));
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 配置微信内分享卡片(好友 / 朋友圈) */
|
||||
export async function setWechatShareData(
|
||||
config: WeixinSdkConfig,
|
||||
data: WechatShareData,
|
||||
): Promise<void> {
|
||||
if (!canUseWechatShare()) return;
|
||||
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
|
||||
await applyShareData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置分享并尝试调起分享面板。
|
||||
* @returns invoked=true 表示已弹出系统分享;否则需引导用户点右上角
|
||||
*/
|
||||
export async function shareViaWechatSdk(
|
||||
config: WeixinSdkConfig,
|
||||
data: WechatShareData,
|
||||
): Promise<{ invoked: boolean }> {
|
||||
await setWechatShareData(config, data);
|
||||
const invoked = await tryInvokeSharePanel(data);
|
||||
return { invoked };
|
||||
}
|
||||
|
||||
/** 获取当前页分享链接(去掉 hash、OAuth 回跳参数) */
|
||||
export function getWechatShareLink(rawUrl?: string): string {
|
||||
if (typeof window === 'undefined') return rawUrl ?? '';
|
||||
return normalizeJssdkPageUrl(rawUrl ?? window.location.href);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { WechatGpsLocation, WechatJsapiPrepayParams, WechatJssdkConfig } from '@dukang/shared-types';
|
||||
|
||||
export type WeixinSdkConfig = {
|
||||
/** API 根路径,默认 /api/v1 */
|
||||
apiBase?: string;
|
||||
/** X-Client-App 请求头 */
|
||||
clientApp: string;
|
||||
/** 获取 access token(可选,登录后自动带) */
|
||||
getAccessToken?: () => string | null;
|
||||
/** 微信 code 登录接口,默认 /auth/login/wechat */
|
||||
wechatLoginPath?: string;
|
||||
};
|
||||
|
||||
export type WxInvokeResult<T> = {
|
||||
errMsg: string;
|
||||
} & T;
|
||||
|
||||
export type WxApi = {
|
||||
config: (options: WechatJssdkConfig & { debug?: boolean }) => void;
|
||||
ready: (cb: () => void) => void;
|
||||
error: (cb: (res: { errMsg: string }) => void) => void;
|
||||
checkJsApi: (options: {
|
||||
jsApiList: string[];
|
||||
success?: (res: { checkResult?: Record<string, boolean> }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getLocation: (options: {
|
||||
type?: string;
|
||||
success?: (res: WechatGpsLocation) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
scanQRCode: (options: {
|
||||
needResult?: 0 | 1;
|
||||
scanType?: Array<'qrCode' | 'barCode'>;
|
||||
success?: (res: { resultStr: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
chooseWXPay: (options: {
|
||||
timestamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
cancel?: () => void;
|
||||
}) => void;
|
||||
chooseImage: (options: {
|
||||
count?: number;
|
||||
sizeType?: Array<'original' | 'compressed'>;
|
||||
sourceType?: Array<'album' | 'camera'>;
|
||||
success?: (res: { localIds: string[] }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
complete?: (res?: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
getLocalImgData: (options: {
|
||||
localId: string;
|
||||
success?: (res: { localData: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
updateAppMessageShareData: (options: {
|
||||
title: string;
|
||||
desc: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
updateTimelineShareData: (options: {
|
||||
title: string;
|
||||
link: string;
|
||||
imgUrl: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type MiniProgramWx = {
|
||||
login: (options: {
|
||||
success?: (res: { code: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getPhoneNumber: (options: {
|
||||
success?: (res: { code: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getLocation: WxApi['getLocation'];
|
||||
scanCode: (options: {
|
||||
onlyFromCamera?: boolean;
|
||||
scanType?: Array<'qrCode' | 'barCode'>;
|
||||
success?: (res: { result: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
requestPayment: (options: {
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wx?: WxApi & MiniProgramWx;
|
||||
WeixinJSBridge?: {
|
||||
invoke: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb: (res: { err_msg?: string }) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_JS_API_LIST = [
|
||||
'getLocation',
|
||||
'scanQRCode',
|
||||
'chooseWXPay',
|
||||
'chooseImage',
|
||||
'getLocalImgData',
|
||||
'updateAppMessageShareData',
|
||||
'updateTimelineShareData',
|
||||
'onMenuShareAppMessage',
|
||||
'onMenuShareTimeline',
|
||||
] as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2020", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user