This commit is contained in:
2026-05-13 14:43:14 +08:00
parent 87f2d314e0
commit 59d725ee65
64 changed files with 3334 additions and 872 deletions

View File

@@ -62,14 +62,27 @@ setupVbenVxeTable({
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
// 表格配置项可以用 cellRender: { name: 'CellLink' };未传 text 时默认展示当前列字段值
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
const raw =
props?.text !== undefined && props?.text !== null && props?.text !== ''
? props.text
: get(row, column.field);
const display =
raw === null || raw === undefined || raw === '' ? '—' : String(raw);
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
{
size: 'small',
type: 'link',
onClick: isFunction(props?.onClick)
? () => (props.onClick as (r: Recordable) => void)(row)
: undefined,
},
{ default: () => display },
);
},
});
@@ -160,10 +173,10 @@ setupVbenVxeTable({
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
} else {
return { ...defaultProps, ...presets[opt.code], ...opt };
}
@@ -177,6 +190,17 @@ setupVbenVxeTable({
})
.filter((opt) => opt.show !== false);
function isOperationDisabled(opt: Recordable<any>, r: Recordable<any>) {
const d = opt.disabled;
if (d === true) {
return true;
}
if (isFunction(d)) {
return !!d(r);
}
return false;
}
function renderBtn(opt: Recordable<any>, listen = true) {
return h(
Button,
@@ -185,11 +209,15 @@ setupVbenVxeTable({
...opt,
icon: undefined,
onClick: listen
? () =>
? () => {
if (isOperationDisabled(opt, row)) {
return;
}
attrs?.onClick?.({
code: opt.code,
row,
})
});
}
: undefined,
},
{
@@ -225,6 +253,9 @@ setupVbenVxeTable({
...opt,
icon: undefined,
onConfirm: () => {
if (isOperationDisabled(opt, row)) {
return;
}
attrs?.onClick?.({
code: opt.code,
row,

View File

@@ -2,8 +2,11 @@ import { requestClient } from '#/api/request';
export namespace AgentApi {
export interface AgentListItem {
id: number;
user_id: number;
id: string | number;
user_id: string | number;
level?: number;
agent_code?: number;
team_leader_id?: string;
level_name: string;
region: string;
mobile: string;
@@ -13,10 +16,17 @@ export namespace AgentApi {
frozen_balance: number;
withdrawn_amount: number;
create_time: string;
is_real_name_verified: boolean;
is_real_name_verified?: boolean;
/** 与后端 json 字段 is_real_name 一致 */
is_real_name?: boolean;
real_name: string;
id_card: string;
real_name_status: 'approved' | 'pending' | 'rejected';
// 订单统计相关字段
total_orders?: number; // 订单总数
total_order_amount?: number; // 订单总金额
total_agent_profit?: number; // 代理总收益
subordinate_count?: number; // 下级数量
}
export interface AgentList {
@@ -24,17 +34,34 @@ export namespace AgentApi {
items: AgentListItem[];
}
/** 后台代理团队树节点 */
export interface AgentTeamTreeNode {
id: string;
agent_code: number;
level: number;
level_name: string;
mobile: string;
is_anchor: boolean;
children?: AgentTeamTreeNode[];
}
export interface GetAgentListParams {
page: number;
pageSize: number;
mobile?: string;
agent_code?: string;
agent_id?: string;
region?: string;
parent_agent_id?: number;
level?: number;
team_leader_id?: string;
id?: number;
create_time_start?: string;
create_time_end?: string;
order_by?: string;
order_type?: 'asc' | 'desc';
/** 实名姓名模糊筛选(须已三要素核验) */
real_name?: string;
}
export interface AgentLinkListItem {
@@ -54,15 +81,18 @@ export namespace AgentApi {
page: number;
pageSize: number;
agent_id?: number;
agent_code?: string;
product_name?: string;
link_identifier?: string;
}
// 代理佣金相关接口
export interface AgentCommissionListItem {
id: number;
agent_id: number;
order_id: number;
id: string | number;
agent_id: string | number;
agent_code?: number;
order_id: string | number;
order_no: string;
amount: number;
product_name: string;
status: number;
@@ -78,7 +108,8 @@ export namespace AgentApi {
page: number;
pageSize: number;
agent_id?: number;
product_name?: string;
agent_code?: string;
order_no?: string;
status?: number;
}
@@ -109,6 +140,7 @@ export namespace AgentApi {
export interface AgentWithdrawalListItem {
id: string;
agent_id: string;
agent_code?: number;
withdraw_no: string;
amount: number;
tax_amount: number;
@@ -132,9 +164,12 @@ export namespace AgentApi {
page: number;
pageSize: number;
agent_id?: number | string;
status?: number;
agent_code?: string;
withdraw_no?: string;
withdrawal_type?: number;
status?: number | string;
withdrawal_type?: number | string;
payee_account?: string;
payee_name?: string;
}
export interface AuditWithdrawalParams {
@@ -313,11 +348,55 @@ export namespace AgentApi {
items: T[];
}
/** 后台代理订单列表行(与 AdminGetAgentOrderList 对齐) */
export interface AgentOrderListItem {
id: string;
agent_id: string;
agent_code: number;
order_id: string;
order_no: string;
product_id: string;
product_name: string;
order_amount: number;
set_price: number;
actual_base_price: number;
price_cost: number;
agent_profit: number;
process_status: number;
order_status: string;
create_time: string;
}
export interface GetAgentOrderListParams {
page: number;
pageSize: number;
agent_id?: number | string;
[key: string]: any;
agent_code?: string;
order_id?: string;
order_no?: string;
process_status?: number;
order_status?: string;
}
/** 单笔订单分账(佣金 + 返佣) */
export interface AdminGetAgentOrderSettlementResp {
commissions: AgentCommissionListItem[];
rebates: AgentRebateListItem[];
}
/** 后台返佣列表行 */
export interface AgentRebateListItem {
id: string;
agent_id: string;
agent_code: number;
source_agent_id: string;
source_agent_code: number;
order_id: string;
order_no: string;
rebate_type: number;
amount: number;
status: number;
create_time: string;
}
export interface GetAgentRealNameListParams {
@@ -331,21 +410,28 @@ export namespace AgentApi {
page: number;
pageSize: number;
agent_id?: number | string;
[key: string]: any;
agent_code?: string;
source_agent_code?: string;
order_no?: string;
rebate_type?: number;
status?: number;
}
export interface GetAgentUpgradeListParams {
page: number;
pageSize: number;
agent_id?: number | string;
[key: string]: any;
agent_code?: string;
upgrade_type?: number;
status?: number;
}
export interface GetInviteCodeListParams {
page: number;
pageSize: number;
code?: string;
status?: number | string;
target_level?: number;
[key: string]: any;
}
export interface GenerateDiamondInviteCodeParams {
@@ -408,6 +494,16 @@ async function getAgentList(params: AgentApi.GetAgentListParams) {
});
}
/** 后台:代理团队树(同钻石团队 + 邀请关系) */
async function getAgentTeamTree(agentId: string) {
return requestClient.get<{ root: AgentApi.AgentTeamTreeNode }>(
'/agent/team/tree',
{
params: { agent_id: agentId },
},
);
}
/**
* 获取代理推广链接列表
*/
@@ -602,12 +698,20 @@ async function getInviteCodeList(params: AgentApi.GetInviteCodeListParams) {
* 获取代理订单列表(后台)
*/
async function getAgentOrderList(params: AgentApi.GetAgentOrderListParams) {
return requestClient.get<AgentApi.AdminListResp<Record<string, any>>>(
return requestClient.get<AgentApi.AdminListResp<AgentApi.AgentOrderListItem>>(
'/agent/order/list',
{ params },
);
}
/** 单笔订单分账视图(单次请求,避免双接口重复鉴权) */
async function getAgentOrderSettlement(params: { order_no: string }) {
return requestClient.get<AgentApi.AdminGetAgentOrderSettlementResp>(
'/agent/order/settlement',
{ params },
);
}
/**
* 获取代理实名认证列表(后台)
*/
@@ -624,7 +728,7 @@ async function getAgentRealNameList(
* 获取代理返佣列表(后台)
*/
async function getAgentRebateList(params: AgentApi.GetAgentRebateListParams) {
return requestClient.get<AgentApi.AdminListResp<Record<string, any>>>(
return requestClient.get<AgentApi.AdminListResp<AgentApi.AgentRebateListItem>>(
'/agent/rebate/list',
{ params },
);
@@ -659,8 +763,10 @@ export {
getAgentConfig,
getAgentLinkList,
getAgentList,
getAgentTeamTree,
getAgentMembershipConfigList,
getAgentOrderList,
getAgentOrderSettlement,
getAgentPlatformDeductionList,
getAgentProductionConfigList,
getAgentRealNameList,

View File

@@ -58,11 +58,27 @@ async function getNotificationList(params: Recordable<any>) {
return requestClient.get<NotificationApi.NotificationList>(
'/notification/list',
{
params,
params: {
...params,
_ts: Date.now(),
},
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
},
);
}
/**
* 获取通知详情(按 ID
*/
async function getNotificationDetail(id: number) {
return requestClient.get<NotificationApi.NotificationItem>(
`/notification/detail/${id}`,
);
}
/**
* 创建通知
*/
@@ -100,6 +116,7 @@ async function deleteNotification(id: number) {
export {
createNotification,
deleteNotification,
getNotificationDetail,
getNotificationList,
updateNotification,
};

View File

@@ -17,6 +17,10 @@ export namespace OrderApi {
pay_time: null | string;
refund_time: null | string;
update_time: string;
// 代理相关字段
is_agent_order: boolean; // 是否是代理订单
agent_code?: string; // 代理编号
agent_process_status?: string; // 代理处理状态
}
export interface OrderList {

View File

@@ -25,7 +25,6 @@ export namespace ProductApi {
product_en: string;
description: string;
notes?: string;
cost_price: number;
sell_price: number;
}
@@ -34,7 +33,6 @@ export namespace ProductApi {
product_en?: string;
description?: string;
notes?: string;
cost_price?: number;
sell_price?: number;
}
@@ -44,6 +42,8 @@ export namespace ProductApi {
feature_id: number;
api_id: string;
name: string;
/** 模块成本(元),来自 feature用于产品侧展示与汇总 */
cost_price: number;
sort: number;
enable: number;
is_important: number;

View File

@@ -58,7 +58,7 @@ async function deleteUser(id: string) {
* @param data.password 新密码
*/
async function resetPassword(id: string, data: { password: string }) {
return requestClient.post(`/reset-password/${id}`, data);
return requestClient.put(`/user/reset-password/${id}`, data);
}
export { createUser, deleteUser, getUserList, resetPassword, updateUser };

View File

@@ -0,0 +1,121 @@
import type { Router } from 'vue-router';
/** 跨页进入代理列表时的一次性定位数据(不写 URL避免重置后仍被 query 合并) */
export const AGENT_LIST_FOCUS_STORAGE_KEY = 'qnc_admin_agent_list_focus_v1';
export const AGENT_LIST_FOCUS_EVENT = 'qnc:agent-list-focus';
/** 旧版曾把面包屑栈写入 sessionStorage已不再持久化进入列表时应清除以免把 team_leader_id 写回 URL */
export const AGENT_LIST_NAV_STACK_KEY = 'qnc_admin_agent_list_nav_stack_v1';
export type AgentListNavEntry =
| { kind: 'full' }
| { kind: 'subs'; leaderId: string; leaderCode?: number }
| { kind: 'focus'; agentId?: string; agentCode?: number };
export type AgentListFocusPayload = {
agent_code?: string;
agent_id?: string;
};
/** 清除历史上保存在 sessionStorage 的导航栈(避免新开/重进列表仍恢复下级视图 URL */
export function clearStaleAgentListNavStackStorage() {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(AGENT_LIST_NAV_STACK_KEY);
}
/** 从定位 payload 生成导航栈节点(用于面包屑) */
export function navEntryFromFocusPayload(
p: AgentListFocusPayload,
): AgentListNavEntry | null {
const agentId =
p.agent_id != null && String(p.agent_id).trim() !== ''
? String(p.agent_id)
: undefined;
let agentCode: number | undefined;
if (p.agent_code != null && String(p.agent_code).trim() !== '') {
const n = Number(p.agent_code);
if (!Number.isNaN(n)) {
agentCode = n;
}
}
if (!agentId && agentCode == null) {
return null;
}
return { kind: 'focus', agentId, agentCode };
}
function buildPayload(opts: {
agentCode?: string | number | null;
agentId?: string | null;
}): AgentListFocusPayload | null {
const payload: AgentListFocusPayload = {};
if (opts.agentCode != null && opts.agentCode !== '') {
payload.agent_code = String(opts.agentCode);
}
if (opts.agentId != null && opts.agentId !== '') {
payload.agent_id = String(opts.agentId);
}
if (!payload.agent_code && !payload.agent_id) {
return null;
}
return payload;
}
/**
* 从 sessionStorage 读取并清除一次性定位数据(用于代理列表页首次进入)。
*/
export function consumeAgentListFocusFromStorage(): AgentListFocusPayload | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(AGENT_LIST_FOCUS_STORAGE_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(AGENT_LIST_FOCUS_STORAGE_KEY);
try {
const v = JSON.parse(raw) as AgentListFocusPayload;
if (!v || typeof v !== 'object') {
return null;
}
return v;
} catch {
return null;
}
}
/**
* 跳转到代理列表并定位到指定代理。
* 不写 agent_code / agent_id 到 URL避免点击「重置」后仍被地址栏参数合并进请求。
* 已在代理列表页内跳转时通过 CustomEvent 通知当前页应用定位。
*/
export function navigateToAgentList(
router: Router,
opts: { agentCode?: string | number | null; agentId?: string | null },
) {
const payload = buildPayload(opts);
if (!payload) {
return;
}
const isAlreadyOnList = router.currentRoute.value.name === 'AgentList';
if (isAlreadyOnList) {
// 先同步写入 ephemeral再清空 URL。否则仍带着 team_leader_id 时,第一次请求后 ephemeral 被清空,
// 后续请求会重新按 URL 筛「下级列表」,表现为「查看上级」后再点「查看下级」无效或列表错乱。
window.dispatchEvent(
new CustomEvent(AGENT_LIST_FOCUS_EVENT, { detail: payload }),
);
void router.replace({ name: 'AgentList', query: {} });
return;
}
sessionStorage.setItem(AGENT_LIST_FOCUS_STORAGE_KEY, JSON.stringify(payload));
router.push({
name: 'AgentList',
query: {},
});
}

View File

@@ -0,0 +1,64 @@
import type { Router } from 'vue-router';
/** 跨页进入订单列表时的一次性定位数据(不写 URL避免重置后仍被 query 合并) */
export const ORDER_LIST_FOCUS_STORAGE_KEY = 'qnc_admin_order_list_focus_v1';
export const ORDER_LIST_FOCUS_EVENT = 'qnc:order-list-focus';
export type OrderListFocusPayload = {
order_no?: string;
};
export function consumeOrderListFocusFromStorage(): OrderListFocusPayload | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(ORDER_LIST_FOCUS_STORAGE_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(ORDER_LIST_FOCUS_STORAGE_KEY);
try {
const v = JSON.parse(raw) as OrderListFocusPayload;
if (!v || typeof v !== 'object') {
return null;
}
return v;
} catch {
return null;
}
}
/**
* 跳转到订单列表并按商户订单号筛选。
* 不写 order_no 到 URL避免点击「重置」后仍被地址栏参数合并进请求。
* 已在订单列表页内跳转时通过 CustomEvent 通知当前页应用筛选。
*/
export function navigateToOrderList(
router: Router,
opts: { orderNo?: string | null },
) {
const orderNo = opts.orderNo != null ? String(opts.orderNo).trim() : '';
if (!orderNo) {
return;
}
const payload: OrderListFocusPayload = { order_no: orderNo };
const r = router.currentRoute.value;
const onOrderList =
r.name === 'Order' || r.path === '/order' || r.path.startsWith('/order?');
if (onOrderList) {
window.dispatchEvent(
new CustomEvent(ORDER_LIST_FOCUS_EVENT, { detail: payload }),
);
void router.replace({ path: '/order', query: {} });
return;
}
sessionStorage.setItem(
ORDER_LIST_FOCUS_STORAGE_KEY,
JSON.stringify(payload),
);
router.push({ path: '/order', query: {} });
}

View File

@@ -0,0 +1,130 @@
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
import { message } from 'ant-design-vue';
/** 跨页进入平台用户列表时的一次性筛选(不写 URL query避免点「重置」后仍合并进请求 */
export const PLATFORM_USER_LIST_FOCUS_STORAGE_KEY =
'qnc_admin_platform_user_list_focus_v1';
export const PLATFORM_USER_LIST_FOCUS_EVENT =
'qnc:platform-user-list-focus';
export type PlatformUserListFocusPayload = {
user_id?: string;
};
function normPath(p: string) {
return p.replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
}
function routeScore(
r: Pick<RouteLocationNormalizedLoaded, 'path' | 'name'>,
): number {
const p = normPath(r.path || '');
const n = String(r.name ?? '');
if (p.includes('platform-user') && p.includes('list')) {
return 100;
}
if (/platformuserlist/i.test(n)) {
return 90;
}
if (/platform[-_]?user/i.test(p) && /list$/i.test(p)) {
return 80;
}
return 0;
}
function resolvePlatformUserListTarget(router: Router): {
name?: string | symbol;
path?: string;
} | null {
const routes = router.getRoutes();
let best: (typeof routes)[0] | null = null;
let bestScore = 0;
for (const r of routes) {
const s = routeScore(r);
if (s > bestScore) {
bestScore = s;
best = r;
}
}
if (best && bestScore >= 80) {
if (best.name != null && String(best.name) !== '') {
return { name: best.name };
}
return { path: best.path };
}
const fallbackPath = '/platform-user';
const fb = routes.find((r) => normPath(r.path || '') === normPath(fallbackPath));
if (fb?.name != null && String(fb.name) !== '') {
return { name: fb.name };
}
if (fb?.path) {
return { path: fb.path };
}
return null;
}
export function consumePlatformUserListFocusFromStorage(): PlatformUserListFocusPayload | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(PLATFORM_USER_LIST_FOCUS_STORAGE_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(PLATFORM_USER_LIST_FOCUS_STORAGE_KEY);
try {
const v = JSON.parse(raw) as PlatformUserListFocusPayload;
if (!v || typeof v !== 'object') {
return null;
}
return v;
} catch {
return null;
}
}
/**
* 跳转到平台用户列表并按用户主键 ID 精确筛选。
*/
export function navigateToPlatformUserList(
router: Router,
opts: { userId: string | number | null | undefined },
) {
const userId = opts.userId != null ? String(opts.userId).trim() : '';
if (!userId) {
return;
}
const payload: PlatformUserListFocusPayload = { user_id: userId };
const r = router.currentRoute.value;
const onPlatformUserList = routeScore(r) >= 80;
if (onPlatformUserList) {
window.dispatchEvent(
new CustomEvent(PLATFORM_USER_LIST_FOCUS_EVENT, { detail: payload }),
);
void router.replace({ path: r.path, query: {} });
return;
}
const target = resolvePlatformUserListTarget(router);
if (!target) {
message.warning(
'未找到「平台用户」菜单路由,请确认后台菜单组件为 platform-user',
);
return;
}
sessionStorage.setItem(
PLATFORM_USER_LIST_FOCUS_STORAGE_KEY,
JSON.stringify(payload),
);
if (target.name != null && String(target.name) !== '') {
router.push({ name: target.name as string, query: {} });
} else if (target.path) {
router.push({ path: target.path, query: {} });
}
}

View File

@@ -90,7 +90,8 @@
"setPermissions": "Set Permissions",
"createTime": "Create Time",
"operation": "Operation",
"resetPassword": "Reset Password",
"changePassword": "Change Password",
"changePasswordSuccess": "Password changed successfully",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"confirmPasswordRequired": "Please confirm password",

View File

@@ -92,7 +92,8 @@
"setPermissions": "设置权限",
"createTime": "创建时间",
"operation": "操作",
"resetPassword": "重置密码",
"changePassword": "修改密码",
"changePasswordSuccess": "密码修改成功",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"confirmPasswordRequired": "请确认密码",

View File

@@ -32,6 +32,7 @@ const routes: RouteRecordRaw[] = [
path: '/agent/commission',
name: 'AgentCommission',
meta: {
hideInMenu: true,
icon: 'mdi:cash-multiple',
title: '佣金记录',
},
@@ -41,6 +42,7 @@ const routes: RouteRecordRaw[] = [
path: '/agent/rebate',
name: 'AgentRebate',
meta: {
hideInMenu: true,
icon: 'mdi:currency-usd',
title: '返佣记录',
},

View File

@@ -22,15 +22,6 @@ const routes: RouteRecordRaw[] = [
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
},
];

View File

@@ -65,7 +65,7 @@ export function getOrderProcessStatusName(status: number): string {
*/
export function getUpgradeStatusName(status: number): string {
const map: Record<number, string> = {
1: '待处理',
1: '待支付',
2: '已完成',
3: '已失败',
};

View File

@@ -1,9 +0,0 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
defineOptions({ name: 'About' });
</script>
<template>
<About />
</template>

View File

@@ -1,18 +1,36 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AgentApi } from '#/api/agent';
type OrderNoClickFn = (row: AgentApi.AgentCommissionListItem) => void;
// 佣金记录列表列配置
export function useCommissionColumns(): VxeTableGridOptions['columns'] {
export function useCommissionColumns(
onAgentCodeClick?: (row: AgentApi.AgentCommissionListItem) => void,
onOrderNoClick?: OrderNoClickFn,
): VxeTableGridOptions['columns'] {
return [
{
field: 'agent_id',
title: '代理ID',
width: 100,
field: 'agent_code',
title: '代理编号',
width: 110,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
field: 'order_id',
title: '订单ID',
width: 100,
field: 'order_no',
title: '商户订单号',
minWidth: 180,
cellRender: onOrderNoClick
? {
name: 'CellLink',
props: { onClick: onOrderNoClick },
}
: undefined,
},
{
field: 'amount',
@@ -54,8 +72,21 @@ export function useCommissionFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'product_name',
label: '产品名称',
fieldName: 'agent_code',
label: '代理编号',
componentProps: {
placeholder: '请输入代理编号',
allowClear: true,
},
},
{
component: 'Input',
fieldName: 'order_no',
label: '商户订单号',
componentProps: {
placeholder: '请输入商户订单号',
allowClear: true,
},
},
{
component: 'Select',

View File

@@ -1,15 +1,24 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentCommissionList } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { navigateToOrderList } from '#/composables/use-order-list-navigate';
import { useCommissionColumns, useCommissionFormSchema } from './data';
interface Props {
agentId?: number;
/** 嵌套在弹窗内时,跳转到订单/代理列表后调用(用于关闭弹窗) */
navigateAway?: () => void;
}
interface QueryParams {
@@ -20,29 +29,49 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onCommissionAgentCodeClick(row: AgentApi.AgentCommissionListItem) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
props.navigateAway?.();
}
}
function onOrderNoClick(row: AgentApi.AgentCommissionListItem) {
if (row.order_no) {
navigateToOrderList(router, { orderNo: row.order_no });
props.navigateAway?.();
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCommissionFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useCommissionColumns(),
columns: useCommissionColumns(onCommissionAgentCodeClick, onOrderNoClick),
toolbarConfig: {
custom: true,
export: false,
refresh: false,
search: true,
zoom: true,
},
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async (
{ page }: { page: QueryParams },
formValues: Record<string, any>,
) => {
return await getAgentCommissionList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});
@@ -53,7 +82,7 @@ const [Grid] = useVbenVxeGrid({
total: 'total',
},
},
},
} as VxeTableGridOptions<AgentApi.AgentCommissionListItem>,
});
</script>

View File

@@ -1,18 +1,31 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { onMounted, reactive, ref } from 'vue';
import { h, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Form, InputNumber, Row, Space, message } from 'ant-design-vue';
import {
Alert,
Button,
Card,
Form,
InputNumber,
Modal,
Space,
Typography,
message,
} from 'ant-design-vue';
import { getAgentConfig, updateAgentConfig } from '#/api/agent';
import AgentConfigPreview from './modules/agent-config-preview.vue';
const { Text } = Typography;
const loading = ref(false);
const config = ref<AgentApi.AgentConfig | null>(null);
// 使用 reactive 管理表单数据(价格配置已移除,改为产品配置表管理)
const formData = reactive<AgentApi.AgentConfig>({
level_bonus: {
normal: 0,
@@ -45,7 +58,6 @@ const formData = reactive<AgentApi.AgentConfig>({
diamond_max_uplift_amount: 0,
});
// 加载配置
async function loadConfig() {
loading.value = true;
try {
@@ -59,8 +71,33 @@ async function loadConfig() {
}
}
// 保存配置
async function handleSave() {
function collectSaveWarnings(): string[] {
const w: string[] = [];
const g = Number(formData.upgrade_fee.normal_to_gold);
const d = Number(formData.upgrade_fee.normal_to_diamond);
const rg = Number(formData.upgrade_rebate.normal_to_gold_rebate);
const rd = Number(formData.upgrade_rebate.to_diamond_rebate);
if (rg > g) {
w.push('「普通→黄金」升级返佣大于升级费用。');
}
if (rd > d) {
w.push('「升至钻石」升级返佣大于「普通→钻石」升级费用。');
}
if (d < g) {
w.push('「普通→钻石」费用小于「普通→黄金」费用,黄金升钻石应付差价可能异常。');
}
const r = Number(formData.commission_freeze.ratio);
if (r < 0 || r > 1) {
w.push('佣金冻结比例应在 01 之间。');
}
const tr = Number(formData.tax_rate);
if (tr < 0 || tr > 1) {
w.push('税率应在 01 之间。');
}
return w;
}
async function doSave() {
try {
const params: AgentApi.UpdateAgentConfigParams = {
level_bonus: {
@@ -100,7 +137,30 @@ async function handleSave() {
}
}
// 重置配置
async function handleSave() {
const warnings = collectSaveWarnings();
if (warnings.length > 0) {
Modal.confirm({
title: '保存前请确认',
width: 480,
content: () =>
h('div', { class: 'text-sm' }, [
h('p', { class: 'mb-2 text-gray-600' }, '以下情况可能影响业务逻辑,若确认无误可继续保存:'),
h(
'ul',
{ class: 'mb-0 list-disc pl-4 text-gray-800' },
warnings.map((t) => h('li', { class: 'mb-1' }, t)),
),
]),
okText: '仍要保存',
cancelText: '返回修改',
onOk: () => doSave(),
});
return;
}
await doSave();
}
function handleReset() {
if (config.value) {
Object.assign(formData, config.value);
@@ -114,161 +174,355 @@ onMounted(() => {
<template>
<Page auto-content-height>
<Card title="系统配置" :loading="loading">
<Form layout="vertical">
<Card title="等级奖金" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['level_bonus', 'normal']" label="普通代理奖金">
<InputNumber v-model:value="formData.level_bonus.normal" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['level_bonus', 'gold']" label="黄金代理奖金">
<InputNumber v-model:value="formData.level_bonus.gold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['level_bonus', 'diamond']" label="钻石代理奖金">
<InputNumber v-model:value="formData.level_bonus.diamond" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="等级最高价上调金额" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="gold_max_uplift_amount" label="黄金代理最高价上调金额">
<InputNumber v-model:value="formData.gold_max_uplift_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="diamond_max_uplift_amount" label="钻石代理最高价上调金额">
<InputNumber v-model:value="formData.diamond_max_uplift_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="升级费用" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['upgrade_fee', 'normal_to_gold']" label="普通→黄金">
<InputNumber v-model:value="formData.upgrade_fee.normal_to_gold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['upgrade_fee', 'normal_to_diamond']" label="普通→钻石">
<InputNumber v-model:value="formData.upgrade_fee.normal_to_diamond" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="升级返佣" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item :name="['upgrade_rebate', 'normal_to_gold_rebate']" label="普通→黄金返佣">
<InputNumber v-model:value="formData.upgrade_rebate.normal_to_gold_rebate" :min="0" :precision="2"
:step="0.01" style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item :name="['upgrade_rebate', 'to_diamond_rebate']" label="→钻石返佣">
<InputNumber v-model:value="formData.upgrade_rebate.to_diamond_rebate" :min="0" :precision="2"
:step="0.01" style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="直接上级返佣配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['direct_parent_rebate', 'diamond']" label="直接上级是钻石的返佣金额">
<InputNumber v-model:value="formData.direct_parent_rebate.diamond" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['direct_parent_rebate', 'gold']" label="直接上级是黄金的返佣金额">
<InputNumber v-model:value="formData.direct_parent_rebate.gold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['direct_parent_rebate', 'normal']" label="直接上级是普通的返佣金额">
<InputNumber v-model:value="formData.direct_parent_rebate.normal" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="返佣限额配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="max_gold_rebate_amount" label="黄金代理最大返佣金额">
<InputNumber v-model:value="formData.max_gold_rebate_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="佣金冻结配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['commission_freeze', 'ratio']" label="佣金冻结比例例如0.1表示10%">
<InputNumber v-model:value="formData.commission_freeze.ratio" :min="0" :max="1" :precision="4"
:step="0.0001" style="width: 100%" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['commission_freeze', 'threshold']" label="佣金冻结阈值">
<InputNumber v-model:value="formData.commission_freeze.threshold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['commission_freeze', 'days']" label="佣金冻结解冻天数">
<InputNumber v-model:value="formData.commission_freeze.days" :min="0" :precision="0" :step="1"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="税费配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="tax_rate" label="税率例如0.06表示6%">
<InputNumber v-model:value="formData.tax_rate" :min="0" :max="1" :precision="4" :step="0.0001"
style="width: 100%" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="tax_exemption_amount" label="免税额度">
<InputNumber v-model:value="formData.tax_exemption_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card size="small" title="代理系统配置" class="agent-config-card" :loading="loading">
<template #extra>
<Space>
<Button type="primary" @click="handleSave">保存</Button>
<Button @click="handleReset">重置</Button>
<Button type="primary" size="small" @click="handleSave">
保存
</Button>
<Button size="small" @click="handleReset">重置</Button>
</Space>
</Form>
</template>
<div class="agent-config-layout">
<div class="agent-config-main">
<Form layout="vertical" size="small" class="agent-config-form">
<!-- 等级加成 -->·
<div class="config-panel config-panel--a">
<div class="config-panel-head">
<Text strong class="config-panel-title">等级加成</Text>
<div class="config-panel-desc">
按推广代理等级在产品底价上叠加的金额用于计算代理订单实际底价单位为元
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item :name="['level_bonus', 'normal']" label="普通代理" class="config-cell">
<InputNumber v-model:value="formData.level_bonus.normal" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['level_bonus', 'gold']" label="黄金代理" class="config-cell">
<InputNumber v-model:value="formData.level_bonus.gold" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['level_bonus', 'diamond']" label="钻石代理" class="config-cell">
<InputNumber v-model:value="formData.level_bonus.diamond" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
<!-- 最高价上调 -->
<div class="config-panel config-panel--b">
<div class="config-panel-head">
<Text strong class="config-panel-title">等级最高价上调上限</Text>
<div class="config-panel-desc">
黄金钻石代理在定价时可上浮的售价上限额度单位为元
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item name="gold_max_uplift_amount" label="黄金代理" class="config-cell">
<InputNumber v-model:value="formData.gold_max_uplift_amount" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
<Form.Item name="diamond_max_uplift_amount" label="钻石代理" class="config-cell">
<InputNumber v-model:value="formData.diamond_max_uplift_amount" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
<!-- 升级费用 -->
<div class="config-panel config-panel--c">
<div class="config-panel-head">
<Text strong class="config-panel-title">升级费用</Text>
<div class="config-panel-desc">
用户自主升级时需支付的金额单位为元
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item :name="['upgrade_fee', 'normal_to_gold']" label="普通 → 黄金" class="config-cell">
<InputNumber v-model:value="formData.upgrade_fee.normal_to_gold" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['upgrade_fee', 'normal_to_diamond']" label="普通 → 钻石" class="config-cell">
<InputNumber v-model:value="formData.upgrade_fee.normal_to_diamond" :min="0" :precision="2"
:step="0.01" addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
<!-- 升级返佣 -->
<div class="config-panel config-panel--d">
<div class="config-panel-head">
<Text strong class="config-panel-title">升级返佣</Text>
<div class="config-panel-desc">
下级完成升级付费后返给直接上级的金额单位为元
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item :name="['upgrade_rebate', 'normal_to_gold_rebate']" label="普通升黄金" class="config-cell">
<InputNumber v-model:value="formData.upgrade_rebate.normal_to_gold_rebate" :min="0" :precision="2"
:step="0.01" addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['upgrade_rebate', 'to_diamond_rebate']" label="升至钻石(含普通或黄金路径)" class="config-cell">
<InputNumber v-model:value="formData.upgrade_rebate.to_diamond_rebate" :min="0" :precision="2"
:step="0.01" addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
<!-- 直接上级返佣 -->
<div class="config-panel config-panel--e">
<div class="config-panel-head">
<Text strong class="config-panel-title">直接上级返佣</Text>
<div class="config-panel-desc">
推广订单产生等级加成时按直接上级的等级拆分给上级的金额单位为元
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item :name="['direct_parent_rebate', 'diamond']" label="上级为钻石" class="config-cell">
<InputNumber v-model:value="formData.direct_parent_rebate.diamond" :min="0" :precision="2"
:step="0.01" addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['direct_parent_rebate', 'gold']" label="上级为黄金" class="config-cell">
<InputNumber v-model:value="formData.direct_parent_rebate.gold" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['direct_parent_rebate', 'normal']" label="上级为普通" class="config-cell">
<InputNumber v-model:value="formData.direct_parent_rebate.normal" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
<!-- 黄金返佣上限 -->
<div class="config-panel config-panel--f">
<div class="config-panel-head">
<Text strong class="config-panel-title">黄金代理返佣上限</Text>
<div class="config-panel-desc">
规则内黄金上级所获返佣的上限金额单位为元
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item name="max_gold_rebate_amount" label="上限金额" class="config-cell">
<InputNumber v-model:value="formData.max_gold_rebate_amount" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
<!-- 佣金冻结 -->
<div class="config-panel config-panel--g">
<div class="config-panel-head">
<Text strong class="config-panel-title">佣金冻结</Text>
<div class="config-panel-desc">
订单单价较高时将部分佣金先放入冻结余额到期再解冻
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item :name="['commission_freeze', 'ratio']" label="冻结比例" class="config-cell">
<InputNumber v-model:value="formData.commission_freeze.ratio" :min="0" :max="1" :precision="4"
:step="0.0001" class="w-full" />
<div class="field-hint">
01 的小数触发冻结后冻结金额 = min(订单单价×本比例,
该笔佣金)与右侧示意一致
</div>
</Form.Item>
<Form.Item :name="['commission_freeze', 'threshold']" label="触发阈值(按订单单价)" class="config-cell">
<InputNumber v-model:value="formData.commission_freeze.threshold" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
<Form.Item :name="['commission_freeze', 'days']" label="冻结天数" class="config-cell">
<InputNumber v-model:value="formData.commission_freeze.days" :min="0" :precision="0" :step="1"
addon-after="" class="w-full" />
<div class="field-hint"> 0 表示不经过延迟仍受是否触发冻结影响保存后会写入后台</div>
</Form.Item>
</div>
</div>
<!-- 税费 -->
<div class="config-panel config-panel--h">
<div class="config-panel-head">
<Text strong class="config-panel-title">税费</Text>
<div class="config-panel-desc">
提现等环节使用的税率与免税额度
</div>
</div>
<div class="config-grid config-grid--3">
<Form.Item name="tax_rate" label="税率" class="config-cell">
<InputNumber v-model:value="formData.tax_rate" :min="0" :max="1" :precision="4" :step="0.0001"
class="w-full" />
<div class="field-hint">零到一之间的小数例如填 0.06 表示百分之六</div>
</Form.Item>
<Form.Item name="tax_exemption_amount" label="免税额度" class="config-cell">
<InputNumber v-model:value="formData.tax_exemption_amount" :min="0" :precision="2" :step="0.01"
addon-after="" class="w-full" />
</Form.Item>
</div>
</div>
</Form>
</div>
<aside class="agent-config-aside">
<AgentConfigPreview :config="formData" />
</aside>
</div>
</Card>
</Page>
</template>
<style scoped lang="less">
.agent-config-card :deep(.ant-card-body) {
padding-top: 10px;
padding-bottom: 12px;
}
.agent-config-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.agent-config-main {
flex: 1;
min-width: 0;
}
.agent-config-aside {
width: 380px;
flex-shrink: 0;
position: sticky;
top: 8px;
}
.agent-config-form {
max-width: 1080px;
}
@media (max-width: 1200px) {
.agent-config-layout {
flex-direction: column;
}
.agent-config-aside {
width: 100%;
position: static;
max-height: none;
}
}
.config-panel {
border-radius: 6px;
padding: 10px 12px 8px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-left-width: 3px;
border-left-style: solid;
}
.config-panel--a {
background: #f2f6ff;
border-left-color: #8ca8e8;
}
.config-panel--b {
background: #f4faf4;
border-left-color: #7cb87c;
}
.config-panel--c {
background: #fffbf0;
border-left-color: #e8b86d;
}
.config-panel--d {
background: #f0fbfa;
border-left-color: #5dbeb3;
}
.config-panel--e {
background: #fdf4fa;
border-left-color: #d68bb8;
}
.config-panel--f {
background: #f7f3fc;
border-left-color: #a78bdb;
}
.config-panel--g {
background: #fff8f0;
border-left-color: #e89e6c;
}
.config-panel--h {
background: #f8faf0;
border-left-color: #a8b86a;
}
.config-panel-head {
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.config-panel-title {
display: block;
font-size: 13px;
line-height: 1.4;
}
.config-panel-desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: rgba(0, 0, 0, 0.55);
}
.config-grid {
display: grid;
column-gap: 16px;
row-gap: 2px;
align-items: start;
}
.config-grid--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 992px) {
.config-grid--3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 576px) {
.config-grid--3 {
grid-template-columns: 1fr;
}
}
.config-cell {
margin-bottom: 0;
}
.config-cell :deep(.ant-form-item-label) {
padding-bottom: 2px;
}
.config-cell :deep(label) {
font-size: 12px;
height: auto;
line-height: 1.35;
}
.field-hint {
font-size: 11px;
color: rgba(0, 0, 0, 0.48);
line-height: 1.35;
margin-top: 4px;
}
.w-full {
width: 100%;
}
</style>

View File

@@ -0,0 +1,288 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { computed, ref } from 'vue';
import { Alert, Divider, InputNumber, Typography } from 'ant-design-vue';
const { Text, Paragraph } = Typography;
const props = defineProps<{
/** 与左侧表单同一引用,随输入实时变化 */
config: AgentApi.AgentConfig;
}>();
const demoOrderPrice = ref(500);
const demoCommission = ref(80);
const demoWithdraw = ref(10_000);
const demoMonthWithdrawn = ref(2000);
const round2 = (n: number) => Math.round(n * 100) / 100;
const diamondUpgradeFee = computed(() =>
round2(Number(props.config.upgrade_fee?.normal_to_diamond ?? 0)),
);
const commissionFreezePreview = computed(() => {
const threshold = Number(props.config.commission_freeze?.threshold ?? 0);
const ratio = Number(props.config.commission_freeze?.ratio ?? 0);
const days = Number(props.config.commission_freeze?.days ?? 0);
const op = Number(demoOrderPrice.value || 0);
const comm = Number(demoCommission.value || 0);
if (op < threshold) {
return {
active: false,
freezeAmount: 0,
threshold,
ratio,
days,
op,
comm,
raw: 0 as number,
};
}
const raw = round2(op * ratio);
const freezeAmount = round2(Math.min(raw, comm));
return {
active: freezeAmount > 0,
freezeAmount,
threshold,
ratio,
days,
op,
comm,
raw,
};
});
const taxPreview = computed(() => {
const amount = Number(demoWithdraw.value || 0);
const monthly = Number(demoMonthWithdrawn.value || 0);
const exemption = Number(props.config.tax_exemption_amount ?? 0);
const rate = Number(props.config.tax_rate ?? 0);
let taxable = amount;
if (exemption > 0) {
const remainingExemption = round2(exemption - monthly);
if (remainingExemption > 0) {
if (amount <= remainingExemption) taxable = 0;
else taxable = round2(amount - remainingExemption);
}
}
const tax = round2(taxable * rate);
const actual = round2(amount - tax);
return { amount, monthly, exemption, rate, taxable, tax, actual };
});
const upgradeWarnings = computed(() => {
const w: string[] = [];
const g = Number(props.config.upgrade_fee?.normal_to_gold ?? 0);
const d = Number(props.config.upgrade_fee?.normal_to_diamond ?? 0);
const rg = Number(props.config.upgrade_rebate?.normal_to_gold_rebate ?? 0);
const rd = Number(props.config.upgrade_rebate?.to_diamond_rebate ?? 0);
if (rg > g) w.push('「普通→黄金」升级返佣大于升级费用,请确认是否符合运营预期。');
if (rd > d) w.push('「升至钻石」升级返佣大于「普通→钻石」升级费用,请确认是否符合运营预期。');
if (d < g) w.push('「普通→钻石」费用小于「普通→黄金」费用,请检查。');
return w;
});
function row(label: string, value: string) {
return { label, value };
}
</script>
<template>
<div class="agent-config-preview rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="mb-3 text-base font-semibold text-gray-800">配置效果示意</div>
<Paragraph type="secondary" class="!mb-3 !text-xs">
下方数字随左侧表单实时变化演示类数字仅用于模拟单笔订单/提现不会保存
</Paragraph>
<Alert
v-if="upgradeWarnings.length"
type="warning"
show-icon
class="mb-3"
message="升级费用与返佣"
>
<template #description>
<ul class="mb-0 list-disc pl-4 text-xs">
<li v-for="(t, i) in upgradeWarnings" :key="i">{{ t }}</li>
</ul>
</template>
</Alert>
<div class="preview-section-title">升级费用与返佣</div>
<Text type="secondary" class="mb-2 block text-xs">
黄金代理升为钻石时应付升级费与普通钻石相同均取配置普通钻石金额
</Text>
<div class="preview-table">
<div
v-for="(r, idx) in [
row(
'普通 → 黄金',
`用户付 ¥${round2(config.upgrade_fee?.normal_to_gold ?? 0).toFixed(2)},上级返佣 ¥${round2(config.upgrade_rebate?.normal_to_gold_rebate ?? 0).toFixed(2)}`,
),
row(
'普通 → 钻石',
`用户付 ¥${diamondUpgradeFee.toFixed(2)},上级返佣 ¥${round2(config.upgrade_rebate?.to_diamond_rebate ?? 0).toFixed(2)}`,
),
row(
'黄金 → 钻石',
`用户付 ¥${diamondUpgradeFee.toFixed(2)}(与普通→钻石相同),上级返佣仍按「升至钻石」档:¥${round2(config.upgrade_rebate?.to_diamond_rebate ?? 0).toFixed(2)}`,
),
]"
:key="idx"
class="preview-table-row"
>
<span class="preview-table-label">{{ r.label }}</span>
<span class="preview-table-value">{{ r.value }}</span>
</div>
</div>
<Divider class="!my-3" />
<div class="preview-section-title">定价空间(示意)</div>
<Text type="secondary" class="mb-2 block text-xs">
假设某产品底价为 100 元:可设售价上限约为「底价 + 上调上限」;等级加成参与的是「实际底价」计算,不计入此处售价上限示意。
</Text>
<div class="preview-table">
<div class="preview-table-row">
<span class="preview-table-label">黄金代理</span>
<span class="preview-table-value">
≤ {{ round2(100 + (config.gold_max_uplift_amount ?? 0)).toFixed(2) }} 元100 + 上调上限
{{ round2(config.gold_max_uplift_amount ?? 0).toFixed(2) }}
</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">钻石代理</span>
<span class="preview-table-value">
≤ {{ round2(100 + (config.diamond_max_uplift_amount ?? 0)).toFixed(2) }} 元100 + 上调上限
{{ round2(config.diamond_max_uplift_amount ?? 0).toFixed(2) }}
</span>
</div>
</div>
<Divider class="!my-3" />
<div class="preview-section-title">佣金冻结(与线上一致)</div>
<Alert
type="info"
show-icon
class="mb-2"
message="规则说明"
description="当订单单价 ≥ 触发阈值时:冻结金额 = min(订单单价 × 冻结比例, 该笔佣金金额);达到冻结天数后解冻。比例为 01 的小数。"
/>
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs">
<span>演示订单单价</span>
<InputNumber v-model:value="demoOrderPrice" :min="0" :precision="2" size="small" class="w-28" />
<span>演示佣金</span>
<InputNumber v-model:value="demoCommission" :min="0" :precision="2" size="small" class="w-28" />
</div>
<div class="preview-table">
<div class="preview-table-row">
<span class="preview-table-label">是否触发</span>
<span class="preview-table-value">
{{
commissionFreezePreview.op >= commissionFreezePreview.threshold
? '是(单价 ≥ 阈值)'
: '否'
}}
</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">冻结金额(示意)</span>
<span class="preview-table-value">
<Text strong>¥{{ commissionFreezePreview.freezeAmount.toFixed(2) }}</Text>
<span v-if="commissionFreezePreview.op >= commissionFreezePreview.threshold" class="text-gray-500">
(单价×比例 = ¥{{ commissionFreezePreview.raw.toFixed(2) }},与佣金取小)
</span>
</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">解冻天数</span>
<span class="preview-table-value">{{ commissionFreezePreview.days }} 天</span>
</div>
</div>
<Divider class="!my-3" />
<div class="preview-section-title">提现税费(与线上一致)</div>
<Text type="secondary" class="mb-2 block text-xs">
免税额度按自然月累计提现额抵扣:本月已提现 + 本次提现 未超过免税部分则不计税。
</Text>
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs">
<span>演示本次提现</span>
<InputNumber v-model:value="demoWithdraw" :min="0" :precision="2" size="small" class="w-28" />
<span>本月已提现</span>
<InputNumber v-model:value="demoMonthWithdrawn" :min="0" :precision="2" size="small" class="w-28" />
</div>
<div class="preview-table">
<div class="preview-table-row">
<span class="preview-table-label">税率</span>
<span class="preview-table-value">{{ ((taxPreview.rate || 0) * 100).toFixed(2) }}%</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">免税额度</span>
<span class="preview-table-value">¥{{ round2(taxPreview.exemption).toFixed(2) }}</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">本次应税金额</span>
<span class="preview-table-value">¥{{ taxPreview.taxable.toFixed(2) }}</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">本次税费</span>
<span class="preview-table-value">¥{{ taxPreview.tax.toFixed(2) }}</span>
</div>
<div class="preview-table-row">
<span class="preview-table-label">本次到账(示意)</span>
<span class="preview-table-value">
<Text strong>¥{{ taxPreview.actual.toFixed(2) }}</Text>
</span>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.agent-config-preview {
max-height: calc(100vh - 120px);
overflow: auto;
}
.preview-section-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
margin-bottom: 8px;
}
.preview-table {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 6px;
overflow: hidden;
font-size: 12px;
}
.preview-table-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
align-items: start;
}
.preview-table-row:last-child {
border-bottom: none;
}
.preview-table-label {
color: rgba(0, 0, 0, 0.55);
}
.preview-table-value {
color: rgba(0, 0, 0, 0.85);
line-height: 1.45;
word-break: break-word;
}
</style>

View File

@@ -3,22 +3,31 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getLevelName } from '#/utils/agent';
export function useInviteCodeColumns(): VxeTableGridOptions['columns'] {
export type InviteCodeRow = Record<string, any> & {
agent_code?: number;
used_agent_code?: number;
};
export function useInviteCodeColumns(
onIssuerAgentCodeClick?: (row: InviteCodeRow) => void,
onUsedAgentCodeClick?: (row: InviteCodeRow) => void,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'code',
title: '邀请码',
width: 200,
},
{
field: 'agent_id',
title: '发放代理ID',
field: 'agent_code',
title: '发放代理编号',
width: 120,
cellRender: onIssuerAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onIssuerAgentCodeClick },
}
: undefined,
},
{
field: 'agent_mobile',
@@ -52,9 +61,15 @@ export function useInviteCodeColumns(): VxeTableGridOptions['columns'] {
width: 120,
},
{
field: 'used_agent_id',
title: '使用代理ID',
field: 'used_agent_code',
title: '使用代理编号',
width: 120,
cellRender: onUsedAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onUsedAgentCodeClick },
}
: undefined,
},
{
field: 'used_time',
@@ -77,7 +92,7 @@ export function useInviteCodeColumns(): VxeTableGridOptions['columns'] {
width: 160,
sortable: true,
},
] as const;
];
}
export function useInviteCodeFormSchema(): VbenFormSchema[] {
@@ -86,11 +101,10 @@ export function useInviteCodeFormSchema(): VbenFormSchema[] {
component: 'Input',
fieldName: 'code',
label: '邀请码',
},
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '发放代理ID',
componentProps: {
placeholder: '',
allowClear: true,
},
},
{
component: 'Select',

View File

@@ -1,19 +1,23 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Page } from '@vben/common-ui';
import { Button, Input, InputNumber, Modal, Space, message } from 'ant-design-vue';
import { Button, Input, InputNumber, Modal, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
generateDiamondInviteCode,
getInviteCodeList,
} from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { useInviteCodeColumns, useInviteCodeFormSchema } from './data';
import {
type InviteCodeRow,
useInviteCodeColumns,
useInviteCodeFormSchema,
} from './data';
interface QueryParams {
currentPage: number;
@@ -21,6 +25,8 @@ interface QueryParams {
[key: string]: any;
}
const router = useRouter();
const generateModalVisible = ref(false);
const generatedCodes = ref<string[]>([]);
const generateForm = ref({
@@ -29,24 +35,30 @@ const generateForm = ref({
remark: '',
});
function onInviteIssuerCode(row: InviteCodeRow) {
if (row.agent_code != null && row.agent_code > 0) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
}
function onInviteUsedCode(row: InviteCodeRow) {
if (row.used_agent_code != null && row.used_agent_code > 0) {
navigateToAgentList(router, { agentCode: row.used_agent_code });
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useInviteCodeFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useInviteCodeColumns(),
columns: useInviteCodeColumns(onInviteIssuerCode, onInviteUsedCode),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async ({ page }: { page: QueryParams }, formValues: Record<string, any>) => {
return await getInviteCodeList({
...form,
...formValues,
target_level: 3,
page: page.currentPage,
pageSize: page.pageSize,

View File

@@ -1,18 +1,26 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
export type AgentLinkRow = Record<string, any> & {
agent_id: string;
agent_code?: number;
};
// 推广链接列表列配置
export function useLinkColumns(): VxeTableGridOptions['columns'] {
export function useLinkColumns(
onAgentCodeClick?: (row: AgentLinkRow) => void,
): VxeTableGridOptions['columns'] {
return [
{
field: 'agent_id',
title: '代理ID',
width: 100,
},
{
field: 'product_id',
title: '产品ID',
width: 100,
field: 'agent_code',
title: '代理编号',
width: 110,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
field: 'product_name',
@@ -48,20 +56,14 @@ export function useLinkColumns(): VxeTableGridOptions['columns'] {
// 推广链接搜索表单配置
export function useLinkFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'product_id',
label: '产品ID',
},
{
component: 'Input',
fieldName: 'product_name',
label: '产品名称',
},
{
component: 'Input',
fieldName: 'link_identifier',
label: '推广码',
fieldName: 'agent_code',
label: '代理编号',
componentProps: {
placeholder: '请输入代理编号',
allowClear: true,
},
},
];
}

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentLinkList } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { useLinkColumns, useLinkFormSchema } from './data';
import { type AgentLinkRow, useLinkColumns, useLinkFormSchema } from './data';
interface Props {
agentId?: number;
@@ -20,29 +22,31 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onLinkAgentCode(row: AgentLinkRow) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useLinkFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useLinkColumns(),
columns: useLinkColumns(onLinkAgentCode),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async ({ page }: { page: QueryParams }, formValues: Record<string, any>) => {
return await getAgentLinkList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});

View File

@@ -1,5 +1,6 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AgentApi } from '#/api/agent';
import { getLevelName } from '#/utils/agent';
@@ -48,6 +49,20 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'mobile',
label: '手机号',
},
{
component: 'Input',
fieldName: 'real_name',
label: '姓名',
componentProps: {
allowClear: true,
placeholder: '实名姓名,模糊匹配',
},
},
{
component: 'Input',
fieldName: 'agent_code',
label: '代理编号',
},
{
component: 'Input',
fieldName: 'region',
@@ -66,38 +81,53 @@ export function useGridFormSchema(): VbenFormSchema[] {
],
},
},
{
component: 'InputNumber',
fieldName: 'team_leader_id',
label: '团队首领ID',
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: '创建时间',
label: '成为代理时间',
componentProps: {
showTime: true,
},
},
];
}
function fmtMoney(cellValue: unknown) {
const n = Number(cellValue);
const v = Number.isFinite(n) ? n : 0;
return `¥${v.toFixed(2)}`;
}
// 表格列配置
export function useColumns(): VxeTableGridOptions['columns'] {
export function useColumns(
onAgentCodeClick?: (row: AgentApi.AgentListItem) => void,
onUserIdClick?: (row: AgentApi.AgentListItem) => void,
): VxeTableGridOptions['columns'] {
const columns = [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'user_id',
title: '用户ID',
width: 100,
width: 110,
cellRender: onUserIdClick
? {
name: 'CellLink',
props: {
onClick: onUserIdClick,
},
}
: undefined,
},
{
field: 'agent_code',
title: '代理编',
title: '代理编',
width: 100,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: {
onClick: onAgentCodeClick,
},
}
: undefined,
},
{
field: 'level',
@@ -129,6 +159,23 @@ export function useColumns(): VxeTableGridOptions['columns'] {
title: '实名认证状态',
width: 120,
},
{
field: 'real_name',
title: '姓名',
minWidth: 100,
formatter: ({
row,
cellValue,
}: {
row: AgentApi.AgentListItem;
cellValue?: string;
}) => {
if (row.is_real_name && (cellValue || row.real_name)) {
return String(cellValue ?? row.real_name ?? '');
}
return '—';
},
},
{
field: 'wechat_id',
title: '微信号',
@@ -145,29 +192,51 @@ export function useColumns(): VxeTableGridOptions['columns'] {
field: 'balance',
title: '钱包余额',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
formatter: ({ cellValue }: { cellValue: unknown }) => fmtMoney(cellValue),
},
{
field: 'total_earnings',
title: '累计收益',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
formatter: ({ cellValue }: { cellValue: unknown }) => fmtMoney(cellValue),
},
{
field: 'frozen_balance',
title: '冻结余额',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
formatter: ({ cellValue }: { cellValue: unknown }) => fmtMoney(cellValue),
},
{
field: 'withdrawn_amount',
title: '提现总额',
width: 120,
formatter: ({ cellValue }: { cellValue: unknown }) => fmtMoney(cellValue),
},
{
field: 'total_orders',
title: '订单总数',
width: 100,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
cellValue || 0,
},
{
field: 'total_order_amount',
title: '订单总金额',
width: 130,
formatter: ({ cellValue }: { cellValue: unknown }) => fmtMoney(cellValue),
},
{
field: 'total_agent_profit',
title: '代理总收益',
width: 130,
formatter: ({ cellValue }: { cellValue: unknown }) => fmtMoney(cellValue),
},
{
field: 'subordinate_count',
title: '下级数量',
width: 100,
formatter: ({ cellValue }: { cellValue: number }) =>
cellValue || 0,
},
{
field: 'create_time',
@@ -182,7 +251,7 @@ export function useColumns(): VxeTableGridOptions['columns'] {
field: 'operation',
fixed: 'right' as const,
title: '操作',
width: 280,
width: 240,
},
];
return columns;
@@ -234,11 +303,6 @@ export function useLinkFormSchema(): VbenFormSchema[] {
// 佣金记录列表列配置
export function useCommissionColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
@@ -310,11 +374,6 @@ export function useCommissionFormSchema(): VbenFormSchema[] {
// 奖励记录列表列配置
export function useRewardColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
@@ -365,11 +424,6 @@ export function useRewardFormSchema(): VbenFormSchema[] {
// 提现记录列表列配置
export function useWithdrawalColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',

View File

@@ -6,29 +6,193 @@ import type {
} from '#/adapter/vxe-table';
import type { AgentApi } from '#/api/agent';
import { computed } from 'vue';
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Card, Dropdown, Menu } from 'ant-design-vue';
import { Breadcrumb, Button, Card, Dropdown, Menu } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentList } from '#/api/agent';
import {
AGENT_LIST_FOCUS_EVENT,
clearStaleAgentListNavStackStorage,
consumeAgentListFocusFromStorage,
navigateToAgentList,
navEntryFromFocusPayload,
type AgentListFocusPayload,
type AgentListNavEntry,
} from '#/composables/use-agent-list-navigate';
import { navigateToPlatformUserList } from '#/composables/use-platform-user-list-navigate';
import { useColumns, useGridFormSchema } from './data';
/** 金额类列在表格级 cellStyle 着色(列配置里的 cellStyle 不会透传到 VxeGrid */
const agentListMoneyCellStyleMap: Record<string, { color: string; fontWeight: string }> =
{
balance: { color: '#1677ff', fontWeight: '600' },
total_earnings: { color: '#389e0d', fontWeight: '600' },
frozen_balance: { color: '#d46b08', fontWeight: '600' },
withdrawn_amount: { color: '#531dab', fontWeight: '600' },
total_order_amount: { color: '#08979c', fontWeight: '600' },
total_agent_profit: { color: '#c41d7f', fontWeight: '600' },
};
function agentListCellStyle({
column,
}: {
column?: { field?: string };
}) {
const field = column?.field;
if (!field) {
return undefined;
}
return agentListMoneyCellStyleMap[field];
}
import CommissionModal from './modules/commission-modal.vue';
import Form from './modules/form.vue';
import LinkModal from './modules/link-modal.vue';
import OrderModal from './modules/order-modal.vue';
import RebateModal from './modules/rebate-modal.vue';
import PlatformUpgradeModal from './modules/platform-upgrade-modal.vue';
import TeamTreeModal from './modules/team-tree-modal.vue';
import UpgradeModal from './modules/upgrade-modal.vue';
import WithdrawalModal from './modules/withdrawal-modal.vue';
const route = useRoute();
const router = useRouter();
/** 下一次列表请求合并的定位参数(来自 sessionStorage 或同页 CustomEvent请求后清除 */
const ephemeralAgentFocus = ref<AgentListFocusPayload | null>(null);
/** 列表内跳转轨迹:全部列表 → 某代理直属下级 → 精确查看某代理,用于面包屑与返回上一级 */
const navStack = ref<AgentListNavEntry[]>([{ kind: 'full' }]);
function navEntryLabel(entry: AgentListNavEntry): string {
if (entry.kind === 'full') {
return '全部代理';
}
if (entry.kind === 'subs') {
return entry.leaderCode != null
? `编号 ${entry.leaderCode} 的直属下级`
: `直属下级(${entry.leaderId.slice(0, 8)}…)`;
}
if (entry.kind === 'focus') {
if (entry.agentCode != null) {
return `查看代理 · 编号 ${entry.agentCode}`;
}
return `查看代理 · ${entry.agentId?.slice(0, 8) ?? ''}`;
}
return '';
}
function focusPayloadFromNavEntry(
entry: Extract<AgentListNavEntry, { kind: 'focus' }>,
): AgentListFocusPayload {
return {
agent_id: entry.agentId,
agent_code:
entry.agentCode != null ? String(entry.agentCode) : undefined,
};
}
/** 避免连续重复写入相同「精确查看」节点 */
function appendFocusNavFromPayload(p: AgentListFocusPayload) {
const entry = navEntryFromFocusPayload(p);
if (!entry) {
return;
}
const last = navStack.value[navStack.value.length - 1];
if (last && last.kind === 'focus') {
if (last.agentId === entry.agentId && last.agentCode === entry.agentCode) {
return;
}
}
navStack.value = [...navStack.value, entry];
}
async function alignRouteToStackTop() {
const top = navStack.value[navStack.value.length - 1];
if (!top) {
return;
}
const tl = route.query.team_leader_id
? String(route.query.team_leader_id)
: '';
if (top.kind === 'subs') {
if (tl !== top.leaderId) {
await router.replace({
name: 'AgentList',
query: { team_leader_id: top.leaderId },
});
}
return;
}
if (tl) {
await router.replace({ name: 'AgentList', query: {} });
}
}
/** 根据栈顶同步路由与一次性定位(栈仅在内存中;刷新后仅能通过 URL 的 team_leader_id 或一次性 focus 恢复) */
async function applyNavStackTop() {
const top = navStack.value[navStack.value.length - 1];
if (!top) {
return;
}
if (top.kind === 'full') {
ephemeralAgentFocus.value = null;
await router.replace({ name: 'AgentList', query: {} });
} else if (top.kind === 'subs') {
ephemeralAgentFocus.value = null;
await router.replace({
name: 'AgentList',
query: { team_leader_id: top.leaderId },
});
} else {
ephemeralAgentFocus.value = focusPayloadFromNavEntry(top);
await router.replace({ name: 'AgentList', query: {} });
}
await syncRouteQueryToSearchForm();
loadTeamLeaderSummary();
}
async function goNavIndex(index: number) {
if (index < 0 || index >= navStack.value.length - 1) {
return;
}
navStack.value = navStack.value.slice(0, index + 1);
await applyNavStackTop();
}
async function navBackOne() {
if (navStack.value.length <= 1) {
return;
}
navStack.value = navStack.value.slice(0, -1);
await applyNavStackTop();
}
/** 导航栈不持久化:避免下次从菜单进入时代 URL 仍带 team_leader_id。仅当前 URL 含 team_leader_id 时重建「下级视图」栈。 */
function initNavStackFromRoute() {
clearStaleAgentListNavStackStorage();
const tl = route.query.team_leader_id
? String(route.query.team_leader_id)
: '';
if (tl) {
navStack.value = [{ kind: 'full' }, { kind: 'subs', leaderId: tl }];
} else {
navStack.value = [{ kind: 'full' }];
}
}
// 表单抽屉
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
@@ -77,6 +241,12 @@ const [WithdrawalModalComponent, withdrawalModalApi] = useVbenModal({
destroyOnClose: true,
});
// 团队树弹窗
const [TeamTreeModalComponent, teamTreeModalApi] = useVbenModal({
connectedComponent: TeamTreeModal,
destroyOnClose: true,
});
// 表格配置
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
@@ -92,7 +262,20 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
} as VxeGridListeners<AgentApi.AgentListItem>,
gridOptions: {
columns: useColumns(),
cellStyle: agentListCellStyle,
columns: useColumns(
(row) => {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
},
(row) => {
const uid = row.user_id;
if (uid != null && String(uid).trim() !== '') {
navigateToPlatformUserList(router, { userId: uid });
}
},
),
height: 'auto',
keepSource: true,
sortConfig: {
@@ -112,16 +295,36 @@ const [Grid, gridApi] = useVbenVxeGrid({
}
: {};
// 兼容旧链接URL 上的 agent_code / agent_id新跳转走 sessionStorage / 同页事件,不写 URL
const urlAgentCode = route.query.agent_code
? String(route.query.agent_code)
: undefined;
const urlAgentId = route.query.agent_id
? String(route.query.agent_id)
: undefined;
const shot = ephemeralAgentFocus.value;
const res = await getAgentList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
...sortParams,
team_leader_id: route.query.team_leader_id
? Number(route.query.team_leader_id)
: undefined,
team_leader_id:
shot?.agent_id || urlAgentId || !route.query.team_leader_id
? undefined
: String(route.query.team_leader_id),
agent_code:
formValues?.agent_code ||
shot?.agent_code ||
urlAgentCode,
agent_id: shot?.agent_id || urlAgentId,
});
if (shot) {
ephemeralAgentFocus.value = null;
}
return {
...res,
sort: sort || null,
@@ -132,7 +335,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
result: 'items',
total: 'total',
},
autoLoad: true,
autoLoad: false,
},
rowConfig: {
keyField: 'id',
@@ -147,6 +350,102 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions<AgentApi.AgentListItem>,
});
/** 团队首领一行摘要(下级视图顶栏) */
const teamLeaderRow = ref<AgentApi.AgentListItem | null>(null);
async function loadTeamLeaderSummary() {
teamLeaderRow.value = null;
const tl = route.query.team_leader_id;
if (!tl || String(tl) === '') {
return;
}
try {
const res = await getAgentList({
page: 1,
pageSize: 1,
agent_id: String(tl),
});
teamLeaderRow.value = (res.items && res.items[0]) || null;
} catch {
teamLeaderRow.value = null;
}
}
async function syncRouteQueryToSearchForm() {
await nextTick();
if (!gridApi.formApi?.setValues) {
return;
}
const shot = ephemeralAgentFocus.value;
await gridApi.formApi.setValues({
agent_code:
(shot?.agent_code?.trim() ? shot.agent_code : '') ||
(route.query.agent_code ? String(route.query.agent_code) : '') ||
'',
});
await gridApi.query();
}
function onAgentListExternalFocus(e: Event) {
const ce = e as CustomEvent<AgentListFocusPayload>;
const d = ce.detail;
if (!d?.agent_code && !d?.agent_id) {
return;
}
ephemeralAgentFocus.value = d;
appendFocusNavFromPayload(d);
void syncRouteQueryToSearchForm();
loadTeamLeaderSummary();
}
watch(
() => route.query.team_leader_id,
() => {
loadTeamLeaderSummary();
void syncRouteQueryToSearchForm();
},
{ flush: 'post' },
);
watch(
() => [route.query.agent_code, route.query.agent_id],
() => {
void syncRouteQueryToSearchForm();
},
{ flush: 'post' },
);
onMounted(async () => {
window.addEventListener(
AGENT_LIST_FOCUS_EVENT,
onAgentListExternalFocus as EventListener,
);
initNavStackFromRoute();
const fromStorage = consumeAgentListFocusFromStorage();
if (fromStorage?.agent_code || fromStorage?.agent_id) {
ephemeralAgentFocus.value = fromStorage;
appendFocusNavFromPayload(fromStorage);
} else {
const top = navStack.value[navStack.value.length - 1];
if (top?.kind === 'focus') {
ephemeralAgentFocus.value = focusPayloadFromNavEntry(top);
}
}
await alignRouteToStackTop();
await syncRouteQueryToSearchForm();
loadTeamLeaderSummary();
});
onUnmounted(() => {
window.removeEventListener(
AGENT_LIST_FOCUS_EVENT,
onAgentListExternalFocus as EventListener,
);
});
// 更多操作菜单项
const moreMenuItems = [
{
@@ -157,6 +456,10 @@ const moreMenuItems = [
key: 'links',
label: '推广链接',
},
{
key: 'commission',
label: '佣金记录',
},
{
key: 'rebate',
label: '返佣记录',
@@ -178,14 +481,16 @@ const moreMenuItems = [
// 团队首领信息
const teamLeaderId = computed(() => route.query.team_leader_id);
// 返回团队首领列表
function onBackToParent() {
router.replace({
query: {
...route.query,
team_leader_id: undefined,
},
});
function onOpenTeamTree(row: AgentApi.AgentListItem) {
teamTreeModalApi
.setData({
anchorAgentId: String(row.id),
onLocate: ({ agentId }: { agentId: string }) => {
navigateToAgentList(router, { agentId });
teamTreeModalApi.close();
},
})
.open();
}
// 操作处理函数
@@ -223,15 +528,6 @@ function onActionClick(
onViewOrder(e.row);
break;
}
case 'view-sub-agent': {
router.replace({
query: {
...route.query,
team_leader_id: e.row.id,
},
});
break;
}
case 'withdrawal': {
onViewWithdrawal(e.row);
break;
@@ -297,12 +593,53 @@ function onRefresh() {
<UpgradeModalComponent />
<OrderModalComponent />
<WithdrawalModalComponent />
<TeamTreeModalComponent />
<div class="mb-3 flex flex-wrap items-center gap-3">
<Button
v-if="navStack.length > 1"
type="default"
@click="navBackOne"
>
返回上一级
</Button>
<Breadcrumb class="agent-list-nav-bc flex-1 min-w-[200px]">
<Breadcrumb.Item v-for="(entry, index) in navStack" :key="index">
<a
v-if="index < navStack.length - 1"
class="cursor-pointer text-primary"
@click.prevent="goNavIndex(index)"
>
{{ navEntryLabel(entry) }}
</a>
<span v-else>{{ navEntryLabel(entry) }}</span>
</Breadcrumb.Item>
</Breadcrumb>
</div>
<!-- 团队首领信息卡片 -->
<Card v-if="teamLeaderId" class="mb-4">
<div class="flex items-center gap-4">
<Button @click="onBackToParent">返回上级列表</Button>
<div>团队首领ID{{ teamLeaderId }}</div>
<div class="flex flex-wrap items-center gap-3">
<Button :disabled="navStack.length <= 1" @click="navBackOne">
返回上一级
</Button>
<template v-if="teamLeaderRow">
<span>团队首领编号 {{ teamLeaderRow.agent_code }}</span>
<span>{{ teamLeaderRow.level_name }}</span>
<span>{{ teamLeaderRow.mobile }}</span>
<Button
v-if="teamLeaderRow.agent_code != null"
type="link"
@click="
navigateToAgentList(router, {
agentCode: teamLeaderRow.agent_code,
})
"
>
定位到该代理
</Button>
</template>
<span v-else class="text-gray-500">首领ID{{ teamLeaderId }}</span>
</div>
</Card>
@@ -312,8 +649,8 @@ function onRefresh() {
<Button type="link" @click="onActionClick({ code: 'edit', row })">
编辑
</Button>
<Button type="link" @click="onActionClick({ code: 'view-sub-agent', row })">
查看下级
<Button type="link" @click="onOpenTeamTree(row)">
查看团队
</Button>
<!-- <Button
type="link"

View File

@@ -20,7 +20,10 @@ const modalData = computed(() => modalApi.getData<ModalData>());
<template>
<Modal class="w-[calc(100vw-200px)]" :footer="false">
<div class="agent-commission-modal">
<CommissionList :agent-id="modalData?.agentId" />
<CommissionList
:agent-id="modalData?.agentId"
:navigate-away="() => modalApi.close()"
/>
</div>
</Modal>
</template>

View File

@@ -23,7 +23,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
void (await formApi.getValues());
drawerApi.lock();
// TODO: 实现更新代理信息的接口
// updateAgent(id.value, values as AgentApi.UpdateAgentRequest)

View File

@@ -20,7 +20,10 @@ const modalData = computed(() => modalApi.getData<ModalData>());
<template>
<Modal class="w-[calc(100vw-200px)]" :footer="false">
<div class="agent-order-modal">
<OrderList :agent-id="modalData?.agentId" />
<OrderList
:agent-id="modalData?.agentId"
:navigate-away="() => modalApi.close()"
/>
</div>
</Modal>
</template>

View File

@@ -46,8 +46,9 @@ const canUpgrade = computed(() => targetLevelOptions.value.length > 0);
// 当可选目标等级变化时(如打开弹窗、切换代理),重置选中为第一项
watch(targetLevelOptions, (opts) => {
if (opts.length) {
selectedToLevel.value = opts[0].value;
const first = opts[0];
if (first) {
selectedToLevel.value = first.value;
}
});

View File

@@ -20,7 +20,10 @@ const modalData = computed(() => modalApi.getData<ModalData>());
<template>
<Modal class="w-[calc(100vw-200px)]" :footer="false">
<div class="agent-rebate-modal">
<RebateList :agent-id="modalData?.agentId" />
<RebateList
:agent-id="modalData?.agentId"
:navigate-away="() => modalApi.close()"
/>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import RewardList from '../../agent-reward/list.vue';
interface ModalData {
agentId: number;
}
const [Modal, modalApi] = useVbenModal({
title: '奖励记录列表',
destroyOnClose: true,
});
const modalData = computed(() => modalApi.getData<ModalData>());
</script>
<template>
<Modal class="w-[calc(100vw-200px)]" :footer="false">
<div class="agent-reward-modal p-4">
<RewardList :agent-id="modalData?.agentId" />
</div>
</Modal>
</template>

View File

@@ -0,0 +1,383 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { ApartmentOutlined, UserOutlined } from '@ant-design/icons-vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Empty, Spin, Tree } from 'ant-design-vue';
import { getAgentTeamTree } from '#/api/agent';
interface ModalData {
anchorAgentId: string;
onLocate: (payload: { agentId: string }) => void;
}
/** ant-design-vue Tree 节点;自定义字段在 dataRef 中带给 #title 插槽 */
interface TeamTreeDataNode {
key: string;
title: string;
children?: TeamTreeDataNode[];
depth: number;
layerCaption: string;
siblingCaption: string;
agentCode: number;
levelName: string;
mobile: string;
isAnchor: boolean;
directBranchCount: number;
}
const treeData = ref<TeamTreeDataNode[]>([]);
const loading = ref(false);
const loadError = ref('');
const [Modal, modalApi] = useVbenModal({
title: '查看团队',
destroyOnClose: true,
onOpenChange(isOpen: boolean) {
if (!isOpen) {
treeData.value = [];
loadError.value = '';
return;
}
void loadTree();
},
});
function mapNode(
node: AgentApi.AgentTeamTreeNode,
depth: number,
siblingIndex: number,
siblingTotal: number,
): TeamTreeDataNode {
const kids = node.children?.length
? node.children.map((c, i) =>
mapNode(c, depth + 1, i, node.children!.length),
)
: undefined;
const layerCaption =
depth === 0 ? '团队根 · 首领' : `邀请第 ${depth}`;
const siblingCaption =
siblingTotal <= 1
? '同级唯一分支'
: `同级分支 ${siblingIndex + 1} / ${siblingTotal}`;
const anchorTag = node.is_anchor ? '(本次查看)' : '';
const summaryTitle = `编号${node.agent_code} ${node.level_name}${anchorTag}`;
return {
key: node.id,
title: summaryTitle,
depth,
layerCaption,
siblingCaption,
agentCode: node.agent_code,
levelName: node.level_name,
mobile: node.mobile,
isAnchor: node.is_anchor,
directBranchCount: kids?.length ?? 0,
children: kids,
};
}
async function loadTree() {
const data = modalApi.getData<ModalData>();
if (!data?.anchorAgentId) {
loadError.value = '缺少代理信息';
return;
}
loading.value = true;
loadError.value = '';
treeData.value = [];
try {
const res = await getAgentTeamTree(data.anchorAgentId);
const root = res.root;
if (root?.id) {
treeData.value = [mapNode(root, 0, 0, 1)];
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '加载失败';
loadError.value = msg;
} finally {
loading.value = false;
}
}
/** 从 Tree #title 插槽解析节点(优先 dataRef */
function nodeFromTitleSlot(slotProps: unknown): TeamTreeDataNode | null {
if (!slotProps || typeof slotProps !== 'object') {
return null;
}
const sp = slotProps as Record<string, unknown>;
const raw = (sp.dataRef ?? sp) as Record<string, unknown>;
if (typeof raw.depth !== 'number' || raw.key === undefined) {
return null;
}
return raw as unknown as TeamTreeDataNode;
}
function asSingleNode(n: TeamTreeDataNode | null): TeamTreeDataNode[] {
return n ? [n] : [];
}
function onTreeSelect(selectedKeys: (string | number)[]) {
const raw = selectedKeys[0];
if (raw === undefined || raw === null || raw === '') {
return;
}
const agentId = String(raw);
const data = modalApi.getData<ModalData>();
data?.onLocate?.({ agentId });
}
</script>
<template>
<Modal class="team-tree-modal w-[min(92vw,780px)]" :footer="false">
<Spin :spinning="loading">
<div class="team-tree-legend mb-3 rounded border border-gray-200 bg-gray-50 px-3 py-2 text-gray-600 text-xs leading-relaxed">
<p class="font-medium text-gray-700 mb-1">
树状图怎么读
</p>
<ul class="list-disc pl-4 space-y-0.5 m-0">
<li>
<strong>自上而下</strong>为邀请链路上级 直属下级一根竖线上的父子
</li>
<li>
<strong>同一父节点下展开的多行</strong>表示该上级发展的<strong>多条并列分支</strong>多个直属下级并排
</li>
<li>
左侧缩进线与 <strong>show-line</strong> 折线对应层级标签里的同级分支 i/n标明是第几条并列支
</li>
</ul>
</div>
<p class="mb-3 text-gray-500 text-sm">
点击任意成员将在下方代理列表中筛选并定位到该代理
</p>
<div v-if="loadError" class="text-red-500 mb-2">
{{ loadError }}
</div>
<Empty
v-if="!loading && !treeData.length && !loadError"
description="暂无团队数据"
/>
<Tree
v-if="treeData.length"
class="team-tree-modal-tree"
block-node
show-line
default-expand-all
:tree-data="treeData"
@select="onTreeSelect"
>
<template #title="slotProps">
<template
v-for="n in asSingleNode(nodeFromTitleSlot(slotProps))"
:key="n.key"
>
<div
class="team-tree-node-row"
:class="[
`team-tree-node-row--depth-${Math.min(n.depth, 6)}`,
{ 'team-tree-node-row--anchor': n.isAnchor },
]"
>
<span class="team-tree-node-row__icons">
<ApartmentOutlined
v-if="n.directBranchCount > 0"
class="text-primary"
title="存在直属下级(多条分支从此分出)"
/>
<UserOutlined
v-else
class="text-gray-400"
title="叶子节点(当前无下级)"
/>
</span>
<div class="team-tree-node-row__meta">
<div class="team-tree-node-row__branch">
<span class="team-tree-badge team-tree-badge--layer">
{{ n.layerCaption }}
</span>
<span class="team-tree-badge team-tree-badge--sibling">
{{ n.siblingCaption }}
</span>
<span
v-if="n.directBranchCount > 0"
class="team-tree-badge team-tree-badge--count"
>
{{ n.directBranchCount }}
条直属分支
</span>
</div>
<div class="team-tree-node-row__main">
<span class="font-medium">编号 {{ n.agentCode }}</span>
<span class="mx-1 text-gray-400">·</span>
<span>{{ n.levelName }}</span>
<span class="mx-1 text-gray-400">·</span>
<span class="text-gray-600">{{ n.mobile }}</span>
<span v-if="n.isAnchor" class="ml-2 text-primary text-xs">
本次查看
</span>
</div>
</div>
</div>
</template>
<span v-if="!nodeFromTitleSlot(slotProps)">{{
(slotProps as { title?: string }).title
}}</span>
</template>
</Tree>
</Spin>
</Modal>
</template>
<style scoped lang="less">
.team-tree-modal-tree {
max-height: min(58vh, 520px);
overflow: auto;
padding: 8px 4px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
:deep(.ant-tree-show-line .ant-tree-indent-unit) {
width: 24px;
}
:deep(.ant-tree-treenode) {
align-items: flex-start;
padding-bottom: 6px;
}
:deep(.ant-tree-node-content-wrapper) {
flex: 1;
min-height: auto;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.15s;
&:hover {
background: rgba(24, 144, 255, 0.06);
}
}
:deep(.ant-tree-switcher) {
line-height: 32px;
}
:deep(.ant-tree-title) {
flex: 1;
min-width: 0;
}
/* 强化纵向连线视觉 */
:deep(.ant-tree-show-line .ant-tree-switcher-line-icon) {
color: #bfbfbf;
}
}
.team-tree-node-row {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
min-width: 0;
border-left: 3px solid #d9d9d9;
padding-left: 10px;
margin-left: 2px;
&--depth-0 {
border-left-color: #722ed1;
}
&--depth-1 {
border-left-color: #1890ff;
}
&--depth-2 {
border-left-color: #13c2c2;
}
&--depth-3 {
border-left-color: #52c41a;
}
&--depth-4 {
border-left-color: #faad14;
}
&--depth-5,
&--depth-6 {
border-left-color: #fa8c16;
}
&--anchor {
background: linear-gradient(
90deg,
rgba(24, 144, 255, 0.08),
transparent 72%
);
margin: -6px -8px;
padding: 6px 8px 6px 18px;
border-radius: 6px;
border-left-width: 4px;
border-left-color: #1890ff !important;
}
&__icons {
flex-shrink: 0;
padding-top: 2px;
font-size: 16px;
}
&__meta {
flex: 1;
min-width: 0;
}
&__branch {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
&__main {
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
}
.team-tree-badge {
display: inline-block;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
&--layer {
background: #f0f5ff;
color: #2f54eb;
border: 1px solid #d6e4ff;
}
&--sibling {
background: #e6fffb;
color: #08979c;
border: 1px solid #87e8de;
}
&--count {
background: #fff7e6;
color: #d46b08;
border: 1px solid #ffd591;
}
}
</style>

View File

@@ -1,29 +1,51 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AgentApi } from '#/api/agent';
import { getOrderProcessStatusName } from '#/utils/agent';
export type AgentOrderRow = AgentApi.AgentOrderListItem;
export function useOrderColumns(): VxeTableGridOptions['columns'] {
export function useOrderColumns(
onAgentCodeClick?: (row: AgentOrderRow) => void,
onOrderNoClick?: (row: AgentOrderRow) => void,
onOperationClick?: (e: { code: string; row: AgentOrderRow }) => void,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
field: 'agent_code',
title: '推广代理编号',
width: 130,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
field: 'order_no',
title: '商户订单号',
minWidth: 180,
cellRender: onOrderNoClick
? {
name: 'CellLink',
props: { onClick: onOrderNoClick },
}
: undefined,
},
{
field: 'order_id',
title: '订单ID',
width: 100,
},
{
field: 'product_id',
title: '产品ID',
field: 'order_status',
title: '订单状态',
width: 100,
cellRender: {
name: 'CellTag',
options: [
{ value: 'pending', color: 'warning', label: '待支付' },
{ value: 'paid', color: 'success', label: '已支付' },
{ value: 'failed', color: 'error', label: '支付失败' },
{ value: 'refunded', color: 'default', label: '已退款' },
{ value: 'closed', color: 'default', label: '已关闭' },
],
},
},
{
field: 'product_name',
@@ -35,35 +57,35 @@ export function useOrderColumns(): VxeTableGridOptions['columns'] {
title: '订单金额',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
`¥${Number(cellValue).toFixed(2)}`,
},
{
field: 'set_price',
title: '设定价格',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
`¥${Number(cellValue).toFixed(2)}`,
},
{
field: 'actual_base_price',
title: '实际底价',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
`¥${Number(cellValue).toFixed(2)}`,
},
{
field: 'price_cost',
title: '提价成本',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
`¥${Number(cellValue).toFixed(2)}`,
},
{
field: 'agent_profit',
title: '代理收益',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
`¥${Number(cellValue).toFixed(2)}`,
},
{
field: 'process_status',
@@ -84,20 +106,54 @@ export function useOrderColumns(): VxeTableGridOptions['columns'] {
width: 160,
sortable: true,
},
] as const;
...(onOperationClick
? [
{
align: 'center' as const,
cellRender: {
attrs: {
nameField: 'order_no',
nameTitle: '商户订单号',
onClick: onOperationClick,
},
name: 'CellOperation',
options: [
{
code: 'settlement',
text: '分账链路',
disabled: (row: AgentOrderRow) => !row.order_no,
},
],
},
field: 'operation',
fixed: 'right' as const,
title: '操作',
width: 110,
},
]
: []),
];
}
export function useOrderFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
component: 'Input',
fieldName: 'agent_code',
label: '推广代理编号',
componentProps: {
placeholder: '请输入推广代理编号',
allowClear: true,
},
},
{
component: 'InputNumber',
fieldName: 'order_id',
label: '订单ID',
component: 'Input',
fieldName: 'order_no',
label: '商户订单号',
componentProps: {
placeholder: '请输入商户订单号',
allowClear: true,
},
},
{
component: 'Select',
@@ -112,6 +168,20 @@ export function useOrderFormSchema(): VbenFormSchema[] {
],
},
},
{
component: 'Select',
fieldName: 'order_status',
label: '订单状态',
componentProps: {
allowClear: true,
options: [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已退款', value: 'refunded' },
{ label: '已关闭', value: 'closed' },
],
},
},
];
}

View File

@@ -1,15 +1,27 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentOrderList } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { navigateToOrderList } from '#/composables/use-order-list-navigate';
import { useOrderColumns, useOrderFormSchema } from './data';
import {
type AgentOrderRow,
useOrderColumns,
useOrderFormSchema,
} from './data';
import OrderSettlementModal from './modules/order-settlement-modal.vue';
interface Props {
agentId?: number;
/** 嵌套在弹窗内时,跳转到订单/代理列表后关闭外层弹窗 */
navigateAway?: () => void;
}
interface QueryParams {
@@ -20,29 +32,70 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onOrderAgentCode(row: AgentOrderRow) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
props.navigateAway?.();
}
}
function onOrderNoClick(row: AgentOrderRow) {
if (row.order_no) {
navigateToOrderList(router, { orderNo: row.order_no });
props.navigateAway?.();
}
}
const [SettlementModalComponent, settlementModalApi] = useVbenModal({
connectedComponent: OrderSettlementModal,
destroyOnClose: true,
});
function onOrderOperationClick(e: { code: string; row: AgentOrderRow }) {
if (e.code === 'settlement' && e.row.order_no) {
settlementModalApi
.setData({
order_no: e.row.order_no,
record_agent_code:
e.row.agent_code != null ? Number(e.row.agent_code) : undefined,
})
.open();
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useOrderFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useOrderColumns(),
columns: useOrderColumns(
onOrderAgentCode,
onOrderNoClick,
onOrderOperationClick,
),
toolbarConfig: {
custom: true,
export: false,
refresh: false,
search: true,
zoom: true,
},
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async (
{ page }: { page: QueryParams },
formValues: Record<string, any>,
) => {
return await getAgentOrderList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});
@@ -53,12 +106,13 @@ const [Grid] = useVbenVxeGrid({
total: 'total',
},
},
},
} as VxeTableGridOptions<AgentOrderRow>,
});
</script>
<template>
<Page :auto-content-height="!agentId">
<SettlementModalComponent />
<Grid :table-title="agentId ? '订单记录列表' : '所有订单记录'" />
</Page>
</template>

View File

@@ -0,0 +1,203 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import type { AgentRebateRow } from '../../agent-rebate/data';
import { computed, ref, watch } from 'vue';
import { Alert, Descriptions, Spin, Table, Tag } from 'ant-design-vue';
import {
getAgentOrderSettlement,
} from '#/api/agent';
import { getRebateTypeName } from '#/utils/agent';
const props = defineProps<{
orderNo: string;
/** 当前列表行所在代理编号,用于高亮「本行视角」 */
highlightAgentCode?: number;
}>();
const loading = ref(false);
const errorMsg = ref('');
const commissions = ref<AgentApi.AgentCommissionListItem[]>([]);
const rebates = ref<AgentRebateRow[]>([]);
function commissionStatusLabel(s: number) {
const m: Record<number, string> = {
0: '待结算',
1: '已结算',
2: '已取消',
};
return m[s] ?? '未知';
}
function rebateStatusLabel(s: number) {
const m: Record<number, string> = {
1: '已发放',
2: '已冻结',
3: '已取消',
};
return m[s] ?? '未知';
}
async function load() {
if (!props.orderNo) {
return;
}
loading.value = true;
errorMsg.value = '';
try {
const res = await getAgentOrderSettlement({ order_no: props.orderNo });
commissions.value = res.commissions ?? [];
rebates.value = (res.rebates ?? []) as AgentRebateRow[];
} catch (e: any) {
errorMsg.value = e?.message || '加载失败';
commissions.value = [];
rebates.value = [];
} finally {
loading.value = false;
}
}
watch(
() => props.orderNo,
() => {
void load();
},
{ immediate: true },
);
const involvedCodes = computed(() => {
const set = new Set<number>();
for (const r of commissions.value) {
if (r.agent_code != null) {
set.add(Number(r.agent_code));
}
}
for (const r of rebates.value) {
if (r.agent_code != null) {
set.add(Number(r.agent_code));
}
if (r.source_agent_code != null) {
set.add(Number(r.source_agent_code));
}
}
return [...set].sort((a, b) => a - b);
});
const commissionColumns = [
{ title: '代理编号', dataIndex: 'agent_code', key: 'agent_code', width: 100 },
{ title: '佣金', dataIndex: 'amount', key: 'amount', width: 110 },
{
title: '产品',
dataIndex: 'product_name',
key: 'product_name',
ellipsis: true,
},
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
];
const rebateColumns = [
{
title: '返佣接收方(代理编号)',
dataIndex: 'agent_code',
key: 'agent_code',
width: 140,
},
{
title: '来源代理编号',
dataIndex: 'source_agent_code',
key: 'source_agent_code',
width: 120,
},
{ title: '类型', dataIndex: 'rebate_type', key: 'rebate_type', width: 130 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
];
function tagColor(code: number) {
if (props.highlightAgentCode != null && code === props.highlightAgentCode) {
return 'blue';
}
return 'default';
}
</script>
<template>
<div class="order-settlement-body">
<Spin :spinning="loading">
<Alert v-if="errorMsg" class="mb-3" type="error" :message="errorMsg" show-icon />
<Descriptions bordered size="small" :column="1" class="mb-4">
<Descriptions.Item label="商户订单号">{{ orderNo }}</Descriptions.Item>
<Descriptions.Item label="涉及代理编号(去重)">
<template v-if="involvedCodes.length">
<Tag v-for="code in involvedCodes" :key="code" :color="tagColor(code)">
{{ code }}
</Tag>
</template>
<span v-else class="text-gray-400">暂无</span>
</Descriptions.Item>
</Descriptions>
<div class="mb-2 text-sm font-medium text-gray-700">链路示意</div>
<div class="flow-hint mb-4 rounded border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<div class="flow-line">
<span class="flow-node">订单支付成功</span>
<span class="flow-arrow"></span>
<span class="flow-node">产生销售代理佣金</span>
<span class="flow-arrow"></span>
<span class="flow-node">按规则向上级代理发放返佣</span>
</div>
<p class="mt-2 mb-0 text-xs text-gray-500">
下表为接口实际数据佣金表为谁卖了这一单拿多少返佣表为谁因下级/来源代理的订单而获得返佣
</p>
</div>
<div class="mb-2 text-sm font-medium text-gray-700">销售佣金本单</div>
<Table size="small" :columns="commissionColumns" :data-source="commissions" :pagination="false" row-key="id"
class="mb-4">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'amount'">
¥{{ Number(record.amount).toFixed(2) }}
</template>
<template v-else-if="column.key === 'status'">
{{ commissionStatusLabel(Number(record.status)) }}
</template>
</template>
</Table>
<div class="mb-2 text-sm font-medium text-gray-700">返佣本单</div>
<Table size="small" :columns="rebateColumns" :data-source="rebates" :pagination="false" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rebate_type'">
{{ getRebateTypeName(Number(record.rebate_type)) }}
</template>
<template v-else-if="column.key === 'amount'">
¥{{ Number(record.amount).toFixed(2) }}
</template>
<template v-else-if="column.key === 'status'">
{{ rebateStatusLabel(Number(record.status)) }}
</template>
</template>
</Table>
</Spin>
</div>
</template>
<style scoped>
.flow-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.flow-node {
font-weight: 500;
color: #1f2937;
}
.flow-arrow {
color: #9ca3af;
}
</style>

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import OrderSettlementBody from './order-settlement-body.vue';
interface ModalData {
order_no: string;
record_agent_code?: number;
}
const [Modal, modalApi] = useVbenModal({
title: '本单分账与代理链路',
destroyOnClose: true,
});
const modalData = computed(() => modalApi.getData<ModalData>());
</script>
<template>
<Modal class="w-[920px] max-w-[calc(100vw-48px)]" :footer="false">
<OrderSettlementBody
v-if="modalData?.order_no"
:key="modalData.order_no"
:order-no="modalData.order_no"
:highlight-agent-code="modalData.record_agent_code"
/>
</Modal>
</template>

View File

@@ -8,11 +8,6 @@ import { z } from '#/adapter/form';
// 代理产品配置列表列配置
export function useColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'product_name',
title: '产品名称',

View File

@@ -1,14 +1,27 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { maskIdCard, maskMobile, getRealNameStatusName } from '#/utils/agent';
import { maskIdCard, maskMobile } from '#/utils/agent';
export function useRealNameColumns(): VxeTableGridOptions['columns'] {
export type AgentRealNameRow = Record<string, any> & {
agent_id: string;
agent_code?: number;
};
export function useRealNameColumns(
onAgentCodeClick?: (row: AgentRealNameRow) => void,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
field: 'agent_code',
title: '代理编号',
width: 110,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
field: 'agent_id',
@@ -59,15 +72,19 @@ export function useRealNameColumns(): VxeTableGridOptions['columns'] {
width: 160,
sortable: true,
},
] as const;
];
}
export function useRealNameFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
component: 'Input',
fieldName: 'agent_id',
label: '代理ID',
componentProps: {
placeholder: '请输入代理ID',
allowClear: true,
},
},
{
component: 'Select',

View File

@@ -1,12 +1,18 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentRealNameList } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { useRealNameColumns, useRealNameFormSchema } from './data';
import {
type AgentRealNameRow,
useRealNameColumns,
useRealNameFormSchema,
} from './data';
interface Props {
agentId?: number;
@@ -20,29 +26,34 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onRealNameAgentCode(row: AgentRealNameRow) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useRealNameFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useRealNameColumns(),
columns: useRealNameColumns(onRealNameAgentCode),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async (
{ page }: { page: QueryParams },
formValues: Record<string, any>,
) => {
return await getAgentRealNameList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});

View File

@@ -3,27 +3,60 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getRebateTypeName } from '#/utils/agent';
export function useRebateColumns(): VxeTableGridOptions['columns'] {
export type AgentRebateRow = {
id: string;
agent_id: string;
agent_code?: number;
source_agent_id: string;
source_agent_code?: number;
order_id: string;
order_no: string;
rebate_type: number;
amount: number;
status: number;
create_time: string;
};
type OrderNoClickFn = (row: AgentRebateRow) => void;
export function useRebateColumns(
onAgentCodeClick?: (row: AgentRebateRow) => void,
onSourceAgentCodeClick?: (row: AgentRebateRow) => void,
onOrderNoClick?: OrderNoClickFn,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
field: 'agent_code',
title: '代理编号',
width: 110,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
},
{
field: 'source_agent_id',
title: '来源代理ID',
field: 'source_agent_code',
title: '来源代理编号',
width: 120,
cellRender: onSourceAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onSourceAgentCodeClick },
}
: undefined,
},
{
field: 'order_id',
title: '订单ID',
width: 100,
field: 'order_no',
title: '商户订单号',
minWidth: 180,
cellRender: onOrderNoClick
? {
name: 'CellLink',
props: { onClick: onOrderNoClick },
}
: undefined,
},
{
field: 'rebate_type',
@@ -40,26 +73,56 @@ export function useRebateColumns(): VxeTableGridOptions['columns'] {
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'status',
title: '状态',
width: 100,
formatter: ({ cellValue }: { cellValue: number }) => {
const m: Record<number, string> = {
1: '已发放',
2: '已冻结',
3: '已取消',
};
return m[cellValue] ?? '未知';
},
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
},
] as const;
];
}
export function useRebateFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
component: 'Input',
fieldName: 'agent_code',
label: '代理编号',
componentProps: {
placeholder: '请输入代理编号',
allowClear: true,
},
},
{
component: 'InputNumber',
fieldName: 'source_agent_id',
label: '来源代理ID',
component: 'Input',
fieldName: 'source_agent_code',
label: '来源代理编号',
componentProps: {
placeholder: '请输入来源代理编号',
allowClear: true,
},
},
{
component: 'Input',
fieldName: 'order_no',
label: '商户订单号',
componentProps: {
placeholder: '请输入商户订单号',
allowClear: true,
},
},
{
component: 'Select',

View File

@@ -1,15 +1,26 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentRebateList } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { navigateToOrderList } from '#/composables/use-order-list-navigate';
import { useRebateColumns, useRebateFormSchema } from './data';
import {
type AgentRebateRow,
useRebateColumns,
useRebateFormSchema,
} from './data';
interface Props {
agentId?: number;
/** 嵌套在弹窗内时,跳转到订单/代理列表后调用(用于关闭弹窗) */
navigateAway?: () => void;
}
interface QueryParams {
@@ -20,29 +31,56 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onRebateAgentCode(row: AgentRebateRow) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
props.navigateAway?.();
}
}
function onRebateSourceAgentCode(row: AgentRebateRow) {
if (row.source_agent_code != null) {
navigateToAgentList(router, { agentCode: row.source_agent_code });
props.navigateAway?.();
}
}
function onOrderNoClick(row: AgentRebateRow) {
if (row.order_no) {
navigateToOrderList(router, { orderNo: row.order_no });
props.navigateAway?.();
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useRebateFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useRebateColumns(),
columns: useRebateColumns(onRebateAgentCode, onRebateSourceAgentCode, onOrderNoClick),
toolbarConfig: {
custom: true,
export: false,
refresh: false,
search: true,
zoom: true,
},
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async (
{ page }: { page: QueryParams },
formValues: Record<string, any>,
) => {
return await getAgentRebateList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});
@@ -53,7 +91,7 @@ const [Grid] = useVbenVxeGrid({
total: 'total',
},
},
},
} as VxeTableGridOptions<AgentRebateRow>,
});
</script>

View File

@@ -33,16 +33,13 @@ const [Grid] = useVbenVxeGrid({
columns: useRewardColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async (
{ page }: { page: QueryParams },
formValues: Record<string, any>,
) => {
return await getAgentRewardList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});

View File

@@ -1,23 +1,27 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import {
getLevelName,
getUpgradeStatusName,
getUpgradeTypeName,
} from '#/utils/agent';
import { getLevelName, getUpgradeTypeName } from '#/utils/agent';
export function useUpgradeColumns(): VxeTableGridOptions['columns'] {
export type AgentUpgradeRow = Record<string, any> & {
agent_id: string;
agent_code?: number;
};
export function useUpgradeColumns(
onAgentCodeClick?: (row: AgentUpgradeRow) => void,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
field: 'agent_code',
title: '代理编号',
width: 110,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
field: 'from_level',
@@ -64,7 +68,7 @@ export function useUpgradeColumns(): VxeTableGridOptions['columns'] {
cellRender: {
name: 'CellTag',
options: [
{ value: 1, color: 'warning', label: '待处理' },
{ value: 1, color: 'warning', label: '待支付' },
{ value: 2, color: 'success', label: '已完成' },
{ value: 3, color: 'error', label: '已失败' },
],
@@ -76,15 +80,19 @@ export function useUpgradeColumns(): VxeTableGridOptions['columns'] {
width: 160,
sortable: true,
},
] as const;
];
}
export function useUpgradeFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
component: 'Input',
fieldName: 'agent_code',
label: '代理编号',
componentProps: {
placeholder: '请输入代理编号',
allowClear: true,
},
},
{
component: 'Select',
@@ -105,7 +113,7 @@ export function useUpgradeFormSchema(): VbenFormSchema[] {
componentProps: {
allowClear: true,
options: [
{ label: '待处理', value: 1 },
{ label: '待支付', value: 1 },
{ label: '已完成', value: 2 },
{ label: '已失败', value: 3 },
],
@@ -113,4 +121,3 @@ export function useUpgradeFormSchema(): VbenFormSchema[] {
},
];
}

View File

@@ -1,12 +1,18 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentUpgradeList } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import { useUpgradeColumns, useUpgradeFormSchema } from './data';
import {
type AgentUpgradeRow,
useUpgradeColumns,
useUpgradeFormSchema,
} from './data';
interface Props {
agentId?: number;
@@ -20,29 +26,34 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onUpgradeAgentCode(row: AgentUpgradeRow) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useUpgradeFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useUpgradeColumns(),
columns: useUpgradeColumns(onUpgradeAgentCode),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
query: async (
{ page }: { page: QueryParams },
formValues: Record<string, any>,
) => {
return await getAgentUpgradeList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});

View File

@@ -6,6 +6,7 @@ import type { AgentApi } from '#/api/agent';
export function useWithdrawalColumns(
onActionClick?: OnActionClickFn<AgentApi.AgentWithdrawalListItem>,
onAgentCodeClick?: (row: AgentApi.AgentWithdrawalListItem) => void,
): VxeTableGridOptions['columns'] {
return [
{
@@ -14,9 +15,15 @@ export function useWithdrawalColumns(
width: 180,
},
{
title: '代理ID',
field: 'agent_id',
width: 100,
title: '代理编号',
field: 'agent_code',
width: 110,
cellRender: onAgentCodeClick
? {
name: 'CellLink',
props: { onClick: onAgentCodeClick },
}
: undefined,
},
{
title: '提现金额',
@@ -40,12 +47,12 @@ export function useWithdrawalColumns(
title: '提现方式',
field: 'withdrawal_type',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) => {
const methodMap: Record<number, string> = {
1: '支付宝',
2: '银行卡',
};
return methodMap[cellValue] || '未知';
cellRender: {
name: 'CellTag',
options: [
{ value: 1, color: 'processing', label: '支付宝' },
{ value: 2, color: 'success', label: '银行卡' },
],
},
},
{
@@ -126,16 +133,16 @@ export function useWithdrawalFormSchema(): VbenFormSchema[] {
label: '提现单号',
component: 'Input',
componentProps: {
placeholder: '请输入提现单号',
placeholder: '支持模糊匹配',
allowClear: true,
},
},
{
fieldName: 'agent_id',
label: '代理ID',
fieldName: 'agent_code',
label: '代理编号',
component: 'Input',
componentProps: {
placeholder: '请输入代理ID',
placeholder: '请输入代理编号',
allowClear: true,
},
},
@@ -144,10 +151,28 @@ export function useWithdrawalFormSchema(): VbenFormSchema[] {
label: '提现方式',
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '支付宝', value: 1 },
{ label: '银行卡', value: 2 },
],
},
},
{
fieldName: 'payee_account',
label: '收款账户',
component: 'Input',
componentProps: {
placeholder: '支持模糊匹配',
allowClear: true,
},
},
{
fieldName: 'payee_name',
label: '收款人',
component: 'Input',
componentProps: {
placeholder: '支持模糊匹配',
allowClear: true,
},
},

View File

@@ -1,11 +1,13 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentWithdrawalList } from '#/api/agent';
import type { AgentApi } from '#/api/agent';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import AuditModal from './modules/audit-modal.vue';
import { useWithdrawalColumns, useWithdrawalFormSchema } from './data';
@@ -22,10 +24,18 @@ interface QueryParams {
const props = defineProps<Props>();
const router = useRouter();
const queryParams = computed(() => ({
...(props.agentId ? { agent_id: props.agentId } : {}),
}));
function onWithdrawalAgentCode(row: AgentApi.AgentWithdrawalListItem) {
if (row.agent_code != null) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
}
// 审核弹窗
const [AuditModalComponent, auditModalApi] = useVbenModal({
connectedComponent: AuditModal,
@@ -59,19 +69,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
submitOnChange: true,
},
gridOptions: {
columns: useWithdrawalColumns(handleActionClick),
columns: useWithdrawalColumns(handleActionClick, onWithdrawalAgentCode),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
page: QueryParams;
form: Record<string, any>;
}) => {
query: async ({ page }: { page: QueryParams }, formValues: Record<string, any>) => {
return await getAgentWithdrawalList({
...queryParams.value,
...form,
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});

View File

@@ -1,266 +0,0 @@
<script lang="ts" setup>
import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisChartCard,
WorkbenchHeader,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
const userStore = useUserStore();
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
// 例如url: /dashboard/workspace
const projectItems: WorkbenchProjectItem[] = [
{
color: '',
content: '不要等待机会,而要创造机会。',
date: '2021-04-01',
group: '开源组',
icon: 'carbon:logo-github',
title: 'Github',
url: 'https://github.com',
},
{
color: '#3fb27f',
content: '现在的你决定将来的你。',
date: '2021-04-01',
group: '算法组',
icon: 'ion:logo-vue',
title: 'Vue',
url: 'https://vuejs.org',
},
{
color: '#e18525',
content: '没有什么才能比努力更重要。',
date: '2021-04-01',
group: '上班摸鱼',
icon: 'ion:logo-html5',
title: 'Html5',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
},
{
color: '#bf0c2c',
content: '热情和欲望可以突破一切难关。',
date: '2021-04-01',
group: 'UI',
icon: 'ion:logo-angular',
title: 'Angular',
url: 'https://angular.io',
},
{
color: '#00d8ff',
content: '健康的身体是实现目标的基石。',
date: '2021-04-01',
group: '技术牛',
icon: 'bx:bxl-react',
title: 'React',
url: 'https://reactjs.org',
},
{
color: '#EBD94E',
content: '路是走出来的,而不是空想出来的。',
date: '2021-04-01',
group: '架构组',
icon: 'ion:logo-javascript',
title: 'Js',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
},
];
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
},
{
color: '#bf0c2c',
icon: 'ion:grid-outline',
title: '仪表盘',
url: '/dashboard',
},
{
color: '#e18525',
icon: 'ion:layers-outline',
title: '组件',
url: '/demos/features/icons',
},
{
color: '#3fb27f',
icon: 'ion:settings-outline',
title: '系统管理',
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
},
{
color: '#4daf1bc9',
icon: 'ion:key-outline',
title: '权限管理',
url: '/demos/access/page-control',
},
{
color: '#00d8ff',
icon: 'ion:bar-chart-outline',
title: '图表',
url: '/analytics',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
date: '2024-07-30 11:00:00',
title: '审查前端代码提交',
},
{
completed: true,
content: `检查并优化系统性能降低CPU使用率。`,
date: '2024-07-30 11:00:00',
title: '系统性能优化',
},
{
completed: false,
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
date: '2024-07-30 11:00:00',
title: '安全检查',
},
{
completed: false,
content: `更新项目中的所有npm依赖包确保使用最新版本。`,
date: '2024-07-30 11:00:00',
title: '更新项目依赖',
},
{
completed: false,
content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
date: '2024-07-30 11:00:00',
title: '修复UI显示问题',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
date: '刚刚',
title: '威廉',
},
{
avatar: 'svg:avatar-2',
content: `关注了 <a>威廉</a> `,
date: '1个小时前',
title: '艾文',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1天前',
title: '克里斯',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写一个Vite插件</a> `,
date: '2天前',
title: 'Vben',
},
{
avatar: 'svg:avatar-1',
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
date: '3天前',
title: '皮特',
},
{
avatar: 'svg:avatar-2',
content: `关闭了问题 <a>如何运行项目</a> `,
date: '1周前',
title: '杰克',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1周前',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `推送了代码到 <a>Github</a>`,
date: '2021-04-01 20:00',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
date: '2021-03-01 20:00',
title: 'Vben',
},
];
const router = useRouter();
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
// This is a sample method, adjust according to the actual project requirements
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
}
}
</script>
<template>
<div class="p-5">
<WorkbenchHeader
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
>
<template #title>
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧
</template>
<template #description> 今日晴20 - 32 </template>
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
</div>
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="快捷导航"
@click="navTo"
/>
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
<AnalysisChartCard class="mt-5" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
</div>
</div>
</div>
</template>

View File

@@ -58,18 +58,18 @@ export function useGridFormSchema(): VbenFormSchema[] {
component: 'Input',
fieldName: 'title',
label: '通知标题',
componentProps: {
allowClear: true,
placeholder: '模糊匹配标题',
},
},
{
component: 'Select',
component: 'Input',
fieldName: 'notification_page',
label: '通知页面',
componentProps: {
allowClear: true,
options: [
{ label: '首页', value: 'home' },
{ label: '个人中心', value: 'profile' },
{ label: '订单页', value: 'order' },
],
placeholder: '模糊匹配路径',
},
},
{
@@ -79,8 +79,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
componentProps: {
allowClear: true,
options: [
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' },
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
@@ -115,7 +115,7 @@ export function useColumns<T = NotificationApi.NotificationItem>(
width: 200,
},
{
field: 'show_date',
field: 'start_date',
title: '展示日期',
width: 300,
formatter: ({ row }) => {
@@ -125,7 +125,7 @@ export function useColumns<T = NotificationApi.NotificationItem>(
},
},
{
field: 'show_time',
field: 'start_time',
title: '展示时间',
width: 300,
formatter: ({ row }) => {

View File

@@ -15,6 +15,7 @@ import { Button, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteNotification,
getNotificationDetail,
getNotificationList,
updateNotification,
} from '#/api/notification';
@@ -36,23 +37,56 @@ const columns = useColumns<NotificationApi.NotificationItem>(
},
);
function buildNotificationListParams(formValues: Record<string, any>) {
const params: Record<string, any> = { ...formValues };
delete params.date_range;
for (const key of ['title', 'notification_page']) {
const v = params[key];
if (v === '' || v === undefined || v === null) {
delete params[key];
}
}
const statusRaw = params.status;
if (statusRaw === '' || statusRaw === undefined || statusRaw === null) {
delete params.status;
} else {
const n = Number(String(statusRaw).trim());
if (n === 0 || n === 1) {
params.status = n;
} else {
delete params.status;
}
}
return params;
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['date', ['startDate', 'endDate']]],
fieldMappingTime: [['date_range', ['start_date', 'end_date']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns,
height: 'auto',
keepSource: true,
keepSource: false,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
query: async (
{ page, form }: { page: { currentPage: number; pageSize: number }; form?: Record<string, any> },
formValues: Record<string, any>,
) => {
const filters =
formValues && Object.keys(formValues).length > 0
? formValues
: (form ?? {});
return await getNotificationList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
...buildNotificationListParams(filters),
});
},
},
@@ -125,17 +159,8 @@ async function onStatusChange(
`你要将通知"${row.title}"的状态切换为【${status[newStatus]}】吗?`,
'切换状态',
);
// 获取完整的通知数据
const notification = await getNotificationList({
page: 1,
pageSize: 1,
id: row.id,
});
const fullData = notification.items[0];
if (!fullData) {
message.error('获取通知数据失败');
return false;
}
// 列表接口不支持按 id 筛选,必须用详情接口取完整字段再更新
const fullData = await getNotificationDetail(row.id);
await updateNotification(row.id, {
id: row.id,
status: newStatus,
@@ -147,6 +172,7 @@ async function onStatusChange(
end_date: fullData.end_date,
end_time: fullData.end_time,
});
onRefresh();
return true;
} catch {
return false;

View File

@@ -78,7 +78,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
));
emit('success');
drawerApi.close();
} catch {
} finally {
drawerApi.unlock();
}
},

View File

@@ -2,8 +2,11 @@ import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { OrderApi } from '#/api/order';
type AgentClickFn = (row: OrderApi.Order) => void;
export function useColumns<T = OrderApi.Order>(
onActionClick: OnActionClickFn<T>,
onAgentClick?: AgentClickFn,
): VxeTableGridOptions['columns'] {
return [
{
@@ -61,6 +64,45 @@ export function useColumns<T = OrderApi.Order>(
return `¥${row.amount.toFixed(2)}`;
},
},
{
cellRender: {
name: 'CellTag',
options: [
{ label: '是', value: true, color: 'success' },
{ label: '否', value: false, color: 'default' },
],
},
field: 'is_agent_order',
title: '是否代理订单',
width: 120,
},
{
field: 'agent_code',
title: '代理编号',
width: 120,
cellRender: onAgentClick
? {
name: 'CellLink',
props: {
onClick: onAgentClick,
},
}
: undefined,
},
{
cellRender: {
name: 'CellTag',
options: [
{ value: 'not_agent', color: 'default', label: '非代理订单' },
{ value: 'pending', color: 'warning', label: '待处理' },
{ value: 'success', color: 'success', label: '处理成功' },
{ value: 'failed', color: 'error', label: '处理失败' },
],
},
field: 'agent_process_status',
title: '代理处理状态',
width: 130,
},
{
cellRender: {
name: 'CellTag',
@@ -130,7 +172,7 @@ export function useColumns<T = OrderApi.Order>(
},
},
{
code: 'query',
code: 'openOrderQueryResultPage',
text: '查询结果',
disabled: (row: OrderApi.Order) => {
return row.query_state !== 'success';
@@ -205,6 +247,23 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'status',
label: '支付状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
},
fieldName: 'is_agent_order',
label: '是否代理订单',
},
{
component: 'Input',
fieldName: 'agent_code',
label: '代理编号',
},
{
component: 'RangePicker',
fieldName: 'create_time',

View File

@@ -5,11 +5,17 @@ import type {
} from '#/adapter/vxe-table';
import type { OrderApi } from '#/api/order';
import { useRouter } from 'vue-router';
import { nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { navigateToAgentList } from '#/composables/use-agent-list-navigate';
import {
ORDER_LIST_FOCUS_EVENT,
consumeOrderListFocusFromStorage,
} from '#/composables/use-order-list-navigate';
import { getOrderList } from '#/api/order';
import { useColumns, useGridFormSchema } from './data';
@@ -26,7 +32,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick),
columns: useColumns(onActionClick, onAgentClick),
height: 'auto',
keepSource: true,
proxyConfig: {
@@ -46,7 +52,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
custom: true,
export: true,
refresh: { code: 'query' },
// 关闭工具栏刷新,避免与代理 query / 误触操作列逻辑产生冲突;可用搜索区「搜索」刷新列表
refresh: false,
search: true,
zoom: true,
},
@@ -58,15 +65,25 @@ const [RefundDrawer, refundDrawerApi] = useVbenDrawer({
destroyOnClose: true,
});
const route = useRoute();
const router = useRouter();
function onActionClick(e: OnActionClickParams<OrderApi.Order>) {
switch (e.code) {
// 历史/误触:勿与表格代理 code 混用
case 'query': {
return;
}
case 'openOrderQueryResultPage': {
const id = e.row?.id;
const idStr = id == null ? '' : String(id).trim();
if (!idStr || idStr === ':id') {
return;
}
router.push({
name: 'OrderQueryDetail',
params: {
id: e.row.id,
id: idStr,
},
});
break;
@@ -75,6 +92,9 @@ function onActionClick(e: OnActionClickParams<OrderApi.Order>) {
onRefund(e.row);
break;
}
default: {
break;
}
}
}
@@ -89,6 +109,89 @@ function onRefundSuccess() {
function onRefresh() {
gridApi.query();
}
// 点击代理编号跳转到代理列表并筛选
function onAgentClick(row: OrderApi.Order) {
if (row.agent_code) {
navigateToAgentList(router, { agentCode: row.agent_code });
}
}
/** 表格挂载后 formApi 才可用,外部带单号进入时需短暂等待 */
async function waitForGridFormReady(maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
const setValues = (gridApi.formApi as unknown as Record<string, unknown>)
?.setValues;
if (typeof setValues === 'function') {
return true;
}
await nextTick();
await new Promise<void>((r) => setTimeout(r, 16));
}
return false;
}
// 从外部跳转过来时,读取 sessionStorage 中的 order_no 并填入表单并筛选列表(不进入任何详情页)
async function applyOrderListFocus(orderNo: string) {
const ok = await waitForGridFormReady();
const setValues = (gridApi.formApi as unknown as Record<string, unknown>)
?.setValues;
if (!ok || typeof setValues !== 'function') {
return;
}
await gridApi.formApi.setValues({ order_no: orderNo });
await gridApi.query();
}
function onOrderListExternalFocus(e: Event) {
const ce = e as CustomEvent<{ order_no?: string }>;
const d = ce.detail;
if (d?.order_no) {
void applyOrderListFocus(d.order_no);
}
}
async function applyOrderNoFromRouteIfPresent() {
const q = route.query.order_no;
const orderNo =
typeof q === 'string' ? q.trim() : Array.isArray(q) ? String(q[0] ?? '').trim() : '';
if (!orderNo) {
return;
}
await applyOrderListFocus(orderNo);
if (route.query.order_no != null) {
await router.replace({ path: '/order', query: {} });
}
}
onMounted(() => {
window.addEventListener(
ORDER_LIST_FOCUS_EVENT,
onOrderListExternalFocus as EventListener,
);
void (async () => {
await applyOrderNoFromRouteIfPresent();
const fromStorage = consumeOrderListFocusFromStorage();
if (fromStorage?.order_no) {
await applyOrderListFocus(fromStorage.order_no);
}
})();
});
watch(
() => route.query.order_no,
() => {
void applyOrderNoFromRouteIfPresent();
},
);
onUnmounted(() => {
window.removeEventListener(
ORDER_LIST_FOCUS_EVENT,
onOrderListExternalFocus as EventListener,
);
});
</script>
<template>

View File

@@ -22,7 +22,25 @@ import { getOrderQueryDetail } from '#/api/order/query';
const route = useRoute();
const router = useRouter();
const orderId = route.params.id as string;
const rawId = route.params.id;
const orderId =
typeof rawId === 'string'
? rawId
: Array.isArray(rawId)
? String(rawId[0] ?? '')
: '';
function isValidOrderIdForQuery(id: string) {
const t = id.trim();
if (!t || t === ':id' || t === 'undefined' || t === 'null') {
return false;
}
// 未替换的路由占位(如字面量 :xxx
if (t.startsWith(':')) {
return false;
}
return true;
}
const loading = ref(false);
const queryDetail = ref<OrderQueryApi.QueryDetail>();
@@ -84,7 +102,10 @@ function handleBack() {
// 获取查询详情
async function fetchQueryDetail() {
if (!orderId) return;
if (!isValidOrderIdForQuery(orderId)) {
await router.replace({ path: '/order', query: {} });
return;
}
loading.value = true;
try {

View File

@@ -42,6 +42,15 @@ export function useFormSchema(): VbenFormSchema[] {
// 搜索表单配置
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'user_id',
label: '用户ID',
componentProps: {
allowClear: true,
placeholder: '平台用户主键,精确查询',
},
},
{
component: 'Input',
fieldName: 'mobile',

View File

@@ -6,14 +6,24 @@ import type {
} from '#/adapter/vxe-table';
import type { PlatformUserApi } from '#/api/platform-user';
import { nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
consumePlatformUserListFocusFromStorage,
PLATFORM_USER_LIST_FOCUS_EVENT,
} from '#/composables/use-platform-user-list-navigate';
import { getPlatformUserList } from '#/api/platform-user';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const route = useRoute();
const router = useRouter();
// 表单抽屉
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
@@ -108,6 +118,88 @@ function onEdit(row: PlatformUserApi.PlatformUserItem) {
function onRefresh() {
gridApi.query();
}
async function waitForGridFormReady(maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
const setValues = (gridApi.formApi as unknown as Record<string, unknown>)
?.setValues;
if (typeof setValues === 'function') {
return true;
}
await nextTick();
await new Promise<void>((r) => setTimeout(r, 16));
}
return false;
}
async function applyPlatformUserListFocus(userId: string) {
const id = userId.trim();
if (!id) {
return;
}
const ok = await waitForGridFormReady();
const setValues = (gridApi.formApi as unknown as Record<string, unknown>)
?.setValues;
if (!ok || typeof setValues !== 'function') {
return;
}
await gridApi.formApi.setValues({ user_id: id });
await gridApi.query();
}
function onPlatformUserListExternalFocus(e: Event) {
const ce = e as CustomEvent<{ user_id?: string }>;
const d = ce.detail;
if (d?.user_id) {
void applyPlatformUserListFocus(d.user_id);
}
}
async function applyUserIdFromRouteIfPresent() {
const q = route.query.user_id;
const userId =
typeof q === 'string'
? q.trim()
: Array.isArray(q)
? String(q[0] ?? '').trim()
: '';
if (!userId) {
return;
}
await applyPlatformUserListFocus(userId);
if (route.query.user_id != null) {
await router.replace({ path: route.path, query: {} });
}
}
onMounted(() => {
window.addEventListener(
PLATFORM_USER_LIST_FOCUS_EVENT,
onPlatformUserListExternalFocus as EventListener,
);
void (async () => {
await applyUserIdFromRouteIfPresent();
const fromStorage = consumePlatformUserListFocusFromStorage();
if (fromStorage?.user_id) {
await applyPlatformUserListFocus(fromStorage.user_id);
}
})();
});
watch(
() => route.query.user_id,
() => {
void applyUserIdFromRouteIfPresent();
},
);
onUnmounted(() => {
window.removeEventListener(
PLATFORM_USER_LIST_FOCUS_EVENT,
onPlatformUserListExternalFocus as EventListener,
);
});
</script>
<template>

View File

@@ -28,16 +28,6 @@ export function useFormSchema(): VbenFormSchema[] {
fieldName: 'notes',
label: '备注',
},
{
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
fieldName: 'cost_price',
label: '成本价',
rules: 'required',
},
{
component: 'InputNumber',
componentProps: {
@@ -88,9 +78,9 @@ export function useColumns<T = ProductApi.ProductItem>(
},
{
field: 'cost_price',
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
title: '成本价',
width: 120,
formatter: ({ cellValue }) => `¥${(cellValue || 0).toFixed(2)}`,
title: '成本价(模块合计)',
width: 140,
},
{
field: 'sell_price',

View File

@@ -10,7 +10,7 @@ import type {
import type { FeatureApi } from '#/api/product-manage/feature';
import type { ProductApi } from '#/api/product-manage/product';
import { h, ref } from 'vue';
import { computed, h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -85,6 +85,14 @@ const [Modal, modalApi] = useVbenModal({
const loading = ref(false);
const tempFeatureList = ref<TempFeatureItem[]>([]);
/** 已关联模块成本合计(与后台汇总规则一致:各模块 cost_price 相加) */
const totalLinkedCost = computed(() =>
tempFeatureList.value.reduce(
(sum, row) => sum + Number(row.cost_price ?? 0),
0,
),
);
// 表格配置
const [Grid] = useVbenVxeGrid({
formOptions: {
@@ -166,6 +174,15 @@ const columns: TableColumnsType<TempFeatureItem> = [
title: '模块描述',
dataIndex: 'name',
},
{
title: '模块成本(元)',
dataIndex: 'cost_price',
width: 130,
customRender: ({ record }) => {
const v = Number(record.cost_price ?? 0);
return `¥${v.toFixed(2)}`;
},
},
{
title: '是否启用',
dataIndex: 'enable',
@@ -303,6 +320,7 @@ function handleAddFeature(feature: FeatureApi.FeatureItem) {
feature_id: feature.id,
api_id: feature.api_id,
name: feature.name,
cost_price: feature.cost_price ?? 0,
sort: maxSort + 1,
enable: 1,
is_important: 0,
@@ -342,7 +360,15 @@ function handleRemoveFeature(record: TempFeatureItem) {
</div>
<!-- 右侧已关联模块列表 -->
<div class="flex-1">
<div class="mb-2 text-base font-medium">已关联模块</div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2 text-base font-medium">
<span>已关联模块</span>
<span class="text-sm font-normal text-gray-600">
总成本
<span class="font-semibold text-primary">
¥{{ totalLinkedCost.toFixed(2) }}
</span>
</span>
</div>
<div class="mb-4 text-sm text-gray-500">
提示可以通过拖拽行来调整模块顺序通过开关控制模块的启用状态和重要程度
</div>

View File

@@ -70,11 +70,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'api_name',
label: $t('system.api.apiName'),
},
{
component: 'Input',
fieldName: 'api_code',
label: $t('system.api.apiCode'),
},
{
component: 'Select',
componentProps: {
@@ -103,11 +98,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'status',
label: $t('system.api.status'),
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: $t('system.api.createTime'),
},
];
}

View File

@@ -26,7 +26,6 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},

View File

@@ -51,10 +51,9 @@ export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'role_name',
fieldName: 'name',
label: $t('system.role.roleName'),
},
{ component: 'Input', fieldName: 'id', label: $t('system.role.id') },
{
component: 'Select',
componentProps: {
@@ -67,16 +66,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'status',
label: $t('system.role.status'),
},
{
component: 'Input',
fieldName: 'description',
label: $t('system.role.remark'),
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: $t('system.role.createTime'),
},
];
}

View File

@@ -32,7 +32,6 @@ const [ApiPermissionsDrawer, apiPermissionsDrawerApi] = useVbenDrawer({
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},

View File

@@ -76,11 +76,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'status',
label: $t('system.user.status'),
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: $t('system.user.createTime'),
},
];
}
@@ -123,7 +118,7 @@ export function useColumns<T = SystemUserApi.SystemUser>(
},
options: [
{ code: 'edit', text: '编辑' },
{ code: 'resetPassword', text: '重置密码' },
{ code: 'changePassword', text: $t('system.user.changePassword') },
{ code: 'delete', text: '删除' },
],
name: 'CellOperation',

View File

@@ -25,14 +25,13 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
destroyOnClose: true,
});
const [ResetPasswordDrawer, resetPasswordDrawerApi] = useVbenDrawer({
const [ChangePasswordDrawer, changePasswordDrawerApi] = useVbenDrawer({
connectedComponent: ResetPasswordForm,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
@@ -75,8 +74,8 @@ function onActionClick(e: OnActionClickParams<SystemUserApi.SystemUser>) {
onEdit(e.row);
break;
}
case 'resetPassword': {
onResetPassword(e.row);
case 'changePassword': {
onChangePassword(e.row);
break;
}
}
@@ -132,8 +131,8 @@ function onEdit(row: SystemUserApi.SystemUser) {
formDrawerApi.setData(row).open();
}
function onResetPassword(row: SystemUserApi.SystemUser) {
resetPasswordDrawerApi.setData(row).open();
function onChangePassword(row: SystemUserApi.SystemUser) {
changePasswordDrawerApi.setData(row).open();
}
function onDelete(row: SystemUserApi.SystemUser) {
@@ -166,7 +165,7 @@ function onCreate() {
<template>
<Page auto-content-height>
<FormDrawer />
<ResetPasswordDrawer @success="onRefresh" />
<ChangePasswordDrawer @success="onRefresh" />
<Grid :table-title="$t('system.user.list')">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">

View File

@@ -3,10 +3,13 @@ import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenDrawer, z } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { resetPassword } from '#/api/system/user';
import { $t } from '#/locales';
const emits = defineEmits(['success']);
@@ -51,6 +54,8 @@ const [Drawer, drawerApi] = useVbenDrawer({
drawerApi.lock();
resetPassword(id.value, { password: values.password })
.then(() => {
message.success($t('system.user.changePasswordSuccess'));
drawerApi.unlock();
emits('success');
drawerApi.close();
})
@@ -72,9 +77,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
},
});
const getDrawerTitle = computed(() => {
return '重置密码';
});
const getDrawerTitle = computed(() => $t('system.user.changePassword'));
</script>
<template>
<Drawer :title="getDrawerTitle">