From 72d77a7f70643e2c74856ac41c3d90d9157a7a31 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 3 Sep 2026 17:49:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../first-leg/api/inboundBilling.test.mjs | 14 +++- src/modules/first-leg/api/inboundBilling.ts | 26 +++++++ .../components/InboundBillingPanel.test.mjs | 8 ++- .../components/InboundBillingPanel.vue | 69 ++++++++++++++++--- src/modules/first-leg/types/inboundBilling.ts | 10 +++ .../first-leg/views/FirstLegOrdersView.vue | 3 + 6 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/modules/first-leg/api/inboundBilling.test.mjs b/src/modules/first-leg/api/inboundBilling.test.mjs index 2e8e05a..d0742c1 100644 --- a/src/modules/first-leg/api/inboundBilling.test.mjs +++ b/src/modules/first-leg/api/inboundBilling.test.mjs @@ -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') test('queries the merchant order inbound-billing endpoint without tenant parameters', () => { - assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/) - assert.match(source, /method: 'GET'/) - assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/) + const readSource = source.slice(source.indexOf('export async function getMerchantInboundBilling'), source.indexOf('function createFeeConfirmationKey')) + assert.match(readSource, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/) + 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', () => { diff --git a/src/modules/first-leg/api/inboundBilling.ts b/src/modules/first-leg/api/inboundBilling.ts index befba7b..59d9dc3 100644 --- a/src/modules/first-leg/api/inboundBilling.ts +++ b/src/modules/first-leg/api/inboundBilling.ts @@ -65,6 +65,8 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho feeStatus: fee.feeStatus, inboundId: fee.inboundId, inboundCode: fee.inboundCode, + measurementId: fee.measurementId == null ? null : String(fee.measurementId), + measurementSource: fee.measurementSource, chargeRuleId: fee.chargeRuleId, ruleCode: fee.ruleCode, chargeRuleVersion: fee.chargeRuleVersion, @@ -101,6 +103,10 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho confirmedAt: fee.confirmedAt, sentAt: fee.sentAt, 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, volumeWeightKg: item.volumeWeightKg, actualGoodsName: item.actualGoodsName, + calculationSource: item.calculationSource, } } @@ -130,6 +137,9 @@ function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems' unitVolumeCbm: item.unitVolumeCbm, totalVolumeCbm: item.totalVolumeCbm, 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({ + method: 'POST', + url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing/confirm`, + headers: { 'Idempotency-Key': idempotencyKey }, + data: { expectedFeeVersion }, + }) + return sanitizeFee(response) +} diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index 4669d84..197e707 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -10,17 +10,21 @@ test('renders loading, pending, isolated error, and published states', () => { assert.match(source, /role="alert"/) assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) assert.match(source, /role="status"/) - assert.match(source, /待分柜 · 费用已发布/) + assert.match(source, /费用已发布 · 待确认/) 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 ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) { assert.match(source, new RegExp(label)) } assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) assert.match(source, /formatConversionPrecision\(/) assert.match(source, /确认费用/) + assert.match(source, /二次确认费用/) + assert.match(source, /确认后该费用将锁定,平台不能再修改/) + assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/) + assert.match(source, /confirmMerchantInboundFee/) assert.match(source, /measurementItems/) assert.doesNotMatch(source, /调整费用|发送费用|下载照片/) assert.doesNotMatch(source, /internalNotes|statusHistory|files/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index a857566..0af69bd 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -1,6 +1,8 @@ @@ -214,6 +266,7 @@ watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => { .inbound-measurements > div { min-width: 0; padding: 12px; } .measurement-detail-table { margin-top: 12px; } .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 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); } diff --git a/src/modules/first-leg/types/inboundBilling.ts b/src/modules/first-leg/types/inboundBilling.ts index 9341bd7..bb34de7 100644 --- a/src/modules/first-leg/types/inboundBilling.ts +++ b/src/modules/first-leg/types/inboundBilling.ts @@ -62,6 +62,8 @@ export interface MerchantInboundFeeSnapshot { feeStatus: MerchantInboundFeeStatus inboundId: string inboundCode: string + measurementId?: string | null + measurementSource?: string | null chargeRuleId: string ruleCode: string chargeRuleVersion: number @@ -98,6 +100,10 @@ export interface MerchantInboundFeeSnapshot { confirmedAt: string | null sentAt: string | null nextProcessStatus: MerchantInboundProcessStatus | null + customerConfirmationStatus: 'PENDING_CONFIRMATION' | 'CONFIRMED' + customerConfirmedBy: string | null + customerConfirmedByName: string | null + customerConfirmedAt: string | null } export interface MerchantMeasurementItem { @@ -111,6 +117,9 @@ export interface MerchantMeasurementItem { unitVolumeCbm: number | null totalVolumeCbm: number | null volumeWeightKg: number | null + chargeRuleId: string | null + coefficientMode: 'ADD' | 'MULTIPLY' | null + calculationRuleVersion: string | null } export interface MerchantBillingBasis { @@ -137,6 +146,7 @@ export interface MerchantInboundBilling { actualVolumeCbm: number | null volumeWeightKg: number | null actualGoodsName: string | null + calculationSource: 'OUTER' | string | null } measurementItems: MerchantMeasurementItem[] billingBasis: MerchantBillingBasis diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 984f9aa..aa0f9db 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -72,6 +72,7 @@ const companyAuditLoaded = ref(false) const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE') const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create')) 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 availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType)) const filters = ref({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' }) @@ -2148,7 +2149,9 @@ async function searchOrders(model: QueryModel) { :unavailable="inboundBillingUnavailable" :unavailable-message="inboundBillingUnavailableMessage" :error="inboundBillingError" + :can-confirm="canConfirmInboundFee" @retry="retryInboundBilling" + @confirmed="retryInboundBilling" /> From 23f4a9aef960905c62fc65d2ce804a92cc9725a3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 18:05:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc442ba..78ada59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,10 +47,10 @@ | 企业资料、成员与角色 | `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/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/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/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | @@ -105,7 +105,7 @@ pnpm build 当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。 | 验收域 | Client 范围与代码入口 | 验收首读 | -|---|---|---|---| +|---|---|---| | 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 | | 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 | From b3649c314a4d0d110b73e4f9dde15fcf41b3f47b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 18:11:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(client):=20=E4=BF=AE=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BC=9A=E8=AF=9D=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/api/http.ts | 11 +++-- src/api/session.test.mjs | 58 +++++++++++++++++++++++ src/api/session.ts | 21 +++++--- src/modules/auth/store/auth.ts | 29 ++++++++---- src/modules/auth/utils/jwt.test.mjs | 4 +- src/modules/auth/utils/jwt.ts | 12 ++++- src/modules/auth/views/AuthViews.test.mjs | 11 ++++- src/modules/auth/views/LoginView.vue | 8 ++-- 9 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 src/api/session.test.mjs diff --git a/README.md b/README.md index b2a4b55..91bac4a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ pnpm test 认证会话保存 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}`,内置角色及仍有关联成员的角色继续受保护。 diff --git a/src/api/http.ts b/src/api/http.ts index ed8a391..0d619ee 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -46,6 +46,11 @@ function mapError(error: unknown) { let refreshPromise: Promise | null = null 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() { if (redirectingToLogin || typeof window === 'undefined') return @@ -98,7 +103,7 @@ export async function refreshAuthenticatedSession() { transport.interceptors.request.use((config) => { const token = getAccessToken() - if (token) config.headers.Authorization = `Bearer ${token}` + if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}` return config }) @@ -106,9 +111,7 @@ transport.interceptors.response.use( (response) => response, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined - const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] - const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path)) - if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) { + if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs new file mode 100644 index 0000000..ce48286 --- /dev/null +++ b/src/api/session.test.mjs @@ -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(), '') +}) diff --git a/src/api/session.ts b/src/api/session.ts index 9d933bb..2f2dd3b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -2,6 +2,7 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token' export const REFRESH_TOKEN_KEY = 'cc-client-refresh-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 REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id' export interface TokenPair { tokenType: string @@ -33,17 +34,25 @@ export function getOverseasAccessToken() { return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || '' } -export function persistTokenPair(tokens: TokenPair, remember?: boolean) { - const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage +export function persistTokenPair(tokens: TokenPair) { clearSessionTokens() - storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) - storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) { - storage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) - storage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000)) + localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) + 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) { clearSessionTokens() sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken) diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 76e63fb..9c1a903 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -2,7 +2,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import type { CurrentUser } from '@chongchong/shared' import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth' -import { ApiError } from '@/api/http' +import { ApiError, refreshAuthenticatedSession } from '@/api/http' import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, hasMappedMerchantPermission, @@ -11,8 +11,8 @@ import { getTenantProfile, updateMerchantPersonalProfile, } from '@/modules/tenant-management/public' -import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session' -import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt' +import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session' +import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt' export interface ClientAccountProfile { username: string @@ -103,8 +103,8 @@ export const useAuthStore = defineStore('client-auth', () => { if (token.value) { try { applyToken(token.value) - } catch { - clearSession() + } catch (error) { + if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession() } } @@ -133,8 +133,20 @@ export const useAuthStore = defineStore('client-auth', () => { async function restoreSession() { if (sessionRestored.value) return if (!isAuthenticated.value) { - sessionRestored.value = true - return + if (!getRefreshToken()) { + sessionRestored.value = true + return + } + try { + applyToken(await refreshAuthenticatedSession()) + } catch (error) { + if (isExpiredSessionError(error)) { + clearSession() + throw error + } + sessionRestored.value = true + return + } } try { await loadSessionContext() @@ -152,7 +164,8 @@ export const useAuthStore = defineStore('client-auth', () => { async function login(loginId: string, password: string, remember = false) { const tokens = await loginMerchant(loginId, password) const decoded = decodeMerchantToken(tokens.accessToken) - persistTokenPair(tokens, remember) + persistTokenPair(tokens) + persistRememberedLoginId(loginId, remember) token.value = tokens.accessToken payload.value = decoded user.value = currentUser(decoded) diff --git a/src/modules/auth/utils/jwt.test.mjs b/src/modules/auth/utils/jwt.test.mjs index b0a0bed..bf1d3f7 100644 --- a/src/modules/auth/utils/jwt.test.mjs +++ b/src/modules/auth/utils/jwt.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { decodeMerchantToken } from './jwt.ts' +import { decodeMerchantToken, MerchantTokenExpiredError } from './jwt.ts' function tokenFor(payload) { const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url') @@ -29,5 +29,5 @@ test('rejects malformed tokens', () => { }) test('rejects expired tokens', () => { - assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/) + assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError) }) diff --git a/src/modules/auth/utils/jwt.ts b/src/modules/auth/utils/jwt.ts index 73bdcda..4ec31df 100644 --- a/src/modules/auth/utils/jwt.ts +++ b/src/modules/auth/utils/jwt.ts @@ -9,6 +9,13 @@ export interface MerchantJwtPayload { exp: number } +export class MerchantTokenExpiredError extends Error { + constructor() { + super('登录状态已过期') + this.name = 'MerchantTokenExpiredError' + } +} + function decodeBase64Url(value: string) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') 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 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.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') + if (payload.exp * 1000 <= Date.now()) throw new MerchantTokenExpiredError() return payload as MerchantJwtPayload } 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('登录凭证无法解析,请重新登录') } } diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index e9d911c..44831e8 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -44,10 +44,14 @@ test('matches the frozen username-or-mobile login contract', () => { 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, /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/) }) @@ -93,6 +97,7 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /refreshPromise/) assert.match(httpSource, /\/auth\/refresh/) assert.match(httpSource, /persistTokenPair\(tokens\)/) + assert.match(httpSource, /token && !isPublicAuthRequest\(config\.url\)/) assert.match(httpSource, /request\._retriedAfterRefresh = true/) assert.match(httpSource, /return await transport\.request\(request\)/) 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, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(authStoreSource, /decodeMerchantToken/) + assert.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/) + assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/) assert.doesNotMatch(authStoreSource, /mock-client:/) }) diff --git a/src/modules/auth/views/LoginView.vue b/src/modules/auth/views/LoginView.vue index f10f820..6f48b3a 100644 --- a/src/modules/auth/views/LoginView.vue +++ b/src/modules/auth/views/LoginView.vue @@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue' import { useAuthStore } from '@/modules/auth/store/auth' import AuthShell from '@/modules/auth/components/AuthShell.vue' +import { getRememberedLoginId } from '@/api/session' interface LoginForm { account: string @@ -19,12 +20,13 @@ const formRef = ref() const loading = ref(false) const impersonationFailed = route.query.impersonation === 'failed' const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated' +const rememberedLoginId = getRememberedLoginId() const form = reactive({ account: typeof route.query.account === 'string' ? route.query.account - : '', + : rememberedLoginId, password: '', - remember: false, + remember: Boolean(rememberedLoginId), }) const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/ const mainlandMobilePattern = /^1[3-9]\d{9}$/ @@ -113,7 +115,7 @@ function showResetHint() {
- 记住我 + 记住账号
登录