Files
Ticket/web/src/features/piaotong/digital-accounts/index.vue
T

551 lines
15 KiB
Vue

<template>
<div class="page-shell">
<section class="page-card table-fill">
<div class="page-toolbar">
<h2 class="page-toolbar-title">数电账号管理</h2>
<div class="page-toolbar-actions">
<n-button :loading="loading" @click="refreshAccounts">
<template #icon><n-icon :component="RefreshCw" /></template>
刷新
</n-button>
<n-button v-if="canManage" type="primary" @click="showCreate = true">
<template #icon><n-icon :component="Plus" /></template>
新增
</n-button>
</div>
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
size="small"
:columns="columns"
:data="accounts"
:loading="loading"
:pagination="{ pageSize: 10 }"
:row-key="(row: DigitalAccountItem) => row.id"
:scroll-x="1460"
/>
</div>
</section>
<n-modal v-model:show="showCreate" preset="card" title="新增数电账号" class="modal-card">
<n-form ref="createFormRef" :model="createForm" :rules="createRules" label-placement="top">
<n-grid responsive="screen" :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="税局账号" path="account">
<n-input v-model:value="createForm.account" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="税局密码" path="taxPassword">
<n-input v-model:value="createForm.taxPassword" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="姓名" path="name">
<n-input v-model:value="createForm.name" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="手机号" path="phoneNum">
<n-input v-model:value="createForm.phoneNum" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="身份类型" path="identityType">
<n-select v-model:value="createForm.identityType" :options="identityOptions" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="平台密码" path="platformPassword">
<n-input
v-model:value="createForm.platformPassword"
type="password"
show-password-on="click"
/>
</n-form-item>
</n-gi>
</n-grid>
<div class="modal-actions">
<n-button @click="showCreate = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="createAccount">保存</n-button>
</div>
</n-form>
</n-modal>
<n-modal
v-model:show="showSms"
preset="card"
title="短信登录"
:style="{ width: '360px', maxWidth: '88vw' }"
content-style="padding: 14px 18px 18px"
>
<n-space vertical size="small" class="auth-panel">
<div class="auth-account">
<span>税局账号</span>
<strong>{{ selected?.account || '-' }}</strong>
</div>
<n-alert v-if="smsTip" type="info" :show-icon="false">{{ smsTip }}</n-alert>
<n-input-group>
<n-input v-model:value="smsCode" placeholder="请输入短信验证码" />
<n-button :loading="smsCodeLoading" @click="sendSmsCode">发送验证码</n-button>
</n-input-group>
<div class="modal-actions">
<n-button @click="showSms = false">取消</n-button>
<n-button type="primary" :loading="smsLoading" @click="submitSms">确认登录</n-button>
</div>
</n-space>
</n-modal>
<n-modal
v-model:show="showQrcode"
preset="card"
title="风险认证"
:style="{ width: '400px', maxWidth: '88vw' }"
content-style="padding: 14px 18px 18px"
>
<n-space vertical size="small" class="auth-panel">
<div class="auth-account">
<span>税局账号</span>
<strong>{{ selected?.account || '-' }}</strong>
</div>
<n-radio-group v-model:value="qrcodeType" size="small" @update:value="loadQrcode">
<n-radio-button value="1">电子税务局 APP</n-radio-button>
<n-radio-button value="2">国家网络身份认证 APP</n-radio-button>
</n-radio-group>
<div class="qrcode-wrap">
<img v-if="qrcodeImg" :src="qrcodeImg" :alt="qrcodeTypeLabel" />
<n-empty v-else description="暂无二维码" />
</div>
<div class="qrcode-meta">
<span>{{ qrcodeTypeLabel }}</span>
<small v-if="qrcodeGenTime">生成时间:{{ qrcodeGenTime }}</small>
<small v-if="qrcodeTip">{{ qrcodeTip }}</small>
</div>
<div class="modal-actions">
<n-button @click="showQrcode = false">关闭</n-button>
<n-button type="primary" :loading="qrLoading" @click="loadQrcode">刷新二维码</n-button>
</div>
</n-space>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
import {
NAlert,
NButton,
NDataTable,
NEmpty,
NForm,
NFormItem,
NGi,
NGrid,
NIcon,
NInput,
NInputGroup,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
NSelect,
NSpace,
NTag,
NTooltip,
useMessage
} from 'naive-ui'
import { KeyRound, Plus, RefreshCw, ShieldCheck } from 'lucide-vue-next'
import {
createDigitalAccountApi,
getAuthQrcodeApi,
listDigitalAccountsApi,
refreshDigitalAccountsApi,
sendLoginSmsCodeApi,
smsLoginApi,
updateDigitalAccountStatusApi
} from '@/api/piaotong'
import type { DigitalAccountItem } from '@/api/piaotong'
import { useAuthStore } from '@/stores/auth'
const message = useMessage()
const authStore = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const accounts = ref<DigitalAccountItem[]>([])
const showCreate = ref(false)
const showSms = ref(false)
const showQrcode = ref(false)
const smsLoading = ref(false)
const smsCodeLoading = ref(false)
const qrLoading = ref(false)
const smsCode = ref('')
const smsTip = ref('')
const qrcodeImg = ref('')
const qrcodeType = ref('1')
const qrcodeGenTime = ref('')
const qrcodeTip = ref('')
const selected = ref<DigitalAccountItem | null>(null)
const createFormRef = ref<FormInst | null>(null)
const canManage = computed(() => authStore.user?.userType !== 'DIGITAL_OPERATOR')
const canUpdateStatus = computed(() => authStore.hasPermission('digital-account:status'))
const qrcodeTypeLabel = computed(() =>
qrcodeType.value === '2' ? '国家网络身份认证 APP' : '电子税务局 APP'
)
const createForm = reactive({
account: '',
taxPassword: '',
identityType: '09',
phoneNum: '',
name: '',
platformPassword: ''
})
const createRules: FormRules = {
account: [{ required: true, message: '请输入税局账号', trigger: ['blur', 'input'] }],
taxPassword: [{ required: true, message: '请输入税局密码', trigger: ['blur', 'input'] }],
name: [{ required: true, message: '请输入姓名', trigger: ['blur', 'input'] }],
phoneNum: [{ required: true, message: '请输入手机号', trigger: ['blur', 'input'] }],
platformPassword: [{ required: true, message: '请输入平台密码', trigger: ['blur', 'input'] }]
}
const identityOptions = [
{ label: '法定代表人', value: '01' },
{ label: '财务负责人', value: '02' },
{ label: '办税员', value: '03' },
{ label: '管理员', value: '05' },
{ label: '开票员', value: '09' }
]
const columns: DataTableColumns<DigitalAccountItem> = [
{ title: '税局账号', key: 'account', minWidth: 140 },
{
title: '平台登录账号',
key: 'platformUsername',
width: 180,
ellipsis: true,
render: (row) => renderEllipsisText(row.platformUsername)
},
{ title: '姓名', key: 'name', minWidth: 100 },
{ title: '登录身份', key: 'identityType', render: (row) => identityLabel(row.identityType) },
{ title: '账号状态', key: 'authStatus', render: (row) => authStatusLabel(row.authStatus) },
{
title: '登录认证',
key: 'loginAuthStatus',
render: (row) =>
h(
NTag,
{ type: row.loginAuthStatus === '1' ? 'success' : 'warning' },
{ default: () => (row.loginAuthStatus === '1' ? '已登录' : '未登录') }
)
},
{
title: '风险认证',
key: 'riskAuthStatus',
render: (row) =>
h(
NTag,
{ type: row.riskAuthStatus === '1' ? 'success' : 'warning' },
{ default: () => (row.riskAuthStatus === '1' ? '已认证' : '未认证') }
)
},
{ title: '最近认证', key: 'lastAuthSuccTime', minWidth: 160 },
{
title: '平台状态',
key: 'status',
width: 110,
render: (row) =>
h(
NTag,
{ type: row.status === 'ENABLED' ? 'success' : 'warning' },
{ default: () => (row.status === 'ENABLED' ? '可用' : '禁用') }
)
},
{
title: 'API Key',
key: 'apiKey',
width: 180,
ellipsis: true,
render: (row) => renderEllipsisText(row.apiKey)
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 300,
render: (row) =>
h('div', { class: 'row-actions' }, [
h(
NButton,
{
size: 'small',
secondary: true,
disabled: row.status !== 'ENABLED',
onClick: () => openSms(row)
},
{ icon: () => h(NIcon, { component: KeyRound }), default: () => '短信登录' }
),
h(
NButton,
{
size: 'small',
tertiary: true,
disabled: row.status !== 'ENABLED',
onClick: () => openQrcode(row)
},
{ icon: () => h(NIcon, { component: ShieldCheck }), default: () => '认证' }
),
canUpdateStatus.value
? h(
NPopconfirm,
{
onPositiveClick: () => updateAccountStatus(row)
},
{
trigger: () =>
h(
NButton,
{ size: 'small', tertiary: true, type: row.status === 'ENABLED' ? 'warning' : 'success' },
{ default: () => (row.status === 'ENABLED' ? '禁用' : '恢复') }
),
default: () =>
row.status === 'ENABLED'
? `确认禁用数电账号 ${row.account} 吗?`
: `确认恢复数电账号 ${row.account} 吗?`
}
)
: null
])
}
]
function identityLabel(value?: string | null) {
return identityOptions.find((item) => item.value === value)?.label || value || '-'
}
function authStatusLabel(value?: string | null) {
const map: Record<string, string> = {
'0': '无需认证',
'1': '风险认证',
'2': '登录认证',
'3': '风险+登录认证'
}
return value ? map[value] || value : '-'
}
function renderEllipsisText(value?: string | null) {
if (!value) return '-'
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => h('span', { class: 'cell-ellipsis' }, value),
default: () => value
}
)
}
async function load() {
loading.value = true
try {
accounts.value = await listDigitalAccountsApi()
} finally {
loading.value = false
}
}
async function refreshAccounts() {
loading.value = true
try {
accounts.value = await refreshDigitalAccountsApi()
message.success('数电账号已刷新')
} finally {
loading.value = false
}
}
async function createAccount() {
await createFormRef.value?.validate()
saving.value = true
try {
await createDigitalAccountApi({ ...createForm })
message.success('数电账号已创建')
showCreate.value = false
await load()
} finally {
saving.value = false
}
}
async function updateAccountStatus(row: DigitalAccountItem) {
const nextStatus = row.status === 'ENABLED' ? 'DISABLED' : 'ENABLED'
await updateDigitalAccountStatusApi(row.id, nextStatus)
message.success(nextStatus === 'ENABLED' ? '数电账号已恢复' : '数电账号已禁用')
await load()
}
async function openSms(row: DigitalAccountItem) {
selected.value = row
smsCode.value = ''
smsTip.value = ''
showSms.value = true
}
async function sendSmsCode() {
if (!selected.value) return
smsCodeLoading.value = true
try {
const res = await sendLoginSmsCodeApi({
taxpayerNum: selected.value.taxpayerNum,
account: selected.value.account
})
smsTip.value = res.phoneNum ? `验证码已发送至 ${res.phoneNum}` : res.resultMsg
} finally {
smsCodeLoading.value = false
}
}
async function submitSms() {
if (!selected.value || !smsCode.value.trim()) return
smsLoading.value = true
try {
await smsLoginApi({
taxpayerNum: selected.value.taxpayerNum,
account: selected.value.account,
smsCode: smsCode.value.trim()
})
message.success('短信登录成功')
showSms.value = false
await load()
} finally {
smsLoading.value = false
}
}
async function openQrcode(row: DigitalAccountItem) {
selected.value = row
qrcodeType.value = '1'
qrcodeImg.value = ''
qrcodeGenTime.value = ''
qrcodeTip.value = ''
showQrcode.value = true
await loadQrcode()
}
async function loadQrcode() {
if (!selected.value) return
qrLoading.value = true
try {
const res = await getAuthQrcodeApi(qrcodeType.value, selected.value.id)
qrcodeImg.value = res.qrcodeImg ? `data:image/png;base64,${res.qrcodeImg}` : ''
qrcodeType.value = res.qrcodeType || qrcodeType.value
qrcodeGenTime.value = res.genTime || ''
qrcodeTip.value = res.resultMsg || ''
} finally {
qrLoading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.modal-actions,
.row-actions {
display: flex;
align-items: center;
gap: 8px;
}
.row-actions {
flex-wrap: nowrap;
white-space: nowrap;
}
.row-actions :deep(.n-button) {
flex-shrink: 0;
}
.cell-ellipsis {
display: inline-block;
width: 148px;
max-width: 148px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.modal-card {
width: min(720px, 92vw);
}
.modal-actions {
justify-content: flex-end;
margin-top: 10px;
padding-top: 12px;
border-top: 1px solid #eef1f5;
}
.auth-panel {
width: 100%;
}
.auth-account {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fafafa;
}
.auth-account span {
color: #6b7280;
font-size: 12px;
}
.auth-account strong {
color: #111827;
font-size: 13px;
font-weight: 600;
word-break: break-all;
}
.qrcode-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 236px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fff;
}
.qrcode-wrap img {
width: 220px;
height: 220px;
}
.qrcode-meta {
display: flex;
flex-direction: column;
gap: 2px;
color: #6b7280;
font-size: 12px;
text-align: center;
}
.qrcode-meta span {
color: #111827;
font-weight: 600;
}
</style>