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

This commit is contained in:
luo
2026-07-28 14:00:22 +08:00
parent f0a2b84e53
commit 2706c52a47
63 changed files with 4113 additions and 3945 deletions

View File

@@ -44,16 +44,13 @@
:close-on-click-modal="false"
>
<ElForm label-width="120px">
<ElFormItem label="配置 Key">
<ElInput :model-value="currentConfig?.config_key || '-'" disabled />
</ElFormItem>
<ElFormItem label="配置说明">
<span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem>
<ElFormItem label="配置值">
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
{{ method }}
{{ paymentMethodLabels[method] }}
</ElCheckbox>
</ElCheckboxGroup>
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
@@ -89,7 +86,6 @@
placeholder="请输入配置值"
/>
</ElFormItem>
<div v-if="valueHint" class="config-hint">{{ valueHint }}</div>
</ElForm>
<template #footer>
@@ -103,6 +99,7 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
@@ -120,6 +117,7 @@
defineOptions({ name: 'SystemConfigs' })
const { hasAuth } = useAuth()
const route = useRoute()
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
@@ -149,14 +147,18 @@
const editNumber = ref<number | null>(null)
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
const paymentMethodLabels: Record<AllowedPaymentMethod, string> = {
wallet: '钱包',
wechat: '微信',
alipay: '支付宝'
}
const targetConfigHandled = ref(false)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const columnOptions = [
{ label: '配置 Key', prop: 'config_key' },
{ label: '模块', prop: 'module' },
{ label: '配置说明', prop: 'description' },
{ label: '配置值', prop: 'value' },
{ label: '控件', prop: 'control' },
{ label: '注册状态', prop: 'registered' },
{ label: '只读', prop: 'readonly' },
{ label: '敏感', prop: 'sensitive' },
@@ -167,7 +169,6 @@
moduleOptions.find((item) => item.value === module)?.label || module
const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'config_key', label: '配置 Key', minWidth: 220, showOverflowTooltip: true },
{
prop: 'module',
label: '模块',
@@ -178,10 +179,10 @@
{
prop: 'value',
label: '配置值',
minWidth: 150,
formatter: (row: SystemConfigItem) => row.value || '-'
minWidth: 260,
showOverflowTooltip: true,
formatter: (row: SystemConfigItem) => formatConfigValue(row)
},
{ prop: 'control', label: '控件', width: 100 },
{
prop: 'registered',
label: '注册状态',
@@ -216,7 +217,8 @@
)
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]
currentConfig.value
?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
)
const isIntegerConfig = computed(
@@ -233,14 +235,18 @@
!isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json'
)
const valueHint = computed(() => {
if (!currentConfig.value) return ''
const { min, max } = currentConfig.value
if (min !== null && max !== null) return `取值范围:${min}${max}`
if (min !== null) return `最小值:${min}`
if (max !== null) return `最大值:${max}`
return ''
})
const isPaymentConfigKey = (configKey: string) =>
Object.values(PAYMENT_CONFIG_KEYS).includes(
configKey as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
const formatConfigValue = (config: SystemConfigItem) => {
if (isPaymentConfigKey(config.config_key)) {
const methods = parsePaymentMethods(config.value)
return methods.length ? methods.map((method) => paymentMethodLabels[method]).join('、') : '-'
}
return config.value || '-'
}
const getSubmittedValue = () => {
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
@@ -293,6 +299,12 @@
if (res.code === 0) {
configList.value = res.data.list || []
pagination.total = res.data.total || 0
const targetKey = String(route.query.config_key || '')
const targetConfig = configList.value.find((config) => config.config_key === targetKey)
if (targetKey && targetConfig && !targetConfigHandled.value) {
targetConfigHandled.value = true
showEditDialog(targetConfig)
}
}
} catch (error) {
console.error('获取系统配置失败:', error)
@@ -370,10 +382,5 @@
<style scoped lang="scss">
.system-configs-page {
.config-hint {
margin: -12px 0 0 120px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<ElDialog
v-model="visible"
:title="isEdit ? '编辑企微应用' : '新增企微应用'"
width="50%"
destroy-on-close
@closed="resetForm"
>
<div class="dialog-description">应用凭据由平台管理员维护保存后可在应用列表测试连接</div>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="130px" 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 :span="24">
<ElFormItem label="回调 Token" prop="callback_token">
<ElInput
v-model="form.callback_token"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入回调 Token"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
<ElInput
v-model="form.encoding_aes_key"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入 43 位 EncodingAESKey"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="启用状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="visible = false">取消</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>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type { WecomApplication } from '@/types/api'
defineOptions({ name: 'WecomApplicationFormDialog' })
const props = defineProps<{
modelValue: boolean
application: WecomApplication | null
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => Boolean(props.application?.id))
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
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 | null) => {
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 resetForm = () => {
fillForm(null)
formRef.value?.clearValidate()
}
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('企微应用保存成功')
emit('success')
visible.value = false
}
} finally {
saving.value = false
}
}
const testApplication = async () => {
if (!props.application?.id) return
testing.value = true
try {
const response = await WecomService.testApplication(props.application.id)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
}
} finally {
testing.value = false
}
}
watch(
() => [props.modelValue, props.application] as const,
([opened]) => {
if (opened) fillForm(props.application)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.dialog-description {
margin-bottom: 20px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.application-form {
padding-right: 8px;
}
.full-width {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,253 +0,0 @@
<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

@@ -1,107 +1,128 @@
<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">
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-applications-page">
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="loadApplications"
>
<template #left>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
type="primary"
@click="testApplication(scope.row.id)"
@click="openCreateDialog"
>
测试连接
</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>
</ArtTableHeader>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="applications"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
:margin-top="10"
:actions="getActions"
:actions-width="160"
:inline-actions-count="1"
@size-change="handleSizeChange"
@current-change="loadApplications"
/>
</div>
</ElCard>
</div>
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<WecomApplicationFormDialog
v-model="applicationDialogVisible"
:application="editingApplication"
@success="handleApplicationSaved"
/>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { useRouter } from 'vue-router'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
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'
import WecomApplicationFormDialog from './components/WecomApplicationFormDialog.vue'
defineOptions({ name: 'WecomApplications' })
const router = useRouter()
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false)
const applications = ref<WecomApplication[]>([])
const applicationDialogVisible = ref(false)
const editingApplication = ref<WecomApplication | null>(null)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const columnOptions = [
{ label: '应用名称', prop: 'name' },
{ label: '凭据状态', prop: 'credentials_set' },
{ label: '启用状态', prop: 'status' },
{ label: '默认发起人', prop: 'default_creator_name' },
{ label: '最近连接', prop: 'last_connected_at' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'name',
label: '应用名称',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'credentials_set',
label: '凭据状态',
width: 110,
formatter: (row: WecomApplication) =>
h(ElTag, { type: row.credentials_set ? 'success' : 'warning', size: 'small' }, () =>
row.credentials_set ? '已完整' : '未完整'
)
},
{
prop: 'status',
label: '启用状态',
width: 100,
formatter: (row: WecomApplication) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info', size: 'small' },
() => row.status_name
)
},
{
prop: 'default_creator_name',
label: '默认发起人',
minWidth: 150,
formatter: (row: WecomApplication) => row.default_creator_name || '未设置'
},
{
prop: 'last_connected_at',
label: '最近连接',
width: 180,
formatter: (row: WecomApplication) => formatDate(row.last_connected_at)
}
])
const loadApplications = async () => {
loading.value = true
try {
@@ -124,8 +145,20 @@
void loadApplications()
}
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
const openCreateDialog = () => {
editingApplication.value = null
applicationDialogVisible.value = true
}
const openEditDialog = (application: WecomApplication) => {
editingApplication.value = application
applicationDialogVisible.value = true
}
const handleApplicationSaved = () => {
void loadApplications()
}
const goToMembers = (id: number) =>
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
@@ -138,34 +171,31 @@
}
}
const getActions = (row: WecomApplication) => {
const actions: any[] = []
if (hasAuth(JULY_PERMISSIONS.wecom.application)) {
actions.push({
label: '测试连接',
handler: () => testApplication(row.id),
type: 'primary'
})
actions.push({
label: '编辑配置',
handler: () => openEditDialog(row),
type: 'primary'
})
}
if (hasAuth(JULY_PERMISSIONS.wecom.member)) {
actions.push({
label: '成员管理',
handler: () => goToMembers(row.id),
type: 'primary'
})
}
return actions
}
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>
<style scoped lang="scss"></style>

View File

@@ -1,167 +1,94 @@
<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>
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-members-page">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<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">
<ElCard v-loading="loading" shadow="never" class="art-table-card members-card">
<div class="members-toolbar">
<div class="toolbar-actions">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
link
:disabled="!selectedApplicationId"
:loading="syncing"
type="primary"
@click.stop="setDefaultCreator(scope.row)"
@click="syncMembers"
>
设为默认发起人
同步成员
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.binding"
link
@click.stop="selectMember(scope.row)"
>
绑定账号
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElButton @click="goToApplications">返回应用列表</ElButton>
</div>
</div>
<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>
<ElEmpty v-if="!members.length && !loading" description="暂无企微成员" />
<div v-else class="member-grid">
<ElCard v-for="member in members" :key="member.userid" shadow="hover" class="member-card">
<div class="member-card__header">
<div class="member-card__identity">
<ElAvatar :size="36" class="member-card__avatar">
{{ getMemberInitial(member.name) }}
</ElAvatar>
<div class="member-card__name" :title="member.name">
{{ member.name || '-' }}
</div>
</div>
<ElTag v-if="isDefaultCreator(member)" type="success" size="small">
默认发起人
</ElTag>
<ElTag
v-else
v-permission="JULY_PERMISSIONS.wecom.member"
class="member-card__action-tag"
type="primary"
effect="plain"
size="small"
role="button"
tabindex="0"
@click.stop="setDefaultCreator(member)"
@keydown.enter.stop="setDefaultCreator(member)"
>
设为默认发起人
</ElTag>
</div>
<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="member-card__info">
<span class="member-card__label">同步时间</span>
<span>{{ formatDateTime(member.synced_at) }}</span>
</div>
</ElCard>
</div>
<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>
<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>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AccountService, WecomService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication, WecomMember } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomMembers' })
@@ -170,30 +97,65 @@
const loading = ref(false)
const syncing = ref(false)
const savingDefault = ref(false)
const binding = ref(false)
const pendingDefaultUserid = ref('')
const applications = ref<WecomApplication[]>([])
const members = ref<WecomMember[]>([])
const selectedApplicationId = ref<number>()
const selectedUserid = ref('')
const bindingAccountId = ref<number>()
const keyword = ref('')
const searchForm = reactive({
application_id: undefined as number | undefined,
keyword: ''
})
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const selectedApplicationId = computed(() => searchForm.application_id)
const selectedApplication = computed(() =>
applications.value.find((application) => application.id === selectedApplicationId.value)
)
const selectedMember = computed(() =>
members.value.find((member) => member.userid === selectedUserid.value)
)
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '企微应用',
prop: 'application_id',
type: 'select',
options: applications.value.map((application) => ({
label: application.name,
value: application.id
})),
onChange: () => {
pagination.page = 1
void loadMembers()
},
config: {
filterable: true,
clearable: false,
placeholder: '请选择企微应用'
}
},
{
label: '成员搜索',
prop: 'keyword',
type: 'input',
config: {
clearable: true,
placeholder: '请输入成员姓名'
}
}
])
const isDefaultCreator = (member: WecomMember) =>
member.userid === selectedApplication.value?.default_creator_userid
const getMemberInitial = (name: string) => name.trim().slice(0, 1) || '-'
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 preferredApplication =
applications.value.find((application) => application.id === queryApplicationId) ||
applications.value.find((application) => application.id === searchForm.application_id) ||
applications.value[0]
searchForm.application_id = preferredApplication?.id
}
const loadMembers = async () => {
@@ -207,33 +169,25 @@
const response = await WecomService.getMembers(selectedApplicationId.value, {
page: pagination.page,
page_size: pagination.page_size,
keyword: keyword.value.trim() || undefined
keyword: searchForm.keyword.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 = ''
searchForm.keyword = ''
searchForm.application_id = applications.value[0]?.id
pagination.page = 1
void loadMembers()
}
@@ -244,10 +198,6 @@
void loadMembers()
}
const selectMember = (member: WecomMember) => {
selectedUserid.value = member.userid
}
const syncMembers = async () => {
if (!selectedApplicationId.value) return
syncing.value = true
@@ -263,49 +213,40 @@
}
const setDefaultCreator = async (member: WecomMember) => {
if (!selectedApplicationId.value) return
selectedUserid.value = member.userid
await saveSelectedDefaultCreator()
}
if (!selectedApplicationId.value || savingDefault.value) return
const saveSelectedDefaultCreator = async () => {
if (!selectedApplicationId.value || !selectedMember.value) {
ElMessage.warning('请选择要设置的成员')
try {
await ElMessageBox.confirm(
`确定将 ${member.name} 设置为当前应用的默认发起人吗?`,
'确认设置默认发起人',
{
confirmButtonText: '确定设置',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
savingDefault.value = true
pendingDefaultUserid.value = member.userid
try {
const response = await WecomService.setDefaultCreator(
selectedApplicationId.value,
selectedMember.value.userid
member.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
application.default_creator_userid = member.userid
application.default_creator_name = member.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
pendingDefaultUserid.value = ''
}
}
@@ -319,105 +260,152 @@
<style scoped lang="scss">
.wecom-members-page {
.page-header,
.toolbar,
.operation-row {
.members-card {
min-height: 0;
overflow: hidden;
:deep(> .el-card__body) {
box-sizing: border-box;
min-height: 0;
overflow-y: auto;
padding-bottom: 24px;
}
}
.members-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.member-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding-bottom: 4px;
}
.member-card {
min-width: 0;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
:deep(.el-card__body) {
display: flex;
height: auto;
min-height: 108px;
flex-direction: column;
justify-content: space-between;
gap: 10px;
overflow: visible;
}
}
.member-card__header,
.member-card__info {
display: flex;
align-items: center;
}
.page-header,
.toolbar {
.member-card__header {
justify-content: space-between;
gap: 16px;
gap: 12px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
.member-card__identity {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 10px;
}
.member-card__avatar {
flex-shrink: 0;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
.member-card__name {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-card__info {
justify-content: space-between;
gap: 16px;
color: var(--el-text-color-regular);
font-size: 13px;
span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.search-form {
margin-top: 4px;
padding: 14px 16px 0;
background: var(--el-fill-color-light);
border-radius: 4px;
.member-card__label {
color: var(--el-text-color-secondary);
}
.application-select {
width: 280px;
}
.member-card__action-tag {
cursor: pointer;
transition: opacity 0.2s ease;
.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;
}
&:hover {
opacity: 0.8;
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
margin-top: 20px;
}
.operation-card {
margin-top: 16px;
@media (max-width: 1400px) {
.member-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.operation-row {
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
@media (max-width: 1024px) {
.member-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.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;
@media (max-width: 640px) {
.member-grid {
grid-template-columns: 1fr;
}
.application-select,
.keyword-input {
width: 100%;
.members-toolbar {
justify-content: flex-start;
}
.pagination-wrapper {
justify-content: center;
overflow-x: auto;
}
}
}

View File

@@ -1,158 +1,240 @@
<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
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-scenes-page">
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="loadData"
>
<template #left>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
@click="createScene"
>
新增场景配置
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="business_type"
:loading="loading"
:data="scenes"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="140"
@size-change="handleSizeChange"
@current-change="handleCurrentPageChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog
v-model="editorVisible"
:title="editing ? '编辑审批场景' : '新增审批场景'"
width="55%"
class="wecom-scene-dialog"
destroy-on-close
>
<ElForm :model="sceneForm" label-width="80px" class="scene-editor-form">
<div class="dialog-section">
<div class="dialog-section__header">
<div class="dialog-section__title">基础配置</div>
<div class="dialog-section__description">选择审批业务和对应的企业微信模板</div>
</div>
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
新增场景配置
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="业务类型" required>
<ElSelect
v-model="sceneForm.business_type"
:disabled="editing"
class="full-width"
@change="handleBusinessTypeChange"
>
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<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>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="模板 ID" required>
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID">
<template #append>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
:loading="inspectingTemplate"
@click="syncTemplate()"
>
同步模板
</ElButton>
</template>
</ElInput>
<div v-if="templateName" class="template-name">当前模板{{ templateName }}</div>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="启用状态">
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<div class="dialog-section mapping-section">
<div class="dialog-section__header mapping-heading">
<div>
<div class="dialog-section__title">控件映射</div>
<div class="dialog-section__description">将业务字段绑定到企业微信模板控件</div>
</div>
<ElTag v-if="!templateControls.length" type="info" effect="plain" class="mapping-tip">
请先填写模板 ID 并点击同步模板再选择模板控件
</ElTag>
</div>
<div v-if="mappingRows.length" class="mapping-list">
<div v-for="mapping in mappingRows" :key="mapping.key" class="mapping-row">
<div class="mapping-row__grid">
<div class="mapping-field">
<span class="mapping-field__label">业务字段</span>
<ElSelect
v-model="mapping.business_field"
filterable
clearable
:loading="fieldsLoading"
placeholder="选择业务字段"
>
<ElOption
v-for="field in businessFields"
:key="field.code"
:label="field.name"
:value="field.code"
/>
</ElSelect>
</div>
<div class="mapping-field">
<span class="mapping-field__label">模板控件</span>
<ElInput
:model-value="mapping.control_title"
readonly
placeholder="模板控件名称"
/>
</div>
<div class="mapping-field">
<span class="mapping-field__label">控件 ID</span>
<ElInput
:model-value="mapping.control_id"
readonly
placeholder="选择模板控件后自动填充"
/>
</div>
<div class="mapping-field">
<span class="mapping-field__label">控件类型</span>
<ElInput
:model-value="mapping.control_type"
readonly
placeholder="同步模板后自动填充"
/>
</div>
<div class="mapping-field mapping-field--option">
<span class="mapping-field__label">选择项映射</span>
<ElInput v-model="mapping.option_mapping" placeholder="JSON可为空对象" />
</div>
</div>
</div>
</div>
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="editorVisible = false">取消</ElButton>
<ElButton @click="createScene">清空表单</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
:loading="saving"
@click="saveScene"
>
保存并校验
</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>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type {
WecomApplication,
WecomBusinessField,
WecomBusinessType,
WecomScene,
WecomSceneControlMapping
WecomSceneControlMapping,
WecomTemplateControl
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomScenes' })
interface MappingEditor {
key: number
business_field: string
control_title: string
control_id: string
control_type: string
required: boolean
option_mapping: string
}
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false)
const saving = ref(false)
const fieldsLoading = ref(false)
const inspectingTemplate = ref(false)
const editorVisible = ref(false)
const applications = ref<WecomApplication[]>([])
const businessFields = ref<WecomBusinessField[]>([])
const templateControls = ref<WecomTemplateControl[]>([])
const templateName = ref('')
const scenes = ref<WecomScene[]>([])
const selectedBusinessType = ref<WecomBusinessType>()
const nextMappingKey = ref(1)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const sceneForm = reactive({
business_type: 'refund_approval' as WecomBusinessType,
application_id: undefined as number | undefined,
@@ -162,19 +244,69 @@
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 columnOptions = [
{ label: '业务类型', prop: 'business_type_name' },
{ label: '模板名称', prop: 'template_name' },
{ label: '状态', prop: 'status' },
{ label: '最近校验', prop: 'last_verified_at' }
]
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const loadBusinessFields = async (businessType: WecomBusinessType) => {
fieldsLoading.value = true
try {
const response = await WecomService.getBusinessFields(businessType)
if (response.code === 0) {
businessFields.value = response.data.items || []
}
} finally {
fieldsLoading.value = false
}
}
const removeMapping = (index: number) => mappingRows.value.splice(index, 1)
const handleBusinessTypeChange = () => {
businessFields.value = []
mappingRows.value.forEach((mapping) => {
mapping.business_field = ''
})
void loadBusinessFields(sceneForm.business_type)
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'business_type_name',
label: '业务类型',
minWidth: 180
},
{
prop: 'template_name',
label: '模板名称',
minWidth: 220,
showOverflowTooltip: true
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: WecomScene) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info', size: 'small' },
() => row.status_name
)
},
{
prop: 'last_verified_at',
label: '最近校验',
width: 180,
formatter: (row: WecomScene) => formatDate(row.last_verified_at)
}
])
const fillEditor = (scene?: WecomScene) => {
templateControls.value = []
templateName.value = ''
if (!scene) {
selectedBusinessType.value = undefined
Object.assign(sceneForm, {
@@ -184,7 +316,6 @@
enabled: true
})
mappingRows.value = []
addMapping()
return
}
selectedBusinessType.value = scene.business_type
@@ -197,41 +328,124 @@
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
key: nextMappingKey.value++,
business_field: mapping.business_field,
control_title: '',
control_id: mapping.control_id,
control_type: mapping.control_type,
required: true,
option_mapping: JSON.stringify(mapping.option_mapping || {})
}))
}
const selectScene = (scene: WecomScene) => fillEditor(scene)
const createScene = () => fillEditor()
const createScene = () => {
fillEditor()
editorVisible.value = true
void loadBusinessFields(sceneForm.business_type)
}
const editScene = (scene: WecomScene) => {
fillEditor(scene)
editorVisible.value = true
void loadBusinessFields(scene.business_type)
void syncTemplate(false)
}
const syncMappingRows = (controls: WecomTemplateControl[]) => {
const existingMappings = new Map(
mappingRows.value.map((mapping) => [mapping.control_id, mapping])
)
mappingRows.value = controls.map((control) => {
const existing = existingMappings.get(control.id)
return {
key: existing?.key || nextMappingKey.value++,
business_field: existing?.business_field || '',
control_title: control.title,
control_id: control.id,
control_type: control.type,
required: control.required,
option_mapping: existing?.option_mapping || '{}'
}
})
}
const syncTemplate = async (showMessage = true) => {
if (!sceneForm.application_id) {
ElMessage.warning('请先选择企微应用')
return
}
if (!sceneForm.template_id.trim()) {
ElMessage.warning('请输入模板 ID')
return
}
inspectingTemplate.value = true
try {
const response = await WecomService.inspectTemplate(sceneForm.application_id, {
template_id: sceneForm.template_id.trim()
})
if (response.code === 0) {
templateControls.value = response.data.controls || []
templateName.value = response.data.name || ''
syncMappingRows(templateControls.value)
if (showMessage) {
ElMessage.success(`模板控件已同步,共 ${templateControls.value.length}`)
}
}
} finally {
inspectingTemplate.value = false
}
}
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 })
WecomService.getScenes({
page: pagination.page,
page_size: pagination.page_size
})
])
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])
pagination.total = sceneResponse.data.total || 0
if (selectedBusinessType.value) {
const current = scenes.value.find(
(scene) => scene.business_type === selectedBusinessType.value
)
if (current) fillEditor(current)
}
}
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadData()
}
const handleCurrentPageChange = (page: number) => {
pagination.page = page
void loadData()
}
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('请完整填写控件映射字段')
if (!mapping.business_field) {
if (mapping.required) {
ElMessage.warning(
`请为必填控件“${mapping.control_title || mapping.control_id}”选择业务字段`
)
return
}
continue
}
if (!mapping.control_id || !mapping.control_type) {
ElMessage.warning(`控件“${mapping.control_title || mapping.control_id}”信息不完整`)
return
}
let optionMapping: Record<string, string>
@@ -271,6 +485,7 @@
})
if (response.code === 0) {
ElMessage.success('场景保存并校验成功')
editorVisible.value = false
await loadData()
}
} finally {
@@ -278,98 +493,164 @@
}
}
const getActions = (row: WecomScene) => {
if (!hasAuth(JULY_PERMISSIONS.wecom.scene)) return []
return [
{
label: '编辑',
handler: () => editScene(row),
type: 'primary' as const
}
]
}
onMounted(() => void loadData())
</script>
<style scoped lang="scss">
.wecom-scenes-page {
.page-header,
.editor-header,
.mapping-heading,
.editor-actions {
display: flex;
align-items: center;
.full-width {
width: 100%;
}
.scene-editor-form {
padding-bottom: 12px;
.dialog-section {
padding: 18px 20px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.page-header,
.editor-header,
.mapping-heading {
.dialog-section + .dialog-section {
margin-top: 18px;
}
.dialog-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
.dialog-section__title {
color: var(--el-text-color-primary);
font-size: 16px;
font-size: 15px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
.dialog-section__description {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
font-size: 12px;
}
.scene-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
gap: 16px;
.mapping-tip {
flex-shrink: 0;
margin-top: 0;
white-space: nowrap;
}
.section-heading {
margin-bottom: 12px;
color: var(--el-text-color-primary);
font-weight: 600;
.template-name {
margin-top: 6px;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-card {
min-width: 0;
}
.full-width {
width: 100%;
.mapping-section {
padding-bottom: 12px;
}
.mapping-heading {
margin-bottom: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
margin-bottom: 0;
}
.mapping-list {
display: flex;
max-height: 420px;
flex-direction: column;
gap: 10px;
gap: 12px;
overflow-y: auto;
padding: 0 4px 4px;
}
.mapping-row {
display: grid;
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
gap: 8px;
align-items: center;
padding: 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
margin-top: 6px;
background: var(--el-fill-color-blank);
}
.editor-actions {
.mapping-row__grid {
display: grid;
grid-template-columns:
minmax(120px, 1.1fr) minmax(140px, 1.3fr) minmax(120px, 1fr) minmax(100px, 0.8fr)
minmax(140px, 1.2fr);
gap: 12px;
}
.mapping-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.mapping-field__label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.dialog-footer {
display: flex;
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;
.dialog-section {
padding: 14px;
}
.mapping-row {
.dialog-section__header {
align-items: stretch;
flex-direction: column;
gap: 8px;
}
.mapping-tip {
align-self: flex-start;
white-space: normal;
}
.mapping-row__grid {
grid-template-columns: 1fr;
}
.mapping-field--option {
grid-column: 1 / -1;
}
}
}
@media (max-width: 1200px) {
.mapping-row__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mapping-field--option {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
:deep(.wecom-scene-dialog) {
width: calc(100vw - 32px) !important;
}
}
</style>