Merge pull request 'feat: H5 门户切换与充值审核,订单编辑支持仓库件重尺及 COS 资源代理修复' (#7) from test into master
All checks were successful
Build and Deploy Front / deploy (push) Successful in 1m53s

Reviewed-on: #7
This commit is contained in:
zzh 2026-10-15 20:01:31 +08:00
commit cd02a48017
27 changed files with 1484 additions and 135 deletions

View File

@ -155,20 +155,19 @@ server {
location ^~ /marketing/commissionWithdraw { try_files $uri @spa; }
location ^~ /marketing/referralTree { try_files $uri @spa; }
# 面单 PDF 同源代理:/__cos__/<host>/<path> → https://<host>/<path>
# 与 Vite 开发代理一致,避免浏览器直打 cos.* 触发 CORS
# 面单/截图同源代理:/__cos__/<host>/<path> → http://<host>/<path>
# cos.ideship.com 自定义域名 https 证书不可用,上游走 http;避免浏览器直打 CORS / 混合内容
location ~ ^/__cos__/([^/]+)(/.*)$ {
set $cos_host $1;
set $cos_uri $2;
resolver 1.1.1.1 8.8.8.8 ipv6=off valid=300s;
resolver_timeout 5s;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host $cos_host;
proxy_set_header Authorization "";
proxy_set_header Cookie "";
proxy_set_header X-Real-IP "";
proxy_pass https://$cos_host$cos_uri$is_args$args;
proxy_pass http://$cos_host$cos_uri$is_args$args;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
proxy_buffering off;

View File

@ -72,7 +72,7 @@ export interface CustomerProfileCompleteness {
}
function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
return {
const body: Record<string, unknown> = {
id,
customerCode: data.customerCode,
customerName: data.customerName,
@ -92,6 +92,8 @@ function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
}
// 去掉 undefined,避免局部字段被序列化成 null 后把后端原值冲掉
return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined))
}
export interface CustomerServiceAdvisorDto {

View File

@ -103,12 +103,29 @@ export async function omsPost<T = any>(path: string, body?: Record<string, any>)
return unwrapData<T>(res.data)
}
/**
* COS 绝对地址 → 同源 `/__cos__/<host>/...`(开发 Vite / 生产 nginx 反代)。
* cos.ideship.com 等自定义域名 https 不可用,直链 http 又会在 https 页面触发混合内容。
*/
function toCosSameOriginProxyUrl(absoluteUrl: string): string | null {
try {
const u = new URL(absoluteUrl)
const host = u.hostname.toLowerCase()
if (!host.startsWith('cos.') && !host.includes('myqcloud.com')) return null
return `/__cos__/${u.host}${u.pathname}${u.search}`
} catch {
return null
}
}
/** 将 /uploads/... 相对路径转为可访问 URL(同源相对,或 VITE_API_ASSET_ORIGIN 绝对前缀) */
export function resolveOmsAssetUrl(path?: string | null): string {
if (!path) return ''
const value = path.trim()
if (!value) return ''
if (/^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('blob:')) {
const cosProxied = toCosSameOriginProxyUrl(value)
if (cosProxied) return cosProxied
// 纠正常见错链:api.nanafa.com/uploads → api.nanafa.com/admin/uploads
return value.replace(
/^(https?:\/\/api\.(?:nanafa|ideship)\.com)\/uploads\//i,

View File

@ -1094,6 +1094,8 @@ export default {
'order.platformStoreOrder.message.batchResult': 'Batch done: {success} succeeded, {fail} failed',
'order.platformStoreOrder.message.selectOrders': 'Select orders first',
'order.platformStoreOrder.message.selectStore': 'Select stores first',
'order.platformStoreOrder.message.noStore': 'No authorized stores for this customer. Bind one under Platform Store first.',
'order.platformStoreOrder.message.loadStoreFailed': 'Failed to load stores',
'order.platformStoreOrder.message.selectStatus': 'Select status types',
'order.platformStoreOrder.message.syncConfirm': 'Sync status for {count} orders?',
'order.platformStoreOrder.message.rtsConfirm': 'Ready-to-ship {count} orders?',
@ -4570,8 +4572,10 @@ export default {
'user.recharge.record.table.payType': 'Payment Method',
'user.recharge.record.table.payAmount': 'Recharge Amount',
'user.recharge.record.table.payImage': 'Recharge Screenshot',
'user.recharge.record.table.viewImage': 'View',
'user.recharge.record.table.payStatus': 'Recharge Status',
'user.recharge.record.table.remarks': 'Remarks',
'user.recharge.record.message.noPayImage': 'No screenshot available',
'user.recharge.record.payment.alipay': 'Alipay',
'user.recharge.record.payment.wechat': 'WeChat',
'user.recharge.record.actions.approve': 'Approve',

View File

@ -1085,6 +1085,8 @@ export default {
'order.platformStoreOrder.message.batchResult': '批量匹配完成:成功 {success},失败 {fail}',
'order.platformStoreOrder.message.selectOrders': '请选择订单',
'order.platformStoreOrder.message.selectStore': '请选择店铺',
'order.platformStoreOrder.message.noStore': '当前客户下暂无已授权店铺,请先在「店铺授权」完成绑定',
'order.platformStoreOrder.message.loadStoreFailed': '加载店铺列表失败',
'order.platformStoreOrder.message.selectStatus': '请选择同步类型',
'order.platformStoreOrder.message.syncConfirm': '是否手动同步 {count} 个订单的状态?',
'order.platformStoreOrder.message.rtsConfirm': '是否对 {count} 个订单执行准备发货(RTS)?',
@ -4596,8 +4598,10 @@ export default {
'user.recharge.record.table.payType': '支付方式',
'user.recharge.record.table.payAmount': '充值金额',
'user.recharge.record.table.payImage': '充值截图',
'user.recharge.record.table.viewImage': '查看',
'user.recharge.record.table.payStatus': '充值状态',
'user.recharge.record.table.remarks': '备注',
'user.recharge.record.message.noPayImage': '暂无充值截图',
'user.recharge.record.payment.alipay': '支付宝',
'user.recharge.record.payment.wechat': '微信',
'user.recharge.record.actions.approve': '通过',

View File

@ -152,6 +152,17 @@ const router = createRouter({
adminPortalOnly: true,
},
},
{
path: 'recharge',
name: 'H5AdminRecharge',
component: () => import('@/views/h5/admin-center/recharge/index.vue'),
meta: {
requiresAuth: true,
locale: 'h5Admin.recharge.title',
h5HideHeader: true,
adminPortalOnly: true,
},
},
{
path: ':pathMatch(.*)*',
name: 'H5AdminCenterFallback',

View File

@ -1,50 +1,82 @@
export type FeeRowLike = {
feeType?: string
FeeType?: string
type?: string
currency?: string
amount?: number
costItem?: string
CostItem?: string
chargingStage?: string
ChargingStage?: string
}
export function normalizeFeeType(row: FeeRowLike): 'receivable' | 'payable' | '' {
const raw = String(row?.feeType ?? row?.type ?? '').toLowerCase()
const raw = String(row?.feeType ?? row?.FeeType ?? row?.type ?? '').toLowerCase()
if (raw === 'receivable' || raw === 'payable') return raw
return ''
}
function getChargingStageRank(stage?: string) {
function getChargingStageRank(stage?: string, options?: { includeOutbound?: boolean }) {
const s = String(stage ?? '').toLowerCase()
if (s === 'outbound') return 3
const includeOutbound = options?.includeOutbound !== false
if (includeOutbound && s === 'outbound') return 3
if (s === 'inbound') return 2
if (s === 'forecast') return 1
return 0
}
/**
* 每个费用类型 + 费用项只保留最高计费阶段(出库 > 入库 > 预报),
* 避免预报与入库明细叠算进合计。
*/
export function filterEffectiveStageFees<T extends FeeRowLike>(rows: T[]): T[] {
function pickCostItem(row: FeeRowLike) {
return String(row?.costItem ?? row?.CostItem ?? '').trim() || '_'
}
function pickChargingStage(row: FeeRowLike) {
return String(row?.chargingStage ?? row?.ChargingStage ?? '')
}
function isOutboundStage(row: FeeRowLike) {
return pickChargingStage(row).toLowerCase() === 'outbound'
}
function pickStageWinners<T extends FeeRowLike>(
rows: T[],
options?: { includeOutbound?: boolean },
): T[] {
const includeOutbound = options?.includeOutbound !== false
const winners = new Map<string, T>()
for (const row of rows) {
if (!includeOutbound && isOutboundStage(row)) continue
const feeType = normalizeFeeType(row) || 'unknown'
const costItem = String(row?.costItem ?? '').trim() || '_'
const key = `${feeType}|${costItem}`
const key = `${feeType}|${pickCostItem(row)}`
const current = winners.get(key)
if (!current) {
winners.set(key, row)
continue
}
const rowStage = String((row as any)?.chargingStage ?? (row as any)?.ChargingStage ?? '')
const curStage = String((current as any)?.chargingStage ?? (current as any)?.ChargingStage ?? '')
if (getChargingStageRank(rowStage) > getChargingStageRank(curStage)) {
const rankOpts = { includeOutbound }
if (
getChargingStageRank(pickChargingStage(row), rankOpts)
> getChargingStageRank(pickChargingStage(current), rankOpts)
) {
winners.set(key, row)
}
}
return [...winners.values()]
}
/**
* 管理端 / 内部:同费用项保留最高阶段(出库 > 入库 > 预报)。
*/
export function filterEffectiveStageFees<T extends FeeRowLike>(rows: T[]): T[] {
return pickStageWinners(rows, { includeOutbound: true })
}
/**
* 客户侧展示 / 应付:不含出库,同费用项只保留入库 > 预报。
*/
export function filterCustomerFacingFees<T extends FeeRowLike>(rows: T[]): T[] {
return pickStageWinners(rows, { includeOutbound: false })
}
export function sumFeesByCurrency(list: FeeRowLike[]) {
const totals = new Map<string, number>()
for (const row of list) {

View File

@ -535,11 +535,22 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
const leftMenuData = ref<any[]>([]);
const leftExpandedKeys = ref<string[]>(['category-list']);
/** 左侧关键字本地过滤(后端 list 暂无 Keyword) */
const filteredLeftMenuData = computed(() => {
const keyword = String(searchModel.keyword ?? '').trim().toLowerCase();
if (!keyword) return leftMenuData.value;
return leftMenuData.value.filter((item) =>
String(item.name ?? '')
.toLowerCase()
.includes(keyword)
);
});
const leftTreeData = computed<CustomLeftMenuTreeNode[]>(() => [
{
key: 'category-list',
title: t('channel.area.listTitle'),
children: leftMenuData.value.map((item) => ({
children: filteredLeftMenuData.value.map((item) => ({
key: `item-${item.id}`,
title: item.name,
isLeaf: true,
@ -882,9 +893,9 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
leftLoading.value = true;
try {
const response: any = await postForewarningServiceKpiAreaGetKpiAreas({
pageIndex: 1,
page: 1,
pageSize: 500,
...searchModel,
areaType: searchModel.areaType,
});
leftMenuData.value = filterByAreaType(response?.items ?? response?.data?.items ?? []);
} catch {

View File

@ -160,6 +160,14 @@ interface RechargeRecord {
remarks?: string
}
/** 无截图占位(提交时写 "0");GetPublicUrl("0") 也会返回空串 */
function resolvePayImageUrl(raw: unknown): string | undefined {
const s = String(raw ?? '').trim()
if (!s || s === '0' || /^https?:\/\/[^/]+\/0$/i.test(s)) return undefined
const url = resolveOmsAssetUrl(s).trim()
return url || undefined
}
function mapRow(item: any): RechargeRecord {
const pt = Number(item.payType)
// 接口约定:1-微信 2-支付宝
@ -170,10 +178,7 @@ function mapRow(item: any): RechargeRecord {
payTime: item.rechargeTime ?? '',
payType,
payAmount: item.rechargeAmount ?? 0,
payImage:
item.rechargeScreenshot && String(item.rechargeScreenshot) !== '0'
? resolveOmsAssetUrl(item.rechargeScreenshot)
: undefined,
payImage: resolvePayImageUrl(item.rechargeScreenshot ?? item.RechargeScreenshot),
payStatus: String(item.rechargeStatus ?? ''),
remarks: item.remark ?? '',
}
@ -307,8 +312,8 @@ function customCellRenderer(row: number, col: number, value: any, rowData: any):
}
if (colKey === 'payImage') {
if (!rowData?.payImage) return '--'
const imageEscaped = String(rowData.payImage).replace(/"/g, '&quot;')
return `<span data-role="view-pay-image" data-image-url="${imageEscaped}" style="color:var(--app-primary-color);cursor:pointer;">查看</span>`
// 用行号取图,避免签名 URL 中的 & 写入属性被截断导致预览空白
return `<span data-role="view-pay-image" data-row-index="${row}" style="color:var(--app-primary-color);cursor:pointer;">${t('user.recharge.record.table.viewImage')}</span>`
}
if (colKey === 'payStatus') {
const status = rowData?.payStatus
@ -388,8 +393,15 @@ async function handleRejectSubmit() {
function handleViewPayImageClick(event: MouseEvent) {
const trigger = (event.target as HTMLElement | null)?.closest?.('[data-role="view-pay-image"]')
if (!trigger) return
const imageUrl = trigger.getAttribute('data-image-url')
if (!imageUrl) return
const rowIndex = Number(trigger.getAttribute('data-row-index'))
const imageUrl =
(Number.isFinite(rowIndex) ? tableRows.value[rowIndex]?.payImage : '') ||
trigger.getAttribute('data-image-url') ||
''
if (!imageUrl) {
Message.warning(t('user.recharge.record.message.noPayImage'))
return
}
event.preventDefault()
event.stopPropagation()
previewImageUrl.value = imageUrl

View File

@ -84,6 +84,15 @@
</a-spin>
<footer class="h5-detail__foot">
<button
v-if="canModify"
type="button"
class="h5-detail__icon-btn"
:aria-label="t('h5.orders.actions.edit')"
@click="openEdit"
>
<IconEdit class="h5-detail__icon" />
</button>
<button type="button" class="h5-detail__btn is-ghost" @click="close">
{{ t('h5.orders.detail.close') }}
</button>
@ -95,13 +104,15 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconClose, IconClockCircle, IconCommon } from '@arco-design/web-vue/es/icon';
import { IconClose, IconClockCircle, IconCommon, IconEdit } from '@arco-design/web-vue/es/icon';
import { getOrderDetail } from '@/api/order';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import { filterEffectiveStageFees, normalizeFeeType } from '@/utils/feeTotals';
import {
getOrderStatusMeta,
resolveOrderStatusCode,
type OrderStatusCode,
} from '@/constants/orderStatus';
import H5EmptyState from '@/views/h5/customer-center/components/H5EmptyState.vue';
@ -115,8 +126,18 @@ const props = defineProps<{
const emit = defineEmits<{
'update:visible': [value: boolean];
edit: [orderId: number | string];
}>();
/** 与客户 H5 / 运单管理可改阶段对齐:入库前可编辑 */
const H5_ADMIN_MODIFY_STATUSES: OrderStatusCode[] = [
'draft',
'predeclared',
'pendingAllocation',
'pendingPayment',
'pendingShip',
];
const { t } = useI18n();
const tabs = ['overview', 'track', 'fee'] as const;
@ -149,6 +170,9 @@ const waybillNo = computed(
() => pick(detail.value, 'serviceNo', 'ServiceNo', 'waybillNo', 'WaybillNo') || '',
);
const statusCode = computed(() => resolveOrderStatusCode(detail.value));
const canModify = computed(() =>
H5_ADMIN_MODIFY_STATUSES.includes(statusCode.value as OrderStatusCode),
);
const overviewRows = computed(() => {
const d = detail.value || {};
@ -253,7 +277,12 @@ const feeList = computed(() => {
const d = detail.value || {};
const fees = d.feeList ?? d.FeeList ?? d.orderFees ?? d.fees ?? [];
if (!Array.isArray(fees)) return [];
return fees.map((f: any) => ({
// 同费用项只保留最高计费阶段;有类型时按应收/应付分别去重
const receivable = fees.filter((f: any) => normalizeFeeType(f) === 'receivable');
const payable = fees.filter((f: any) => normalizeFeeType(f) === 'payable');
const typed = [...receivable, ...payable];
const source = typed.length ? typed : fees;
return filterEffectiveStageFees(source).map((f: any) => ({
name: getCostItemLabel(f),
amount: pick(f, 'amount', 'Amount', 'fee') ?? '-',
}));
@ -316,6 +345,11 @@ function close() {
emit('update:visible', false);
}
function openEdit() {
if (!props.orderId || !canModify.value) return;
emit('edit', props.orderId);
}
watch(activeTab, (tab) => {
if (tab === 'track' && props.visible) void loadTrack();
});
@ -544,14 +578,43 @@ watch(
.h5-detail__foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1px solid #eef0f3;
flex-shrink: 0;
}
.h5-detail__icon-btn {
flex: 0 0 48px;
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e6eb;
border-radius: 12px;
background: #fff;
color: #4e5969;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active {
background: #f7f8fa;
transform: scale(0.96);
}
}
.h5-detail__icon {
width: 22px;
height: 22px;
font-size: 22px;
display: block;
}
.h5-detail__btn {
flex: 1;
flex: 1 1 auto;
min-width: 0;
height: 48px;
border-radius: 12px;
font-size: 15px;

View File

@ -21,25 +21,61 @@ export default {
'h5Admin.login.privacy': 'Privacy Policy',
'h5Admin.login.terms': 'Terms of Service',
'h5Admin.login.and': 'and',
'h5.login.switchToAdmin': 'Switch to staff login',
'h5.login.switchToCustomer': 'Switch to customer login',
'h5Admin.me.title': 'Profile',
'h5Admin.me.account': 'Account',
'h5Admin.me.org': 'Organization',
'h5Admin.me.roles': 'Roles',
'h5Admin.me.email': 'Email',
'h5Admin.me.rechargeAudit': 'Recharge review',
'h5Admin.me.logout': 'Sign out',
'h5Admin.me.logoutConfirm': 'Sign out of this account?',
'h5Admin.me.emptyRole': 'No roles',
'h5Admin.me.emptyOrg': 'No organization',
'h5Admin.me.emptyEmail': 'Not set',
'h5Admin.recharge.title': 'Recharge review',
'h5Admin.recharge.subtitle': 'Approve customer top-ups',
'h5Admin.recharge.refresh': 'Refresh',
'h5Admin.recharge.search': 'Search',
'h5Admin.recharge.searchPh': 'Search serial No.',
'h5Admin.recharge.empty': 'No recharge records',
'h5Admin.recharge.loadMore': 'Load more',
'h5Admin.recharge.loadFailed': 'Failed to load',
'h5Admin.recharge.tab.pending': 'Pending',
'h5Admin.recharge.tab.all': 'All',
'h5Admin.recharge.tab.success': 'Approved',
'h5Admin.recharge.tab.failed': 'Rejected',
'h5Admin.recharge.customer': 'Customer',
'h5Admin.recharge.serialNo': 'Serial No.',
'h5Admin.recharge.time': 'Time',
'h5Admin.recharge.remark': 'Remark',
'h5Admin.recharge.viewImage': 'Screenshot',
'h5Admin.recharge.approve': 'Approve',
'h5Admin.recharge.reject': 'Reject',
'h5Admin.recharge.approveTitle': 'Confirm approval',
'h5Admin.recharge.approveConfirm': 'Approve ¥{amount} for {customer}?',
'h5Admin.recharge.approveOk': 'Approved',
'h5Admin.recharge.approveFail': 'Approval failed',
'h5Admin.recharge.rejectTitle': 'Reject recharge',
'h5Admin.recharge.rejectConfirm': 'Confirm reject',
'h5Admin.recharge.rejectRemarkPh': 'Reason (optional)',
'h5Admin.recharge.rejectOk': 'Rejected',
'h5Admin.recharge.rejectFail': 'Reject failed',
'h5Admin.recharge.pay.alipay': 'Alipay',
'h5Admin.recharge.pay.wechat': 'WeChat',
'h5Admin.workplace.greeting.morning': 'Good morning',
'h5Admin.workplace.greeting.afternoon': 'Good afternoon',
'h5Admin.workplace.greeting.evening': 'Good evening',
'h5Admin.workplace.subtitle': 'Clear high-priority queues first',
'h5Admin.workplace.rechargeAudit': 'Recharge review',
'h5Admin.workplace.opsTitle': 'Today’s queue',
'h5Admin.workplace.refresh': 'Refresh',
'h5Admin.workplace.updated': 'Updated {time}',
'h5Admin.workplace.pendingRecharge': 'Recharge pending',
'h5Admin.workplace.todayCreated': 'Created today',
'h5Admin.workplace.pendingPayment': 'Unpaid',
'h5Admin.workplace.pendingShip': 'To ship',

View File

@ -21,25 +21,61 @@ export default {
'h5Admin.login.privacy': '隐私政策',
'h5Admin.login.terms': '服务条款',
'h5Admin.login.and': '与',
'h5.login.switchToAdmin': '切换至管理登录',
'h5.login.switchToCustomer': '切换至客户登录',
'h5Admin.me.title': '我的',
'h5Admin.me.account': '账号',
'h5Admin.me.org': '组织',
'h5Admin.me.roles': '角色',
'h5Admin.me.email': '邮箱',
'h5Admin.me.rechargeAudit': '充值审核',
'h5Admin.me.logout': '退出登录',
'h5Admin.me.logoutConfirm': '确定退出当前账号?',
'h5Admin.me.emptyRole': '暂无角色',
'h5Admin.me.emptyOrg': '未分配组织',
'h5Admin.me.emptyEmail': '未填写',
'h5Admin.recharge.title': '充值审核',
'h5Admin.recharge.subtitle': '审核客户充值申请',
'h5Admin.recharge.refresh': '刷新',
'h5Admin.recharge.search': '搜索',
'h5Admin.recharge.searchPh': '搜索流水号',
'h5Admin.recharge.empty': '暂无充值记录',
'h5Admin.recharge.loadMore': '加载更多',
'h5Admin.recharge.loadFailed': '加载失败',
'h5Admin.recharge.tab.pending': '待审核',
'h5Admin.recharge.tab.all': '全部',
'h5Admin.recharge.tab.success': '已通过',
'h5Admin.recharge.tab.failed': '已驳回',
'h5Admin.recharge.customer': '客户',
'h5Admin.recharge.serialNo': '流水号',
'h5Admin.recharge.time': '时间',
'h5Admin.recharge.remark': '备注',
'h5Admin.recharge.viewImage': '查看截图',
'h5Admin.recharge.approve': '通过',
'h5Admin.recharge.reject': '驳回',
'h5Admin.recharge.approveTitle': '确认通过',
'h5Admin.recharge.approveConfirm': '确定通过 {customer} 的 ¥{amount} 充值申请?',
'h5Admin.recharge.approveOk': '已通过',
'h5Admin.recharge.approveFail': '审核失败',
'h5Admin.recharge.rejectTitle': '驳回充值',
'h5Admin.recharge.rejectConfirm': '确认驳回',
'h5Admin.recharge.rejectRemarkPh': '驳回原因(选填)',
'h5Admin.recharge.rejectOk': '已驳回',
'h5Admin.recharge.rejectFail': '驳回失败',
'h5Admin.recharge.pay.alipay': '支付宝',
'h5Admin.recharge.pay.wechat': '微信',
'h5Admin.workplace.greeting.morning': '早上好',
'h5Admin.workplace.greeting.afternoon': '下午好',
'h5Admin.workplace.greeting.evening': '晚上好',
'h5Admin.workplace.subtitle': '先处理高优先级待办,再看今日概况',
'h5Admin.workplace.rechargeAudit': '充值审核',
'h5Admin.workplace.opsTitle': '今日待办',
'h5Admin.workplace.refresh': '刷新',
'h5Admin.workplace.updated': '更新于 {time}',
'h5Admin.workplace.pendingRecharge': '充值待审',
'h5Admin.workplace.todayCreated': '今日创建',
'h5Admin.workplace.pendingPayment': '待支付',
'h5Admin.workplace.pendingShip': '待出库',

View File

@ -73,6 +73,8 @@
>
<div class="h5-login-agreement">{{ agreementContent }}</div>
</a-modal>
<H5LoginPortalSwitch target="customer" />
</div>
</template>
@ -89,6 +91,7 @@ import { useRbacUserStore } from '@/store/modules/rbac-user';
import { ADMIN_H5_HOME_PATH, isAdminH5Path } from '@/utils/login-portal';
import logoImg from '@/assets/logo2.png';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
import H5LoginPortalSwitch from '@/views/h5/components/H5LoginPortalSwitch.vue';
defineOptions({ name: 'H5AdminLogin' });

View File

@ -1,24 +1,43 @@
<template>
<div class="h5-order-detail-page">
<H5AdminOrderDetailSheet
v-model:visible="sheetVisible"
:key="detailKey"
:visible="sheetVisible && !editVisible"
:order-id="orderId"
:initial-tab="initialTab"
@update:visible="onDetailVisible"
@edit="openEdit"
/>
<H5PlaceOrderSheet
v-if="editVisible"
v-model:visible="editVisible"
:order-id="editOrderId"
variant="admin"
@success="onEditSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import H5AdminOrderDetailSheet from '../components/H5AdminOrderDetailSheet.vue';
defineOptions({ name: 'H5AdminOrderDetail' });
const H5PlaceOrderSheet = defineAsyncComponent(
() => import('@/views/h5/customer-center/components/H5PlaceOrderSheet.vue'),
);
const route = useRoute();
const router = useRouter();
const sheetVisible = ref(true);
const editVisible = ref(false);
const editOrderId = ref<number | string | null>(null);
const detailKey = ref(0);
const orderId = computed(() => String(route.params.id || ''));
const initialTab = computed(() => {
const tab = String(route.query.tab || '');
@ -34,8 +53,34 @@ function leave() {
router.replace('/h5/admin/orders');
}
function onDetailVisible(visible: boolean) {
if (!visible && !editVisible.value) {
sheetVisible.value = false;
}
}
function openEdit(id: number | string) {
if (id == null || id === '') return;
editOrderId.value = id;
editVisible.value = true;
}
function onEditSuccess() {
editVisible.value = false;
editOrderId.value = null;
detailKey.value += 1;
sheetVisible.value = true;
}
watch(sheetVisible, (visible) => {
if (!visible) leave();
if (!visible && !editVisible.value) leave();
});
watch(editVisible, (visible) => {
if (!visible) {
editOrderId.value = null;
sheetVisible.value = true;
}
});
onMounted(() => {

View File

@ -0,0 +1,726 @@
<template>
<div class="h5-ar">
<header class="h5-ar__hero h5-enter">
<div class="h5-ar__hero-text">
<h1>{{ t('h5Admin.recharge.title') }}</h1>
<p>{{ t('h5Admin.recharge.subtitle') }}</p>
</div>
<button type="button" class="h5-ar__refresh" :disabled="loading" @click="reload">
{{ t('h5Admin.recharge.refresh') }}
</button>
</header>
<div class="h5-ar__tabs h5-enter">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="h5-ar__tab"
:class="{ 'is-active': statusFilter === tab.key }"
@click="setStatus(tab.key)"
>
{{ t(tab.labelKey) }}
</button>
</div>
<div class="h5-ar__search h5-enter">
<icon-search class="h5-ar__search-icon" />
<input
v-model="keyword"
class="h5-ar__search-input"
type="search"
enterkeyhint="search"
:placeholder="t('h5Admin.recharge.searchPh')"
@keyup.enter="reload"
/>
<button
v-if="keyword"
type="button"
class="h5-ar__search-clear"
aria-label="clear"
@click="keyword = ''; reload()"
>
×
</button>
<button type="button" class="h5-ar__search-btn" @click="reload">
{{ t('h5Admin.recharge.search') }}
</button>
</div>
<div v-if="loading && !rows.length" class="h5-skel">
<div v-for="n in 3" :key="n" class="h5-skel-card" />
</div>
<a-spin :loading="loading && rows.length > 0" class="h5-ar__spin">
<H5EmptyState
v-if="!loading && !rows.length"
:icon="IconCommon"
:text="t('h5Admin.recharge.empty')"
/>
<article
v-for="(row, index) in rows"
:key="row.id || row.serialNo"
class="h5-ar__card h5-enter"
:style="{ animationDelay: `${Math.min(index, 6) * 40}ms` }"
>
<div class="h5-ar__card-top">
<div class="h5-ar__card-main">
<strong class="h5-ar__amount">¥{{ formatAmount(row.payAmount) }}</strong>
<span class="h5-ar__status" :class="`is-${statusTone(row.payStatus)}`">
{{ statusLabel(row.payStatus) }}
</span>
</div>
<span class="h5-ar__pay">
<img :src="payIcon(row.payType)" alt="" />
{{ payLabel(row.payType) }}
</span>
</div>
<div class="h5-ar__meta">
<p>
<span>{{ t('h5Admin.recharge.customer') }}</span>
{{ row.customerCode || row.customerPhone || row.customerId || '—' }}
</p>
<p>
<span>{{ t('h5Admin.recharge.serialNo') }}</span>
{{ row.serialNo || '—' }}
</p>
<p>
<span>{{ t('h5Admin.recharge.time') }}</span>
{{ formatTime(row.payTime) }}
</p>
<p v-if="row.remarks">
<span>{{ t('h5Admin.recharge.remark') }}</span>
{{ row.remarks }}
</p>
</div>
<div class="h5-ar__actions">
<button
v-if="row.payImage"
type="button"
class="h5-ar__btn is-ghost"
@click="previewImage(row.payImage)"
>
{{ t('h5Admin.recharge.viewImage') }}
</button>
<template v-if="row.payStatus === '1'">
<button
type="button"
class="h5-ar__btn is-danger"
:disabled="actingId === row.id"
@click="openReject(row)"
>
{{ t('h5Admin.recharge.reject') }}
</button>
<button
type="button"
class="h5-ar__btn is-primary"
:disabled="actingId === row.id"
@click="confirmApprove(row)"
>
{{ t('h5Admin.recharge.approve') }}
</button>
</template>
</div>
</article>
<a-button
v-if="hasMore"
long
class="h5-ar__more"
:loading="loadingMore"
@click="loadMore"
>
{{ t('h5Admin.recharge.loadMore') }}
</a-button>
</a-spin>
<a-modal
v-model:visible="imageVisible"
:title="t('h5Admin.recharge.viewImage')"
:footer="false"
unmount-on-close
width="92%"
>
<div class="h5-ar__preview">
<img v-if="previewUrl" :src="previewUrl" alt="" />
</div>
</a-modal>
<a-modal
v-model:visible="rejectVisible"
:title="t('h5Admin.recharge.rejectTitle')"
:ok-text="t('h5Admin.recharge.rejectConfirm')"
:mask-closable="false"
unmount-on-close
@before-ok="submitReject"
@cancel="rejectVisible = false"
>
<a-textarea
v-model="rejectRemark"
:placeholder="t('h5Admin.recharge.rejectRemarkPh')"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { Message, Modal } from '@arco-design/web-vue';
import { IconCommon, IconSearch } from '@arco-design/web-vue/es/icon';
import {
approveRechargeRecord,
getRechargeRecordList,
rejectRechargeRecord,
resolveOmsAssetUrl,
} from '@/api/recharge/rechargeRecord';
import H5EmptyState from '@/views/h5/customer-center/components/H5EmptyState.vue';
import zfbIcon from '@/assets/zfb.png';
import wxIcon from '@/assets/wx.png';
defineOptions({ name: 'H5AdminRecharge' });
type StatusKey = '1' | 'all' | '2' | '3';
interface RechargeRow {
id?: number | string;
serialNo: string;
payTime: string;
payType: 'zfb' | 'wx' | '';
payAmount: number;
payImage?: string;
payStatus: string;
remarks?: string;
customerId?: number | string;
customerCode?: string;
customerPhone?: string;
}
const { t } = useI18n();
const tabs: Array<{ key: StatusKey; labelKey: string }> = [
{ key: '1', labelKey: 'h5Admin.recharge.tab.pending' },
{ key: 'all', labelKey: 'h5Admin.recharge.tab.all' },
{ key: '2', labelKey: 'h5Admin.recharge.tab.success' },
{ key: '3', labelKey: 'h5Admin.recharge.tab.failed' },
];
const statusFilter = ref<StatusKey>('1');
const keyword = ref('');
const rows = ref<RechargeRow[]>([]);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const loading = ref(false);
const loadingMore = ref(false);
const actingId = ref<number | string | null>(null);
const imageVisible = ref(false);
const previewUrl = ref('');
const rejectVisible = ref(false);
const rejectRemark = ref('');
const pendingReject = ref<RechargeRow | null>(null);
const hasMore = computed(() => rows.value.length < total.value);
function resolvePayImageUrl(raw: unknown): string | undefined {
const s = String(raw ?? '').trim();
if (!s || s === '0' || /^https?:\/\/[^/]+\/0$/i.test(s)) return undefined;
const url = resolveOmsAssetUrl(s).trim();
return url || undefined;
}
function mapRow(item: any): RechargeRow {
const pt = Number(item.payType ?? item.PayType);
return {
id: item.id ?? item.Id,
serialNo: String(item.serialNumber ?? item.SerialNumber ?? ''),
payTime: String(item.rechargeTime ?? item.RechargeTime ?? ''),
payType: pt === 2 ? 'zfb' : pt === 1 ? 'wx' : '',
payAmount: Number(item.rechargeAmount ?? item.RechargeAmount ?? 0) || 0,
payImage: resolvePayImageUrl(item.rechargeScreenshot ?? item.RechargeScreenshot),
payStatus: String(item.rechargeStatus ?? item.RechargeStatus ?? ''),
remarks: String(item.remark ?? item.Remark ?? '') || undefined,
customerId: item.customerId ?? item.CustomerId,
customerCode: String(item.customerCode ?? item.CustomerCode ?? '') || undefined,
customerPhone: String(item.customerPhone ?? item.CustomerPhone ?? '') || undefined,
};
}
function formatAmount(n: number) {
return Number.isFinite(n) ? n.toFixed(2) : '0.00';
}
function formatTime(v?: string) {
if (!v) return '—';
const d = dayjs(v);
return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : String(v);
}
function statusLabel(code: string) {
if (code === '1') return t('h5Admin.recharge.tab.pending');
if (code === '2') return t('h5Admin.recharge.tab.success');
if (code === '3') return t('h5Admin.recharge.tab.failed');
return code || '—';
}
function statusTone(code: string) {
if (code === '1') return 'wait';
if (code === '2') return 'ok';
if (code === '3') return 'fail';
return 'mute';
}
function payIcon(type: RechargeRow['payType']) {
return type === 'zfb' ? zfbIcon : wxIcon;
}
function payLabel(type: RechargeRow['payType']) {
if (type === 'zfb') return t('h5Admin.recharge.pay.alipay');
if (type === 'wx') return t('h5Admin.recharge.pay.wechat');
return '—';
}
function buildParams(pageNo: number) {
const params: Record<string, any> = {
page: pageNo,
pageSize,
};
if (statusFilter.value !== 'all') {
params.rechargeStatus = Number(statusFilter.value);
}
const sn = keyword.value.trim();
if (sn) params.serialNumber = sn;
return params;
}
async function fetchPage(pageNo: number, append: boolean) {
const res = await getRechargeRecordList(buildParams(pageNo));
const list = (res.items || []).map(mapRow);
total.value = Number(res.total || 0);
rows.value = append ? [...rows.value, ...list] : list;
page.value = pageNo;
}
async function reload() {
loading.value = true;
try {
await fetchPage(1, false);
} catch (e: any) {
Message.error(e?.message || t('h5Admin.recharge.loadFailed'));
rows.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return;
loadingMore.value = true;
try {
await fetchPage(page.value + 1, true);
} catch (e: any) {
Message.error(e?.message || t('h5Admin.recharge.loadFailed'));
} finally {
loadingMore.value = false;
}
}
function setStatus(key: StatusKey) {
if (statusFilter.value === key) return;
statusFilter.value = key;
void reload();
}
function previewImage(url: string) {
previewUrl.value = url;
imageVisible.value = true;
}
function confirmApprove(row: RechargeRow) {
if (!row.id) return;
Modal.confirm({
title: t('h5Admin.recharge.approveTitle'),
content: t('h5Admin.recharge.approveConfirm', {
amount: formatAmount(row.payAmount),
customer: row.customerCode || row.customerPhone || row.serialNo || '',
}),
okText: t('h5Admin.recharge.approve'),
onOk: async () => {
actingId.value = row.id!;
try {
await approveRechargeRecord({ id: row.id! });
Message.success(t('h5Admin.recharge.approveOk'));
await reload();
} catch (e: any) {
Message.error(e?.message || t('h5Admin.recharge.approveFail'));
throw e;
} finally {
actingId.value = null;
}
},
});
}
function openReject(row: RechargeRow) {
pendingReject.value = row;
rejectRemark.value = '';
rejectVisible.value = true;
}
async function submitReject() {
const row = pendingReject.value;
if (!row?.id) {
rejectVisible.value = false;
return false;
}
actingId.value = row.id;
try {
await rejectRechargeRecord({
id: row.id,
remark: rejectRemark.value.trim() || undefined,
});
Message.success(t('h5Admin.recharge.rejectOk'));
rejectVisible.value = false;
pendingReject.value = null;
await reload();
return true;
} catch (e: any) {
Message.error(e?.message || t('h5Admin.recharge.rejectFail'));
return false;
} finally {
actingId.value = null;
}
}
onMounted(() => {
void reload();
});
</script>
<style lang="less" scoped>
.h5-ar {
padding: 12px 16px 24px;
box-sizing: border-box;
}
.h5-ar__hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.h5-ar__hero-text {
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--h5-text, #1d2129);
}
p {
margin: 4px 0 0;
font-size: 13px;
color: var(--h5-text-3, #86909c);
}
}
.h5-ar__refresh {
flex-shrink: 0;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--h5-border, #e5e6eb);
background: #fff;
color: var(--h5-text-2, #4e5969);
font-size: 13px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:disabled {
opacity: 0.55;
}
&:active:not(:disabled) {
background: #f7f8fa;
}
}
.h5-ar__tabs {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 12px;
padding-bottom: 2px;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
.h5-ar__tab {
flex: 0 0 auto;
height: 32px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid var(--h5-border, #e5e6eb);
background: #fff;
color: var(--h5-text-2, #4e5969);
font-size: 13px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&.is-active {
border-color: color-mix(in srgb, var(--app-primary-color) 35%, #fff);
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: var(--app-primary-color);
font-weight: 600;
}
}
.h5-ar__search {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 10px 0 12px;
margin-bottom: 14px;
border-radius: 12px;
background: #fff;
border: 1px solid var(--h5-border, #e5e6eb);
}
.h5-ar__search-icon {
color: #c9cdd4;
font-size: 16px;
flex-shrink: 0;
}
.h5-ar__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: var(--h5-text, #1d2129);
}
.h5-ar__search-clear {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: #f2f3f5;
color: #86909c;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.h5-ar__search-btn {
flex-shrink: 0;
height: 28px;
padding: 0 10px;
border: none;
border-radius: 8px;
background: var(--app-primary-color);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.h5-ar__spin {
width: 100%;
display: block;
}
.h5-skel {
display: flex;
flex-direction: column;
gap: 12px;
}
.h5-skel-card {
height: 140px;
border-radius: 16px;
background: linear-gradient(90deg, #f2f3f5 25%, #fafafa 37%, #f2f3f5 63%);
background-size: 400% 100%;
animation: h5-ar-skel 1.2s ease infinite;
}
@keyframes h5-ar-skel {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.h5-ar__card {
margin-bottom: 12px;
padding: 14px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--h5-border-soft, #f2f3f5);
box-shadow: var(--h5-shadow, 0 4px 16px rgba(15, 23, 42, 0.05));
}
.h5-ar__card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.h5-ar__card-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.h5-ar__amount {
font-size: 22px;
font-weight: 700;
color: var(--h5-text, #1d2129);
font-variant-numeric: tabular-nums;
}
.h5-ar__status {
flex-shrink: 0;
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
line-height: 22px;
font-weight: 600;
&.is-wait {
color: #ff7d00;
background: #fff7e8;
}
&.is-ok {
color: #00b42a;
background: #e8ffea;
}
&.is-fail {
color: #f53f3f;
background: #ffece8;
}
&.is-mute {
color: #86909c;
background: #f2f3f5;
}
}
.h5-ar__pay {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--h5-text-2, #4e5969);
img {
width: 16px;
height: 16px;
object-fit: contain;
}
}
.h5-ar__meta {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
p {
margin: 0;
font-size: 13px;
color: var(--h5-text, #1d2129);
line-height: 1.4;
word-break: break-all;
}
span {
color: var(--h5-text-3, #86909c);
margin-right: 6px;
}
}
.h5-ar__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.h5-ar__btn {
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&.is-ghost {
border: 1px solid var(--h5-border, #e5e6eb);
background: #fff;
color: var(--h5-text-2, #4e5969);
}
&.is-primary {
border: none;
background: var(--app-primary-color);
color: #fff;
}
&.is-danger {
border: 1px solid color-mix(in srgb, #f53f3f 28%, #fff);
background: #ffece8;
color: #f53f3f;
}
&:active:not(:disabled) {
transform: scale(0.97);
}
}
.h5-ar__more {
margin-top: 4px;
margin-bottom: 8px;
}
.h5-ar__preview {
display: flex;
justify-content: center;
img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
}
</style>

View File

@ -26,7 +26,7 @@
type="button"
class="h5-wp__ops-card"
:class="[`is-${item.tone}`, { 'is-alert': item.alert && item.value > 0 }]"
@click="goOrders(item.status)"
@click="onOpsClick(item)"
>
<span class="h5-wp__ops-label">{{ t(item.labelKey) }}</span>
<strong class="h5-wp__ops-value">{{ formatNum(item.value) }}</strong>
@ -121,6 +121,7 @@ import {
type OrderWorkplaceStats,
type OrderWorkplaceTrendPoint,
} from '@/api/workplace';
import { getRechargeRecordList } from '@/api/recharge/rechargeRecord';
import { useRbacUserStore } from '@/store/modules/rbac-user';
defineOptions({ name: 'H5AdminWorkplace' });
@ -133,6 +134,7 @@ const loading = ref(false);
const trendLoading = ref(false);
const loadError = ref(false);
const stats = ref<OrderWorkplaceStats | null>(null);
const pendingRechargeCount = ref(0);
const trendPoints = ref<OrderWorkplaceTrendPoint[]>([]);
const trendDimension = ref<'day' | 'week' | 'month'>('day');
const trendDims = ['day', 'week', 'month'] as const;
@ -162,9 +164,28 @@ const updatedLabel = computed(() => {
}
});
const opsItems = computed(() => {
type OpsItem = {
key: string;
labelKey: string;
value: number;
status: string;
tone: string;
alert: boolean;
action?: 'orders' | 'recharge';
};
const opsItems = computed<OpsItem[]>(() => {
const s = stats.value;
return [
{
key: 'pendingRecharge',
labelKey: 'h5Admin.workplace.pendingRecharge',
value: Math.round(Number(pendingRechargeCount.value) || 0),
status: '',
tone: 'warning',
alert: true,
action: 'recharge',
},
{
key: 'problematic',
labelKey: 'h5Admin.workplace.problematic',
@ -172,6 +193,7 @@ const opsItems = computed(() => {
status: 'problematic',
tone: 'danger',
alert: true,
action: 'orders',
},
{
key: 'claimed',
@ -180,6 +202,7 @@ const opsItems = computed(() => {
status: 'claimed',
tone: 'warning',
alert: true,
action: 'orders',
},
{
key: 'pendingShip',
@ -188,6 +211,7 @@ const opsItems = computed(() => {
status: 'pendingShip',
tone: 'warning',
alert: true,
action: 'orders',
},
{
key: 'pendingPayment',
@ -196,6 +220,7 @@ const opsItems = computed(() => {
status: 'pendingPayment',
tone: 'warning',
alert: true,
action: 'orders',
},
{
key: 'todayCreated',
@ -204,6 +229,7 @@ const opsItems = computed(() => {
status: '',
tone: 'primary',
alert: false,
action: 'orders',
},
];
});
@ -296,6 +322,31 @@ function goOrders(status: string) {
go('/h5/admin/orders', { status });
}
function goRecharge() {
go('/h5/admin/recharge');
}
function onOpsClick(item: OpsItem) {
if (item.action === 'recharge') {
goRecharge();
return;
}
goOrders(item.status);
}
async function loadPendingRechargeCount() {
try {
const res = await getRechargeRecordList({
page: 1,
pageSize: 1,
rechargeStatus: 1,
});
pendingRechargeCount.value = Number(res.total || 0);
} catch {
pendingRechargeCount.value = 0;
}
}
function resolveTrendCount(dim: 'day' | 'week' | 'month') {
if (dim === 'week') return 4;
if (dim === 'month') return 6;
@ -327,7 +378,11 @@ async function load() {
loading.value = true;
loadError.value = false;
try {
const [statsRes] = await Promise.all([getOrderWorkplaceStats(), loadTrend()]);
const [statsRes] = await Promise.all([
getOrderWorkplaceStats(),
loadTrend(),
loadPendingRechargeCount(),
]);
stats.value = statsRes;
updatedAt.value = new Date();
} catch {
@ -385,10 +440,15 @@ onMounted(() => {
color: var(--h5-text-2, #4e5969);
font-size: 13px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:disabled {
opacity: 0.55;
}
&:active:not(:disabled) {
background: #f7f8fa;
}
}
.h5-wp__updated,

View File

@ -0,0 +1,65 @@
<template>
<router-link
class="h5-login-portal-switch"
:to="to"
replace
:title="label"
:aria-label="label"
>
<component :is="icon" :size="18" />
</router-link>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconSettings, IconUser } from '@arco-design/web-vue/es/icon';
const props = defineProps<{
/** 当前页面对侧:admin=去管理登录;customer=去客户登录 */
target: 'admin' | 'customer';
}>();
const { t } = useI18n();
const to = computed(() =>
props.target === 'admin' ? '/h5/admin/login' : '/h5/login',
);
const icon = computed(() =>
props.target === 'admin' ? IconSettings : IconUser,
);
const label = computed(() =>
props.target === 'admin'
? t('h5.login.switchToAdmin')
: t('h5.login.switchToCustomer'),
);
</script>
<style lang="less" scoped>
.h5-login-portal-switch {
position: fixed;
right: 14px;
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
z-index: 30;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
color: #c9cdd4;
text-decoration: none;
background: transparent;
border: none;
opacity: 0.55;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.15s ease, color 0.15s ease;
&:active {
opacity: 0.85;
color: #86909c;
}
}
</style>

View File

@ -149,6 +149,7 @@ import { IconClose, IconClockCircle, IconCommon, IconEdit } from '@arco-design/w
import { getOrderDetail, postOrderPreDeclare } from '@/api/order';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import { filterCustomerFacingFees, normalizeFeeType } from '@/utils/feeTotals';
import { h5Navigate } from '@/utils/device';
import {
getOrderStatusMeta,
@ -332,7 +333,10 @@ const feeList = computed(() => {
const d = detail.value || {};
const fees = d.feeList ?? d.FeeList ?? d.orderFees ?? d.fees ?? [];
if (!Array.isArray(fees)) return [];
return fees.map((f: any) => ({
// 客户侧只展示应收;不含出库,同费用项只保留入库>预报
const receivable = fees.filter((f: any) => normalizeFeeType(f) === 'receivable');
const source = receivable.length ? receivable : fees;
return filterCustomerFacingFees(source).map((f: any) => ({
name: getCostItemLabel(f),
amount: pick(f, 'amount', 'Amount', 'fee') ?? '-',
}));

View File

@ -104,6 +104,12 @@ import { Message } from '@arco-design/web-vue';
import { IconClose, IconSafe } from '@arco-design/web-vue/es/icon';
import { getOrderDetail, postOrderPay } from '@/api/order';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import {
filterCustomerFacingFees,
formatFeeTotals,
normalizeFeeType,
sumFeesByCurrency,
} from '@/utils/feeTotals';
import H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5OrderPaySheet' });
@ -162,7 +168,7 @@ const grandTotalText = computed(() => {
totals.set(fee.currency, (totals.get(fee.currency) ?? 0) + amount);
}
}
return formatTotals(totals);
return formatFeeTotals(totals);
});
/** 单币种时突出显示数字,多币种保留原文 */
@ -189,30 +195,6 @@ function pick(raw: Record<string, any> | undefined, ...keys: string[]) {
return undefined;
}
function normalizeFeeType(row: any): 'receivable' | 'payable' | '' {
const raw = String(row?.type ?? row?.feeType ?? row?.FeeType ?? '').toLowerCase();
if (raw === 'receivable' || raw === 'payable') return raw;
return '';
}
function sumReceivableFees(list: any[]) {
const totals = new Map<string, number>();
for (const row of list) {
const currency = String(row?.currency ?? 'CNY').trim() || 'CNY';
const amount = Number(row?.amount);
if (!Number.isFinite(amount)) continue;
totals.set(currency, (totals.get(currency) ?? 0) + amount);
}
return totals;
}
function formatTotals(totals: Map<string, number>) {
if (!totals.size) return '--';
return [...totals.entries()]
.map(([currency, amount]) => `${currency} ${amount.toFixed(2)}`)
.join(' + ');
}
function getCostItemLabel(row: any) {
const fromApi = row?.costItemName ?? row?.CostItemName;
if (fromApi != null && fromApi !== '') return String(fromApi);
@ -278,10 +260,12 @@ async function loadPreviews() {
try {
const detail = await getOrderDetail(id);
const feesRaw = detail?.feeList ?? detail?.FeeList ?? detail?.orderFees ?? detail?.fees;
const receivableFees = (Array.isArray(feesRaw) ? feesRaw : []).filter(
const receivableFees = filterCustomerFacingFees(
(Array.isArray(feesRaw) ? feesRaw : []).filter(
(row) => normalizeFeeType(row) === 'receivable',
),
);
const totals = sumReceivableFees(receivableFees);
const totals = sumFeesByCurrency(receivableFees);
const totalAmount = [...totals.values()].reduce((sum, val) => sum + val, 0);
const waybillNo =
pick(detail, 'waybillNo', 'WaybillNo', 'serviceNo', 'ServiceNo') || fallbackWaybill;
@ -289,7 +273,7 @@ async function loadPreviews() {
id,
waybillNo: String(waybillNo),
fees: mapFeeRows(receivableFees),
totalText: formatTotals(totals),
totalText: formatFeeTotals(totals),
totalAmount,
error: totalAmount <= 0 ? t('h5.pay.invalidAmount') : undefined,
});

View File

@ -103,6 +103,24 @@
<p v-if="fieldError('clientNo')" class="h5-po__field-err">{{ fieldError('clientNo') }}</p>
</div>
<div v-if="isEditMode" class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.downstreamServiceNo') }}</label>
<a-input
v-model="form.downstreamServiceNo"
allow-clear
:placeholder="t('h5.placeOrder.downstreamServiceNoPh')"
/>
</div>
<div v-if="canEditTrackNo" class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.trackNo') }}</label>
<a-input
v-model="form.trackNo"
allow-clear
:placeholder="t('h5.placeOrder.trackNoPh')"
/>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.product') }}</label>
<a-select
@ -154,7 +172,7 @@
<a-select
v-model="form.warehouseId"
allow-clear
:disabled="isEditMode"
allow-search
:error="fieldHasError('warehouseId')"
>
<a-option v-for="opt in warehouseOptions" :key="opt.value" :value="opt.value">
@ -167,6 +185,31 @@
</div>
</div>
<template v-if="isEditMode">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.isCod') }}</label>
<a-switch v-model="form.isCod" />
</div>
<div v-if="form.isCod" class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.codCurrency') }}</label>
<a-select v-model="form.codCurrency" allow-clear allow-search>
<a-option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.codSum') }}</label>
<a-input
v-model="form.codSum"
allow-clear
:placeholder="t('h5.placeOrder.codSumPh')"
/>
</div>
</div>
</template>
<div class="h5-po__field">
<label class="h5-po__label">
{{ t('h5.placeOrder.weight') }} / {{ t('h5.placeOrder.dimensions') }}
@ -175,25 +218,21 @@
<a-input
v-model="form.actualWeight"
allow-clear
:disabled="isEditMode"
:error="fieldHasError('actualWeight')"
:placeholder="t('h5.placeOrder.weightPh')"
/>
<a-input
v-model="form.length"
:disabled="isEditMode"
:error="fieldHasError('length')"
:placeholder="t('h5.placeOrder.length')"
/>
<a-input
v-model="form.width"
:disabled="isEditMode"
:error="fieldHasError('width')"
:placeholder="t('h5.placeOrder.width')"
/>
<a-input
v-model="form.height"
:disabled="isEditMode"
:error="fieldHasError('height')"
:placeholder="t('h5.placeOrder.height')"
/>
@ -1172,6 +1211,7 @@ import {
postOrderParcelCreate,
postOrderReceiverParseAddress,
postOrderUpdate,
postOrderWeightUpdate,
} from '@/api/order';
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
@ -1227,13 +1267,18 @@ defineOptions({ name: 'H5PlaceOrderSheet' });
const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft';
const props = defineProps<{
const props = withDefaults(
defineProps<{
visible: boolean;
/** 查价带入草稿;不传则尝试读本地缓存 */
draft?: Record<string, any> | null;
/** 传入则为编辑模式:加载详情并走 /order/update */
orderId?: number | string | null;
}>();
/** admin:管理端编辑(可改跟踪号) */
variant?: 'customer' | 'admin';
}>(),
{ variant: 'customer' },
);
const emit = defineEmits<{
'update:visible': [value: boolean];
@ -1247,8 +1292,12 @@ const isEditMode = computed(() => {
const id = Number(props.orderId);
return Number.isFinite(id) && id > 0;
});
const isAdminVariant = computed(() => props.variant === 'admin');
const canEditTrackNo = computed(() => isEditMode.value && isAdminVariant.value);
/** 详情是否已有发件人(更新接口不能新建 sender) */
const editHasSender = ref(false);
/** 编辑态件重尺类型(默认预报重) */
const editWeightType = ref('forecast');
const countryIdToCode = ref<Record<string, string>>({});
const step = ref(0);
@ -1499,7 +1548,11 @@ const form = reactive({
actualWeight: '',
quantity: '1',
declaredCurrency: 'USD',
downstreamServiceNo: '',
trackNo: '',
isCod: false,
codCurrency: '',
codSum: '',
receiver: {
recipientsName: '',
phone: '',
@ -1884,6 +1937,12 @@ function resetForm() {
form.actualWeight = '';
form.quantity = '1';
form.declaredCurrency = 'USD';
form.downstreamServiceNo = '';
form.trackNo = '';
form.isCod = false;
form.codCurrency = '';
form.codSum = '';
editWeightType.value = 'forecast';
declaredTotalInput.value = '';
declaredTotalInputFocused.value = false;
selectedAdditionalServices.value = [];
@ -2672,6 +2731,10 @@ function applyOrderDetail(detail: Record<string, any>) {
{};
form.clientNo = String(pickDetail(detail, 'clientNo', 'ClientNo') ?? '');
form.downstreamServiceNo = String(
pickDetail(detail, 'downstreamServiceNo', 'DownstreamServiceNo') ?? '',
);
form.trackNo = String(pickDetail(detail, 'trackNo', 'TrackNo', 'trackingNo') ?? '');
form.productCode = String(pickDetail(detail, 'productCode', 'ProductCode') ?? '');
form.cargoAttribute = normalizeCargoAttributeCode(
pickDetail(detail, 'cargoAttribute', 'CargoAttribute') ?? form.cargoAttribute,
@ -2688,8 +2751,19 @@ function applyOrderDetail(detail: Record<string, any>) {
pickDetail(detail, 'declaredCurrency', 'DeclaredCurrency') ?? form.declaredCurrency,
);
form.quantity = String(pickDetail(detail, 'quantity', 'Quantity') ?? (form.quantity || '1'));
{
const rawCod = pickDetail(detail, 'isCod', 'IsCod');
form.isCod = rawCod === true || rawCod === 1 || rawCod === '1';
}
form.codCurrency = String(pickDetail(detail, 'codCurrency', 'CodCurrency') ?? '');
form.codSum =
pickDetail(detail, 'codSum', 'CodSum') != null
? String(pickDetail(detail, 'codSum', 'CodSum'))
: '';
form.inboundMethod =
String(pickDetail(detail, 'inboundMethod', 'InboundMethod') ?? 'selfShip') || 'selfShip';
editWeightType.value =
String(pickDetail(weight, 'type', 'Type') ?? 'forecast').trim() || 'forecast';
form.length = String(pickDetail(weight, 'length', 'Length') ?? form.length ?? '');
form.width = String(pickDetail(weight, 'width', 'Width') ?? form.width ?? '');
form.height = String(pickDetail(weight, 'height', 'Height') ?? form.height ?? '');
@ -2779,13 +2853,21 @@ function buildUpdatePayload() {
skuImage: normalizeSkuImageForStorage(row.skuImage),
};
});
const declaredSum =
parseNum(declaredTotalInput.value) ??
payloadItems.reduce((sum, row) => sum + (Number(row.amount) || 0), 0);
const body: Record<string, any> = {
id: Number(props.orderId),
clientNo: trim(form.clientNo),
downstreamServiceNo: trim(form.downstreamServiceNo),
cargoAttribute: normalizeCargoAttributeCode(form.cargoAttribute),
quantity: trim(form.quantity) || '1',
warehouseId: parseNum(form.warehouseId) ?? 0,
quantity: parseNum(form.quantity) ?? 1,
declaredCurrency: trim(form.declaredCurrency),
isCod: false,
declaredSum: declaredSum ?? 0,
isCod: Boolean(form.isCod),
codCurrency: trim(form.codCurrency),
codSum: parseNum(form.codSum),
receiver: {
recipientsName: trim(form.receiver.recipientsName),
companyName: trim(form.receiver.companyName),
@ -2809,6 +2891,9 @@ function buildUpdatePayload() {
},
items: payloadItems,
};
if (canEditTrackNo.value) {
body.trackNo = trim(form.trackNo);
}
if (editHasSender.value || trim(form.sender.senderName)) {
body.sender = {
senderName: trim(form.sender.senderName),
@ -2920,6 +3005,25 @@ async function submit() {
try {
if (isEditMode.value) {
await postOrderUpdate(buildUpdatePayload());
try {
await postOrderWeightUpdate({
id: Number(props.orderId),
weights: [
{
type: editWeightType.value || 'forecast',
length: parseNum(form.length) ?? undefined,
width: parseNum(form.width) ?? undefined,
height: parseNum(form.height) ?? undefined,
actualWeight: parseNum(form.actualWeight) ?? undefined,
},
],
});
} catch (weightErr: any) {
Message.warning(weightErr?.message || t('h5.placeOrder.editWeightSaveFailed'));
emit('success');
emit('update:visible', false);
return;
}
Message.success(t('h5.placeOrder.editSaved'));
emit('success');
emit('update:visible', false);

View File

@ -295,6 +295,8 @@ export default {
'h5.login.failed': 'Sign-in failed. Check your account and password.',
'h5.login.newUserHint': 'No account?',
'h5.login.goRegister': 'Register with WeChat',
'h5.login.switchToAdmin': 'Switch to staff login',
'h5.login.switchToCustomer': 'Switch to customer login',
'h5.recharge.title': 'Recharge',
'h5.recharge.amount': 'Amount',
@ -348,6 +350,15 @@ export default {
'h5.placeOrder.handoffFilled': 'Filled: {items}',
'h5.placeOrder.clientNo': 'Customer order No.',
'h5.placeOrder.clientNoPh': 'Auto-generated or enter your own',
'h5.placeOrder.downstreamServiceNo': 'Carrier tracking No.',
'h5.placeOrder.downstreamServiceNoPh': 'Optional',
'h5.placeOrder.trackNo': 'Tracking No.',
'h5.placeOrder.trackNoPh': 'Optional',
'h5.placeOrder.isCod': 'Cash on delivery (COD)',
'h5.placeOrder.codCurrency': 'COD currency',
'h5.placeOrder.codSum': 'COD amount',
'h5.placeOrder.codSumPh': 'Enter amount',
'h5.placeOrder.editWeightSaveFailed': 'Order saved, but weight update failed',
'h5.placeOrder.product': 'Shipping method',
'h5.placeOrder.productPh': 'Select product',
'h5.placeOrder.additionalServices': 'Additional services',

View File

@ -294,6 +294,8 @@ export default {
'h5.login.failed': '登录失败,请检查账号密码',
'h5.login.newUserHint': '没有账号?',
'h5.login.goRegister': '微信一键注册',
'h5.login.switchToAdmin': '切换至管理登录',
'h5.login.switchToCustomer': '切换至客户登录',
'h5.recharge.title': '账户充值',
'h5.recharge.amount': '充值金额',
@ -347,6 +349,15 @@ export default {
'h5.placeOrder.handoffFilled': '已带入:{items}',
'h5.placeOrder.clientNo': '客户单号',
'h5.placeOrder.clientNoPh': '可自动生成,也可自行填写',
'h5.placeOrder.downstreamServiceNo': '服务商单号',
'h5.placeOrder.downstreamServiceNoPh': '可选填',
'h5.placeOrder.trackNo': '跟踪号',
'h5.placeOrder.trackNoPh': '可选填',
'h5.placeOrder.isCod': '货到付款 COD',
'h5.placeOrder.codCurrency': 'COD 币种',
'h5.placeOrder.codSum': 'COD 金额',
'h5.placeOrder.codSumPh': '请输入金额',
'h5.placeOrder.editWeightSaveFailed': '基本信息已保存,件重尺保存失败',
'h5.placeOrder.product': '运输方式',
'h5.placeOrder.productPh': '选择销售产品',
'h5.placeOrder.additionalServices': '附加服务',

View File

@ -94,13 +94,7 @@
</div>
</main>
<router-link
class="h5-login-admin-entry"
to="/h5/admin/login"
replace
>
admin
</router-link>
<H5LoginPortalSwitch target="admin" />
</template>
<a-modal
@ -130,6 +124,7 @@ import { isWeChatBrowser } from '@/utils/device';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import logoImg from '@/assets/logo2.png';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
import H5LoginPortalSwitch from '@/views/h5/components/H5LoginPortalSwitch.vue';
import WechatOAuthLoginButton from '../components/WechatOAuthLoginButton.vue';
import { wxOAuthLogin } from '@/api/wechat';
import {
@ -624,30 +619,6 @@ onMounted(async () => {
overflow: auto;
}
.h5-login-admin-entry {
position: fixed;
right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 30;
padding: 6px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: #86909c;
text-decoration: none;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(229, 230, 235, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
-webkit-tap-highlight-color: transparent;
&:active {
color: var(--app-primary-color);
transform: scale(0.96);
}
}
@keyframes h5-login-rise {
from {
opacity: 0;

View File

@ -143,7 +143,24 @@
<div class="detail-field-value">
<template v-if="basicEditing && isOrderFieldEditable(fieldKey)">
<a-select
v-if="getOrderEditMeta(fieldKey)?.inputType === 'select'"
v-if="fieldKey === 'warehouse'"
v-model="basicEditForm.warehouseId"
allow-clear
allow-search
size="small"
:loading="warehouseOptionsLoading"
:placeholder="$t('order.myOrders.drawer.warehouse')"
>
<a-option
v-for="opt in warehouseOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
<a-select
v-else-if="getOrderEditMeta(fieldKey)?.inputType === 'select'"
v-model="basicEditForm[fieldKey]"
allow-clear
size="small"
@ -703,6 +720,7 @@ import {
type OrderOperationLogItem,
} from '@/api/order';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import { getWarehouseList } from '@/api/system/warehouse';
import { getOrderStatusMeta, getOrderStatusTagStyle, resolveOrderStatusCode } from '@/constants/orderStatus';
import { getBooleanTagStyle } from '@/utils/statusTag';
import { resolveOmsAssetUrl } from '@/api/oms/common';
@ -850,6 +868,7 @@ interface FormFieldDef {
const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{ key: 'warehouse', labelKey: 'order.myOrders.drawer.warehouse', inputType: 'select' },
{
key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute',
@ -903,6 +922,7 @@ function getRecipientFormKey(key: string) {
const basicEditForm = reactive({
clientNo: '',
downstreamServiceNo: '',
warehouseId: undefined as number | undefined,
cargoAttribute: 'general',
isCod: false,
codSum: undefined as number | undefined,
@ -913,6 +933,40 @@ const basicEditForm = reactive({
const currencyOptions = ref<Array<{ value: string; label: string }>>([]);
const currencyOptionsLoading = ref(false);
const warehouseOptions = ref<Array<{ value: number; label: string }>>([]);
const warehouseOptionsLoading = ref(false);
async function ensureWarehouseOptions(preferred?: {
id?: number;
name?: string;
}) {
warehouseOptionsLoading.value = true;
try {
if (warehouseOptions.value.length === 0) {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 });
warehouseOptions.value = (items || [])
.map((row: any) => {
const id = Number(row?.id ?? row?.Id);
if (!Number.isFinite(id) || id <= 0) return null;
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
return { value: id, label: name || `#${id}` };
})
.filter((item): item is { value: number; label: string } => !!item);
}
const id = preferred?.id != null && preferred.id > 0 ? preferred.id : undefined;
const name = String(preferred?.name ?? '').trim();
if (id && !warehouseOptions.value.some((opt) => opt.value === id)) {
warehouseOptions.value = [
...warehouseOptions.value,
{ value: id, label: name || `#${id}` },
];
}
} catch {
if (warehouseOptions.value.length === 0) warehouseOptions.value = [];
} finally {
warehouseOptionsLoading.value = false;
}
}
async function ensureCurrencyOptions(preferredCode?: string) {
currencyOptionsLoading.value = true;
@ -1904,6 +1958,10 @@ function fillBasicEditForm() {
if (!o?.id) return;
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? '');
basicEditForm.downstreamServiceNo = String(o.downstreamServiceNo ?? o.DownstreamServiceNo ?? '');
const warehouseIdRaw = o.warehouseId ?? o.WarehouseId;
const warehouseIdNum = warehouseIdRaw != null ? Number(warehouseIdRaw) : NaN;
basicEditForm.warehouseId =
Number.isFinite(warehouseIdNum) && warehouseIdNum > 0 ? warehouseIdNum : undefined;
basicEditForm.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
@ -1973,6 +2031,10 @@ function startBasicEdit() {
}
fillBasicEditForm();
void ensureCurrencyOptions(basicEditForm.declaredCurrency);
void ensureWarehouseOptions({
id: basicEditForm.warehouseId,
name: String(detail.value?.warehouse ?? detail.value?.warehouseName ?? ''),
});
basicEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .basic-info');
}
@ -2008,6 +2070,7 @@ async function handleBasicEditSave() {
id,
clientNo: basicEditForm.clientNo,
downstreamServiceNo: basicEditForm.downstreamServiceNo,
warehouseId: basicEditForm.warehouseId ?? 0,
cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum,

View File

@ -59,10 +59,8 @@ const modalVisible = computed({
set: (v: boolean) => emit('update:visible', v),
})
watch(
() => props.visible,
async (open) => {
if (!open) return
async function loadStoreOptions() {
storeIds.value = []
storeLoading.value = true
try {
const { items } = await getPlatformStoreList({
@ -70,18 +68,32 @@ watch(
pageSize: 200,
customerId: props.customerId,
})
storeOptions.value = (items || []).map((raw: Record<string, unknown>) => {
storeOptions.value = (items || [])
.map((raw: Record<string, unknown>) => {
const id = Number(pickField(raw, 'id', 'Id'))
const name = String(pickField(raw, 'storeNickname', 'StoreNickname') ?? `#${id}`)
const platform = String(pickField(raw, 'platformCode', 'PlatformCode') ?? '')
return { label: platform ? `${name} (${platform})` : name, value: id }
}).filter((x) => x.value > 0)
} catch {
})
.filter((x) => x.value > 0)
if (!storeOptions.value.length) {
$baseMessage(t('order.platformStoreOrder.message.noStore'), 'warning')
}
} catch (e: any) {
storeOptions.value = []
$baseMessage(e?.message || t('order.platformStoreOrder.message.loadStoreFailed'), 'error')
} finally {
storeLoading.value = false
}
}
/** v-if 挂载时 visible 已是 true,必须 immediate,否则不会请求店铺列表 */
watch(
() => props.visible,
(open) => {
if (open) void loadStoreOptions()
},
{ immediate: true },
)
async function handleSubmit(): Promise<boolean> {

View File

@ -134,7 +134,24 @@
<div class="detail-field-value">
<template v-if="basicEditing && isOrderFieldEditable(fieldKey)">
<a-select
v-if="getOrderEditMeta(fieldKey)?.inputType === 'select'"
v-if="fieldKey === 'warehouse'"
v-model="basicEditForm.warehouseId"
allow-clear
allow-search
size="small"
:loading="warehouseOptionsLoading"
:placeholder="$t('order.myOrders.drawer.warehouse')"
>
<a-option
v-for="opt in warehouseOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
<a-select
v-else-if="getOrderEditMeta(fieldKey)?.inputType === 'select'"
v-model="basicEditForm[fieldKey]"
allow-clear
size="small"
@ -980,6 +997,7 @@ import {
} from '@/api/order';
import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesProduct';
import { getDictListAll, type DictDto } from '@/api/system/dict';
import { getWarehouseList } from '@/api/system/warehouse';
import { getOrderStatusMeta, getOrderStatusTagStyle, resolveOrderStatusCode } from '@/constants/orderStatus';
import { getBooleanTagStyle } from '@/utils/statusTag';
import { resolveOmsAssetUrl } from '@/api/oms/common';
@ -1109,6 +1127,7 @@ const orderEditFieldDefs: FormFieldDef[] = [
{ key: 'clientNo', labelKey: 'order.myOrders.drawer.clientNo' },
{ key: 'trackNo', labelKey: 'order.myOrders.drawer.trackNo' },
{ key: 'downstreamServiceNo', labelKey: 'order.myOrders.drawer.downstreamServiceNo' },
{ key: 'warehouse', labelKey: 'order.myOrders.drawer.warehouse', inputType: 'select' },
{
key: 'cargoAttribute',
labelKey: 'order.myOrders.drawer.cargoAttribute',
@ -1160,6 +1179,7 @@ const basicEditForm = reactive({
clientNo: '',
trackNo: '',
downstreamServiceNo: '',
warehouseId: undefined as number | undefined,
cargoAttribute: 'general',
isCod: false,
codSum: undefined as number | undefined,
@ -1170,6 +1190,40 @@ const basicEditForm = reactive({
const currencyOptions = ref<Array<{ value: string; label: string }>>([]);
const currencyOptionsLoading = ref(false);
const warehouseOptions = ref<Array<{ value: number; label: string }>>([]);
const warehouseOptionsLoading = ref(false);
async function ensureWarehouseOptions(preferred?: {
id?: number;
name?: string;
}) {
warehouseOptionsLoading.value = true;
try {
if (warehouseOptions.value.length === 0) {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 });
warehouseOptions.value = (items || [])
.map((row: any) => {
const id = Number(row?.id ?? row?.Id);
if (!Number.isFinite(id) || id <= 0) return null;
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
return { value: id, label: name || `#${id}` };
})
.filter((item): item is { value: number; label: string } => !!item);
}
const id = preferred?.id != null && preferred.id > 0 ? preferred.id : undefined;
const name = String(preferred?.name ?? '').trim();
if (id && !warehouseOptions.value.some((opt) => opt.value === id)) {
warehouseOptions.value = [
...warehouseOptions.value,
{ value: id, label: name || `#${id}` },
];
}
} catch {
if (warehouseOptions.value.length === 0) warehouseOptions.value = [];
} finally {
warehouseOptionsLoading.value = false;
}
}
async function ensureCurrencyOptions(preferredCode?: string) {
currencyOptionsLoading.value = true;
@ -2392,6 +2446,10 @@ function fillBasicEditForm() {
basicEditForm.clientNo = String(o.clientNo ?? o.customerOrderNo ?? '');
basicEditForm.trackNo = String(o.trackNo ?? o.trackingNo ?? '');
basicEditForm.downstreamServiceNo = String(o.downstreamServiceNo ?? o.DownstreamServiceNo ?? '');
const warehouseIdRaw = o.warehouseId ?? o.WarehouseId;
const warehouseIdNum = warehouseIdRaw != null ? Number(warehouseIdRaw) : NaN;
basicEditForm.warehouseId =
Number.isFinite(warehouseIdNum) && warehouseIdNum > 0 ? warehouseIdNum : undefined;
basicEditForm.cargoAttribute = String(o.cargoAttribute ?? 'general');
basicEditForm.isCod = !!(o.isCod ?? o.IsCod);
basicEditForm.codSum = o.codSum != null ? Number(o.codSum) : undefined;
@ -2457,6 +2515,10 @@ async function focusFirstEditableField(rootSelector: string) {
function startBasicEdit() {
fillBasicEditForm();
void ensureCurrencyOptions(basicEditForm.declaredCurrency);
void ensureWarehouseOptions({
id: basicEditForm.warehouseId,
name: String(detail.value?.warehouse ?? detail.value?.warehouseName ?? ''),
});
basicEditing.value = true;
void focusFirstEditableField('.order-detail-drawer .basic-info');
}
@ -2494,6 +2556,7 @@ async function handleBasicEditSave() {
clientNo: basicEditForm.clientNo,
trackNo: basicEditForm.trackNo,
downstreamServiceNo: basicEditForm.downstreamServiceNo,
warehouseId: basicEditForm.warehouseId ?? 0,
cargoAttribute: basicEditForm.cargoAttribute,
isCod: basicEditForm.isCod,
codSum: basicEditForm.codSum,

View File

@ -276,9 +276,9 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: false,
},
// 开发环境面单 PDF 预览:同源代理 COS 签名链接(dev 后端桶可能与 printUrl 不一致)
// 开发环境面单/截图:同源代理 COS(自定义域名 https 不可用,上游走 http)
'/__cos__': {
target: 'https://cos.ideship.com',
target: 'http://cos.ideship.com',
changeOrigin: true,
secure: false,
xfwd: false,
@ -287,7 +287,7 @@ export default defineConfig(({ mode }) => {
router: (req) => {
const raw = String(req.originalUrl || req.url || '').split('?')[0];
const host = raw.replace(/^\/__cos__\//, '').split('/')[0];
return host ? `https://${host}` : 'https://cos.ideship.com';
return host ? `http://${host}` : 'http://cos.ideship.com';
},
rewrite: (path) => path.replace(/^\/__cos__\/[^/]+/, '') || '/',
configure: (proxy) => {