feat(payment): 移动端应用管理前端: 卡片页+详情页+API+locale

- API: src/api/payment/config/mobile-app.api.ts (list/list-by-app-type/get/save/update-enabled)
- 卡片页 MobileAppConfig.vue: 三张卡片(商户端/管理端可点击, 收银台灰显敬请期待)
- 详情页 detail/MobileAppDetail.vue: 按移动平台分Tab配置(密钥/通知/绑定/启用), 路由 /payment/config/mobile-app/detail/:appType
- locale: payment/config/mobileApp.json (zh-CN/en-US, 含 platformNames)
This commit is contained in:
DaxPay Dev
2026-07-05 12:36:30 +08:00
parent 0b53ec55a9
commit c07e7a3657
5 changed files with 596 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import type { BaseEntity, Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 移动端应用配置 API平台级, 按端类型+移动平台维度)
*/
export const MobileAppApi = {
/**
* 查询全部(按端类型分组)
*/
list(): Promise<Result<MobileAppResult[]>> {
return defHttp.get({ url: '/admin/mobile-app/list' });
},
/**
* 按端类型查询所有平台配置
*/
listByAppType(appType: string): Promise<Result<MobileAppResult[]>> {
return defHttp.get({
url: '/admin/mobile-app/list-by-app-type',
params: { appType },
});
},
/**
* 查询单条详情
*/
findById(id: number): Promise<Result<MobileAppResult>> {
return defHttp.get({ url: '/admin/mobile-app/get', params: { id } });
},
/**
* 保存(按端类型+平台 upsert)
*/
save(param: MobileAppParam): Promise<Result<MobileAppResult>> {
return defHttp.post({ url: '/admin/mobile-app/save', data: param });
},
/**
* 更新启用状态
*/
updateEnabled(id: number, enable: boolean): Promise<Result<void>> {
return defHttp.post({
url: '/admin/mobile-app/update-enabled',
params: { id, enable },
});
},
};
/** 移动端应用配置结果 */
export interface MobileAppResult extends BaseEntity {
/** 端类型: merchant/admin/cashier */
appType?: string;
/** 移动平台: wx_h5/wx_mini/alipay_mini/dy_mini/android/ios */
platform?: string;
/** 应用名称 */
appName?: string;
/** 平台特有密钥配置(JSON文本) */
appConfig?: string;
/** 消息通知配置(JSON文本) */
notifyConfig?: string;
/** 是否启用第三方账号用户绑定 */
bindingEnabled?: boolean;
/** 是否启用 */
enabled?: boolean;
/** 备注 */
remark?: string;
}
/** 移动端应用配置保存参数 */
export interface MobileAppParam {
/** 主键(更新时传) */
id?: number;
/** 端类型 */
appType?: string;
/** 移动平台 */
platform?: string;
/** 应用名称 */
appName?: string;
/** 平台特有密钥配置(JSON文本) */
appConfig?: string;
/** 消息通知配置(JSON文本) */
notifyConfig?: string;
/** 是否启用第三方账号用户绑定 */
bindingEnabled?: boolean;
/** 是否启用 */
enabled?: boolean;
/** 备注 */
remark?: string;
}

View File

@@ -0,0 +1,44 @@
{
"title": "Mobile App Management",
"card": {
"merchant": {
"name": "Merchant App",
"desc": "Mobile app for merchant staff"
},
"admin": {
"name": "Admin App",
"desc": "Mobile app for platform operators"
},
"cashier": {
"name": "Cashier Mini Program",
"desc": "Mini program for consumer payment"
},
"comingSoon": "Coming Soon",
"enter": "Configure"
},
"platformNames": {
"wx_h5": "WeChat Official Account",
"wx_mini": "WeChat Mini Program",
"alipay_mini": "Alipay Mini Program",
"dy_mini": "Douyin Mini Program",
"android": "Android App",
"ios": "iOS App"
},
"detail": {
"back": "Back",
"appName": "App Name",
"appNamePlaceholder": "Enter app name",
"appConfig": "Platform Key Config",
"appConfigPlaceholder": "Enter platform key config in JSON format",
"notifyConfig": "Notification Config",
"notifyConfigPlaceholder": "Enter notification config in JSON format",
"bindingEnabled": "Enable third-party account binding",
"enabled": "Enabled",
"remark": "Remark",
"remarkPlaceholder": "Enter remark",
"save": "Save",
"saveSuccess": "Saved successfully",
"notSupportedAppType": "Unsupported app type",
"emptyPlatformConfig": "No platform config yet, fill in and save to create"
}
}

View File

@@ -0,0 +1,44 @@
{
"title": "移动端应用管理",
"card": {
"merchant": {
"name": "商户端",
"desc": "商户员工使用的移动应用"
},
"admin": {
"name": "管理端",
"desc": "平台运营使用的移动应用"
},
"cashier": {
"name": "收银台小程序",
"desc": "消费者扫码支付的小程序"
},
"comingSoon": "敬请期待",
"enter": "进入配置"
},
"platformNames": {
"wx_h5": "微信公众号",
"wx_mini": "微信小程序",
"alipay_mini": "支付宝小程序",
"dy_mini": "抖音小程序",
"android": "安卓APP",
"ios": "iOS APP"
},
"detail": {
"back": "返回",
"appName": "应用名称",
"appNamePlaceholder": "请输入应用名称",
"appConfig": "平台密钥配置",
"appConfigPlaceholder": "请输入 JSON 格式的平台密钥配置",
"notifyConfig": "消息通知配置",
"notifyConfigPlaceholder": "请输入 JSON 格式的消息通知配置",
"bindingEnabled": "启用第三方账号用户绑定",
"enabled": "是否启用",
"remark": "备注",
"remarkPlaceholder": "请输入备注",
"save": "保存",
"saveSuccess": "保存成功",
"notSupportedAppType": "不支持的端类型",
"emptyPlatformConfig": "暂无平台配置,填写后保存即可创建"
}
}

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
defineOptions({ name: 'MobileAppConfig' });
const router = useRouter();
// 三种端类型卡片配置(收银台本期灰显)
const cards: {
appType: string;
disabled: boolean;
icon: string;
theme: string;
}[] = [
{
appType: 'merchant',
disabled: false,
icon: 'ant-design:shop-filled',
theme: 'blue',
},
{
appType: 'admin',
disabled: false,
icon: 'ant-design:control-filled',
theme: 'green',
},
{
appType: 'cashier',
disabled: true,
icon: 'ant-design:qrcode-outlined',
theme: 'amber',
},
];
/**
* 进入端详情页
*/
function handleEnter(appType: string, disabled: boolean) {
if (disabled) return;
router.push({ path: `/payment/config/mobile-app/detail/${appType}` });
}
</script>
<template>
<div class="m-4">
<a-card variant="borderless" class="rounded-xl shadow-sm">
<template #title>
<span class="text-lg font-bold text-foreground">{{
$t('payment.config.mobileApp.title')
}}</span>
</template>
<div class="mobile-app-grid">
<div
v-for="card in cards"
:key="card.appType"
class="mobile-app-card"
:class="[
{ 'mobile-app-card-disabled': card.disabled },
`theme-${card.theme}`,
]"
@click="handleEnter(card.appType, card.disabled)"
>
<!-- 收银台灰显角标 -->
<div v-if="card.disabled" class="coming-soon-badge">
{{ $t('payment.config.mobileApp.card.comingSoon') }}
</div>
<div class="card-icon">
<IconifyIcon :icon="card.icon" class="text-5xl" />
</div>
<div class="card-name">
{{ $t(`payment.config.mobileApp.card.${card.appType}.name`) }}
</div>
<div class="card-desc">
{{ $t(`payment.config.mobileApp.card.${card.appType}.desc`) }}
</div>
<div v-if="!card.disabled" class="card-enter">
<IconifyIcon icon="ant-design:setting-outlined" class="text-sm" />
<span>{{ $t('payment.config.mobileApp.card.enter') }}</span>
</div>
</div>
</div>
</a-card>
</div>
</template>
<style scoped>
.mobile-app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
padding: 8px;
}
.mobile-app-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
border-radius: 16px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card));
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.mobile-app-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px hsl(var(--muted) / 0.5);
}
.mobile-app-card.theme-blue:hover {
border-color: hsl(var(--primary));
}
.mobile-app-card.theme-green:hover {
border-color: hsl(142 71% 45%);
}
.mobile-app-card-disabled {
cursor: not-allowed;
opacity: 0.55;
}
.mobile-app-card-disabled:hover {
transform: none;
box-shadow: none;
}
.card-icon {
margin-bottom: 16px;
}
.theme-blue .card-icon {
color: hsl(var(--primary));
}
.theme-green .card-icon {
color: hsl(142 71% 45%);
}
.theme-amber .card-icon {
color: hsl(38 92% 50%);
}
.card-name {
font-size: 18px;
font-weight: bold;
color: hsl(var(--foreground));
margin-bottom: 8px;
}
.card-desc {
font-size: 13px;
color: hsl(var(--muted-foreground));
margin-bottom: 16px;
}
.card-enter {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
border-radius: 9999px;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
font-size: 12px;
font-weight: 600;
}
.mobile-app-card:hover .card-enter {
background: hsl(var(--primary));
color: #fff;
}
.coming-soon-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 2px 10px;
border-radius: 9999px;
background: hsl(38 92% 50%);
color: #fff;
font-size: 11px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,220 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import {
type MobileAppParam,
MobileAppApi,
} from '#/api/payment/config/mobile-app.api';
import { useMessage } from '#/hooks/useMessage';
defineOptions({ name: 'MobileAppDetail' });
const route = useRoute();
const router = useRouter();
const { message } = useMessage();
const appType = ref((route.params.appType as string) || '');
const loading = ref(false);
const activePlatform = ref('');
const saving = ref(false);
// 各端支持的平台(收银台无H5和APP)
const PLATFORMS_BY_APP_TYPE: Record<string, string[]> = {
admin: ['wx_h5', 'wx_mini', 'alipay_mini', 'dy_mini', 'android', 'ios'],
cashier: ['wx_mini', 'alipay_mini', 'dy_mini'],
merchant: ['wx_h5', 'wx_mini', 'alipay_mini', 'dy_mini', 'android', 'ios'],
};
const platforms = computed(() => PLATFORMS_BY_APP_TYPE[appType.value] || []);
// 各平台表单数据(platform -> formData)
const formDataMap = reactive<Record<string, MobileAppParam>>({});
// 端标题
const appTitle = computed(() => {
if (appType.value) {
return $t(`payment.config.mobileApp.card.${appType.value}.name`);
}
return $t('payment.config.mobileApp.detail.notSupportedAppType');
});
/**
* 初始化某平台的空表单
*/
function initForm(platform: string): MobileAppParam {
return {
appType: appType.value,
platform,
appName: '',
appConfig: '',
bindingEnabled: false,
enabled: true,
notifyConfig: '',
remark: '',
};
}
/**
* 加载该端所有平台配置
*/
async function loadData() {
if (!appType.value || !platforms.value.length) return;
loading.value = true;
try {
// 初始化所有平台为空表单
for (const p of platforms.value) {
formDataMap[p] = initForm(p);
}
// 用后端数据填充已存在的配置
const { data } = await MobileAppApi.listByAppType(appType.value);
for (const item of data || []) {
if (item.platform && formDataMap[item.platform]) {
Object.assign(formDataMap[item.platform]!, item);
}
}
activePlatform.value = platforms.value[0]!;
} finally {
loading.value = false;
}
}
/**
* 保存当前平台配置
*/
async function handleSave() {
const form = formDataMap[activePlatform.value];
if (!form) return;
saving.value = true;
try {
await MobileAppApi.save(form);
message.success($t('payment.config.mobileApp.detail.saveSuccess'));
await loadData();
} finally {
saving.value = false;
}
}
/**
* 返回卡片页
*/
function handleBack() {
router.push({ path: '/payment/config/mobile-app' });
}
onMounted(() => {
loadData();
});
</script>
<template>
<div class="m-4">
<a-card variant="borderless" class="rounded-xl shadow-sm">
<template #title>
<div class="flex items-center gap-2">
<a-button
type="text"
class="flex items-center justify-center rounded-full hover:bg-accent"
@click="handleBack"
>
<template #icon>
<IconifyIcon
icon="ant-design:arrow-left-outlined"
class="text-lg"
/>
</template>
</a-button>
<span class="text-lg font-bold text-foreground">{{ appTitle }}</span>
</div>
</template>
<a-spin :spinning="loading">
<a-tabs v-model:active-key="activePlatform">
<a-tab-pane
v-for="p in platforms"
:key="p"
:tab="$t(`payment.config.mobileApp.platformNames.${p}`)"
>
<a-form
v-if="formDataMap[p]"
:model="formDataMap[p]"
layout="vertical"
class="max-w-2xl"
>
<a-form-item :label="$t('payment.config.mobileApp.detail.appName')">
<a-input
v-model:value="formDataMap[p]!.appName"
:placeholder="
$t('payment.config.mobileApp.detail.appNamePlaceholder')
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.detail.appConfig')"
>
<a-textarea
v-model:value="formDataMap[p]!.appConfig"
:rows="6"
:placeholder="
$t('payment.config.mobileApp.detail.appConfigPlaceholder')
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.detail.notifyConfig')"
>
<a-textarea
v-model:value="formDataMap[p]!.notifyConfig"
:rows="4"
:placeholder="
$t('payment.config.mobileApp.detail.notifyConfigPlaceholder')
"
/>
</a-form-item>
<a-form-item>
<template #label>
{{ $t('payment.config.mobileApp.detail.bindingEnabled') }}
</template>
<a-switch v-model:checked="formDataMap[p]!.bindingEnabled" />
</a-form-item>
<a-form-item>
<template #label>
{{ $t('payment.config.mobileApp.detail.enabled') }}
</template>
<a-switch v-model:checked="formDataMap[p]!.enabled" />
</a-form-item>
<a-form-item :label="$t('payment.config.mobileApp.detail.remark')">
<a-textarea
v-model:value="formDataMap[p]!.remark"
:rows="2"
:placeholder="
$t('payment.config.mobileApp.detail.remarkPlaceholder')
"
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="saving"
@click="handleSave"
>
{{ $t('payment.config.mobileApp.detail.save') }}
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-spin>
</a-card>
</div>
</template>