diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index 42e62cd..cd49eea 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -15,10 +15,74 @@ body, 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; + 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 { white-space: nowrap; } diff --git a/apps/admin-web/src/components/AdminCellLine.tsx b/apps/admin-web/src/components/AdminCellLine.tsx new file mode 100644 index 0000000..ab210df --- /dev/null +++ b/apps/admin-web/src/components/AdminCellLine.tsx @@ -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 ( + + {main} + {sub ? ( + + {separator} + {sub} + + ) : null} + + ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 5a71690..139d7b1 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -16,6 +16,7 @@ import { FileTextOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; +import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; const { Header, Sider, Content } = Layout; @@ -105,6 +106,10 @@ export default function AdminLayout() { contentRef.current?.scrollTo({ top: 0, left: 0 }); }, [location.pathname]); + useEffect(() => { + return bindAdminEllipsisTitle(); + }, []); + function logout() { clearAuth(); navigate('/login'); @@ -164,6 +169,7 @@ export default function AdminLayout() {
diff --git a/apps/admin-web/src/lib/ellipsis-title.ts b/apps/admin-web/src/lib/ellipsis-title.ts new file mode 100644 index 0000000..82d6045 --- /dev/null +++ b/apps/admin-web/src/lib/ellipsis-title.ts @@ -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(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); +} diff --git a/apps/admin-web/src/pages/HqLogsPage.tsx b/apps/admin-web/src/pages/HqLogsPage.tsx index 16c32b9..bf552e5 100644 --- a/apps/admin-web/src/pages/HqLogsPage.tsx +++ b/apps/admin-web/src/pages/HqLogsPage.tsx @@ -5,6 +5,7 @@ import { import type { ColumnsType } from 'antd/es/table'; import { useSearchParams } from 'react-router-dom'; import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log'; import { useAdminList } from '../lib/useAdminList'; @@ -55,14 +56,13 @@ export default function HqLogsPage() { { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作人', - width: 160, + width: 200, + ellipsis: true, render: (_, r) => ( -
-
{r.hqName || '—'}
-
- {r.hqPhone || ''} {r.hqAccountId ? `(#${r.hqAccountId})` : ''} -
-
+ ), }, { @@ -170,7 +170,10 @@ export default function HqLogsPage() { {detail.id} {fmtTime(detail.createdAt)} - {detail.hqName} / {detail.hqPhone} (ID: {detail.hqAccountId}) + {detail.hqRole || '—'} diff --git a/apps/admin-web/src/pages/PartnerLogsPage.tsx b/apps/admin-web/src/pages/PartnerLogsPage.tsx index 9580874..02ba7a8 100644 --- a/apps/admin-web/src/pages/PartnerLogsPage.tsx +++ b/apps/admin-web/src/pages/PartnerLogsPage.tsx @@ -9,6 +9,7 @@ import { type PartnerLogCategory, } from '../lib/partner-log'; import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; @@ -71,21 +72,20 @@ export default function PartnerLogsPage() { { title: '合伙人', width: 180, + ellipsis: true, render: (_, r) => ( -
-
{r.companyName || '—'}
-
{r.partnerId}
-
+ ), }, { title: '账号', width: 150, + ellipsis: true, render: (_, r) => ( -
-
{r.accountName || '—'}
-
{r.accountPhone || r.partnerAccountId || '—'}
-
+ ), }, { diff --git a/apps/admin-web/src/pages/StoreLogsPage.tsx b/apps/admin-web/src/pages/StoreLogsPage.tsx index 5f9e193..99fb995 100644 --- a/apps/admin-web/src/pages/StoreLogsPage.tsx +++ b/apps/admin-web/src/pages/StoreLogsPage.tsx @@ -9,6 +9,7 @@ import { type StoreLogCategory, } from '../lib/store-log'; import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; @@ -82,21 +83,16 @@ export default function StoreLogsPage() { const columns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '门店', width: 180, - render: (_, r) => ( -
-
{r.storeName || '—'}
-
{r.storeId}
-
- ), + title: '门店', width: 180, ellipsis: true, + render: (_, r) => , }, { - title: '账号', width: 150, + title: '账号', width: 150, ellipsis: true, render: (_, r) => ( -
-
{r.accountName || '—'}
-
{r.accountPhone || r.storeAccountId || '系统/HQ'}
-
+ ), }, { diff --git a/apps/admin-web/src/pages/UserLogsPage.tsx b/apps/admin-web/src/pages/UserLogsPage.tsx index 7073bb5..c66044e 100644 --- a/apps/admin-web/src/pages/UserLogsPage.tsx +++ b/apps/admin-web/src/pages/UserLogsPage.tsx @@ -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 { useSearchParams } from 'react-router-dom'; import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; @@ -63,12 +64,12 @@ export default function UserLogsPage() { const columns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '用户', width: 160, + title: '用户', width: 180, ellipsis: true, render: (_, r) => ( -
-
{r.nickname || r.userNo || '—'}
-
{r.phone || r.userId || '—'}
-
+ ), }, { diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index cc17170..b0321f1 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -12,6 +12,7 @@ import { sessionFromWechatLogin, } from '../lib/wechat-auth'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; +import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import WechatScanAuthModal from '../components/WechatScanAuthModal'; const PENDING_SCAN_KEY = 'shop_pending_scan'; @@ -20,6 +21,14 @@ function formatMoney(n: number) { 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() { const navigate = useNavigate(); const { applySession } = useStoreSession(); @@ -51,11 +60,12 @@ export default function HomePage() { } setAuthModalOpen(false); setAuthError(''); + stripOAuthParamsFromLocation(); setSearchParams({}, { replace: true }); const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; sessionStorage.removeItem(PENDING_SCAN_KEY); if (shouldScan) { - void runScan(); + window.setTimeout(() => void runScan(), 0); } }) .catch((e) => { @@ -81,7 +91,7 @@ export default function HomePage() { } navigate(`/redeem?token=${encodeURIComponent(token)}`); } catch (e) { - setScanMsg(e instanceof Error ? e.message : '扫码失败,请重试'); + setScanMsg(formatScanError(e)); } finally { setScanning(false); } diff --git a/packages/weixin-sdk/src/chooseImage.ts b/packages/weixin-sdk/src/chooseImage.ts index 013d663..bfcced3 100644 --- a/packages/weixin-sdk/src/chooseImage.ts +++ b/packages/weixin-sdk/src/chooseImage.ts @@ -7,6 +7,39 @@ export type ChooseWechatImageOptions = { 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 = { + '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 { const normalized = base64.startsWith('data:') ? base64 @@ -52,30 +85,58 @@ export async function chooseWechatImages( if (platform === 'wechat-h5') { const pageUrl = typeof window !== 'undefined' ? window.location.href.split('#')[0] : ''; - await ensureJssdkReady({ - apiBase: config.apiBase ?? '/api/v1', - clientApp: config.clientApp, - getAccessToken: config.getAccessToken, - url: pageUrl, - jsApiList: ['chooseImage', 'getLocalImgData'], - }); + const sourceTypeKey = sourceType.join(','); + try { + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + 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) { - throw new Error('微信选图接口不可用,请刷新页面后重试'); + const errMsg = '微信选图接口不可用,请刷新页面后重试'; + void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' }); + throw new Error(errMsg); } - const localIds = await new Promise((resolve, reject) => { - window.wx!.chooseImage!({ - count, - sizeType: ['compressed'], - sourceType, - success: (res) => resolve(res.localIds ?? []), - fail: (err) => reject(new Error(err.errMsg || '无法打开相册')), + let localIds: string[]; + try { + localIds = await new Promise((resolve, reject) => { + window.wx!.chooseImage!({ + count, + sizeType: ['compressed'], + 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[] = []; 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; } diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index 3cec4f0..d94f1be 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -1,5 +1,5 @@ export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env'; -export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk'; +export { initWechatJssdk, ensureJssdkReady, isJssdkReady, normalizeJssdkPageUrl, stripOAuthParamsFromLocation } from './jssdk'; export { getWechatLocation, getWechatLocationDetailed, diff --git a/packages/weixin-sdk/src/jssdk.ts b/packages/weixin-sdk/src/jssdk.ts index 7b56c36..ec3dd42 100644 --- a/packages/weixin-sdk/src/jssdk.ts +++ b/packages/weixin-sdk/src/jssdk.ts @@ -8,7 +8,32 @@ let configured = false; let configuredUrl: string | null = null; 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 { diff --git a/server/dukang-api/src/integrations/wechat/wechat-log.util.ts b/server/dukang-api/src/integrations/wechat/wechat-log.util.ts index 06959f6..9cd9132 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-log.util.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-log.util.ts @@ -5,6 +5,11 @@ export type WechatActorRef = { refId: bigint; }; +export function wechatActorRefFromAuth(actorType?: string, actorId?: bigint): WechatActorRef | undefined { + if (!actorType || actorId == null) return undefined; + return { refType: actorType, refId: actorId }; +} + type LogWechatAuthInput = { scene: string; requestUrl?: string; diff --git a/server/dukang-api/src/modules/common/wechat-location.service.ts b/server/dukang-api/src/modules/common/wechat-location.service.ts index 2ef7068..95fd68f 100644 --- a/server/dukang-api/src/modules/common/wechat-location.service.ts +++ b/server/dukang-api/src/modules/common/wechat-location.service.ts @@ -128,4 +128,29 @@ export class WechatLocationService { 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() }; + } } diff --git a/server/dukang-api/src/modules/common/wechat.controller.ts b/server/dukang-api/src/modules/common/wechat.controller.ts index 66ca90c..6589430 100644 --- a/server/dukang-api/src/modules/common/wechat.controller.ts +++ b/server/dukang-api/src/modules/common/wechat.controller.ts @@ -6,6 +6,7 @@ import { WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-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'; class PhoneNumberDto { @@ -41,6 +42,29 @@ class WechatLocationDto { 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') export class WechatController { constructor( @@ -51,13 +75,27 @@ export class WechatController { @Get('jssdk-config') async jssdkConfig(@Query('url') url: string, @Req() req: Request) { 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 actorRef = user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined; 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') oauthUrl( @Query('redirectUri') redirectUri: string, @@ -91,4 +129,18 @@ export class WechatController { 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), + }); + } }