fix: 回调配置, 调整信用位置, 套餐
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m55s

This commit is contained in:
luo
2026-07-29 18:20:42 +08:00
parent 4394ae0f78
commit 17d2eeebc5
39 changed files with 2687 additions and 1532 deletions

View File

@@ -20,10 +20,19 @@
- Decision: `docs/所需接口文档` 是本变更的接口契约索引;页面 API 类型、请求参数、枚举和响应字段必须以对应模块文档为准,不从七月说明中自行扩展路径。 - Decision: `docs/所需接口文档` 是本变更的接口契约索引;页面 API 类型、请求参数、枚举和响应字段必须以对应模块文档为准,不从七月说明中自行扩展路径。
- Decision: 文件类业务统一采用 `POST /api/admin/storage/upload-url` 获取预签名 URL直传成功后仅把 `file_key` 传给批量订购或设备分配接口。 - Decision: 文件类业务统一采用 `POST /api/admin/storage/upload-url` 获取预签名 URL直传成功后仅把 `file_key` 传给批量订购或设备分配接口。
- Decision: 批量订购、设备分配和导出均保存服务端返回的任务 ID页面恢复时查询原任务终态后停止轮询不能因刷新或超时重复创建。 - Decision: 批量订购、设备分配和导出均保存服务端返回的任务 ID页面恢复时查询原任务终态后停止轮询不能因刷新或超时重复创建。
- Decision: 代理钱包在线充值和平台线下代充共用 `POST /api/admin/agent-recharges`,但使用两套互斥的表单模型。在线表单只允许 `amount``payment_method``request_id`;线下表单才允许 `shop_id``payment_voucher_key``remark`,不能把线下表单对象整体复用到在线请求中。
- Decision: 在线充值弹窗打开时先请求 `/api/admin/agent-recharges/payment-methods`,支付方式和金额上下限完全由响应驱动。金额在界面显示为元,提交前转换为整数分;支付方式为空时禁止提交并显示无可用支付方式提示。
- Decision: 在线创建成功后只使用返回的 `recharge_id` 查询 `/api/admin/agent-recharges/{id}/payment-status`,把原始 `qr_content` 交给二维码组件。二维码弹窗可见且订单非终态时每 3 秒轮询;弹窗隐藏、页面进入后台或状态进入 3/4/5/6 后停止轮询。
- Decision: `request_id` 是一次主动在线充值的幂等键。网络超时或断开只用同一个 ID 重试一次并复用后端返回的原订单/二维码;用户关闭弹窗后重新发起充值或改变金额/支付方式时生成新的 ID冲突响应不得覆盖原表单或自动创建新订单。
- Decision: 充值列表和详情均以 `recharge_source` 做业务分支,并优先展示 `recharge_source_name`。在线单展示支付方式、第三方流水和支付/完成时间;线下单展示凭证、备注、提交人和审批状态。存在企微审批来源或实例时只读展示审批进度,不显示确认/驳回操作。
- Decision: 充值相关导出权限按场景拆分:业务列表入口使用 `agent_recharge:export`,导出任务详情使用 `export_task:agent_recharge_detail`,下载文件使用 `export_task:agent_recharge_download`。权限不足时隐藏入口或操作,不通过前端绕过权限。
## Risks / Trade-offs ## Risks / Trade-offs
- 接口 DTO 若未按说明返回,页面无法可靠展示审批人、历史资产标识或新旧字段;通过联调清单阻断实现前确认。 - 接口 DTO 若未按说明返回,页面无法可靠展示审批人、历史资产标识或新旧字段;通过联调清单阻断实现前确认。
- 支付配置为空或二维码内容为空时,在线充值无法继续;通过动态支付方式加载、空状态和联调环境中微信/支付宝二维码内容非空校验阻断上线。
- 在线创建请求若错误生成新的 `request_id`,可能产生重复充值单;通过提交态保存 ID、幂等重试和冲突保护降低重复支付风险。
- `status=2` 只代表第三方已支付,不代表钱包已经入账;通过以 `status=3` 作为余额刷新条件避免提前展示到账。
- 设备批量分配接口文档已明确任务列表和详情仅平台用户可操作,前端不得向代理账号展示任务入口或通过前端绕过权限。 - 设备批量分配接口文档已明确任务列表和详情仅平台用户可操作,前端不得向代理账号展示任务入口或通过前端绕过权限。
- H5/C 端不在本次实现范围,后台只能依赖后端提供的最终字段,跨端验收需要单独安排。 - H5/C 端不在本次实现范围,后台只能依赖后端提供的最终字段,跨端验收需要单独安排。
- 部分接口文档只给出字段模型,缺少业务错误码、权限编码和分页默认值;这些列入待确认清单。 - 部分接口文档只给出字段模型,缺少业务错误码、权限编码和分页默认值;这些列入待确认清单。
@@ -38,5 +47,5 @@
## Open Questions ## Open Questions
- 退款和代理充值详情是否需要直接返回企微审批节点人员列表? - 退款和代理充值详情是否需要直接返回企微审批节点人员列表?
- 各新增后台页面和按钮的最终权限编码是什么? - 代理在线充值创建和查询的最终权限编码是否与现有 `agent_recharge:*` 权限保持一致;导出权限编码已由接口文档确定。
- 批量任务、导出任务、企微审批和 Gateway 超时的稳定错误码及重试语义是什么? - 批量任务、导出任务、企微审批和 Gateway 超时的稳定错误码及重试语义是什么?

View File

@@ -7,9 +7,13 @@
## What Changes ## What Changes
-`00-七月迭代实现与接口对接说明.md` 更新后台管理端的接口调用和页面展示契约。 -`00-七月迭代实现与接口对接说明.md` 更新后台管理端的接口调用和页面展示契约。
-`产品迭代7月份/000-代理钱包扫码充值接口对接说明.md``所需接口文档/000-代理钱包扫码充值接口对接.md` 补齐代理钱包在线扫码充值、线下代充和充值导出权限的前端契约。
-`docs/所需接口文档/01-店铺.md``21-设备批量分配.md` 为接口参数、响应字段、枚举、权限和错误语义的具体来源。 -`docs/所需接口文档/01-店铺.md``21-设备批量分配.md` 为接口参数、响应字段、枚举、权限和错误语义的具体来源。
- 对资产、店铺、订单、换货、套餐、资金、系列授权、审批、通知、批量任务、导出和 Gateway 相关页面补齐前端适配要求。 - 对资产、店铺、订单、换货、套餐、资金、系列授权、审批、通知、批量任务、导出和 Gateway 相关页面补齐前端适配要求。
- 统一使用后端返回的实名策略、支付方式、预计到期、审批状态、金额计算结果和任务状态,不在前端重复推导。 - 统一使用后端返回的实名策略、支付方式、预计到期、审批状态、金额计算结果和任务状态,不在前端重复推导。
- 代理在线充值只提交金额、动态可用支付方式和幂等 `request_id`,用后端返回的原始 `qr_content` 渲染二维码,并按充值状态轮询到帐结果。
-`recharge_source` 区分代理在线自充与平台线下代充;线下单展示凭证和审批状态,在线单隐藏线下字段和人工审批操作。
- 补齐代理充值列表导出入口、导出任务详情和下载的权限编码。
- 补齐 CSV 上传模板、异步任务恢复、权限按钮、错误处理和并发冲突处理要求。 - 补齐 CSV 上传模板、异步任务恢复、权限按钮、错误处理和并发冲突处理要求。
- 明确预签名上传先取 `file_key`、再调用批量业务接口的两阶段流程,并固定批量订购、设备分配和导出任务的终态处理规则。 - 明确预签名上传先取 `file_key`、再调用批量业务接口的两阶段流程,并固定批量订购、设备分配和导出任务的终态处理规则。
- **不实施 H5/C 端页面改动**,但在规格和联调清单中标注其依赖的接口及外部工作项。 - **不实施 H5/C 端页面改动**,但在规格和联调清单中标注其依赖的接口及外部工作项。
@@ -18,6 +22,6 @@
## Impact ## Impact
- Affected specs: `july-iteration-frontend-integration` - Affected specs: `july-iteration-frontend-integration`
- Affected code: 后台资产、店铺、订单、换货、套餐、代理资金、系列授权、审批配置、通知、批量任务和导出页面 - Affected code: 后台资产、店铺、订单、换货、套餐、代理充值与资金、系列授权、审批配置、通知、批量任务和导出页面;重点涉及 `src/views/finance/agent-recharge``src/api/modules/agentRecharge.ts``src/types/api/agentRecharge.ts`
- External follow-up: H5/C 端认证限制、实名流程、支付按钮、C 端订单续费、C 端通知和 C 端资产展示 - External follow-up: H5/C 端认证限制、实名流程、支付按钮、C 端订单续费、C 端通知和 C 端资产展示
- Approval gate: 接口待确认项完成前不进入大范围实现 - Approval gate: 接口待确认项完成前不进入大范围实现

View File

@@ -191,3 +191,129 @@ The admin frontend SHALL enforce the documented permission boundary for device a
- **WHEN** an agent or enterprise account loads the device allocation area - **WHEN** an agent or enterprise account loads the device allocation area
- **THEN** the frontend hides the entry and does not call `/api/admin/devices/import/tasks` or `/api/admin/devices/import/tasks/{id}` - **THEN** the frontend hides the entry and does not call `/api/admin/devices/import/tasks` or `/api/admin/devices/import/tasks/{id}`
### Requirement: Agent Wallet Online Recharge Contract
The admin frontend SHALL support the documented agent wallet online QR recharge flow through `/api/admin/agent-recharges/payment-methods` and `/api/admin/agent-recharges`. The available payment methods and amount limits SHALL come from the payment-methods response; the online create request SHALL contain only `amount` in integer fen, `payment_method` from the returned methods, and a required `request_id`. Online agent recharge requests MUST NOT send `shop_id`, `payment_voucher_key`, `remark`, merchant credentials, or payment configuration identifiers.
#### Scenario: Load available online payment methods
- **WHEN** an agent opens the online recharge dialog
- **THEN** the frontend calls `GET /api/admin/agent-recharges/payment-methods`
- **AND** it displays only the returned `methods` values (`wechat` and/or `alipay`)
- **AND** it validates the integer-fen amount against the returned `min_amount` and `max_amount`
#### Scenario: No online payment method is available
- **WHEN** the payment-methods response returns an empty `methods` array
- **THEN** the frontend hides or disables the submit action
- **AND** it displays that no online payment method is currently available
- **AND** it does not call the online create endpoint
#### Scenario: Create an online QR recharge
- **WHEN** an authorized agent submits a valid online recharge
- **THEN** the frontend calls `POST /api/admin/agent-recharges` with `amount`, `payment_method`, and `request_id`
- **AND** it converts the displayed yuan amount to an integer amount in fen before submission
- **AND** it uses the returned `recharge_id` and does not derive the target shop or wallet from user-editable form fields
#### Scenario: Render the returned QR content
- **WHEN** online recharge creation succeeds with a non-empty `qr_content`
- **THEN** the frontend passes `qr_content` unchanged to the QR-code component
- **AND** it does not parse, concatenate, replace, upload, persist, or log the QR content
### Requirement: Idempotent Online Recharge Submission
The frontend SHALL preserve one `request_id` for the lifetime of one active online recharge submission. A timeout or network interruption MAY retry the same submission once with the same `request_id`, but a user-initiated new recharge MUST generate a new request ID. A conflict caused by reusing the same request ID with a different amount or payment method SHALL preserve the existing form state and require an explicit new recharge attempt.
#### Scenario: Retry after an uncertain create result
- **WHEN** the online create request times out or the network disconnects
- **THEN** the frontend retries at most once with the original `request_id`
- **AND** it does not create a new `request_id` or blindly open a second recharge dialog
#### Scenario: Start a new recharge after closing the dialog
- **WHEN** the user closes the QR dialog and clicks online recharge again
- **THEN** the frontend creates a new `request_id`
- **AND** the new amount and payment method are treated as a new active submission
#### Scenario: Reused request ID conflicts
- **WHEN** the backend rejects a request because the same `request_id` is paired with different recharge parameters
- **THEN** the frontend displays the backend business message
- **AND** it does not overwrite the original request or automatically submit another amount
### Requirement: Online Payment And Wallet Arrival Polling
The online QR recharge dialog SHALL poll only the documented local payment-status endpoint `GET /api/admin/agent-recharges/{id}/payment-status`. While the QR dialog is visible and the recharge is non-terminal, the frontend SHALL poll at approximately three-second intervals. The frontend SHALL stop polling when the dialog is hidden, the page is not visible, or the recharge status is `3` (completed), `4` (closed), `5` (refunded), or `6` (rejected). It MUST treat `status=2` as third-party payment completed but wallet arrival still processing; only `status=3` permits refreshing the wallet balance and recharge list.
#### Scenario: Payment is waiting for the user
- **WHEN** payment-status returns `status=1` and `payment_status=0`
- **THEN** the dialog displays that the user is waiting to scan and pay
- **AND** polling continues while the dialog is visible
#### Scenario: Payment is received but wallet arrival is processing
- **WHEN** payment-status returns `status=2` and `payment_status=1`
- **THEN** the dialog displays payment success and wallet arrival processing
- **AND** the frontend does not increase or refresh the wallet balance as completed
- **AND** polling continues
#### Scenario: Wallet arrival completes
- **WHEN** payment-status returns `status=3`
- **THEN** the frontend displays that the recharge has arrived
- **AND** it stops polling
- **AND** it refreshes the current wallet balance and recharge list
#### Scenario: Online recharge reaches a terminal failure state
- **WHEN** payment-status returns `status=4`, `status=5`, or `status=6`
- **THEN** the frontend displays the backend status name or an appropriate failure message
- **AND** it stops polling without automatically creating a replacement recharge
### Requirement: Recharge Source And Operation Boundaries
The recharge list and detail pages SHALL use `recharge_source` as the only business branch: `agent_online` for agent online self-recharge and `platform_offline` for platform offline代充. They SHALL prefer `recharge_source_name` for display and use the documented list filters `shop_id`, `status`, `recharge_source`, `start_date`, and `end_date`. Online records SHALL show payment information and payment/completion timestamps while hiding offline vouchers and manual approval operations. Offline records SHALL show vouchers, remarks, submitter, and approval status; they SHALL not show QR content or invoke the online payment-status endpoint.
#### Scenario: Filter online recharge records
- **WHEN** a user selects online recharge source or status filters
- **THEN** the frontend requests `GET /api/admin/agent-recharges` with the documented filter names and values
- **AND** it displays the backend recharge source name, amount, payment method, status, submitter, and timestamps
#### Scenario: Display a platform offline recharge
- **WHEN** a platform user opens a record with `recharge_source=platform_offline`
- **THEN** the detail view displays the payment voucher, remark, submitter, and approval status
- **AND** it does not render a QR code or start payment-status polling
#### Scenario: Confirm or reject an offline recharge
- **WHEN** a pending offline recharge has no WeCom approval provider or approval instance
- **THEN** an authorized platform user MAY confirm it through `POST /api/admin/agent-recharges/{id}/offline-pay` with `operation_password`
- **AND** an authorized platform user MAY reject it through `POST /api/admin/agent-recharges/{id}/reject` with a required `rejection_reason` of at most 500 characters
#### Scenario: WeCom approval controls the offline record
- **WHEN** an offline recharge has `approval_provider=wecom` or an `approval_instance_id`
- **THEN** the frontend displays approval progress as read-only
- **AND** it hides confirm and reject operations
### Requirement: Agent Recharge Export Permissions
The agent recharge list SHALL show its export entry only with `agent_recharge:export`. The export-task page SHALL protect the `agent_recharge` detail and download operations with `export_task:agent_recharge_detail` and `export_task:agent_recharge_download`, respectively. The frontend SHALL hide unauthorized controls and SHALL NOT invoke the corresponding API as a fallback.
#### Scenario: An authorized user exports agent recharges
- **WHEN** the current account has `agent_recharge:export`
- **THEN** the agent recharge page displays the export entry and opens the `agent_recharge` export flow
#### Scenario: An unauthorized user accesses export actions
- **WHEN** the current account lacks an export permission
- **THEN** the related entry, detail action, or download action is hidden
- **AND** the frontend does not call the export-task detail or download endpoint

View File

@@ -54,7 +54,17 @@
- [x] 6.3 验证代理数据隔离、平台权限、敏感字段脱敏和财务操作审计入口。 - [x] 6.3 验证代理数据隔离、平台权限、敏感字段脱敏和财务操作审计入口。
- [ ] 6.4 完成后台页面的接口联调、权限组合、空数据、错误、并发冲突和任务恢复验收。 - [ ] 6.4 完成后台页面的接口联调、权限组合、空数据、错误、并发冲突和任务恢复验收。
## 7. Explicitly Out Of Scope ## 7. Agent Wallet QR Recharge
- [ ] 7.1 Extend `AgentRecharge` types and `AgentRechargeService` for `alipay`, dynamic payment methods, amount bounds, `request_id`, `qr_content`, `payment_status`, and the documented payment-status endpoint while preserving nullable fields.
- [ ] 7.2 Add the agent online recharge dialog flow that loads `/api/admin/agent-recharges/payment-methods`, renders only returned payment methods, validates the returned fen bounds, and disables submission when no method is available.
- [ ] 7.3 Split online and offline create payloads; submit online recharge with only `amount`, `payment_method`, and a generated `request_id`, render the returned raw `qr_content`, and prevent leakage of shop, voucher, remark, or payment configuration fields.
- [ ] 7.4 Implement idempotent online-create retry and conflict handling: reuse the original `request_id` for one uncertain retry, generate a new ID for a new user action, and preserve the original form on request conflicts.
- [ ] 7.5 Implement visible-dialog payment polling with a three-second interval, stop on hidden/background or terminal status, distinguish `status=2` payment success from `status=3` wallet arrival, and refresh the wallet balance/list only after completion.
- [ ] 7.6 Update recharge list/detail branches to use `recharge_source`, document the online/offline field visibility rules, keep offline confirmation/rejection conditional on the absence of WeCom approval, and prevent online records from invoking offline operations.
- [ ] 7.7 Apply agent recharge export permissions: `agent_recharge:export`, `export_task:agent_recharge_detail`, and `export_task:agent_recharge_download`; add focused tests for role visibility, payload exclusion, idempotent retry, polling termination, and status transitions.
## 8. Explicitly Out Of Scope
- H5/C 端登录限制、实名流程、支付按钮、资产详情、订单续费和 C 端通知不在本次实现任务内。 - H5/C 端登录限制、实名流程、支付按钮、资产详情、订单续费和 C 端通知不在本次实现任务内。
- H5 首页隐藏设备下 ICCID 仍按最新说明标记为不做。 - H5 首页隐藏设备下 ICCID 仍按最新说明标记为不做。

View File

@@ -50,13 +50,13 @@ async function importBundledActions() {
} }
} }
test('pending agent recharge exposes reject action for admins with rejection permission', async () => { test('pending offline recharge exposes reject action for admins with rejection permission', async () => {
const { module, cleanup } = await importBundledActions() const { module, cleanup } = await importBundledActions()
const { buildAgentRechargeActions } = module const { buildAgentRechargeActions } = module
const row = { const row = {
id: 42, id: 42,
status: 1, status: 1,
payment_method: 'wechat', payment_method: 'offline',
payment_voucher_key: [] payment_voucher_key: []
} }
const handledRows = [] const handledRows = []
@@ -108,3 +108,27 @@ test('non-pending agent recharge does not expose reject action', async () => {
await cleanup() await cleanup()
} }
}) })
test('pending online recharge does not expose offline reject action', async () => {
const { module, cleanup } = await importBundledActions()
const { buildAgentRechargeActions } = module
const row = {
id: 42,
status: 1,
payment_method: 'wechat',
payment_voucher_key: []
}
try {
const actions = buildAgentRechargeActions(row, {
hasAuth: (permission) => permission === 'agent_recharge:reject',
onViewPaymentVoucher: () => {},
onConfirmPayment: () => {},
onReject: () => {}
})
assert.deepEqual(actions, [])
} finally {
await cleanup()
}
})

View File

@@ -0,0 +1,72 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
async function importBundledOnlineHelpers() {
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-online-'))
const bundlePath = join(tempDir, 'agentRechargeOnline.mjs')
await esbuild.build({
entryPoints: ['src/views/finance/agent-recharge/agentRechargeOnline.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd(),
plugins: [
{
name: 'alias-src',
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => {
const resolvedPath = join(process.cwd(), 'src', args.path.slice(2))
try {
if ((await stat(resolvedPath)).isDirectory())
return { path: join(resolvedPath, 'index.ts') }
} catch {
// Let esbuild report the original resolution failure.
}
return { path: resolvedPath }
})
}
}
]
})
const module = await import(pathToFileURL(bundlePath).href)
return {
module,
cleanup: () => rm(tempDir, { recursive: true, force: true })
}
}
test('online recharge helpers convert yuan to integer fen and identify terminal states', async () => {
const { module, cleanup } = await importBundledOnlineHelpers()
try {
assert.equal(module.amountYuanToFen(100.01), 10001)
assert.equal(module.isOnlineRechargeTerminal(1), false)
assert.equal(module.isOnlineRechargeTerminal(2), false)
assert.equal(module.isOnlineRechargeTerminal(3), true)
assert.equal(module.isOnlineRechargeTerminal(6), true)
} finally {
await cleanup()
}
})
test('online recharge request IDs are unique and namespaced', async () => {
const { module, cleanup } = await importBundledOnlineHelpers()
try {
const first = module.createOnlineRechargeRequestId()
const second = module.createOnlineRechargeRequestId()
assert.match(first, /^recharge-/)
assert.match(second, /^recharge-/)
assert.notEqual(first, second)
} finally {
await cleanup()
}
})

View File

@@ -20,6 +20,17 @@ test('agent recharge rejection posts the rejection reason to the order rejection
post(options) { post(options) {
calls.push(options) calls.push(options)
return Promise.resolve({ code: 0, data: undefined }) return Promise.resolve({ code: 0, data: undefined })
},
get(options) {
calls.push(options)
return Promise.resolve({
code: 0,
data: {
methods: ['wechat', 'alipay'],
min_amount: 10000,
max_amount: 100000000
}
})
} }
} }
`, `,
@@ -53,6 +64,8 @@ test('agent recharge rejection posts the rejection reason to the order rejection
await AgentRechargeService.rejectAgentRecharge(42, { await AgentRechargeService.rejectAgentRecharge(42, {
rejection_reason: '支付凭证不符合要求' rejection_reason: '支付凭证不符合要求'
}) })
await AgentRechargeService.getPaymentMethods()
await AgentRechargeService.getPaymentStatus(42)
assert.deepEqual(calls, [ assert.deepEqual(calls, [
{ {
@@ -60,6 +73,14 @@ test('agent recharge rejection posts the rejection reason to the order rejection
data: { data: {
rejection_reason: '支付凭证不符合要求' rejection_reason: '支付凭证不符合要求'
} }
},
{
url: '/api/admin/agent-recharges/payment-methods',
params: undefined
},
{
url: '/api/admin/agent-recharges/42/payment-status',
params: undefined
} }
]) ])
} finally { } finally {

View File

@@ -8,12 +8,23 @@ import type {
AgentRechargeQueryParams, AgentRechargeQueryParams,
AgentRechargeListResponse, AgentRechargeListResponse,
CreateAgentRechargeRequest, CreateAgentRechargeRequest,
AgentRechargePaymentMethods,
AgentRechargePaymentStatusResponse,
ConfirmOfflinePaymentRequest, ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest, RejectAgentRechargeRequest,
BaseResponse BaseResponse
} from '@/types/api' } from '@/types/api'
export class AgentRechargeService extends BaseService { export class AgentRechargeService extends BaseService {
/**
* 获取代理在线充值可用支付方式和金额限制
*/
static getPaymentMethods(): Promise<BaseResponse<AgentRechargePaymentMethods>> {
return this.get<BaseResponse<AgentRechargePaymentMethods>>(
'/api/admin/agent-recharges/payment-methods'
)
}
/** /**
* 获取代理充值订单列表 * 获取代理充值订单列表
* @param params 查询参数 * @param params 查询参数
@@ -32,6 +43,15 @@ export class AgentRechargeService extends BaseService {
return this.getOne<AgentRecharge>(`/api/admin/agent-recharges/${id}`) return this.getOne<AgentRecharge>(`/api/admin/agent-recharges/${id}`)
} }
/**
* 查询在线充值支付及钱包到账状态
*/
static getPaymentStatus(id: number): Promise<BaseResponse<AgentRechargePaymentStatusResponse>> {
return this.getOne<AgentRechargePaymentStatusResponse>(
`/api/admin/agent-recharges/${id}/payment-status`
)
}
/** /**
* 创建代理充值订单 * 创建代理充值订单
* @param data 创建充值订单请求参数 * @param data 创建充值订单请求参数

View File

@@ -0,0 +1,290 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="props.dialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="40%"
:close-on-click-modal="false"
@closed="emit('closed')"
>
<ElForm ref="formRef" :model="props.form" :rules="props.rules" label-width="130px">
<ElFormItem v-if="props.dialogType === 'add'" label="选择套餐" prop="package_ids">
<ElSelect
:model-value="props.form.package_ids"
@update:model-value="updateForm({ package_ids: $event })"
placeholder="请选择套餐(可多选)"
style="width: 100%"
filterable
remote
:remote-method="(query: string) => emit('search', query)"
:loading="props.packageLoading"
clearable
multiple
:multiple-limit="100"
popper-class="package-select-dropdown"
>
<template
v-if="props.availablePackages.length === 0 && !props.packageLoading && props.hasSeries"
>
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in props.availablePackages"
:key="pkg.id"
:label="getPackageOptionLabel(pkg)"
:value="pkg.id"
:disabled="pkg.is_authorized"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="props.dialogType === 'add' && props.form.packages.length"
label="套餐配置"
prop="packages"
>
<div class="package-config-list">
<div v-for="pkg in props.form.packages" :key="pkg.package_id" class="package-config-item">
<div class="package-config-name" :title="pkg.package_name || ''">
{{ pkg.package_name || '套餐名称不可用' }}
</div>
<div class="package-config-cost">
<span class="package-config-field-label">套餐成本价(元)</span>
<ElInputNumber
:model-value="pkg.cost_price_yuan"
:min="pkg.original_cost_price || 0"
:max="getPackageCostPriceMax(pkg)"
:precision="2"
:step="0.01"
:controls="false"
placeholder="请输入成本价"
style="width: 150px"
@update:model-value="updatePackageCost(pkg.package_id, $event)"
/>
</div>
</div>
</div>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'edit'" label="套餐名称">
<span>{{ props.form.package_name }}</span>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'edit'" label="套餐编码">
<span>{{ props.form.package_code }}</span>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'edit'" label="成本价()" prop="cost_price_yuan">
<ElInputNumber
:model-value="props.form.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
@update:model-value="updateForm({ cost_price_yuan: $event ?? 0 })"
/>
<div v-if="props.form.original_cost_price" class="form-tip">
请参考{{ props.form.package_name || '套餐' }} - 套餐成本价: ¥{{
props.form.original_cost_price.toFixed(2)
}}
</div>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'add' || props.canUpdateExpiryBase" label="生效条件">
<ElSelect
:model-value="props.form.expiry_base_override"
placeholder="请选择生效条件"
style="width: 100%"
@update:model-value="updateForm({ expiry_base_override: $event })"
>
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
<div class="form-tip">仅影响后续新订单,不影响已购买套餐</div>
</ElFormItem>
<template v-if="props.dialogType === 'edit'">
<ElFormItem label="套餐默认生效条件">
{{ props.form.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
{{ props.form.expiry_base_override_name || '跟随套餐默认' }}
</ElFormItem>
<ElFormItem label="最终生效条件">
{{ props.form.effective_expiry_base_name || '-' }}
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="props.submitLoading" @click="handleSubmit">
保存
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInputNumber,
ElOption,
ElSelect,
type FormInstance,
type FormRules
} from 'element-plus'
import type {
GrantPackageCandidate,
SeriesGrantPackageForm
} from '@/utils/business/seriesGrantPackage'
const props = withDefaults(
defineProps<{
modelValue: boolean
dialogType: 'add' | 'edit'
form: SeriesGrantPackageForm
rules: FormRules
availablePackages: GrantPackageCandidate[]
packageLoading?: boolean
submitLoading?: boolean
canUpdateExpiryBase?: boolean
hasSeries?: boolean
}>(),
{
packageLoading: false,
submitLoading: false,
canUpdateExpiryBase: false,
hasSeries: false
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:form': [form: SeriesGrantPackageForm]
search: [query: string]
submit: []
closed: []
}>()
const formRef = ref<FormInstance>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const formatPackageOptionPrice = (price?: number | null) => {
if (price === undefined || price === null) return '未配置'
return `¥${(price / 100).toFixed(2)}`
}
const getPackageOptionLabel = (pkg: GrantPackageCandidate) => {
const packageName = pkg.package_name || '套餐名称不可用'
return `${packageName} - 成本价 ${formatPackageOptionPrice(pkg.cost_price)} - 建议售价 ${formatPackageOptionPrice(pkg.suggested_retail_price)}`
}
const getPackageCostPriceMax = (pkg: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
const updateForm = (values: Partial<SeriesGrantPackageForm>) => {
emit('update:form', { ...props.form, ...values })
}
const updatePackageCost = (packageId: number, costPrice: number | undefined) => {
updateForm({
packages: props.form.packages.map((pkg) =>
pkg.package_id === packageId ? { ...pkg, cost_price_yuan: costPrice ?? 0 } : pkg
)
})
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
const valid = await formRef.value.validate()
if (valid) emit('submit')
} catch {
// 表单校验失败时由表单项展示提示
}
}
watch(
() => props.modelValue,
(visible) => {
if (visible) nextTick(() => formRef.value?.clearValidate())
}
)
</script>
<style scoped lang="scss">
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:global(.package-select-dropdown .el-select-dropdown__item) {
height: auto;
min-height: 34px;
padding-top: 6px;
padding-bottom: 6px;
line-height: 1.5;
white-space: normal;
}
.package-config-list {
width: 100%;
}
.package-config-item {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.package-config-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-config-cost {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
}
.package-config-field-label {
color: var(--el-text-color-secondary);
font-size: 13px;
white-space: nowrap;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<ElTable
v-loading="props.loading"
:data="props.data"
row-key="package_id"
border
stripe
:empty-text="props.emptyText"
>
<ElTableColumn prop="package_name" label="套餐名称" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="package_code" label="套餐编码" min-width="180" show-overflow-tooltip />
<ElTableColumn label="成本价" width="120">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="套餐默认生效条件" min-width="150">
<template #default="{ row }">
{{ row.default_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="覆盖生效条件" min-width="150">
<template #default="{ row }">
{{ row.expiry_base_override_name || '跟随套餐默认' }}
</template>
</ElTableColumn>
<ElTableColumn label="最终生效条件" min-width="150">
<template #default="{ row }">
{{ row.effective_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="props.showActions" label="操作" :width="props.actionsWidth" fixed="right">
<template #default="{ row }">
<slot name="actions" :row="row" />
</template>
</ElTableColumn>
</ElTable>
</template>
<script setup lang="ts">
import { ElTable, ElTableColumn, ElTag } from 'element-plus'
import type { GrantPackageInfo } from '@/types/api'
const props = withDefaults(
defineProps<{
data: GrantPackageInfo[]
loading?: boolean
emptyText?: string
showActions?: boolean
actionsWidth?: number | string
}>(),
{
loading: false,
emptyText: '暂无套餐',
showActions: false,
actionsWidth: 240
}
)
</script>
<style scoped lang="scss">
.amount-value {
font-weight: 600;
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="`调整实际信用额度 - ${currentShop?.shop_name || ''}`"
width="520px"
:close-on-click-modal="false"
@closed="resetDialog"
>
<ElDescriptions :column="1" border class="credit-preview">
<ElDescriptionsItem label="店铺名称">
{{ currentShop?.shop_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前版本">
{{ currentShop?.version ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="修改前">
{{ formatCreditPreview(currentShop?.credit_enabled, currentShop?.credit_limit) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="修改后">
{{ formatCreditPreview(creditForm.credit_enabled, creditLimitFen) }}
</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
title="实际可用金额、欠款金额和欠款状态以后端刷新后的资金概况为准。"
type="info"
:closable="false"
show-icon
class="credit-dialog-alert"
/>
<ElForm ref="creditFormRef" :model="creditForm" :rules="creditRules" label-width="110px">
<ElFormItem label="启用信用">
<ElSwitch v-model="creditForm.credit_enabled" @change="handleCreditEnabledChange" />
</ElFormItem>
<ElFormItem label="实际信用额度" prop="credit_limit_yuan">
<ElInputNumber
v-model="creditForm.credit_limit_yuan"
:disabled="!creditForm.credit_enabled"
:min="0"
:precision="2"
:step="100"
controls-position="right"
style="width: 100%"
placeholder="请输入实际信用额度"
/>
<div class="credit-dialog-tip">单位关闭信用时额度将自动归零</div>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">确认调整</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { CommissionService, ShopService } from '@/api/modules'
import type { ShopFundSummaryItem } from '@/types/api/commission'
import { fenToYuan, formatMoney, yuanToFen } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
const props = defineProps<{
modelValue: boolean
shop: ShopFundSummaryItem | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
submitted: []
}>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const creditFormRef = ref<FormInstance>()
const submitting = ref(false)
const currentShop = ref<ShopFundSummaryItem | null>(null)
const creditForm = reactive({
credit_enabled: false,
credit_limit_yuan: 0
})
const creditLimitFen = computed(() =>
creditForm.credit_enabled ? yuanToFen(creditForm.credit_limit_yuan) || 0 : 0
)
const creditRules = computed<FormRules>(() => ({
credit_limit_yuan: [
{
validator: (
_rule: unknown,
value: number | undefined,
callback: (error?: Error) => void
) => {
if (!creditForm.credit_enabled) {
callback()
return
}
if (value === undefined || value === null || Number.isNaN(value)) {
callback(new Error('请输入实际信用额度'))
return
}
if (value <= 0) {
callback(new Error('实际信用额度必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
}))
const formatCreditPreview = (enabled?: boolean, creditLimit?: number) =>
enabled ? `启用 / ${formatMoney(creditLimit || 0)}` : '关闭 / ¥0.00'
const syncForm = (summary: ShopFundSummaryItem) => {
creditForm.credit_enabled = Boolean(summary.credit_enabled)
creditForm.credit_limit_yuan = summary.credit_enabled ? fenToYuan(summary.credit_limit) : 0
nextTick(() => creditFormRef.value?.clearValidate())
}
watch(
() => [props.modelValue, props.shop] as const,
([visible, shop]) => {
if (visible && shop) {
currentShop.value = shop
syncForm(shop)
}
},
{ immediate: true }
)
const handleCreditEnabledChange = (enabled: boolean | string | number) => {
if (!enabled) {
creditForm.credit_limit_yuan = 0
creditFormRef.value?.clearValidate('credit_limit_yuan')
}
}
const resetDialog = () => {
creditFormRef.value?.resetFields()
currentShop.value = null
creditForm.credit_enabled = false
creditForm.credit_limit_yuan = 0
}
const loadLatestSummary = async () => {
if (!currentShop.value) return null
const res = await CommissionService.getShopFundSummary({
page: 1,
page_size: 100,
shop_name: currentShop.value.shop_name
})
if (res.code !== 0) return null
return (
(res.data.items || []).find((item) => item.shop_id === currentShop.value?.shop_id) || null
)
}
const isConflict = (error: unknown) => {
const normalized = normalizeApiError(error)
return normalized.kind === 'conflict' || normalized.status === 409
}
const handleSubmit = async () => {
if (!currentShop.value || !creditFormRef.value) return
try {
await creditFormRef.value.validate()
} catch {
return
}
submitting.value = true
try {
const res = await ShopService.updateShopCreditLimit(currentShop.value.shop_id, {
credit_enabled: creditForm.credit_enabled,
credit_limit: creditLimitFen.value,
version: currentShop.value.version
})
if (res.code === 0) {
ElMessage.success('实际信用额度调整成功')
dialogVisible.value = false
emit('submitted')
return
}
if (res.code === 409) {
const latest = await loadLatestSummary()
if (latest) {
currentShop.value = latest
syncForm(latest)
}
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
return
}
ElMessage.error(res.msg || '实际信用额度调整失败')
} catch (error) {
if (isConflict(error)) {
const latest = await loadLatestSummary()
if (latest) {
currentShop.value = latest
syncForm(latest)
}
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
} else {
ElMessage.error(normalizeApiError(error).message)
}
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.credit-preview {
margin-bottom: 16px;
}
.credit-dialog-alert {
margin-bottom: 16px;
}
.credit-dialog-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -400,17 +400,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: false keepAlive: false
} }
}, },
// 授权记录详情(放在 asset-management 层级)
{
path: 'record-management/authorization-records/detail/:id',
name: 'AuthorizationRecordDetail',
component: RoutesAlias.AuthorizationRecordDetail,
meta: {
title: 'menus.assetManagement.authorizationRecordDetail',
isHide: true,
keepAlive: false
}
},
// 任务管理 // 任务管理
{ {
path: 'task-management', path: 'task-management',

View File

@@ -52,7 +52,6 @@ export enum RoutesAlias {
// 记录管理 // 记录管理
AuthorizationRecords = '/asset-management/record-management/authorization-records', // 授权记录 AuthorizationRecords = '/asset-management/record-management/authorization-records', // 授权记录
AuthorizationRecordDetail = '/asset-management/record-management/authorization-records/detail', // 授权记录详情
AssetAssign = '/asset-management/record-management/asset-assign', // 分配记录 AssetAssign = '/asset-management/record-management/asset-assign', // 分配记录
AssetAssignDetail = '/asset-management/record-management/asset-assign/detail', // 分配记录详情 AssetAssignDetail = '/asset-management/record-management/asset-assign/detail', // 分配记录详情

View File

@@ -26,6 +26,10 @@ export interface PlatformAccount {
enterprise_name?: string // ⭐ 新增:企业名称 enterprise_name?: string // ⭐ 新增:企业名称
shop_id?: number | null // 关联店铺ID shop_id?: number | null // 关联店铺ID
shop_name?: string // ⭐ 新增:店铺名称 shop_name?: string // ⭐ 新增:店铺名称
wecom_bound?: boolean // 是否已绑定企业微信账号
wecom_corp_id?: string // 企业微信 CorpID
wecom_name?: string // 企业微信成员名称
wecom_userid?: string // 企业微信成员 UserID
status: AccountStatus // 状态 (0:禁用, 1:启用) status: AccountStatus // 状态 (0:禁用, 1:启用)
} }

View File

@@ -13,10 +13,17 @@ export enum AgentRechargeStatus {
} }
// 支付方式 // 支付方式
export type AgentRechargePaymentMethod = 'wechat' | 'offline' export type AgentRechargeOnlinePaymentMethod = 'wechat' | 'alipay'
export type AgentRechargePaymentMethod = AgentRechargeOnlinePaymentMethod | 'offline'
// 充值来源
export type AgentRechargeSource = 'agent_online' | 'platform_offline'
// 第三方支付状态
export type AgentRechargePaymentStatus = 0 | 1 | 2 | 3
// 支付通道 // 支付通道
export type AgentRechargePaymentChannel = 'wechat_direct' | 'fuyou' | 'offline' export type AgentRechargePaymentChannel = string
// 代理充值订单 // 代理充值订单
export interface AgentRecharge { export interface AgentRecharge {
@@ -31,7 +38,14 @@ export interface AgentRecharge {
payment_method: AgentRechargePaymentMethod payment_method: AgentRechargePaymentMethod
payment_channel: AgentRechargePaymentChannel payment_channel: AgentRechargePaymentChannel
payment_config_id: number | null payment_config_id: number | null
payment_transaction_id: string payment_transaction_id: string | null
payment_no?: string | null
request_id?: string | null
qr_content?: string | null
payment_status?: AgentRechargePaymentStatus | null
payment_status_name?: string | null
recharge_source?: AgentRechargeSource | null
recharge_source_name?: string | null
payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串 payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串
rejection_reason?: string | null // 拒绝原因 rejection_reason?: string | null // 拒绝原因
remark?: string // 运营备注 remark?: string // 运营备注
@@ -57,6 +71,7 @@ export interface AgentRechargeQueryParams {
page_size?: number page_size?: number
shop_id?: number shop_id?: number
status?: AgentRechargeStatus status?: AgentRechargeStatus
recharge_source?: AgentRechargeSource
start_date?: string start_date?: string
end_date?: string end_date?: string
dateRange?: string[] // For date range picker in UI dateRange?: string[] // For date range picker in UI
@@ -72,14 +87,45 @@ export interface AgentRechargeListResponse {
} }
// 创建代理充值订单请求 // 创建代理充值订单请求
export interface CreateAgentRechargeRequest { export interface CreateAgentRechargeOnlineRequest {
amount: number // 充值金额(单位:分),最小 1即 ¥0.01 amount: number // 充值金额(单位:分),最小 1即 ¥0.01
payment_method: AgentRechargePaymentMethod payment_method: AgentRechargeOnlinePaymentMethod
request_id: string
}
export interface CreateAgentRechargeOfflineRequest {
amount: number // 充值金额(单位:分),最小 1即 ¥0.01
payment_method: 'offline'
shop_id: number shop_id: number
payment_voucher_key?: string[] // 线下支付凭证附件列表payment_method=offline 时必填) payment_voucher_key: string[] // 线下支付凭证附件列表payment_method=offline 时必填)
remark?: string // 运营备注,最多 1000 字 remark?: string // 运营备注,最多 1000 字
} }
export type CreateAgentRechargeRequest =
| CreateAgentRechargeOnlineRequest
| CreateAgentRechargeOfflineRequest
// 在线充值可用支付方式和金额范围
export interface AgentRechargePaymentMethods {
methods: AgentRechargeOnlinePaymentMethod[]
min_amount: number
max_amount: number
}
// 在线充值支付及钱包到账状态
export interface AgentRechargePaymentStatusResponse {
recharge_id: number
recharge_no: string
recharge_source: AgentRechargeSource
recharge_source_name?: string | null
status: AgentRechargeStatus
status_name?: string | null
payment_status: AgentRechargePaymentStatus
payment_status_name?: string | null
paid_at: string | null
completed_at: string | null
}
// 确认线下充值请求 // 确认线下充值请求
export interface ConfirmOfflinePaymentRequest { export interface ConfirmOfflinePaymentRequest {
operation_password: string operation_password: string

View File

@@ -17,7 +17,7 @@ export interface SystemConfigItem {
value_type: SystemConfigValueType value_type: SystemConfigValueType
description: string description: string
control: string control: string
enum_values: string[] enum_values?: string[]
min: number | null min: number | null
max: number | null max: number | null
readonly: boolean readonly: boolean

View File

@@ -148,9 +148,12 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default'] SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default']
SeriesGrantPackageDialog: typeof import('./../components/business/SeriesGrantPackageDialog.vue')['default']
SeriesGrantPackageTable: typeof import('./../components/business/SeriesGrantPackageTable.vue')['default']
SettingDrawer: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingDrawer.vue')['default'] SettingDrawer: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingDrawer.vue')['default']
SettingHeader: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingHeader.vue')['default'] SettingHeader: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingHeader.vue')['default']
SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default'] SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default']
ShopCreditLimitDialog: typeof import('./../components/business/ShopCreditLimitDialog.vue')['default']
SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default'] SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default']
SpeedLimitDialog: typeof import('./../components/device/SpeedLimitDialog.vue')['default'] SpeedLimitDialog: typeof import('./../components/device/SpeedLimitDialog.vue')['default']
SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default'] SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default']

View File

@@ -1,4 +1,36 @@
import type { GrantPackageInfo, PackageResponse } from '@/types/api' import type {
GrantPackageInfo,
PackageAllocationExpiryBaseOverride,
PackageResponse
} from '@/types/api'
export type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
export type PackageCostFormItem = {
package_id: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
}
export type SeriesGrantPackageForm = {
package_id?: number
allocation_id?: number
package_ids: number[]
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
packages: PackageCostFormItem[]
}
export interface GrantPackageCandidate { export interface GrantPackageCandidate {
id: number id: number

View File

@@ -512,6 +512,8 @@
{ label: '账号类型', prop: 'user_type' }, { label: '账号类型', prop: 'user_type' },
{ label: '店铺名称', prop: 'shop_name' }, { label: '店铺名称', prop: 'shop_name' },
{ label: '企业名称', prop: 'enterprise_name' }, { label: '企业名称', prop: 'enterprise_name' },
{ label: '企微绑定', prop: 'wecom_bound' },
{ label: '企微用户名称', prop: 'wecom_name' },
...(canModifyAccountStatus ? [{ label: '状态', prop: 'status' }] : []), ...(canModifyAccountStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' } { label: '创建时间', prop: 'created_at' }
] ]
@@ -620,6 +622,21 @@
return row.enterprise_name || '-' return row.enterprise_name || '-'
} }
}, },
{
prop: 'wecom_bound',
label: '企微绑定',
width: 100,
formatter: (row: any) =>
h(ElTag, { type: row.wecom_bound ? 'success' : 'info', size: 'small' }, () =>
row.wecom_bound ? '已绑定' : '未绑定'
)
},
{
prop: 'wecom_name',
label: '企微用户名称',
minWidth: 130,
formatter: (row: any) => row.wecom_name || '-'
},
...(canModifyAccountStatus ...(canModifyAccountStatus
? [ ? [
{ {
@@ -688,7 +705,7 @@
return actions return actions
} }
const showWecomBindingDialog = (row: any) => { const showWecomBindingDialog = async (row: any) => {
if (row.user_type !== 2) { if (row.user_type !== 2) {
ElMessage.warning('只有平台用户可以绑定企微账号') ElMessage.warning('只有平台用户可以绑定企微账号')
return return
@@ -699,7 +716,17 @@
wecomBindingForm.userid = '' wecomBindingForm.userid = ''
wecomMembers.value = [] wecomMembers.value = []
wecomBindingDialogVisible.value = true wecomBindingDialogVisible.value = true
void loadWecomApplications() await loadWecomApplications()
if (row.wecom_bound && row.wecom_corp_id) {
const boundApplication = wecomApplications.value.find(
(application) => application.corp_id === row.wecom_corp_id
)
if (boundApplication) {
wecomBindingForm.application_id = boundApplication.id
await handleWecomApplicationChange(boundApplication.id, row.wecom_userid || '')
}
}
} }
const loadWecomApplications = async () => { const loadWecomApplications = async () => {
@@ -712,8 +739,8 @@
} }
} }
const handleWecomApplicationChange = async (applicationId?: number) => { const handleWecomApplicationChange = async (applicationId?: number, selectedUserid = '') => {
wecomBindingForm.userid = '' wecomBindingForm.userid = selectedUserid
wecomMembers.value = [] wecomMembers.value = []
if (!applicationId) return if (!applicationId) return
@@ -723,7 +750,15 @@
page: 1, page: 1,
page_size: 100 page_size: 100
}) })
if (response.code === 0) wecomMembers.value = response.data.items || [] if (response.code === 0) {
wecomMembers.value = response.data.items || []
if (
selectedUserid &&
!wecomMembers.value.some((member) => member.userid === selectedUserid)
) {
wecomBindingForm.userid = selectedUserid
}
}
} finally { } finally {
wecomMembersLoading.value = false wecomMembersLoading.value = false
} }
@@ -753,6 +788,7 @@
if (response.code === 0) { if (response.code === 0) {
ElMessage.success('账号企微绑定成功') ElMessage.success('账号企微绑定成功')
wecomBindingDialogVisible.value = false wecomBindingDialogVisible.value = false
await getAccountList()
} }
} finally { } finally {
wecomBindingSubmitting.value = false wecomBindingSubmitting.value = false

View File

@@ -11,6 +11,7 @@
<ElFormItem label="固定档位" prop="code"> <ElFormItem label="固定档位" prop="code">
<ElSelect <ElSelect
v-model="form.code" v-model="form.code"
placeholder="请选择限速档位"
style="width: 100%" style="width: 100%"
:teleported="true" :teleported="true"
append-to="body" append-to="body"
@@ -50,9 +51,11 @@
confirmLoading?: boolean confirmLoading?: boolean
} }
type SpeedTierCode = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void (e: 'confirm', data: { code: SpeedTierCode }): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -74,7 +77,7 @@
{ code: 8, label: '100Mbps' } { code: 8, label: '100Mbps' }
] as const ] as const
const form = reactive({ code: 3 as (typeof speedTiers)[number]['code'] }) const form = reactive<{ code: SpeedTierCode | undefined }>({ code: undefined })
const rules: FormRules = { const rules: FormRules = {
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }] code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
@@ -97,7 +100,7 @@
(newVal) => { (newVal) => {
setLayoutScrollLock(newVal) setLayoutScrollLock(newVal)
if (newVal) { if (newVal) {
form.code = 3 form.code = undefined
} }
}, },
{ immediate: true } { immediate: true }
@@ -117,6 +120,7 @@
try { try {
await formRef.value.validate() await formRef.value.validate()
if (form.code === undefined) return
emit('confirm', { emit('confirm', {
code: form.code code: form.code
}) })

View File

@@ -32,6 +32,14 @@
> >
批量回收 批量回收
</ElButton> </ElButton>
<ElButton
type="info"
:disabled="!selectedDevices.length"
@click="handleBatchSetSeries"
v-permission="'device:batch_set_series'"
>
批量设置套餐系列
</ElButton>
<ElButton <ElButton
type="primary" type="primary"
:disabled="!selectedDevices.length" :disabled="!selectedDevices.length"
@@ -2656,6 +2664,18 @@
await loadPackageSeriesList(query || undefined) await loadPackageSeriesList(query || undefined)
} }
// 显示批量设置套餐系列对话框
const handleBatchSetSeries = async () => {
seriesBindingResult.value = null
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([loadPackageSeriesList(), loadSearchBatchNoOptions()])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}
const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => { const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => {
const request: BatchSetDeviceSeriesBindingRequest = { const request: BatchSetDeviceSeriesBindingRequest = {
selection_type: seriesBindingForm.selection_type, selection_type: seriesBindingForm.selection_type,

View File

@@ -136,6 +136,7 @@
style="width: 100%" style="width: 100%"
clearable clearable
@change="handleOldIdentifierChange" @change="handleOldIdentifierChange"
@focus="handleOldIotCardsFocus"
> >
<ElOption <ElOption
v-for="card in oldIotCardOptions" v-for="card in oldIotCardOptions"
@@ -151,6 +152,9 @@
</div> </div>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
<div v-if="oldIotCardEmptyMessage" class="exchange-field-error">
{{ oldIotCardEmptyMessage }}
</div>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
v-if="createForm.old_asset_type === 'device'" v-if="createForm.old_asset_type === 'device'"
@@ -169,6 +173,7 @@
style="width: 100%" style="width: 100%"
clearable clearable
@change="handleOldIdentifierChange" @change="handleOldIdentifierChange"
@focus="handleOldDevicesFocus"
> >
<ElOption <ElOption
v-for="device in oldDeviceOptions" v-for="device in oldDeviceOptions"
@@ -184,6 +189,9 @@
</div> </div>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
<div v-if="oldDeviceEmptyMessage" class="exchange-field-error">
{{ oldDeviceEmptyMessage }}
</div>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="createOldAssetShopDisplay" label="旧资产所属店铺"> <ElFormItem v-if="createOldAssetShopDisplay" label="旧资产所属店铺">
<ElInput :model-value="createOldAssetShopDisplay" readonly disabled /> <ElInput :model-value="createOldAssetShopDisplay" readonly disabled />
@@ -204,6 +212,7 @@
no-data-text="暂无同店铺可用的新资产" no-data-text="暂无同店铺可用的新资产"
style="width: 100%" style="width: 100%"
clearable clearable
@focus="handleCreateNewIotCardsFocus"
> >
<ElOption <ElOption
v-for="card in newIotCardOptions" v-for="card in newIotCardOptions"
@@ -219,6 +228,9 @@
</div> </div>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
<div v-if="createNewIotCardEmptyMessage" class="exchange-field-error">
{{ createNewIotCardEmptyMessage }}
</div>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
v-if="createForm.flow_type === 'direct' && createForm.old_asset_type === 'device'" v-if="createForm.flow_type === 'direct' && createForm.old_asset_type === 'device'"
@@ -236,6 +248,7 @@
no-data-text="暂无同店铺可用的新资产" no-data-text="暂无同店铺可用的新资产"
style="width: 100%" style="width: 100%"
clearable clearable
@focus="handleCreateNewDevicesFocus"
> >
<ElOption <ElOption
v-for="device in newDeviceOptions" v-for="device in newDeviceOptions"
@@ -251,6 +264,9 @@
</div> </div>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
<div v-if="createNewDeviceEmptyMessage" class="exchange-field-error">
{{ createNewDeviceEmptyMessage }}
</div>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="createDirectInheritedShopHint" label="归属提示"> <ElFormItem v-if="createDirectInheritedShopHint" label="归属提示">
<div class="exchange-shop-hint">{{ createDirectInheritedShopHint }}</div> <div class="exchange-shop-hint">{{ createDirectInheritedShopHint }}</div>
@@ -308,6 +324,8 @@
no-data-text="暂无可用的新资产" no-data-text="暂无可用的新资产"
style="width: 100%" style="width: 100%"
clearable clearable
@change="handleShipNewIdentifierChange"
@focus="handleShipNewIotCardsFocus"
> >
<ElOption <ElOption
v-for="card in newIotCardOptions" v-for="card in newIotCardOptions"
@@ -323,6 +341,9 @@
</div> </div>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
<div v-if="shipNewIotCardEmptyMessage" class="exchange-field-error">
{{ shipNewIotCardEmptyMessage }}
</div>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
v-else-if="shipAssetType === 'device'" v-else-if="shipAssetType === 'device'"
@@ -340,6 +361,8 @@
no-data-text="暂无可用的新资产" no-data-text="暂无可用的新资产"
style="width: 100%" style="width: 100%"
clearable clearable
@change="handleShipNewIdentifierChange"
@focus="handleShipNewDevicesFocus"
> >
<ElOption <ElOption
v-for="device in newDeviceOptions" v-for="device in newDeviceOptions"
@@ -355,6 +378,9 @@
</div> </div>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
<div v-if="shipNewDeviceEmptyMessage" class="exchange-field-error">
{{ shipNewDeviceEmptyMessage }}
</div>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="shipInheritedShopDisplayText" label="归属提示"> <ElFormItem v-if="shipInheritedShopDisplayText" label="归属提示">
<div class="exchange-shop-hint">{{ shipInheritedShopDisplayText }}</div> <div class="exchange-shop-hint">{{ shipInheritedShopDisplayText }}</div>
@@ -534,6 +560,42 @@
const newDeviceSearchLoading = ref(false) const newDeviceSearchLoading = ref(false)
const completeLoadingId = ref<number | null>(null) const completeLoadingId = ref<number | null>(null)
type AssetFilterOption = {
label: string
value: string
}
const oldAssetFilterOptions = ref<AssetFilterOption[]>([])
const newAssetFilterOptions = ref<AssetFilterOption[]>([])
const oldAssetFilterLoading = ref(false)
const newAssetFilterLoading = ref(false)
const oldIotCardEmptyMessage = computed(() =>
oldCardSearchLoading.value || oldIotCardOptions.value.length > 0 ? '' : '暂无可用的旧资产'
)
const oldDeviceEmptyMessage = computed(() =>
oldDeviceSearchLoading.value || oldDeviceOptions.value.length > 0 ? '' : '暂无可用的旧资产'
)
const createNewIotCardEmptyMessage = computed(() => {
if (newCardSearchLoading.value || newIotCardOptions.value.length > 0) return ''
return createForm.old_identifier ? '暂无同店铺可用的新资产' : '请先选择旧资产'
})
const createNewDeviceEmptyMessage = computed(() => {
if (newDeviceSearchLoading.value || newDeviceOptions.value.length > 0) return ''
return createForm.old_identifier ? '暂无同店铺可用的新资产' : '请先选择旧资产'
})
const shipNewIotCardEmptyMessage = computed(() =>
newCardSearchLoading.value || newIotCardOptions.value.length > 0 ? '' : '暂无可用的新资产'
)
const shipNewDeviceEmptyMessage = computed(() =>
newDeviceSearchLoading.value || newDeviceOptions.value.length > 0 ? '' : '暂无可用的新资产'
)
// 发货表单 // 发货表单
const shipForm = reactive({ const shipForm = reactive({
new_identifier: '', new_identifier: '',
@@ -543,11 +605,90 @@
}) })
const shipRules = reactive<FormRules>({ const shipRules = reactive<FormRules>({
new_identifier: [{ required: true, message: '请输入新资产标识符', trigger: 'blur' }], new_identifier: [{ required: true, message: '请选择新资产标识符', trigger: 'change' }],
express_company: [{ required: true, message: '请输入快递公司', trigger: 'blur' }], express_company: [{ required: true, message: '请输入快递公司', trigger: 'blur' }],
express_no: [{ required: true, message: '请输入快递单号', trigger: 'blur' }] express_no: [{ required: true, message: '请输入快递单号', trigger: 'blur' }]
}) })
const handleShipNewIdentifierChange = () => {
shipFormRef.value?.clearValidate('new_identifier')
}
const handleShipNewIotCardsFocus = async () => {
if (newCardSearchLoading.value || newIotCardOptions.value.length > 0) return
await loadDefaultShipNewIotCards()
}
const handleShipNewDevicesFocus = async () => {
if (newDeviceSearchLoading.value || newDeviceOptions.value.length > 0) return
await loadDefaultShipNewDevices()
}
const handleOldIotCardsFocus = async () => {
if (oldCardSearchLoading.value || oldIotCardOptions.value.length > 0) return
await loadDefaultIotCards()
}
const handleOldDevicesFocus = async () => {
if (oldDeviceSearchLoading.value || oldDeviceOptions.value.length > 0) return
await loadDefaultDevices()
}
const handleCreateNewIotCardsFocus = async () => {
if (newCardSearchLoading.value || newIotCardOptions.value.length > 0) return
await loadDefaultNewIotCards()
}
const handleCreateNewDevicesFocus = async () => {
if (newDeviceSearchLoading.value || newDeviceOptions.value.length > 0) return
await loadDefaultNewDevices()
}
const searchFilterAssets = async (query: string, target: 'old' | 'new') => {
const options = target === 'old' ? oldAssetFilterOptions : newAssetFilterOptions
const loading = target === 'old' ? oldAssetFilterLoading : newAssetFilterLoading
loading.value = true
try {
const keyword = query.trim() || undefined
const [cardRes, deviceRes] = await Promise.all([
CardService.getStandaloneIotCards({
is_standalone: true,
keyword,
page: 1,
page_size: 10
}),
DeviceService.getDevices({
keyword,
page: 1,
page_size: 10
})
])
const cardOptions: AssetFilterOption[] =
cardRes.code === 0
? (cardRes.data?.items || []).map((card) => ({
label: `${card.iccid} (IoT卡)`,
value: card.iccid
}))
: []
const deviceOptions: AssetFilterOption[] =
deviceRes.code === 0
? (deviceRes.data?.items || []).map((device) => ({
label: `${device.virtual_no} (设备)`,
value: device.virtual_no
}))
: []
options.value = [...cardOptions, ...deviceOptions]
} catch (error) {
console.error('加载资产筛选选项失败:', error)
options.value = []
} finally {
loading.value = false
}
}
type ShopSelectableOption = { type ShopSelectableOption = {
shop_id?: number | null shop_id?: number | null
shop_name?: string | null shop_name?: string | null
@@ -644,7 +785,7 @@
}) })
// 搜索表单配置 // 搜索表单配置
const searchFormItems: SearchFormItem[] = [ const searchFormItems = computed<SearchFormItem[]>(() => [
{ {
label: '换货状态', label: '换货状态',
prop: 'status', prop: 'status',
@@ -677,19 +818,31 @@
{ {
label: '旧资产', label: '旧资产',
prop: 'old_asset_keyword', prop: 'old_asset_keyword',
type: 'input', type: 'select',
options: () => oldAssetFilterOptions.value,
config: { config: {
placeholder: '请输入旧资产标识', placeholder: '请选择或搜索旧资产标识',
clearable: true clearable: true,
filterable: true,
remote: true,
reserveKeyword: true,
loading: oldAssetFilterLoading.value,
remoteMethod: (query: string) => searchFilterAssets(query, 'old')
} }
}, },
{ {
label: '新资产', label: '新资产',
prop: 'new_asset_keyword', prop: 'new_asset_keyword',
type: 'input', type: 'select',
options: () => newAssetFilterOptions.value,
config: { config: {
placeholder: '请输入新资产标识', placeholder: '请选择或搜索新资产标识',
clearable: true clearable: true,
filterable: true,
remote: true,
reserveKeyword: true,
loading: newAssetFilterLoading.value,
remoteMethod: (query: string) => searchFilterAssets(query, 'new')
} }
}, },
{ {
@@ -704,7 +857,7 @@
valueFormat: 'YYYY-MM-DD' valueFormat: 'YYYY-MM-DD'
} }
} }
] ])
// 动态列配置 // 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
@@ -1037,9 +1190,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
oldIotCardOptions.value = res.data.items || [] oldIotCardOptions.value = res.data.items || []
if (oldIotCardOptions.value.length === 0) {
ElMessage.info('暂无可用的IoT卡')
}
} }
} catch (error) { } catch (error) {
console.error('加载IoT卡列表失败:', error) console.error('加载IoT卡列表失败:', error)
@@ -1059,9 +1209,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
oldDeviceOptions.value = res.data.items || [] oldDeviceOptions.value = res.data.items || []
if (oldDeviceOptions.value.length === 0) {
ElMessage.info('暂无可用的设备')
}
} }
} catch (error) { } catch (error) {
console.error('加载设备列表失败:', error) console.error('加载设备列表失败:', error)
@@ -1089,9 +1236,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
oldIotCardOptions.value = res.data.items || [] oldIotCardOptions.value = res.data.items || []
if (oldIotCardOptions.value.length === 0) {
ElMessage.info('未找到匹配的IoT卡')
}
} }
} catch (error) { } catch (error) {
console.error('搜索IoT卡失败:', error) console.error('搜索IoT卡失败:', error)
@@ -1118,9 +1262,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
oldDeviceOptions.value = res.data.items || [] oldDeviceOptions.value = res.data.items || []
if (oldDeviceOptions.value.length === 0) {
ElMessage.info('未找到匹配的设备')
}
} }
} catch (error) { } catch (error) {
console.error('搜索设备失败:', error) console.error('搜索设备失败:', error)
@@ -1141,7 +1282,6 @@
const shopId = getCreateOldAssetShopId() const shopId = getCreateOldAssetShopId()
if (shopId === undefined) { if (shopId === undefined) {
newIotCardOptions.value = [] newIotCardOptions.value = []
ElMessage.warning('请先选择旧资产')
return return
} }
@@ -1155,9 +1295,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || [] newIotCardOptions.value = res.data.items || []
if (newIotCardOptions.value.length === 0) {
ElMessage.info('未找到匹配的IoT卡')
}
} }
} catch (error) { } catch (error) {
console.error('搜索IoT卡失败:', error) console.error('搜索IoT卡失败:', error)
@@ -1184,9 +1321,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || [] newIotCardOptions.value = res.data.items || []
if (newIotCardOptions.value.length === 0) {
ElMessage.info('未找到匹配的IoT卡')
}
} }
} catch (error) { } catch (error) {
console.error('搜索IoT卡失败:', error) console.error('搜索IoT卡失败:', error)
@@ -1213,9 +1347,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || [] newIotCardOptions.value = res.data.items || []
if (newIotCardOptions.value.length === 0) {
ElMessage.info('暂无同店铺可用的新资产')
}
} }
} catch (error) { } catch (error) {
console.error('加载IoT卡列表失败:', error) console.error('加载IoT卡列表失败:', error)
@@ -1234,8 +1365,8 @@
page: 1, page: 1,
page_size: 10 page_size: 10
}) })
if (res.code === 0 && res.data) { if (res.code === 0) {
newIotCardOptions.value = res.data.items || [] newIotCardOptions.value = res.data?.items || []
} }
} catch (error) { } catch (error) {
console.error('加载IoT卡列表失败:', error) console.error('加载IoT卡列表失败:', error)
@@ -1256,7 +1387,6 @@
const shopId = getCreateOldAssetShopId() const shopId = getCreateOldAssetShopId()
if (shopId === undefined) { if (shopId === undefined) {
newDeviceOptions.value = [] newDeviceOptions.value = []
ElMessage.warning('请先选择旧资产')
return return
} }
@@ -1269,9 +1399,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || [] newDeviceOptions.value = res.data.items || []
if (newDeviceOptions.value.length === 0) {
ElMessage.info('未找到匹配的设备')
}
} }
} catch (error) { } catch (error) {
console.error('搜索设备失败:', error) console.error('搜索设备失败:', error)
@@ -1297,9 +1424,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || [] newDeviceOptions.value = res.data.items || []
if (newDeviceOptions.value.length === 0) {
ElMessage.info('未找到匹配的设备')
}
} }
} catch (error) { } catch (error) {
console.error('搜索设备失败:', error) console.error('搜索设备失败:', error)
@@ -1325,9 +1449,6 @@
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || [] newDeviceOptions.value = res.data.items || []
if (newDeviceOptions.value.length === 0) {
ElMessage.info('暂无同店铺可用的新资产')
}
} }
} catch (error) { } catch (error) {
console.error('加载设备列表失败:', error) console.error('加载设备列表失败:', error)
@@ -1345,8 +1466,8 @@
page: 1, page: 1,
page_size: 10 page_size: 10
}) })
if (res.code === 0 && res.data) { if (res.code === 0) {
newDeviceOptions.value = res.data.items || [] newDeviceOptions.value = res.data?.items || []
} }
} catch (error) { } catch (error) {
console.error('加载设备列表失败:', error) console.error('加载设备列表失败:', error)
@@ -1641,6 +1762,8 @@
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
loadExchangeList() loadExchangeList()
searchFilterAssets('', 'old')
searchFilterAssets('', 'new')
}) })
</script> </script>
@@ -1657,5 +1780,13 @@
border: 1px solid var(--el-color-primary-light-7); border: 1px solid var(--el-color-primary-light-7);
border-radius: 6px; border-radius: 6px;
} }
.exchange-field-error {
width: 100%;
margin-top: 4px;
color: var(--el-color-danger);
font-size: 12px;
line-height: 1.2;
}
} }
</style> </style>

View File

@@ -1,140 +0,0 @@
<template>
<div class="authorization-record-detail">
<ElCard shadow="never">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">授权记录详情</h2>
</div>
<!-- 详情内容 -->
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { AuthorizationService } from '@/api/modules'
import type { AuthorizationItem } from '@/types/api/authorization'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'AuthorizationRecordDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<AuthorizationItem | null>(null)
// 详情页配置
const detailSections: DetailSection[] = [
{
title: '基本信息',
fields: [
{ label: '企业ID', prop: 'enterprise_id' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '卡ID', prop: 'card_id' },
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn', formatter: (value) => value || '-' },
{ label: '授权人ID', prop: 'authorized_by' },
{ label: '授权人', prop: 'authorizer_name' },
{
label: '授权人类型',
formatter: (_, data) => {
return data.authorizer_type === 2 ? '平台' : '代理'
}
},
{ label: '授权时间', prop: 'authorized_at', formatter: (value) => formatDateTime(value) },
{
label: '状态',
formatter: (_, data) => {
return data.status === 1 ? '有效' : '已回收'
}
},
{ label: '备注', prop: 'remark', formatter: (value) => value || '-' }
]
}
]
// 返回上一页
const handleBack = () => {
router.back()
}
// 获取详情数据
const fetchDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('缺少ID参数')
return
}
loading.value = true
try {
const res = await AuthorizationService.getAuthorizationDetail(id)
if (res.code === 0) {
detailData.value = res.data
}
} catch (error) {
console.error(error)
console.log('获取授权记录详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.authorization-record-detail {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.loading-container {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
</style>

View File

@@ -28,29 +28,16 @@
:pageSize="pagination.pageSize" :pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:row-class-name="getRowClassName" :actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
> >
<template #default> <template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" /> <ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template> </template>
</ArtTable> </ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
<!-- 修改备注对话框 --> <!-- 修改备注对话框 -->
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="500px"> <ElDialog v-model="remarkDialogVisible" title="修改备注" width="500px">
<ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules" label-width="80px"> <ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules" label-width="80px">
@@ -81,40 +68,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router'
import { AuthorizationService, EnterpriseService } from '@/api/modules' import { AuthorizationService, EnterpriseService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus' import { ElMessage, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import type { import type {
AuthorizationItem, AuthorizationItem,
AuthorizationStatus, AuthorizationStatus,
AuthorizerType AuthorizerType
} from '@/types/api/authorization' } from '@/types/api/authorization'
import { CommonStatus } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'AuthorizationRecords' }) defineOptions({ name: 'AuthorizationRecords' })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const router = useRouter()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const loading = ref(false) const loading = ref(false)
const remarkDialogVisible = ref(false) const remarkDialogVisible = ref(false)
@@ -122,8 +91,6 @@
const tableRef = ref() const tableRef = ref()
const remarkFormRef = ref<FormInstance>() const remarkFormRef = ref<FormInstance>()
const currentRecordId = ref<number>(0) const currentRecordId = ref<number>(0)
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<AuthorizationItem | null>(null)
// 企业搜索选项 // 企业搜索选项
const enterpriseOptions = ref<any[]>([]) const enterpriseOptions = ref<any[]>([])
@@ -252,7 +219,8 @@
{ label: '授权人类型', prop: 'authorizer_type' }, { label: '授权人类型', prop: 'authorizer_type' },
{ label: '授权时间', prop: 'authorized_at' }, { label: '授权时间', prop: 'authorized_at' },
{ label: '状态', prop: 'status' }, { label: '状态', prop: 'status' },
{ label: '备注', prop: 'remark' } { label: '备注', prop: 'remark' },
{ label: '操作', prop: 'operation' }
] ]
const authorizationList = ref<AuthorizationItem[]>([]) const authorizationList = ref<AuthorizationItem[]>([])
@@ -284,17 +252,7 @@
label: 'ICCID', label: 'ICCID',
width: 200, width: 200,
formatter: (row: AuthorizationItem) => { formatter: (row: AuthorizationItem) => {
return h( return row.iccid || '-'
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.iccid
)
} }
}, },
{ {
@@ -346,6 +304,21 @@
} }
]) ])
// 操作列配置,样式与店铺列表保持一致
const getActions = (row: AuthorizationItem) => {
const actions: any[] = []
if (hasAuth('authorization_records:update_remark')) {
actions.push({
label: '编辑',
handler: () => showRemarkDialog(row),
type: 'primary'
})
}
return actions
}
onMounted(() => { onMounted(() => {
getTableData() getTableData()
searchEnterprises('') searchEnterprises('')
@@ -417,22 +390,6 @@
getTableData() getTableData()
} }
// 查看详情
const viewDetail = (row: AuthorizationItem) => {
router.push({
path: `${RoutesAlias.AuthorizationRecords}/detail/${row.id}`
})
}
// 处理名称点击
const handleNameClick = (row: AuthorizationItem) => {
if (hasAuth('authorization_records:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 显示修改备注对话框 // 显示修改备注对话框
const showRemarkDialog = (row: AuthorizationItem) => { const showRemarkDialog = (row: AuthorizationItem) => {
currentRecordId.value = row.id currentRecordId.value = row.id
@@ -462,44 +419,10 @@
} }
}) })
} }
// 右键菜单项配置
const contextMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
if (hasAuth('authorization_records:update_remark')) {
items.push({ key: 'edit', label: '编辑' })
}
return items
})
// 处理表格行右键菜单
const handleRowContextMenu = (row: AuthorizationItem, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'edit':
showRemarkDialog(currentRow.value)
break
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.authorization-records-page { .authorization-records-page {
// Authorization records page styles // Authorization records page styles
} }
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style> </style>

View File

@@ -5,12 +5,12 @@
v-model="createDrawerVisible" v-model="createDrawerVisible"
title="批量订购套餐" title="批量订购套餐"
direction="rtl" direction="rtl"
size="520px" size="40%"
:close-on-click-modal="!createDrawerBusy" :close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy" :close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed" @closed="handleCreateDrawerClosed"
> >
<ElForm ref="formRef" :model="form" :rules="rules" label-width="90px" class="create-form"> <ElForm ref="formRef" :model="form" :rules="rules" label-width="100px" class="create-form">
<ElFormItem label="套餐系列" prop="series_id"> <ElFormItem label="套餐系列" prop="series_id">
<ElSelect <ElSelect
v-model="form.series_id" v-model="form.series_id"

View File

@@ -5,12 +5,12 @@
v-model="createDrawerVisible" v-model="createDrawerVisible"
title="设备批量任务" title="设备批量任务"
direction="rtl" direction="rtl"
size="520px" size="40%"
:close-on-click-modal="!createDrawerBusy" :close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy" :close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed" @closed="handleCreateDrawerClosed"
> >
<ElForm ref="formRef" :model="form" label-width="110px" class="create-form"> <ElForm ref="formRef" :model="form" label-width="80px" class="create-form">
<ElFormItem label="操作类型" prop="operation_type"> <ElFormItem label="操作类型" prop="operation_type">
<ElSelect <ElSelect
v-model="form.operation_type" v-model="form.operation_type"

View File

@@ -31,8 +31,6 @@
:pageSize="pagination.pageSize" :pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
@@ -429,74 +427,13 @@
</div> </div>
</template> </template>
</ElDialog> </ElDialog>
<!-- 店铺实际信用额度调整弹框 -->
<ElDialog
v-model="creditDialogVisible"
:title="`调整实际信用额度 - ${currentCreditShop?.shop_name || ''}`"
width="520px"
@closed="resetCreditDialog"
>
<ElDescriptions :column="1" border class="credit-preview">
<ElDescriptionsItem label="店铺名称">
{{ currentCreditShop?.shop_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前版本">
{{ currentCreditShop?.version ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="修改前">
{{
formatCreditPreview(currentCreditShop?.credit_enabled, currentCreditShop?.credit_limit)
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="修改后">
{{ formatCreditPreview(creditForm.credit_enabled, creditFormCreditLimitFen) }}
</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
title="实际可用金额、欠款金额和欠款状态以后端刷新后的资金概况为准。"
type="info"
:closable="false"
show-icon
class="credit-dialog-alert"
/>
<ElForm ref="creditFormRef" :model="creditForm" :rules="creditRules" label-width="110px">
<ElFormItem label="启用信用">
<ElSwitch v-model="creditForm.credit_enabled" @change="handleCreditEnabledChange" />
</ElFormItem>
<ElFormItem label="实际信用额度" prop="credit_limit_yuan">
<ElInputNumber
v-model="creditForm.credit_limit_yuan"
:disabled="!creditForm.credit_enabled"
:min="0"
:precision="2"
:step="100"
controls-position="right"
style="width: 100%"
placeholder="请输入实际信用额度"
/>
<div class="credit-dialog-tip">单位关闭信用时额度将自动归零</div>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="creditDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="creditSubmitting" @click="handleCreditSubmit">
确认调整
</ElButton>
</div>
</template>
</ElDialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h, watch, onBeforeUnmount, ref, reactive, onMounted, computed, nextTick } from 'vue' import { h, watch, onBeforeUnmount, ref, reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { CommissionService, ShopService } from '@/api/modules' import { CommissionService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus' import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { MainWalletTransactionType } from '@/types/api/commission' import { MainWalletTransactionType } from '@/types/api/commission'
@@ -512,7 +449,7 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue' import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { fenToYuan, formatDateTime, formatMoney, yuanToFen } from '@/utils/business/format' import { formatDateTime, formatMoney } from '@/utils/business/format'
import { import {
CommissionStatusMap, CommissionStatusMap,
WithdrawalStatusMap, WithdrawalStatusMap,
@@ -520,7 +457,6 @@
CommissionSourceMap CommissionSourceMap
} from '@/config/constants/commission' } from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { normalizeApiError } from '@/utils/business/apiError'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
defineOptions({ name: 'AgentCommission' }) defineOptions({ name: 'AgentCommission' })
@@ -584,20 +520,6 @@
remark: '' remark: ''
}) })
// 实际信用额度调整弹框状态
const creditDialogVisible = ref(false)
const creditFormRef = ref<FormInstance>()
const creditSubmitting = ref(false)
const currentCreditShop = ref<ShopFundSummaryItem | null>(null)
const creditForm = reactive({
credit_enabled: false,
credit_limit_yuan: 0
})
const creditFormCreditLimitFen = computed(() =>
creditForm.credit_enabled ? yuanToFen(creditForm.credit_limit_yuan) || 0 : 0
)
// 佣金修正表单验证规则 // 佣金修正表单验证规则
const resolveRules = computed<FormRules>(() => ({ const resolveRules = computed<FormRules>(() => ({
amount: amount:
@@ -615,36 +537,6 @@
remark: [{ max: 500, message: '备注最多500字符', trigger: 'blur' }] remark: [{ max: 500, message: '备注最多500字符', trigger: 'blur' }]
})) }))
const creditRules = computed<FormRules>(() => ({
credit_limit_yuan: [
{
validator: (
_rule: unknown,
value: number | undefined,
callback: (error?: Error) => void
) => {
if (!creditForm.credit_enabled) {
callback()
return
}
if (value === undefined || value === null || Number.isNaN(value)) {
callback(new Error('请输入实际信用额度'))
return
}
if (value <= 0) {
callback(new Error('实际信用额度必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
}))
// 预充值钱包流水状态 // 预充值钱包流水状态
const mainWalletLoading = ref(false) const mainWalletLoading = ref(false)
const mainWalletTableRef = ref() const mainWalletTableRef = ref()
@@ -987,120 +879,6 @@
}) })
// 获取操作按钮 // 获取操作按钮
const getActions = (row: ShopFundSummaryItem) => {
const actions: any[] = []
if (hasAuth('shop:credit_limit_manage')) {
actions.push({
label: '调整额度',
handler: () => showCreditDialog(row),
type: 'primary'
})
}
return actions
}
const formatCreditPreview = (enabled?: boolean, creditLimit?: number) => {
return enabled ? `启用 / ${formatMoney(creditLimit || 0)}` : '关闭 / ¥0.00'
}
const showCreditDialog = (row: ShopFundSummaryItem) => {
currentCreditShop.value = row
creditForm.credit_enabled = Boolean(row.credit_enabled)
creditForm.credit_limit_yuan = row.credit_enabled ? fenToYuan(row.credit_limit) : 0
creditDialogVisible.value = true
nextTick(() => {
creditFormRef.value?.clearValidate()
})
}
const handleCreditEnabledChange = (enabled: boolean | string | number) => {
if (!enabled) {
creditForm.credit_limit_yuan = 0
creditFormRef.value?.clearValidate('credit_limit_yuan')
}
}
const resetCreditDialog = () => {
creditFormRef.value?.resetFields()
currentCreditShop.value = null
creditForm.credit_enabled = false
creditForm.credit_limit_yuan = 0
}
const isCreditConflictMessage = (message?: string) => {
if (!message) return false
return /版本|冲突|并发|过期|conflict/i.test(message)
}
const isCreditConflictResponse = (response: any) => {
return response?.code === 409 || isCreditConflictMessage(response?.msg)
}
const isCreditConflictError = (error: any) => {
return (
error?.response?.status === 409 ||
error?.response?.data?.code === 409 ||
isCreditConflictMessage(error?.response?.data?.msg || error?.message)
)
}
const handleCreditConflict = async () => {
const shopId = currentCreditShop.value?.shop_id
await getTableData()
if (shopId) {
const latest = summaryList.value.find((item) => item.shop_id === shopId)
if (latest) {
// 仅刷新后端版本和当前余额,保留用户刚填写的表单值,方便确认后重试。
currentCreditShop.value = latest
}
}
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
}
const handleCreditSubmit = async () => {
if (!creditFormRef.value || !currentCreditShop.value) return
await creditFormRef.value.validate(async (valid) => {
if (!valid || !currentCreditShop.value) return
creditSubmitting.value = true
try {
const res = await ShopService.updateShopCreditLimit(currentCreditShop.value.shop_id, {
credit_enabled: creditForm.credit_enabled,
credit_limit: creditFormCreditLimitFen.value,
version: currentCreditShop.value.version
})
if (res.code === 0) {
ElMessage.success('实际信用额度调整成功')
creditDialogVisible.value = false
await getTableData()
return
}
if (isCreditConflictResponse(res)) {
await handleCreditConflict()
return
}
ElMessage.error(res.msg || '实际信用额度调整失败')
} catch (error: any) {
if (isCreditConflictError(error)) {
await handleCreditConflict()
} else {
console.error('实际信用额度调整失败:', error)
ElMessage.error(normalizeApiError(error).message)
}
} finally {
creditSubmitting.value = false
}
})
}
// 监听tab切换 // 监听tab切换
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
if (newTab === 'commission') { if (newTab === 'commission') {
@@ -1379,21 +1157,6 @@
} }
} }
.credit-preview {
margin-bottom: 16px;
}
.credit-dialog-alert {
margin-bottom: 16px;
}
.credit-dialog-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
@media (width <= 1200px) { @media (width <= 1200px) {
.main-wallet-filter__grid { .main-wallet-filter__grid {
grid-template-columns: repeat(2, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(220px, 1fr));

View File

@@ -120,7 +120,11 @@
<ElEmpty v-if="filteredAvailableRoles.length === 0" description="暂无角色" /> <ElEmpty v-if="filteredAvailableRoles.length === 0" description="暂无角色" />
<template v-else-if="isPlatformUser"> <template v-else-if="isPlatformUser">
<ElCheckboxGroup v-model="rolesToAdd" class="role-list"> <ElCheckboxGroup v-model="rolesToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="getRoleId(role)" class="role-item"> <div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElCheckbox <ElCheckbox
:label="getRoleId(role)" :label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))" :disabled="selectedRoles.includes(getRoleId(role))"
@@ -137,7 +141,11 @@
</template> </template>
<template v-else> <template v-else>
<ElRadioGroup v-model="roleToAdd" class="role-list"> <ElRadioGroup v-model="roleToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="getRoleId(role)" class="role-item"> <div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElRadio <ElRadio
:label="getRoleId(role)" :label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))" :disabled="selectedRoles.includes(getRoleId(role))"
@@ -404,6 +412,22 @@
}) })
} }
baseColumns.push(
{
prop: 'wecom_bound',
label: '企微绑定',
formatter: (row: PlatformAccount) =>
h(ElTag, { type: row.wecom_bound ? 'success' : 'info' }, () =>
row.wecom_bound ? '已绑定' : '未绑定'
)
},
{
prop: 'wecom_name',
label: '企微用户名称',
formatter: (row: PlatformAccount) => h('span', row.wecom_name || '-')
}
)
// 添加状态和创建时间 // 添加状态和创建时间
baseColumns.push( baseColumns.push(
{ {

View File

@@ -53,6 +53,7 @@ export const buildAgentRechargeActions = (
if ( if (
!hasApprovalRecord && !hasApprovalRecord &&
row.status === AgentRechargeStatus.PENDING && row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' &&
options.hasAuth('agent_recharge:reject') options.hasAuth('agent_recharge:reject')
) { ) {
actions.push({ actions.push({

View File

@@ -0,0 +1,23 @@
import { normalizeApiError } from '@/utils/business/apiError'
import type { AgentRechargeStatus } from '@/types/api'
export const ONLINE_RECHARGE_TERMINAL_STATUSES: AgentRechargeStatus[] = [3, 4, 5, 6]
export const isOnlineRechargeTerminal = (status: AgentRechargeStatus): boolean => {
return ONLINE_RECHARGE_TERMINAL_STATUSES.includes(status)
}
export const createOnlineRechargeRequestId = (): string => {
const randomId = globalThis.crypto?.randomUUID?.()
const suffix = randomId || Math.random().toString(36).slice(2, 12)
return `recharge-${Date.now()}-${suffix}`
}
export const amountYuanToFen = (amount: number): number => {
return Math.round(amount * 100)
}
export const shouldRetryOnlineCreate = (error: unknown): boolean => {
const kind = normalizeApiError(error).kind
return kind === 'timeout' || kind === 'server' || kind === 'unknown'
}

View File

@@ -78,11 +78,19 @@
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => { const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
const methodMap: Record<AgentRechargePaymentMethod, string> = { const methodMap: Record<AgentRechargePaymentMethod, string> = {
wechat: '微信在线支付', wechat: '微信在线支付',
alipay: '支付宝在线支付',
offline: '线下转账' offline: '线下转账'
} }
return methodMap[method] || method return methodMap[method] || method
} }
const getRechargeSourceText = (data: AgentRecharge): string => {
if (data.recharge_source_name) return data.recharge_source_name
if (data.recharge_source === 'agent_online') return '代理在线自充'
if (data.recharge_source === 'platform_offline') return '平台线下代充'
return '-'
}
const getApprovalProviderText = (data: AgentRecharge) => { const getApprovalProviderText = (data: AgentRecharge) => {
if (data.approval_provider === 'wecom' || data.approval_source === 'wecom') return '企微' if (data.approval_provider === 'wecom' || data.approval_source === 'wecom') return '企微'
if (data.approval_source === 'legacy') return '历史审批' if (data.approval_source === 'legacy') return '历史审批'
@@ -90,12 +98,20 @@
} }
// 详情配置 // 详情配置
const detailSections = computed((): DetailSection[] => [ const detailSections = computed((): DetailSection[] => {
if (!detailData.value) return []
const isOfflineRecharge =
detailData.value.recharge_source === 'platform_offline' ||
detailData.value.payment_method === 'offline'
const sections: DetailSection[] = [
{ {
title: '订单信息', title: '订单信息',
fields: [ fields: [
{ label: '充值单号', prop: 'recharge_no' }, { label: '充值单号', prop: 'recharge_no' },
{ label: '店铺名称', prop: 'shop_name' }, { label: '店铺名称', prop: 'shop_name' },
{ label: '充值来源', formatter: (_, data) => getRechargeSourceText(data) },
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' }, { label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
{ {
label: '充值金额', label: '充值金额',
@@ -114,21 +130,29 @@
} }
] ]
}, },
...(isOfflineRecharge
? [
{ {
title: '企微审批信息', title: '企微审批信息',
fields: [ fields: [
{ label: '审批渠道', formatter: (_, data) => getApprovalProviderText(data) }, {
label: '审批渠道',
formatter: (_: unknown, data: AgentRecharge) => getApprovalProviderText(data)
},
{ {
label: '审批状态', label: '审批状态',
formatter: (_, data) => data.approval_status_name || '-' formatter: (_: unknown, data: AgentRecharge) => data.approval_status_name || '-'
}, },
{ {
label: '当前审批人摘要', label: '当前审批人摘要',
formatter: (_, data) => data.current_approver_summary || '-', formatter: (_: unknown, data: AgentRecharge) =>
data.current_approver_summary || '-',
fullWidth: true fullWidth: true
} }
] ]
}, }
]
: []),
{ {
title: '业务处理结果', title: '业务处理结果',
fields: [ fields: [
@@ -156,10 +180,18 @@
formatter: (value) => value || '-', formatter: (value) => value || '-',
fullWidth: true fullWidth: true
}, },
{
label: '支付单号',
prop: 'payment_no',
formatter: (value) => value || '-',
fullWidth: true
},
...(isOfflineRecharge
? [
{ {
label: '支付凭证', label: '支付凭证',
fullWidth: true, fullWidth: true,
render: (data) => render: (data: AgentRecharge) =>
hasVoucherKeys(data.payment_voucher_key) hasVoucherKeys(data.payment_voucher_key)
? h( ? h(
ElButton, ElButton,
@@ -167,13 +199,17 @@
type: 'primary', type: 'primary',
link: true, link: true,
onClick: () => { onClick: () => {
paymentVoucherFileKeys.value = toVoucherKeyList(data.payment_voucher_key) paymentVoucherFileKeys.value = toVoucherKeyList(
data.payment_voucher_key
)
} }
}, },
() => '查看支付凭证' () => '查看支付凭证'
) )
: h('span', '-') : h('span', '-')
}, }
]
: []),
{ {
label: '运营备注', label: '运营备注',
prop: 'remark', prop: 'remark',
@@ -207,7 +243,10 @@
} }
] ]
} }
]) ]
return sections
})
// 加载详情数据 // 加载详情数据
const loadDetailData = async () => { const loadDetailData = async () => {

View File

@@ -21,8 +21,8 @@
<ElButton <ElButton
type="primary" type="primary"
@click="showCreateDialog" @click="showCreateDialog"
v-if="hasAuth('agent_recharge:create')" v-if="hasAuth('agent_recharge:create') && canCreateRecharge"
>创建充值订单</ElButton >{{ createButtonLabel }}</ElButton
> >
<ElButton v-if="hasAuth('agent_recharge:export')" @click="exportDialogVisible = true"> <ElButton v-if="hasAuth('agent_recharge:export')" @click="exportDialogVisible = true">
导出 导出
@@ -54,7 +54,7 @@
<!-- 创建充值订单对话框 --> <!-- 创建充值订单对话框 -->
<ElDialog <ElDialog
v-model="createDialogVisible" v-model="createDialogVisible"
title="创建充值订单" :title="createDialogTitle"
width="500px" width="500px"
@closed="handleCreateDialogClosed" @closed="handleCreateDialogClosed"
> >
@@ -62,14 +62,17 @@
<ElFormItem label="充值金额" prop="amount"> <ElFormItem label="充值金额" prop="amount">
<ElInputNumber <ElInputNumber
v-model="createForm.amount" v-model="createForm.amount"
:min="0.01" :min="minimumAmountYuan"
:max="maximumAmountYuan"
:precision="2" :precision="2"
:step="0.01" :step="0.01"
style="width: 100%" style="width: 100%"
placeholder="请输入充值金额(元)" placeholder="请输入充值金额(元)"
/> />
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)"> <div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
最小: ¥0.01最大: ¥1,000,000.00 金额范围¥{{ minimumAmountYuan.toFixed(2) }} - ¥{{
maximumAmountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
}}
</div> </div>
</ElFormItem> </ElFormItem>
<ElFormItem label="支付方式" prop="payment_method"> <ElFormItem label="支付方式" prop="payment_method">
@@ -77,11 +80,27 @@
v-model="createForm.payment_method" v-model="createForm.payment_method"
placeholder="请选择支付方式" placeholder="请选择支付方式"
style="width: 100%" style="width: 100%"
:loading="paymentMethodsLoading"
> >
<ElOption label="线下转账" value="offline" /> <ElOption
v-for="option in availablePaymentMethodOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect> </ElSelect>
<div
v-if="
createMode === 'online' &&
!paymentMethodsLoading &&
onlinePaymentMethods.length === 0
"
class="online-recharge-empty-hint"
>
当前暂无可用在线支付方式,暂时无法提交充值。
</div>
</ElFormItem> </ElFormItem>
<ElFormItem label="店铺" prop="shop_id"> <ElFormItem v-if="createMode === 'offline'" label="店铺" prop="shop_id">
<ElCascader <ElCascader
v-model="createForm.shop_id" v-model="createForm.shop_id"
:options="shopCascadeOptions" :options="shopCascadeOptions"
@@ -106,7 +125,7 @@
@change="createFormRef?.validateField('payment_voucher_key')" @change="createFormRef?.validateField('payment_voucher_key')"
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="运营备注" prop="remark"> <ElFormItem v-if="createMode === 'offline'" label="运营备注" prop="remark">
<ElInput <ElInput
v-model="createForm.remark" v-model="createForm.remark"
type="textarea" type="textarea"
@@ -123,15 +142,54 @@
<ElButton <ElButton
type="primary" type="primary"
@click="handleCreateRecharge" @click="handleCreateRecharge"
:loading="createLoading || voucherUploading" :loading="createLoading || voucherUploading || paymentMethodsLoading"
:disabled="voucherUploading" :disabled="createSubmitDisabled"
> >
{{ voucherUploading ? '凭证上传中...' : '确认创建' }} {{ voucherUploading ? '凭证上传中...' : createButtonLabel }}
</ElButton> </ElButton>
</div> </div>
</template> </template>
</ElDialog> </ElDialog>
<!-- 在线充值二维码对话框 -->
<ElDialog
v-model="onlineQrDialogVisible"
title="扫码完成充值"
width="430px"
align-center
@closed="handleQrDialogClosed"
>
<div class="online-recharge-qr-dialog">
<QrcodeVue
v-if="qrContent"
:value="qrContent"
:size="240"
level="H"
render-as="canvas"
/>
<ElEmpty v-else description="暂未获取到支付二维码" />
<div v-if="onlineQrRecharge" class="online-recharge-qr-dialog__summary">
<div>充值单号:{{ onlineQrRecharge.recharge_no || '-' }}</div>
<div>充值金额:{{ formatCurrency(onlineQrRecharge.amount) }}</div>
<div v-if="onlineWalletBalance !== null">
当前主钱包余额:{{ formatCurrency(onlineWalletBalance) }}
</div>
<ElTag :type="getStatusType(onlinePaymentStatus?.status || onlineQrRecharge.status)">
{{ onlinePaymentStatus?.status_name || onlineQrRecharge.status_name || '-' }}
</ElTag>
<div class="online-recharge-qr-dialog__hint">
{{ onlinePaymentStatusMessage }}
</div>
<div v-if="paymentStatusLoading" class="online-recharge-qr-dialog__loading">
正在查询支付状态...
</div>
</div>
</div>
<template #footer>
<ElButton @click="onlineQrDialogVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- 导出任务对话框 --> <!-- 导出任务对话框 -->
<ExportTaskCreateDialog <ExportTaskCreateDialog
v-model="exportDialogVisible" v-model="exportDialogVisible"
@@ -224,7 +282,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { AgentRechargeService, ShopService } from '@/api/modules' import QrcodeVue from 'qrcode.vue'
import { AgentRechargeService, CommissionService, ShopService } from '@/api/modules'
import { import {
ElMessage, ElMessage,
ElTag, ElTag,
@@ -240,7 +299,9 @@
AgentRecharge, AgentRecharge,
AgentRechargeQueryParams, AgentRechargeQueryParams,
AgentRechargeStatus, AgentRechargeStatus,
AgentRechargeOnlinePaymentMethod,
AgentRechargePaymentMethod, AgentRechargePaymentMethod,
AgentRechargePaymentStatusResponse,
CreateAgentRechargeRequest, CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest, ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest RejectAgentRechargeRequest
@@ -248,6 +309,7 @@
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { import {
getApprovalStatusText, getApprovalStatusText,
getCurrentApproverSummaryText, getCurrentApproverSummaryText,
@@ -255,16 +317,36 @@
} from '@/utils/business/approvalSummary' } from '@/utils/business/approvalSummary'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { buildAgentRechargeActions } from './agentRechargeActions' import { buildAgentRechargeActions } from './agentRechargeActions'
import { formatRejectionReason } from './agentRechargeDisplay' import { formatRejectionReason } from './agentRechargeDisplay'
import {
amountYuanToFen,
createOnlineRechargeRequestId,
isOnlineRechargeTerminal,
shouldRetryOnlineCreate
} from './agentRechargeOnline'
defineOptions({ name: 'AgentRechargeList' }) defineOptions({ name: 'AgentRechargeList' })
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const userStore = useUserStore()
const isAgentAccount = computed(() => Number(userStore.info.user_type) === 3)
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const canViewRecharge = computed(() => [1, 2, 3].includes(Number(userStore.info.user_type)))
const canCreateRecharge = computed(() => isAgentAccount.value || isPlatformAccount.value)
const createMode = ref<'online' | 'offline'>('offline')
const createDialogTitle = computed(() =>
createMode.value === 'online' ? '代理钱包在线扫码充值' : '创建平台线下代充'
)
const createButtonLabel = computed(() =>
createMode.value === 'online' ? '立即充值' : '创建充值订单'
)
const loading = ref(false) const loading = ref(false)
const createLoading = ref(false) const createLoading = ref(false)
@@ -276,13 +358,29 @@
const exportDialogVisible = ref(false) const exportDialogVisible = ref(false)
const confirmPayDialogVisible = ref(false) const confirmPayDialogVisible = ref(false)
const rejectDialogVisible = ref(false) const rejectDialogVisible = ref(false)
const onlineQrDialogVisible = ref(false)
const currentRecharge = ref<AgentRecharge | null>(null) const currentRecharge = ref<AgentRecharge | null>(null)
const onlineQrRecharge = ref<AgentRecharge | null>(null)
const onlinePaymentStatus = ref<AgentRechargePaymentStatusResponse | null>(null)
const onlineWalletBalance = ref<number | null>(null)
const onlinePaymentMethods = ref<AgentRechargeOnlinePaymentMethod[]>([])
const paymentMethodsLoading = ref(false)
const paymentStatusLoading = ref(false)
const qrContent = ref('')
const onlineRequestId = ref<string | null>(null)
const onlineCreateRetried = ref(false)
const paymentStatusTimer = ref<ReturnType<typeof setInterval> | null>(null)
const paymentMethodsBounds = reactive({
min_amount: 10000,
max_amount: 100000000
})
const paymentVoucherFileKeys = ref<string[]>([]) const paymentVoucherFileKeys = ref<string[]>([])
// 搜索表单初始值 // 搜索表单初始值
const initialSearchState: AgentRechargeQueryParams = { const initialSearchState: AgentRechargeQueryParams = {
shop_id: undefined, shop_id: undefined,
status: undefined, status: undefined,
recharge_source: undefined,
dateRange: [], dateRange: [],
start_date: '', start_date: '',
end_date: '' end_date: ''
@@ -325,8 +423,11 @@
} }
// 搜索表单配置 // 搜索表单配置
const searchFormItems: SearchFormItem[] = [ const searchFormItems = computed<SearchFormItem[]>(() => {
{ const items: SearchFormItem[] = []
if (isPlatformAccount.value) {
items.push({
label: '店铺名称', label: '店铺名称',
prop: 'shop_id', prop: 'shop_id',
type: 'select', type: 'select',
@@ -342,9 +443,12 @@
remote: true, remote: true,
remoteMethod: (query: string) => searchShops(query) remoteMethod: (query: string) => searchShops(query)
} }
}, })
}
items.push(
{ {
label: '支付状态', label: '充值状态',
prop: 'status', prop: 'status',
type: 'select', type: 'select',
placeholder: '请选择状态', placeholder: '请选择状态',
@@ -356,9 +460,18 @@
{ label: '已退款', value: 5 }, { label: '已退款', value: 5 },
{ label: '已驳回', value: 6 } { label: '已驳回', value: 6 }
], ],
config: { config: { clearable: true }
clearable: true },
} {
label: '充值来源',
prop: 'recharge_source',
type: 'select',
placeholder: '请选择充值来源',
options: [
{ label: '代理在线自充', value: 'agent_online' },
{ label: '平台线下代充', value: 'platform_offline' }
],
config: { clearable: true }
}, },
{ {
label: '起止时间', label: '起止时间',
@@ -372,7 +485,10 @@
valueFormat: 'YYYY-MM-DD' valueFormat: 'YYYY-MM-DD'
} }
} }
] )
return items
})
// 分页 // 分页
const pagination = reactive({ const pagination = reactive({
@@ -385,6 +501,7 @@
const columnOptions = [ const columnOptions = [
{ label: '充值单号', prop: 'recharge_no' }, { label: '充值单号', prop: 'recharge_no' },
{ label: '店铺名称', prop: 'shop_name' }, { label: '店铺名称', prop: 'shop_name' },
{ label: '充值来源', prop: 'recharge_source' },
{ label: '充值金额', prop: 'amount' }, { label: '充值金额', prop: 'amount' },
{ label: '状态', prop: 'status' }, { label: '状态', prop: 'status' },
{ label: '审批渠道', prop: 'approval_provider' }, { label: '审批渠道', prop: 'approval_provider' },
@@ -406,8 +523,43 @@
const confirmPayFormRef = ref<FormInstance>() const confirmPayFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>() const rejectFormRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>() const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const MIN_RECHARGE_AMOUNT = 0.01 const OFFLINE_MIN_RECHARGE_AMOUNT = 0.01
const MAX_RECHARGE_AMOUNT = 1_000_000 const OFFLINE_MAX_RECHARGE_AMOUNT = 1_000_000
const minimumAmountYuan = computed(() =>
createMode.value === 'online'
? paymentMethodsBounds.min_amount / 100
: OFFLINE_MIN_RECHARGE_AMOUNT
)
const maximumAmountYuan = computed(() =>
createMode.value === 'online'
? paymentMethodsBounds.max_amount / 100
: OFFLINE_MAX_RECHARGE_AMOUNT
)
const availablePaymentMethodOptions = computed(() => {
if (createMode.value === 'offline') return [{ label: '线下转账', value: 'offline' }]
return onlinePaymentMethods.value.map((method) => ({
label: method === 'wechat' ? '微信支付' : '支付宝',
value: method
}))
})
const createSubmitDisabled = computed(
() =>
voucherUploading.value ||
paymentMethodsLoading.value ||
(createMode.value === 'online' && onlinePaymentMethods.value.length === 0)
)
const onlinePaymentStatusMessage = computed(() => {
const status = onlinePaymentStatus.value?.status ?? onlineQrRecharge.value?.status
if (status === 1) return '请使用对应支付方式扫码完成支付'
if (status === 2) return '支付已成功,钱包正在入账,请稍候'
if (status === 3) return '充值成功,钱包已到账'
if (status === 4) return '充值订单已关闭,请重新发起充值'
if (status === 5) return '充值订单已退款'
if (status === 6) return '充值订单已驳回'
return '正在获取支付状态'
})
const createRules = computed<FormRules>(() => { const createRules = computed<FormRules>(() => {
const rules: FormRules = { const rules: FormRules = {
@@ -419,12 +571,16 @@
callback() callback()
return return
} }
if (Number(value) < MIN_RECHARGE_AMOUNT) { if (Number(value) < minimumAmountYuan.value) {
callback(new Error(`充值金额最小为 ¥${MIN_RECHARGE_AMOUNT.toFixed(2)}`)) callback(new Error(`充值金额最小为 ¥${minimumAmountYuan.value.toFixed(2)}`))
return return
} }
if (Number(value) > MAX_RECHARGE_AMOUNT) { if (Number(value) > maximumAmountYuan.value) {
callback(new Error(`充值金额最大为 ¥${MAX_RECHARGE_AMOUNT.toFixed(2)}`)) callback(
new Error(
`充值金额最大为 ¥${maximumAmountYuan.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
)
)
return return
} }
callback() callback()
@@ -435,9 +591,10 @@
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }], payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
shop_id: [{ required: true, message: '请选择目标店铺', trigger: 'change' }] shop_id: [{ required: true, message: '请选择目标店铺', trigger: 'change' }]
} }
if (createForm.payment_method === 'offline') { if (createMode.value === 'offline') {
rules.payment_voucher_key = [{ required: true, message: '请上传支付凭证', trigger: 'change' }] rules.payment_voucher_key = [{ required: true, message: '请上传支付凭证', trigger: 'change' }]
} }
if (createMode.value === 'online') delete rules.shop_id
return rules return rules
}) })
@@ -452,13 +609,13 @@
}) })
const createForm = reactive<{ const createForm = reactive<{
amount: number amount: number | null
payment_method: string payment_method: AgentRechargePaymentMethod | ''
shop_id: number | null shop_id: number | null
payment_voucher_key: string[] payment_voucher_key: string[]
remark: string remark: string
}>({ }>({
amount: MIN_RECHARGE_AMOUNT, amount: OFFLINE_MIN_RECHARGE_AMOUNT,
payment_method: '', payment_method: '',
shop_id: null, shop_id: null,
payment_voucher_key: [], payment_voucher_key: [],
@@ -499,11 +656,19 @@
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => { const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
const methodMap: Record<AgentRechargePaymentMethod, string> = { const methodMap: Record<AgentRechargePaymentMethod, string> = {
wechat: '微信在线支付', wechat: '微信在线支付',
alipay: '支付宝在线支付',
offline: '线下转账' offline: '线下转账'
} }
return methodMap[method] || method return methodMap[method] || method
} }
const getRechargeSourceText = (row: AgentRecharge): string => {
if (row.recharge_source_name) return row.recharge_source_name
if (row.recharge_source === 'agent_online') return '代理在线自充'
if (row.recharge_source === 'platform_offline') return '平台线下代充'
return '-'
}
// 动态列配置 // 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ {
@@ -530,6 +695,12 @@
minWidth: 150, minWidth: 150,
showOverflowTooltip: true showOverflowTooltip: true
}, },
{
prop: 'recharge_source',
label: '充值来源',
width: 140,
formatter: (row: AgentRecharge) => getRechargeSourceText(row)
},
{ {
prop: 'amount', prop: 'amount',
label: '充值金额', label: '充值金额',
@@ -635,7 +806,17 @@
onMounted(() => { onMounted(() => {
getTableData() getTableData()
loadShops() if (isPlatformAccount.value) loadShops()
document.addEventListener('visibilitychange', handleDocumentVisibilityChange)
})
onBeforeUnmount(() => {
stopPaymentStatusPolling()
document.removeEventListener('visibilitychange', handleDocumentVisibilityChange)
})
onDeactivated(() => {
stopPaymentStatusPolling()
}) })
let isFirstActivation = true let isFirstActivation = true
@@ -643,6 +824,9 @@
if (!isFirstActivation) { if (!isFirstActivation) {
getTableData() getTableData()
} }
if (onlineQrDialogVisible.value && document.visibilityState === 'visible') {
startPaymentStatusPolling()
}
isFirstActivation = false isFirstActivation = false
}) })
@@ -693,13 +877,20 @@
// 获取充值订单列表 // 获取充值订单列表
const getTableData = async () => { const getTableData = async () => {
if (!canViewRecharge.value) {
rechargeList.value = []
pagination.total = 0
return
}
loading.value = true loading.value = true
try { try {
const params: AgentRechargeQueryParams = { const params: AgentRechargeQueryParams = {
page: pagination.page, page: pagination.page,
page_size: pagination.page_size, page_size: pagination.page_size,
shop_id: searchForm.shop_id, shop_id: isPlatformAccount.value ? searchForm.shop_id : undefined,
status: searchForm.status, status: searchForm.status,
recharge_source: searchForm.recharge_source,
start_date: searchForm.start_date || undefined, start_date: searchForm.start_date || undefined,
end_date: searchForm.end_date || undefined end_date: searchForm.end_date || undefined
} }
@@ -737,8 +928,9 @@
} }
const exportQuery = computed(() => ({ const exportQuery = computed(() => ({
shop_id: searchForm.shop_id, shop_id: isPlatformAccount.value ? searchForm.shop_id : undefined,
status: searchForm.status, status: searchForm.status,
recharge_source: searchForm.recharge_source,
start_date: searchForm.start_date || searchForm.dateRange?.[0], start_date: searchForm.start_date || searchForm.dateRange?.[0],
end_date: searchForm.end_date || searchForm.dateRange?.[1] end_date: searchForm.end_date || searchForm.dateRange?.[1]
})) }))
@@ -761,22 +953,56 @@
// 显示创建订单对话框 // 显示创建订单对话框
const showCreateDialog = async () => { const showCreateDialog = async () => {
createMode.value = isAgentAccount.value ? 'online' : 'offline'
resetCreateForm()
if (createMode.value === 'online') {
await loadPaymentMethods()
} else {
// 重新加载店铺列表,确保获取最新数据 // 重新加载店铺列表,确保获取最新数据
await loadShops() await loadShops()
createForm.payment_method = '' }
createDialogVisible.value = true createDialogVisible.value = true
} }
const resetCreateForm = () => {
createForm.amount = minimumAmountYuan.value
createForm.payment_method = ''
createForm.shop_id = null
createForm.payment_voucher_key = []
createForm.remark = ''
onlineRequestId.value = null
onlineCreateRetried.value = false
voucherUploading.value = false
uploadRef.value?.clearFiles(false)
}
// 对话框关闭后的清理 // 对话框关闭后的清理
const handleCreateDialogClosed = () => { const handleCreateDialogClosed = () => {
createFormRef.value?.resetFields() createFormRef.value?.resetFields()
createForm.amount = MIN_RECHARGE_AMOUNT resetCreateForm()
createForm.payment_method = '' }
createForm.shop_id = null
createForm.payment_voucher_key = [] // 加载代理在线充值可用支付方式
createForm.remark = '' const loadPaymentMethods = async () => {
voucherUploading.value = false paymentMethodsLoading.value = true
uploadRef.value?.clearFiles(false) onlinePaymentMethods.value = []
try {
const res = await AgentRechargeService.getPaymentMethods()
if (res.code === 0) {
onlinePaymentMethods.value = Array.isArray(res.data?.methods) ? res.data.methods : []
paymentMethodsBounds.min_amount = Number(res.data?.min_amount) || 10000
paymentMethodsBounds.max_amount = Number(res.data?.max_amount) || 100000000
createForm.amount = minimumAmountYuan.value
} else {
ElMessage.warning(res.msg || '当前暂无可用在线支付方式')
}
} catch (error) {
console.error('加载在线支付方式失败:', error)
} finally {
paymentMethodsLoading.value = false
}
} }
// 创建充值订单 // 创建充值订单
@@ -788,28 +1014,43 @@
return return
} }
await formRef.validate(async (valid) => { const valid = await formRef.validate().catch(() => false)
if (valid) { if (!valid || createForm.amount == null || !createForm.payment_method) return
createLoading.value = true createLoading.value = true
try { try {
if (createMode.value === 'online') {
await handleOnlineRechargeCreate(createForm.amount, createForm.payment_method)
return
}
if (!createForm.shop_id) {
ElMessage.warning('请选择目标店铺')
return
}
const voucherKeys = toVoucherKeyList(createForm.payment_voucher_key)
if (!hasVoucherKeys(voucherKeys)) {
ElMessage.warning('请上传支付凭证')
return
}
const data: CreateAgentRechargeRequest = { const data: CreateAgentRechargeRequest = {
amount: Math.round(createForm.amount * 100), // 元转分 amount: amountYuanToFen(createForm.amount),
payment_method: createForm.payment_method as AgentRechargePaymentMethod, payment_method: 'offline',
shop_id: createForm.shop_id!, shop_id: createForm.shop_id,
payment_voucher_key: voucherKeys,
remark: createForm.remark || undefined remark: createForm.remark || undefined
} }
if ( const res = await AgentRechargeService.createAgentRecharge(data)
createForm.payment_method === 'offline' && if (res.code !== 0) {
hasVoucherKeys(createForm.payment_voucher_key) ElMessage.error(res.msg || '充值订单创建失败')
) { return
data.payment_voucher_key = toVoucherKeyList(createForm.payment_voucher_key)
} }
await AgentRechargeService.createAgentRecharge(data)
ElMessage.success('充值订单创建成功') ElMessage.success('充值订单创建成功')
createDialogVisible.value = false createDialogVisible.value = false
formRef.resetFields()
await getTableData() await getTableData()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -817,7 +1058,155 @@
createLoading.value = false createLoading.value = false
} }
} }
const handleOnlineRechargeCreate = async (
amountYuan: number,
paymentMethod: AgentRechargePaymentMethod
) => {
if (paymentMethod === 'offline') return
const requestId = onlineRequestId.value || createOnlineRechargeRequestId()
onlineRequestId.value = requestId
onlineCreateRetried.value = false
const request: CreateAgentRechargeRequest = {
amount: amountYuanToFen(amountYuan),
payment_method: paymentMethod,
request_id: requestId
}
let attempt = 0
while (attempt < 2) {
try {
const res = await AgentRechargeService.createAgentRecharge(request)
if (res.code !== 0) {
ElMessage.error(res.msg || '在线充值创建失败')
onlineRequestId.value = null
return
}
if (!res.data?.qr_content) {
ElMessage.error('支付接口未返回有效二维码,请刷新后重试')
return
}
onlineQrRecharge.value = res.data
onlinePaymentStatus.value = null
qrContent.value = res.data.qr_content
onlineQrDialogVisible.value = true
createDialogVisible.value = false
onlineRequestId.value = null
await getTableData()
return
} catch (error) {
if (attempt === 0 && shouldRetryOnlineCreate(error)) {
attempt += 1
onlineCreateRetried.value = true
continue
}
const normalized = normalizeApiError(error)
if (normalized.kind === 'validation' || normalized.kind === 'conflict') {
ElMessage.error(normalized.message)
} else {
ElMessage.warning('在线充值请求结果未知,请勿重复提交;可关闭后刷新列表确认')
}
onlineRequestId.value = null
return
}
}
}
const stopPaymentStatusPolling = () => {
if (paymentStatusTimer.value) {
clearInterval(paymentStatusTimer.value)
paymentStatusTimer.value = null
}
}
const loadOnlinePaymentStatus = async () => {
const rechargeId = onlineQrRecharge.value?.id
if (!rechargeId || !onlineQrDialogVisible.value || document.visibilityState !== 'visible')
return
paymentStatusLoading.value = true
try {
const res = await AgentRechargeService.getPaymentStatus(rechargeId)
if (res.code !== 0) return
onlinePaymentStatus.value = res.data
if (onlineQrRecharge.value) {
onlineQrRecharge.value = {
...onlineQrRecharge.value,
status: res.data.status,
status_name: res.data.status_name || onlineQrRecharge.value.status_name,
paid_at: res.data.paid_at,
completed_at: res.data.completed_at
}
}
if (isOnlineRechargeTerminal(res.data.status)) {
stopPaymentStatusPolling()
if (res.data.status === 3) {
await Promise.all([getTableData(), refreshAgentWalletBalance()])
}
}
} catch (error) {
console.error('查询在线充值状态失败:', error)
} finally {
paymentStatusLoading.value = false
}
}
const startPaymentStatusPolling = () => {
stopPaymentStatusPolling()
void loadOnlinePaymentStatus()
paymentStatusTimer.value = setInterval(() => {
if (!onlineQrDialogVisible.value || document.visibilityState !== 'visible') {
stopPaymentStatusPolling()
return
}
void loadOnlinePaymentStatus()
}, 3000)
}
const handleDocumentVisibilityChange = () => {
if (document.visibilityState === 'visible' && onlineQrDialogVisible.value) {
startPaymentStatusPolling()
} else if (document.visibilityState === 'hidden') {
stopPaymentStatusPolling()
}
}
watch(onlineQrDialogVisible, (visible) => {
if (visible) {
startPaymentStatusPolling()
} else {
stopPaymentStatusPolling()
}
}) })
const handleQrDialogClosed = () => {
stopPaymentStatusPolling()
qrContent.value = ''
onlineQrRecharge.value = null
onlinePaymentStatus.value = null
onlineWalletBalance.value = null
paymentStatusLoading.value = false
}
const refreshAgentWalletBalance = async () => {
if (!isAgentAccount.value) return
try {
const res = await CommissionService.getShopFundSummary({ page: 1, page_size: 100 })
const currentShopId = Number(userStore.info.shop_id)
const currentShop =
res.code === 0 ? res.data.items?.find((item) => item.shop_id === currentShopId) : null
if (currentShop) onlineWalletBalance.value = currentShop.main_balance
} catch (error) {
console.error('刷新代理钱包余额失败:', error)
}
} }
// 显示确认支付对话框 // 显示确认支付对话框
@@ -935,4 +1324,38 @@
.agent-recharge-page { .agent-recharge-page {
height: 100%; height: 100%;
} }
.online-recharge-empty-hint {
margin-top: 8px;
color: var(--el-color-danger);
font-size: 12px;
line-height: 1.5;
}
.online-recharge-qr-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
&__summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
color: var(--el-text-color-regular);
font-size: 14px;
text-align: center;
}
&__hint {
color: var(--el-text-color-secondary);
}
&__loading {
color: var(--el-color-primary);
font-size: 12px;
}
}
</style> </style>

View File

@@ -727,10 +727,10 @@
package_name: '', package_name: '',
series_id: undefined, series_id: undefined,
package_type: '', package_type: '',
calendar_type: undefined, calendar_type: null,
duration_days: undefined, duration_days: undefined,
duration_months: 1, duration_months: 1,
data_reset_cycle: undefined, data_reset_cycle: null,
enable_virtual_data: false, enable_virtual_data: false,
expiry_base: 'from_activation', expiry_base: 'from_activation',
real_data_mb: 0, real_data_mb: 0,
@@ -1345,10 +1345,10 @@
form.package_name = data.package_name form.package_name = data.package_name
form.series_id = data.series_id form.series_id = data.series_id
form.package_type = data.package_type form.package_type = data.package_type
form.calendar_type = data.calendar_type || undefined form.calendar_type = data.calendar_type ?? null
form.duration_days = data.duration_days form.duration_days = data.duration_days
form.duration_months = data.duration_months form.duration_months = data.duration_months
form.data_reset_cycle = data.data_reset_cycle || undefined form.data_reset_cycle = data.data_reset_cycle ?? null
form.enable_virtual_data = data.enable_virtual_data || false form.enable_virtual_data = data.enable_virtual_data || false
form.expiry_base = data.expiry_base || 'from_activation' form.expiry_base = data.expiry_base || 'from_activation'
form.real_data_mb = data.real_data_mb || 0 form.real_data_mb = data.real_data_mb || 0
@@ -1384,10 +1384,10 @@
form.package_name = '' form.package_name = ''
form.series_id = undefined form.series_id = undefined
form.package_type = '' form.package_type = ''
form.calendar_type = undefined form.calendar_type = null
form.duration_days = undefined form.duration_days = undefined
form.duration_months = 1 form.duration_months = 1
form.data_reset_cycle = undefined form.data_reset_cycle = null
form.enable_virtual_data = false form.enable_virtual_data = false
form.expiry_base = 'from_activation' form.expiry_base = 'from_activation'
form.real_data_mb = 0 form.real_data_mb = 0
@@ -1428,10 +1428,10 @@
form.package_name = '' form.package_name = ''
form.series_id = undefined form.series_id = undefined
form.package_type = '' form.package_type = ''
form.calendar_type = undefined form.calendar_type = null
form.duration_days = undefined form.duration_days = undefined
form.duration_months = 1 form.duration_months = 1
form.data_reset_cycle = undefined form.data_reset_cycle = null
form.enable_virtual_data = false form.enable_virtual_data = false
form.expiry_base = 'from_activation' form.expiry_base = 'from_activation'
form.real_data_mb = 0 form.real_data_mb = 0
@@ -1483,6 +1483,8 @@
package_type: form.package_type, package_type: form.package_type,
duration_months: form.duration_months, duration_months: form.duration_months,
cost_price: costPriceInCents, cost_price: costPriceInCents,
calendar_type: form.calendar_type ?? null,
data_reset_cycle: form.data_reset_cycle ?? null,
is_gift: form.is_gift is_gift: form.is_gift
} }
@@ -1494,12 +1496,6 @@
if (form.series_id !== undefined) { if (form.series_id !== undefined) {
data.series_id = form.series_id data.series_id = form.series_id
} }
if (form.calendar_type) {
data.calendar_type = form.calendar_type
}
if (form.data_reset_cycle) {
data.data_reset_cycle = form.data_reset_cycle
}
// 只有 calendar_type 为 by_day 时才传 duration_days // 只有 calendar_type 为 by_day 时才传 duration_days
if (form.calendar_type === 'by_day' && form.duration_days !== undefined) { if (form.calendar_type === 'by_day' && form.duration_days !== undefined) {
data.duration_days = form.duration_days data.duration_days = form.duration_days

View File

@@ -143,45 +143,23 @@
<!-- 套餐列表 --> <!-- 套餐列表 -->
<div v-if="detailData.packages && detailData.packages.length > 0" class="package-table"> <div v-if="detailData.packages && detailData.packages.length > 0" class="package-table">
<ElTable :data="detailData.packages" border stripe> <SeriesGrantPackageTable
<ElTableColumn prop="package_name" label="套餐名称" /> :data="detailData.packages"
<ElTableColumn prop="package_code" label="套餐编码" /> :loading="loading"
<ElTableColumn label="成本价"> :show-actions="true"
<template #default="{ row }"> :actions-width="240"
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span> >
</template> <template #actions="{ row }">
</ElTableColumn> <div class="package-table-actions">
<ElTableColumn label="套餐默认生效条件"> <ElButton
<template #default="{ row }"> v-if="canUpdateExpiryBase"
{{ row.default_expiry_base_name || '-' }} type="primary"
</template> size="small"
</ElTableColumn> link
<ElTableColumn label="覆盖生效条件"> @click="showExpiryBaseDialog(row)"
<template #default="{ row }"> >
{{ row.expiry_base_override_name || '跟随套餐默认' }} 修改生效条件
</template> </ElButton>
</ElTableColumn>
<ElTableColumn label="最终生效条件">
<template #default="{ row }">
{{ row.effective_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<ElButton <ElButton
type="primary" type="primary"
size="small" size="small"
@@ -200,9 +178,9 @@
> >
删除 删除
</ElButton> </ElButton>
</div>
</template> </template>
</ElTableColumn> </SeriesGrantPackageTable>
</ElTable>
</div> </div>
<ElEmpty v-else description="暂无套餐" :image-size="80" /> <ElEmpty v-else description="暂无套餐" :image-size="80" />
</div> </div>
@@ -214,95 +192,58 @@
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<!-- 添加/编辑套餐对话框 --> <SeriesGrantPackageDialog
<ElDialog
v-model="packageDialogVisible" v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'" v-model:form="packageForm"
width="500px" :dialog-type="packageDialogType"
:close-on-click-modal="false" :rules="packageRules"
:available-packages="availablePackages"
:package-loading="packageLoading"
:submit-loading="submitLoading"
:can-update-expiry-base="canUpdateExpiryBase"
:has-series="Boolean(detailData?.series_id)"
@search="searchPackages"
@submit="handleSavePackage"
@closed="handlePackageDialogClosed" @closed="handlePackageDialogClosed"
>
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="100px">
<!-- 添加模式选择套餐 -->
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'">
<ElSelect
v-model="packageForm.package_id"
placeholder="请选择套餐"
style="width: 100%"
filterable
remote
:remote-method="searchPackages"
:loading="packageLoading"
clearable
>
<template
v-if="availablePackages.length === 0 && !packageLoading && detailData?.series_id"
>
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in availablePackages"
:key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id"
:disabled="pkg.is_authorized"
/> />
</ElSelect>
</ElFormItem>
<!-- 编辑模式显示套餐信息 --> <ElDialog
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'"> v-model="expiryDialogVisible"
<span>{{ packageForm.package_name }}</span> title="修改生效条件"
</ElFormItem> width="460px"
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'"> :close-on-click-modal="false"
<span>{{ packageForm.package_code }}</span>
</ElFormItem>
<!-- 成本价 -->
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价: ¥{{
packageForm.original_cost_price.toFixed(2)
}}
</div>
</ElFormItem>
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect
v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件"
style="width: 100%"
> >
<ElForm label-width="130px">
<ElFormItem label="套餐名称">
<span class="ellipsis-value" :title="expiryForm.package_name || ''">
{{ expiryForm.package_name || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐编码">
<span class="ellipsis-value" :title="expiryForm.package_code || ''">
{{ expiryForm.package_code || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐默认生效条件">
{{ expiryForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
<ElSelect v-model="expiryForm.expiry_base_override" style="width: 100%">
<ElOption label="跟随套餐默认" value="default" /> <ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" /> <ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" /> <ElOption label="实名激活时生效" value="from_activation" />
</ElSelect> </ElSelect>
<div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElFormItem>
<template v-if="packageDialogType === 'edit'">
<ElFormItem label="套餐默认生效条件">
{{ packageForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
{{ packageForm.expiry_base_override_name || '跟随套餐默认' }}
</ElFormItem> </ElFormItem>
<ElFormItem label="最终生效条件"> <ElFormItem label="最终生效条件">
{{ packageForm.effective_expiry_base_name || '-' }} {{ expiryForm.effective_expiry_base_name || '-' }}
</ElFormItem> </ElFormItem>
</template> <div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElForm> </ElForm>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton> <ElButton @click="expiryDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="submitLoading"> <ElButton type="primary" :loading="expirySubmitLoading" @click="saveExpiryBase">
保存 保存
</ElButton> </ElButton>
</div> </div>
@@ -330,10 +271,8 @@
ElDialog, ElDialog,
ElSelect, ElSelect,
ElOption, ElOption,
ElInputNumber,
ElForm, ElForm,
ElFormItem, ElFormItem,
FormInstance,
FormRules FormRules
} from 'element-plus' } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue' import { ArrowLeft, Loading } from '@element-plus/icons-vue'
@@ -354,9 +293,13 @@
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText, JULY_PERMISSIONS } from '@/config/constants' import { getEnableStatusText, JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import SeriesGrantPackageTable from '@/components/business/SeriesGrantPackageTable.vue'
import SeriesGrantPackageDialog from '@/components/business/SeriesGrantPackageDialog.vue'
import { import {
mergeGrantPackageCandidates, mergeGrantPackageCandidates,
type GrantPackageCandidate type GrantPackageCandidate,
type PackageCostFormItem,
type SeriesGrantPackageForm
} from '@/utils/business/seriesGrantPackage' } from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantsDetail' }) defineOptions({ name: 'SeriesGrantsDetail' })
@@ -374,61 +317,79 @@
const packageDialogVisible = ref(false) const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add') const packageDialogType = ref<'add' | 'edit'>('add')
const availablePackages = ref<GrantPackageCandidate[]>([]) const availablePackages = ref<GrantPackageCandidate[]>([])
const packageFormRef = ref<FormInstance>() const expiryDialogVisible = ref(false)
const expirySubmitLoading = ref(false)
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default' type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
// 套餐表单 type ExpiryFormState = {
const packageForm = ref<{
package_id?: number package_id?: number
allocation_id?: number allocation_id?: number
package_name?: string package_name?: string
package_code?: string package_code?: string
original_cost_price?: number
cost_price_yuan: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null default_expiry_base_name?: string | null
expiry_base_override: ExpiryBaseSelection
expiry_base_override_name?: string | null expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null effective_expiry_base_name?: string | null
}>({ }
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const createDefaultPackageForm = (): SeriesGrantPackageForm => ({
package_id: undefined,
allocation_id: undefined,
package_ids: [],
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0, cost_price_yuan: 0,
expiry_base_override: 'default', expiry_base_override: 'default',
initial_expiry_base_override: 'default' initial_expiry_base_override: 'default',
packages: []
}) })
const packageForm = ref<SeriesGrantPackageForm>(createDefaultPackageForm())
// 表单验证规则 // 表单验证规则
const packageRules = computed<FormRules>(() => ({ const packageRules = computed<FormRules>(() => ({
package_id: [ package_ids: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' } {
validator: (_rule, value, callback) => {
if (packageDialogType.value !== 'add') {
callback()
return
}
if (!Array.isArray(value) || value.length === 0) {
callback(new Error('请至少选择一个套餐'))
} else if (value.length > 100) {
callback(new Error('最多选择100个套餐'))
} else {
callback()
}
},
trigger: 'change'
}
], ],
cost_price_yuan: [ cost_price_yuan: [
{ required: true, message: '请输入成本价', trigger: 'blur' }, {
{ type: 'number', min: 0, message: '成本价不能小于0', trigger: 'blur' } required: packageDialogType.value === 'edit',
message: '请输入成本价',
trigger: 'blur'
},
{
validator: (_rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (value < 0) {
callback(new Error('成本价不能小于0'))
} else {
callback()
}
},
trigger: 'blur'
}
] ]
})) }))
// 梯度配置表格数据(合并授权数据和系列数据) // 梯度配置表格数据(合并授权数据和系列数据)
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value !== 'add' || !packageId) return
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
if (!selectedPackage) return
const originalCostPrice =
selectedPackage.cost_price !== undefined && selectedPackage.cost_price !== null
? selectedPackage.cost_price / 100
: 0
packageForm.value.package_name = selectedPackage.package_name
packageForm.value.package_code = selectedPackage.package_code
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
)
const tierTableData = computed(() => { const tierTableData = computed(() => {
if ( if (
!detailData.value?.commission_tiers || !detailData.value?.commission_tiers ||
@@ -507,19 +468,31 @@
} }
} }
// 显示添加套餐对话框
const assignPackageForm = (values: Partial<SeriesGrantPackageForm> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
const resolvePackagePricing = (row?: GrantPackageInfo) => {
const pkgOption = availablePackages.value.find((pkg) => pkg.id === row?.package_id)
const currentCostPrice = row?.cost_price !== undefined ? row.cost_price / 100 : 0
return {
original_cost_price:
pkgOption?.cost_price !== undefined && pkgOption.cost_price !== null
? pkgOption.cost_price / 100
: currentCostPrice,
suggested_retail_price:
pkgOption?.suggested_retail_price !== undefined && pkgOption.suggested_retail_price !== null
? pkgOption.suggested_retail_price / 100
: undefined
}
}
// 显示添加套餐对话框 // 显示添加套餐对话框
const showAddPackageDialog = () => { const showAddPackageDialog = () => {
packageDialogType.value = 'add' packageDialogType.value = 'add'
packageForm.value = { assignPackageForm({ package_ids: [], packages: [] })
package_id: undefined,
allocation_id: undefined,
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
}
// 加载可用套餐 // 加载可用套餐
if (detailData.value?.series_id) { if (detailData.value?.series_id) {
@@ -532,23 +505,44 @@
// 显示编辑套餐对话框 // 显示编辑套餐对话框
const showEditPackageDialog = (row: GrantPackageInfo) => { const showEditPackageDialog = (row: GrantPackageInfo) => {
packageDialogType.value = 'edit' packageDialogType.value = 'edit'
packageForm.value = { const packagePricing = resolvePackagePricing(row)
assignPackageForm({
package_id: row.package_id, package_id: row.package_id,
allocation_id: row.allocation_id, allocation_id: row.allocation_id,
package_name: row.package_name, package_name: row.package_name,
package_code: row.package_code, package_code: row.package_code,
original_cost_price: row.cost_price / 100, original_cost_price: packagePricing.original_cost_price,
cost_price_yuan: row.cost_price / 100, cost_price_yuan: row.cost_price / 100,
suggested_retail_price: packagePricing.suggested_retail_price,
expiry_base_override: row.expiry_base_override ?? 'default', expiry_base_override: row.expiry_base_override ?? 'default',
initial_expiry_base_override: row.expiry_base_override ?? 'default', initial_expiry_base_override: row.expiry_base_override ?? 'default',
default_expiry_base_name: row.default_expiry_base_name, default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override_name: row.expiry_base_override_name, expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name effective_expiry_base_name: row.effective_expiry_base_name
} })
packageDialogVisible.value = true packageDialogVisible.value = true
} }
const showExpiryBaseDialog = (row: GrantPackageInfo) => {
if (row.allocation_id === undefined || row.allocation_id === null) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
expiryForm.value = {
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override: row.expiry_base_override ?? 'default',
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
}
expiryDialogVisible.value = true
}
// 加载可用套餐 // 加载可用套餐
const loadAvailablePackages = async (packageName?: string) => { const loadAvailablePackages = async (packageName?: string) => {
if (!detailData.value?.series_id) return if (!detailData.value?.series_id) return
@@ -596,33 +590,98 @@
} }
} }
const createPackageCostFormItem = (packageId: number): PackageCostFormItem => {
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
const originalCostPrice = selectedPackage?.cost_price ? selectedPackage.cost_price / 100 : 0
return {
package_id: packageId,
package_name: selectedPackage?.package_name,
package_code: selectedPackage?.package_code,
cost_price_yuan: originalCostPrice,
original_cost_price: originalCostPrice,
suggested_retail_price:
selectedPackage?.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
}
}
const getPackageCostPriceMax = (pkg?: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg?.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg?.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
watch(
() => packageForm.value.package_ids,
(packageIds) => {
if (packageDialogType.value !== 'add') return
const existingItems = new Map(
packageForm.value.packages.map((item) => [item.package_id, item])
)
packageForm.value.packages = packageIds.map(
(packageId) => existingItems.get(packageId) || createPackageCostFormItem(packageId)
)
},
{ deep: true }
)
// 保存套餐 // 保存套餐
const handleSavePackage = async () => { const handleSavePackage = async () => {
if (!packageFormRef.value || !detailData.value) return if (!detailData.value) return
const grantId = detailData.value.id // 保存到常量中避免类型检查问题
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
const grantId = detailData.value.id
submitLoading.value = true submitLoading.value = true
try { try {
if (packageDialogType.value === 'add') { if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
if (pkg.original_cost_price !== undefined && currentCostPrice < pkg.original_cost_price) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId, { await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override: expiry_base_override:
packageForm.value.expiry_base_override === 'default' packageForm.value.expiry_base_override === 'default'
? null ? null
: packageForm.value.expiry_base_override, : packageForm.value.expiry_base_override,
packages: [ packages
{
package_id: packageForm.value.package_id!,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
}
]
}) })
} else { } else {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const costPrice = Math.round(currentCostPriceYuan * 100)
const expiryBaseChanged = const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.expiry_base_override !== packageForm.value.initial_expiry_base_override
packageForm.value.initial_expiry_base_override
if ( if (
expiryBaseChanged && expiryBaseChanged &&
(packageForm.value.allocation_id === undefined || (packageForm.value.allocation_id === undefined ||
@@ -633,10 +692,7 @@
} }
const packages: GrantPackageItem[] = [ const packages: GrantPackageItem[] = [
{ { package_id: packageForm.value.package_id!, cost_price: costPrice }
package_id: packageForm.value.package_id!,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
}
] ]
if (expiryBaseChanged) { if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase( await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
@@ -663,12 +719,10 @@
// 刷新详情数据 // 刷新详情数据
await fetchDetail() await fetchDetail()
} catch (error) { } catch (error) {
console.error(error) ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
console.log(packageDialogType.value === 'add' ? '添加失败' : '更新失败')
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
})
} }
// 删除套餐 // 删除套餐
@@ -690,7 +744,10 @@
} }
] ]
await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, { packages }) await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, {
expiry_base_override: row.expiry_base_override ?? null,
packages
})
ElMessage.success('删除成功') ElMessage.success('删除成功')
// 刷新详情数据 // 刷新详情数据
@@ -707,17 +764,35 @@
}) })
} }
const saveExpiryBase = async () => {
if (expiryForm.value.allocation_id === undefined || expiryForm.value.allocation_id === null) {
return
}
expirySubmitLoading.value = true
try {
const response = await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
expiryForm.value.allocation_id,
{
expiry_base_override:
expiryForm.value.expiry_base_override === 'default'
? null
: expiryForm.value.expiry_base_override
}
)
if (response.code === 0) {
ElMessage.success('生效条件更新成功')
expiryDialogVisible.value = false
await fetchDetail()
}
} finally {
expirySubmitLoading.value = false
}
}
// 关闭对话框 // 关闭对话框
const handlePackageDialogClosed = () => { const handlePackageDialogClosed = () => {
packageFormRef.value?.resetFields() assignPackageForm()
packageForm.value = {
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
}
availablePackages.value = [] availablePackages.value = []
} }
@@ -764,6 +839,13 @@
.package-table { .package-table {
margin-top: 12px; margin-top: 12px;
.package-table-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
} }
.tier-table-wrapper { .tier-table-wrapper {
@@ -794,6 +876,21 @@
color: var(--el-color-warning); color: var(--el-color-warning);
} }
.ellipsis-value {
display: block;
width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }

View File

@@ -21,189 +21,60 @@
</ElButton> </ElButton>
</div> </div>
<ArtTable <SeriesGrantPackageTable
ref="tableRef"
row-key="package_id"
:loading="loading"
:data="packageList" :data="packageList"
:marginTop="10" :loading="loading"
:pagination="false" :show-actions="true"
:actions="getActions" :actions-width="240"
:actionsWidth="240"
:inlineActionsCount="3"
> >
<template #default> <template #actions="{ row }">
<ElTableColumn <div class="package-table-actions">
prop="package_name" <ElButton
label="套餐名称" v-if="canUpdateExpiryBase"
minWidth="220" type="primary"
show-overflow-tooltip size="small"
/> link
<ElTableColumn @click="showExpiryBaseDialog(row)"
prop="package_code"
label="套餐编码"
minWidth="180"
show-overflow-tooltip
/>
<ElTableColumn label="成本价" width="120">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="套餐默认生效条件" minWidth="150">
<template #default="{ row }">
{{ row.default_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="覆盖生效条件" minWidth="150">
<template #default="{ row }">
{{ row.expiry_base_override_name || '跟随套餐默认' }}
</template>
</ElTableColumn>
<ElTableColumn label="最终生效条件" minWidth="150">
<template #default="{ row }">
{{ row.effective_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
<ElDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="40%"
:close-on-click-modal="false"
@closed="handlePackageDialogClosed"
> >
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="140px"> 修改生效条件
<ElFormItem label="选择套餐" prop="package_ids" v-if="packageDialogType === 'add'"> </ElButton>
<ElSelect <ElButton
v-model="packageForm.package_ids" v-if="hasAuth('series_grants:edit_packages_list')"
placeholder="请选择套餐(可多选)" type="primary"
style="width: 100%" size="small"
filterable link
remote @click="showEditPackageDialog(row)"
:remote-method="searchAvailablePackages"
:loading="packageLoading"
clearable
multiple
:multiple-limit="100"
> >
<template v-if="availablePackages.length === 0 && !packageLoading && seriesId"> 编辑
<ElOption disabled value="" label="该系列没有可选套餐" /> </ElButton>
</template> <ElButton
<ElOption v-if="hasAuth('series_grants:delete_packages_list')"
v-for="pkg in availablePackages" type="danger"
:key="pkg.id" size="small"
:label="`${pkg.package_name} (${pkg.package_code})`" link
:value="pkg.id" @click="handleDeletePackage(row)"
:disabled="pkg.is_authorized"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="packageDialogType === 'add' && packageForm.packages.length"
label="套餐配置"
prop="packages"
> >
<div class="package-config-list"> 删除
<div
v-for="pkg in packageForm.packages"
:key="pkg.package_id"
class="package-config-item"
>
<div class="package-config-name" :title="pkg.package_name || ''">
{{ pkg.package_name || '套餐名称不可用' }}
</div>
<div class="package-config-cost">
<span class="package-config-field-label">套餐成本价</span>
<ElInputNumber
v-model="pkg.cost_price_yuan"
:min="pkg.original_cost_price || 0"
:max="getPackageCostPriceMax(pkg)"
:precision="2"
:step="0.01"
:controls="false"
placeholder="请输入成本价"
style="width: 150px"
/>
</div>
</div>
</div>
</ElFormItem>
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span>
</ElFormItem>
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_code }}</span>
</ElFormItem>
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价: ¥{{
packageForm.original_cost_price.toFixed(2)
}}
</div>
</ElFormItem>
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect
v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件"
style="width: 100%"
>
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
<div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElFormItem>
<template v-if="packageDialogType === 'edit'">
<ElFormItem label="套餐默认生效条件">
{{ packageForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
{{ packageForm.expiry_base_override_name || '跟随套餐默认' }}
</ElFormItem>
<ElFormItem label="最终生效条件">
{{ packageForm.effective_expiry_base_name || '-' }}
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="submitLoading">
保存
</ElButton> </ElButton>
</div> </div>
</template> </template>
</ElDialog> </SeriesGrantPackageTable>
</ElCard>
<SeriesGrantPackageDialog
v-model="packageDialogVisible"
v-model:form="packageForm"
:dialog-type="packageDialogType"
:rules="packageRules"
:available-packages="availablePackages"
:package-loading="packageLoading"
:submit-loading="submitLoading"
:can-update-expiry-base="canUpdateExpiryBase"
:has-series="Boolean(seriesId)"
@search="searchAvailablePackages"
@submit="handleSavePackage"
@closed="handlePackageDialogClosed"
/>
<ElDialog <ElDialog
v-model="expiryDialogVisible" v-model="expiryDialogVisible"
@@ -252,22 +123,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
ElCard, ElCard,
ElButton, ElButton,
ElIcon, ElIcon,
ElTableColumn,
ElTag,
ElDialog, ElDialog,
ElSelect, ElSelect,
ElOption, ElOption,
ElInputNumber,
ElForm, ElForm,
ElFormItem, ElFormItem,
ElMessage, ElMessage,
FormInstance,
FormRules FormRules
} from 'element-plus' } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue' import { ArrowLeft } from '@element-plus/icons-vue'
@@ -283,9 +150,13 @@
} from '@/types/api' } from '@/types/api'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants' import { JULY_PERMISSIONS } from '@/config/constants'
import SeriesGrantPackageTable from '@/components/business/SeriesGrantPackageTable.vue'
import SeriesGrantPackageDialog from '@/components/business/SeriesGrantPackageDialog.vue'
import { import {
mergeGrantPackageCandidates, mergeGrantPackageCandidates,
type GrantPackageCandidate type GrantPackageCandidate,
type SeriesGrantPackageForm,
type PackageCostFormItem
} from '@/utils/business/seriesGrantPackage' } from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantPackages' }) defineOptions({ name: 'SeriesGrantPackages' })
@@ -298,15 +169,11 @@
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const packageLoading = ref(false) const packageLoading = ref(false)
const tableRef = ref()
const grantId = computed(() => Number(route.params.id)) const grantId = computed(() => Number(route.params.id))
const seriesId = ref<number>(0) const seriesId = ref<number>(0)
const grantName = ref<string>('') const grantName = ref<string>('')
const packageList = ref<GrantPackageInfo[]>([]) const packageList = ref<GrantPackageInfo[]>([])
const availablePackages = ref<GrantPackageCandidate[]>([]) const availablePackages = ref<GrantPackageCandidate[]>([])
const packageFormRef = ref<FormInstance>()
const packageDialogVisible = ref(false) const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add') const packageDialogType = ref<'add' | 'edit'>('add')
const expiryDialogVisible = ref(false) const expiryDialogVisible = ref(false)
@@ -314,32 +181,6 @@
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default' type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type PackageCostFormItem = {
package_id: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
}
type PackageFormState = {
package_id?: number
allocation_id?: number
package_ids: number[]
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
packages: PackageCostFormItem[]
}
type ExpiryFormState = { type ExpiryFormState = {
package_id?: number package_id?: number
allocation_id?: number allocation_id?: number
@@ -351,7 +192,7 @@
effective_expiry_base_name?: string | null effective_expiry_base_name?: string | null
} }
const createDefaultPackageForm = (): PackageFormState => ({ const createDefaultPackageForm = (): SeriesGrantPackageForm => ({
package_id: undefined, package_id: undefined,
allocation_id: undefined, allocation_id: undefined,
package_ids: [], package_ids: [],
@@ -362,10 +203,10 @@
initial_expiry_base_override: 'default', initial_expiry_base_override: 'default',
packages: [] packages: []
}) })
const packageForm = ref<PackageFormState>(createDefaultPackageForm()) const packageForm = ref<SeriesGrantPackageForm>(createDefaultPackageForm())
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' }) const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const assignPackageForm = (values: Partial<PackageFormState> = {}) => { const assignPackageForm = (values: Partial<SeriesGrantPackageForm> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values) Object.assign(packageForm.value, createDefaultPackageForm(), values)
} }
@@ -426,36 +267,6 @@
] ]
})) }))
const getActions = (row: GrantPackageInfo) => {
const actions: any[] = []
if (canUpdateExpiryBase) {
actions.push({
label: '修改生效条件',
handler: () => showExpiryBaseDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:edit_packages_list')) {
actions.push({
label: '编辑',
handler: () => showEditPackageDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:delete_packages_list')) {
actions.push({
label: '删除',
handler: () => handleDeletePackage(row),
type: 'danger'
})
}
return actions
}
// 返回上一页 // 返回上一页
const handleBack = () => { const handleBack = () => {
router.back() router.back()
@@ -484,7 +295,6 @@
assignPackageForm({ package_ids: [], packages: [] }) assignPackageForm({ package_ids: [], packages: [] })
loadAvailablePackages() loadAvailablePackages()
packageDialogVisible.value = true packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
} }
const showEditPackageDialog = (row: GrantPackageInfo) => { const showEditPackageDialog = (row: GrantPackageInfo) => {
@@ -506,7 +316,6 @@
effective_expiry_base_name: row.effective_expiry_base_name effective_expiry_base_name: row.effective_expiry_base_name
}) })
packageDialogVisible.value = true packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
} }
const showExpiryBaseDialog = (row: GrantPackageInfo) => { const showExpiryBaseDialog = (row: GrantPackageInfo) => {
@@ -605,41 +414,11 @@
{ deep: true } { deep: true }
) )
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value === 'add' && packageId) {
const selectedPackage = availablePackages.value.find((p) => p.id === packageId)
if (selectedPackage) {
const originalCostPrice = selectedPackage.cost_price
? selectedPackage.cost_price / 100
: 0
const suggestedRetailPrice =
selectedPackage.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.suggested_retail_price = suggestedRetailPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
}
}
)
const handleSavePackage = async () => { const handleSavePackage = async () => {
if (!packageFormRef.value || !grantId.value) return if (!grantId.value) return
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true submitLoading.value = true
try { try {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const costPrice = Math.round(currentCostPriceYuan * 100)
if (packageDialogType.value === 'add') { if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) { if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐') ElMessage.error('请至少选择一个套餐')
@@ -656,10 +435,7 @@
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}` `套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
) )
} }
if ( if (pkg.original_cost_price !== undefined && currentCostPrice < pkg.original_cost_price) {
pkg.original_cost_price !== undefined &&
currentCostPrice < pkg.original_cost_price
) {
throw new Error( throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}` `套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
) )
@@ -677,9 +453,13 @@
packages packages
}) })
} else { } else {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const costPrice = Math.round(currentCostPriceYuan * 100)
const expiryBaseChanged = const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.expiry_base_override !== packageForm.value.initial_expiry_base_override
packageForm.value.initial_expiry_base_override
if ( if (
expiryBaseChanged && expiryBaseChanged &&
(packageForm.value.allocation_id === undefined || (packageForm.value.allocation_id === undefined ||
@@ -718,7 +498,6 @@
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
})
} }
const saveExpiryBase = async () => { const saveExpiryBase = async () => {
@@ -779,7 +558,6 @@
} }
const handlePackageDialogClosed = () => { const handlePackageDialogClosed = () => {
packageFormRef.value?.clearValidate()
assignPackageForm() assignPackageForm()
availablePackages.value = [] availablePackages.value = []
} }
@@ -827,9 +605,11 @@
} }
} }
.amount-value { .package-table-actions {
font-weight: 600; display: flex;
color: var(--el-color-warning); flex-wrap: wrap;
gap: 8px;
align-items: center;
} }
.form-tip { .form-tip {
@@ -838,51 +618,15 @@
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.package-config-list {
width: 100%;
}
.package-config-item {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.package-config-name,
.ellipsis-value { .ellipsis-value {
display: block;
width: 260px;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.package-config-name {
flex: 1;
}
.package-config-cost {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
}
.package-config-field-label {
color: var(--el-text-color-secondary);
font-size: 13px;
white-space: nowrap;
}
.ellipsis-value {
display: block;
width: 260px;
}
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }

View File

@@ -40,10 +40,10 @@
<ElDialog <ElDialog
v-model="dialogVisible" v-model="dialogVisible"
title="编辑系统配置" title="编辑系统配置"
width="560px" width="30%"
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<ElForm label-width="120px"> <ElForm label-width="80px">
<ElFormItem label="配置说明"> <ElFormItem label="配置说明">
<span>{{ currentConfig?.description || '-' }}</span> <span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem> </ElFormItem>
@@ -90,7 +90,14 @@
<template #footer> <template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton> <ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitLoading" @click="handleSubmit"> 保存 </ElButton> <ElButton
type="primary"
:loading="submitLoading"
:disabled="submitLoading"
@click="handleSubmit"
>
保存
</ElButton>
</template> </template>
</ElDialog> </ElDialog>
</div> </div>
@@ -111,6 +118,7 @@
type SystemConfigModule type SystemConfigModule
} from '@/types/api/systemConfig' } from '@/types/api/systemConfig'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { JULY_PERMISSIONS } from '@/config/constants' import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
@@ -258,7 +266,8 @@
const validateValue = (value: string) => { const validateValue = (value: string) => {
const config = currentConfig.value const config = currentConfig.value
if (!config) return '配置不存在' if (!config) return '配置不存在'
if (config.enum_values.length > 0 && !config.enum_values.includes(value)) { const enumValues = config.enum_values ?? []
if (enumValues.length > 0 && !enumValues.includes(value)) {
return '配置值不在允许的枚举范围内' return '配置值不在允许的枚举范围内'
} }
if (config.value_type === 'int') { if (config.value_type === 'int') {
@@ -324,7 +333,14 @@
} }
const handleSubmit = async () => { const handleSubmit = async () => {
if (!currentConfig.value || currentConfig.value.readonly) return if (!currentConfig.value) {
ElMessage.error('未选择要编辑的系统配置')
return
}
if (currentConfig.value.readonly) {
ElMessage.warning('当前配置为只读配置,不能保存')
return
}
const value = getSubmittedValue() const value = getSubmittedValue()
if (currentConfig.value.sensitive && value === originalValue.value) { if (currentConfig.value.sensitive && value === originalValue.value) {
ElMessage.warning('敏感配置未输入新值,无需保存') ElMessage.warning('敏感配置未输入新值,无需保存')
@@ -351,6 +367,7 @@
} }
} catch (error) { } catch (error) {
console.error('更新系统配置失败:', error) console.error('更新系统配置失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }

View File

@@ -303,6 +303,12 @@
</div> </div>
</template> </template>
</ElDialog> </ElDialog>
<ShopCreditLimitDialog
v-model="creditDialogVisible"
:shop="currentCreditShop"
@submitted="getShopList"
/>
</ElCard> </ElCard>
</div> </div>
</ArtTableFullScreen> </ArtTableFullScreen>
@@ -326,7 +332,8 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { generateShopCode } from '@/utils/codeGenerator' import { generateShopCode } from '@/utils/codeGenerator'
import { ShopService, RoleService } from '@/api/modules' import { CommissionService, ShopService, RoleService } from '@/api/modules'
import ShopCreditLimitDialog from '@/components/business/ShopCreditLimitDialog.vue'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import type { import type {
CreateShopParams, CreateShopParams,
@@ -335,6 +342,7 @@
ShopRoleResponse, ShopRoleResponse,
UpdateShopParams UpdateShopParams
} from '@/types/api' } from '@/types/api'
import type { ShopFundSummaryItem } from '@/types/api/commission'
import { RoleType, RoleStatus } from '@/types/api' import { RoleType, RoleStatus } from '@/types/api'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { getCompatibleNumericId } from '@/utils/business/id' import { getCompatibleNumericId } from '@/utils/business/id'
@@ -356,6 +364,9 @@
const defaultRoleList = ref<any[]>([]) const defaultRoleList = ref<any[]>([])
const salespersonLoading = ref(false) const salespersonLoading = ref(false)
const salespersonOptions = ref<ShopBusinessOwnerCandidate[]>([]) const salespersonOptions = ref<ShopBusinessOwnerCandidate[]>([])
const creditDialogVisible = ref(false)
const currentCreditShop = ref<ShopFundSummaryItem | null>(null)
const creditLoadingShopId = ref<number | null>(null)
// 级联选择相关 // 级联选择相关
const parentShopCascadeOptions = ref<any[]>([]) const parentShopCascadeOptions = ref<any[]>([])
@@ -803,6 +814,14 @@
}) })
} }
if (hasAuth('shop:credit_limit_manage')) {
actions.push({
label: '调整实际信用额度',
handler: () => showCreditDialog(row),
type: 'primary'
})
}
if (hasAuth('shop:edit')) { if (hasAuth('shop:edit')) {
actions.push({ actions.push({
label: '编辑', label: '编辑',
@@ -822,6 +841,34 @@
return actions return actions
} }
const showCreditDialog = async (row: ShopResponse) => {
if (creditLoadingShopId.value === row.id) return
creditLoadingShopId.value = row.id
try {
const res = await CommissionService.getShopFundSummary({
page: 1,
page_size: 100,
shop_name: row.shop_name
})
const summary =
res.code === 0 ? (res.data.items || []).find((item) => item.shop_id === row.id) : null
if (!summary) {
ElMessage.warning('未获取到该店铺的资金概况,请刷新店铺列表后重试')
return
}
currentCreditShop.value = summary
creditDialogVisible.value = true
} catch (error) {
console.error('获取店铺资金概况失败:', error)
ElMessage.error('获取店铺资金概况失败,请稍后重试')
} finally {
creditLoadingShopId.value = null
}
}
// 表单实例 // 表单实例
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()

View File

@@ -21,10 +21,10 @@
<ElDescriptionsItem label="状态"> <ElDescriptionsItem label="状态">
{{ role.status === 1 ? '启用' : '禁用' }} {{ role.status === 1 ? '启用' : '禁用' }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="新建代理默认信用"> <ElDescriptionsItem label="代理默认信用">
{{ role.default_credit_enabled ? '启用' : '未启用' }} {{ role.default_credit_enabled ? '启用' : '未启用' }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="默认信用额度"> <ElDescriptionsItem label="默认信用额度(元)">
{{ formatCredit(role.default_credit_limit) }} {{ formatCredit(role.default_credit_limit) }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="信用生效范围"> <ElDescriptionsItem label="信用生效范围">
@@ -60,7 +60,10 @@
const formatCredit = (value?: number) => { const formatCredit = (value?: number) => {
if (value === undefined || value === null) return '-' if (value === undefined || value === null) return '-'
return `${(value / 100).toFixed(2)}` return (value / 100).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
} }
const handleBack = () => router.back() const handleBack = () => router.back()

View File

@@ -88,6 +88,7 @@
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="默认信用额度"> <ElFormItem label="默认信用额度">
<div class="credit-form-unit">单位</div>
<ElInputNumber <ElInputNumber
v-model="form.credit_limit_yuan" v-model="form.credit_limit_yuan"
:disabled="!form.credit_enabled" :disabled="!form.credit_enabled"
@@ -97,8 +98,10 @@
controls-position="right" controls-position="right"
style="width: 100%" style="width: 100%"
placeholder="请输入默认信用额度" placeholder="请输入默认信用额度"
:formatter="formatCreditInput"
:parser="parseCreditInput"
/> />
<div class="credit-form-tip">单位修改后不会影响已有店铺</div> <div class="credit-form-tip">修改后不会影响已有店铺</div>
</ElFormItem> </ElFormItem>
</template> </template>
</ElForm> </ElForm>
@@ -349,8 +352,8 @@
{ label: '角色名称', prop: 'role_name' }, { label: '角色名称', prop: 'role_name' },
{ label: '角色描述', prop: 'role_desc' }, { label: '角色描述', prop: 'role_desc' },
{ label: '角色类型', prop: 'role_type' }, { label: '角色类型', prop: 'role_type' },
{ label: '新建代理默认信用', prop: 'default_credit_enabled' }, { label: '代理默认信用', prop: 'default_credit_enabled' },
{ label: '默认信用额度', prop: 'default_credit_limit' }, { label: '默认信用额度(元)', prop: 'default_credit_limit' },
...(canUpdateRoleStatus ? [{ label: '状态', prop: 'status' }] : []), ...(canUpdateRoleStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' } { label: '创建时间', prop: 'created_at' }
] ]
@@ -414,6 +417,29 @@
} }
} }
const formatCreditInput = (value: string | number | undefined) => {
if (value === undefined || value === null || value === '') return ''
const normalizedValue = String(value).replace(/,/g, '')
const [integerPart, decimalPart] = normalizedValue.split('.')
const formattedIntegerPart = (integerPart || '0').replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return decimalPart === undefined
? formattedIntegerPart
: `${formattedIntegerPart}.${decimalPart}`
}
const parseCreditInput = (value: string) => value.replace(/,/g, '')
const formatCreditYuan = (value?: number | null) => {
if (value === undefined || value === null) return '-'
return fenToYuan(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const roleList = ref<PlatformRole[]>([]) const roleList = ref<PlatformRole[]>([])
const getRoleId = (role: PlatformRole | any) => getCompatibleNumericId(role) ?? 0 const getRoleId = (role: PlatformRole | any) => getCompatibleNumericId(role) ?? 0
@@ -463,7 +489,7 @@
}, },
{ {
prop: 'default_credit_enabled', prop: 'default_credit_enabled',
label: '新建代理默认信用', label: '代理默认信用',
width: 150, width: 150,
formatter: (row: PlatformRole) => { formatter: (row: PlatformRole) => {
if (row.role_type !== RoleType.CUSTOMER) return '-' if (row.role_type !== RoleType.CUSTOMER) return '-'
@@ -474,11 +500,11 @@
}, },
{ {
prop: 'default_credit_limit', prop: 'default_credit_limit',
label: '默认信用额度', label: '默认信用额度(元)',
width: 140, width: 160,
formatter: (row: PlatformRole) => { formatter: (row: PlatformRole) => {
if (row.role_type !== RoleType.CUSTOMER || !row.default_credit_enabled) return '-' if (row.role_type !== RoleType.CUSTOMER || !row.default_credit_enabled) return '-'
return `${fenToYuan(row.default_credit_limit)}` return formatCreditYuan(row.default_credit_limit)
} }
}, },
...(canUpdateRoleStatus ...(canUpdateRoleStatus
@@ -1267,6 +1293,13 @@
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.credit-form-unit {
margin-bottom: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
.permission-tree-transfer-container { .permission-tree-transfer-container {
display: flex; display: flex;
gap: 20px; gap: 20px;