多数电账号管理

This commit is contained in:
BBIT-Kai
2026-05-22 15:37:45 +08:00
parent f718ff46da
commit d57ea3960c
63 changed files with 2421 additions and 2886 deletions
+5 -1
View File
@@ -1,10 +1,14 @@
import http from '@/api/http'
import type { LoginRequest, LoginResponse, MeResponse } from '@/types/auth'
import type { EnterpriseRegisterRequest, LoginRequest, LoginResponse, MeResponse } from '@/types/auth'
export function loginApi(payload: LoginRequest) {
return http.post<never, LoginResponse>('/auth/login', payload)
}
export function registerEnterpriseApi(payload: EnterpriseRegisterRequest) {
return http.post<never, LoginResponse>('/auth/register-enterprise', payload)
}
export function logoutApi() {
return http.post<never, void>('/auth/logout')
}
+97 -65
View File
@@ -95,83 +95,83 @@ export function getPTInfoApi(): Promise<TaxBureauAccountAuth> {
return http.get('/pt/info')
}
export interface TaxEnterpriseRegisterRequest {
taxpayerNum: string
enterpriseName: string
legalPersonName: string
contactsName: string
contactsEmail: string
contactsPhone: string
regionCode: string
cityName: string
enterpriseAddress: string
taxRegistrationCertificate: string
}
/**
* 注册企业(纳税人)
*/
export function registerEnterpriseApi(payload: TaxEnterpriseRegisterRequest): Promise<string> {
return http.post('/pt/register', payload)
}
export interface TaxRegisterUserRequest {
taxpayerNum: string
taxAccount: string
}
/**
* 登记账号
*/
export function registerUserApi(payload: TaxRegisterUserRequest): Promise<string> {
return http.post('/pt/registerUser', payload)
}
// =============================================
// 基础信息配置(本地 CRUD
// =============================================
/** 企业信息 */
export interface EnterpriseInfo {
id: string
taxpayerNum: string
enterpriseName: string
legalPersonName: string
contactsName: string
contactsEmail: string
contactsPhone: string
regionCode: string
cityName: string
enterpriseAddress: string
taxRegistrationCertificate: string
legalPersonName?: string | null
contactsName?: string | null
contactsEmail?: string | null
contactsPhone?: string | null
regionCode?: string | null
cityName?: string | null
enterpriseAddress?: string | null
taxRegistrationCertificate?: string | null
invitationCode?: string | null
reviewStatus?: string | null
reviewOpinion?: string | null
invoiceKind?: string | null
invoiceLayoutFileType?: string | null
serviceStatus?: string | null
bankName?: string | null
bankAccount?: string | null
presetAddress?: string | null
presetPhone?: string | null
}
/** 获取企业信息 */
export function getEnterpriseInfoApi(): Promise<EnterpriseInfo> {
return http.get('/pt/enterprise')
}
/** 更新企业信息 */
export function updateEnterpriseInfoApi(payload: Partial<EnterpriseInfo>): Promise<string> {
return http.put('/pt/enterprise', payload)
export function refreshEnterpriseInfoApi(): Promise<EnterpriseInfo> {
return http.post('/pt/enterprise/refresh')
}
/** 数电账号信息 */
export interface DigitalAccountInfo {
export interface DigitalAccountItem {
id: string
enterpriseId: string
taxpayerNum: string
taxAccount: string
account: string
name?: string | null
identityType?: string | null
operationProposed?: string | null
authStatus?: string | null
switchable?: string | null
wechatUserBindStatus?: string | null
lastAuthSuccTime?: string | null
loginAuthStatus?: string | null
lastLoginAuthTime?: string | null
riskAuthStatus?: string | null
lastRiskAuthTime?: string | null
platformUserId?: string | null
platformUsername?: string | null
apiKey?: string | null
status: string
}
/** 获取数电账号信息 */
export function getDigitalAccountApi(): Promise<DigitalAccountInfo> {
return http.get('/pt/digital-account')
export interface CreateDigitalAccountRequest {
account: string
taxPassword: string
identityType: string
phoneNum: string
name: string
platformPassword: string
}
/** 更新数电账号信息 */
export function updateDigitalAccountApi(payload: DigitalAccountInfo): Promise<string> {
return http.put('/pt/digital-account', payload)
export function listDigitalAccountsApi(): Promise<DigitalAccountItem[]> {
return http.get('/pt/digital-accounts')
}
export function refreshDigitalAccountsApi(): Promise<DigitalAccountItem[]> {
return http.post('/pt/digital-accounts/refresh')
}
export function createDigitalAccountApi(
payload: CreateDigitalAccountRequest
): Promise<DigitalAccountItem> {
return http.post('/pt/digital-accounts', payload)
}
/** 开票预设数据 */
export interface PresetData {
bankName: string
bankAccount: string
@@ -179,16 +179,44 @@ export interface PresetData {
phone: string
}
/** 获取开票预设数据 */
export function getPresetDataApi(): Promise<PresetData> {
return http.get('/pt/preset')
export interface EnterpriseBankAccount {
bankName: string
bankAccount?: string | null
source: string
}
export function getPresetDataApi(): Promise<PresetData> {
return getEnterpriseInfoApi().then((info) => ({
bankName: info.bankName ?? '',
bankAccount: info.bankAccount ?? '',
address: info.presetAddress ?? '',
phone: info.presetPhone ?? ''
}))
}
/** 更新开票预设数据 */
export function updatePresetDataApi(payload: PresetData): Promise<string> {
return http.put('/pt/preset', payload)
}
export function queryEnterpriseBankAccountsApi(): Promise<EnterpriseBankAccount[]> {
return http.get('/pt/enterprise/bank-accounts')
}
export interface OpenApiStatisticsItem {
digitalAccountId?: string | null
account?: string | null
interfaceCode?: string | null
total: number
success: number
failed: number
avgCostMs: number
lastCalledAt?: string | null
}
export function openApiStatisticsApi(): Promise<OpenApiStatisticsItem[]> {
return http.get('/pt/openapi/statistics')
}
// =============================================
// 开票相关
// =============================================
@@ -269,6 +297,7 @@ export interface OrderInfo {
* 数电发票开票请求
*/
export interface InvoiceRequest {
digitalAccountId?: string | null
/** 销方纳税人识别号 */
taxpayerNum: string
/** 发票请求流水号 */
@@ -850,8 +879,11 @@ export interface AuthQrcodeResponse {
* 获取实名认证二维码
* @param qrcodeType 1: 电子税务局 APP, 2: 国家网络身份认证 APP
*/
export function getAuthQrcodeApi(qrcodeType: string): Promise<AuthQrcodeResponse> {
return http.get('/pt/authentication', { params: { qrcodeType } })
export function getAuthQrcodeApi(
qrcodeType: string,
digitalAccountId?: string
): Promise<AuthQrcodeResponse> {
return http.get('/pt/authentication', { params: { qrcodeType, digitalAccountId } })
}
/** 查询认证二维码扫码状态响应 */
+1
View File
@@ -60,6 +60,7 @@ function handleSelect(key: string) {
<style scoped>
:deep(.n-menu) {
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
font-size: 13px;
@@ -25,6 +25,7 @@
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
remote
size="small"
:columns="columns"
@@ -25,6 +25,7 @@
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
remote
size="small"
:columns="columns"
@@ -0,0 +1,363 @@
<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="短信登录" class="sms-card">
<n-space vertical>
<n-alert v-if="smsTip" type="info">{{ smsTip }}</n-alert>
<n-input v-model:value="smsCode" placeholder="请输入短信验证码" />
<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="风险认证" class="sms-card">
<div class="qrcode-wrap">
<img v-if="qrcodeImg" :src="qrcodeImg" alt="认证二维码" />
<n-empty v-else description="暂无二维码" />
<n-button size="small" :loading="qrLoading" @click="loadQrcode">刷新二维码</n-button>
</div>
</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,
NModal,
NSelect,
NSpace,
NTag,
useMessage
} from 'naive-ui'
import { KeyRound, Plus, RefreshCw, ShieldCheck } from 'lucide-vue-next'
import {
createDigitalAccountApi,
getAuthQrcodeApi,
listDigitalAccountsApi,
refreshDigitalAccountsApi,
sendLoginSmsCodeApi,
smsLoginApi
} 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 qrLoading = ref(false)
const smsCode = ref('')
const smsTip = ref('')
const qrcodeImg = ref('')
const selected = ref<DigitalAccountItem | null>(null)
const createFormRef = ref<FormInst | null>(null)
const canManage = computed(() => authStore.user?.userType !== 'DIGITAL_OPERATOR')
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', minWidth: 180, render: (row) => 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: 'API Key',
key: 'apiKey',
minWidth: 180,
render: (row) => row.apiKey || '-'
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 220,
render: (row) =>
h('div', { class: 'row-actions' }, [
h(
NButton,
{ size: 'small', secondary: true, onClick: () => openSms(row) },
{ icon: () => h(NIcon, { component: KeyRound }), default: () => '短信登录' }
),
h(
NButton,
{ size: 'small', tertiary: true, onClick: () => openQrcode(row) },
{ icon: () => h(NIcon, { component: ShieldCheck }), default: () => '认证' }
)
])
}
]
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 : '-'
}
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 openSms(row: DigitalAccountItem) {
selected.value = row
smsCode.value = ''
smsLoading.value = true
showSms.value = true
try {
const res = await sendLoginSmsCodeApi({ taxpayerNum: row.taxpayerNum, account: row.account })
smsTip.value = res.phoneNum ? `验证码已发送至 ${res.phoneNum}` : res.resultMsg
} finally {
smsLoading.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
showQrcode.value = true
await loadQrcode()
}
async function loadQrcode() {
if (!selected.value) return
qrLoading.value = true
try {
const res = await getAuthQrcodeApi('1', selected.value.id)
qrcodeImg.value = res.qrcodeImg ? `data:image/png;base64,${res.qrcodeImg}` : ''
} 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;
}
.modal-card {
width: min(720px, 92vw);
}
.sms-card {
width: min(420px, 92vw);
}
.modal-actions {
justify-content: flex-end;
}
.qrcode-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qrcode-wrap img {
width: 220px;
height: 220px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
remote
:columns="columns"
:data="dataSource"
@@ -190,6 +190,17 @@
</div>
<div class="section-body">
<n-grid item-responsive responsive="screen" cols="2 s:2 m:3 l:4 xl:5 2xl:6" :x-gap="12" :y-gap="4">
<n-gi>
<n-form-item label="数电账号" path="digitalAccountId">
<n-select
v-model:value="form.digitalAccountId"
:options="digitalAccountOptions"
:disabled="isOperator"
placeholder="请选择开票员"
@update:value="applySelectedDigitalAccount"
/>
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="销方纳税人识别号" path="taxpayerNum">
<n-input
@@ -914,8 +925,13 @@ import {
useMessage
} from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
import { invoiceIssueApi, getEnterpriseInfoApi, getPresetDataApi } from '@/api/piaotong'
import type { InvoiceRequest, InvoiceItem, VariableLevyProof } from '@/api/piaotong'
import {
invoiceIssueApi,
getEnterpriseInfoApi,
getPresetDataApi,
listDigitalAccountsApi
} from '@/api/piaotong'
import type { DigitalAccountItem, InvoiceRequest, InvoiceItem, VariableLevyProof } from '@/api/piaotong'
import type { FormInst, FormRules } from 'naive-ui'
const message = useMessage()
@@ -931,6 +947,14 @@ const orderInputValue = ref('')
const activeSidebar = ref<string | number>('basic')
const currentUser = computed(() => authStore.user)
const digitalAccounts = ref<DigitalAccountItem[]>([])
const isOperator = computed(() => currentUser.value?.userType === 'DIGITAL_OPERATOR')
const digitalAccountOptions = computed(() =>
digitalAccounts.value.map((item) => ({
label: `${item.name || item.account}${item.account}`,
value: item.id
}))
)
const currentItem = computed({
get: () => form.itemList[activeSidebar.value as number] ?? form.itemList[0],
set: (val) => {
@@ -1144,6 +1168,7 @@ function createEmptyVariableLevyProof(): VariableLevyProof {
}
const form = reactive<InvoiceRequest>({
digitalAccountId: null,
taxpayerNum: '',
invoiceReqSerialNo: '',
invoiceIssueKindCode: '82',
@@ -1231,6 +1256,13 @@ function generateSerialNo() {
autoSerial.value = true
}
function applySelectedDigitalAccount() {
const selected = digitalAccounts.value.find((item) => item.id === form.digitalAccountId)
if (!selected) return
form.taxpayerNum = selected.taxpayerNum
form.account = selected.account
}
function confirmRemove(index: number) {
const item = form.itemList[index]
const itemName = item?.goodsName || `商品 #${item?.lineNo || index + 1}`
@@ -1533,20 +1565,24 @@ async function handleSubmit() {
}
onMounted(async () => {
if (currentUser.value?.taxpayerNum) {
form.taxpayerNum = currentUser.value.taxpayerNum
}
if (currentUser.value?.account) {
form.account = currentUser.value.account
}
generateSerialNo()
// 加载企业信息和预设数据,自动填入销方信息
try {
const [enterpriseInfo, presetData] = await Promise.all([
const [enterpriseInfo, presetData, accounts] = await Promise.all([
getEnterpriseInfoApi(),
getPresetDataApi()
getPresetDataApi(),
listDigitalAccountsApi()
])
digitalAccounts.value = accounts
const defaultAccount =
accounts.find((item) => item.id === currentUser.value?.digitalAccountId) || accounts[0]
if (defaultAccount) {
form.digitalAccountId = defaultAccount.id
applySelectedDigitalAccount()
} else if (enterpriseInfo?.taxpayerNum) {
form.taxpayerNum = enterpriseInfo.taxpayerNum
}
if (enterpriseInfo?.enterpriseAddress) {
form.sellerAddress = enterpriseInfo.enterpriseAddress
}
@@ -1650,7 +1686,7 @@ watch(
/** 重置表单到初始状态 */
function resetForm() {
form.taxpayerNum = currentUser.value?.taxpayerNum || ''
form.taxpayerNum = ''
form.invoiceReqSerialNo = ''
form.buyerName = ''
form.buyerTaxpayerNum = ''
@@ -1670,7 +1706,7 @@ function resetForm() {
form.showSellerBank = '0'
form.showBuyerAddrTel = '0'
form.showSellerAddrTel = '0'
form.account = currentUser.value?.account || ''
form.account = ''
form.variableLevyFlag = ''
form.casherName = ''
form.reviewerName = ''
@@ -1687,6 +1723,7 @@ function resetForm() {
form.variableLevyProofList = []
form.orderList = []
activeSidebar.value = 'basic'
applySelectedDigitalAccount()
generateSerialNo()
}
</script>
@@ -0,0 +1,143 @@
<template>
<div class="page-shell">
<section class="page-card">
<div class="page-toolbar">
<h2 class="page-toolbar-title">开票设置</h2>
<div class="page-toolbar-actions">
<n-button :loading="bankLoading" @click="openBankPicker">快捷选择银行</n-button>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</div>
</div>
<div class="card-body">
<n-form :model="form" label-placement="top" class="form">
<n-grid responsive="screen" :cols="2" :x-gap="16">
<n-gi>
<n-form-item label="开户行">
<n-input v-model:value="form.bankName" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="银行账号">
<n-input v-model:value="form.bankAccount" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="销售方地址">
<n-input v-model:value="form.address" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="销售方电话">
<n-input v-model:value="form.phone" />
</n-form-item>
</n-gi>
</n-grid>
</n-form>
</div>
</section>
<n-modal
v-model:show="bankModalVisible"
preset="card"
title="快捷选择银行"
:style="{ width: '520px', maxWidth: '92vw' }"
:mask-closable="false"
>
<n-space vertical>
<n-select
v-model:value="selectedBankKey"
:options="bankOptions"
:loading="bankLoading"
filterable
clearable
placeholder="请选择开户行及账号"
/>
<div class="modal-actions">
<n-button @click="bankModalVisible = false">取消</n-button>
<n-button type="primary" :disabled="!selectedBankKey" :loading="saving" @click="confirmBank">
确定
</n-button>
</div>
</n-space>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { NButton, NForm, NFormItem, NGi, NGrid, NInput, NModal, NSelect, NSpace, useMessage } from 'naive-ui'
import {
getPresetDataApi,
queryEnterpriseBankAccountsApi,
updatePresetDataApi
} from '@/api/piaotong'
import type { EnterpriseBankAccount } from '@/api/piaotong'
const message = useMessage()
const saving = ref(false)
const bankLoading = ref(false)
const bankModalVisible = ref(false)
const selectedBankKey = ref<string | null>(null)
const bankAccounts = ref<EnterpriseBankAccount[]>([])
const form = reactive({ bankName: '', bankAccount: '', address: '', phone: '' })
const bankOptions = computed(() =>
bankAccounts.value.map((item, index) => ({
label: `${item.bankName}${item.bankAccount ? ` / ${item.bankAccount}` : ''}`,
value: String(index)
}))
)
async function load() {
Object.assign(form, await getPresetDataApi())
}
async function save() {
saving.value = true
try {
await updatePresetDataApi({ ...form })
message.success('开票设置已保存')
} finally {
saving.value = false
}
}
async function openBankPicker() {
bankModalVisible.value = true
bankLoading.value = true
try {
bankAccounts.value = await queryEnterpriseBankAccountsApi()
selectedBankKey.value = bankAccounts.value.length === 1 ? '0' : null
if (bankAccounts.value.length === 0) {
message.info('未查询到可选择的开户行及账号')
}
} finally {
bankLoading.value = false
}
}
async function confirmBank() {
if (selectedBankKey.value === null) return
const selected = bankAccounts.value[Number(selectedBankKey.value)]
if (!selected) return
form.bankName = selected.bankName
form.bankAccount = selected.bankAccount ?? ''
await save()
bankModalVisible.value = false
}
onMounted(load)
</script>
<style scoped>
.form {
max-width: 880px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="page-shell">
<section class="page-card table-fill">
<div class="page-toolbar">
<h2 class="page-toolbar-title">OpenAPI</h2>
<div class="page-toolbar-actions">
<n-button :loading="loading" @click="load">
<template #icon><n-icon :component="RefreshCw" /></template>
刷新
</n-button>
</div>
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
size="small"
:columns="columns"
:data="rows"
:loading="loading"
:pagination="{ pageSize: 12 }"
:row-key="(row: OpenApiStatisticsItem) => `${row.digitalAccountId}-${row.interfaceCode}`"
:scroll-x="1040"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { h, onMounted, ref } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NIcon, NTag } from 'naive-ui'
import { RefreshCw } from 'lucide-vue-next'
import { openApiStatisticsApi } from '@/api/piaotong'
import type { OpenApiStatisticsItem } from '@/api/piaotong'
const loading = ref(false)
const rows = ref<OpenApiStatisticsItem[]>([])
const columns: DataTableColumns<OpenApiStatisticsItem> = [
{ title: '数电账号ID', key: 'digitalAccountId', minWidth: 220 },
{ title: '接口', key: 'interfaceCode', minWidth: 180 },
{ title: '调用次数', key: 'total', width: 100 },
{ title: '成功', key: 'success', width: 100, render: (row) => h(NTag, { type: 'success' }, { default: () => row.success }) },
{ title: '失败', key: 'failed', width: 100, render: (row) => h(NTag, { type: row.failed > 0 ? 'error' : 'default' }, { default: () => row.failed }) },
{ title: '平均耗时(ms)', key: 'avgCostMs', width: 140 },
{ title: '最近调用', key: 'lastCalledAt', minWidth: 180 }
]
async function load() {
loading.value = true
try {
rows.value = await openApiStatisticsApi()
} finally {
loading.value = false
}
}
onMounted(load)
</script>
+2
View File
@@ -8,6 +8,7 @@
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
:columns="typeColumns"
:data="typeRows"
:pagination="typePagination"
@@ -26,6 +27,7 @@
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
:columns="itemColumns"
:data="itemRows"
:pagination="itemPagination"
+1
View File
@@ -20,6 +20,7 @@
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
:columns="columns"
:data="rows"
:pagination="pagination"
+2 -15
View File
@@ -32,6 +32,7 @@
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
remote
size="small"
:columns="columns"
@@ -166,9 +167,6 @@
<n-form-item label="邮箱" path="email">
<n-input v-model:value="editForm.email" />
</n-form-item>
<n-form-item v-if="editModal.mode === 'edit'" label="API Key">
<n-input v-model:value="editForm.apiKey" readonly />
</n-form-item>
<n-form-item v-if="editModal.mode === 'create'" label="状态" path="status">
<n-radio-group v-model:value="editForm.status">
<n-radio-button value="ENABLED">启用</n-radio-button>
@@ -340,8 +338,7 @@ const baseDetailItems = computed(() => {
{ label: '邮箱', value: displayValue(user.email) },
{ label: '组织', value: formatOrg(user) },
{ label: '状态', value: user.statusLabel || statusLabel(user.status) },
{ label: '头像', value: displayValue(user.avatar) },
{ label: 'API Key', value: displayValue(user.apiKey) }
{ label: '头像', value: displayValue(user.avatar) }
]
})
@@ -405,7 +402,6 @@ const editForm = reactive({
realName: '',
phone: '',
email: '',
apiKey: '',
status: 'ENABLED'
})
@@ -526,7 +522,6 @@ function resetEditForm() {
editForm.realName = ''
editForm.phone = ''
editForm.email = ''
editForm.apiKey = ''
editForm.status = 'ENABLED'
}
@@ -572,7 +567,6 @@ async function openEdit(row: UserListItem) {
editForm.realName = detail.realName ?? ''
editForm.phone = detail.phone ?? ''
editForm.email = detail.email ?? ''
editForm.apiKey = detail.apiKey ?? ''
editForm.status = detail.status
editModal.visible = true
}
@@ -639,13 +633,6 @@ const columns = computed<DataTableColumns<UserListItem>>(() => [
minWidth: 180,
render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-')
},
{
title: 'API Key',
key: 'apiKey',
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.apiKey || '-'
},
{
title: '状态',
key: 'status',
+13 -2
View File
@@ -275,11 +275,20 @@ async function handleLogout() {
height: 0;
}
.layout-sider :deep(.n-layout-sider-scroll-container) {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.layout-sider-hidden {
border-right: 0;
}
.brand-mark {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
@@ -321,9 +330,11 @@ async function handleLogout() {
}
.menu-wrap {
height: calc(100% - 64px);
flex: 1 1 auto;
min-height: 0;
padding: 10px 8px 14px;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
+15 -2
View File
@@ -1,8 +1,14 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { loginApi, logoutApi, meApi } from '@/api/auth'
import { loginApi, logoutApi, meApi, registerEnterpriseApi } from '@/api/auth'
import { listDictItemsApi } from '@/api/system/dict'
import type { CurrentUserProfile, LoginRequest, MeResponse, MenuNode } from '@/types/auth'
import type {
CurrentUserProfile,
EnterpriseRegisterRequest,
LoginRequest,
MeResponse,
MenuNode
} from '@/types/auth'
import type { DictItem } from '@/types/system/dict'
const TOKEN_KEY = 'platform.token'
@@ -59,6 +65,12 @@ export const useAuthStore = defineStore('auth', () => {
await loadProfile()
}
async function registerEnterprise(payload: EnterpriseRegisterRequest) {
const result = await registerEnterpriseApi(payload)
setToken(result.accessToken)
await loadProfile()
}
async function logout() {
try {
if (token.value) {
@@ -168,6 +180,7 @@ export const useAuthStore = defineStore('auth', () => {
setToken,
clearAuth,
login,
registerEnterprise,
logout,
loadProfile,
hasPermission,
+21
View File
@@ -100,6 +100,22 @@ select {
background: #ffffff;
}
.page-toolbar-title {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
.page-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.toolbar-form {
flex: 1;
}
@@ -220,4 +236,9 @@ select {
flex-direction: column;
align-items: stretch;
}
.page-toolbar-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
}
+18 -4
View File
@@ -9,21 +9,35 @@ export interface LoginResponse {
expiresIn: number
}
export interface EnterpriseRegisterRequest {
taxpayerNum: string
enterpriseName: string
legalPersonName?: string | null
contactsName?: string | null
contactsEmail?: string | null
contactsPhone: string
regionCode: string
cityName?: string
enterpriseAddress?: string | null
taxRegistrationCertificate?: string | null
password: string
confirmPassword: string
}
export interface CurrentUserProfile {
id: string
username: string
nickname?: string | null
realName?: string | null
orgId?: string | null
enterpriseId?: string | null
digitalAccountId?: string | null
userType: 'SYSTEM' | 'ENTERPRISE_ADMIN' | 'DIGITAL_OPERATOR' | string
status: string
avatar?: string | null
email?: string | null
phone?: string | null
createdAt?: string | null
taxpayerNum?: string | null
account?: string | null
taxPassword?: string | null
taxIdentityType?: string | null
}
export interface MenuNode {
-2
View File
@@ -9,7 +9,6 @@ export interface UserListItem {
status: string
statusLabel?: string
roleCodes: string[]
apiKey?: string | null
}
export interface UserDetail {
@@ -27,7 +26,6 @@ export interface UserDetail {
statusLabel?: string
roleIds: string[]
roles: UserRoleBrief[]
apiKey?: string | null
tokenVersion: number
lastLoginAt?: string | null
lastLoginIp?: string | null
+318 -22
View File
@@ -4,7 +4,9 @@
<template #header>
<div class="login-card-header">
<div class="login-card-title">{{ appTitle }}</div>
<div class="login-card-subtitle">登录后继续使用</div>
<div class="login-card-subtitle">
{{ isRegister ? '注册企业后直接进入系统' : '登录后继续使用' }}
</div>
</div>
</template>
@@ -12,13 +14,20 @@
未登录或登录已失效请重新登录
</n-alert>
<n-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="onSubmit">
<n-form
v-if="!isRegister"
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
size="large"
@submit.prevent="onLogin"
>
<n-form-item path="username" label="用户名">
<n-input v-model:value="form.username" placeholder="请输入用户名" />
<n-input v-model:value="loginForm.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input
v-model:value="form.password"
v-model:value="loginForm.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
@@ -28,6 +37,113 @@
登录
</n-button>
</n-form>
<n-form
v-else
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
size="medium"
label-placement="top"
@submit.prevent="onRegister"
>
<n-grid responsive="screen" :cols="2" :x-gap="12">
<n-gi>
<n-form-item path="taxpayerNum" label="纳税人识别号">
<n-input v-model:value="registerForm.taxpayerNum" clearable />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="enterpriseName" label="企业名称">
<n-input v-model:value="registerForm.enterpriseName" clearable />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="legalPersonName" label="法人姓名">
<n-input v-model:value="registerForm.legalPersonName" clearable />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="contactsName" label="联系人">
<n-input v-model:value="registerForm.contactsName" clearable />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="contactsPhone" label="联系人手机">
<n-input v-model:value="registerForm.contactsPhone" clearable />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="contactsEmail" label="联系邮箱">
<n-input v-model:value="registerForm.contactsEmail" clearable />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="regionCode" label="省份">
<n-select
v-model:value="registerForm.regionCode"
clearable
filterable
placeholder="请选择省份"
:options="regionCodeOptions"
/>
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="cityName" label="城市">
<n-input v-model:value="registerForm.cityName" clearable />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item path="enterpriseAddress" label="企业地址">
<n-input v-model:value="registerForm.enterpriseAddress" clearable />
</n-form-item>
<n-form-item path="taxRegistrationCertificate" label="企业注册证书">
<div class="cert-upload">
<div class="cert-actions">
<n-button secondary @click="triggerCertInput">选择图片</n-button>
<span class="cert-state">
{{ registerForm.taxRegistrationCertificate ? '已选择图片' : '未选择,可不上传' }}
</span>
</div>
<input
ref="certInputRef"
type="file"
accept="image/*"
class="hidden-input"
@change="onCertFileChange"
/>
<img v-if="certPreview" class="cert-preview" :src="certPreview" alt="企业注册证书预览" />
</div>
</n-form-item>
<n-grid responsive="screen" :cols="2" :x-gap="12">
<n-gi>
<n-form-item path="password" label="登录密码">
<n-input v-model:value="registerForm.password" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item path="confirmPassword" label="确认密码">
<n-input
v-model:value="registerForm.confirmPassword"
type="password"
show-password-on="click"
/>
</n-form-item>
</n-gi>
</n-grid>
<n-button type="primary" block size="large" :loading="loading" attr-type="submit">
注册并登录
</n-button>
</n-form>
<n-button text block class="switch-btn" @click="toggleMode">
{{ isRegister ? '已有账号返回登录' : '注册企业' }}
</n-button>
</n-card>
</div>
</template>
@@ -35,7 +151,18 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import { useMessage } from 'naive-ui'
import {
NAlert,
NButton,
NCard,
NForm,
NFormItem,
NGi,
NGrid,
NInput,
NSelect,
useMessage
} from 'naive-ui'
import { useRoute, useRouter } from 'vue-router'
import { appEnv } from '@/config/env'
import { useAuthStore } from '@/stores/auth'
@@ -47,25 +174,142 @@ const authStore = useAuthStore()
const appTitle = appEnv.appTitle
const loading = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
username: '',
password: ''
const isRegister = ref(false)
const loginFormRef = ref<FormInst | null>(null)
const registerFormRef = ref<FormInst | null>(null)
const certInputRef = ref<HTMLInputElement | null>(null)
const certPreview = ref('')
const loginForm = reactive({ username: '', password: '' })
const registerForm = reactive({
taxpayerNum: '',
enterpriseName: '',
legalPersonName: '',
contactsName: '',
contactsEmail: '',
contactsPhone: '',
regionCode: '',
cityName: '',
enterpriseAddress: '',
taxRegistrationCertificate: '',
password: '',
confirmPassword: ''
})
const rules: FormRules = {
const loginRules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: ['blur', 'input'] }],
password: [{ required: true, message: '请输入密码', trigger: ['blur', 'input'] }]
}
async function onSubmit() {
await formRef.value?.validate()
const registerRules: FormRules = {
taxpayerNum: [{ required: true, message: '请输入纳税人识别号', trigger: ['blur', 'input'] }],
enterpriseName: [{ required: true, message: '请输入企业名称', trigger: ['blur', 'input'] }],
contactsPhone: [{ required: true, message: '请输入联系人手机', trigger: ['blur', 'input'] }],
regionCode: [{ required: true, message: '请选择省份', trigger: ['blur', 'change'] }],
password: [{ required: true, message: '请输入登录密码', trigger: ['blur', 'input'] }],
confirmPassword: [
{ required: true, message: '请确认登录密码', trigger: ['blur', 'input'] },
{
validator: () => registerForm.password === registerForm.confirmPassword,
message: '两次输入的密码不一致',
trigger: ['blur', 'input']
}
]
}
const regionCodeOptions = [
{ label: '北京市', value: '11' },
{ label: '上海市', value: '31' },
{ label: '天津市', value: '12' },
{ label: '河北省', value: '13' },
{ label: '山西省', value: '14' },
{ label: '内蒙古自治区', value: '15' },
{ label: '辽宁省', value: '21' },
{ label: '吉林省', value: '22' },
{ label: '黑龙江省', value: '23' },
{ label: '江苏省', value: '32' },
{ label: '浙江省', value: '33' },
{ label: '安徽省', value: '34' },
{ label: '福建省', value: '35' },
{ label: '江西省', value: '36' },
{ label: '山东省', value: '37' },
{ label: '河南省', value: '41' },
{ label: '湖北省', value: '42' },
{ label: '湖南省', value: '43' },
{ label: '广东省', value: '44' },
{ label: '广西壮族自治区', value: '45' },
{ label: '海南省', value: '46' },
{ label: '重庆市', value: '50' },
{ label: '四川省', value: '51' },
{ label: '贵州省', value: '52' },
{ label: '云南省', value: '53' },
{ label: '西藏自治区', value: '54' },
{ label: '陕西省', value: '61' },
{ label: '甘肃省', value: '62' },
{ label: '青海省', value: '63' },
{ label: '宁夏回族自治区', value: '64' },
{ label: '新疆维吾尔自治区', value: '65' }
]
function toggleMode() {
isRegister.value = !isRegister.value
}
function triggerCertInput() {
certInputRef.value?.click()
}
function onCertFileChange(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const base64 = String(reader.result || '')
registerForm.taxRegistrationCertificate = base64
certPreview.value = base64
}
reader.readAsDataURL(file)
target.value = ''
}
async function enterSystem() {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.replace(redirect)
}
async function onLogin() {
await loginFormRef.value?.validate()
loading.value = true
try {
await authStore.login(form)
await authStore.login(loginForm)
message.success('登录成功')
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.replace(redirect)
await enterSystem()
} finally {
loading.value = false
}
}
async function onRegister() {
await registerFormRef.value?.validate()
loading.value = true
try {
await authStore.registerEnterprise({
taxpayerNum: registerForm.taxpayerNum.trim(),
enterpriseName: registerForm.enterpriseName.trim(),
legalPersonName: registerForm.legalPersonName.trim() || null,
contactsName: registerForm.contactsName.trim() || null,
contactsEmail: registerForm.contactsEmail.trim() || null,
contactsPhone: registerForm.contactsPhone.trim(),
regionCode: registerForm.regionCode,
cityName: registerForm.cityName.trim(),
enterpriseAddress: registerForm.enterpriseAddress.trim() || null,
taxRegistrationCertificate: registerForm.taxRegistrationCertificate || null,
password: registerForm.password,
confirmPassword: registerForm.confirmPassword
})
message.success('注册成功')
await enterSystem()
} finally {
loading.value = false
}
@@ -74,21 +318,25 @@ async function onSubmit() {
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
position: fixed;
inset: 0;
padding: 32px 24px;
overflow-y: auto;
background: #f5f6f8;
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 16px;
max-width: 720px;
margin: 0 auto;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.login-card :deep(.n-card__content) {
overflow: visible;
}
.login-card-header {
display: flex;
flex-direction: column;
@@ -109,4 +357,52 @@ async function onSubmit() {
.login-alert {
margin-bottom: 18px;
}
.cert-upload {
width: 100%;
}
.cert-actions {
display: flex;
align-items: center;
gap: 10px;
}
.cert-state {
color: #6b7280;
font-size: 12px;
}
.hidden-input {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.cert-preview {
display: block;
max-width: 220px;
max-height: 132px;
margin-top: 10px;
border: 1px solid #e5e7eb;
border-radius: 6px;
object-fit: contain;
}
.switch-btn {
margin-top: 14px;
}
@media (max-width: 640px) {
.login-page {
padding: 18px 14px;
}
.login-card {
margin: 0;
}
}
</style>