feat: 产品迭代7月份
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m51s

This commit is contained in:
luo
2026-07-27 16:30:29 +08:00
parent cc6fc9243e
commit f0a2b84e53
75 changed files with 2926 additions and 534 deletions

View File

@@ -51,7 +51,12 @@
<span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem>
<ElFormItem label="配置值">
<ElSwitch v-if="isBooleanConfig" v-model="editBoolean" />
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
{{ method }}
</ElCheckbox>
</ElCheckboxGroup>
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
<ElInputNumber
v-else-if="isIntegerConfig"
v-model="editNumber"
@@ -101,11 +106,20 @@
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { SystemConfigItem, SystemConfigModule } from '@/types/api/systemConfig'
import {
PAYMENT_CONFIG_KEYS,
parsePaymentMethods,
type AllowedPaymentMethod,
type SystemConfigItem,
type SystemConfigModule
} from '@/types/api/systemConfig'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'SystemConfigs' })
const { hasAuth } = useAuth()
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
@@ -133,6 +147,8 @@
const originalValue = ref('')
const editBoolean = ref(false)
const editNumber = ref<number | null>(null)
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const columnOptions = [
@@ -198,6 +214,11 @@
const isBooleanConfig = computed(
() => currentConfig.value?.value_type === 'bool' || currentConfig.value?.control === 'switch'
)
const isPaymentConfig = computed(() =>
[PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(
currentConfig.value?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
)
const isIntegerConfig = computed(
() => !isBooleanConfig.value && currentConfig.value?.value_type === 'int'
)
@@ -222,6 +243,7 @@
})
const getSubmittedValue = () => {
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
if (isBooleanConfig.value) return String(editBoolean.value)
if (isIntegerConfig.value) return editNumber.value === null ? '' : String(editNumber.value)
return editValue.value
@@ -246,12 +268,17 @@
return '请输入有效的 JSON'
}
}
if (isPaymentConfig.value) {
if (editPaymentMethods.value.length === 0) return '至少保留一种支付方式'
}
if (value === '') return '配置值不能为空'
return ''
}
const getActions = (row: SystemConfigItem) =>
row.readonly
row.readonly ||
([PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(row.config_key as never) &&
!hasAuth(JULY_PERMISSIONS.systemConfig.payment))
? []
: [{ label: '编辑', handler: () => showEditDialog(row), type: 'primary' as const }]
@@ -280,6 +307,7 @@
editValue.value = row.value
editBoolean.value = row.value === 'true'
editNumber.value = /^-?\d+$/.test(row.value) ? Number(row.value) : null
editPaymentMethods.value = parsePaymentMethods(row.value)
dialogVisible.value = true
}

View File

@@ -0,0 +1,253 @@
<template>
<div class="wecom-application-detail-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">{{ isEdit ? '编辑企微应用' : '新增企微应用' }}</div>
<div class="page-description"
>应用凭据由平台管理员维护保存后可在应用列表测试连接</div
>
</div>
<ElButton @click="goBack">返回应用列表</ElButton>
</div>
</template>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="140px"
class="application-form"
>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="企业 ID" prop="corp_id">
<ElInput v-model="form.corp_id" placeholder="请输入企业微信 CorpID" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="AgentID" prop="agent_id">
<ElInputNumber
v-model="form.agent_id"
:min="1"
controls-position="right"
class="full-width"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="应用名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入应用名称" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="Secret" prop="secret">
<ElInput
v-model="form.secret"
type="password"
show-password
placeholder="请输入应用 Secret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">回调配置</ElDivider>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="回调 Token" prop="callback_token">
<ElInput v-model="form.callback_token" placeholder="请输入回调 Token" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
<ElInput v-model="form.encoding_aes_key" placeholder="请输入 43 位 EncodingAESKey" />
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="启用状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
<div class="form-actions">
<ElButton @click="goBack">取消</ElButton>
<ElButton
v-if="isEdit"
v-permission="JULY_PERMISSIONS.wecom.application"
:loading="testing"
@click="testApplication"
>
测试连接
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
:loading="saving"
@click="saveApplication"
>
保存应用
</ElButton>
</div>
</ElForm>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
defineOptions({ name: 'WecomApplicationDetail' })
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const applicationId = computed(() => Number(route.params.id) || 0)
const isEdit = computed(() => applicationId.value > 0)
const form = reactive({
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
})
const rules = reactive<FormRules>({
corp_id: [{ required: true, message: '请输入企业 ID', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入 AgentID', trigger: 'change' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
})
const fillForm = (application?: WecomApplication) => {
Object.assign(
form,
application
? {
corp_id: application.corp_id,
agent_id: application.agent_id,
name: application.name,
secret: application.secret || '',
callback_token: application.callback_token || '',
encoding_aes_key: application.encoding_aes_key || '',
enabled: application.status === 1
}
: {
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
}
)
}
const loadApplication = async () => {
if (!isEdit.value) {
fillForm()
return
}
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code === 0) {
const application = response.data.items?.find((item) => item.id === applicationId.value)
if (application) fillForm(application)
else {
ElMessage.error('未找到对应的企微应用')
goBack()
}
}
}
const saveApplication = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const response = await WecomService.saveApplication({
corp_id: form.corp_id,
agent_id: form.agent_id,
name: form.name,
secret: form.secret,
callback_token: form.callback_token,
encoding_aes_key: form.encoding_aes_key,
status: form.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('企微应用保存成功')
await router.push(RoutesAlias.WecomApplications)
}
} finally {
saving.value = false
}
}
const testApplication = async () => {
if (!applicationId.value) return
testing.value = true
try {
const response = await WecomService.testApplication(applicationId.value)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
}
} finally {
testing.value = false
}
}
const goBack = () => void router.push(RoutesAlias.WecomApplications)
onMounted(() => void loadApplication())
</script>
<style scoped lang="scss">
.wecom-application-detail-page {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.application-form {
max-width: 1000px;
}
.full-width {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="wecom-applications-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微应用管理</div>
<div class="page-description">管理企业微信应用凭据并查看连接和默认发起人状态</div>
</div>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
@click="goToCreate"
>
新增应用
</ElButton>
</div>
</template>
<ElTable v-loading="loading" :data="applications" border>
<ElTableColumn prop="name" label="应用名称" min-width="160" show-overflow-tooltip />
<ElTableColumn label="凭据状态" width="110">
<template #default="scope">
<ElTag :type="scope.row.credentials_set ? 'success' : 'warning'" size="small">
{{ scope.row.credentials_set ? '已完整' : '未完整' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="启用状态" width="100">
<template #default="scope">
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="默认发起人" min-width="150">
<template #default="scope">{{ scope.row.default_creator_name || '未设置' }}</template>
</ElTableColumn>
<ElTableColumn label="最近连接" min-width="170">
<template #default="scope">{{ formatDate(scope.row.last_connected_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="300" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
type="primary"
@click="testApplication(scope.row.id)"
>
测试连接
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
link
type="primary"
@click="goToMembers(scope.row.id)"
>
成员管理
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
@click="goToDetail(scope.row.id)"
>
编辑配置
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadApplications"
/>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomApplications' })
const router = useRouter()
const loading = ref(false)
const applications = ref<WecomApplication[]>([])
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const loadApplications = async () => {
loading.value = true
try {
const response = await WecomService.getApplications({
page: pagination.page,
page_size: pagination.page_size
})
if (response.code === 0) {
applications.value = response.data.items || []
pagination.total = response.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadApplications()
}
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
const goToMembers = (id: number) =>
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
const testApplication = async (id: number) => {
const response = await WecomService.testApplication(id)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
await loadApplications()
}
}
onMounted(() => void loadApplications())
</script>
<style scoped lang="scss">
.wecom-applications-page {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,424 @@
<template>
<div class="wecom-members-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微成员管理</div>
<div class="page-description"
>同步应用可见成员设置默认审批发起人并完成平台账号绑定</div
>
</div>
<ElButton @click="goToApplications">返回应用列表</ElButton>
</div>
</template>
<ElForm inline class="search-form" @submit.prevent>
<ElFormItem label="企微应用">
<ElSelect
v-model="selectedApplicationId"
filterable
class="application-select"
placeholder="请选择企微应用"
@change="handleApplicationChange"
>
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="成员搜索">
<ElInput
v-model="keyword"
clearable
placeholder="姓名"
class="keyword-input"
@keyup.enter="handleSearch"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
</ElFormItem>
</ElForm>
<div class="toolbar">
<div class="selected-application">
当前应用<span>{{ selectedApplication?.name || '未选择' }}</span>
<ElTag v-if="selectedApplication?.default_creator_name" type="success" size="small">
默认发起人{{ selectedApplication.default_creator_name }}
</ElTag>
</div>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
:disabled="!selectedApplicationId"
:loading="syncing"
@click="syncMembers"
>
同步成员
</ElButton>
</div>
<ElTable
v-loading="loading"
:data="members"
row-key="userid"
highlight-current-row
border
@row-click="selectMember"
>
<ElTableColumn label="选择" width="70" align="center">
<template #default="scope">
<ElRadio v-model="selectedUserid" :label="scope.row.userid">
<span class="sr-only">选择 {{ scope.row.name }}</span>
</ElRadio>
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="成员姓名" min-width="150" />
<ElTableColumn prop="synced_at" label="同步时间" min-width="180" />
<ElTableColumn label="操作" width="230" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
link
type="primary"
@click.stop="setDefaultCreator(scope.row)"
>
设为默认发起人
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.binding"
link
@click.stop="selectMember(scope.row)"
>
绑定账号
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadMembers"
/>
</div>
</ElCard>
<ElCard shadow="never" class="operation-card">
<template #header>当前成员操作</template>
<ElAlert
v-if="selectedMember"
:title="`已选择:${selectedMember.name}`"
type="info"
:closable="false"
/>
<ElEmpty v-else description="请先在上方列表选择成员" :image-size="70" />
<div class="operation-row">
<span class="operation-label">绑定平台账号</span>
<ElInputNumber
v-model="bindingAccountId"
:min="1"
:disabled="!selectedMember"
controls-position="right"
placeholder="账号 ID"
/>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.binding"
type="primary"
:disabled="!selectedMember || !bindingAccountId"
:loading="binding"
@click="bindAccount"
>
绑定账号
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
:disabled="!selectedMember"
:loading="savingDefault"
@click="saveSelectedDefaultCreator"
>
保存为默认发起人
</ElButton>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AccountService, WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication, WecomMember } from '@/types/api'
defineOptions({ name: 'WecomMembers' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const syncing = ref(false)
const savingDefault = ref(false)
const binding = ref(false)
const applications = ref<WecomApplication[]>([])
const members = ref<WecomMember[]>([])
const selectedApplicationId = ref<number>()
const selectedUserid = ref('')
const bindingAccountId = ref<number>()
const keyword = ref('')
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const selectedApplication = computed(() =>
applications.value.find((application) => application.id === selectedApplicationId.value)
)
const selectedMember = computed(() =>
members.value.find((member) => member.userid === selectedUserid.value)
)
const loadApplications = async () => {
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code !== 0) return
applications.value = response.data.items || []
const queryApplicationId = Number(route.query.application_id)
selectedApplicationId.value =
applications.value.find((application) => application.id === queryApplicationId)?.id ||
applications.value[0]?.id
}
const loadMembers = async () => {
if (!selectedApplicationId.value) {
members.value = []
pagination.total = 0
return
}
loading.value = true
try {
const response = await WecomService.getMembers(selectedApplicationId.value, {
page: pagination.page,
page_size: pagination.page_size,
keyword: keyword.value.trim() || undefined
})
if (response.code === 0) {
members.value = response.data.items || []
pagination.total = response.data.total || 0
if (!members.value.some((member) => member.userid === selectedUserid.value)) {
selectedUserid.value = ''
}
}
} finally {
loading.value = false
}
}
const handleApplicationChange = () => {
pagination.page = 1
selectedUserid.value = ''
void loadMembers()
}
const handleSearch = () => {
pagination.page = 1
void loadMembers()
}
const handleReset = () => {
keyword.value = ''
pagination.page = 1
void loadMembers()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadMembers()
}
const selectMember = (member: WecomMember) => {
selectedUserid.value = member.userid
}
const syncMembers = async () => {
if (!selectedApplicationId.value) return
syncing.value = true
try {
const response = await WecomService.syncMembers(selectedApplicationId.value)
if (response.code === 0) {
ElMessage.success(`已同步 ${response.data.synced_count} 名成员`)
await loadMembers()
}
} finally {
syncing.value = false
}
}
const setDefaultCreator = async (member: WecomMember) => {
if (!selectedApplicationId.value) return
selectedUserid.value = member.userid
await saveSelectedDefaultCreator()
}
const saveSelectedDefaultCreator = async () => {
if (!selectedApplicationId.value || !selectedMember.value) {
ElMessage.warning('请选择要设置的成员')
return
}
savingDefault.value = true
try {
const response = await WecomService.setDefaultCreator(
selectedApplicationId.value,
selectedMember.value.userid
)
if (response.code === 0) {
ElMessage.success('默认发起人已保存')
const application = selectedApplication.value
if (application) {
application.default_creator_userid = selectedMember.value.userid
application.default_creator_name = selectedMember.value.name
}
}
} finally {
savingDefault.value = false
}
}
const bindAccount = async () => {
if (!selectedApplicationId.value || !selectedMember.value || !bindingAccountId.value) {
ElMessage.warning('请选择成员并输入账号 ID')
return
}
binding.value = true
try {
const response = await AccountService.bindWecom(bindingAccountId.value, {
application_id: selectedApplicationId.value,
userid: selectedMember.value.userid
})
if (response.code === 0) ElMessage.success('账号企微绑定成功')
} finally {
binding.value = false
}
}
const goToApplications = () => void router.push(RoutesAlias.WecomApplications)
onMounted(async () => {
await loadApplications()
await loadMembers()
})
</script>
<style scoped lang="scss">
.wecom-members-page {
.page-header,
.toolbar,
.operation-row {
display: flex;
align-items: center;
}
.page-header,
.toolbar {
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.search-form {
margin-top: 4px;
padding: 14px 16px 0;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.application-select {
width: 280px;
}
.keyword-input {
width: 220px;
}
.toolbar {
margin: 20px 0 12px;
.selected-application {
color: var(--el-text-color-secondary);
span {
color: var(--el-text-color-primary);
font-weight: 600;
}
.el-tag {
margin-left: 10px;
}
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.operation-card {
margin-top: 16px;
}
.operation-row {
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
}
.operation-label {
color: var(--el-text-color-primary);
font-weight: 500;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 768px) {
.page-header,
.toolbar {
align-items: flex-start;
flex-direction: column;
}
.application-select,
.keyword-input {
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,375 @@
<template>
<div class="wecom-scenes-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微审批场景</div>
<div class="page-description"
>分别维护退款和线下代充值审批模板并提交后由后端校验控件映射</div
>
</div>
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
新增场景配置
</ElButton>
</div>
</template>
<div class="scene-layout">
<div class="scene-list">
<div class="section-heading">场景配置列表</div>
<ElTable
v-loading="loading"
:data="scenes"
row-key="business_type"
highlight-current-row
border
@row-click="selectScene"
>
<ElTableColumn prop="business_type_name" label="业务类型" min-width="150" />
<ElTableColumn
prop="template_name"
label="模板名称"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="状态" width="90">
<template #default="scope">
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="last_verified_at" label="最近校验" min-width="170" />
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
link
type="primary"
@click.stop="selectScene(scope.row)"
>
编辑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
<ElCard shadow="never" class="editor-card">
<template #header>
<div class="editor-header">
<span>{{ editing ? '编辑审批场景' : '新增审批场景' }}</span>
<ElTag v-if="editing" size="small" type="info">{{ sceneForm.business_type }}</ElTag>
</div>
</template>
<ElForm :model="sceneForm" label-width="110px">
<ElFormItem label="业务类型" required>
<ElSelect v-model="sceneForm.business_type" :disabled="editing" class="full-width">
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
</ElSelect>
</ElFormItem>
<ElFormItem label="企微应用">
<ElSelect v-model="sceneForm.application_id" clearable filterable class="full-width">
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="模板 ID" required>
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID" />
</ElFormItem>
<ElFormItem label="启用状态">
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
<ElDivider content-position="left">控件映射</ElDivider>
<div class="mapping-heading">
<span>业务字段与模板控件的对应关系</span>
<ElButton text type="primary" @click="addMapping()">新增映射</ElButton>
</div>
<div v-if="mappingRows.length" class="mapping-list">
<div v-for="(mapping, index) in mappingRows" :key="mapping.key" class="mapping-row">
<ElInput v-model="mapping.business_field" placeholder="业务字段,如 refund_no" />
<ElInput v-model="mapping.control_id" placeholder="控件 ID" />
<ElInput v-model="mapping.control_type" placeholder="控件类型,如 Text" />
<ElInput
v-model="mapping.option_mapping"
placeholder="选择项映射 JSON可为空对象"
/>
<ElButton text type="danger" @click="removeMapping(index)">删除</ElButton>
</div>
</div>
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
<div class="editor-actions">
<ElButton @click="createScene">清空表单</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
:loading="saving"
@click="saveScene"
>
保存并校验
</ElButton>
</div>
</ElForm>
</ElCard>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type {
WecomApplication,
WecomBusinessType,
WecomScene,
WecomSceneControlMapping
} from '@/types/api'
defineOptions({ name: 'WecomScenes' })
interface MappingEditor {
key: number
business_field: string
control_id: string
control_type: string
option_mapping: string
}
const loading = ref(false)
const saving = ref(false)
const applications = ref<WecomApplication[]>([])
const scenes = ref<WecomScene[]>([])
const selectedBusinessType = ref<WecomBusinessType>()
const nextMappingKey = ref(1)
const sceneForm = reactive({
business_type: 'refund_approval' as WecomBusinessType,
application_id: undefined as number | undefined,
template_id: '',
enabled: true
})
const mappingRows = ref<MappingEditor[]>([])
const editing = computed(() => Boolean(selectedBusinessType.value))
const addMapping = (mapping?: Partial<MappingEditor>) => {
mappingRows.value.push({
key: nextMappingKey.value++,
business_field: mapping?.business_field || '',
control_id: mapping?.control_id || '',
control_type: mapping?.control_type || '',
option_mapping: mapping?.option_mapping || '{}'
})
}
const removeMapping = (index: number) => mappingRows.value.splice(index, 1)
const fillEditor = (scene?: WecomScene) => {
if (!scene) {
selectedBusinessType.value = undefined
Object.assign(sceneForm, {
business_type: 'refund_approval' as WecomBusinessType,
application_id: applications.value[0]?.id,
template_id: '',
enabled: true
})
mappingRows.value = []
addMapping()
return
}
selectedBusinessType.value = scene.business_type
Object.assign(sceneForm, {
business_type: scene.business_type,
application_id: scene.application_id,
template_id: scene.template_id,
enabled: scene.status === 1
})
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
key: nextMappingKey.value++,
business_field: mapping.business_field,
control_id: mapping.control_id,
control_type: mapping.control_type,
option_mapping: JSON.stringify(mapping.option_mapping || {})
}))
}
const selectScene = (scene: WecomScene) => fillEditor(scene)
const createScene = () => fillEditor()
const loadData = async () => {
loading.value = true
try {
const [applicationResponse, sceneResponse] = await Promise.all([
WecomService.getApplications({ page: 1, page_size: 100 }),
WecomService.getScenes({ page: 1, page_size: 100 })
])
if (applicationResponse.code === 0) applications.value = applicationResponse.data.items || []
if (sceneResponse.code === 0) {
scenes.value = sceneResponse.data.items || []
const current = scenes.value.find(
(scene) => scene.business_type === selectedBusinessType.value
)
fillEditor(current || scenes.value[0])
}
} finally {
loading.value = false
}
}
const parseMapping = (): WecomSceneControlMapping[] | undefined => {
const result: WecomSceneControlMapping[] = []
for (const mapping of mappingRows.value) {
if (!mapping.business_field && !mapping.control_id && !mapping.control_type) continue
if (!mapping.business_field || !mapping.control_id || !mapping.control_type) {
ElMessage.warning('请完整填写控件映射字段')
return
}
let optionMapping: Record<string, string>
try {
const parsed = JSON.parse(mapping.option_mapping || '{}')
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
throw new Error('invalid')
optionMapping = parsed as Record<string, string>
} catch {
ElMessage.warning(`${result.length + 1} 条选择项映射不是有效 JSON`)
return
}
result.push({
business_field: mapping.business_field,
control_id: mapping.control_id,
control_type: mapping.control_type,
option_mapping: optionMapping
})
}
return result
}
const saveScene = async () => {
if (!sceneForm.template_id.trim()) {
ElMessage.warning('请输入模板 ID')
return
}
const controlMapping = parseMapping()
if (!controlMapping) return
saving.value = true
try {
const response = await WecomService.saveScene(sceneForm.business_type, {
application_id: sceneForm.application_id,
template_id: sceneForm.template_id.trim(),
control_mapping: controlMapping,
status: sceneForm.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('场景保存并校验成功')
await loadData()
}
} finally {
saving.value = false
}
}
onMounted(() => void loadData())
</script>
<style scoped lang="scss">
.wecom-scenes-page {
.page-header,
.editor-header,
.mapping-heading,
.editor-actions {
display: flex;
align-items: center;
}
.page-header,
.editor-header,
.mapping-heading {
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.scene-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
gap: 16px;
}
.section-heading {
margin-bottom: 12px;
color: var(--el-text-color-primary);
font-weight: 600;
}
.editor-card {
min-width: 0;
}
.full-width {
width: 100%;
}
.mapping-heading {
margin-bottom: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mapping-row {
display: grid;
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
gap: 8px;
align-items: center;
}
.editor-actions {
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 1200px) {
.scene-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-header {
align-items: flex-start;
flex-direction: column;
}
.mapping-row {
grid-template-columns: 1fr;
}
}
}
</style>