@@ -4,27 +4,41 @@ export default defineAppConfig({
|
||||
'pages/stores/index',
|
||||
'pages/benefit/index',
|
||||
'pages/mine/index',
|
||||
'pages/product-detail/index',
|
||||
'pages/store-detail/index',
|
||||
'pages/store-package-detail/index',
|
||||
'pages/order-confirm/index',
|
||||
'pages/order-confirm-pickup/index',
|
||||
'pages/pay/index',
|
||||
'pages/orders/index',
|
||||
'pages/order-detail/index',
|
||||
'pages/order-logistics/index',
|
||||
'pages/pickup-receive/index',
|
||||
'pages/addresses/index',
|
||||
'pages/address-edit/index',
|
||||
'pages/customer-service/index',
|
||||
'pages/benefit-detail/index',
|
||||
'pages/redeem/index',
|
||||
'pages/redeem-code/index',
|
||||
'pages/redeem-success/index',
|
||||
'pages/login/index',
|
||||
'pages/user-agreement/index',
|
||||
'pages/privacy-policy/index',
|
||||
],
|
||||
subPackages: [
|
||||
{ root: 'pages/product-detail', pages: ['index'] },
|
||||
{ root: 'pages/store-detail', pages: ['index'] },
|
||||
{ root: 'pages/store-package-detail', pages: ['index'] },
|
||||
{ root: 'pages/order-confirm', pages: ['index'] },
|
||||
{ root: 'pages/order-confirm-pickup', pages: ['index'] },
|
||||
{ root: 'pages/pay', pages: ['index'] },
|
||||
{ root: 'pages/orders', pages: ['index'] },
|
||||
{ root: 'pages/order-detail', pages: ['index'] },
|
||||
{ root: 'pages/order-logistics', pages: ['index'] },
|
||||
{ root: 'pages/pickup-receive', pages: ['index'] },
|
||||
{ root: 'pages/addresses', pages: ['index'] },
|
||||
{ root: 'pages/address-edit', pages: ['index'] },
|
||||
{ root: 'pages/customer-service', pages: ['index'] },
|
||||
{ root: 'pages/benefit-detail', pages: ['index'] },
|
||||
{ root: 'pages/redeem', pages: ['index'] },
|
||||
{ root: 'pages/redeem-code', pages: ['index'] },
|
||||
{ root: 'pages/redeem-success', pages: ['index'] },
|
||||
{ root: 'pages/login', pages: ['index'] },
|
||||
{ root: 'pages/user-agreement', pages: ['index'] },
|
||||
{ root: 'pages/privacy-policy', pages: ['index'] },
|
||||
{ root: 'pages/invoice-titles', pages: ['index'] },
|
||||
{ root: 'pages/invoice-apply', pages: ['index'] },
|
||||
],
|
||||
preloadRule: {
|
||||
'pages/home/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/product-detail', 'pages/login'],
|
||||
},
|
||||
'pages/mine/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/orders', 'pages/login'],
|
||||
},
|
||||
},
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#FAF9F7',
|
||||
|
||||
@@ -5,13 +5,7 @@
|
||||
@import './styles/stores.css';
|
||||
@import './styles/benefit.css';
|
||||
@import './styles/mine.css';
|
||||
@import './styles/product-detail.css';
|
||||
@import './styles/store-detail.css';
|
||||
@import './styles/login.css';
|
||||
@import './styles/legal.css';
|
||||
@import './styles/order.css';
|
||||
@import './styles/address.css';
|
||||
@import './styles/redeem.css';
|
||||
|
||||
page,
|
||||
body {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import './lib/intl-polyfill';
|
||||
import './lib/text-encoding-polyfill';
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import './lib/intl-polyfill';
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||
import { installClientErrorReporting } from './lib/client-error';
|
||||
import { prefetchShareBrandAssets } from './lib/wechat-share';
|
||||
import { capturePromoSceneAndTouchScan } from './lib/promo';
|
||||
import { initClientVersionChecks } from './lib/client-version';
|
||||
import './app.css';
|
||||
|
||||
installClientErrorReporting();
|
||||
prefetchShareBrandAssets();
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
const handlingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void capturePromoSceneAndTouchScan();
|
||||
initClientVersionChecks();
|
||||
}, []);
|
||||
|
||||
useDidShow((options?: {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
|
||||
};
|
||||
}) => {
|
||||
if (handlingRef.current) return;
|
||||
|
||||
const referrerInfo =
|
||||
options?.referrerInfo ||
|
||||
(typeof Taro.getEnterOptionsSync === 'function'
|
||||
? (
|
||||
Taro.getEnterOptionsSync() as {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).referrerInfo
|
||||
: undefined);
|
||||
if (!referrerInfo?.appId) return;
|
||||
|
||||
handlingRef.current = true;
|
||||
void handleWechatOrderConfirmShow({ referrerInfo })
|
||||
.then((result) => {
|
||||
if (result.redirectUrl) {
|
||||
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
|
||||
Taro.reLaunch({ url: result.redirectUrl! });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!result.handled || !result.orderId) return;
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
if (route.includes('pickup-receive')) {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
handlingRef.current = false;
|
||||
});
|
||||
});
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 828 B |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 198 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { type PageSharePayload } from '../lib/wechat-share';
|
||||
|
||||
/** 小程序:开启右上角分享菜单(不引入 H5 JSSDK) */
|
||||
export default function WechatShareReady({ payload: _payload }: { payload?: PageSharePayload }) {
|
||||
useDidShow(() => {
|
||||
void Taro.showShareMenu({
|
||||
withShareTicket: true,
|
||||
showShareItems: ['shareAppMessage', 'shareTimeline'],
|
||||
}).catch(() => {
|
||||
void Taro.showShareMenu({ withShareTicket: true }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
/* weapp 无 JSSDK 入场 URL */
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||
try {
|
||||
const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
||||
Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
return { latitude: loc.latitude, longitude: loc.longitude };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { goLogin } from './auth-nav';
|
||||
import { isLoggedIn } from './api';
|
||||
import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat';
|
||||
|
||||
export async function ensurePayReady(returnPath: string): Promise<boolean> {
|
||||
if (!isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
if (!needsWechatAuthForPay(config, profile)) {
|
||||
return true;
|
||||
}
|
||||
goLogin(returnPath, { needWechat: '1' });
|
||||
return false;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '';
|
||||
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
ClientRuntimeConfig,
|
||||
WechatJsapiPrepayParams,
|
||||
WechatLoginResult,
|
||||
WechatPayOrderResult,
|
||||
} from '@dukang/shared-types';
|
||||
import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request, saveAuth, type UserProfile } from './api';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
export function isMiniWechatEnv(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isWechatAuthRequiredError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return request<UserProfile>('/auth/me');
|
||||
}
|
||||
|
||||
export function needsWechatAuthForPay(
|
||||
config: ClientRuntimeConfig,
|
||||
profile: UserProfile | null,
|
||||
): boolean {
|
||||
if (!isWxAuthorizeEnabled(config)) return false;
|
||||
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
|
||||
}
|
||||
|
||||
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveAuth({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
|
||||
throw new Error('请使用小程序微信授权');
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function waitOrderPaid(orderId: string, maxAttempts = 15): Promise<boolean> {
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const order = await request<{ payStatus?: string }>(`/trade/orders/${orderId}`);
|
||||
if (order.payStatus === 'PAID') return true;
|
||||
await sleep(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function invokeMiniPay(prepay: WechatJsapiPrepayParams) {
|
||||
await Taro.requestPayment({
|
||||
timeStamp: prepay.timeStamp,
|
||||
nonceStr: prepay.nonceStr,
|
||||
package: prepay.package,
|
||||
signType: prepay.signType,
|
||||
paySign: prepay.paySign,
|
||||
});
|
||||
}
|
||||
|
||||
export async function payOrder(orderId: string): Promise<'paid' | 'pending'> {
|
||||
const result = await request<WechatPayOrderResult>(`/trade/orders/${orderId}/pay`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (result.mode === 'jsapi' && result.prepay) {
|
||||
await invokeMiniPay(result.prepay as WechatJsapiPrepayParams);
|
||||
const paid = await waitOrderPaid(orderId);
|
||||
return paid ? 'paid' : 'pending';
|
||||
}
|
||||
|
||||
return 'paid';
|
||||
}
|
||||
|
||||
export type WechatBindResult =
|
||||
| { ok: true; profile?: UserProfile }
|
||||
| { ok: false; needBindPhone: true; wxSessionKey: string }
|
||||
| { ok: false; redirecting: true };
|
||||
@@ -1,20 +1,8 @@
|
||||
import { regionData } from 'element-china-area-data';
|
||||
import rawTree from './region-tree.json';
|
||||
|
||||
export type RegionTree = Record<string, Record<string, string[]>>;
|
||||
|
||||
function buildRegionTree(): RegionTree {
|
||||
const tree: RegionTree = {};
|
||||
for (const province of regionData) {
|
||||
const cities: Record<string, string[]> = {};
|
||||
for (const city of province.children ?? []) {
|
||||
cities[city.label] = (city.children ?? []).map((district) => district.label);
|
||||
}
|
||||
tree[province.label] = cities;
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
export const REGION_TREE: RegionTree = buildRegionTree();
|
||||
export const REGION_TREE = rawTree as RegionTree;
|
||||
export const PROVINCES = Object.keys(REGION_TREE);
|
||||
export const REGION_ALL = '全市';
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request } from './api';
|
||||
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
|
||||
import { FALLBACK_CITY_CODE } from './product-images';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
const USER_COORDS_KEY = 'dukang_user_coords';
|
||||
const LOCATION_DENIED_KEY = 'dukang_location_denied';
|
||||
|
||||
export type ResolvedUserCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity: boolean;
|
||||
region: RegionSelection;
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
export type UserCoords = { latitude: number; longitude: number };
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
const FALLBACK_CITY: ResolvedUserCity = {
|
||||
province: DEFAULT_REGION.province,
|
||||
city: DEFAULT_REGION.city,
|
||||
district: REGION_ALL,
|
||||
cityCode: FALLBACK_CITY_CODE,
|
||||
cityName: '郑州市',
|
||||
openCity: true,
|
||||
region: DEFAULT_REGION,
|
||||
displayCity: '郑州市',
|
||||
};
|
||||
|
||||
function isLocationDenied(): boolean {
|
||||
try {
|
||||
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLocationDenied() {
|
||||
try {
|
||||
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocationDenied() {
|
||||
try {
|
||||
Taro.removeStorageSync(LOCATION_DENIED_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function isDenyMessage(errMsg?: string): boolean {
|
||||
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
|
||||
errMsg || '',
|
||||
);
|
||||
}
|
||||
|
||||
function readCache(): GpsCityCache | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(GPS_CITY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as GpsCityCache;
|
||||
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: ResolvedUserCity) {
|
||||
try {
|
||||
Taro.setStorageSync(
|
||||
GPS_CITY_STORAGE_KEY,
|
||||
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function writeUserCoords(latitude: number, longitude: number) {
|
||||
try {
|
||||
Taro.setStorageSync(
|
||||
USER_COORDS_KEY,
|
||||
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedUserCoords(): UserCoords | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(USER_COORDS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
|
||||
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
|
||||
return { latitude: parsed.latitude, longitude: parsed.longitude };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toCityWideRegion(region: RegionSelection): RegionSelection {
|
||||
return {
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
function cacheFallbackAndMaybeDeny(denied: boolean) {
|
||||
if (denied) markLocationDenied();
|
||||
writeCache(FALLBACK_CITY);
|
||||
}
|
||||
|
||||
async function reportLocationToServer(payload: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
}) {
|
||||
return request<{
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
}>('/common/wechat/location', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
function toResolved(data: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
}): ResolvedUserCity | null {
|
||||
if (!data.province || !data.city) return null;
|
||||
const region = regionFromGeo(data.province, data.city, data.district);
|
||||
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`);
|
||||
return {
|
||||
province: data.province,
|
||||
city: data.city,
|
||||
district: data.district ?? '',
|
||||
cityCode: data.cityCode,
|
||||
cityName: data.cityName,
|
||||
openCity: !!data.openCity,
|
||||
region,
|
||||
displayCity,
|
||||
};
|
||||
}
|
||||
|
||||
async function promptLocationAuthOnce() {
|
||||
await Taro.showModal({
|
||||
title: '位置授权',
|
||||
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
|
||||
confirmText: '知道了',
|
||||
showCancel: false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
|
||||
if (!force) {
|
||||
if (isLocationDenied()) {
|
||||
const cached = readCache();
|
||||
return cached ?? FALLBACK_CITY;
|
||||
}
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const loc = await getMiniLocation();
|
||||
writeUserCoords(loc.latitude, loc.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
sdk: 'jssdk',
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
clearLocationDenied();
|
||||
writeCache(resolved);
|
||||
return resolved;
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const denied = isDenyMessage(errMsg);
|
||||
if (denied && !isLocationDenied()) {
|
||||
await promptLocationAuthOnce();
|
||||
}
|
||||
await reportLocationToServer({
|
||||
sdk: 'jssdk',
|
||||
status: 'fail',
|
||||
errMsg: errMsg.slice(0, 200),
|
||||
}).catch(() => {});
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
}
|
||||
|
||||
return FALLBACK_CITY;
|
||||
}
|
||||
|
||||
export function getCityCodeForCatalog(resolved: ResolvedUserCity): string {
|
||||
return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
syncMiniWechatProfile,
|
||||
} from './mini-wechat-profile';
|
||||
import {
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
needsWechatAuthForPay,
|
||||
saveWechatLoginResult,
|
||||
type WechatBindResult,
|
||||
} from './pay-wechat';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
|
||||
|
||||
export type WechatAuthEnsureResult =
|
||||
| { ok: true }
|
||||
| { ok: false; redirecting: true }
|
||||
| { ok: false; needBindPhone: true; wxSessionKey: string };
|
||||
|
||||
export async function loginWithWechat(): Promise<WechatLoginResult | void> {
|
||||
const res = await Taro.login();
|
||||
if (!res.code) {
|
||||
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
|
||||
}
|
||||
const { request } = await import('./api');
|
||||
return request<WechatLoginResult>('/auth/login/wechat', {
|
||||
method: 'POST',
|
||||
data: { code: res.code, platform: 'mini' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkNeedsWechatAuth(): Promise<boolean> {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
return needsWechatAuthForPay(config, profile);
|
||||
}
|
||||
|
||||
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
|
||||
if (!isWechatEnv()) return { ok: true };
|
||||
if (!(await checkNeedsWechatAuth())) return { ok: true };
|
||||
return { ok: false, redirecting: true };
|
||||
}
|
||||
|
||||
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
|
||||
throw new Error('请使用小程序微信授权');
|
||||
}
|
||||
|
||||
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
return saveWechatLoginResult(result);
|
||||
}
|
||||
|
||||
export async function bindWechatForUser(
|
||||
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
|
||||
): Promise<WechatBindResult> {
|
||||
const res = await Taro.login();
|
||||
if (!res.code) {
|
||||
throw new Error(res.errMsg || '微信授权失败');
|
||||
}
|
||||
const { request } = await import('./api');
|
||||
const data = await request<WechatLoginResult>('/auth/wechat/bind', {
|
||||
method: 'POST',
|
||||
data: { code: res.code, platform: 'mini' },
|
||||
});
|
||||
if (data.needBindPhone && data.wxSessionKey) {
|
||||
return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey };
|
||||
}
|
||||
await syncMiniWechatProfile(prefetchedWxProfile);
|
||||
const profile = await fetchUserProfile();
|
||||
return { ok: true, profile };
|
||||
}
|
||||
|
||||
export type { WechatBindResult };
|
||||
@@ -0,0 +1,165 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
applyShareTitleTemplate,
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_HINT,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
resolveMiniShareRuntime,
|
||||
type ClientRuntimeConfig,
|
||||
type MiniShareRuntime,
|
||||
type MiniShareSceneConfig,
|
||||
} from '@dukang/shared-types';
|
||||
import { toast } from './api';
|
||||
import { getBrandAssetsSync, loadBrandAssets } from './brand-assets';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
export type ShareScene =
|
||||
| 'home'
|
||||
| 'stores'
|
||||
| 'storeDetail'
|
||||
| 'benefit'
|
||||
| 'mine'
|
||||
| 'productDetail'
|
||||
| 'orderDetail';
|
||||
|
||||
export { DEFAULT_SHARE_TITLE, DEFAULT_SHARE_DESC };
|
||||
|
||||
export const WECHAT_SHARE_HINT = DEFAULT_SHARE_HINT;
|
||||
|
||||
const FALLBACK_SHARE: MiniShareRuntime = resolveMiniShareRuntime({});
|
||||
|
||||
let shareCached: MiniShareRuntime | null = null;
|
||||
|
||||
export function getShareRuntimeSync(): MiniShareRuntime {
|
||||
return shareCached ?? FALLBACK_SHARE;
|
||||
}
|
||||
|
||||
export function applyShareFromClientConfig(config?: ClientRuntimeConfig | null) {
|
||||
if (config?.share) {
|
||||
shareCached = config.share;
|
||||
return shareCached;
|
||||
}
|
||||
return getShareRuntimeSync();
|
||||
}
|
||||
|
||||
export async function loadShareConfig(force = false): Promise<MiniShareRuntime> {
|
||||
if (!force && shareCached) return shareCached;
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
if (cfg?.share) {
|
||||
shareCached = cfg.share;
|
||||
return shareCached;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
shareCached = FALLBACK_SHARE;
|
||||
return shareCached;
|
||||
}
|
||||
|
||||
export function getDefaultShareImageUrl(): string {
|
||||
const share = getShareRuntimeSync();
|
||||
return share.default.imageUrl || getBrandAssetsSync().brandLogoUrl;
|
||||
}
|
||||
|
||||
export function getShareHint(): string {
|
||||
return getShareRuntimeSync().hint || DEFAULT_SHARE_HINT;
|
||||
}
|
||||
|
||||
export function prefetchShareBrandAssets() {
|
||||
void loadBrandAssets();
|
||||
void loadShareConfig();
|
||||
}
|
||||
|
||||
function sceneConfig(scene?: ShareScene): MiniShareSceneConfig {
|
||||
const runtime = getShareRuntimeSync();
|
||||
if (!scene) return runtime.default;
|
||||
return runtime[scene] ?? runtime.default;
|
||||
}
|
||||
|
||||
export type PageSharePayload = {
|
||||
title?: string;
|
||||
desc?: string;
|
||||
path?: string;
|
||||
imgUrl?: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
export function buildSceneSharePayload(
|
||||
scene: ShareScene,
|
||||
options?: {
|
||||
path?: string;
|
||||
dynamicTitle?: string | null;
|
||||
dynamicDesc?: string | null;
|
||||
dynamicImageUrl?: string | null;
|
||||
titleVars?: Record<string, string | undefined | null>;
|
||||
},
|
||||
): PageSharePayload {
|
||||
const def = getShareRuntimeSync().default;
|
||||
const sc = sceneConfig(scene);
|
||||
let title = (sc.title || '').trim();
|
||||
if (title && options?.titleVars) {
|
||||
const vars = options.titleVars;
|
||||
const missingRequired = Object.entries(vars).some(
|
||||
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
|
||||
);
|
||||
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
|
||||
}
|
||||
if (!title) {
|
||||
title = (options?.dynamicTitle || '').trim() || def.title;
|
||||
}
|
||||
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
||||
const imgUrl =
|
||||
(sc.imageUrl || '').trim() ||
|
||||
(options?.dynamicImageUrl || '').trim() ||
|
||||
def.imageUrl ||
|
||||
getDefaultShareImageUrl();
|
||||
return {
|
||||
title,
|
||||
desc,
|
||||
path: options?.path,
|
||||
imgUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyWechatShare(): Promise<void> {
|
||||
/* weapp 使用原生分享菜单,无 H5 JSSDK */
|
||||
}
|
||||
|
||||
export async function handleShareButtonClick(
|
||||
payload?: PageSharePayload,
|
||||
): Promise<{ showGuide: boolean }> {
|
||||
try {
|
||||
await Taro.showShareMenu({
|
||||
withShareTicket: true,
|
||||
showShareItems: ['shareAppMessage', 'shareTimeline'],
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
await Taro.showShareMenu({ withShareTicket: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
toast(getShareHint());
|
||||
void payload;
|
||||
return { showGuide: false };
|
||||
}
|
||||
|
||||
export function toWeappShareMessage(payload?: PageSharePayload) {
|
||||
const def = getShareRuntimeSync().default;
|
||||
return {
|
||||
title: payload?.title || def.title,
|
||||
path: payload?.path || '/pages/home/index',
|
||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toWeappShareTimeline(payload?: PageSharePayload, query = '') {
|
||||
const def = getShareRuntimeSync().default;
|
||||
return {
|
||||
title: payload?.title || def.title,
|
||||
query,
|
||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** 小程序端恒为微信环境,避免引入 H5 JSSDK 门面 */
|
||||
export function isWechatEnv() {
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Textarea, Switch } from '@tarojs/components';
|
||||
import '../../styles/address.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/address.css';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '申请发票',
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Radio } from '@tarojs/components';
|
||||
import '../../styles/invoice.css';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceKind,
|
||||
type UserInvoiceTitleDto,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
type InvoiceStatus = {
|
||||
exists: boolean;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
type OrderBrief = {
|
||||
orderNo?: string;
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
payAmount?: number;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '开票中',
|
||||
ISSUED: '已开票',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export default function InvoiceApplyPage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.orderId ?? '';
|
||||
usePageView('user_invoice_apply_view', orderId ? { orderId } : undefined);
|
||||
|
||||
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
||||
const [emailOverride, setEmailOverride] = useState('');
|
||||
const [phoneOverride, setPhoneOverride] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [invoiceStatus, setInvoiceStatus] = useState<InvoiceStatus | null>(null);
|
||||
const [order, setOrder] = useState<OrderBrief | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!orderId) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
request<UserInvoiceTitleDto[]>(`/trade/orders/${orderId}/invoice-titles`).catch(() => []),
|
||||
request<InvoiceStatus>(`/trade/orders/${orderId}/invoice-status`).catch(() => ({ exists: false })),
|
||||
request<OrderBrief>(`/trade/orders/${orderId}`).catch(() => ({})),
|
||||
])
|
||||
.then(([list, status, orderData]) => {
|
||||
const titlesList = Array.isArray(list) ? list : [];
|
||||
setTitles(titlesList);
|
||||
setInvoiceStatus(status);
|
||||
setOrder(orderData || {});
|
||||
const def = titlesList.find((t) => t.isDefault);
|
||||
setSelectedId(def?.id || (titlesList[0]?.id ?? ''));
|
||||
})
|
||||
.catch(() => {
|
||||
setTitles([]);
|
||||
setInvoiceStatus({ exists: false });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useDidShow(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
const selected = titles.find((t) => t.id === selectedId);
|
||||
|
||||
async function submit() {
|
||||
if (!orderId || submitting) return;
|
||||
if (order?.status && order.status !== 'COMPLETED') {
|
||||
toast('仅已完成订单可申请发票');
|
||||
return;
|
||||
}
|
||||
if (!selected) {
|
||||
toast('请先选择发票抬头');
|
||||
return;
|
||||
}
|
||||
const email = (selected.email || emailOverride).trim();
|
||||
const phone = (selected.phone || phoneOverride).trim();
|
||||
if (!email) {
|
||||
toast('请填写接收邮箱');
|
||||
return;
|
||||
}
|
||||
if (!phone) {
|
||||
toast('请填写联系电话');
|
||||
return;
|
||||
}
|
||||
if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') {
|
||||
toast('专用发票仅支持企业抬头');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request(`/trade/orders/${orderId}/invoices`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
titleId: selectedId,
|
||||
invoiceKind,
|
||||
email,
|
||||
phone,
|
||||
},
|
||||
});
|
||||
toast('发票申请已提交', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack().catch(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=completed' });
|
||||
});
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function goAddTitle() {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/invoice-titles/index',
|
||||
});
|
||||
}
|
||||
|
||||
const alreadyApplied = invoiceStatus?.exists;
|
||||
const orderCompletable = !order?.status || order.status === 'COMPLETED';
|
||||
const canApply = !alreadyApplied && orderCompletable;
|
||||
const productLine = order
|
||||
? [order.productName, order.productSpec, order.quantity ? `×${order.quantity}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="invoice-apply-page">
|
||||
{isH5 ? (
|
||||
<SubPageHeader
|
||||
title="申请发票"
|
||||
onBack={() => Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))}
|
||||
/>
|
||||
) : null}
|
||||
<View className="invoice-apply-body">
|
||||
{order?.orderNo ? (
|
||||
<View className="invoice-apply-order">
|
||||
<Text className="invoice-apply-order-label">订单编号</Text>
|
||||
<Text className="invoice-apply-order-no">{order.orderNo}</Text>
|
||||
{productLine ? (
|
||||
<Text className="invoice-apply-order-meta">{productLine}</Text>
|
||||
) : null}
|
||||
{order.payAmount != null ? (
|
||||
<Text className="invoice-apply-order-meta">
|
||||
实付 ¥{Number(order.payAmount).toFixed(2)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{alreadyApplied ? (
|
||||
<View className="invoice-apply-status-card">
|
||||
<Text className="invoice-apply-status-icon">✓</Text>
|
||||
<View>
|
||||
<Text className="invoice-apply-status-title">已申请发票</Text>
|
||||
<Text className="invoice-apply-status-desc">
|
||||
状态:{INVOICE_STATUS_LABELS[invoiceStatus?.status ?? ''] || invoiceStatus?.status || '处理中'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!alreadyApplied && !orderCompletable ? (
|
||||
<View className="invoice-apply-status-card">
|
||||
<View>
|
||||
<Text className="invoice-apply-status-title">当前订单不可开票</Text>
|
||||
<Text className="invoice-apply-status-desc">仅已完成订单可申请发票</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canApply ? (
|
||||
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
||||
<Text className="invoice-title-label">发票类型</Text>
|
||||
<View className="invoice-title-type-row">
|
||||
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
|
||||
<Text
|
||||
key={k}
|
||||
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
|
||||
onClick={() => setInvoiceKind(k)}
|
||||
>
|
||||
{INVOICE_KIND_LABELS[k]}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canApply ? (
|
||||
<View className="invoice-apply-section-title">
|
||||
<Text>选择发票抬头</Text>
|
||||
<Text className="invoice-apply-add-link" onClick={goAddTitle}>
|
||||
+ 新增抬头
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{canApply && !loading && titles.length === 0 ? (
|
||||
<View className="invoice-apply-empty-titles">
|
||||
<Text className="u-empty">暂无发票抬头</Text>
|
||||
<View className="invoice-apply-empty-cta" onClick={goAddTitle}>
|
||||
去添加
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canApply && !loading && titles.length > 0 ? (
|
||||
<View className="invoice-apply-title-list">
|
||||
{titles.map((t) => (
|
||||
<View
|
||||
key={t.id}
|
||||
className={`invoice-apply-title-item${selectedId === t.id ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedId(t.id)}
|
||||
>
|
||||
<View className="invoice-apply-title-info">
|
||||
<View className="invoice-apply-title-head">
|
||||
<Text className="invoice-apply-title-name">{t.titleName}</Text>
|
||||
<Text className="invoice-apply-title-type">
|
||||
{INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType}
|
||||
</Text>
|
||||
{t.isDefault ? <Text className="invoice-apply-title-default">默认</Text> : null}
|
||||
</View>
|
||||
{t.taxNo ? <Text className="invoice-apply-title-sub">税号:{t.taxNo}</Text> : null}
|
||||
{t.email ? <Text className="invoice-apply-title-sub">邮箱:{t.email}</Text> : null}
|
||||
</View>
|
||||
<Radio
|
||||
checked={selectedId === t.id}
|
||||
color="#A61D24"
|
||||
onClick={() => setSelectedId(t.id)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canApply && selected && !selected.email ? (
|
||||
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
||||
<Text className="invoice-title-label">接收邮箱</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
placeholder="电子发票将发送至此邮箱"
|
||||
value={emailOverride}
|
||||
onInput={(e) => setEmailOverride(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{canApply && selected && !selected.phone ? (
|
||||
<View className="invoice-title-field" style={{ marginTop: 12 }}>
|
||||
<Text className="invoice-title-label">联系电话</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
type="number"
|
||||
placeholder="请填写联系电话"
|
||||
value={phoneOverride}
|
||||
onInput={(e) => setPhoneOverride(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{canApply && titles.length > 0 ? (
|
||||
<View className="invoice-apply-footer">
|
||||
<View
|
||||
className={`invoice-apply-submit${submitting ? ' is-disabled' : ''}`}
|
||||
onClick={() => { if (!submitting) void submit(); }}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交申请'}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '发票抬头',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Switch, Image } from '@tarojs/components';
|
||||
import '../../styles/invoice.css';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
import {
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceTitleType,
|
||||
type UpsertInvoiceTitleRequest,
|
||||
type UserInvoiceTitleDto,
|
||||
} from '@dukang/shared-types';
|
||||
import iconEdit from '../../assets/icons/编辑.png';
|
||||
import iconDefault from '../../assets/icons/默认.png';
|
||||
import iconDelete from '../../assets/icons/删除.png';
|
||||
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
const ACTION_ICONS = {
|
||||
edit: iconEdit,
|
||||
star: iconDefault,
|
||||
trash: iconDelete,
|
||||
} as const;
|
||||
|
||||
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
|
||||
|
||||
function TitleActionIcon({
|
||||
kind,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
kind: keyof typeof ACTION_ICONS;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
className="invoice-title-icon-btn"
|
||||
hoverClass="invoice-title-icon-btn--hover"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Image className="invoice-title-icon-img" src={ACTION_ICONS[kind]} mode="aspectFit" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: EditDraft = {
|
||||
titleType: 'PERSONAL',
|
||||
titleName: '',
|
||||
taxNo: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
addressPhone: '',
|
||||
bankAccount: '',
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
export default function InvoiceTitlesPage() {
|
||||
usePageView('user_invoice_titles_view');
|
||||
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<EditDraft>(EMPTY_DRAFT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
return request<UserInvoiceTitleDto[]>('/trade/invoice-titles')
|
||||
.then((data) => setTitles(Array.isArray(data) ? data : []))
|
||||
.catch((e) => {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
setTitles([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useDidShow(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void load().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setDraft({ ...EMPTY_DRAFT });
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(t: UserInvoiceTitleDto) {
|
||||
setDraft({
|
||||
id: t.id,
|
||||
titleType: t.titleType,
|
||||
titleName: t.titleName,
|
||||
taxNo: t.taxNo ?? '',
|
||||
email: t.email ?? '',
|
||||
phone: t.phone ?? '',
|
||||
addressPhone: t.addressPhone ?? '',
|
||||
bankAccount: t.bankAccount ?? '',
|
||||
isDefault: t.isDefault,
|
||||
});
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
const titleName = draft.titleName.trim();
|
||||
if (!titleName) {
|
||||
toast('请填写发票抬头名称');
|
||||
return;
|
||||
}
|
||||
if (draft.titleType === 'ENTERPRISE' && !draft.taxNo?.trim()) {
|
||||
toast('企业抬头须填写税号');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: UpsertInvoiceTitleRequest = {
|
||||
titleType: draft.titleType,
|
||||
titleName,
|
||||
taxNo: draft.taxNo?.trim() || null,
|
||||
email: draft.email?.trim() || null,
|
||||
phone: draft.phone?.trim() || null,
|
||||
addressPhone: draft.addressPhone?.trim() || null,
|
||||
bankAccount: draft.bankAccount?.trim() || null,
|
||||
isDefault: !!draft.isDefault,
|
||||
};
|
||||
if (draft.id) {
|
||||
await request(`/trade/invoice-titles/${draft.id}`, {
|
||||
method: 'PUT',
|
||||
data: payload,
|
||||
});
|
||||
toast('已更新', 'success');
|
||||
} else {
|
||||
await request('/trade/invoice-titles', { method: 'POST', data: payload });
|
||||
toast('已添加', 'success');
|
||||
}
|
||||
setSheetOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(t: UserInvoiceTitleDto) {
|
||||
try {
|
||||
await request(`/trade/invoice-titles/${t.id}`, {
|
||||
method: 'PUT',
|
||||
data: {
|
||||
titleType: t.titleType,
|
||||
titleName: t.titleName,
|
||||
taxNo: t.taxNo,
|
||||
email: t.email,
|
||||
phone: t.phone,
|
||||
addressPhone: t.addressPhone,
|
||||
bankAccount: t.bankAccount,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
toast('已设为默认', 'success');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '设置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
const ok = await Taro.showModal({ title: '提示', content: '确认删除该发票抬头?' });
|
||||
if (!ok.confirm) return;
|
||||
try {
|
||||
await request(`/trade/invoice-titles/${id}`, { method: 'DELETE' });
|
||||
toast('已删除', 'success');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="invoice-titles-page">
|
||||
{isH5 ? (
|
||||
<SubPageHeader
|
||||
title="发票抬头"
|
||||
onBack={() => Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))}
|
||||
/>
|
||||
) : null}
|
||||
<View className="invoice-titles-body">
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && titles.length === 0 ? (
|
||||
<View className="u-empty">暂无发票抬头,点击右上角添加</View>
|
||||
) : null}
|
||||
{!loading &&
|
||||
titles.map((t) => (
|
||||
<View key={t.id} className="invoice-title-card">
|
||||
<View className="invoice-title-card-head">
|
||||
<Text className="invoice-title-name">{t.titleName}</Text>
|
||||
<Text className="invoice-title-type">
|
||||
{INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType}
|
||||
</Text>
|
||||
{t.isDefault ? <Text className="invoice-title-default">默认</Text> : null}
|
||||
</View>
|
||||
{t.taxNo ? <Text className="invoice-title-line">税号:{t.taxNo}</Text> : null}
|
||||
{t.email ? <Text className="invoice-title-line">邮箱:{t.email}</Text> : null}
|
||||
{t.phone ? <Text className="invoice-title-line">电话:{t.phone}</Text> : null}
|
||||
{t.addressPhone ? (
|
||||
<Text className="invoice-title-line">地址电话:{t.addressPhone}</Text>
|
||||
) : null}
|
||||
{t.bankAccount ? (
|
||||
<Text className="invoice-title-line">开户行账号:{t.bankAccount}</Text>
|
||||
) : null}
|
||||
<View className="invoice-title-actions">
|
||||
<TitleActionIcon kind="edit" label="编辑" onClick={() => openEdit(t)} />
|
||||
{!t.isDefault ? (
|
||||
<TitleActionIcon kind="star" label="设为默认" onClick={() => void setDefault(t)} />
|
||||
) : null}
|
||||
<TitleActionIcon kind="trash" label="删除" onClick={() => void remove(t.id)} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="invoice-titles-fab" onClick={openCreate}>
|
||||
<Text>+ 添加抬头</Text>
|
||||
</View>
|
||||
|
||||
{sheetOpen ? (
|
||||
<View className="invoice-title-sheet-mask">
|
||||
<View className="invoice-title-sheet-mask-backdrop" onClick={() => !saving && setSheetOpen(false)} />
|
||||
<View className="invoice-title-sheet">
|
||||
<Text className="invoice-title-sheet-title">
|
||||
{draft.id ? '编辑发票抬头' : '添加发票抬头'}
|
||||
</Text>
|
||||
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">抬头类型</Text>
|
||||
<View className="invoice-title-type-row">
|
||||
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
|
||||
<Text
|
||||
key={tp}
|
||||
className={`invoice-title-type-chip${
|
||||
draft.titleType === tp ? ' active' : ''
|
||||
}`}
|
||||
onClick={() => setDraft({ ...draft, titleType: tp })}
|
||||
>
|
||||
{INVOICE_TITLE_TYPE_LABELS[tp]}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">抬头名称</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
maxlength={128}
|
||||
placeholder="个人姓名或企业名称"
|
||||
value={draft.titleName}
|
||||
onInput={(e) => setDraft({ ...draft, titleName: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{draft.titleType === 'ENTERPRISE' ? (
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">税号</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
maxlength={32}
|
||||
placeholder="企业税号"
|
||||
value={draft.taxNo || ''}
|
||||
onInput={(e) => setDraft({ ...draft, taxNo: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">接收邮箱</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
maxlength={128}
|
||||
placeholder="电子发票将发送至此邮箱"
|
||||
value={draft.email || ''}
|
||||
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">联系电话</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
type="number"
|
||||
maxlength={20}
|
||||
placeholder="选填"
|
||||
value={draft.phone || ''}
|
||||
onInput={(e) => setDraft({ ...draft, phone: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{draft.titleType === 'ENTERPRISE' ? (
|
||||
<>
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">地址电话</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
maxlength={256}
|
||||
placeholder="专用发票需要,选填"
|
||||
value={draft.addressPhone || ''}
|
||||
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
<View className="invoice-title-field">
|
||||
<Text className="invoice-title-label">开户行账号</Text>
|
||||
<Input
|
||||
className="invoice-title-input"
|
||||
maxlength={256}
|
||||
placeholder="专用发票需要,选填"
|
||||
value={draft.bankAccount || ''}
|
||||
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View className="invoice-title-default-row">
|
||||
<Text className="invoice-title-label">设为默认抬头</Text>
|
||||
<Switch
|
||||
checked={!!draft.isDefault}
|
||||
color="#A61D24"
|
||||
onChange={(e) => setDraft({ ...draft, isDefault: !!e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="invoice-title-sheet-actions">
|
||||
<Text
|
||||
className="invoice-title-sheet-cancel"
|
||||
onClick={() => !saving && setSheetOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Text>
|
||||
<Text
|
||||
className={`invoice-title-sheet-save${saving ? ' is-disabled' : ''}`}
|
||||
onClick={() => { if (!saving) void save(); }}
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Image } from '@tarojs/components';
|
||||
import '../../styles/login.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
SmsScene,
|
||||
|
||||
@@ -56,6 +56,7 @@ const SERVICES = [
|
||||
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
|
||||
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
|
||||
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
|
||||
{ icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' },
|
||||
] as const;
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
import type { InvoiceStatus } from '@dukang/shared-types';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
@@ -58,6 +60,7 @@ type OrderDetail = {
|
||||
deliveryType?: string;
|
||||
items?: OrderItem[];
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
invoiceStatus?: InvoiceStatus | null;
|
||||
delivery?: {
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
@@ -150,6 +153,9 @@ export default function OrderDetailPage() {
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||
const canConfirmReceive =
|
||||
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||
const canInvoice =
|
||||
!!order && !isReship && order.status === 'COMPLETED' && order.invoiceStatus !== 'PENDING' && order.invoiceStatus !== 'ISSUED';
|
||||
const invoicePending = !!order && !isReship && order.status === 'COMPLETED' && order.invoiceStatus === 'PENDING';
|
||||
const canViewLogistics =
|
||||
!!order &&
|
||||
order.deliveryType !== 'ON_SITE_PICKUP' &&
|
||||
@@ -403,6 +409,22 @@ export default function OrderDetailPage() {
|
||||
{confirming ? '提交中…' : '确认收货'}
|
||||
</View>
|
||||
) : null}
|
||||
{canInvoice ? (
|
||||
<View
|
||||
className="order-detail-invoice-btn"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${order.id}` })}
|
||||
>
|
||||
申请发票
|
||||
</View>
|
||||
) : null}
|
||||
{invoicePending ? (
|
||||
<View
|
||||
className="order-detail-invoice-status"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${order.id}` })}
|
||||
>
|
||||
开票中
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
@@ -7,7 +8,7 @@ import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
|
||||
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
|
||||
import { ORDER_STATUS_LABELS, type InvoiceStatus } from '@dukang/shared-types';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: '全部订单' },
|
||||
@@ -50,6 +51,7 @@ type OrderRow = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
items?: OrderItem[];
|
||||
invoiceStatus?: InvoiceStatus | null;
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
@@ -125,6 +127,9 @@ export default function OrdersPage() {
|
||||
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
|
||||
const unitPrice = Number(item?.unitPrice ?? 0);
|
||||
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
|
||||
const invoiceBlocking = o.invoiceStatus === 'PENDING' || o.invoiceStatus === 'ISSUED';
|
||||
const canInvoice = o.status === 'COMPLETED' && !o.originOrderId && !invoiceBlocking;
|
||||
const invoicePending = o.status === 'COMPLETED' && o.invoiceStatus === 'PENDING';
|
||||
const isProxy = o.isProxyOrder || o.orderType === 'PROXY';
|
||||
|
||||
return (
|
||||
@@ -177,6 +182,28 @@ export default function OrdersPage() {
|
||||
付款
|
||||
</View>
|
||||
) : null}
|
||||
{canInvoice ? (
|
||||
<View
|
||||
className="order-list-invoice-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${o.id}` });
|
||||
}}
|
||||
>
|
||||
申请发票
|
||||
</View>
|
||||
) : null}
|
||||
{invoicePending ? (
|
||||
<View
|
||||
className="order-list-invoice-status"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: `/pages/invoice-apply/index?orderId=${o.id}` });
|
||||
}}
|
||||
>
|
||||
开票中
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/order.css';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/legal.css';
|
||||
import { getLegalDocument } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/product-detail.css';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
usePageScroll,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/store-detail.css';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
useLoad,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/store-detail.css';
|
||||
import '../../styles/product-detail.css';
|
||||
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/legal.css';
|
||||
import { getLegalDocument } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/* 发票抬头管理 + 申请发票 */
|
||||
.invoice-titles-page,
|
||||
.invoice-apply-page {
|
||||
background: var(--color-background);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.invoice-titles-body,
|
||||
.invoice-apply-body {
|
||||
padding: 12px var(--space-page) 80px;
|
||||
}
|
||||
|
||||
/* —— 发票抬头列表 —— */
|
||||
.invoice-title-card {
|
||||
background: var(--color-card, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.invoice-title-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.invoice-title-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface, #1a1a1a);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invoice-title-type {
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
background: var(--color-surface-variant, #f0eee9);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invoice-title-default {
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: var(--color-primary, #A61D24);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invoice-title-line {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant, #888);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.invoice-title-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-outline-variant, #eee);
|
||||
}
|
||||
|
||||
.invoice-title-icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.invoice-title-icon-btn--hover {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.invoice-title-icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* FAB */
|
||||
.invoice-titles-fab {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary, #A61D24);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 16px rgba(166, 29, 36, 0.3);
|
||||
}
|
||||
|
||||
/* —— 编辑/新增底部弹层 —— */
|
||||
.invoice-title-sheet-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.invoice-title-sheet-mask-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.invoice-title-sheet {
|
||||
position: relative;
|
||||
background: var(--color-card, #fff);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px var(--space-page, 16px) calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.invoice-title-sheet-title {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invoice-title-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.invoice-title-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.invoice-title-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-outline, #ddd);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
background: var(--color-surface, #fafafa);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invoice-title-type-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invoice-title-type-chip {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
border: 1px solid var(--color-outline, #ddd);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
}
|
||||
|
||||
.invoice-title-type-chip.active {
|
||||
border-color: var(--color-primary, #A61D24);
|
||||
color: var(--color-primary, #A61D24);
|
||||
background: rgba(166, 29, 36, 0.06);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invoice-title-default-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.invoice-title-sheet-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.invoice-title-sheet-cancel,
|
||||
.invoice-title-sheet-save {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invoice-title-sheet-cancel {
|
||||
background: var(--color-surface-variant, #f0eee9);
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
}
|
||||
|
||||
.invoice-title-sheet-save {
|
||||
background: var(--color-primary, #A61D24);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.invoice-title-sheet-save.is-disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* —— 申请发票页 —— */
|
||||
.invoice-apply-order {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-card, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invoice-apply-order-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant, #888);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invoice-apply-order-no {
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface, #333);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invoice-apply-order-meta {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant, #888);
|
||||
}
|
||||
|
||||
.invoice-apply-status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(76, 175, 80, 0.08);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invoice-apply-status-icon {
|
||||
font-size: 24px;
|
||||
color: #4caf50;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invoice-apply-status-title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.invoice-apply-status-desc {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #4caf50;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.invoice-apply-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invoice-apply-section-title > text:first-child {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface, #1a1a1a);
|
||||
}
|
||||
|
||||
.invoice-apply-add-link {
|
||||
font-size: 14px;
|
||||
color: var(--color-primary, #A61D24);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.invoice-apply-empty-titles {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.invoice-apply-empty-cta {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
padding: 8px 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary, #A61D24);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.invoice-apply-title-list {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invoice-apply-title-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--color-card, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.invoice-apply-title-item.is-selected {
|
||||
border-color: var(--color-primary, #A61D24);
|
||||
}
|
||||
|
||||
.invoice-apply-title-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invoice-apply-title-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.invoice-apply-title-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface, #1a1a1a);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invoice-apply-title-type {
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
background: var(--color-surface-variant, #f0eee9);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invoice-apply-title-default {
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: var(--color-primary, #A61D24);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invoice-apply-title-sub {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant, #888);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.invoice-apply-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
padding: 12px var(--space-page, 16px) calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--color-card, #fff);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.invoice-apply-submit {
|
||||
text-align: center;
|
||||
padding: 14px 0;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary, #A61D24);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invoice-apply-submit.is-disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -422,6 +422,64 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.order-list-invoice-btn {
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-heritage-red, #A61D24);
|
||||
color: var(--color-heritage-red, #A61D24);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.order-list-invoice-status {
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(166, 29, 36, 0.06);
|
||||
color: var(--color-heritage-red, #A61D24);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.order-detail-invoice-btn {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-heritage-red, #A61D24);
|
||||
color: var(--color-heritage-red, #A61D24);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.order-detail-invoice-status {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(166, 29, 36, 0.06);
|
||||
color: var(--color-heritage-red, #A61D24);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pay-status {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
|
||||