Merge #19 into dev from dev_jacy
商铺端核销,调用相机报无效签名修复 * dev_jacy: (3 commits) 微信登录日志 后端webadmin调整显示布局 商铺端核销,调用相机报无效签名修复 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/19
This commit is contained in:
@@ -15,10 +15,74 @@ body,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table-nowrap .ant-table-cell {
|
/*
|
||||||
|
* 后台内容行:不换行 + 超出省略
|
||||||
|
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
|
||||||
|
*/
|
||||||
|
.admin-layout .ant-table-cell,
|
||||||
|
.ant-drawer .ant-table-cell,
|
||||||
|
.ant-modal .ant-table-cell,
|
||||||
|
.admin-layout .ant-descriptions-item-content,
|
||||||
|
.ant-drawer .ant-descriptions-item-content,
|
||||||
|
.ant-modal .ant-descriptions-item-content {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-layout .ant-descriptions-item-content,
|
||||||
|
.ant-drawer .ant-descriptions-item-content,
|
||||||
|
.ant-modal .ant-descriptions-item-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头同样不换行 */
|
||||||
|
.admin-layout .ant-table-thead > tr > th,
|
||||||
|
.ant-drawer .ant-table-thead > tr > th,
|
||||||
|
.ant-modal .ant-table-thead > tr > th {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作列、含交互控件的不截断 */
|
||||||
|
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||||
|
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||||
|
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||||
|
.admin-layout .ant-table-cell:has(.ant-space),
|
||||||
|
.ant-drawer .ant-table-cell:has(.ant-space),
|
||||||
|
.ant-modal .ant-table-cell:has(.ant-space) {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块、表单区域保持原样 */
|
||||||
|
.admin-layout pre,
|
||||||
|
.ant-drawer pre,
|
||||||
|
.ant-modal pre,
|
||||||
|
.admin-layout .ant-form-item-control-input,
|
||||||
|
.ant-drawer .ant-form-item-control-input,
|
||||||
|
.ant-modal .ant-form-item-control-input {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: auto;
|
||||||
|
text-overflow: unset;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格单元格双行信息合并为单行省略 */
|
||||||
|
.admin-cell-line {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell-line-secondary {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-nowrap .ant-table-cell,
|
||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
type AdminCellLineProps = {
|
||||||
|
primary?: string | null;
|
||||||
|
secondary?: string | null;
|
||||||
|
separator?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
||||||
|
export function AdminCellLine({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
separator = ' · ',
|
||||||
|
}: AdminCellLineProps) {
|
||||||
|
const main = primary?.trim() || '—';
|
||||||
|
const sub = secondary?.trim();
|
||||||
|
const full = sub ? `${main}${separator}${sub}` : main;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
className="admin-cell-line"
|
||||||
|
ellipsis={{ tooltip: full }}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
>
|
||||||
|
{main}
|
||||||
|
{sub ? (
|
||||||
|
<span className="admin-cell-line-secondary">
|
||||||
|
{separator}
|
||||||
|
{sub}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -105,6 +106,10 @@ export default function AdminLayout() {
|
|||||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return bindAdminEllipsisTitle();
|
||||||
|
}, []);
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -164,6 +169,7 @@ export default function AdminLayout() {
|
|||||||
</Header>
|
</Header>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
className="admin-layout"
|
||||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
<Content style={{ margin: 24 }}>
|
<Content style={{ margin: 24 }}>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const ELLIPSIS_TARGETS =
|
||||||
|
'.ant-table-cell, .ant-descriptions-item-content, .ant-typography';
|
||||||
|
|
||||||
|
const ELLIPSIS_SKIP =
|
||||||
|
'.ant-btn, .ant-input, .ant-select, .ant-picker, .ant-checkbox, .ant-radio, pre, textarea, input, select, button, a.ant-btn';
|
||||||
|
|
||||||
|
function isTruncated(el: HTMLElement): boolean {
|
||||||
|
return el.scrollWidth > el.clientWidth + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEllipsisElement(target: EventTarget | null): HTMLElement | null {
|
||||||
|
if (!(target instanceof HTMLElement)) return null;
|
||||||
|
const cell = target.closest<HTMLElement>(ELLIPSIS_TARGETS);
|
||||||
|
if (!cell) return null;
|
||||||
|
if (cell.closest('.admin-cell-line')) return null;
|
||||||
|
if (cell.closest(ELLIPSIS_SKIP)) return null;
|
||||||
|
if (cell.querySelector(ELLIPSIS_SKIP)) return null;
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindAdminEllipsisTitle(root: HTMLElement | Document = document) {
|
||||||
|
const onMouseOver = (event: MouseEvent) => {
|
||||||
|
const el = resolveEllipsisElement(event.target);
|
||||||
|
if (!el) return;
|
||||||
|
if (isTruncated(el)) {
|
||||||
|
const text = el.innerText.replace(/\s+/g, ' ').trim();
|
||||||
|
if (text && el.getAttribute('title') !== text) {
|
||||||
|
el.setAttribute('title', text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('title');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener('mouseover', onMouseOver, true);
|
||||||
|
return () => root.removeEventListener('mouseover', onMouseOver, true);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -55,14 +56,13 @@ export default function HqLogsPage() {
|
|||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作人',
|
title: '操作人',
|
||||||
width: 160,
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<AdminCellLine
|
||||||
<div>{r.hqName || '—'}</div>
|
primary={r.hqName}
|
||||||
<div style={{ color: '#999', fontSize: 12 }}>
|
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||||
{r.hqPhone || ''} {r.hqAccountId ? `(#${r.hqAccountId})` : ''}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -170,7 +170,10 @@ export default function HqLogsPage() {
|
|||||||
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
|
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="操作人">
|
<Descriptions.Item label="操作人">
|
||||||
{detail.hqName} / {detail.hqPhone} (ID: {detail.hqAccountId})
|
<AdminCellLine
|
||||||
|
primary={detail.hqName}
|
||||||
|
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||||
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="行为">
|
<Descriptions.Item label="行为">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type PartnerLogCategory,
|
type PartnerLogCategory,
|
||||||
} from '../lib/partner-log';
|
} from '../lib/partner-log';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
@@ -71,21 +72,20 @@ export default function PartnerLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 180,
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
|
||||||
<div>{r.companyName || '—'}</div>
|
|
||||||
<div style={{ color: '#999', fontSize: 12 }}>{r.partnerId}</div>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账号',
|
title: '账号',
|
||||||
width: 150,
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<AdminCellLine
|
||||||
<div>{r.accountName || '—'}</div>
|
primary={r.accountName}
|
||||||
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.partnerAccountId || '—'}</div>
|
secondary={r.accountPhone || r.partnerAccountId}
|
||||||
</div>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type StoreLogCategory,
|
type StoreLogCategory,
|
||||||
} from '../lib/store-log';
|
} from '../lib/store-log';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
@@ -82,21 +83,16 @@ export default function StoreLogsPage() {
|
|||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '门店', width: 180,
|
title: '门店', width: 180, ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
|
||||||
<div>
|
|
||||||
<div>{r.storeName || '—'}</div>
|
|
||||||
<div style={{ color: '#999', fontSize: 12 }}>{r.storeId}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账号', width: 150,
|
title: '账号', width: 150, ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<AdminCellLine
|
||||||
<div>{r.accountName || '—'}</div>
|
primary={r.accountName}
|
||||||
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.storeAccountId || '系统/HQ'}</div>
|
secondary={r.accountPhone || r.storeAccountId || '系统/HQ'}
|
||||||
</div>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
|
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
@@ -63,12 +64,12 @@ export default function UserLogsPage() {
|
|||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '用户', width: 160,
|
title: '用户', width: 180, ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<AdminCellLine
|
||||||
<div>{r.nickname || r.userNo || '—'}</div>
|
primary={r.nickname || r.userNo}
|
||||||
<div style={{ color: '#999', fontSize: 12 }}>{r.phone || r.userId || '—'}</div>
|
secondary={r.phone || r.userId}
|
||||||
</div>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
sessionFromWechatLogin,
|
sessionFromWechatLogin,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||||
|
|
||||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||||
@@ -20,6 +21,14 @@ function formatMoney(n: number) {
|
|||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatScanError(e: unknown): string {
|
||||||
|
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||||
|
if (/invalid signature/i.test(msg)) {
|
||||||
|
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 shop.runxian.top,并刷新页面后重试';
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
const { applySession } = useStoreSession();
|
||||||
@@ -51,11 +60,12 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
|
stripOAuthParamsFromLocation();
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
if (shouldScan) {
|
if (shouldScan) {
|
||||||
void runScan();
|
window.setTimeout(() => void runScan(), 0);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -81,7 +91,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setScanMsg(e instanceof Error ? e.message : '扫码失败,请重试');
|
setScanMsg(formatScanError(e));
|
||||||
} finally {
|
} finally {
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,39 @@ export type ChooseWechatImageOptions = {
|
|||||||
sourceType?: Array<'album' | 'camera'>;
|
sourceType?: Array<'album' | 'camera'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function reportChooseImageEvent(
|
||||||
|
config: WeixinSdkConfig,
|
||||||
|
payload: {
|
||||||
|
status: 'fail';
|
||||||
|
errMsg: string;
|
||||||
|
sourceType?: string;
|
||||||
|
stage: 'jssdk' | 'choose' | 'read' | 'empty';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (/cancel/i.test(payload.errMsg)) return;
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': config.clientApp,
|
||||||
|
};
|
||||||
|
const token = config.getAccessToken?.();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
await fetch(`${config.apiBase ?? '/api/v1'}/common/wechat/choose-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'fail',
|
||||||
|
errMsg: payload.errMsg,
|
||||||
|
sourceType: payload.sourceType,
|
||||||
|
stage: payload.stage,
|
||||||
|
pageUrl: typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* 上报失败不影响主流程 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function base64ToFile(base64: string, fileName: string): File {
|
function base64ToFile(base64: string, fileName: string): File {
|
||||||
const normalized = base64.startsWith('data:')
|
const normalized = base64.startsWith('data:')
|
||||||
? base64
|
? base64
|
||||||
@@ -52,30 +85,58 @@ export async function chooseWechatImages(
|
|||||||
|
|
||||||
if (platform === 'wechat-h5') {
|
if (platform === 'wechat-h5') {
|
||||||
const pageUrl = typeof window !== 'undefined' ? window.location.href.split('#')[0] : '';
|
const pageUrl = typeof window !== 'undefined' ? window.location.href.split('#')[0] : '';
|
||||||
await ensureJssdkReady({
|
const sourceTypeKey = sourceType.join(',');
|
||||||
apiBase: config.apiBase ?? '/api/v1',
|
try {
|
||||||
clientApp: config.clientApp,
|
await ensureJssdkReady({
|
||||||
getAccessToken: config.getAccessToken,
|
apiBase: config.apiBase ?? '/api/v1',
|
||||||
url: pageUrl,
|
clientApp: config.clientApp,
|
||||||
jsApiList: ['chooseImage', 'getLocalImgData'],
|
getAccessToken: config.getAccessToken,
|
||||||
});
|
url: pageUrl,
|
||||||
|
jsApiList: ['chooseImage', 'getLocalImgData'],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const errMsg = e instanceof Error ? e.message : 'JSSDK 初始化失败';
|
||||||
|
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
if (!window.wx?.chooseImage) {
|
if (!window.wx?.chooseImage) {
|
||||||
throw new Error('微信选图接口不可用,请刷新页面后重试');
|
const errMsg = '微信选图接口不可用,请刷新页面后重试';
|
||||||
|
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' });
|
||||||
|
throw new Error(errMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localIds = await new Promise<string[]>((resolve, reject) => {
|
let localIds: string[];
|
||||||
window.wx!.chooseImage!({
|
try {
|
||||||
count,
|
localIds = await new Promise<string[]>((resolve, reject) => {
|
||||||
sizeType: ['compressed'],
|
window.wx!.chooseImage!({
|
||||||
sourceType,
|
count,
|
||||||
success: (res) => resolve(res.localIds ?? []),
|
sizeType: ['compressed'],
|
||||||
fail: (err) => reject(new Error(err.errMsg || '无法打开相册')),
|
sourceType,
|
||||||
|
success: (res) => resolve(res.localIds ?? []),
|
||||||
|
fail: (err) => reject(new Error(err.errMsg || '无法打开相册')),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
} catch (e) {
|
||||||
|
const errMsg = e instanceof Error ? e.message : '无法打开相册';
|
||||||
|
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'choose' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localIds.length) {
|
||||||
|
const errMsg = '未选择图片';
|
||||||
|
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'empty' });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
for (const localId of localIds) {
|
for (const localId of localIds) {
|
||||||
files.push(await localIdToFile(localId));
|
try {
|
||||||
|
files.push(await localIdToFile(localId));
|
||||||
|
} catch (e) {
|
||||||
|
const errMsg = e instanceof Error ? e.message : '读取图片失败';
|
||||||
|
void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'read' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
||||||
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
|
export { initWechatJssdk, ensureJssdkReady, isJssdkReady, normalizeJssdkPageUrl, stripOAuthParamsFromLocation } from './jssdk';
|
||||||
export {
|
export {
|
||||||
getWechatLocation,
|
getWechatLocation,
|
||||||
getWechatLocationDetailed,
|
getWechatLocationDetailed,
|
||||||
|
|||||||
@@ -8,7 +8,32 @@ let configured = false;
|
|||||||
let configuredUrl: string | null = null;
|
let configuredUrl: string | null = null;
|
||||||
|
|
||||||
function currentPageUrl() {
|
function currentPageUrl() {
|
||||||
return typeof window !== 'undefined' ? window.location.href.split('#')[0] : '';
|
return typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 参与 JSSDK 签名的页面 URL(去掉 hash、OAuth 回跳参数) */
|
||||||
|
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
url.hash = '';
|
||||||
|
url.searchParams.delete('code');
|
||||||
|
url.searchParams.delete('state');
|
||||||
|
const query = url.searchParams.toString();
|
||||||
|
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||||
|
} catch {
|
||||||
|
return rawUrl.split('#')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripOAuthParamsFromLocation(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
|
||||||
|
url.searchParams.delete('code');
|
||||||
|
url.searchParams.delete('state');
|
||||||
|
const query = url.searchParams.toString();
|
||||||
|
const next = `${url.pathname}${query ? `?${query}` : ''}${url.hash}`;
|
||||||
|
window.history.replaceState({}, '', next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadScript(): Promise<void> {
|
function loadScript(): Promise<void> {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export type WechatActorRef = {
|
|||||||
refId: bigint;
|
refId: bigint;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function wechatActorRefFromAuth(actorType?: string, actorId?: bigint): WechatActorRef | undefined {
|
||||||
|
if (!actorType || actorId == null) return undefined;
|
||||||
|
return { refType: actorType, refId: actorId };
|
||||||
|
}
|
||||||
|
|
||||||
type LogWechatAuthInput = {
|
type LogWechatAuthInput = {
|
||||||
scene: string;
|
scene: string;
|
||||||
requestUrl?: string;
|
requestUrl?: string;
|
||||||
|
|||||||
@@ -128,4 +128,29 @@ export class WechatLocationService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reportChooseImage(input: {
|
||||||
|
status: 'success' | 'fail';
|
||||||
|
errMsg?: string;
|
||||||
|
sourceType?: string;
|
||||||
|
stage?: string;
|
||||||
|
pageUrl?: string;
|
||||||
|
actorRef?: WechatActorRef;
|
||||||
|
}) {
|
||||||
|
const logId = await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'CHOOSE_IMAGE',
|
||||||
|
requestUrl: input.pageUrl?.split('#')[0]?.slice(0, 512),
|
||||||
|
requestBody: {
|
||||||
|
status: input.status,
|
||||||
|
...(input.sourceType ? { sourceType: input.sourceType } : {}),
|
||||||
|
...(input.stage ? { stage: input.stage } : {}),
|
||||||
|
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
|
||||||
|
},
|
||||||
|
responseBody: { reported: true },
|
||||||
|
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
|
||||||
|
actorRef: input.actorRef,
|
||||||
|
});
|
||||||
|
return { ok: true, logId: logId.toString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
|||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { wechatActorRefFromAuth } from '../../integrations/wechat/wechat-log.util';
|
||||||
import { WechatLocationService } from './wechat-location.service';
|
import { WechatLocationService } from './wechat-location.service';
|
||||||
|
|
||||||
class PhoneNumberDto {
|
class PhoneNumberDto {
|
||||||
@@ -41,6 +42,29 @@ class WechatLocationDto {
|
|||||||
errMsg?: string;
|
errMsg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WechatChooseImageDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['success', 'fail'])
|
||||||
|
status: 'success' | 'fail';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
errMsg?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sourceType?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['jssdk', 'choose', 'read', 'empty'])
|
||||||
|
@IsOptional()
|
||||||
|
stage?: 'jssdk' | 'choose' | 'read' | 'empty';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
pageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('common/wechat')
|
@Controller('common/wechat')
|
||||||
export class WechatController {
|
export class WechatController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -51,13 +75,27 @@ export class WechatController {
|
|||||||
@Get('jssdk-config')
|
@Get('jssdk-config')
|
||||||
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
|
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
|
||||||
if (!url) throw new BadRequestException('url 参数必填');
|
if (!url) throw new BadRequestException('url 参数必填');
|
||||||
const pageUrl = decodeURIComponent(url).split('#')[0];
|
const decoded = decodeURIComponent(url).split('#')[0];
|
||||||
|
const pageUrl = this.normalizeJssdkUrl(decoded);
|
||||||
const user = (req as Request & { user?: AuthUser }).user;
|
const user = (req as Request & { user?: AuthUser }).user;
|
||||||
const actorRef =
|
const actorRef =
|
||||||
user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined;
|
user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined;
|
||||||
return this.wechat.createJssdkConfig(pageUrl, actorRef);
|
return this.wechat.createJssdkConfig(pageUrl, actorRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeJssdkUrl(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.searchParams.delete('code');
|
||||||
|
parsed.searchParams.delete('state');
|
||||||
|
const query = parsed.searchParams.toString();
|
||||||
|
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
|
||||||
|
} catch {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('oauth-url')
|
@Get('oauth-url')
|
||||||
oauthUrl(
|
oauthUrl(
|
||||||
@Query('redirectUri') redirectUri: string,
|
@Query('redirectUri') redirectUri: string,
|
||||||
@@ -91,4 +129,18 @@ export class WechatController {
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('choose-image')
|
||||||
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
|
reportChooseImage(@Req() req: Request, @Body() dto: WechatChooseImageDto) {
|
||||||
|
const user = (req as Request & { user?: AuthUser }).user;
|
||||||
|
return this.locationService.reportChooseImage({
|
||||||
|
status: dto.status,
|
||||||
|
errMsg: dto.errMsg,
|
||||||
|
sourceType: dto.sourceType,
|
||||||
|
stage: dto.stage,
|
||||||
|
pageUrl: dto.pageUrl,
|
||||||
|
actorRef: wechatActorRefFromAuth(user?.actorType, user?.actorId),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user