Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -47,10 +47,10 @@
|
|||||||
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换 |
|
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换 |
|
||||||
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
|
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
|
||||||
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
|
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
|
||||||
| 头程下单、订单、入仓码业务关系、箱唛与履约进度 | `src/modules/first-leg/`;列表从 `views/FirstLegOrdersView.vue` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 |
|
| 头程下单、中文批量导入、入仓码关系、箱唛与履约进度 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
|
||||||
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
|
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
|
||||||
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
||||||
| 客户财务账单、收款、未收余额与客户确认 | `src/modules/finance/`;确认入口 `views/FinanceBillsView.vue`、`api/finance.ts` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 |
|
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
|
||||||
| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 |
|
| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 |
|
||||||
| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` |
|
| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` |
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ pnpm build
|
|||||||
当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
|
当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
|
||||||
|
|
||||||
| 验收域 | Client 范围与代码入口 | 验收首读 |
|
| 验收域 | Client 范围与代码入口 | 验收首读 |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 |
|
| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 |
|
||||||
| 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 |
|
| 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 |
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pnpm test
|
|||||||
|
|
||||||
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
|
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
|
||||||
|
|
||||||
登录表单在开发和生产环境均默认留空;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。
|
登录表单在开发和生产环境默认留空;用户主动选择“记住账号”后只回填登录账号、不保存密码,也不改变 Access/Refresh Token 的持久化和刷新策略;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。
|
||||||
|
|
||||||
“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。
|
“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -46,6 +46,11 @@ function mapError(error: unknown) {
|
|||||||
|
|
||||||
let refreshPromise: Promise<string> | null = null
|
let refreshPromise: Promise<string> | null = null
|
||||||
let redirectingToLogin = false
|
let redirectingToLogin = false
|
||||||
|
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
|
||||||
|
|
||||||
|
function isPublicAuthRequest(url?: string) {
|
||||||
|
return publicAuthPaths.some((path) => url?.endsWith(path))
|
||||||
|
}
|
||||||
|
|
||||||
function redirectToLoginAfterSessionExpiry() {
|
function redirectToLoginAfterSessionExpiry() {
|
||||||
if (redirectingToLogin || typeof window === 'undefined') return
|
if (redirectingToLogin || typeof window === 'undefined') return
|
||||||
@@ -98,7 +103,7 @@ export async function refreshAuthenticatedSession() {
|
|||||||
|
|
||||||
transport.interceptors.request.use((config) => {
|
transport.interceptors.request.use((config) => {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,9 +111,7 @@ transport.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const request = error.config as RetryableRequest | undefined
|
const request = error.config as RetryableRequest | undefined
|
||||||
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
|
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
|
||||||
const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path))
|
|
||||||
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) {
|
|
||||||
throw mapError(error)
|
throw mapError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
ACCESS_TOKEN_KEY,
|
||||||
|
REFRESH_TOKEN_KEY,
|
||||||
|
REMEMBERED_LOGIN_ID_KEY,
|
||||||
|
clearSessionTokens,
|
||||||
|
getRememberedLoginId,
|
||||||
|
persistRememberedLoginId,
|
||||||
|
persistTokenPair,
|
||||||
|
} from './session.ts'
|
||||||
|
|
||||||
|
function storage() {
|
||||||
|
const values = new Map()
|
||||||
|
return {
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
setItem: (key, value) => values.set(key, String(value)),
|
||||||
|
removeItem: (key) => values.delete(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStorage() {
|
||||||
|
globalThis.localStorage = storage()
|
||||||
|
globalThis.sessionStorage = storage()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('persists the normal rotating session independently of remembered account preference', () => {
|
||||||
|
installStorage()
|
||||||
|
persistTokenPair({
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
expiresIn: 1440,
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
refreshExpiresIn: 604800,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), 'access-token')
|
||||||
|
assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), 'refresh-token')
|
||||||
|
assert.equal(sessionStorage.getItem(ACCESS_TOKEN_KEY), null)
|
||||||
|
assert.equal(sessionStorage.getItem(REFRESH_TOKEN_KEY), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remembers only the login id and keeps it when session tokens are cleared', () => {
|
||||||
|
installStorage()
|
||||||
|
persistRememberedLoginId('merchant-user', true)
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, 'access-token')
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, 'refresh-token')
|
||||||
|
|
||||||
|
clearSessionTokens()
|
||||||
|
|
||||||
|
assert.equal(getRememberedLoginId(), 'merchant-user')
|
||||||
|
assert.equal(localStorage.getItem(REMEMBERED_LOGIN_ID_KEY), 'merchant-user')
|
||||||
|
assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), null)
|
||||||
|
assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), null)
|
||||||
|
|
||||||
|
persistRememberedLoginId('merchant-user', false)
|
||||||
|
assert.equal(getRememberedLoginId(), '')
|
||||||
|
})
|
||||||
+15
-6
@@ -2,6 +2,7 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
|
|||||||
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
|
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
|
||||||
export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token'
|
export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token'
|
||||||
export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at'
|
export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at'
|
||||||
|
export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id'
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
tokenType: string
|
tokenType: string
|
||||||
@@ -33,17 +34,25 @@ export function getOverseasAccessToken() {
|
|||||||
return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || ''
|
return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function persistTokenPair(tokens: TokenPair, remember?: boolean) {
|
export function persistTokenPair(tokens: TokenPair) {
|
||||||
const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage
|
|
||||||
clearSessionTokens()
|
clearSessionTokens()
|
||||||
storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||||
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||||
if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) {
|
if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) {
|
||||||
storage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
|
localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
|
||||||
storage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
|
localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRememberedLoginId() {
|
||||||
|
return localStorage.getItem(REMEMBERED_LOGIN_ID_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistRememberedLoginId(loginId: string, remember: boolean) {
|
||||||
|
if (remember) localStorage.setItem(REMEMBERED_LOGIN_ID_KEY, loginId)
|
||||||
|
else localStorage.removeItem(REMEMBERED_LOGIN_ID_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
export function persistTemporaryAccessToken(accessToken: string) {
|
export function persistTemporaryAccessToken(accessToken: string) {
|
||||||
clearSessionTokens()
|
clearSessionTokens()
|
||||||
sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { CurrentUser } from '@chongchong/shared'
|
import type { CurrentUser } from '@chongchong/shared'
|
||||||
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
|
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError, refreshAuthenticatedSession } from '@/api/http'
|
||||||
import {
|
import {
|
||||||
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
||||||
hasMappedMerchantPermission,
|
hasMappedMerchantPermission,
|
||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
getTenantProfile,
|
getTenantProfile,
|
||||||
updateMerchantPersonalProfile,
|
updateMerchantPersonalProfile,
|
||||||
} from '@/modules/tenant-management/public'
|
} from '@/modules/tenant-management/public'
|
||||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
||||||
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
||||||
|
|
||||||
export interface ClientAccountProfile {
|
export interface ClientAccountProfile {
|
||||||
username: string
|
username: string
|
||||||
@@ -103,8 +103,8 @@ export const useAuthStore = defineStore('client-auth', () => {
|
|||||||
if (token.value) {
|
if (token.value) {
|
||||||
try {
|
try {
|
||||||
applyToken(token.value)
|
applyToken(token.value)
|
||||||
} catch {
|
} catch (error) {
|
||||||
clearSession()
|
if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +133,20 @@ export const useAuthStore = defineStore('client-auth', () => {
|
|||||||
async function restoreSession() {
|
async function restoreSession() {
|
||||||
if (sessionRestored.value) return
|
if (sessionRestored.value) return
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
sessionRestored.value = true
|
if (!getRefreshToken()) {
|
||||||
return
|
sessionRestored.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
applyToken(await refreshAuthenticatedSession())
|
||||||
|
} catch (error) {
|
||||||
|
if (isExpiredSessionError(error)) {
|
||||||
|
clearSession()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
sessionRestored.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadSessionContext()
|
await loadSessionContext()
|
||||||
@@ -152,7 +164,8 @@ export const useAuthStore = defineStore('client-auth', () => {
|
|||||||
async function login(loginId: string, password: string, remember = false) {
|
async function login(loginId: string, password: string, remember = false) {
|
||||||
const tokens = await loginMerchant(loginId, password)
|
const tokens = await loginMerchant(loginId, password)
|
||||||
const decoded = decodeMerchantToken(tokens.accessToken)
|
const decoded = decodeMerchantToken(tokens.accessToken)
|
||||||
persistTokenPair(tokens, remember)
|
persistTokenPair(tokens)
|
||||||
|
persistRememberedLoginId(loginId, remember)
|
||||||
token.value = tokens.accessToken
|
token.value = tokens.accessToken
|
||||||
payload.value = decoded
|
payload.value = decoded
|
||||||
user.value = currentUser(decoded)
|
user.value = currentUser(decoded)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { decodeMerchantToken } from './jwt.ts'
|
import { decodeMerchantToken, MerchantTokenExpiredError } from './jwt.ts'
|
||||||
|
|
||||||
function tokenFor(payload) {
|
function tokenFor(payload) {
|
||||||
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
@@ -29,5 +29,5 @@ test('rejects malformed tokens', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('rejects expired tokens', () => {
|
test('rejects expired tokens', () => {
|
||||||
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/)
|
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export interface MerchantJwtPayload {
|
|||||||
exp: number
|
exp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MerchantTokenExpiredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('登录状态已过期')
|
||||||
|
this.name = 'MerchantTokenExpiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function decodeBase64Url(value: string) {
|
function decodeBase64Url(value: string) {
|
||||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||||
@@ -27,10 +34,11 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload {
|
|||||||
const payload = value as Partial<MerchantJwtPayload>
|
const payload = value as Partial<MerchantJwtPayload>
|
||||||
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
|
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
|
||||||
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
|
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
|
||||||
if (payload.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
|
if (payload.exp * 1000 <= Date.now()) throw new MerchantTokenExpiredError()
|
||||||
return payload as MerchantJwtPayload
|
return payload as MerchantJwtPayload
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && ['账号不属于当前应用端', '登录状态已过期'].includes(error.message)) throw error
|
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
|
||||||
|
if (error instanceof MerchantTokenExpiredError) throw error
|
||||||
throw new Error('登录凭证无法解析,请重新登录')
|
throw new Error('登录凭证无法解析,请重新登录')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,14 @@ test('matches the frozen username-or-mobile login contract', () => {
|
|||||||
assert.match(loginSource, /忘记密码/)
|
assert.match(loginSource, /忘记密码/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('persists both rotating tokens according to the remember choice', () => {
|
test('persists rotating tokens independently and only remembers the login id by choice', () => {
|
||||||
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
|
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
|
||||||
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
|
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
|
||||||
assert.match(sessionSource, /remember \? localStorage : sessionStorage/)
|
assert.match(sessionSource, /localStorage\.setItem\(ACCESS_TOKEN_KEY, tokens\.accessToken\)/)
|
||||||
|
assert.match(sessionSource, /localStorage\.setItem\(REFRESH_TOKEN_KEY, tokens\.refreshToken\)/)
|
||||||
|
assert.match(sessionSource, /persistRememberedLoginId/)
|
||||||
|
assert.match(loginSource, /getRememberedLoginId/)
|
||||||
|
assert.match(loginSource, /记住账号/)
|
||||||
assert.match(sessionSource, /clearSessionTokens/)
|
assert.match(sessionSource, /clearSessionTokens/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
|
|||||||
assert.match(httpSource, /refreshPromise/)
|
assert.match(httpSource, /refreshPromise/)
|
||||||
assert.match(httpSource, /\/auth\/refresh/)
|
assert.match(httpSource, /\/auth\/refresh/)
|
||||||
assert.match(httpSource, /persistTokenPair\(tokens\)/)
|
assert.match(httpSource, /persistTokenPair\(tokens\)/)
|
||||||
|
assert.match(httpSource, /token && !isPublicAuthRequest\(config\.url\)/)
|
||||||
assert.match(httpSource, /request\._retriedAfterRefresh = true/)
|
assert.match(httpSource, /request\._retriedAfterRefresh = true/)
|
||||||
assert.match(httpSource, /return await transport\.request\(request\)/)
|
assert.match(httpSource, /return await transport\.request\(request\)/)
|
||||||
assert.match(httpSource, /envelope\.code !== '200'/)
|
assert.match(httpSource, /envelope\.code !== '200'/)
|
||||||
@@ -102,6 +107,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
|
|||||||
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/)
|
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/)
|
||||||
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
|
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
|
||||||
assert.match(authStoreSource, /decodeMerchantToken/)
|
assert.match(authStoreSource, /decodeMerchantToken/)
|
||||||
|
assert.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/)
|
||||||
|
assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/)
|
||||||
assert.doesNotMatch(authStoreSource, /mock-client:/)
|
assert.doesNotMatch(authStoreSource, /mock-client:/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|||||||
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
|
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
|
||||||
import { useAuthStore } from '@/modules/auth/store/auth'
|
import { useAuthStore } from '@/modules/auth/store/auth'
|
||||||
import AuthShell from '@/modules/auth/components/AuthShell.vue'
|
import AuthShell from '@/modules/auth/components/AuthShell.vue'
|
||||||
|
import { getRememberedLoginId } from '@/api/session'
|
||||||
|
|
||||||
interface LoginForm {
|
interface LoginForm {
|
||||||
account: string
|
account: string
|
||||||
@@ -19,12 +20,13 @@ const formRef = ref<FormInstance>()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const impersonationFailed = route.query.impersonation === 'failed'
|
const impersonationFailed = route.query.impersonation === 'failed'
|
||||||
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
|
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
|
||||||
|
const rememberedLoginId = getRememberedLoginId()
|
||||||
const form = reactive<LoginForm>({
|
const form = reactive<LoginForm>({
|
||||||
account: typeof route.query.account === 'string'
|
account: typeof route.query.account === 'string'
|
||||||
? route.query.account
|
? route.query.account
|
||||||
: '',
|
: rememberedLoginId,
|
||||||
password: '',
|
password: '',
|
||||||
remember: false,
|
remember: Boolean(rememberedLoginId),
|
||||||
})
|
})
|
||||||
const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
|
const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
|
||||||
const mainlandMobilePattern = /^1[3-9]\d{9}$/
|
const mainlandMobilePattern = /^1[3-9]\d{9}$/
|
||||||
@@ -113,7 +115,7 @@ function showResetHint() {
|
|||||||
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
|
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="auth-form-options">
|
<div class="auth-form-options">
|
||||||
<el-checkbox v-model="form.remember">记住我</el-checkbox>
|
<el-checkbox v-model="form.remember">记住账号</el-checkbox>
|
||||||
<button type="button" @click="showResetHint">忘记密码?</button>
|
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||||
</div>
|
</div>
|
||||||
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'ut
|
|||||||
const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8')
|
const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('queries the merchant order inbound-billing endpoint without tenant parameters', () => {
|
test('queries the merchant order inbound-billing endpoint without tenant parameters', () => {
|
||||||
assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
|
const readSource = source.slice(source.indexOf('export async function getMerchantInboundBilling'), source.indexOf('function createFeeConfirmationKey'))
|
||||||
assert.match(source, /method: 'GET'/)
|
assert.match(readSource, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
|
||||||
assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
|
assert.match(readSource, /method: 'GET'/)
|
||||||
|
assert.doesNotMatch(readSource, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirms the latest customer fee version with an idempotency key', () => {
|
||||||
|
assert.match(source, /function confirmMerchantInboundFee/)
|
||||||
|
assert.match(source, /\/inbound-billing\/confirm/)
|
||||||
|
assert.match(source, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
|
||||||
|
assert.match(source, /data: \{ expectedFeeVersion \}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sanitizes the platform-shaped response into a merchant whitelist', () => {
|
test('sanitizes the platform-shaped response into a merchant whitelist', () => {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
|||||||
feeStatus: fee.feeStatus,
|
feeStatus: fee.feeStatus,
|
||||||
inboundId: fee.inboundId,
|
inboundId: fee.inboundId,
|
||||||
inboundCode: fee.inboundCode,
|
inboundCode: fee.inboundCode,
|
||||||
|
measurementId: fee.measurementId == null ? null : String(fee.measurementId),
|
||||||
|
measurementSource: fee.measurementSource,
|
||||||
chargeRuleId: fee.chargeRuleId,
|
chargeRuleId: fee.chargeRuleId,
|
||||||
ruleCode: fee.ruleCode,
|
ruleCode: fee.ruleCode,
|
||||||
chargeRuleVersion: fee.chargeRuleVersion,
|
chargeRuleVersion: fee.chargeRuleVersion,
|
||||||
@@ -101,6 +103,10 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
|||||||
confirmedAt: fee.confirmedAt,
|
confirmedAt: fee.confirmedAt,
|
||||||
sentAt: fee.sentAt,
|
sentAt: fee.sentAt,
|
||||||
nextProcessStatus: fee.nextProcessStatus,
|
nextProcessStatus: fee.nextProcessStatus,
|
||||||
|
customerConfirmationStatus: fee.customerConfirmationStatus,
|
||||||
|
customerConfirmedBy: fee.customerConfirmedBy == null ? null : String(fee.customerConfirmedBy),
|
||||||
|
customerConfirmedByName: fee.customerConfirmedByName,
|
||||||
|
customerConfirmedAt: fee.customerConfirmedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +121,7 @@ function sanitizeMeasurementSummary(item: MerchantInboundBilling['measurementSum
|
|||||||
actualVolumeCbm: item.actualVolumeCbm,
|
actualVolumeCbm: item.actualVolumeCbm,
|
||||||
volumeWeightKg: item.volumeWeightKg,
|
volumeWeightKg: item.volumeWeightKg,
|
||||||
actualGoodsName: item.actualGoodsName,
|
actualGoodsName: item.actualGoodsName,
|
||||||
|
calculationSource: item.calculationSource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +137,9 @@ function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems'
|
|||||||
unitVolumeCbm: item.unitVolumeCbm,
|
unitVolumeCbm: item.unitVolumeCbm,
|
||||||
totalVolumeCbm: item.totalVolumeCbm,
|
totalVolumeCbm: item.totalVolumeCbm,
|
||||||
volumeWeightKg: item.volumeWeightKg,
|
volumeWeightKg: item.volumeWeightKg,
|
||||||
|
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
|
||||||
|
coefficientMode: item.coefficientMode,
|
||||||
|
calculationRuleVersion: item.calculationRuleVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,3 +164,19 @@ export async function getMerchantInboundBilling(orderId: string): Promise<Mercha
|
|||||||
})
|
})
|
||||||
return sanitizeInboundBilling(response)
|
return sanitizeInboundBilling(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFeeConfirmationKey(orderId: string, feeVersion: number) {
|
||||||
|
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
return `inbound-fee-confirm-${orderId}-${feeVersion}-${randomPart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmMerchantInboundFee(orderId: string, expectedFeeVersion: number,
|
||||||
|
idempotencyKey = createFeeConfirmationKey(orderId, expectedFeeVersion)) {
|
||||||
|
const response = await apiRequest<MerchantInboundFeeSnapshot>({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing/confirm`,
|
||||||
|
headers: { 'Idempotency-Key': idempotencyKey },
|
||||||
|
data: { expectedFeeVersion },
|
||||||
|
})
|
||||||
|
return sanitizeFee(response)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ test('renders loading, pending, isolated error, and published states', () => {
|
|||||||
assert.match(source, /role="alert"/)
|
assert.match(source, /role="alert"/)
|
||||||
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
|
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
|
||||||
assert.match(source, /role="status"/)
|
assert.match(source, /role="status"/)
|
||||||
assert.match(source, /待分柜 · 费用已发布/)
|
assert.match(source, /费用已发布 · 待确认/)
|
||||||
assert.match(source, /\$emit\('retry'\)/)
|
assert.match(source, /\$emit\('retry'\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows the confirmed merchant inbound, measurement details and customer confirmation entry', () => {
|
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
|
||||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||||
assert.match(source, new RegExp(label))
|
assert.match(source, new RegExp(label))
|
||||||
}
|
}
|
||||||
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
||||||
assert.match(source, /formatConversionPrecision\(/)
|
assert.match(source, /formatConversionPrecision\(/)
|
||||||
assert.match(source, /确认费用/)
|
assert.match(source, /确认费用/)
|
||||||
|
assert.match(source, /二次确认费用/)
|
||||||
|
assert.match(source, /确认后该费用将锁定,平台不能再修改/)
|
||||||
|
assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/)
|
||||||
|
assert.match(source, /confirmMerchantInboundFee/)
|
||||||
assert.match(source, /measurementItems/)
|
assert.match(source, /measurementItems/)
|
||||||
assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
|
assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
|
||||||
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
|
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||||
|
import { confirmMerchantInboundFee } from '@/modules/first-leg/api/inboundBilling'
|
||||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||||
import {
|
import {
|
||||||
formatConversionPrecision,
|
formatConversionPrecision,
|
||||||
@@ -19,6 +21,7 @@ const props = withDefaults(defineProps<{
|
|||||||
unavailableMessage?: string
|
unavailableMessage?: string
|
||||||
error?: string
|
error?: string
|
||||||
autoOpen?: boolean
|
autoOpen?: boolean
|
||||||
|
canConfirm?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
data: null,
|
data: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -26,16 +29,49 @@ const props = withDefaults(defineProps<{
|
|||||||
unavailableMessage: '还未测量推送',
|
unavailableMessage: '还未测量推送',
|
||||||
error: '',
|
error: '',
|
||||||
autoOpen: false,
|
autoOpen: false,
|
||||||
|
canConfirm: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
retry: []
|
retry: []
|
||||||
|
confirmed: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const feeDialogOpen = ref(false)
|
const feeDialogOpen = ref(false)
|
||||||
|
const confirming = ref(false)
|
||||||
watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
||||||
if (autoOpen && data) feeDialogOpen.value = true
|
if (autoOpen && data) feeDialogOpen.value = true
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function confirmFee() {
|
||||||
|
const fee = props.data?.latestFee
|
||||||
|
const orderId = props.data?.inbound.summary.orderId
|
||||||
|
if (!fee || !orderId || !props.canConfirm || confirming.value) return
|
||||||
|
if (fee.customerConfirmationStatus === 'CONFIRMED') {
|
||||||
|
ElMessage.info('当前费用已经确认,无需重复操作')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`请再次确认费用单 ${fee.feeNo},合计 ${formatInboundMoney(fee.totalAmount, fee.currency)}。确认后该费用将锁定,平台不能再修改。`,
|
||||||
|
'二次确认费用',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认费用无误', cancelButtonText: '返回核对' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirming.value = true
|
||||||
|
try {
|
||||||
|
await confirmMerchantInboundFee(orderId, fee.feeVersion)
|
||||||
|
ElMessage.success('费用确认成功,当前费用已锁定')
|
||||||
|
feeDialogOpen.value = false
|
||||||
|
emit('confirmed')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '费用确认失败,请刷新后重试')
|
||||||
|
} finally {
|
||||||
|
confirming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,8 +79,11 @@ watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
|||||||
<header class="inbound-billing-heading">
|
<header class="inbound-billing-heading">
|
||||||
<h3 id="inbound-billing-title">入库与费用</h3>
|
<h3 id="inbound-billing-title">入库与费用</h3>
|
||||||
<div v-if="props.data" class="inbound-billing-actions">
|
<div v-if="props.data" class="inbound-billing-actions">
|
||||||
<el-tag type="success" effect="light">待分柜 · 费用已发布</el-tag>
|
<el-tag :type="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? 'success' : 'warning'" effect="light">
|
||||||
<el-button type="primary" @click="feeDialogOpen = true">确认费用</el-button>
|
{{ props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? '客户已确认 · 费用已锁定' : '费用已发布 · 待确认' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button v-if="props.data.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" @click="feeDialogOpen = true">确认费用</el-button>
|
||||||
|
<el-button v-else plain @click="feeDialogOpen = true">查看费用</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -163,10 +202,10 @@ watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
|||||||
|
|
||||||
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body>
|
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body>
|
||||||
<dl v-if="props.data" class="inbound-measurements fee-dialog-summary">
|
<dl v-if="props.data" class="inbound-measurements fee-dialog-summary">
|
||||||
<div><dt>测量总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
|
<div><dt>计费总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
|
||||||
<div><dt>测量总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
|
<div><dt>计费总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
|
||||||
<div><dt>测量总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
|
<div><dt>外箱规总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
|
||||||
<div><dt>测量总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
|
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
|
||||||
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
|
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
|
||||||
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -179,9 +218,22 @@ watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
|||||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||||
|
<el-table-column prop="calculationRuleVersion" label="箱规规则" min-width="180" />
|
||||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<template #footer><el-button type="primary" @click="feeDialogOpen = false">确认</el-button></template>
|
<el-alert
|
||||||
|
v-if="props.data?.latestFee.customerConfirmationStatus === 'CONFIRMED'"
|
||||||
|
class="fee-confirmed-alert"
|
||||||
|
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-alert v-else class="fee-confirmed-alert" title="请核对全部计费数据。点击确认后还会进行一次确认,完成后费用将锁定。" type="warning" :closable="false" show-icon />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="feeDialogOpen = false">关闭</el-button>
|
||||||
|
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认费用</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -214,6 +266,7 @@ watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
|||||||
.inbound-measurements > div { min-width: 0; padding: 12px; }
|
.inbound-measurements > div { min-width: 0; padding: 12px; }
|
||||||
.measurement-detail-table { margin-top: 12px; }
|
.measurement-detail-table { margin-top: 12px; }
|
||||||
.fee-dialog-summary { margin-top: 0; }
|
.fee-dialog-summary { margin-top: 0; }
|
||||||
|
.fee-confirmed-alert { margin-top: 14px; }
|
||||||
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
|
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
|
||||||
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||||
.pallet-location-list article > svg { color: var(--primary); }
|
.pallet-location-list article > svg { color: var(--primary); }
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface MerchantInboundFeeSnapshot {
|
|||||||
feeStatus: MerchantInboundFeeStatus
|
feeStatus: MerchantInboundFeeStatus
|
||||||
inboundId: string
|
inboundId: string
|
||||||
inboundCode: string
|
inboundCode: string
|
||||||
|
measurementId?: string | null
|
||||||
|
measurementSource?: string | null
|
||||||
chargeRuleId: string
|
chargeRuleId: string
|
||||||
ruleCode: string
|
ruleCode: string
|
||||||
chargeRuleVersion: number
|
chargeRuleVersion: number
|
||||||
@@ -98,6 +100,10 @@ export interface MerchantInboundFeeSnapshot {
|
|||||||
confirmedAt: string | null
|
confirmedAt: string | null
|
||||||
sentAt: string | null
|
sentAt: string | null
|
||||||
nextProcessStatus: MerchantInboundProcessStatus | null
|
nextProcessStatus: MerchantInboundProcessStatus | null
|
||||||
|
customerConfirmationStatus: 'PENDING_CONFIRMATION' | 'CONFIRMED'
|
||||||
|
customerConfirmedBy: string | null
|
||||||
|
customerConfirmedByName: string | null
|
||||||
|
customerConfirmedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MerchantMeasurementItem {
|
export interface MerchantMeasurementItem {
|
||||||
@@ -111,6 +117,9 @@ export interface MerchantMeasurementItem {
|
|||||||
unitVolumeCbm: number | null
|
unitVolumeCbm: number | null
|
||||||
totalVolumeCbm: number | null
|
totalVolumeCbm: number | null
|
||||||
volumeWeightKg: number | null
|
volumeWeightKg: number | null
|
||||||
|
chargeRuleId: string | null
|
||||||
|
coefficientMode: 'ADD' | 'MULTIPLY' | null
|
||||||
|
calculationRuleVersion: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MerchantBillingBasis {
|
export interface MerchantBillingBasis {
|
||||||
@@ -137,6 +146,7 @@ export interface MerchantInboundBilling {
|
|||||||
actualVolumeCbm: number | null
|
actualVolumeCbm: number | null
|
||||||
volumeWeightKg: number | null
|
volumeWeightKg: number | null
|
||||||
actualGoodsName: string | null
|
actualGoodsName: string | null
|
||||||
|
calculationSource: 'OUTER' | string | null
|
||||||
}
|
}
|
||||||
measurementItems: MerchantMeasurementItem[]
|
measurementItems: MerchantMeasurementItem[]
|
||||||
billingBasis: MerchantBillingBasis
|
billingBasis: MerchantBillingBasis
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const companyAuditLoaded = ref(false)
|
|||||||
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
|
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
|
||||||
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||||
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||||
|
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||||
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
|
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
|
||||||
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
|
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
|
||||||
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
|
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
|
||||||
@@ -2155,7 +2156,9 @@ async function searchOrders(model: QueryModel) {
|
|||||||
:unavailable="inboundBillingUnavailable"
|
:unavailable="inboundBillingUnavailable"
|
||||||
:unavailable-message="inboundBillingUnavailableMessage"
|
:unavailable-message="inboundBillingUnavailableMessage"
|
||||||
:error="inboundBillingError"
|
:error="inboundBillingError"
|
||||||
|
:can-confirm="canConfirmInboundFee"
|
||||||
@retry="retryInboundBilling"
|
@retry="retryInboundBilling"
|
||||||
|
@confirmed="retryInboundBilling"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user