feat: 系统配置
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled

This commit is contained in:
luo
2026-07-25 14:13:49 +08:00
parent ee508c3e1e
commit dd6bceeeb7
20 changed files with 805 additions and 41 deletions

View File

@@ -0,0 +1,26 @@
## Context
系统配置由后端注册,前端不能假设固定字段。列表接口返回控件提示、值类型、枚举和范围,更新接口要求配置 Key 与路径一致且值字符串化。
## Goals / Non-Goals
- Goals:
- 在设置管理提供统一的模块筛选、分页和配置编辑入口。
- 动态遵循后端配置元数据进行展示和校验。
- 防止误修改只读配置和敏感配置脱敏值。
- Non-Goals:
- 不在前端维护配置 Key 白名单或复制后端注册规则。
- 不对 JSON 配置做业务语义解析。
- 不绕过后端对已注册配置、范围和枚举的最终校验。
## Decisions
- 列表数据读取 `data.list`,分页使用 `page``page_size``total`
- 编辑控件优先根据 `control` 渲染,`value_type` 作为值转换和校验兜底。
- `bool` 以开关展示并提交 `true`/`false` 字符串;`int` 以数字输入展示并提交十进制字符串;其他类型以输入框或文本域提交字符串。
- `readonly=true` 的配置不显示可编辑操作;`sensitive=true` 的值仅显示接口返回值,只有用户主动输入新值时才提交更新。
## Risks / Trade-offs
- 未知控件提示可能无法映射到专用控件,使用文本输入作为安全兜底,并仍由后端校验。
- 脱敏敏感值无法判断是否为完整原值,因此禁止把未修改的脱敏值再次提交。

View File

@@ -0,0 +1,18 @@
# Change: 增加系统配置管理
## Why
系统配置接口已经提供受控配置的注册信息、控件提示和校验范围,设置管理需要提供统一入口,避免为每个配置模块单独维护页面和字段。
## What Changes
- 在设置管理下新增系统配置页面和路由。
- 接入系统配置列表查询和按配置 Key 更新接口。
- 按后端返回的 `control``value_type`、枚举值及范围动态渲染编辑控件。
- 只读配置不可编辑,更新请求统一提交 `{ key, value }`,其中 `value` 为字符串。
- 敏感配置按接口脱敏值展示,保存时不覆盖未重新输入的脱敏值。
## Impact
- Affected specs: `system-config-management`
- Affected code: `src/api/modules/systemConfig.ts`, `src/types/api/systemConfig.ts`, `src/views/settings/system-configs/index.vue`, `src/router/routesAlias.ts`, `src/router/routes/asyncRoutes.ts`

View File

@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: 受控系统配置列表
系统 SHALL 在设置管理提供受控系统配置列表,支持按 `module` 筛选和分页,并展示后端返回的配置元数据及当前值。
#### Scenario: 查询系统配置
- **WHEN** 用户进入系统配置页面或执行刷新
- **THEN** 前端调用 `GET /api/admin/system-configs`
- **AND** 使用响应中的 `data.list``data.page``data.page_size``data.total` 渲染列表
- **AND** 展示配置 Key、模块、说明、值、控件提示、注册状态、只读状态、敏感状态和更新时间
#### Scenario: 按模块筛选
- **WHEN** 用户选择 `carrier_callback``c2b.payment`
- **THEN** 查询请求携带对应的 `module` 参数
- **AND** 页面从第一页展示筛选后的配置
### Requirement: 动态配置编辑
系统 SHALL 根据配置返回的 `control``value_type``enum_values``min``max` 元数据渲染编辑控件并进行前端基础校验;更新请求 SHALL 统一提交字符串形式的 `value`
#### Scenario: 更新可编辑配置
- **WHEN** 用户修改非只读配置并提交
- **THEN** 前端调用 `PUT /api/admin/system-configs/{key}`
- **AND** 请求体为 `{ "key": "配置Key", "value": "字符串化配置值" }`
- **AND** 成功后使用接口返回的配置数据更新列表
#### Scenario: bool 和 int 配置
- **WHEN** 配置类型分别为 `bool``int`
- **THEN** 页面分别使用开关或数字输入控件
- **AND** bool 提交 `true`/`false` 字符串int 提交十进制数字字符串
- **AND** int 值违反 `min``max` 时阻止提交
#### Scenario: 枚举配置
- **WHEN** 配置返回非空 `enum_values`
- **THEN** 页面使用枚举选择控件
- **AND** 只能提交允许的枚举值
### Requirement: 只读和敏感配置保护
系统 SHALL 禁止前端编辑只读配置;敏感配置 SHALL 展示接口返回的脱敏值,用户未主动输入新值时不得将该脱敏值再次提交。
#### Scenario: 只读配置
- **WHEN** 配置的 `readonly``true`
- **THEN** 页面禁用编辑控件和保存操作
#### Scenario: 敏感配置未修改
- **WHEN** 敏感配置仅展示脱敏值且用户未输入新值
- **THEN** 页面不提交该配置的更新请求

View File

@@ -0,0 +1,20 @@
## 1. API And Types
- [x] 1.1 新增系统配置字段、查询参数和分页响应类型。
- [x] 1.2 新增 `GET /api/admin/system-configs` 服务方法,读取 `data.list`
- [x] 1.3 新增 `PUT /api/admin/system-configs/{key}` 服务方法,提交 `{ key, value }`
## 2. Settings Management Page
- [x] 2.1 在设置管理下新增系统配置路由和菜单入口。
- [x] 2.2 实现模块筛选、分页、刷新和配置列表展示。
- [x] 2.3 根据配置元数据动态渲染开关、数字、枚举、文本和 JSON 控件。
- [x] 2.4 实现只读配置禁用编辑、敏感值脱敏保护和字符串化提交。
- [x] 2.5 展示配置注册状态、更新时间、范围和校验提示。
## 3. Verification
- [x] 3.1 验证两个模块筛选和分页参数正确传递。
- [x] 3.2 验证 bool、int、enum、string 和 json 配置的展示及提交值。
- [x] 3.3 验证只读配置不能提交,敏感配置未修改时不会回传脱敏值。
- [x] 3.4 运行类型检查、lint 和构建。

View File

@@ -0,0 +1,36 @@
## Context
代理系列授权详情返回完整的 `packages[]`,套餐列表接口返回可选套餐候选。前端创建授权时需要从候选套餐中选择初始套餐,详情和套餐管理页面则需要同时展示候选套餐信息与已授权状态。
## Goals / Non-Goals
- Goals:
-`package_id` 为唯一键合并套餐候选和授权详情。
- 在创建前校验初始套餐数量为 1100 且不重复。
- 使用 `remove=true` 表达后续删除,保持接口响应结构不变。
- Non-Goals:
- 不修改后端接口路径或响应字段结构。
- 不在前端复制后端的成本价、生效条件或套餐状态计算规则。
- 不新增独立的套餐删除 API 调用。
## Decisions
- 套餐候选状态以 `package_id` 合并:详情返回的套餐标记为已授权,候选列表中不存在于详情的套餐标记为未授权。
- 创建请求始终携带 `packages`,数量范围为 1100每个 `package_id` 只能出现一次。
- 后续新增或调价沿用 `PUT /api/admin/shop-series-grants/{id}/packages`,删除项只提交 `{ package_id, remove: true }`
- 成功响应直接按后端返回的完整授权详情刷新页面,不根据请求内容自行拼装最终响应。
## Risks / Trade-offs
- 套餐候选接口分页或搜索结果可能不包含已授权套餐,合并时必须保留详情中的已授权项,避免误显示为未授权。
- 用户一次选择超过 100 个套餐时前端应提前阻止提交,同时仍依赖后端校验作为最终约束。
## Migration Plan
1. 更新类型和 API 请求约束。
2. 更新创建页、授权详情页和套餐列表页的候选合并及套餐操作逻辑。
3. 使用接口响应重新加载授权详情,验证创建、调价、删除和空候选场景。
## Open Questions
- 套餐候选接口的分页大小是否允许一次返回全部候选;若不允许,页面需要继续使用远程搜索加载候选。

View File

@@ -0,0 +1,18 @@
# Change: 更新代理系列授权套餐管理契约
## Why
代理系列授权接口已更新为创建时必须一次提交初始套餐,后续通过套餐批量管理接口维护套餐。前端需要将套餐列表作为候选集合、授权详情作为已授权状态来源进行合并,避免新增、编辑和删除时状态不一致。
## What Changes
- 套餐列表页和授权详情页统一按 `package_id` 合并套餐候选数据与已授权数据。
- 创建代理系列授权时强制提交 1100 个不重复套餐。
- 后续套餐管理继续通过 `packages[]` 提交新增、调价和 `remove=true` 删除操作。
- 保持创建、详情、更新和套餐管理接口的完整授权详情响应结构不变。
- 更新 API 类型、表单校验和页面状态管理,禁止通过空套餐列表创建授权或使用独立删除接口删除授权套餐。
## Impact
- Affected specs: `package-series-allocation`
- Affected code: `src/api/modules/shopSeriesGrant.ts`, `src/types/api/packageManagement.ts`, `src/views/package-management/series-grants/index.vue`, `src/views/package-management/series-grants/detail.vue`, `src/views/package-management/series-grants/packages.vue`

View File

@@ -0,0 +1,61 @@
## MODIFIED Requirements
### Requirement: 创建代理系列授权时提交初始套餐
系统 SHALL 在创建代理系列授权时要求提交 `packages`,且 `packages` 必须包含 1100 个套餐项;套餐 ID 不得重复。创建成功响应 SHALL 保持完整授权详情结构不变。
#### Scenario: 创建授权并提交有效初始套餐
- **WHEN** 用户提交 `shop_id``series_id` 和包含 1100 个不重复套餐项的 `packages`
- **THEN** 前端提交 `POST /api/admin/shop-series-grants`
- **AND** 每个套餐项携带必要的 `package_id`,新增或调价时携带 `cost_price`
- **AND** 页面按接口返回的完整授权详情刷新授权信息
#### Scenario: 创建授权未选择套餐
- **WHEN** 用户未选择任何初始套餐
- **THEN** 前端阻止提交并提示至少选择 1 个套餐
#### Scenario: 创建授权超过套餐数量上限或包含重复套餐
- **WHEN** 用户选择超过 100 个套餐或同一 `package_id` 重复出现
- **THEN** 前端阻止提交并提示套餐数量或重复项错误
### Requirement: 合并套餐候选与授权状态
系统 SHALL 在套餐列表和授权详情相关页面以 `package_id` 为唯一键合并套餐候选数据与授权详情中的 `packages[]`。已授权套餐即使不在当前候选搜索结果中,也 SHALL 保留并标记为已授权。
#### Scenario: 候选套餐与已授权套餐合并
- **WHEN** 套餐候选列表和授权详情均加载成功
- **THEN** 页面按 `package_id` 合并两者
- **AND** 详情中的套餐显示为已授权并保留其成本价、生效条件和状态字段
- **AND** 仅出现在候选列表中的套餐显示为未授权候选
#### Scenario: 候选搜索结果不包含已授权套餐
- **WHEN** 当前候选搜索结果缺少某个已授权套餐
- **THEN** 页面仍从授权详情保留该套餐
- **AND** 不将其误判为未授权或从当前编辑状态中删除
### Requirement: 后续套餐管理使用批量操作删除
系统 SHALL 使用 `PUT /api/admin/shop-series-grants/{id}/packages` 管理既有授权套餐。新增和调价提交套餐项,删除套餐授权 SHALL 提交对应套餐项的 `remove: true`;操作成功后 SHALL 按接口返回的完整授权详情刷新页面。
#### Scenario: 新增或调整授权套餐
- **WHEN** 用户在已有授权中新增套餐或调整成本价
- **THEN** 前端提交 `packages[]` 中对应的套餐项及成本价
- **AND** 不调用独立删除接口
#### Scenario: 删除授权套餐
- **WHEN** 用户删除已有授权套餐
- **THEN** 前端提交 `{ package_id, remove: true }`
- **AND** 页面使用接口返回的完整授权详情刷新套餐列表
#### Scenario: 套餐管理响应结构保持不变
- **WHEN** 套餐批量管理接口成功
- **THEN** 前端按详情响应读取授权基本信息、佣金配置、强充配置、`packages[]`、状态和时间字段
- **AND** 不要求后端返回新的响应包装结构

View File

@@ -0,0 +1,26 @@
## 1. API And Types
- [x] 1.1 按 21 号接口文档核对代理系列授权列表、创建、详情、更新和套餐管理的请求响应类型。
- [x] 1.2 将创建请求的 `packages` 定义为必填,并补充套餐 1100 项及套餐 ID 不重复的约束说明。
- [x] 1.3 明确 `GrantPackageItem.remove` 仅用于后续套餐删除,保留完整授权详情响应类型。
## 2. Candidate State Merge
- [x] 2.1 在创建授权页面按 `package_id` 合并套餐候选列表和当前选择状态。
- [x] 2.2 在授权详情页面按 `package_id` 合并套餐列表候选和详情中的已授权套餐,已授权套餐必须保留在合并结果中。
- [x] 2.3 在独立授权套餐列表页面复用同一状态规则,避免候选套餐和已授权套餐重复或丢失。
## 3. Create And Manage Packages
- [x] 3.1 创建授权提交前校验套餐数量为 1100且套餐 ID 不重复。
- [x] 3.2 创建授权始终提交 `packages[]`,禁止空数组或缺失套餐列表提交。
- [x] 3.3 新增或调价继续提交套餐项和成本价;删除只提交 `remove: true`,不调用独立删除接口。
- [x] 3.4 套餐管理成功后使用后端返回的完整授权详情刷新页面,不前端重算响应结构。
## 4. Verification
- [x] 4.1 验证创建 1 个套餐成功,创建 0 个、101 个和重复套餐被阻止。
- [x] 4.2 验证套餐候选与授权详情合并后,已授权状态、成本价和生效条件展示正确。
- [x] 4.3 验证新增、调价和 `remove=true` 删除均使用套餐管理接口。
- [x] 4.4 验证接口成功响应仍按完整授权详情结构处理。
- [x] 4.5 运行类型检查、lint 和构建。

View File

@@ -23,6 +23,7 @@ export { OrderService } from './order'
export { AssetService } from './asset' export { AssetService } from './asset'
export { AgentRechargeService } from './agentRecharge' export { AgentRechargeService } from './agentRecharge'
export { PaymentSettingsService } from './paymentSettings' export { PaymentSettingsService } from './paymentSettings'
export { SystemConfigService } from './systemConfig'
export { ExchangeService } from './exchange' export { ExchangeService } from './exchange'
export { RefundService } from './refund' export { RefundService } from './refund'
export { DataCleanupService } from './dataCleanup' export { DataCleanupService } from './dataCleanup'

View File

@@ -0,0 +1,28 @@
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
SystemConfigItem,
SystemConfigPageResult,
SystemConfigQueryParams,
UpdateSystemConfigRequest
} from '@/types/api/systemConfig'
const SYSTEM_CONFIG_BASE_URL = '/api/admin/system-configs'
export class SystemConfigService extends BaseService {
static getSystemConfigs(
params?: SystemConfigQueryParams
): Promise<BaseResponse<SystemConfigPageResult>> {
return this.get<BaseResponse<SystemConfigPageResult>>(SYSTEM_CONFIG_BASE_URL, params)
}
static updateSystemConfig(
key: string,
data: UpdateSystemConfigRequest
): Promise<BaseResponse<SystemConfigItem>> {
return this.put<BaseResponse<SystemConfigItem>>(
`${SYSTEM_CONFIG_BASE_URL}/${encodeURIComponent(key)}`,
data
)
}
}

View File

@@ -764,6 +764,17 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true, keepAlive: true,
roles: ['R_SUPER'] roles: ['R_SUPER']
} }
},
// 系统配置
{
path: 'system-configs',
name: 'SystemConfigs',
component: RoutesAlias.SystemConfigs,
meta: {
title: '系统配置',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
} }
] ]
}, },

View File

@@ -92,6 +92,7 @@ export enum RoutesAlias {
PaymentSettings = '/settings/payment-settings', // 支付设置 PaymentSettings = '/settings/payment-settings', // 支付设置
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情 PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置 OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
SystemConfigs = '/settings/system-configs', // 系统配置
// 轮询管理 // 轮询管理
DataCleanup = '/polling-management/data-cleanup', // 数据清理 DataCleanup = '/polling-management/data-cleanup', // 数据清理

View File

@@ -78,6 +78,9 @@ export * from './agentRecharge'
// 支付设置相关 // 支付设置相关
export * from './paymentSettings' export * from './paymentSettings'
// 系统配置相关
export * from './systemConfig'
// 退款管理相关 // 退款管理相关
export * from './refund' export * from './refund'

View File

@@ -336,8 +336,8 @@ export interface CreateShopSeriesGrantRequest {
commission_tiers?: CommissionTier[] // 梯度配置列表,梯度模式时必填 commission_tiers?: CommissionTier[] // 梯度配置列表,梯度模式时必填
enable_force_recharge?: boolean // 是否启用强充 enable_force_recharge?: boolean // 是否启用强充
force_recharge_amount?: number // 强充金额(分) force_recharge_amount?: number // 强充金额(分)
packages?: GrantPackageItem[] // 套餐列表 packages: GrantPackageItem[] // 初始套餐列表1100项
expiry_base_override: PackageAllocationExpiryBaseOverride | null expiry_base_override?: PackageAllocationExpiryBaseOverride | null
} }
/** /**
@@ -354,7 +354,7 @@ export interface UpdateShopSeriesGrantRequest {
* 管理套餐请求中的套餐项 * 管理套餐请求中的套餐项
*/ */
export interface GrantPackageItem { export interface GrantPackageItem {
package_id?: number // 套餐ID package_id: number // 套餐ID
cost_price?: number // 成本价(分) cost_price?: number // 成本价(分)
expiry_base_override?: PackageAllocationExpiryBaseOverride | null expiry_base_override?: PackageAllocationExpiryBaseOverride | null
remove?: boolean | null // 是否删除该套餐授权true=删除) remove?: boolean | null // 是否删除该套餐授权true=删除)
@@ -364,6 +364,6 @@ export interface GrantPackageItem {
* 管理套餐请求 * 管理套餐请求
*/ */
export interface ManageGrantPackagesRequest { export interface ManageGrantPackagesRequest {
packages?: GrantPackageItem[] | null // 套餐操作列表 packages: GrantPackageItem[] // 套餐操作列表1100项
expiry_base_override: PackageAllocationExpiryBaseOverride | null // 新增套餐统一生效条件 expiry_base_override?: PackageAllocationExpiryBaseOverride | null // 新增套餐统一生效条件
} }

View File

@@ -0,0 +1,37 @@
import type { PaginationParams } from './common'
export type SystemConfigModule = 'carrier_callback' | 'c2b.payment'
export type SystemConfigValueType = 'string' | 'int' | 'bool' | 'json'
export interface SystemConfigItem {
config_key: string
module: SystemConfigModule
value: string
value_type: SystemConfigValueType
description: string
control: string
enum_values: string[]
min: number | null
max: number | null
readonly: boolean
registered: boolean
sensitive: boolean
updated_at: string | null
}
export interface SystemConfigQueryParams extends PaginationParams {
module?: SystemConfigModule
}
export interface SystemConfigPageResult {
list: SystemConfigItem[]
page: number
page_size: number
total: number
}
export interface UpdateSystemConfigRequest {
key: string
value: string
}

View File

@@ -0,0 +1,44 @@
import type { GrantPackageInfo, PackageResponse } from '@/types/api'
export interface GrantPackageCandidate {
id: number
package_name?: string
package_code?: string
cost_price?: number
suggested_retail_price?: number | null
is_authorized: boolean
authorized_package?: GrantPackageInfo
}
export const mergeGrantPackageCandidates = (
candidates: PackageResponse[],
authorizedPackages: GrantPackageInfo[]
): GrantPackageCandidate[] => {
const merged = new Map<number, GrantPackageCandidate>()
candidates.forEach((candidate) => {
merged.set(candidate.id, {
id: candidate.id,
package_name: candidate.package_name,
package_code: candidate.package_code,
cost_price: candidate.cost_price,
suggested_retail_price: candidate.suggested_retail_price,
is_authorized: false
})
})
authorizedPackages.forEach((authorizedPackage) => {
const candidate = merged.get(authorizedPackage.package_id)
merged.set(authorizedPackage.package_id, {
id: authorizedPackage.package_id,
package_name: candidate?.package_name ?? authorizedPackage.package_name,
package_code: candidate?.package_code ?? authorizedPackage.package_code,
cost_price: authorizedPackage.cost_price ?? candidate?.cost_price,
suggested_retail_price: candidate?.suggested_retail_price,
is_authorized: true,
authorized_package: authorizedPackage
})
})
return [...merged.values()]
}

View File

@@ -245,7 +245,7 @@
:key="pkg.id" :key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`" :label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id" :value="pkg.id"
:disabled="isPackageAlreadyAdded(pkg.id)" :disabled="pkg.is_authorized"
/> />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
@@ -345,7 +345,6 @@
} from '@/api/modules' } from '@/api/modules'
import type { import type {
ShopSeriesGrantResponse, ShopSeriesGrantResponse,
PackageResponse,
GrantPackageItem, GrantPackageItem,
GrantPackageInfo, GrantPackageInfo,
PackageAllocationExpiryBaseOverride, PackageAllocationExpiryBaseOverride,
@@ -353,6 +352,10 @@
} from '@/types/api' } from '@/types/api'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText } from '@/config/constants' import { getEnableStatusText } from '@/config/constants'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate
} from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantsDetail' }) defineOptions({ name: 'SeriesGrantsDetail' })
@@ -366,7 +369,7 @@
const seriesData = ref<PackageSeriesResponse | null>(null) const seriesData = ref<PackageSeriesResponse | null>(null)
const packageDialogVisible = ref(false) const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add') const packageDialogType = ref<'add' | 'edit'>('add')
const availablePackages = ref<PackageResponse[]>([]) const availablePackages = ref<GrantPackageCandidate[]>([])
const packageFormRef = ref<FormInstance>() const packageFormRef = ref<FormInstance>()
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default' type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
@@ -559,7 +562,10 @@
const res = await PackageManageService.getPackages(params) const res = await PackageManageService.getPackages(params)
if (res.code === 0) { if (res.code === 0) {
availablePackages.value = res.data.items availablePackages.value = mergeGrantPackageCandidates(
res.data.items,
detailData.value.packages || []
)
} }
} catch (error) { } catch (error) {
console.error('加载套餐选项失败:', error) console.error('加载套餐选项失败:', error)
@@ -577,11 +583,6 @@
} }
} }
// 检查套餐是否已添加
const isPackageAlreadyAdded = (packageId: number) => {
return detailData.value?.packages?.some((p) => p.package_id === packageId) || false
}
// 保存套餐 // 保存套餐
const handleSavePackage = async () => { const handleSavePackage = async () => {
if (!packageFormRef.value || !detailData.value) return if (!packageFormRef.value || !detailData.value) return
@@ -600,7 +601,7 @@
: packageForm.value.expiry_base_override, : packageForm.value.expiry_base_override,
packages: [ packages: [
{ {
package_id: packageForm.value.package_id, package_id: packageForm.value.package_id!,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100) cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
} }
] ]
@@ -616,7 +617,7 @@
const packages: GrantPackageItem[] = [ const packages: GrantPackageItem[] = [
{ {
package_id: packageForm.value.package_id, package_id: packageForm.value.package_id!,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100) cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
} }
] ]
@@ -672,10 +673,7 @@
} }
] ]
await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, { await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, { packages })
expiry_base_override: row.expiry_base_override ?? null,
packages
})
ElMessage.success('删除成功') ElMessage.success('删除成功')
// 刷新详情数据 // 刷新详情数据

View File

@@ -113,17 +113,18 @@
<!-- 套餐配置 --> <!-- 套餐配置 -->
<div v-if="form.shop_id" class="form-section-title"> <div v-if="form.shop_id" class="form-section-title">
<span class="title-text">套餐配置</span> <span class="title-text">套餐配置</span>
</div> </div>
<template v-if="form.shop_id"> <template v-if="form.shop_id">
<!-- 选择套餐 --> <!-- 选择套餐 -->
<ElFormItem label="选择套餐"> <ElFormItem label="选择套餐" prop="packages">
<ElSelect <ElSelect
v-model="selectedPackageIds" v-model="selectedPackageIds"
placeholder="请选择套餐" placeholder="请选择套餐"
style="width: 100%" style="width: 100%"
multiple multiple
:multiple-limit="100"
filterable filterable
remote remote
:remote-method="searchPackages" :remote-method="searchPackages"
@@ -142,7 +143,7 @@
:value="pkg.id" :value="pkg.id"
/> />
</ElSelect> </ElSelect>
<div class="form-tip">选择该授权下包含的套餐</div> <div class="form-tip">首次创建必须选择 1100 个不重复套餐</div>
</ElFormItem> </ElFormItem>
<!-- 套餐成本价 --> <!-- 套餐成本价 -->
@@ -799,6 +800,24 @@
shop_id: [{ required: true, message: '请选择店铺', trigger: 'change' }] shop_id: [{ required: true, message: '请选择店铺', trigger: 'change' }]
} }
baseRules.packages = [
{
validator: (_rule, value, callback) => {
const packageIds = (value || []).map((pkg: { package_id: number }) => pkg.package_id)
if (packageIds.length < 1) {
callback(new Error('请至少选择 1 个套餐'))
} else if (packageIds.length > 100) {
callback(new Error('最多选择 100 个套餐'))
} else if (new Set(packageIds).size !== packageIds.length) {
callback(new Error('套餐不能重复'))
} else {
callback()
}
},
trigger: 'change'
}
]
// 只有启用一次性佣金时才添加佣金验证规则 // 只有启用一次性佣金时才添加佣金验证规则
if (form.enable_one_time_commission) { if (form.enable_one_time_commission) {
// 根据佣金类型添加验证规则 // 根据佣金类型添加验证规则
@@ -1092,9 +1111,9 @@
// 移除套餐 // 移除套餐
const removePackage = (index: number) => { const removePackage = (index: number) => {
const removedPackageId = form.packages[index]?.package_id
form.packages.splice(index, 1) form.packages.splice(index, 1)
// 同步更新选中的套餐ID // 同步更新选中的套餐ID
const removedPackageId = form.packages[index]?.package_id
if (removedPackageId) { if (removedPackageId) {
const idIndex = selectedPackageIds.value.indexOf(removedPackageId) const idIndex = selectedPackageIds.value.indexOf(removedPackageId)
if (idIndex > -1) { if (idIndex > -1) {
@@ -1732,6 +1751,20 @@
cost_price: Math.round((pkg.cost_price ?? 0) * 100) cost_price: Math.round((pkg.cost_price ?? 0) * 100)
})) }))
if (dialogType.value === 'add') {
const packageIds = packageItems.map((pkg: { package_id: number }) => pkg.package_id)
if (packageIds.length < 1 || packageIds.length > 100) {
ElMessage.error('首次创建必须选择 1100 个套餐')
submitLoading.value = false
return
}
if (new Set(packageIds).size !== packageIds.length) {
ElMessage.error('套餐不能重复')
submitLoading.value = false
return
}
}
if (packageItems.length > 0) { if (packageItems.length > 0) {
// 验证是否包含赠送套餐 // 验证是否包含赠送套餐
for (const pkg of form.packages) { for (const pkg of form.packages) {
@@ -1815,8 +1848,8 @@
let fieldsToValidate: string[] = [] let fieldsToValidate: string[] = []
if (currentStep.value === 0) { if (currentStep.value === 0) {
// 第一步:验证系列店铺选择 // 第一步:验证系列店铺和初始套餐
fieldsToValidate = ['series_id', 'shop_id'] fieldsToValidate = ['series_id', 'shop_id', 'packages']
} }
try { try {

View File

@@ -100,7 +100,7 @@
:key="pkg.id" :key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`" :label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id" :value="pkg.id"
:disabled="isPackageAlreadyAdded(pkg.id)" :disabled="pkg.is_authorized"
/> />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
@@ -193,10 +193,13 @@
import type { import type {
GrantPackageInfo, GrantPackageInfo,
GrantPackageItem, GrantPackageItem,
PackageAllocationExpiryBaseOverride, PackageAllocationExpiryBaseOverride
PackageResponse
} from '@/types/api' } from '@/types/api'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate
} from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantPackages' }) defineOptions({ name: 'SeriesGrantPackages' })
@@ -213,7 +216,7 @@
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<PackageResponse[]>([]) const availablePackages = ref<GrantPackageCandidate[]>([])
const packageFormRef = ref<FormInstance>() const packageFormRef = ref<FormInstance>()
const packageDialogVisible = ref(false) const packageDialogVisible = ref(false)
@@ -378,7 +381,7 @@
} }
const res = await PackageManageService.getPackages(params) const res = await PackageManageService.getPackages(params)
if (res.code === 0) { if (res.code === 0) {
availablePackages.value = res.data.items availablePackages.value = mergeGrantPackageCandidates(res.data.items, packageList.value)
} }
} catch (error) { } catch (error) {
console.error('加载套餐选项失败:', error) console.error('加载套餐选项失败:', error)
@@ -395,10 +398,6 @@
} }
} }
const isPackageAlreadyAdded = (packageId: number) => {
return packageList.value.some((p) => p.package_id === packageId)
}
watch( watch(
() => packageForm.value.package_id, () => packageForm.value.package_id,
(packageId) => { (packageId) => {
@@ -440,7 +439,7 @@
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: [{ package_id: packageForm.value.package_id, cost_price: costPrice }] packages: [{ package_id: packageForm.value.package_id!, cost_price: costPrice }]
}) })
} else { } else {
const expiryBaseChanged = const expiryBaseChanged =
@@ -452,7 +451,7 @@
} }
const packages: GrantPackageItem[] = [ const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id, cost_price: costPrice } { package_id: packageForm.value.package_id!, cost_price: costPrice }
] ]
if (expiryBaseChanged) { if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase( await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
@@ -499,10 +498,7 @@
remove: true remove: true
} }
] ]
await ShopSeriesGrantService.manageGrantPackages(grantId.value, { await ShopSeriesGrantService.manageGrantPackages(grantId.value, { packages })
expiry_base_override: row.expiry_base_override ?? null,
packages
})
await fetchPackageList() await fetchPackageList()
} catch (error) { } catch (error) {
console.error(error) console.error(error)

View File

@@ -0,0 +1,351 @@
<template>
<ArtTableFullScreen>
<div class="system-configs-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
label-width="90px"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="loadConfigs"
/>
<ArtTable
ref="tableRef"
row-key="config_key"
:loading="loading"
:data="configList"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<ElDialog
v-model="dialogVisible"
title="编辑系统配置"
width="560px"
:close-on-click-modal="false"
>
<ElForm label-width="120px">
<ElFormItem label="配置 Key">
<ElInput :model-value="currentConfig?.config_key || '-'" disabled />
</ElFormItem>
<ElFormItem label="配置说明">
<span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem>
<ElFormItem label="配置值">
<ElSwitch v-if="isBooleanConfig" v-model="editBoolean" />
<ElInputNumber
v-else-if="isIntegerConfig"
v-model="editNumber"
:min="currentConfig?.min ?? undefined"
:max="currentConfig?.max ?? undefined"
:precision="0"
controls-position="right"
style="width: 100%"
/>
<ElSelect v-else-if="isEnumConfig" v-model="editValue" style="width: 100%">
<ElOption
v-for="item in currentConfig?.enum_values"
:key="item"
:value="item"
:label="item"
/>
</ElSelect>
<ElInput
v-else-if="isJsonConfig"
v-model="editValue"
type="textarea"
:rows="6"
placeholder="请输入 JSON 配置"
/>
<ElInput
v-else
v-model="editValue"
:type="currentConfig?.sensitive ? 'password' : 'text'"
:show-password="Boolean(currentConfig?.sensitive)"
placeholder="请输入配置值"
/>
</ElFormItem>
<div v-if="valueHint" class="config-hint">{{ valueHint }}</div>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitLoading" @click="handleSubmit"> 保存 </ElButton>
</template>
</ElDialog>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { SystemConfigItem, SystemConfigModule } from '@/types/api/systemConfig'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
defineOptions({ name: 'SystemConfigs' })
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
{ label: 'C 端支付方式配置', value: 'c2b.payment' }
]
const searchForm = reactive<{ module?: SystemConfigModule }>({ module: undefined })
const searchFormItems: SearchFormItem[] = [
{
label: '配置模块',
prop: 'module',
type: 'select',
options: moduleOptions,
config: { clearable: true, placeholder: '请选择配置模块' }
}
]
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const tableRef = ref()
const currentConfig = ref<SystemConfigItem | null>(null)
const configList = ref<SystemConfigItem[]>([])
const editValue = ref('')
const originalValue = ref('')
const editBoolean = ref(false)
const editNumber = ref<number | null>(null)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const columnOptions = [
{ label: '配置 Key', prop: 'config_key' },
{ label: '模块', prop: 'module' },
{ label: '配置说明', prop: 'description' },
{ label: '配置值', prop: 'value' },
{ label: '控件', prop: 'control' },
{ label: '注册状态', prop: 'registered' },
{ label: '只读', prop: 'readonly' },
{ label: '敏感', prop: 'sensitive' },
{ label: '更新时间', prop: 'updated_at' }
]
const moduleLabel = (module: SystemConfigModule) =>
moduleOptions.find((item) => item.value === module)?.label || module
const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'config_key', label: '配置 Key', minWidth: 220, showOverflowTooltip: true },
{
prop: 'module',
label: '模块',
minWidth: 150,
formatter: (row: SystemConfigItem) => moduleLabel(row.module)
},
{ prop: 'description', label: '配置说明', minWidth: 180, showOverflowTooltip: true },
{
prop: 'value',
label: '配置值',
minWidth: 150,
formatter: (row: SystemConfigItem) => row.value || '-'
},
{ prop: 'control', label: '控件', width: 100 },
{
prop: 'registered',
label: '注册状态',
width: 100,
formatter: (row: SystemConfigItem) =>
h(ElTag, { type: row.registered ? 'success' : 'info' }, () =>
row.registered ? '已注册' : '未注册'
)
},
{
prop: 'readonly',
label: '只读',
width: 80,
formatter: (row: SystemConfigItem) => (row.readonly ? '是' : '否')
},
{
prop: 'sensitive',
label: '敏感',
width: 80,
formatter: (row: SystemConfigItem) => (row.sensitive ? '是' : '否')
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: SystemConfigItem) => formatDateTime(row.updated_at)
}
])
const isBooleanConfig = computed(
() => currentConfig.value?.value_type === 'bool' || currentConfig.value?.control === 'switch'
)
const isIntegerConfig = computed(
() => !isBooleanConfig.value && currentConfig.value?.value_type === 'int'
)
const isEnumConfig = computed(
() =>
!isBooleanConfig.value &&
!isIntegerConfig.value &&
Boolean(currentConfig.value?.enum_values?.length)
)
const isJsonConfig = computed(
() =>
!isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json'
)
const valueHint = computed(() => {
if (!currentConfig.value) return ''
const { min, max } = currentConfig.value
if (min !== null && max !== null) return `取值范围:${min}${max}`
if (min !== null) return `最小值:${min}`
if (max !== null) return `最大值:${max}`
return ''
})
const getSubmittedValue = () => {
if (isBooleanConfig.value) return String(editBoolean.value)
if (isIntegerConfig.value) return editNumber.value === null ? '' : String(editNumber.value)
return editValue.value
}
const validateValue = (value: string) => {
const config = currentConfig.value
if (!config) return '配置不存在'
if (config.enum_values.length > 0 && !config.enum_values.includes(value)) {
return '配置值不在允许的枚举范围内'
}
if (config.value_type === 'int') {
if (!/^-?\d+$/.test(value)) return '请输入整数'
const numberValue = Number(value)
if (config.min !== null && numberValue < config.min) return `配置值不能小于 ${config.min}`
if (config.max !== null && numberValue > config.max) return `配置值不能大于 ${config.max}`
}
if (config.value_type === 'json') {
try {
JSON.parse(value)
} catch {
return '请输入有效的 JSON'
}
}
if (value === '') return '配置值不能为空'
return ''
}
const getActions = (row: SystemConfigItem) =>
row.readonly
? []
: [{ label: '编辑', handler: () => showEditDialog(row), type: 'primary' as const }]
const loadConfigs = async () => {
loading.value = true
try {
const res = await SystemConfigService.getSystemConfigs({
page: pagination.page,
page_size: pagination.page_size,
module: searchForm.module
})
if (res.code === 0) {
configList.value = res.data.list || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error('获取系统配置失败:', error)
} finally {
loading.value = false
}
}
const showEditDialog = (row: SystemConfigItem) => {
currentConfig.value = row
originalValue.value = row.value
editValue.value = row.value
editBoolean.value = row.value === 'true'
editNumber.value = /^-?\d+$/.test(row.value) ? Number(row.value) : null
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!currentConfig.value || currentConfig.value.readonly) return
const value = getSubmittedValue()
if (currentConfig.value.sensitive && value === originalValue.value) {
ElMessage.warning('敏感配置未输入新值,无需保存')
return
}
const errorMessage = validateValue(value)
if (errorMessage) {
ElMessage.warning(errorMessage)
return
}
submitLoading.value = true
try {
const res = await SystemConfigService.updateSystemConfig(currentConfig.value.config_key, {
key: currentConfig.value.config_key,
value
})
if (res.code === 0) {
ElMessage.success('系统配置更新成功')
dialogVisible.value = false
await loadConfigs()
} else {
ElMessage.error(res.msg || '系统配置更新失败')
}
} catch (error) {
console.error('更新系统配置失败:', error)
} finally {
submitLoading.value = false
}
}
const handleReset = () => {
searchForm.module = undefined
pagination.page = 1
loadConfigs()
}
const handleSearch = () => {
pagination.page = 1
loadConfigs()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadConfigs()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadConfigs()
}
onMounted(loadConfigs)
</script>
<style scoped lang="scss">
.system-configs-page {
.config-hint {
margin: -12px 0 0 120px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>