diff --git a/AGENTS.md b/AGENTS.md
index aba261cf..57371fb7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,6 +44,7 @@
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
+| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
diff --git a/output/appointment-calendar/final-month.png b/output/appointment-calendar/final-month.png
new file mode 100644
index 00000000..75a0c94e
Binary files /dev/null and b/output/appointment-calendar/final-month.png differ
diff --git a/output/appointment-calendar/month-five-customers.png b/output/appointment-calendar/month-five-customers.png
new file mode 100644
index 00000000..7fe86be1
Binary files /dev/null and b/output/appointment-calendar/month-five-customers.png differ
diff --git a/output/appointment-calendar/verification.json b/output/appointment-calendar/verification.json
new file mode 100644
index 00000000..d07ba16f
--- /dev/null
+++ b/output/appointment-calendar/verification.json
@@ -0,0 +1,78 @@
+{
+ "date": "2026-10-09",
+ "testOnly": true,
+ "deployment": {
+ "admin": {
+ "build": 531,
+ "result": "SUCCESS",
+ "commit": "2caeb12"
+ },
+ "firstleg": {
+ "build": 488,
+ "result": "SUCCESS",
+ "commit": "e94271706ce7"
+ },
+ "lastleg": {
+ "build": 234,
+ "result": "SUCCESS",
+ "commit": "7fd7699f6e83"
+ }
+ },
+ "databaseReset": {
+ "tables": [
+ "ll_inbound_fba_appointment",
+ "ll_inbound_fba_appointment_item"
+ ],
+ "beforeCounts": [
+ 49,
+ 59
+ ],
+ "afterResetCounts": [
+ 0,
+ 0
+ ]
+ },
+ "httpPassed": [
+ "five_customer_one_appointment",
+ "same_command_replay",
+ "duplicate_cargo_conflict",
+ "update_version",
+ "stale_update_conflict",
+ "stale_delete_conflict",
+ "delete_and_calendar_absence",
+ "rebook_after_delete"
+ ],
+ "uiPassed": [
+ "1920x1080_month",
+ "five_customer_detail",
+ "edit_saved",
+ "week_view",
+ "candidate_existing_appointment_disabled",
+ "two_customer_selection",
+ "cross_page_selection_retained",
+ "create_saved",
+ "final_month_top_alignment"
+ ],
+ "initialCalendarRequests": [
+ {
+ "type": "appointments",
+ "count": 1,
+ "durationMs": 59
+ },
+ {
+ "type": "arrivals",
+ "count": 1,
+ "durationMs": 106
+ }
+ ],
+ "pending": [
+ "merchant_identity_isolation: configured login returned HTTP401 A0401",
+ "week_alignment_final_browser_recheck",
+ "browser_delete_confirmation_flow"
+ ],
+ "screenshots": [
+ "month-five-customers.png",
+ "week-five-customers.png",
+ "final-month.png"
+ ]
+}
\ No newline at end of file
diff --git a/output/appointment-calendar/week-five-customers.png b/output/appointment-calendar/week-five-customers.png
new file mode 100644
index 00000000..7a5f0f88
Binary files /dev/null and b/output/appointment-calendar/week-five-customers.png differ
diff --git a/src/app/router/index.ts b/src/app/router/index.ts
index de5e877a..9fe2ee6e 100644
--- a/src/app/router/index.ts
+++ b/src/app/router/index.ts
@@ -33,6 +33,7 @@ import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
+import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
const router = createRouter({
history: createWebHistory(),
@@ -79,6 +80,7 @@ const router = createRouter({
...iamRoutes,
...pdaReleaseRoutes,
...sailingCalendarRoutes,
+ ...appointmentCalendarRoutes,
{
path: '403',
name: 'forbidden',
diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts
index 878b744b..b1dbf3b6 100644
--- a/src/core/navigation/adminNavigation.test.ts
+++ b/src/core/navigation/adminNavigation.test.ts
@@ -22,6 +22,7 @@ describe('adminNavigation', () => {
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
+ { title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] },
@@ -29,8 +30,8 @@ describe('adminNavigation', () => {
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
- expect(visible).toHaveLength(11)
- expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51)
+ expect(visible).toHaveLength(12)
+ expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index 93a6a991..4fd3b351 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -118,6 +118,14 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
+ {
+ key: 'appointment-management',
+ title: '预约管理',
+ icon: CalendarDays,
+ children: [
+ { key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment:list' },
+ ],
+ },
{
key: 'vehicle-management',
title: '车辆管理',
diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts
new file mode 100644
index 00000000..141ca57d
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts
@@ -0,0 +1,53 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
+
+describe('appointment calendar interactions', () => {
+ const render = () => mount(AppointmentCalendarPanel, {
+ props: { initialDate: '2026-09-24', canCreate: true, events: [
+ { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
+ ] },
+ global: { stubs: { ElButton: { template: '' } }, directives: { loading: {} } },
+ })
+
+ it('selects an already loaded date without requesting data again', async () => {
+ const wrapper = render()
+ await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
+ expect(wrapper.emitted('range')).toBeUndefined()
+ expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约')
+ })
+
+ it('requests exactly one range for a view switch and navigates seven days', async () => {
+ const wrapper = render()
+ await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
+ expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
+ await wrapper.get('[aria-label="下一周"]').trigger('click')
+ expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
+ })
+
+ it('keeps busy days to three previews in both views and exposes the full count', async () => {
+ const wrapper = render()
+ await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
+ key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
+ title: `船舶 ${index}`, fields: [],
+ })) })
+ for (const view of ['month', 'week']) {
+ if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
+ const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
+ expect(day.findAll('.event-chip')).toHaveLength(3)
+ expect(day.attributes('title')).toContain('共 233 条安排')
+ expect(day.get('.event-more').text()).toBe('还有 230 条')
+ expect(wrapper.findAll('.detail-event')).toHaveLength(233)
+ }
+ })
+
+ it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
+ const wrapper = render()
+ await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
+ expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
+ await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
+ await wrapper.findAll('button').find(button => button.text() === '删除')!.trigger('click')
+ expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
+ expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
+ })
+})
diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue
new file mode 100644
index 00000000..ad532175
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
{{ day }}
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
new file mode 100644
index 00000000..60a884b4
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
@@ -0,0 +1,69 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AppointmentCalendarView from './AppointmentCalendarView.vue'
+import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
+import { listArrivalCalendar } from './api'
+
+vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() }))
+
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('@/modules/overseas-warehouse', () => ({
+ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
+ listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
+ overseasWarehousePermissions: { inboundOrderAppointment: 'write' },
+}))
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
+
+const candidates = [
+ { lineId: '9007199254740993', customerId: 'A', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
+ { lineId: '9007199254740994', customerId: 'B', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
+]
+function render() {
+ return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
+ AdminPage: { template: '' },
+ AppointmentCalendarPanel: { template: '' },
+ AppDialog: { props: ['modelValue'], template: '
' },
+ ElForm: { template: '' },
+ ElFormItem: { template: '' },
+ ElInput: { props: ['modelValue', 'placeholder'], template: '' },
+ ElDatePicker: { props: ['modelValue'], template: '' },
+ ElButton: { template: '' },
+ ElSelect: true, ElOption: true,
+ DataTable: { props: ['data'], template: '
' },
+ LoadErrorState: true, ApiErrorMeta: true,
+ } } })
+}
+
+describe('appointment calendar data integration', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
+ vi.mocked(listArrivalCalendar).mockResolvedValue([])
+ vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
+ vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
+ })
+
+ it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
+ const wrapper = render()
+ await flushPromises()
+ expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
+ expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
+ expect(listInboundOrders).not.toHaveBeenCalled()
+ await wrapper.get('[data-create]').trigger('click')
+ await wrapper.get('input[placeholder="例如 ONT8"]').setValue('ONT8')
+ await wrapper.findAll('button').find(button => button.text() === '查询可预约货物')!.trigger('click')
+ await flushPromises()
+ await wrapper.get('[data-select]').trigger('click')
+ await wrapper.get('input[placeholder="请输入预约编号"]').setValue('APT-AB')
+ await wrapper.findAll('[data-date]')[2]!.setValue('2026-10-09T07:00:00')
+ await wrapper.get('[data-save]').trigger('click')
+ await flushPromises()
+ expect(createFbaAppointment).toHaveBeenCalledTimes(1)
+ expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
+ appointmentNo: 'APT-AB', lineIds: ['9007199254740993', '9007199254740994'],
+ }))
+ expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
+ expect(listInboundOrders).toHaveBeenCalledTimes(1)
+ expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue
new file mode 100644
index 00000000..15a1939a
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询可预约货物
+
+ 已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物
+
+
+
{{ editing.selectedCount }} 票货物 · 入仓码:{{ editing.items.map(item => item.inboundCode).join('、') || '—' }}
+
+
+
+
+
+
diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
new file mode 100644
index 00000000..c5095740
--- /dev/null
+++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
@@ -0,0 +1,19 @@
+# 预约管理交互矩阵
+
+原型:用户提供 `C:/Users/BY-OY/Desktop/新版.html` 的 `page-appointment-calendar`、`modal-appointment-edit`、`renderAppointmentCalendar`、`renderAppointmentDay`、`openAppointmentModal`、`deleteAppointment`,以及本对话三张截图。
+
+用户确认:同一目的仓允许多个客户货物统一预约、统一送仓,不能按客户拆预约。客户隔离保留在货物明细。
+
+| 页面/操作 | 原型承载与布局 | 实现约束 |
+|---|---|---|
+| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
+| 新增预约 | 居中双列表单,原型最大宽度760px | 保留居中双列表单;为跨客户货物选择扩至1080px,在同弹框下方分页选择真实入仓货物 |
+| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
+| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
+| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 |
+| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
+| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
+
+预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选只在用户点击查询时分页读取,跨页选择保留,重新查询清空。
+
+视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
diff --git a/src/modules/appointment-calendar/api.ts b/src/modules/appointment-calendar/api.ts
new file mode 100644
index 00000000..9affcc1f
--- /dev/null
+++ b/src/modules/appointment-calendar/api.ts
@@ -0,0 +1,28 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
+export interface ArrivalCalendarEntry {
+ containerBookingId: string
+ bookingNo: string
+ containerNo?: string | null
+ vesselName?: string | null
+ voyageNo?: string | null
+ destinationPortName?: string | null
+ destinationWarehouseCode?: string | null
+ arrivalAt: string
+ arrivalType: 'ACTUAL' | 'ESTIMATED'
+}
+
+export async function listArrivalCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
+ const response = await http.get, ApiEnvelope>(
+ '/platform/v1/port-schedules/arrival-calendar', { params: query, signal },
+ )
+ return unwrapApiResponse(response)
+}
+
+export async function deleteAppointment(appointmentNo: string, version: number) {
+ const response = await http.delete, ApiEnvelope>(
+ `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
+ { params: { version } },
+ )
+ return unwrapApiResponse(response)
+}
diff --git a/src/modules/appointment-calendar/calendar.test.ts b/src/modules/appointment-calendar/calendar.test.ts
new file mode 100644
index 00000000..bfd0c581
--- /dev/null
+++ b/src/modules/appointment-calendar/calendar.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
+
+describe('appointment calendar', () => {
+ it('shows six complete Monday-first weeks across year boundaries', () => {
+ const dates = calendarDates('2027-01-15', 'month')
+ expect(dates).toHaveLength(42)
+ expect(dates[0]).toBe('2026-12-28')
+ expect(dates.at(-1)).toBe('2027-02-07')
+ expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
+ })
+
+ it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
+ expect(moveCalendar('2026-09-24', 'week', 1)).toBe('2026-10-01')
+ expect(calendarDates('2026-09-27', 'week')).toEqual([
+ '2026-09-21', '2026-09-22', '2026-09-23', '2026-09-24',
+ '2026-09-25', '2026-09-26', '2026-09-27',
+ ])
+ })
+
+ it('clamps month-end selection and handles leap days', () => {
+ expect(moveCalendar('2028-01-31', 'month', 1)).toBe('2028-02-29')
+ expect(moveCalendar('2027-03-31', 'month', -1)).toBe('2027-02-28')
+ expect(addDays('2028-02-28', 1)).toBe('2028-02-29')
+ expect(() => addDays('2027-02-29', 1)).toThrow('无效的日历日期')
+ })
+})
diff --git a/src/modules/appointment-calendar/calendar.ts b/src/modules/appointment-calendar/calendar.ts
new file mode 100644
index 00000000..c457c48e
--- /dev/null
+++ b/src/modules/appointment-calendar/calendar.ts
@@ -0,0 +1,45 @@
+export type CalendarMode = 'month' | 'week'
+
+/** Calendar dates are warehouse-local dates, never UTC conversions. */
+export function dateKey(date: Date): string {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+}
+
+function parseDate(value: string): Date {
+ const date = new Date(`${value}T12:00:00`)
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(date.getTime()) || dateKey(date) !== value) {
+ throw new Error('无效的日历日期')
+ }
+ return date
+}
+
+export function addDays(value: string, days: number): string {
+ const date = parseDate(value)
+ date.setDate(date.getDate() + days)
+ return dateKey(date)
+}
+
+export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
+ const date = parseDate(anchor)
+ if (mode === 'month') date.setDate(1)
+ date.setDate(date.getDate() - (date.getDay() + 6) % 7)
+ const from = dateKey(date)
+ // A half-open interval also works directly with database timestamps.
+ return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
+}
+
+export function calendarDates(anchor: string, mode: CalendarMode): string[] {
+ const { from } = calendarRange(anchor, mode)
+ return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
+}
+
+export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
+ if (mode === 'week') return addDays(anchor, offset * 7)
+ const date = parseDate(anchor)
+ const day = date.getDate()
+ date.setDate(1)
+ date.setMonth(date.getMonth() + offset)
+ const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
+ date.setDate(Math.min(day, lastDay))
+ return dateKey(date)
+}
diff --git a/src/modules/appointment-calendar/routes.ts b/src/modules/appointment-calendar/routes.ts
new file mode 100644
index 00000000..b141884d
--- /dev/null
+++ b/src/modules/appointment-calendar/routes.ts
@@ -0,0 +1,8 @@
+import type { RouteRecordRaw } from 'vue-router'
+
+export const appointmentCalendarRoutes = [{
+ path: 'appointment-calendar',
+ name: 'appointment-calendar',
+ component: () => import('./AppointmentCalendarView.vue'),
+ meta: { title: '预约列表', showPageHeading: false, permission: 'platform:appointment:list' },
+}] satisfies RouteRecordRaw[]
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
index e6f4e900..6f2c83a2 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
@@ -116,7 +116,8 @@ describe('ContainerCustomsPanel', () => {
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
- expect(wrapper.text()).toContain('实际箱数 12')
+ expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
+ expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('报关行回执')
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 041c9db4..65ea7b45 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -291,7 +291,7 @@ onMounted(load)