feat(mobile): 管理端移动端应用配置结构化表单

完善管理端移动应用详情: 微信/支付宝/抖音小程序按平台渲染结构化字段, 支持查看/编辑态与 diffForm 敏感字段保护; 管理端移除公众号, 安卓/iOS 暂灰显。
This commit is contained in:
DaxPay Dev
2026-07-09 21:44:55 +08:00
parent 104d65443b
commit 36ed1fe3d3
4 changed files with 505 additions and 99 deletions

View File

@@ -26,7 +26,7 @@ export const MobileAppApi = {
/**
* 查询单条详情
*/
findById(id: number): Promise<Result<MobileAppResult>> {
findById(id: string): Promise<Result<MobileAppResult>> {
return defHttp.get({ url: '/admin/mobile-app/get', params: { id } });
},
@@ -40,7 +40,7 @@ export const MobileAppApi = {
/**
* 更新启用状态
*/
updateEnabled(id: number, enable: boolean): Promise<Result<void>> {
updateEnabled(id: string, enable: boolean): Promise<Result<void>> {
return defHttp.post({
url: '/admin/mobile-app/update-enabled',
params: { id, enable },
@@ -56,7 +56,7 @@ export interface MobileAppResult extends BaseEntity {
platform?: string;
/** 应用名称 */
appName?: string;
/** 平台特有密钥配置(JSON文本) */
/** 平台特有密钥配置(JSON文本, 敏感字段脱敏) */
appConfig?: string;
/** 消息通知配置(JSON文本) */
notifyConfig?: string;
@@ -71,7 +71,7 @@ export interface MobileAppResult extends BaseEntity {
/** 移动端应用配置保存参数 */
export interface MobileAppParam {
/** 主键(更新时传) */
id?: number;
id?: string;
/** 端类型 */
appType?: string;
/** 移动平台 */

View File

@@ -24,6 +24,32 @@
"android": "Android App",
"ios": "iOS App"
},
"fields": {
"appId": "AppId",
"appIdPlaceholder": "Enter AppId",
"appIdRequired": "Please enter AppId",
"wxAppId": "Mini Program AppId",
"wxAppIdPlaceholder": "Enter WeChat Mini Program AppId",
"appSecret": "AppSecret",
"appSecretPlaceholder": "Enter AppSecret",
"appSecretRequired": "Please enter AppSecret",
"wxAppSecret": "Mini Program AppSecret",
"wxAppSecretPlaceholder": "Enter WeChat Mini Program AppSecret",
"originalId": "Original ID",
"originalIdPlaceholder": "Enter original ID (gh_..., optional)",
"alipayAppId": "Mini Program AppId",
"alipayAppIdPlaceholder": "Enter Alipay Mini Program AppId",
"privateKey": "App Private Key",
"privateKeyPlaceholder": "Enter app private key",
"privateKeyRequired": "Please enter app private key",
"alipayPublicKey": "Alipay Public Key",
"alipayPublicKeyPlaceholder": "Enter Alipay public key",
"alipayPublicKeyRequired": "Please enter Alipay public key",
"dyAppId": "Mini Program AppId",
"dyAppIdPlaceholder": "Enter Douyin Mini Program AppId",
"dyAppSecret": "Mini Program AppSecret",
"dyAppSecretPlaceholder": "Enter Douyin Mini Program AppSecret"
},
"detail": {
"back": "Back",
"appName": "App Name",
@@ -39,6 +65,7 @@
"save": "Save",
"saveSuccess": "Saved successfully",
"notSupportedAppType": "Unsupported app type",
"emptyPlatformConfig": "No platform config yet, fill in and save to create"
"emptyPlatformConfig": "No platform config yet, fill in and save to create",
"platformComingSoon": "This platform is not available yet; will be handled when the APP is ready"
}
}

View File

@@ -24,6 +24,32 @@
"android": "安卓APP",
"ios": "iOS APP"
},
"fields": {
"appId": "AppId",
"appIdPlaceholder": "请输入 AppId",
"appIdRequired": "请输入 AppId",
"wxAppId": "小程序 AppId",
"wxAppIdPlaceholder": "请输入微信小程序 AppId",
"appSecret": "AppSecret",
"appSecretPlaceholder": "请输入 AppSecret",
"appSecretRequired": "请输入 AppSecret",
"wxAppSecret": "小程序 AppSecret",
"wxAppSecretPlaceholder": "请输入微信小程序 AppSecret",
"originalId": "原始 ID",
"originalIdPlaceholder": "请输入小程序原始 ID(gh_ 开头, 可选)",
"alipayAppId": "小程序 AppId",
"alipayAppIdPlaceholder": "请输入支付宝小程序 AppId",
"privateKey": "应用私钥",
"privateKeyPlaceholder": "请输入应用私钥",
"privateKeyRequired": "请输入应用私钥",
"alipayPublicKey": "支付宝公钥",
"alipayPublicKeyPlaceholder": "请输入支付宝公钥",
"alipayPublicKeyRequired": "请输入支付宝公钥",
"dyAppId": "小程序 AppId",
"dyAppIdPlaceholder": "请输入抖音小程序 AppId",
"dyAppSecret": "小程序 AppSecret",
"dyAppSecretPlaceholder": "请输入抖音小程序 AppSecret"
},
"detail": {
"back": "返回",
"appName": "应用名称",
@@ -39,6 +65,7 @@
"save": "保存",
"saveSuccess": "保存成功",
"notSupportedAppType": "不支持的端类型",
"emptyPlatformConfig": "暂无平台配置,填写后保存即可创建"
"emptyPlatformConfig": "暂无平台配置,填写后保存即可创建",
"platformComingSoon": "该平台配置暂未开放,后续 APP 完善时再处理"
}
}

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@vben/locales';
@@ -10,30 +10,75 @@
type MobileAppParam,
MobileAppApi,
} from '#/api/payment/config/mobile-app.api';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
defineOptions({ name: 'MobileAppDetail' });
/** 结构化配置字段(各平台共用, 按平台取用) */
interface StructuredConfig {
appId: string;
appSecret: string;
originalId: string;
privateKey: string;
alipayPublicKey: string;
}
/** 已支持结构化表单的平台 */
const STRUCTURED_PLATFORMS = new Set(['wx_mini', 'alipay_mini', 'dy_mini']);
const route = useRoute();
const router = useRouter();
const { message } = useMessage();
const { confirm, message } = useMessage();
const { diffForm } = useFormEdit();
const appType = ref((route.params.appType as string) || '');
const loading = ref(false);
const activePlatform = ref('');
const saving = ref(false);
// 是否处于编辑状态(参考平台配置 WechatMpAuthConfigForm)
const isEditing = 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'],
// 各端支持的平台: disabled 表示暂不可用(灰显)
const PLATFORMS_BY_APP_TYPE: Record<
string,
{ disabled?: boolean; platform: string }[]
> = {
admin: [
{ platform: 'wx_mini' },
{ platform: 'alipay_mini' },
{ platform: 'dy_mini' },
{ platform: 'android', disabled: true },
{ platform: 'ios', disabled: true },
],
cashier: [
{ platform: 'wx_mini' },
{ platform: 'alipay_mini' },
{ platform: 'dy_mini' },
],
// 商户端可配微信公众号; 管理端不配(授权回调域名名额被网关/商户端H5占用)
merchant: [
{ platform: 'wx_h5' },
{ platform: 'wx_mini' },
{ platform: 'alipay_mini' },
{ platform: 'dy_mini' },
{ platform: 'android', disabled: true },
{ platform: 'ios', disabled: true },
],
};
const platforms = computed(() => PLATFORMS_BY_APP_TYPE[appType.value] || []);
const platformItems = computed(
() => PLATFORMS_BY_APP_TYPE[appType.value] || [],
);
const platforms = computed(() =>
platformItems.value.map((item) => item.platform),
);
// 各平台表单数据(platform -> formData)
const formDataMap = reactive<Record<string, MobileAppParam>>({});
// 各平台结构化字段
const structuredMap = reactive<Record<string, StructuredConfig>>({});
const originalStructuredMap = reactive<Record<string, StructuredConfig>>({});
// 端标题
const appTitle = computed(() => {
@@ -43,6 +88,23 @@
return $t('payment.config.mobileApp.detail.notSupportedAppType');
});
/**
* 判断平台是否暂不可用
*/
function isPlatformDisabled(platform: string): boolean {
return (
platformItems.value.find((item) => item.platform === platform)
?.disabled === true
);
}
/**
* 是否为结构化表单平台
*/
function isStructuredPlatform(platform: string): boolean {
return STRUCTURED_PLATFORMS.has(platform);
}
/**
* 初始化某平台的空表单
*/
@@ -52,51 +114,222 @@
platform,
appName: '',
appConfig: '',
bindingEnabled: false,
enabled: true,
notifyConfig: '',
remark: '',
};
}
/**
* 初始化结构化字段
*/
function initStructured(): StructuredConfig {
return {
appId: '',
appSecret: '',
originalId: '',
privateKey: '',
alipayPublicKey: '',
};
}
/**
* 从 appConfig JSON 解析结构化字段
*/
function parseStructured(appConfig?: string): StructuredConfig {
const result = initStructured();
try {
if (appConfig) {
const cfg = JSON.parse(appConfig) as Record<string, string>;
result.appId = cfg.appId || '';
result.appSecret = cfg.appSecret || '';
result.originalId = cfg.originalId || '';
result.privateKey = cfg.privateKey || '';
result.alipayPublicKey = cfg.alipayPublicKey || '';
}
} catch {
// 非合法 JSON 时保持空
}
return result;
}
/**
* 按平台序列化 appConfig, 敏感字段仅在 diffForm 有新值时写入
*/
function serializeByPlatform(
platform: string,
structured: StructuredConfig,
sensitive: Partial<StructuredConfig>,
): string {
const obj: Record<string, string | undefined> = {
appId: structured.appId || undefined,
};
if (platform === 'wx_mini') {
obj.originalId = structured.originalId || undefined;
if (sensitive.appSecret !== undefined) {
obj.appSecret = sensitive.appSecret;
}
} else if (platform === 'alipay_mini') {
if (sensitive.privateKey !== undefined) {
obj.privateKey = sensitive.privateKey;
}
if (sensitive.alipayPublicKey !== undefined) {
obj.alipayPublicKey = sensitive.alipayPublicKey;
}
} else if (platform === 'dy_mini') {
if (sensitive.appSecret !== undefined) {
obj.appSecret = sensitive.appSecret;
}
}
return JSON.stringify(obj);
}
/**
* 校验结构化表单
*/
function validateStructured(
platform: string,
structured: StructuredConfig,
original: StructuredConfig,
): boolean {
if (!structured.appId?.trim()) {
message.warning($t('payment.config.mobileApp.fields.appIdRequired'));
return false;
}
if (platform === 'wx_mini' || platform === 'dy_mini') {
if (!original.appSecret && !structured.appSecret?.trim()) {
message.warning(
$t('payment.config.mobileApp.fields.appSecretRequired'),
);
return false;
}
}
if (platform === 'alipay_mini') {
if (!original.privateKey && !structured.privateKey?.trim()) {
message.warning(
$t('payment.config.mobileApp.fields.privateKeyRequired'),
);
return false;
}
if (!original.alipayPublicKey && !structured.alipayPublicKey?.trim()) {
message.warning(
$t('payment.config.mobileApp.fields.alipayPublicKeyRequired'),
);
return false;
}
}
return true;
}
/**
* 加载该端所有平台配置
*/
async function loadData() {
async function loadData(keepPlatform = false) {
if (!appType.value || !platforms.value.length) return;
loading.value = true;
try {
// 初始化所有平台为空表单
for (const p of platforms.value) {
formDataMap[p] = initForm(p);
structuredMap[p] = initStructured();
originalStructuredMap[p] = initStructured();
}
// 用后端数据填充已存在的配置
const { data } = await MobileAppApi.listByAppType(appType.value);
for (const item of data || []) {
if (item.platform && formDataMap[item.platform]) {
Object.assign(formDataMap[item.platform]!, item);
const structured = parseStructured(item.appConfig);
structuredMap[item.platform] = { ...structured };
originalStructuredMap[item.platform] = { ...structured };
}
}
activePlatform.value = platforms.value[0]!;
if (!keepPlatform || !activePlatform.value) {
const firstEnabled = platformItems.value.find((item) => !item.disabled);
activePlatform.value =
firstEnabled?.platform || platforms.value[0] || '';
}
} finally {
loading.value = false;
}
}
/**
* 进入编辑模式
*/
function handleEdit() {
if (isPlatformDisabled(activePlatform.value)) return;
isEditing.value = true;
}
/**
* 取消编辑, 重新加载数据
*/
function handleCancel() {
confirm({
title: $t('common.confirm'),
content: $t('common.confirmCancelContent'),
okText: $t('common.okText'),
cancelText: $t('common.cancelText'),
onOk: async () => {
isEditing.value = false;
await loadData(true);
},
});
}
/**
* 保存当前平台配置
*/
async function handleSave() {
const form = formDataMap[activePlatform.value];
const platform = activePlatform.value;
if (isPlatformDisabled(platform) || !isEditing.value) return;
const form = formDataMap[platform];
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;
let submit: MobileAppParam = {
...form,
appType: appType.value,
platform,
};
if (isStructuredPlatform(platform)) {
const structured = structuredMap[platform];
const originalStructured = originalStructuredMap[platform];
if (!structured || !originalStructured) return;
if (!validateStructured(platform, structured, originalStructured)) {
return;
}
// 按平台敏感字段做 diffForm
const sensitiveKeys =
platform === 'alipay_mini'
? (['privateKey', 'alipayPublicKey'] as const)
: (['appSecret'] as const);
const sensitive = diffForm(
originalStructured,
structured,
...sensitiveKeys,
);
submit = {
...submit,
appConfig: serializeByPlatform(platform, structured, sensitive),
};
}
confirm({
title: $t('common.confirm'),
content: $t('common.confirmSaveContent'),
okText: $t('common.okText'),
cancelText: $t('common.cancelText'),
onOk: async () => {
saving.value = true;
try {
await MobileAppApi.save(submit);
message.success($t('payment.config.mobileApp.detail.saveSuccess'));
isEditing.value = false;
await loadData(true);
} finally {
saving.value = false;
}
},
});
}
/**
@@ -106,6 +339,15 @@
router.push({ path: '/payment/config/mobile-app' });
}
// 切 Tab 时退出编辑并重载, 避免脏数据串平台
watch(activePlatform, async (next, prev) => {
if (!prev || next === prev) return;
if (isEditing.value) {
isEditing.value = false;
await loadData(true);
}
});
onMounted(() => {
loadData();
});
@@ -134,83 +376,193 @@
<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>
<!-- Tabs 右侧: 编辑 / 取消+保存 -->
<template #rightExtra>
<a-space v-if="!isPlatformDisabled(activePlatform)">
<template v-if="!isEditing">
<a-button type="primary" @click="handleEdit">
{{ $t('common.edit') }}
</a-button>
</template>
<template v-else>
<a-button @click="handleCancel">
{{ $t('common.cancel') }}
</a-button>
<a-button
type="primary"
:loading="saving"
@click="handleSave"
>
{{ $t('payment.config.mobileApp.detail.save') }}
{{ $t('common.save') }}
</a-button>
</a-form-item>
</template>
</a-space>
</template>
<a-tab-pane
v-for="item in platformItems"
:key="item.platform"
:disabled="item.disabled"
>
<template #tab>
<span :class="{ 'opacity-50': item.disabled }">
{{
$t(`payment.config.mobileApp.platformNames.${item.platform}`)
}}
<a-tag
v-if="item.disabled"
color="orange"
class="ml-1"
style="font-size: 11px; line-height: 18px"
>
{{ $t('payment.config.mobileApp.card.comingSoon') }}
</a-tag>
</span>
</template>
<!-- 暂不可用平台 -->
<a-empty
v-if="item.disabled"
:description="
$t('payment.config.mobileApp.detail.platformComingSoon')
"
/>
<!-- 结构化表单: wx_mini / alipay_mini / dy_mini -->
<a-form
v-else-if="
isStructuredPlatform(item.platform) &&
formDataMap[item.platform] &&
structuredMap[item.platform]
"
:model="formDataMap[item.platform]"
layout="vertical"
class="max-w-2xl"
>
<!-- 微信小程序 -->
<template v-if="item.platform === 'wx_mini'">
<a-form-item
:label="$t('payment.config.mobileApp.fields.wxAppId')"
required
>
<a-input
v-model:value="structuredMap[item.platform]!.appId"
:disabled="!isEditing"
:placeholder="
$t('payment.config.mobileApp.fields.wxAppIdPlaceholder')
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.fields.wxAppSecret')"
required
>
<a-input
v-model:value="structuredMap[item.platform]!.appSecret"
:disabled="!isEditing"
allow-clear
:placeholder="
$t(
'payment.config.mobileApp.fields.wxAppSecretPlaceholder',
)
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.fields.originalId')"
>
<a-input
v-model:value="structuredMap[item.platform]!.originalId"
:disabled="!isEditing"
:placeholder="
$t(
'payment.config.mobileApp.fields.originalIdPlaceholder',
)
"
/>
</a-form-item>
</template>
<!-- 支付宝小程序 -->
<template v-else-if="item.platform === 'alipay_mini'">
<a-form-item
:label="$t('payment.config.mobileApp.fields.alipayAppId')"
required
>
<a-input
v-model:value="structuredMap[item.platform]!.appId"
:disabled="!isEditing"
:placeholder="
$t(
'payment.config.mobileApp.fields.alipayAppIdPlaceholder',
)
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.fields.privateKey')"
required
>
<a-textarea
v-model:value="structuredMap[item.platform]!.privateKey"
:disabled="!isEditing"
:rows="4"
:placeholder="
$t(
'payment.config.mobileApp.fields.privateKeyPlaceholder',
)
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.fields.alipayPublicKey')"
required
>
<a-textarea
v-model:value="
structuredMap[item.platform]!.alipayPublicKey
"
:disabled="!isEditing"
:rows="4"
:placeholder="
$t(
'payment.config.mobileApp.fields.alipayPublicKeyPlaceholder',
)
"
/>
</a-form-item>
</template>
<!-- 抖音小程序 -->
<template v-else-if="item.platform === 'dy_mini'">
<a-form-item
:label="$t('payment.config.mobileApp.fields.dyAppId')"
required
>
<a-input
v-model:value="structuredMap[item.platform]!.appId"
:disabled="!isEditing"
:placeholder="
$t('payment.config.mobileApp.fields.dyAppIdPlaceholder')
"
/>
</a-form-item>
<a-form-item
:label="$t('payment.config.mobileApp.fields.dyAppSecret')"
required
>
<a-input
v-model:value="structuredMap[item.platform]!.appSecret"
:disabled="!isEditing"
allow-clear
:placeholder="
$t(
'payment.config.mobileApp.fields.dyAppSecretPlaceholder',
)
"
/>
</a-form-item>
</template>
</a-form>
</a-tab-pane>
</a-tabs>