1.01版本 #2

Merged
zzh merged 2 commits from test into master 2026-10-08 17:43:15 +08:00
19 changed files with 695 additions and 372 deletions

View File

@ -68,14 +68,39 @@ export async function trackOrderGetOrderOperationsByIdGet(
); );
} }
/** 获取订单类型字典 GET /Track/Order/GetOrderTypeDict */ /**
* 获取订单类型字典。
* 轨迹后端无 /Track/Order/GetOrderTypeDict(404),统一走 Dictionary/GetList。
* 返回形态兼容旧调用方:`{ data: Record<string, string> }`。
*/
export async function trackOrderGetOrderTypeDictGet(options?: { export async function trackOrderGetOrderTypeDictGet(options?: {
[key: string]: any; [key: string]: any;
}) { }) {
return request<Record<string, any>>(`trackApi/Track/Order/GetOrderTypeDict`, { const list = await request<API.EnumCacheEntry[] | Record<string, any>>(
`trackApi/Dictionary/GetList`,
{
method: 'GET', method: 'GET',
params: {
type: 'TrackOrderType',
},
...(options || {}), ...(options || {}),
}); },
);
if (Array.isArray(list)) {
const dict: Record<string, string> = {};
for (const item of list) {
if (item?.key == null) continue;
dict[String(item.key)] = String(item.value ?? '');
}
return { data: dict };
}
if (list && typeof list === 'object' && (list as any).data != null) {
return list as { data: Record<string, any> };
}
return { data: (list as Record<string, any>) || {} };
} }
/** 获取分页的轨迹订单 POST /Track/Order/GetTrackOrderList */ /** 获取分页的轨迹订单 POST /Track/Order/GetTrackOrderList */

View File

@ -219,6 +219,8 @@
/** 编辑写回源数据时跳过 data watch,避免打断输入 */ /** 编辑写回源数据时跳过 data watch,避免打断输入 */
let ignoreDataWatch = false; let ignoreDataWatch = false;
/** loadData / 程序化选区期间跳过 selection 回写,避免清空业务页已选 */
let selectionLock = 0;
/** 表头初始化/销毁期间不向业务页 emit search,避免首屏重复拉接口 */ /** 表头初始化/销毁期间不向业务页 emit search,避免首屏重复拉接口 */
let headerSearchEmitReady = false; let headerSearchEmitReady = false;
let headerSearchReadyTimer: ReturnType<typeof setTimeout> | null = null; let headerSearchReadyTimer: ReturnType<typeof setTimeout> | null = null;
@ -620,6 +622,54 @@
return props.data[rowIndex]; return props.data[rowIndex];
} }
function publishSelection(rows: any[], indices: Set<number>) {
selectedRows.value = rows;
selectedRowIndicesSet.value = indices;
emit('update:selectedRows', rows);
emit('selection-change', rows);
applySelectedRowStyles();
}
/** 复选框列为批量操作真相源:按 checked 汇总选中行 */
function syncSelectionFromCheckboxes() {
if (!props.showCheckboxColumn) return;
const visualCount =
displayRowIndices.value.length > 0 ? displayRowIndices.value.length : props.data.length;
const indices = new Set<number>();
const rows: any[] = [];
for (let visual = 0; visual < visualCount; visual++) {
const row = rowAt(visual);
if (row && (row as any).checked) {
indices.add(visual);
rows.push(row);
}
}
publishSelection(rows, indices);
}
function shouldKeepSelectionOnOutsideClick(event: MouseEvent): boolean {
const target = event?.target as HTMLElement | null;
if (!target) return false;
// 点工具栏/分页/下拉/弹层时保留选中,避免「选中后点立即支付已清空」
return !!target.closest(
[
'.table-toolbar',
'.actions-right',
'.toolbar-right',
'.toolbar-left',
'.arco-btn',
'.arco-dropdown',
'.arco-dropdown-option',
'.arco-trigger-popup',
'.arco-pagination',
'.arco-modal',
'.arco-drawer',
'.arco-select-dropdown',
'.column-config-icon-btn',
].join(','),
);
}
function buildRowDataArray(row: any, visualIndex: number): any[] { function buildRowDataArray(row: any, visualIndex: number): any[] {
const rowData: any[] = []; const rowData: any[] = [];
// 固定:操作 → 序号 → 复选框 → 数据 // 固定:操作 → 序号 → 复选框 → 数据
@ -1343,6 +1393,7 @@
if (!changes || source === 'loadData') return; if (!changes || source === 'loadData') return;
const { checkboxColIndex, dataColStart } = getLayoutIndices(); const { checkboxColIndex, dataColStart } = getLayoutIndices();
ignoreDataWatch = true; ignoreDataWatch = true;
let checkboxChanged = false;
try { try {
changes.forEach(([row, col, oldValue, newValue]: any[]) => { changes.forEach(([row, col, oldValue, newValue]: any[]) => {
const rowData = rowAt(row); const rowData = rowAt(row);
@ -1350,6 +1401,7 @@
if (col === checkboxColIndex) { if (col === checkboxColIndex) {
(rowData as any).checked = newValue || false; (rowData as any).checked = newValue || false;
checkboxChanged = true;
emit('checkbox-change', rowData, newValue || false, row); emit('checkbox-change', rowData, newValue || false, row);
return; return;
} }
@ -1375,6 +1427,9 @@
oldValue, oldValue,
}); });
}); });
if (checkboxChanged) {
syncSelectionFromCheckboxes();
}
} finally { } finally {
// 可编辑模式:值已写入源对象且 HOT 自身已更新,禁止 loadData, // 可编辑模式:值已写入源对象且 HOT 自身已更新,禁止 loadData,
// 否则会打断连续编辑 / 关掉下拉。仅在 nextTick 后恢复 data watch。 // 否则会打断连续编辑 / 关掉下拉。仅在 nextTick 后恢复 data watch。
@ -1398,6 +1453,11 @@
colHeaders: headerTexts, colHeaders: headerTexts,
rowHeaders: false, rowHeaders: false,
selectionMode: 'multiple', selectionMode: 'multiple',
// 点工具栏/分页时保留选中,避免批量按钮拿到空选区
outsideClickDeselects(event: MouseEvent) {
if (props.showCheckboxColumn) return false;
return !shouldKeepSelectionOnOutsideClick(event);
},
width: '100%', width: '100%',
height: tableHeight, height: tableHeight,
licenseKey: 'non-commercial-and-evaluation', licenseKey: 'non-commercial-and-evaluation',
@ -1780,12 +1840,16 @@
return cellProperties; return cellProperties;
}, },
afterSelectionEnd: function(r: number, c: number, r2: number, c2: number) { afterSelectionEnd: function(r: number, c: number, r2: number, c2: number) {
if (!hotInstance) return; if (!hotInstance || selectionLock) return;
// 开启复选框时,批量选中以 checked 为准,避免单元格选区覆盖
if (props.showCheckboxColumn) {
applySelectedRowStyles();
} else {
const selected = hotInstance.getSelected() || []; const selected = hotInstance.getSelected() || [];
const selectedRowIndices = new Set<number>(); const selectedRowIndices = new Set<number>();
selected.forEach(([rowStart, colStart, rowEnd, colEnd]: number[]) => { selected.forEach(([rowStart, _colStart, rowEnd, _colEnd]: number[]) => {
const startRow = Math.min(rowStart, rowEnd); const startRow = Math.min(rowStart, rowEnd);
const endRow = Math.max(rowStart, rowEnd); const endRow = Math.max(rowStart, rowEnd);
for (let i = startRow; i <= endRow; i++) { for (let i = startRow; i <= endRow; i++) {
@ -1795,16 +1859,11 @@
} }
}); });
selectedRowIndicesSet.value = selectedRowIndices; publishSelection(
selectedRows.value = Array.from(selectedRowIndices) Array.from(selectedRowIndices).map((index) => rowAt(index)).filter(Boolean),
.map(index => rowAt(index)) selectedRowIndices,
.filter(Boolean); );
}
emit('update:selectedRows', selectedRows.value);
emit('selection-change', selectedRows.value);
// 应用选中行样式
applySelectedRowStyles();
// 可编辑模式:单击单元格即进入编辑(表单录入体验) // 可编辑模式:单击单元格即进入编辑(表单录入体验)
if ( if (
@ -1865,7 +1924,7 @@
afterOnCellMouseDown: function(event: any, coords: any, td: any) { afterOnCellMouseDown: function(event: any, coords: any, td: any) {
const { actionColIndex, serialColIndex, checkboxColIndex, dataColStart } = getLayoutIndices(); const { actionColIndex, serialColIndex, checkboxColIndex, dataColStart } = getLayoutIndices();
// 复选框列 // 复选框列(自定义 DOM,不走内置 editor)
if (coords.col === checkboxColIndex) { if (coords.col === checkboxColIndex) {
const target = event.target; const target = event.target;
if (target.type === 'checkbox') { if (target.type === 'checkbox') {
@ -1873,8 +1932,16 @@
if (rowIndex >= 0 && rowIndex < props.data.length) { if (rowIndex >= 0 && rowIndex < props.data.length) {
const row = rowAt(rowIndex); const row = rowAt(rowIndex);
const checked = target.checked; const checked = target.checked;
ignoreDataWatch = true;
try {
(row as any).checked = checked; (row as any).checked = checked;
syncSelectionFromCheckboxes();
emit('checkbox-change', row, checked, rowIndex); emit('checkbox-change', row, checked, rowIndex);
} finally {
nextTick(() => {
ignoreDataWatch = false;
});
}
} }
} }
} }
@ -2290,8 +2357,14 @@
} }
if (!props.data.length) { if (!props.data.length) {
displayRowIndices.value = []; displayRowIndices.value = [];
selectionLock += 1;
try {
hotInstance.loadData([]); hotInstance.loadData([]);
hotInstance.render(); hotInstance.render();
publishSelection([], new Set());
} finally {
selectionLock -= 1;
}
return; return;
} }
@ -2310,9 +2383,19 @@
} }
const data = syncDisplayRows(); const data = syncDisplayRows();
selectionLock += 1;
try {
hotInstance.loadData(data); hotInstance.loadData(data);
hotInstance.render(); hotInstance.render();
// loadData 会清掉 HOT 单元格选区;复选框模式按 checked 回写业务选中
if (props.showCheckboxColumn) {
syncSelectionFromCheckboxes();
} else {
applySelectedRowStyles();
}
} finally {
selectionLock -= 1;
}
// 刷新尺寸以确保 Handsontable 内置滚动视口(.wtHolder)尺寸正确 // 刷新尺寸以确保 Handsontable 内置滚动视口(.wtHolder)尺寸正确
setTimeout(() => { setTimeout(() => {
@ -2642,10 +2725,26 @@
refresh: updateData, refresh: updateData,
getSelectedRows: () => selectedRows.value, getSelectedRows: () => selectedRows.value,
clearSelection: () => { clearSelection: () => {
selectionLock += 1;
try {
if (hotInstance) { if (hotInstance) {
hotInstance.deselectCell(); hotInstance.deselectCell();
selectedRows.value = []; }
selectedRowIndicesSet.value = new Set(); if (props.showCheckboxColumn) {
ignoreDataWatch = true;
try {
props.data.forEach((row) => {
if (row) (row as any).checked = false;
});
} finally {
nextTick(() => {
ignoreDataWatch = false;
});
}
}
publishSelection([], new Set());
} finally {
selectionLock -= 1;
} }
}, },
}); });

View File

@ -149,17 +149,81 @@ async function buildPrintStreamKeyCandidates(rawUrl: string): Promise<string[]>
return keys return keys
} }
function stripUtf8Bom(bytes: Uint8Array): Uint8Array { /** 去掉 BOM / 前导空白,避免面单生成器偶发换行导致 magic 校验失败 */
function stripLeadingNoise(bytes: Uint8Array): Uint8Array {
let start = 0
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
return bytes.slice(3) start = 3
} }
return bytes while (start < bytes.length) {
const b = bytes[start]
if (b === 0x09 || b === 0x0a || b === 0x0d || b === 0x20) {
start += 1
continue
}
break
}
return start === 0 ? bytes : bytes.slice(start)
} }
function bytesStartWithPdfMagic(bytes: Uint8Array): boolean { function bytesStartWithPdfMagic(bytes: Uint8Array): boolean {
const head = bytes.slice(0, 5) // 标准 PDF 头为 "%PDF-1.x";此前误用 5 字节与 4 字符 "%PDF" 比较,合法面单也会被拒
if (head.length < 5) return false if (bytes.length < PDF_MAGIC.length) return false
return String.fromCharCode(...head) === PDF_MAGIC const head = String.fromCharCode(...bytes.slice(0, PDF_MAGIC.length))
return head === PDF_MAGIC
}
function sniffMimeFromBytes(bytes: Uint8Array): string | null {
if (bytesStartWithPdfMagic(bytes)) return 'application/pdf'
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return 'image/png'
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg'
}
if (
bytes.length >= 6 &&
bytes[0] === 0x47 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x38
) {
return 'image/gif'
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return 'image/webp'
}
const sample = new TextDecoder('utf-8', { fatal: false }).decode(bytes.slice(0, 64)).trimStart()
if (/^<!DOCTYPE\s+html/i.test(sample) || /^<html[\s>]/i.test(sample)) {
return 'text/html; charset=utf-8'
}
return null
}
/** Base64 文本面单(常见于下游回传):JVBERi… → %PDF… */
function tryDecodeBase64Pdf(bytes: Uint8Array): Uint8Array | null {
const sample = new TextDecoder('utf-8', { fatal: false }).decode(bytes.slice(0, 32)).trim()
if (!sample.startsWith('JVBERi')) return null
try {
const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes).trim()
const pure = text.replace(/^data:application\/pdf;base64,/i, '').replace(/\s+/g, '')
const bin = atob(pure)
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i)
return bytesStartWithPdfMagic(out) ? out : null
} catch {
return null
}
} }
async function throwIfBytesLookLikeApiError(bytes: Uint8Array) { async function throwIfBytesLookLikeApiError(bytes: Uint8Array) {
@ -182,20 +246,47 @@ async function throwIfBytesLookLikeApiError(bytes: Uint8Array) {
} }
} }
/** 校验并规范化 PDF 字节(拒绝 JSON/HTML 错误体) */ type NormalizedPreviewBytes = { bytes: Uint8Array; mime: string }
export async function normalizePdfBytes(blob: Blob, rawUrl: string): Promise<Uint8Array> {
if (!isPrintPreviewPdf(rawUrl, blob)) { /** 规范化预览字节:按内容嗅探 MIME;扩展名/响应头标成 PDF 时再严格校验 */
return new Uint8Array(await blob.arrayBuffer()) async function normalizePreviewBytes(blob: Blob, rawUrl: string): Promise<NormalizedPreviewBytes> {
let bytes = stripLeadingNoise(new Uint8Array(await blob.arrayBuffer()))
const decoded = tryDecodeBase64Pdf(bytes)
if (decoded) bytes = decoded
const sniffed = sniffMimeFromBytes(bytes)
if (sniffed) {
return { bytes, mime: sniffed }
} }
let bytes = stripUtf8Bom(new Uint8Array(await blob.arrayBuffer()))
if (!bytesStartWithPdfMagic(bytes)) {
await throwIfBytesLookLikeApiError(bytes) await throwIfBytesLookLikeApiError(bytes)
if (isPrintPreviewPdf(rawUrl, blob)) {
throw new Error('面单文件不是有效的 PDF') throw new Error('面单文件不是有效的 PDF')
} }
const fallback =
(blob.type && !/octet-stream|force-download/i.test(blob.type) && blob.type) ||
guessMime(rawUrl) ||
'application/octet-stream'
return { bytes, mime: fallback }
}
/** 校验并规范化 PDF 字节(拒绝 JSON/HTML 错误体;图片/HTML 面单原样返回) */
export async function normalizePdfBytes(blob: Blob, rawUrl: string): Promise<Uint8Array> {
const { bytes, mime } = await normalizePreviewBytes(blob, rawUrl)
if (mime.includes('pdf')) {
if (!bytesStartWithPdfMagic(bytes)) {
throw new Error('面单文件不是有效的 PDF')
}
}
return bytes return bytes
} }
function blobFromNormalized(normalized: NormalizedPreviewBytes): Blob {
return new Blob([normalized.bytes], { type: normalized.mime })
}
async function fetchBlobViaSameOriginPath(path: string): Promise<Blob> { async function fetchBlobViaSameOriginPath(path: string): Promise<Blob> {
const res = await axiosInstance.get(path, { const res = await axiosInstance.get(path, {
responseType: 'blob', responseType: 'blob',
@ -232,14 +323,8 @@ async function fetchBlobViaCosProxy(rawUrl: string): Promise<Blob> {
throw new Error(`COS 代理下载失败 HTTP ${res.status}`) throw new Error(`COS 代理下载失败 HTTP ${res.status}`)
} }
const mime = String(res.headers.get('content-type') || guessMime(rawUrl)) const mime = String(res.headers.get('content-type') || guessMime(rawUrl))
const bytes = stripUtf8Bom(new Uint8Array(await res.arrayBuffer())) const rawBlob = new Blob([await res.arrayBuffer()], { type: mime })
await throwIfBytesLookLikeApiError(bytes) return blobFromNormalized(await normalizePreviewBytes(rawBlob, rawUrl))
if (/\.pdf(\?|#|$)/i.test(rawUrl) && !bytesStartWithPdfMagic(bytes)) {
throw new Error('面单文件不是有效的 PDF')
}
return new Blob([bytes], {
type: /\.pdf(\?|#|$)/i.test(rawUrl) ? 'application/pdf' : mime,
})
} finally { } finally {
window.clearTimeout(timer) window.clearTimeout(timer)
} }
@ -252,10 +337,7 @@ async function fetchViaFileStorageStream(rawUrl: string, errors: string[]): Prom
const blob = await streamFileBlob(key) const blob = await streamFileBlob(key)
const mime = guessMime(key) const mime = guessMime(key)
const typed = blob.type ? blob : new Blob([blob], { type: mime }) const typed = blob.type ? blob : new Blob([blob], { type: mime })
const bytes = await normalizePdfBytes(typed, rawUrl) return blobFromNormalized(await normalizePreviewBytes(typed, rawUrl))
return new Blob([bytes], {
type: isPrintPreviewPdf(rawUrl, typed) ? 'application/pdf' : mime,
})
} catch (e: unknown) { } catch (e: unknown) {
const msg = extractOmsRequestError(e) const msg = extractOmsRequestError(e)
if (msg) errors.push(msg) if (msg) errors.push(msg)
@ -274,11 +356,8 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
// 开发:测试 Admin 可能尚未部署 /file-storage,先走同源 COS 代理 // 开发:测试 Admin 可能尚未部署 /file-storage,先走同源 COS 代理
if (import.meta.env.DEV && isCos) { if (import.meta.env.DEV && isCos) {
try { try {
const blob = await fetchBlobViaCosProxy(raw) // fetchBlobViaCosProxy 内已按内容规范化 MIME
const bytes = await normalizePdfBytes(blob, raw) return await fetchBlobViaCosProxy(raw)
return new Blob([bytes], {
type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw),
})
} catch (e: unknown) { } catch (e: unknown) {
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败') errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
} }
@ -291,11 +370,7 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
// 同源 /__cos__ 代理(生产 nginx;开发上面已试过则跳过) // 同源 /__cos__ 代理(生产 nginx;开发上面已试过则跳过)
if (isCos && !import.meta.env.DEV) { if (isCos && !import.meta.env.DEV) {
try { try {
const blob = await fetchBlobViaCosProxy(raw) return await fetchBlobViaCosProxy(raw)
const bytes = await normalizePdfBytes(blob, raw)
return new Blob([bytes], {
type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw),
})
} catch (e: unknown) { } catch (e: unknown) {
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败') errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
} }
@ -306,10 +381,7 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
if (sameOriginPath.startsWith('/')) { if (sameOriginPath.startsWith('/')) {
try { try {
const blob = await fetchBlobViaSameOriginPath(sameOriginPath) const blob = await fetchBlobViaSameOriginPath(sameOriginPath)
const bytes = await normalizePdfBytes(blob, raw) return blobFromNormalized(await normalizePreviewBytes(blob, raw))
return new Blob([bytes], {
type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw),
})
} catch (e: unknown) { } catch (e: unknown) {
const msg = extractOmsRequestError(e) const msg = extractOmsRequestError(e)
if (msg) errors.push(msg) if (msg) errors.push(msg)
@ -351,6 +423,8 @@ export async function resolvePrintPreviewSrc(rawUrl: string): Promise<string> {
export function isPrintPreviewPdf(rawUrl: string, blob?: Blob | null): boolean { export function isPrintPreviewPdf(rawUrl: string, blob?: Blob | null): boolean {
const mime = String(blob?.type || '').toLowerCase() const mime = String(blob?.type || '').toLowerCase()
if (mime.includes('pdf')) return true if (mime.includes('pdf')) return true
// 已按内容嗅探为图片/HTML 时,即使 URL 带 .pdf 也不再当 PDF
if (mime.startsWith('image/') || mime.includes('html')) return false
return /\.pdf(\?|#|$)/i.test(String(rawUrl || '')) return /\.pdf(\?|#|$)/i.test(String(rawUrl || ''))
} }

View File

@ -1152,6 +1152,16 @@
const id = Number(key.slice(5)); const id = Number(key.slice(5));
const product = filteredProductList.value.find((p: any) => p.id === id); const product = filteredProductList.value.find((p: any) => p.id === id);
if (!product) return; if (!product) return;
// 已选中且卖价已加载:忽略重复 select(树/受控 selectedKeys 偶发连发)
if (
selectedProduct.value === id
&& activeTab.value === 'price'
&& priceTabLoadedProductId === id
&& !configFormDirty.value
&& !rulesFormDirty.value
) {
return;
}
const ok = await confirmDiscardUnsavedChanges(); const ok = await confirmDiscardUnsavedChanges();
if (!ok) return; if (!ok) return;
await handleSelectProduct(product); await handleSelectProduct(product);
@ -1180,14 +1190,25 @@
let priceTabLoadSeq = 0; let priceTabLoadSeq = 0;
/** 当前 priceTableData 所属产品;切走再回来时直接复用,不再空表重拉 */ /** 当前 priceTableData 所属产品;切走再回来时直接复用,不再空表重拉 */
let priceTabLoadedProductId: number | null = null; let priceTabLoadedProductId: number | null = null;
/** 已做过发布态对比的列表签名,避免每次切 tab 都解析整份卖价快照 */ /** 同产品进行中的列表请求复用,避免树 select 连发打两次 price/list */
let pricePublishCheckedSig = ''; let priceTabInflight: { productId: number; promise: Promise<void> } | null = null;
const priceListLoading = ref(false); const priceListLoading = ref(false);
async function loadPriceTabData(productId: number) { async function loadPriceTabData(productId: number, opts?: { force?: boolean }) {
if (priceTabInflight?.productId === productId) {
await priceTabInflight.promise;
return;
}
// 已加载过该产品(含空列表)且非强制刷新:跳过,避免重复 price/list
if (!opts?.force && priceTabLoadedProductId === productId) {
return;
}
const seq = ++priceTabLoadSeq; const seq = ++priceTabLoadSeq;
const showSpinner = priceTabLoadedProductId !== productId || priceTableData.value.length === 0; const showSpinner = priceTabLoadedProductId !== productId || priceTableData.value.length === 0;
if (showSpinner) priceListLoading.value = true; if (showSpinner) priceListLoading.value = true;
const run = (async () => {
try { try {
// 列表不拉 details,也不等发布记录。生效版本在列表渲染后单独补。 // 列表不拉 details,也不等发布记录。生效版本在列表渲染后单独补。
const prices = await getSalesProductPriceList(productId, { includeDetails: false }); const prices = await getSalesProductPriceList(productId, { includeDetails: false });
@ -1224,14 +1245,8 @@
priceTableData.value = rows; priceTableData.value = rows;
priceTabLoadedProductId = productId; priceTabLoadedProductId = productId;
void refreshSalesProductPriceEffectiveInfo(productId); void refreshSalesProductPriceEffectiveInfo(productId);
// 发布同步态必须每次刷新:不能跟价格行 modifyTime 签名绑死,否则刚发布后签名不变会跳过刷新、徽章一直「待发布」 // 徽章只听后端 sync-status,不在前端算差异
void refreshPricePublishSyncStatus(productId).then(() => { void refreshPricePublishSyncStatus(productId);
if (seq === priceTabLoadSeq && selectedProduct.value === productId) {
pricePublishCheckedSig = `${productId}|${rows
.map((r) => `${r.id}:${r.modifyTime}:${r.status ? 1 : 0}`)
.join(',')}`;
}
});
} catch (e) { } catch (e) {
if (seq !== priceTabLoadSeq) return; if (seq !== priceTabLoadSeq) return;
console.error(e); console.error(e);
@ -1240,6 +1255,16 @@
} finally { } finally {
if (seq === priceTabLoadSeq) priceListLoading.value = false; if (seq === priceTabLoadSeq) priceListLoading.value = false;
} }
})();
priceTabInflight = { productId, promise: run };
try {
await run;
} finally {
if (priceTabInflight?.productId === productId && priceTabInflight.promise === run) {
priceTabInflight = null;
}
}
} }
/** 排货规则:服务渠道下拉(GET /channel/service-channel/list) */ /** 排货规则:服务渠道下拉(GET /channel/service-channel/list) */
@ -1404,9 +1429,10 @@
return detail?.content ?? detail?.Content ?? null; return detail?.content ?? detail?.Content ?? null;
} }
/** 仅展示后端 sync-status.pending,前端不做快照 diff */
async function refreshPricePublishSyncStatus(productId?: number) { async function refreshPricePublishSyncStatus(productId?: number) {
const pid = productId ?? selectedProduct.value; const pid = productId ?? selectedProduct.value;
if (pid == null || !priceTableData.value.length) { if (pid == null) {
pricePublishChecking.value = false; pricePublishChecking.value = false;
pricePublishPending.value = null; pricePublishPending.value = null;
return; return;
@ -1419,7 +1445,8 @@
operatorType: 'price', operatorType: 'price',
}); });
if (seq !== priceTabLoadSeq || selectedProduct.value !== pid) return; if (seq !== priceTabLoadSeq || selectedProduct.value !== pid) return;
pricePublishPending.value = !!(status?.pending ?? status?.Pending); const pending = status?.pending ?? status?.Pending;
pricePublishPending.value = typeof pending === 'boolean' ? pending : null;
} catch { } catch {
if (seq === priceTabLoadSeq) pricePublishPending.value = null; if (seq === priceTabLoadSeq) pricePublishPending.value = null;
} finally { } finally {
@ -1561,6 +1588,20 @@
const handleSelectProduct = async (product: any) => { const handleSelectProduct = async (product: any) => {
const productChanged = selectedProduct.value !== product.id; const productChanged = selectedProduct.value !== product.id;
// 同产品重复选中且当前 tab 数据已在:不再打 detail / price/list
if (
!productChanged
&& product?.id
&& (
(activeTab.value === 'price' && priceTabLoadedProductId === product.id)
|| (activeTab.value === 'rules' && rulesTabLoadedProductId === product.id)
|| (activeTab.value === 'records' && recordsTabLoadedProductId === product.id)
|| activeTab.value === 'config'
|| activeTab.value === 'customerGuide'
)
) {
return;
}
selectedProduct.value = product.id; selectedProduct.value = product.id;
configPublishPending.value = false; configPublishPending.value = false;
rulesPublishPending.value = false; rulesPublishPending.value = false;
@ -1569,12 +1610,12 @@
if (productChanged && activeTab.value !== 'price') { if (productChanged && activeTab.value !== 'price') {
priceTableData.value = []; priceTableData.value = [];
priceTabLoadedProductId = null; priceTabLoadedProductId = null;
pricePublishCheckedSig = '';
pricePublishPending.value = null; pricePublishPending.value = null;
pricePublishChecking.value = false; pricePublishChecking.value = false;
effectiveVersion.value = '--'; effectiveVersion.value = '--';
effectiveTime.value = '--'; effectiveTime.value = '--';
} else if (activeTab.value === 'price') { } else if (activeTab.value === 'price') {
// 切到卖价 tab / 换产品:先清空徽章,等 sync-status 回来再显示
pricePublishPending.value = null; pricePublishPending.value = null;
pricePublishChecking.value = false; pricePublishChecking.value = false;
} }
@ -2272,9 +2313,9 @@
publishModalMethods.closeModal(); publishModalMethods.closeModal();
await loadRecordsTabData(pid, true); await loadRecordsTabData(pid, true);
if (operatorType === 'price') { if (operatorType === 'price') {
pricePublishCheckedSig = ''; // 不乐观改徽章;等后端 sync-status 重算
pricePublishPending.value = false; pricePublishPending.value = null;
await loadPriceTabData(pid); await loadPriceTabData(pid, { force: true });
await refreshPricePublishSyncStatus(pid); await refreshPricePublishSyncStatus(pid);
} }
} catch (e: any) { } catch (e: any) {
@ -3576,7 +3617,7 @@
}; };
} else { } else {
// 兼容后端不返回 id:用唯一组合定位刚新增记录,切成编辑态避免下次重复 create // 兼容后端不返回 id:用唯一组合定位刚新增记录,切成编辑态避免下次重复 create
await loadPriceTabData(pid); await loadPriceTabData(pid, { force: true });
const latest = [...priceTableData.value] const latest = [...priceTableData.value]
.reverse() .reverse()
.find((row: any) => row.serialNumber === payload.serialNumber && row.costItem === payload.costItem); .find((row: any) => row.serialNumber === payload.serialNumber && row.costItem === payload.costItem);
@ -3606,7 +3647,7 @@
try { try {
const msg = await submitPriceConfig(pid); const msg = await submitPriceConfig(pid);
Message.success(msg); Message.success(msg);
await loadPriceTabData(pid); await loadPriceTabData(pid, { force: true });
priceModalVisible.value = false; priceModalVisible.value = false;
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '保存失败'); Message.error(e?.message || '保存失败');
@ -4169,7 +4210,7 @@
deletePriceModalVisible.value = false; deletePriceModalVisible.value = false;
deletingPriceRecord.value = null; deletingPriceRecord.value = null;
deletePriceInput.value = ''; deletePriceInput.value = '';
if (productId != null) await loadPriceTabData(productId); if (productId != null) await loadPriceTabData(productId, { force: true });
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '删除失败'); Message.error(e?.message || '删除失败');
} }
@ -4188,7 +4229,7 @@
if (pid == null || record?.id == null) return if (pid == null || record?.id == null) return
try { try {
await toggleSalesProductPriceStatus(record.id, toEnableStatus(!!record.status)) await toggleSalesProductPriceStatus(record.id, toEnableStatus(!!record.status))
await loadPriceTabData(pid) await loadPriceTabData(pid, { force: true })
} catch (e: any) { } catch (e: any) {
record.status = !record.status record.status = !record.status
Message.error(e?.message || '更新失败') Message.error(e?.message || '更新失败')

View File

@ -4106,9 +4106,10 @@ const publishDiffStatus = computed(() => {
return 'changed' return 'changed'
}) })
/** 仅展示后端 sync-status.pending,前端不做快照 diff */
async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) { async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) {
const cid = serviceChannelId ?? (selectedChannel.value as any)?.id const cid = serviceChannelId ?? (selectedChannel.value as any)?.id
if (cid == null || !priceTableData.value.length) { if (cid == null) {
pricePublishChecking.value = false pricePublishChecking.value = false
pricePublishPending.value = null pricePublishPending.value = null
return return
@ -4119,7 +4120,8 @@ async function refreshPricePublishSyncStatus(serviceChannelId?: number | string)
serviceChannelId: cid, serviceChannelId: cid,
operatorType: 'price', operatorType: 'price',
}) })
pricePublishPending.value = !!(status?.pending ?? status?.Pending) const pending = status?.pending ?? status?.Pending
pricePublishPending.value = typeof pending === 'boolean' ? pending : null
} catch { } catch {
pricePublishPending.value = null pricePublishPending.value = null
} finally { } finally {

View File

@ -83,7 +83,11 @@
</template> </template>
</div> </div>
<p class="face-rule"> <p class="face-rule">
{{ t('h5.coupons.minOrder', { amount: formatAmount(item.minOrderAmount) }) }} {{
Number(item.minOrderAmount) > 0
? t('h5.coupons.minOrder', { amount: formatAmount(item.minOrderAmount) })
: t('h5.coupons.noThreshold')
}}
</p> </p>
</div> </div>

View File

@ -453,6 +453,7 @@ defineExpose({ openWithDetail, reload: load })
} }
.h5-pklist__handle { .h5-pklist__handle {
flex-shrink: 0;
width: 36px; width: 36px;
height: 4px; height: 4px;
margin: 10px auto 0; margin: 10px auto 0;
@ -461,6 +462,7 @@ defineExpose({ openWithDetail, reload: load })
} }
.h5-pklist__head { .h5-pklist__head {
flex-shrink: 0;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
@ -496,10 +498,14 @@ defineExpose({ openWithDetail, reload: load })
} }
.h5-pklist__filters { .h5-pklist__filters {
flex-shrink: 0;
z-index: 2;
display: flex; display: flex;
align-items: center;
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
padding: 12px 16px; padding: 12px 16px 10px;
background: #f5f6f8;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { &::-webkit-scrollbar {
@ -526,10 +532,17 @@ defineExpose({ openWithDetail, reload: load })
} }
.h5-pklist__body { .h5-pklist__body {
flex: 1; flex: 1 1 auto;
min-height: 220px; min-height: 0;
width: 100%;
display: block;
overflow: auto; overflow: auto;
padding: 0 16px 16px; padding: 4px 16px 16px;
-webkit-overflow-scrolling: touch;
:deep(.arco-spin-children) {
min-height: 160px;
}
} }
.h5-pklist-card { .h5-pklist-card {

View File

@ -837,6 +837,7 @@ export default {
'h5.coupons.tab.used': 'Used', 'h5.coupons.tab.used': 'Used',
'h5.coupons.tab.expired': 'Expired', 'h5.coupons.tab.expired': 'Expired',
'h5.coupons.minOrder': 'Min order {amount}', 'h5.coupons.minOrder': 'Min order {amount}',
'h5.coupons.noThreshold': 'No minimum',
'h5.coupons.use': 'Use now', 'h5.coupons.use': 'Use now',
'h5.coupons.discountUnit': ' off', 'h5.coupons.discountUnit': ' off',
'h5.coupons.expiringSoon': 'Expiring soon', 'h5.coupons.expiringSoon': 'Expiring soon',

View File

@ -820,6 +820,7 @@ export default {
'h5.coupons.unnamed': '优惠券', 'h5.coupons.unnamed': '优惠券',
'h5.coupons.use': '去使用', 'h5.coupons.use': '去使用',
'h5.coupons.minOrder': '满{amount}可用', 'h5.coupons.minOrder': '满{amount}可用',
'h5.coupons.noThreshold': '无门槛',
'h5.coupons.discountUnit': '折', 'h5.coupons.discountUnit': '折',
'h5.coupons.expiringSoon': '即将过期', 'h5.coupons.expiringSoon': '即将过期',
'h5.coupons.badge.newcomer': '新人', 'h5.coupons.badge.newcomer': '新人',

View File

@ -53,7 +53,6 @@
<a-button <a-button
type="text" type="text"
size="mini" size="mini"
:loading="previewingId === file.id"
@click="openFile(file)" @click="openFile(file)"
> >
{{ $t('order.myOrders.drawer.attachments.preview') }} {{ $t('order.myOrders.drawer.attachments.preview') }}
@ -93,7 +92,6 @@ import {
type OrderFileItem, type OrderFileItem,
} from '@/api/order'; } from '@/api/order';
import { resolveOmsAssetUrl } from '@/api/oms/common'; import { resolveOmsAssetUrl } from '@/api/oms/common';
import { fetchPrintPreviewBlob } from '@/utils/printPreview';
const FILE_TYPE_DEFS = [ const FILE_TYPE_DEFS = [
{ {
@ -154,7 +152,6 @@ const { t } = useI18n();
const loading = ref(false); const loading = ref(false);
const uploadingType = ref<string | null>(null); const uploadingType = ref<string | null>(null);
const deletingId = ref<number | null>(null); const deletingId = ref<number | null>(null);
const previewingId = ref<number | null>(null);
const groups = ref<Record<string, OrderFileItem[]>>({}); const groups = ref<Record<string, OrderFileItem[]>>({});
const canUpload = computed(() => !props.readOnly && !props.customerView); const canUpload = computed(() => !props.readOnly && !props.customerView);
@ -169,51 +166,26 @@ const visibleSections = computed(() => {
})); }));
}); });
function resolveUrl(path?: string) { /** 与打印弹窗「查看面单」一致:同步打开可访问 URL(勿先 await 再开 blob,Chrome 会当搜索词) */
return resolveOmsAssetUrl(path); function toAbsoluteUrl(path?: string) {
} const resolved = resolveOmsAssetUrl(path) || String(path || '').trim();
if (!resolved) return '';
function guessAttachMime(file: OrderFileItem, url: string): string { if (/^https?:\/\//i.test(resolved) || resolved.startsWith('blob:') || resolved.startsWith('data:')) {
const ct = String(file.contentType || '').trim().toLowerCase(); return resolved;
if (ct && !ct.includes('octet-stream') && !ct.includes('force-download')) {
return ct;
} }
const hint = `${file.fileName || ''} ${url}`; if (resolved.startsWith('/')) return `${window.location.origin}${resolved}`;
if (/\.pdf(\?|#|$)/i.test(hint)) return 'application/pdf'; return `${window.location.origin}/${resolved}`;
if (/\.png(\?|#|$)/i.test(hint)) return 'image/png';
if (/\.jpe?g(\?|#|$)/i.test(hint)) return 'image/jpeg';
if (/\.webp(\?|#|$)/i.test(hint)) return 'image/webp';
if (/\.gif(\?|#|$)/i.test(hint)) return 'image/gif';
if (/\.bmp(\?|#|$)/i.test(hint)) return 'image/bmp';
return ct || 'application/octet-stream';
} }
/** 拉取为带正确 MIME 的 blob 再打开,走浏览器内置预览,避免 Content-Disposition 触发下载 */ function openFile(file: OrderFileItem) {
async function openFile(file: OrderFileItem) { const url = toAbsoluteUrl(file.fileUrl);
const raw = resolveUrl(file.fileUrl); if (!url) {
if (!raw) return; Message.warning(t('order.myOrders.drawer.attachments.previewFailed'));
if (previewingId.value === file.id) return;
previewingId.value = file.id;
try {
const blob = await fetchPrintPreviewBlob(raw);
const mime = guessAttachMime(file, raw);
const typed =
blob.type && !/octet-stream|force-download/i.test(blob.type)
? blob
: new Blob([blob], { type: mime });
const blobUrl = URL.createObjectURL(typed);
const win = window.open(blobUrl, '_blank');
if (!win) {
Message.warning(t('order.myOrders.drawer.attachments.previewBlocked'));
URL.revokeObjectURL(blobUrl);
return; return;
} }
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 120_000); const win = window.open(url, '_blank', 'noopener,noreferrer');
} catch (e: any) { if (!win) {
Message.error(e?.message || t('order.myOrders.drawer.attachments.previewFailed')); Message.warning(t('order.myOrders.drawer.attachments.previewBlocked'));
} finally {
previewingId.value = null;
} }
} }

View File

@ -207,23 +207,27 @@ function onClaimTypeChange(key: string) {
formRef.value?.setValues?.({ ...formState, claimType: key }) formRef.value?.setValues?.({ ...formState, claimType: key })
} }
const modalTitle = computed(() =>
t('order.myOrders.claimed.modalTitle', { count: props.orderIds.length }),
)
watch( watch(
() => props.visible, () => props.visible,
async (open) => { async (open) => {
if (!open) return if (!open) return
resetForm() resetForm()
modalMethods.setModalProps({
title: t('order.myOrders.claimed.modalTitle', { count: props.orderIds.length }),
width: 720,
maskClosable: false,
})
modalMethods.openModal(true)
await nextTick() await nextTick()
formRef.value?.setValues?.({ ...formState }) formRef.value?.setValues?.({ ...formState })
formRef.value?.formRef?.clearValidate?.() formRef.value?.formRef?.clearValidate?.()
}, },
{ immediate: true },
) )
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
async function submitBatchJob(payload: Record<string, unknown>) { async function submitBatchJob(payload: Record<string, unknown>) {
const job = await postOrderBatchJob({ const job = await postOrderBatchJob({
action: 'mark-claimed', action: 'mark-claimed',
@ -314,14 +318,18 @@ async function handleSubmit() {
function handleCancel() { function handleCancel() {
emit('update:visible', false) emit('update:visible', false)
modalMethods.closeModal()
} }
</script> </script>
<template> <template>
<ModalPro <ModalPro
:visible="visible"
:title="modalTitle"
:width="720"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal" @register="registerModal"
:loading="submitting" @update:visible="onUpdateVisible"
@ok="handleSubmit" @ok="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
> >

View File

@ -166,24 +166,28 @@ function resetForm() {
}) })
} }
const modalTitle = computed(() =>
t('order.myOrders.problematic.modalTitle', { count: props.orderIds.length }),
)
watch( watch(
() => props.visible, () => props.visible,
async (open) => { async (open) => {
if (!open) return if (!open) return
resetForm() resetForm()
modalMethods.setModalProps({
title: t('order.myOrders.problematic.modalTitle', { count: props.orderIds.length }),
width: 720,
maskClosable: false,
})
modalMethods.openModal(true)
await nextTick() await nextTick()
await loadTemplateOptions() await loadTemplateOptions()
formRef.value?.setValues?.({ ...formState }) formRef.value?.setValues?.({ ...formState })
formRef.value?.formRef?.clearValidate?.() formRef.value?.formRef?.clearValidate?.()
}, },
{ immediate: true },
) )
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
async function submitBatchJob(payload: Record<string, unknown>) { async function submitBatchJob(payload: Record<string, unknown>) {
const job = await postOrderBatchJob({ const job = await postOrderBatchJob({
action: 'mark-problematic', action: 'mark-problematic',
@ -271,14 +275,18 @@ async function handleSubmit() {
function handleCancel() { function handleCancel() {
emit('update:visible', false) emit('update:visible', false)
modalMethods.closeModal()
} }
</script> </script>
<template> <template>
<ModalPro <ModalPro
:visible="visible"
:title="modalTitle"
:width="720"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal" @register="registerModal"
:loading="submitting" @update:visible="onUpdateVisible"
@ok="handleSubmit" @ok="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
> >

View File

@ -231,6 +231,7 @@ watch(
submitting.value = false submitting.value = false
} }
}, },
{ immediate: true },
) )
</script> </script>

View File

@ -207,23 +207,27 @@ function onClaimTypeChange(key: string) {
formRef.value?.setValues?.({ ...formState, claimType: key }) formRef.value?.setValues?.({ ...formState, claimType: key })
} }
const modalTitle = computed(() =>
t('order.myOrders.claimed.modalTitle', { count: props.orderIds.length }),
)
watch( watch(
() => props.visible, () => props.visible,
async (open) => { async (open) => {
if (!open) return if (!open) return
resetForm() resetForm()
modalMethods.setModalProps({
title: t('order.myOrders.claimed.modalTitle', { count: props.orderIds.length }),
width: 720,
maskClosable: false,
})
modalMethods.openModal(true)
await nextTick() await nextTick()
formRef.value?.setValues?.({ ...formState }) formRef.value?.setValues?.({ ...formState })
formRef.value?.formRef?.clearValidate?.() formRef.value?.formRef?.clearValidate?.()
}, },
{ immediate: true },
) )
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
async function submitBatchJob(payload: Record<string, unknown>) { async function submitBatchJob(payload: Record<string, unknown>) {
const job = await postOrderBatchJob({ const job = await postOrderBatchJob({
action: 'mark-claimed', action: 'mark-claimed',
@ -314,14 +318,18 @@ async function handleSubmit() {
function handleCancel() { function handleCancel() {
emit('update:visible', false) emit('update:visible', false)
modalMethods.closeModal()
} }
</script> </script>
<template> <template>
<ModalPro <ModalPro
:visible="visible"
:title="modalTitle"
:width="720"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal" @register="registerModal"
:loading="submitting" @update:visible="onUpdateVisible"
@ok="handleSubmit" @ok="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
> >

View File

@ -166,24 +166,28 @@ function resetForm() {
}) })
} }
const modalTitle = computed(() =>
t('order.myOrders.problematic.modalTitle', { count: props.orderIds.length }),
)
watch( watch(
() => props.visible, () => props.visible,
async (open) => { async (open) => {
if (!open) return if (!open) return
resetForm() resetForm()
modalMethods.setModalProps({
title: t('order.myOrders.problematic.modalTitle', { count: props.orderIds.length }),
width: 720,
maskClosable: false,
})
modalMethods.openModal(true)
await nextTick() await nextTick()
await loadTemplateOptions() await loadTemplateOptions()
formRef.value?.setValues?.({ ...formState }) formRef.value?.setValues?.({ ...formState })
formRef.value?.formRef?.clearValidate?.() formRef.value?.formRef?.clearValidate?.()
}, },
{ immediate: true },
) )
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
async function submitBatchJob(payload: Record<string, unknown>) { async function submitBatchJob(payload: Record<string, unknown>) {
const job = await postOrderBatchJob({ const job = await postOrderBatchJob({
action: 'mark-problematic', action: 'mark-problematic',
@ -271,14 +275,18 @@ async function handleSubmit() {
function handleCancel() { function handleCancel() {
emit('update:visible', false) emit('update:visible', false)
modalMethods.closeModal()
} }
</script> </script>
<template> <template>
<ModalPro <ModalPro
:visible="visible"
:title="modalTitle"
:width="720"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal" @register="registerModal"
:loading="submitting" @update:visible="onUpdateVisible"
@ok="handleSubmit" @ok="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
> >

View File

@ -229,6 +229,7 @@ watch(
submitting.value = false submitting.value = false
} }
}, },
{ immediate: true },
) )
</script> </script>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue' import ModalPro from '@/components/ModalPro/index.vue'
@ -86,21 +86,24 @@ function onSchemeChange(key: string) {
formState.scheme = key as OrderReallocateScheme formState.scheme = key as OrderReallocateScheme
} }
const modalTitle = computed(() =>
t('order.myOrders.reallocate.modalTitle', { count: props.orderIds.length }),
)
watch( watch(
() => props.visible, () => props.visible,
async (open) => { (open) => {
if (!open) return if (!open) return
resetForm() resetForm()
modalMethods.setModalProps({
title: t('order.myOrders.reallocate.modalTitle', { count: props.orderIds.length }),
width: 560,
maskClosable: false,
})
modalMethods.openModal(true)
await nextTick()
}, },
{ immediate: true },
) )
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
watch( watch(
() => formState.scheme, () => formState.scheme,
async (scheme) => { async (scheme) => {
@ -209,14 +212,18 @@ async function handleSubmit() {
function handleCancel() { function handleCancel() {
emit('update:visible', false) emit('update:visible', false)
modalMethods.closeModal()
} }
</script> </script>
<template> <template>
<ModalPro <ModalPro
:visible="visible"
:title="modalTitle"
:width="560"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal" @register="registerModal"
:loading="submitting" @update:visible="onUpdateVisible"
@ok="handleSubmit" @ok="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
> >

View File

@ -364,15 +364,15 @@ body.command-home #navbar .site-nav-dock a {
} }
body.command-home #navbar > ul > li.site-nav-tools { body.command-home #navbar > ul > li.site-nav-tools {
height: 60px; height: 40px;
padding: 5px; padding: 3px;
overflow: hidden; overflow: hidden;
border: none; border: none;
border-radius: 28px; border-radius: 20px;
background: transparent; background: transparent;
box-shadow: box-shadow:
0 12px 40px rgba(15, 23, 42, 0.1), 0 8px 24px rgba(15, 23, 42, 0.08),
0 2px 8px rgba(15, 23, 42, 0.04); 0 1px 4px rgba(15, 23, 42, 0.04);
} }
body.command-home .site-nav-tools__glass { body.command-home .site-nav-tools__glass {
@ -436,13 +436,14 @@ body.command-home #navbar .site-nav-dock a {
z-index: 1; z-index: 1;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: 0 14px; padding: 0 10px;
border: none; border: none;
border-radius: 23px; border-radius: 17px;
background: transparent; background: transparent;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
box-shadow: none; box-shadow: none;
font-size: 12px;
transition: transition:
background 0.22s ease, background 0.22s ease,
box-shadow 0.22s ease, box-shadow 0.22s ease,
@ -466,19 +467,27 @@ body.command-home #navbar .site-nav-dock a {
body.command-home #navbar .site-nav-tools .site-nav-avatar:hover { body.command-home #navbar .site-nav-tools .site-nav-avatar:hover {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 50px; width: 34px;
height: 50px; height: 34px;
border: none; border: none;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
} }
body.command-home #navbar .site-nav-tools .site-nav-avatar img { body.command-home #navbar .site-nav-tools .site-nav-avatar img {
width: 36px; width: 26px;
height: 36px; height: 26px;
border-radius: 50%; border-radius: 50%;
} }
body.command-home #navbar .site-nav-tools .language-toggle-icon {
font-size: 13px;
}
body.command-home #navbar .site-nav-tools .language-toggle__next {
font-size: 11px;
}
body.command-home #navbar .language-toggle-link, body.command-home #navbar .language-toggle-link,
body.command-home #navbar .language-toggle-link:focus { body.command-home #navbar .language-toggle-link:focus {
margin-left: 0; margin-left: 0;

View File

@ -33,7 +33,8 @@
<span class="step-chip">{{ $t('user.recharge.deposit.form.stepChip', { step: current + 1, tip: currentTip }) }}</span> <span class="step-chip">{{ $t('user.recharge.deposit.form.stepChip', { step: current + 1, tip: currentTip }) }}</span>
</div> </div>
<a-form :model="formModel"> <a-form :model="formModel" class="deposit-form">
<div class="form-scroll">
<div v-if="current === 0" class="form-block"> <div v-if="current === 0" class="form-block">
<p class="block-title">{{ $t('user.recharge.deposit.form.selectAmount') }}</p> <p class="block-title">{{ $t('user.recharge.deposit.form.selectAmount') }}</p>
<a-radio-group <a-radio-group
@ -97,10 +98,10 @@
<div class="qr-wrapper"> <div class="qr-wrapper">
<div class="qr-image" v-if="payType === 'zfb'"> <div class="qr-image" v-if="payType === 'zfb'">
<a-image :src="zfbQrCode" :preview="true" width="220" /> <a-image :src="zfbQrCode" :preview="true" width="180" />
</div> </div>
<div class="qr-image" v-else> <div class="qr-image" v-else>
<a-image :src="wxQrCode" :preview="true" width="220" /> <a-image :src="wxQrCode" :preview="true" width="180" />
</div> </div>
<p class="qr-tip">{{ $t('user.recharge.deposit.form.qrTip') }}</p> <p class="qr-tip">{{ $t('user.recharge.deposit.form.qrTip') }}</p>
</div> </div>
@ -141,8 +142,9 @@
</template> </template>
</a-result> </a-result>
</div> </div>
</div>
<div class="action-row"> <div v-if="current < 2" class="action-row">
<a-button v-if="current === 1" @click="current--">{{ $t('user.recharge.deposit.actions.prevStep') }}</a-button> <a-button v-if="current === 1" @click="current--">{{ $t('user.recharge.deposit.actions.prevStep') }}</a-button>
<a-button <a-button
v-if="current === 1" v-if="current === 1"
@ -286,29 +288,36 @@ const goToRecord = () => {
<style lang="less" scoped> <style lang="less" scoped>
.deposit-page { .deposit-page {
min-height: 100vh; box-sizing: border-box;
padding: 40px 0 60px; height: 100%;
min-height: 0;
padding: 16px;
background: radial-gradient(circle at top, #eff4ff, #f7f8fb 60%); background: radial-gradient(circle at top, #eff4ff, #f7f8fb 60%);
display: flex; display: flex;
align-items: center; align-items: stretch;
overflow: hidden;
} }
.deposit-layout { .deposit-layout {
width: 1000px; width: min(1000px, 100%);
margin: 0 auto; margin: 0 auto;
min-height: 0;
max-height: 100%;
display: grid; display: grid;
grid-template-columns: 320px 1fr; grid-template-columns: 300px minmax(0, 1fr);
gap: 24px; gap: 16px;
} }
.info-panel { .info-panel {
padding: 24px; min-height: 0;
padding: 20px;
background: #0d1d3d; background: #0d1d3d;
border-radius: 24px; border-radius: 20px;
color: #f5f8ff; color: #f5f8ff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 14px;
overflow: auto;
box-shadow: 0 20px 40px rgba(1, 14, 38, 0.25); box-shadow: 0 20px 40px rgba(1, 14, 38, 0.25);
} }
@ -365,13 +374,30 @@ const goToRecord = () => {
} }
.form-panel { .form-panel {
padding: 28px 32px 34px; min-height: 0;
padding: 20px 24px 16px;
background: var(--app-card-bg); background: var(--app-card-bg);
border-radius: 32px; border-radius: 24px;
box-shadow: 0 20px 60px rgba(31, 52, 108, 0.12); box-shadow: 0 20px 60px rgba(31, 52, 108, 0.12);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 22px; gap: 16px;
overflow: hidden;
}
.deposit-form {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 4px;
} }
.form-header { .form-header {
@ -391,9 +417,10 @@ const goToRecord = () => {
} }
.amount-value { .amount-value {
font-size: 30px; font-size: 26px;
font-weight: 700; font-weight: 700;
color: #1f2f4d; color: #1f2f4d;
line-height: 1.2;
} }
.step-chip { .step-chip {
@ -504,7 +531,7 @@ const goToRecord = () => {
.qr-image { .qr-image {
border: 2px dashed #dbe3f4; border: 2px dashed #dbe3f4;
border-radius: 16px; border-radius: 16px;
padding: 14px; padding: 10px;
background: #fdfefe; background: #fdfefe;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -513,8 +540,8 @@ const goToRecord = () => {
:deep(.arco-image), :deep(.arco-image),
:deep(.arco-image-with-footer-inner) { :deep(.arco-image-with-footer-inner) {
width: 220px !important; width: 180px !important;
height: 220px !important; height: 180px !important;
} }
:deep(.arco-image-img) { :deep(.arco-image-img) {
@ -532,20 +559,20 @@ const goToRecord = () => {
} }
.upload-card { .upload-card {
width: 200px; width: 160px;
height: 200px; height: 160px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px dashed #d7dff0; border: 1px dashed #d7dff0;
border-radius: 24px; border-radius: 20px;
color: #9da9bf; color: #9da9bf;
background: #f8faff; background: #f8faff;
gap: 8px; gap: 8px;
.arco-icon { .arco-icon {
font-size: 30px; font-size: 28px;
} }
} }
@ -554,36 +581,50 @@ const goToRecord = () => {
} }
.preview-image { .preview-image {
max-width: 220px; max-width: 180px;
max-height: 220px; max-height: 180px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--color-border-2); border: 1px solid var(--color-border-2);
object-fit: contain; object-fit: contain;
} }
.action-row { .action-row {
margin-top: 10px; flex-shrink: 0;
margin-top: 0;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
display: flex; display: flex;
gap: 14px; gap: 12px;
justify-content: flex-end; justify-content: flex-end;
background: var(--app-card-bg);
} }
:deep(.arco-upload-list-picture-card) { :deep(.arco-upload-list-picture-card) {
.arco-upload-list-item { .arco-upload-list-item {
width: 220px; width: 160px;
height: 220px; height: 160px;
border-radius: 24px; border-radius: 20px;
} }
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.deposit-page {
overflow: auto;
}
.deposit-layout { .deposit-layout {
width: 94%; width: 100%;
max-height: none;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.info-panel { .info-panel {
order: 2; order: 2;
max-height: none;
}
.form-panel {
min-height: min(70vh, 640px);
} }
} }
</style> </style>