feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,29 +0,0 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated, account } = usePartnerSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
return <div className="empty">加载中...</div>;
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to={partnerHomePath(account ?? getPartnerProfile())} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getPartnerProfile();
|
||||
if (profile && hasPartnerWxSession() && profile.hasWechat) {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { codeToText } from 'element-china-area-data';
|
||||
import {
|
||||
CHINA_REGION_OPTIONS,
|
||||
DEFAULT_REGION_CODES,
|
||||
formatRegionLabel,
|
||||
parseRegionCodes,
|
||||
} from '../lib/china-region';
|
||||
|
||||
type RegionNode = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: RegionNode[];
|
||||
};
|
||||
|
||||
type ChinaRegionPickerProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const STEP_LABELS = ['选择省份', '选择城市', '选择区县'] as const;
|
||||
|
||||
function lockBodyScroll(lock: boolean) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const body = document.body;
|
||||
if (lock) {
|
||||
const scrollY = window.scrollY;
|
||||
body.dataset.dukangScrollY = String(scrollY);
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.overflow = 'hidden';
|
||||
} else {
|
||||
const scrollY = Number(body.dataset.dukangScrollY ?? '0');
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
body.style.left = '';
|
||||
body.style.right = '';
|
||||
body.style.overflow = '';
|
||||
delete body.dataset.dukangScrollY;
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialDraft(value: string[]): { draft: string[]; step: number } {
|
||||
if (value.length === 3) {
|
||||
return { draft: [...value], step: 2 };
|
||||
}
|
||||
if (value.length > 0) {
|
||||
const draft = [...value];
|
||||
for (let i = value.length; i < DEFAULT_REGION_CODES.length; i += 1) {
|
||||
draft.push(DEFAULT_REGION_CODES[i]);
|
||||
}
|
||||
return { draft, step: Math.min(value.length, 2) };
|
||||
}
|
||||
return { draft: [...DEFAULT_REGION_CODES], step: 1 };
|
||||
}
|
||||
|
||||
export default function ChinaRegionPicker({ value = [], onChange, disabled }: ChinaRegionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(value);
|
||||
const [step, setStep] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const options = CHINA_REGION_OPTIONS as RegionNode[];
|
||||
const parsed = parseRegionCodes(value);
|
||||
const display = parsed ? formatRegionLabel(parsed) : '请选择省 / 市 / 区县';
|
||||
|
||||
const provinces = options;
|
||||
const cities = useMemo(() => {
|
||||
const p = provinces.find((item) => item.value === draft[0]);
|
||||
return p?.children ?? [];
|
||||
}, [draft, provinces]);
|
||||
|
||||
const districts = useMemo(() => {
|
||||
const c = cities.find((item) => item.value === draft[1]);
|
||||
return c?.children ?? [];
|
||||
}, [draft, cities]);
|
||||
|
||||
const listItems = step === 0 ? provinces : step === 1 ? cities : districts;
|
||||
const activeCode = draft[step];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
lockBodyScroll(true);
|
||||
return () => lockBodyScroll(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const container = listRef.current;
|
||||
const activeEl = activeItemRef.current;
|
||||
if (!container || !activeEl) return;
|
||||
const offset = activeEl.offsetTop - container.clientHeight / 2 + activeEl.clientHeight / 2;
|
||||
container.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' });
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, step, activeCode, listItems]);
|
||||
|
||||
function openPicker() {
|
||||
if (disabled) return;
|
||||
const { draft: next, step: initialStep } = buildInitialDraft(value);
|
||||
setDraft(next);
|
||||
setStep(initialStep);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function selectItem(code: string) {
|
||||
const next = [...draft.slice(0, step), code];
|
||||
setDraft(next);
|
||||
if (step < 2) {
|
||||
setStep(step + 1);
|
||||
return;
|
||||
}
|
||||
onChange?.(next);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (draft.length < 3) return;
|
||||
onChange?.(draft);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
const modal = open ? (
|
||||
<div className="partner-region-overlay" role="presentation" onClick={closePicker}>
|
||||
<div
|
||||
className="partner-region-sheet"
|
||||
role="dialog"
|
||||
aria-modal
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="partner-region-sheet-header">
|
||||
<button type="button" className="partner-region-sheet-btn" onClick={closePicker}>取消</button>
|
||||
<span className="headline-md">选择地区</span>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-region-sheet-btn partner-region-sheet-btn--primary"
|
||||
disabled={draft.length < 3}
|
||||
onClick={confirm}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="partner-region-steps">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`partner-region-step${step === index ? ' partner-region-step--active' : ''}${draft[index] ? ' partner-region-step--done' : ''}`}
|
||||
disabled={index === 1 && !draft[0] || index === 2 && !draft[1]}
|
||||
onClick={() => {
|
||||
if (index === 1 && !draft[0]) return;
|
||||
if (index === 2 && !draft[1]) return;
|
||||
setStep(index);
|
||||
}}
|
||||
>
|
||||
<span className="partner-region-step-index">{index + 1}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft[0] && (
|
||||
<p className="partner-region-breadcrumb label-md text-muted">
|
||||
{codeToText[draft[0]]}
|
||||
{draft[1] ? ` / ${codeToText[draft[1]]}` : ''}
|
||||
{draft[2] ? ` / ${codeToText[draft[2]]}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="partner-region-list" role="listbox" ref={listRef}>
|
||||
{listItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={activeCode === item.value}
|
||||
ref={activeCode === item.value ? (el) => { activeItemRef.current = el; } : undefined}
|
||||
className={`partner-region-item${activeCode === item.value ? ' partner-region-item--active' : ''}`}
|
||||
onClick={() => selectItem(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="partner-field-input partner-region-trigger" onClick={openPicker} disabled={disabled}>
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span className={parsed ? '' : 'text-muted'}>{display}</span>
|
||||
<span className="material-symbols-outlined partner-region-chevron">expand_more</span>
|
||||
</button>
|
||||
{typeof document !== 'undefined' ? createPortal(modal, document.body) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { toastError } from '../lib/toast';
|
||||
|
||||
type OssUploadFieldProps = {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
accept?: string;
|
||||
wide?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
/** 兼容现有调用:选图不再依赖 openId 绑定 */
|
||||
wechatReady?: boolean;
|
||||
onWechatReadyChange?: (ready: boolean) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg);
|
||||
}
|
||||
|
||||
function formatWechatUploadError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
const formatted = formatChooseImageFailMessage(msg);
|
||||
if (formatted) return formatted;
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function acceptsImages(accept: string) {
|
||||
return accept.includes('image');
|
||||
}
|
||||
|
||||
export default function OssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
wide,
|
||||
compact,
|
||||
label,
|
||||
}: OssUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||
|
||||
function showUploadError(text: string) {
|
||||
setError(text);
|
||||
toastError(text);
|
||||
}
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||
const inWechat = isWechatEnv();
|
||||
const useWechatPicker =
|
||||
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!useWechatPicker) return;
|
||||
// 仅预热,勿在每次点击时 reset,否则取消后再点常无法调起
|
||||
void weixinSdk.init().catch(() => {
|
||||
/* 点击上传时会再次初始化 */
|
||||
});
|
||||
}, [useWechatPicker]);
|
||||
|
||||
async function persistUpload(file: File) {
|
||||
if (!file.size) {
|
||||
showUploadError('图片读取失败,请重新选择');
|
||||
return;
|
||||
}
|
||||
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
|
||||
return;
|
||||
}
|
||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
}
|
||||
|
||||
async function uploadSelectedFile(file: File) {
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
await enqueueUpload(() => persistUpload(file));
|
||||
} catch (e) {
|
||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function pickWechatImage() {
|
||||
if (pickingRef.current || uploading) return;
|
||||
pickingRef.current = true;
|
||||
setError('');
|
||||
try {
|
||||
// 不要每次 reset:会打断 JSSDK,取消后再点经常无法调起相机/相册
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
// 取消或未选图:直接结束,允许再次点击
|
||||
if (!files?.length) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
// 仅串行化实际上传,选图本身不占上传锁,避免取消后锁态异常
|
||||
await enqueueUpload(() => persistUpload(files[0]!));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (isCancelError(msg)) return;
|
||||
throw e;
|
||||
} finally {
|
||||
pickingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
if (uploading || pickingRef.current) return;
|
||||
setError('');
|
||||
|
||||
if (useWechatPicker) {
|
||||
try {
|
||||
await pickWechatImage();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (isCancelError(msg)) return;
|
||||
const formatted = formatWechatUploadError(e);
|
||||
setError(`${formatted},可改从系统相册选择`);
|
||||
setShowAlbumFallback(true);
|
||||
inputRef.current?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
const isImage = mediaType === 'IMAGE' && value;
|
||||
const isFile = mediaType === 'FILE' && value;
|
||||
const busy = uploading;
|
||||
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
||||
|
||||
const triggerProps = {
|
||||
type: 'button' as const,
|
||||
disabled: busy,
|
||||
onClick: () => void pickFile(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setShowAlbumFallback(false);
|
||||
void uploadSelectedFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isImage ? (
|
||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||
<img src={value} alt={label ?? '已上传'} />
|
||||
<span className="partner-upload-preview-mask">
|
||||
<span className="material-symbols-outlined">{busy ? 'hourglass_top' : 'edit'}</span>
|
||||
<span>{uploading ? '上传中…' : '更换'}</span>
|
||||
</span>
|
||||
</button>
|
||||
) : isFile ? (
|
||||
<button {...triggerProps} className="partner-upload-file">
|
||||
<div className="partner-bills-icon" style={{ background: '#ffb3ae' }}>
|
||||
<span className="material-symbols-outlined text-primary">description</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<span className="text-primary" style={{ fontWeight: 700, display: 'block' }}>已上传合同</span>
|
||||
<span className="label-md text-muted">{uploading ? '上传中…' : '点击更换'}</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
{...triggerProps}
|
||||
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading ? '上传中…' : pickerLabel}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{showAlbumFallback && useWechatPicker && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ marginTop: 8, width: '100%' }}
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
从系统相册选择
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="partner-form-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { PackageFormItem } from '../lib/storePackages';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { emptyPackage } from '../lib/storePackages';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
onChange: (items: PackageFormItem[]) => void;
|
||||
disabled?: boolean;
|
||||
/** 嵌入拓店流程时不重复外层卡片边距 */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export default function StorePackagesForm({ items, onChange, disabled, embedded }: Props) {
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||
const next = items.map((item, i) => (i === index ? { ...item, ...patch } : item));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
onChange([...items, emptyPackage(items.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
setCollapsed((prev) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const i = Number(k);
|
||||
if (i < index) next[i] = v;
|
||||
else if (i > index) next[i - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
const list = items.length ? items : [emptyPackage(0)];
|
||||
|
||||
return (
|
||||
<div className={`partner-packages-form${embedded ? ' partner-packages-form--embedded' : ''}`}>
|
||||
{list.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<section
|
||||
key={index}
|
||||
className={`partner-form-card partner-packages-item${isCollapsed ? ' partner-packages-item--collapsed' : ''}`}
|
||||
style={embedded ? { marginLeft: 0, marginRight: 0 } : undefined}
|
||||
>
|
||||
<div className="partner-packages-item-head">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-packages-toggle"
|
||||
onClick={() => toggleCollapse(index)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? '展开套餐' : '收起套餐'}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{isCollapsed ? 'expand_more' : 'expand_less'}
|
||||
</span>
|
||||
<span className="headline-md">{displayName}</span>
|
||||
</button>
|
||||
{!disabled && list.length > 1 ? (
|
||||
<button type="button" className="partner-packages-remove" onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<div className="partner-field">
|
||||
<label>套餐名称 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">restaurant</span>
|
||||
<input
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>价格(元) <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">payments</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="198"
|
||||
value={item.price}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>菜品 <span className="text-primary">*</span></label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>使用时间</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
<input
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>其他说明</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<input
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<button type="button" className="partner-packages-add" onClick={addItem}>
|
||||
<span className="material-symbols-outlined">add_circle</span>
|
||||
添加套餐
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
buildTencentLocPickerUrl,
|
||||
parseTencentLocPickerMessage,
|
||||
type TencentPickedLocation,
|
||||
} from '../lib/tencentLocPicker';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (loc: TencentPickedLocation) => void;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
/** 保留兼容,iframe 选点不使用 */
|
||||
region?: string | null;
|
||||
};
|
||||
|
||||
let cachedKey: string | null | undefined;
|
||||
|
||||
async function loadTencentLbsKey(): Promise<string> {
|
||||
if (cachedKey) return cachedKey;
|
||||
const cfg = await request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config', {
|
||||
silent: true,
|
||||
});
|
||||
const key = (cfg.tencentLbsKey || '').trim();
|
||||
if (!key) {
|
||||
throw new Error('未配置腾讯位置服务 Key,请联系管理员在系统设置中配置');
|
||||
}
|
||||
cachedKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
export default function TencentLocPickerOverlay({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
latitude,
|
||||
longitude,
|
||||
}: Props) {
|
||||
const [key, setKey] = useState<string | null>(cachedKey ?? null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
/** 选点结果仅存内存,界面不展示经纬度 */
|
||||
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPending(null);
|
||||
setIframeReady(false);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
if (key) return;
|
||||
setLoading(true);
|
||||
void loadTencentLbsKey()
|
||||
.then((k) => {
|
||||
if (!cancelled) setKey(k);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : '加载地图配置失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onMessage(event: MessageEvent) {
|
||||
const picked = parseTencentLocPickerMessage(event.data);
|
||||
if (!picked) return;
|
||||
setPending(picked);
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [open]);
|
||||
|
||||
const src = useMemo(() => {
|
||||
if (!key) return '';
|
||||
const lat = latitude != null ? Number(latitude) : undefined;
|
||||
const lng = longitude != null ? Number(longitude) : undefined;
|
||||
return buildTencentLocPickerUrl(key, {
|
||||
latitude: lat != null && Number.isFinite(lat) ? lat : undefined,
|
||||
longitude: lng != null && Number.isFinite(lng) ? lng : undefined,
|
||||
});
|
||||
}, [key, latitude, longitude]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function confirmPick() {
|
||||
if (!pending) return;
|
||||
onPick(pending);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="partner-locpicker-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100dvh',
|
||||
maxHeight: '-webkit-fill-available',
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '12px 12px 10px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
flexShrink: 0,
|
||||
gap: 10,
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
地图选点
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
opacity: pending ? 1 : 0.45,
|
||||
width: 'auto',
|
||||
}}
|
||||
disabled={!pending}
|
||||
onClick={confirmPick}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ padding: '12px 16px', minWidth: 72, flexShrink: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="label-md text-muted"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '8px 14px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.04)',
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{pending ? '已选定位置,请点右上角「确认」' : '在地图上点选或搜索地点,选中后点右上角「确认」'}
|
||||
</p>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', background: '#f5f5f5' }}>
|
||||
{loading ? (
|
||||
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
|
||||
正在加载地图…
|
||||
</p>
|
||||
) : error ? (
|
||||
<div style={{ padding: 24 }}>
|
||||
<p className="label-md" style={{ color: 'var(--color-heritage-red, #a61d24)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||
请确认系统设置中已配置腾讯位置服务 Key,并白名单 apis.map.qq.com。
|
||||
</p>
|
||||
</div>
|
||||
) : src ? (
|
||||
<>
|
||||
{!iframeReady ? (
|
||||
<p
|
||||
className="label-md text-muted"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: 0,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
地图加载中…
|
||||
</p>
|
||||
) : null}
|
||||
<iframe
|
||||
title="地图选点"
|
||||
src={src}
|
||||
allow="geolocation *"
|
||||
onLoad={() => setIframeReady(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 0,
|
||||
display: 'block',
|
||||
background: '#fff',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user