chore: initial commit

This commit is contained in:
2026-07-30 17:59:42 +08:00
commit 155f06846d
62 changed files with 7852 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
const navigationSource = fs.readFileSync(new URL('../navigation/clientNavigation.ts', import.meta.url), 'utf8')
const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8')
const authSource = fs.readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
const authApiSource = fs.readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
test('registers the account profile route and navigation entry', () => {
assert.match(routerSource, /account\/profile'.*AccountProfileView\.vue/)
assert.match(routerSource, /permission: 'account:profile:view'/)
assert.match(navigationSource, /title: '我的信息'.*path: '\/account\/profile'/)
})
test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /基本信息/)
assert.match(viewSource, /function startEditing\(\)/)
assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/)
assert.match(viewSource, /账号标识暂不支持在客户端修改/)
assert.match(viewSource, /企业归属由平台审核关系确定/)
assert.match(authSource, /async function savePersonalProfile\(/)
assert.match(viewSource, /await auth\.savePersonalProfile/)
})
test('implements password validation, strength feedback and double-submit protection', () => {
assert.match(viewSource, /async function changePassword\(\)/)
assert.match(viewSource, /validatePasswordChange/)
assert.match(viewSource, /getPasswordStrength/)
assert.match(viewSource, /passwordStrengthState/)
assert.match(viewSource, /if \(passwordSaving\.value\) return/)
assert.match(viewSource, /await changeCurrentPassword/)
assert.match(authApiSource, /method: 'PUT', url: '\/auth\/password'/)
assert.match(viewSource, /安全记录已更新/)
assert.match(viewSource, /await router\.replace\('\/login'\)/)
assert.doesNotMatch(viewSource, /DEMO_CURRENT_PASSWORD|静态演示/)
})
test('provides explicit responsive account layouts', () => {
assert.match(viewSource, /@media \(max-width: 820px\)/)
assert.match(viewSource, /@media \(max-width: 620px\)/)
assert.match(viewSource, /\.account-info-grid, \.account-form-grid \{ grid-template-columns: 1fr; \}/)
assert.match(viewSource, /min-height: 44px/)
})
+350
View File
@@ -0,0 +1,350 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
Building2,
CheckCircle2,
KeyRound,
Pencil,
Save,
ShieldCheck,
UserRound,
X,
} from '@lucide/vue'
import { useAuthStore } from '@/stores/auth'
import { changeCurrentPassword } from '@/api/auth'
import {
getPasswordStrength,
validateEditableAccountProfile,
validatePasswordChange,
type PasswordChangePayload,
} from '@/utils/accountProfile'
type AccountTab = 'profile' | 'password'
interface ProfileForm {
username: string
realName: string
phone: string
email: string
tenantName: string
}
const auth = useAuthStore()
const router = useRouter()
const activeTab = ref<AccountTab>('profile')
const editing = ref(false)
const profileSaving = ref(false)
const passwordSaving = ref(false)
const passwordChangedAt = ref('')
const profileFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
const profileForm = reactive<ProfileForm>({ ...auth.profile })
const passwordForm = reactive<PasswordChangePayload>({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const profileRules: FormRules<ProfileForm> = {
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' },
],
phone: [
{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
],
}
const passwordRules: FormRules<PasswordChangePayload> = {
currentPassword: [{
required: true,
validator: (_rule, value: string, callback) => {
if (!value) callback(new Error('请输入当前密码'))
else callback()
},
trigger: 'blur',
}],
newPassword: [{
required: true,
validator: (_rule, value: string, callback) => {
const errors = validatePasswordChange({
currentPassword: passwordForm.currentPassword,
newPassword: value,
confirmPassword: value,
})
const relevantError = errors.find((error) => error.includes('8–64') || error.includes('同时包含') || error.includes('当前密码相同'))
if (relevantError) callback(new Error(relevantError))
else callback()
},
trigger: 'blur',
}],
confirmPassword: [{
required: true,
validator: (_rule, value: string, callback) => {
if (!value) callback(new Error('请再次输入新密码'))
else if (value !== passwordForm.newPassword) callback(new Error('两次输入的新密码不一致'))
else callback()
},
trigger: 'blur',
}],
}
const passwordStrength = computed(() => getPasswordStrength(passwordForm.newPassword))
const passwordStrengthState = computed(() => passwordForm.newPassword ? passwordStrength.value : 'empty')
const strengthLabel = computed(() => passwordForm.newPassword
? ({ weak: '较弱', medium: '中等', strong: '较强' })[passwordStrength.value]
: '未输入')
function syncProfileForm() {
Object.assign(profileForm, auth.profile)
profileFormRef.value?.clearValidate()
}
function startEditing() {
syncProfileForm()
editing.value = true
}
function cancelEditing() {
syncProfileForm()
editing.value = false
}
async function saveProfile() {
if (profileSaving.value) return
const valid = await profileFormRef.value?.validate().catch(() => false)
const errors = validateEditableAccountProfile(profileForm)
if (!valid || errors.length > 0) return
profileSaving.value = true
try {
await auth.savePersonalProfile({
realName: profileForm.realName.trim(),
phone: profileForm.phone.trim(),
email: profileForm.email.trim(),
})
syncProfileForm()
editing.value = false
ElMessage.success('个人信息已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '个人信息保存失败,请重试')
} finally {
profileSaving.value = false
}
}
function resetPasswordForm() {
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
passwordFormRef.value?.clearValidate()
}
async function changePassword() {
if (passwordSaving.value) return
const valid = await passwordFormRef.value?.validate().catch(() => false)
if (!valid || validatePasswordChange(passwordForm).length > 0) return
passwordSaving.value = true
try {
await changeCurrentPassword(passwordForm.currentPassword, passwordForm.newPassword)
passwordChangedAt.value = new Date().toLocaleString('zh-CN', { hour12: false })
resetPasswordForm()
ElMessage.success('密码已修改,请使用新密码重新登录')
await auth.logout().catch(() => undefined)
await router.replace('/login')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '密码修改失败,请重试')
} finally {
passwordSaving.value = false
}
}
</script>
<template>
<section class="account-page">
<div class="client-card account-identity">
<div class="account-avatar" aria-hidden="true"><UserRound :size="28" /></div>
<div class="account-identity-copy">
<h2>{{ auth.profile.realName }}</h2>
<p>{{ auth.profile.username }}</p>
</div>
<div class="account-context">
<span><Building2 :size="16" />{{ auth.profile.tenantName }}</span>
<span><ShieldCheck :size="16" />{{ auth.user?.roles.join('、') }}</span>
</div>
<el-tag type="success" effect="plain"><CheckCircle2 :size="14" />账号正常</el-tag>
</div>
<div class="client-card account-workspace">
<el-tabs v-model="activeTab" class="account-tabs">
<el-tab-pane name="profile">
<template #label><span class="account-tab-label"><UserRound :size="16" />基本信息</span></template>
<header class="account-section-heading">
<div><h2>基本信息</h2><p>维护当前登录人的联系方式;账号和所属企业由系统身份关系确定。</p></div>
<el-button v-if="!editing" type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
</header>
<el-form
v-if="editing"
ref="profileFormRef"
:model="profileForm"
:rules="profileRules"
label-position="top"
class="account-form-grid"
@submit.prevent="saveProfile"
>
<el-form-item label="用户名">
<el-input v-model="profileForm.username" disabled />
<span class="field-help">账号标识暂不支持在客户端修改</span>
</el-form-item>
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit /></el-form-item>
<el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item>
<el-form-item label="所属公司">
<el-input v-model="profileForm.tenantName" disabled />
<span class="field-help">企业归属由平台审核关系确定</span>
</el-form-item>
<div class="account-form-actions">
<el-button :disabled="profileSaving" @click="cancelEditing"><X :size="16" />取消</el-button>
<el-button type="primary" native-type="submit" :loading="profileSaving"><Save :size="16" />保存修改</el-button>
</div>
</el-form>
<dl v-else class="account-info-grid">
<div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div>
<div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
<div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div>
<div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div>
</dl>
</el-tab-pane>
<el-tab-pane name="password">
<template #label><span class="account-tab-label"><KeyRound :size="16" />修改密码</span></template>
<div class="password-layout">
<div>
<header class="account-section-heading password-heading">
<div><h2>修改密码</h2><p>输入当前密码并设置 8–64 位、同时包含字母和数字的新密码。</p></div>
</header>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-position="top"
class="password-form"
@submit.prevent="changePassword"
>
<el-form-item label="当前密码" prop="currentPassword">
<el-input v-model="passwordForm.currentPassword" type="password" show-password autocomplete="current-password" placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="请输入新密码" />
<div class="password-strength" :data-strength="passwordStrengthState">
<span aria-hidden="true"><i /><i /><i /></span>
<small>密码强度:{{ strengthLabel }}</small>
</div>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="请再次输入新密码" />
</el-form-item>
<div class="password-actions">
<el-button :disabled="passwordSaving" @click="resetPasswordForm">清空</el-button>
<el-button type="primary" native-type="submit" :loading="passwordSaving"><KeyRound :size="16" />确认修改</el-button>
</div>
</el-form>
<div v-if="passwordChangedAt" class="password-result" role="status">
<CheckCircle2 :size="18" />
<div><strong>安全记录已更新</strong><p>{{ passwordChangedAt }};服务端已使旧会话失效。</p></div>
</div>
</div>
<aside class="security-note">
<ShieldCheck :size="22" />
<div><strong>账户安全提示</strong><p>建议组合使用字母、数字和符号,不要与其他网站共用密码。</p></div>
<p>当前密码仅用于本次服务端校验,前端不会持久化密码。修改成功后需要重新登录。</p>
</aside>
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
</template>
<style scoped>
.account-page { display: flex; flex-direction: column; gap: 14px; }
.account-identity { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 18px 22px; }
.account-avatar { display: grid; width: 52px; height: 52px; flex: 0 0 52px; place-items: center; border-radius: 14px; background: #e7f3f1; color: var(--primary); }
.account-identity-copy { min-width: 140px; }
.account-identity h2 { margin: 0 0 4px; color: var(--navy); font-size: 18px; }
.account-identity p { margin: 0; color: var(--muted); font-size: 12px; }
.account-context { display: flex; min-width: 0; flex: 1; gap: 22px; color: #52616b; font-size: 12px; }
.account-context span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-wrap: anywhere; }
.account-identity :deep(.el-tag) { display: flex; align-items: center; gap: 5px; }
.account-workspace { min-height: 520px; padding: 0 24px 28px; }
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.account-tabs :deep(.el-tabs__item) { min-height: 52px; }
.account-tab-label { display: inline-flex; align-items: center; gap: 7px; }
.account-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 24px 0 20px; }
.account-section-heading h2 { margin: 0 0 6px; color: var(--navy); font-size: 18px; }
.account-section-heading p { max-width: 620px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.account-actions { display: flex; gap: 8px; }
.account-info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.account-info-grid div { min-width: 0; min-height: 94px; padding: 18px 20px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.account-info-grid dt { color: var(--muted); font-size: 11px; }
.account-info-grid dd { margin: 9px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; font-weight: 650; }
.account-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; max-width: 900px; }
.account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; }
.account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.password-layout { display: grid; grid-template-columns: minmax(320px, 520px) minmax(240px, 1fr); align-items: start; gap: clamp(36px, 7vw, 90px); }
.password-heading { padding-bottom: 18px; }
.password-form { max-width: 440px; }
.password-form :deep(.el-form-item) { margin-bottom: 20px; }
.password-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.password-result { display: flex; align-items: flex-start; gap: 9px; margin-top: 18px; padding: 14px; background: #edf6f4; color: var(--primary); }
.password-result strong { color: var(--navy); font-size: 12px; }
.password-result p { margin: 3px 0 0; color: #526b67; font-size: 11px; line-height: 1.6; }
.password-strength { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; color: var(--muted); }
.password-strength > span { display: grid; flex: 1; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.password-strength i { height: 3px; background: #dce3e6; }
.password-strength[data-strength="weak"] i:first-child { background: #c2413b; }
.password-strength[data-strength="medium"] i:nth-child(-n+2) { background: #c47913; }
.password-strength[data-strength="strong"] i { background: var(--primary); }
.password-strength small { white-space: nowrap; font-size: 10px; }
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 24px; padding: 22px; background: #f2f7f6; color: var(--primary); }
.security-note strong { color: var(--navy); font-size: 13px; }
.security-note p { margin: 5px 0 0; color: #526b67; font-size: 11px; line-height: 1.7; }
.security-note > p { grid-column: 1 / -1; margin-top: 8px; padding-top: 14px; border-top: 1px solid #d7e5e2; }
.security-note code { color: var(--navy); font-weight: 700; }
@media (max-width: 820px) {
.account-context { align-items: flex-start; flex-direction: column; gap: 7px; }
.password-layout { grid-template-columns: 1fr; gap: 14px; }
.security-note { margin-top: 0; }
}
@media (max-width: 620px) {
.account-identity { align-items: flex-start; flex-wrap: wrap; }
.account-identity-copy { flex: 1; }
.account-context { order: 3; width: 100%; flex-basis: 100%; padding-top: 12px; border-top: 1px solid var(--border); }
.account-workspace { min-height: 0; padding: 0 16px 22px; }
.account-section-heading { align-items: stretch; flex-direction: column; gap: 14px; padding-top: 20px; }
.account-section-heading > .el-button { width: 100%; }
.account-form-actions { width: 100%; }
.account-form-actions .el-button { min-height: 44px; flex: 1; }
.account-info-grid, .account-form-grid { grid-template-columns: 1fr; }
.account-info-grid div { min-height: 82px; }
.password-actions { width: 100%; }
.password-actions .el-button { min-height: 44px; flex: 1; }
}
</style>
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const loginSource = readFileSync(new URL('./LoginView.vue', import.meta.url), 'utf8')
const registerSource = readFileSync(new URL('./RegisterView.vue', import.meta.url), 'utf8')
const authStoreSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
const httpSource = readFileSync(new URL('../api/http.ts', import.meta.url), 'utf8')
const sessionSource = readFileSync(new URL('../api/session.ts', import.meta.url), 'utf8')
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
test('matches the backend username login contract', () => {
assert.match(loginSource, /label="用户名"/)
assert.match(loginSource, /\^\[A-Za-z0-9_\]/)
assert.match(loginSource, /const isDevelopment = import\.meta\.env\.DEV/)
assert.match(loginSource, /v-if="developmentHint" class="demo-tip"/)
assert.match(loginSource, /merchant_admin/)
assert.match(loginSource, /form\.remember/)
assert.match(loginSource, /忘记密码/)
})
test('persists both rotating tokens according to the remember choice', () => {
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
assert.match(sessionSource, /remember \? localStorage : sessionStorage/)
assert.match(sessionSource, /clearSessionTokens/)
})
test('implements every backend registration field and real submission', () => {
for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'legalPhone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
assert.match(registerSource, new RegExp(`prop="${field}"`))
}
assert.match(registerSource, /registerMerchant\(/)
assert.match(registerSource, /4–64 位/)
assert.match(registerSource, /8–72 位/)
})
test('shows immediate login after registration and separates company review from account activation', () => {
assert.match(registerSource, /企业账号注册成功/)
assert.match(registerSource, /无需等待注册审核/)
assert.match(registerSource, /提交订单前还需完成一步/)
assert.match(registerSource, /立即登录/)
})
test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(authApiSource, /\/auth\/login/)
assert.match(authApiSource, /\/auth\/register/)
assert.match(authApiSource, /\/auth\/logout/)
assert.match(httpSource, /refreshPromise/)
assert.match(httpSource, /\/auth\/refresh/)
assert.match(httpSource, /envelope\.code !== '0'/)
assert.match(authStoreSource, /decodeMerchantToken/)
assert.doesNotMatch(authStoreSource, /mock-client:/)
})
+372
View File
@@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadFile, UploadInstance } from 'element-plus'
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
import { formatQualificationFileSize, validateQualificationFile } from '@/utils/qualificationFile'
import { downloadQualificationImage, uploadBusinessLicense } from '@/api/merchantIam'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { label: '待完善资料', title: '请上传营业执照', detail: '营业执照提交审核前不能提交头程订单。' },
PENDING: { label: '审核中', title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { label: '已认证', title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
REJECTED: { label: '审核驳回', title: '企业资料审核未通过', detail: '请根据平台反馈修正资料;当前后端暂不支持同一企业重新上传。' },
DISABLED: { label: '企业已停用', title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
})[store.company.auditStatus])
const activeTab = ref('basic')
const editing = ref(false)
const qualificationOpen = ref(false)
const previewOpen = ref(false)
const previewRecord = ref<TenantQualification | null>(null)
const previewUrl = ref('')
const previewLoading = ref(false)
const uploadRef = ref<UploadInstance>()
const selectedFileSize = ref(0)
const selectedFileType = ref('')
const qualificationFileError = ref('')
const qualificationSubmitting = ref(false)
const profileSubmitting = ref(false)
const loading = ref(false)
const selectedFile = ref<File | null>(null)
const draft = reactive<TenantCompanyProfile>({ ...store.company })
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
onMounted(async () => {
loading.value = true
try {
await store.loadCompanyContext()
Object.assign(draft, store.company)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业资料加载失败')
} finally {
loading.value = false
}
})
function startEdit() {
Object.assign(draft, store.company)
editing.value = true
}
function cancelEdit() {
Object.assign(draft, store.company)
editing.value = false
}
async function saveProfile() {
if (profileSubmitting.value) return
if (!draft.companyName || !draft.legalRepresentative || !draft.contactName || !draft.contactPhone) {
ElMessage.warning('请完整填写企业名称、法人及联系人信息')
return
}
profileSubmitting.value = true
try {
await store.updateCompany({ ...draft })
Object.assign(draft, store.company)
editing.value = false
ElMessage.success('企业资料已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业资料保存失败')
} finally {
profileSubmitting.value = false
}
}
function openQualificationDialog() {
resetQualificationDraft()
qualificationOpen.value = true
}
function handleFileChange(file: UploadFile) {
const raw = file.raw
if (!raw) {
qualificationFileError.value = '未能读取所选文件,请重新选择'
return
}
const error = validateQualificationFile({ name: raw.name, size: raw.size, type: raw.type })
if (error) {
qualificationFileError.value = error
qualificationDraft.fileName = ''
selectedFileSize.value = 0
selectedFileType.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
return
}
qualificationFileError.value = ''
selectedFile.value = raw
qualificationDraft.fileName = raw.name
selectedFileSize.value = raw.size
selectedFileType.value = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
}
function removeSelectedFile() {
uploadRef.value?.clearFiles()
qualificationDraft.fileName = ''
selectedFileSize.value = 0
selectedFileType.value = ''
qualificationFileError.value = ''
selectedFile.value = null
}
function resetQualificationDraft() {
uploadRef.value?.clearFiles()
qualificationDraft.type = '营业执照'
qualificationDraft.fileName = ''
qualificationDraft.certificateNo = store.company.creditCode
selectedFileSize.value = 0
selectedFileType.value = ''
qualificationFileError.value = ''
selectedFile.value = null
}
async function submitQualification() {
if (qualificationSubmitting.value) return
if (!qualificationDraft.fileName || !selectedFile.value) {
ElMessage.warning('请选择营业执照图片')
return
}
if (qualificationFileError.value) {
ElMessage.warning(qualificationFileError.value)
return
}
qualificationSubmitting.value = true
try {
await uploadBusinessLicense(selectedFile.value, qualificationDraft.certificateNo.trim() || undefined)
await store.loadCompanyContext()
qualificationOpen.value = false
ElMessage.success('营业执照已提交审核')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '营业执照上传失败')
} finally {
qualificationSubmitting.value = false
}
}
async function previewQualification(record: TenantQualification) {
previewRecord.value = record
previewOpen.value = true
if (!record.documentUrl) return
previewLoading.value = true
try {
const blob = await downloadQualificationImage(record.documentUrl)
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '资质文件加载失败')
} finally {
previewLoading.value = false
}
}
function closeQualificationPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
previewRecord.value = null
}
function qualificationStatus(status: string) {
return status === 'VERIFIED' ? '已验证' : status === 'PENDING' ? '待审核' : status === 'REJECTED' ? '已驳回' : '已过期'
}
</script>
<template>
<section v-loading="loading" class="profile-page">
<div class="profile-summary-card client-card">
<div class="company-mark"><Building2 :size="28" /></div>
<div class="company-summary">
<div><h2>{{ store.company.companyName }}</h2><el-tag :type="companyApproved ? 'success' : store.company.auditStatus === 'REJECTED' || store.company.auditStatus === 'DISABLED' ? 'danger' : 'warning'" effect="light">{{ auditStatusCopy.label }}</el-tag></div>
<p>{{ store.company.creditCode }} · {{ store.company.companyType }}</p>
</div>
<el-button v-if="auth.hasPermission('company:profile:edit')" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button>
</div>
<div class="company-audit-notice" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
<component :is="companyApproved ? CheckCircle2 : Clock3" :size="21" />
<div><strong>{{ auditStatusCopy.title }}</strong><span>{{ auditStatusCopy.detail }}</span></div>
</div>
<el-tabs v-model="activeTab" class="profile-tabs">
<el-tab-pane name="basic">
<template #label><span class="tab-label"><Building2 :size="16" />企业基本信息</span></template>
<div class="client-card info-panel">
<dl class="profile-grid">
<div><dt>公司名称</dt><dd>{{ store.company.companyName }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode }}</dd></div>
<div><dt>企业类型</dt><dd>{{ store.company.companyType }}</dd></div>
<div><dt>注册地</dt><dd>{{ store.company.registeredAddress }}</dd></div>
<div><dt>成立日期</dt><dd>{{ store.company.establishedDate }}</dd></div>
<div><dt>注册资本</dt><dd>{{ store.company.registeredCapital }}</dd></div>
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative }}</dd></div>
<div><dt>法人电话</dt><dd>{{ store.company.legalRepresentativePhone }}</dd></div>
</dl>
</div>
</el-tab-pane>
<el-tab-pane name="contact">
<template #label><span class="tab-label"><UserRound :size="16" />联系信息</span></template>
<div class="client-card info-panel">
<dl class="profile-grid">
<div><dt>联系人</dt><dd>{{ store.company.contactName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail }}</dd></div>
<div><dt>微信</dt><dd>{{ store.company.contactWechat }}</dd></div>
<div class="full-row"><dt>业务地址</dt><dd>{{ store.company.businessAddress }}</dd></div>
</dl>
</div>
</el-tab-pane>
<el-tab-pane name="qualification">
<template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template>
<div class="client-card qualification-panel">
<header><div><h3>企业资质</h3><p>文件更新后需等待平台重新审核,历史业务不受影响。</p></div><el-button v-if="auth.hasPermission('company:qualification:upload')" type="primary" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></header>
<el-table class="desktop-qualification-table" :data="store.qualifications" row-key="id">
<el-table-column prop="type" label="文件类型" min-width="150" />
<el-table-column prop="fileName" label="文件名称" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="expiresAt" label="有效期" width="130" />
<el-table-column prop="updatedAt" label="更新时间" min-width="160" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template></el-table-column>
</el-table>
<div class="mobile-qualification-list">
<article v-for="record in store.qualifications" :key="record.id">
<header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header>
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
</article>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="editing" title="编辑企业资料" size="min(620px, 94vw)" @closed="cancelEdit">
<el-form label-position="top" class="profile-form">
<h3>企业基本信息</h3>
<div class="form-grid-two">
<el-form-item label="公司名称" required><el-input v-model="draft.companyName" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item>
<el-form-item label="企业类型"><el-input v-model="draft.companyType" /></el-form-item>
<el-form-item label="注册地"><el-input v-model="draft.registeredAddress" /></el-form-item>
<el-form-item label="法人姓名" required><el-input v-model="draft.legalRepresentative" /></el-form-item>
<el-form-item label="法人电话"><el-input v-model="draft.legalRepresentativePhone" /></el-form-item>
</div>
<h3>联系信息</h3>
<div class="form-grid-two">
<el-form-item label="联系人" required><el-input v-model="draft.contactName" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="draft.contactPhone" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="draft.contactEmail" /></el-form-item>
<el-form-item label="微信"><el-input v-model="draft.contactWechat" /></el-form-item>
<el-form-item label="业务地址" class="full-row"><el-input v-model="draft.businessAddress" /></el-form-item>
</div>
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template>
</el-drawer>
<el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft">
<el-form label-position="top">
<el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item>
<el-form-item label="选择文件" required :error="qualificationFileError">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange">
<el-button><Upload :size="16" />选择文件</el-button>
<template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template>
</el-upload>
<div v-if="qualificationDraft.fileName" class="selected-qualification-file" aria-live="polite">
<FileText :size="20" />
<div><strong>{{ qualificationDraft.fileName }}</strong><span>{{ selectedFileType }} · {{ formatQualificationFileSize(selectedFileSize) }}</span></div>
<el-button link type="danger" aria-label="移除所选资质文件" @click="removeSelectedFile"><Trash2 :size="16" />移除</el-button>
</div>
</el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">提交审核</el-button></template>
</el-dialog>
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
<div v-if="previewRecord" v-loading="previewLoading" class="document-preview">
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
<FileCheck2 :size="42" />
<strong>{{ previewRecord.fileName }}</strong>
<el-tag :type="previewRecord.status === 'VERIFIED' ? 'success' : previewRecord.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(previewRecord.status) }}</el-tag>
<dl><div><dt>文件类型</dt><dd>{{ previewRecord.type }}</dd></div><div><dt>有效期</dt><dd>{{ previewRecord.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ previewRecord.updatedAt }}</dd></div></dl>
<span>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
</div>
</el-dialog>
</section>
</template>
<style scoped>
.profile-page { display: flex; flex-direction: column; gap: 16px; }
.company-audit-notice { display: flex; align-items: flex-start; gap: 12px; padding: 15px 17px; border: 1px solid; }
.company-audit-notice svg { flex: 0 0 21px; margin-top: 1px; }
.company-audit-notice div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.company-audit-notice strong { color: var(--navy); font-size: 13px; }
.company-audit-notice span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.company-audit-notice.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
.qualification-preview-image { display: block; max-width: 100%; max-height: 360px; margin: 0 auto 14px; object-fit: contain; }
.company-audit-notice.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
.profile-summary-card { display: flex; align-items: center; gap: 16px; }
.company-mark { display: grid; width: 58px; height: 58px; flex: 0 0 58px; place-items: center; background: #e7f3f1; color: var(--primary); }
.company-summary { flex: 1; min-width: 0; }
.company-summary > div { display: flex; align-items: center; gap: 10px; }
.company-summary h2 { overflow: hidden; margin: 0; color: var(--navy); font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }
.company-summary p, .qualification-panel header p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
.profile-tabs :deep(.el-tabs__header) { margin-bottom: 14px; padding: 0 16px; border: 1px solid var(--border); background: #fff; }
.profile-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.tab-label { display: inline-flex; align-items: center; gap: 7px; }
.info-panel { padding: 0; }
.profile-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
.profile-grid > div { min-height: 86px; padding: 20px 22px; border-bottom: 1px solid var(--border); }
.profile-grid > div:nth-child(odd) { border-right: 1px solid var(--border); }
.profile-grid dt { color: var(--muted); font-size: 11px; }
.profile-grid dd { margin: 9px 0 0; color: var(--navy); font-size: 14px; font-weight: 650; }
.profile-grid .full-row { grid-column: 1 / -1; border-right: 0; }
.qualification-panel { padding: 0; overflow: hidden; }
.qualification-panel > header { padding: 20px 22px; border-bottom: 1px solid var(--border); }
.qualification-panel h3 { margin: 0; }
.mobile-qualification-list { display: none; }
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
.profile-form h3:not(:first-child) { margin-top: 22px; }
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.form-grid-two .full-row { grid-column: 1 / -1; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
.upload-tip { margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.55; }
.selected-qualification-file { display: flex; width: 100%; min-width: 0; align-items: center; gap: 10px; margin-top: 12px; padding: 12px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
.selected-qualification-file > svg { flex: 0 0 20px; }
.selected-qualification-file > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.selected-qualification-file strong { overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.selected-qualification-file span { color: var(--muted); font-size: 10px; }
.document-preview { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 12px; border: 1px dashed #b8c8c5; background: #f6faf9; color: var(--primary); }
.document-preview > strong { max-width: min(420px, calc(100% - 32px)); overflow-wrap: anywhere; color: var(--navy); text-align: center; }
.document-preview dl { display: grid; width: min(420px, calc(100% - 32px)); grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 6px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.document-preview dl div { min-width: 0; padding: 12px; text-align: center; }
.document-preview dt { color: var(--muted); font-size: 10px; }
.document-preview dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.document-preview > span { max-width: 440px; padding: 0 16px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: center; }
@media (max-width: 720px) {
.profile-summary-card { align-items: flex-start; flex-wrap: wrap; }
.profile-summary-card > .el-button { width: 100%; }
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
.profile-grid > div:nth-child(odd) { border-right: 0; }
.qualification-panel > header { align-items: flex-start; gap: 12px; }
.qualification-panel > header .el-button { flex: 0 0 auto; }
.desktop-qualification-table { display: none; }
.mobile-qualification-list { display: grid; gap: 10px; padding: 10px; }
.mobile-qualification-list article { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-qualification-list article > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.qualification-file-name { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 14px 0; color: var(--primary); }
.qualification-file-name svg { flex: 0 0 16px; }
.qualification-file-name span { min-width: 0; overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.mobile-qualification-list dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0 0 10px; padding-top: 12px; border-top: 1px solid var(--border); }
.mobile-qualification-list dt { color: var(--muted); font-size: 10px; }
.mobile-qualification-list dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 11px; font-weight: 600; }
.document-preview dl { grid-template-columns: 1fr; }
.document-preview dl div + div { border-top: 1px solid var(--border); }
.selected-qualification-file { align-items: flex-start; flex-wrap: wrap; }
.selected-qualification-file > .el-button { margin-left: 30px; }
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowRight, ClipboardCheck, Clock3, FilePlus2, Users } from '@lucide/vue'
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
import { getFirstLegStatusLabel, getFirstLegStatusTone } from '@/utils/firstLegOrder'
const orders = useFirstLegOrdersStore()
const tenant = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE')
const stats = computed(() => [
{ label: '进行中订单', value: String(orders.activeCount), icon: Clock3 },
{ label: '待平台审核', value: String(orders.pendingCount), icon: ClipboardCheck },
{ label: '企业成员', value: String(tenant.activeMemberCount), icon: Users },
])
const recentOrders = computed(() => orders.orders.slice(0, 3))
</script>
<template>
<div class="client-dashboard">
<section class="quick-order-band">
<div><span>头程物流</span><h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2><p>{{ companyApproved ? '企业资料已审核,可正常提交并跟进订单。' : '企业资料待平台审核,当前可以维护并保存订单草稿。' }}</p></div>
<el-button v-if="auth.hasPermission('order:first-leg:list')" type="primary" size="large" @click="$router.push('/orders/first-leg')"><FilePlus2 :size="18" /> {{ auth.hasPermission('order:first-leg:create') ? '开始下单' : '查看订单' }}</el-button>
</section>
<section class="client-stats">
<article v-for="item in stats" :key="item.label"><component :is="item.icon" :size="20" /><div><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></article>
</section>
<section class="client-content-grid">
<article class="client-card"><header><div><span class="section-label">ORDERS</span><h3>最近订单</h3></div><el-button text @click="$router.push('/orders/first-leg')">查看全部 <ArrowRight :size="16" /></el-button></header><div v-if="recentOrders.length" class="recent-orders"><button v-for="order in recentOrders" :key="order.id" type="button" @click="$router.push('/orders/first-leg')"><span><strong>{{ order.orderNo || '未提交草稿' }}</strong><small>{{ order.origin }} → {{ order.destination }}</small></span><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></button></div><div v-else class="client-empty">暂无订单,提交第一笔头程订单后将在这里展示。</div></article>
<article class="client-card"><header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header><dl class="profile-summary"><div><dt>企业名称</dt><dd>{{ tenant.company.companyName }}</dd></div><div><dt>审核状态</dt><dd><el-tag :type="companyApproved ? 'success' : 'warning'" effect="light">{{ companyApproved ? '已通过' : '待审核' }}</el-tag></dd></div><div><dt>当前成员</dt><dd>{{ auth.user?.name }}</dd></div></dl><el-button v-if="auth.hasPermission('company:profile:view')" plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button></article>
</section>
</div>
</template>
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'.*FirstLegOrdersView\.vue/)
})
test('exposes real list, draft, submit, and detail workflow entries without cancellation', () => {
assert.match(source, /function openCreate\(\)/)
assert.match(source, /async function saveDraft\(\)/)
assert.match(source, /function submitOrder\(\)/)
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
assert.match(source, /Promise\.all\(\[store\.load\(\), firstLegPartySource\.load\(\)\]\)/)
assert.match(source, /await store\.loadDetail\(order\.id\)/)
assert.doesNotMatch(source, /cancelOrder|取消订单/)
})
test('blocks order submission before company approval while preserving draft actions', () => {
assert.match(source, /companyApproved/)
assert.match(source, /企业资料待审核,当前仅可保存订单草稿/)
assert.match(source, /暂时无法提交订单/)
assert.match(source, /router\.push\('\/company\/profile'\)/)
assert.match(source, /auth\.hasPermission\('order:first-leg:submit'\)/)
})
test('renders dynamic cargo lines and responsive drawers', () => {
assert.match(source, /v-for="\(item, index\) in form\.items"/)
assert.match(source, /function addCargoItem\(\)/)
assert.match(source, /function removeCargoItem\(index: number\)/)
assert.match(source, /:global\(\.first-leg-editor\).*width: 100% !important/)
assert.match(source, /class="mobile-order-list"/)
assert.match(source, /\.desktop-order-table \{ display: none; \}/)
})
test('keeps primary mobile order actions touch sized', () => {
assert.match(source, /\.order-toolbar-actions :deep\(\.el-button\) \{ min-height: 44px;/)
assert.match(source, /\.editor-actions :deep\(\.el-button\) \{ min-height: 44px;/)
})
test('does not expose arbitrary status mutation controls to customers', () => {
assert.doesNotMatch(source, /setStatusForPreview/)
assert.doesNotMatch(source, /状态更新|更新状态/)
})
test('requires an enabled platform receiving warehouse in the order form', () => {
assert.match(source, /computed\(\(\) => store\.warehouses\)/)
assert.match(source, /availableWarehouses\.value\.length === 0/)
assert.match(source, /label="平台收货仓" required/)
assert.match(source, /v-model="form\.receivingWarehouseId"/)
assert.match(source, /selectedWarehouse\.businessHours/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/)
})
test('collects the backend-required first-leg fields', () => {
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'channelName', 'pickupMethod', 'taxMode', 'destinationType', 'packageType']) {
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
}
})
test('selects enabled counterparties and stores editable order snapshots', () => {
assert.match(source, /firstLegPartySource\.listAvailable\('SHIPPER'\)/)
assert.match(source, /firstLegPartySource\.findAvailable\(id, role\)/)
assert.match(source, /firstLegPartySource\.load\(\)/)
assert.match(source, /v-model="form\.shipperPartyId"/)
assert.match(source, /v-model="form\.consigneePartyId"/)
assert.match(source, /form\.shipperPartySnapshot\.contactName/)
assert.match(source, /form\.consigneePartySnapshot\.address/)
assert.match(source, /只影响本订单快照/)
})
test('shows fulfillment courier information as read-only client detail', () => {
assert.match(source, /履约快递信息/)
assert.match(source, /detailOrder\.expressCompanyName/)
assert.match(source, /detailOrder\.trackingNumber/)
assert.match(source, /客户端仅只读展示/)
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
})
+605
View File
@@ -0,0 +1,605 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AlertTriangle, Box, Building2, CheckCircle2, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Search, Send, Trash2 } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
import { createFirstLegPartySnapshot, type FirstLegPartyRole } from '@/data/firstLegParties'
import { firstLegPartySource } from '@/data/firstLegPartySource'
import type { FirstLegOrder, FirstLegOrderDraft } from '@/types/firstLegOrder'
import {
canClientEditFirstLegOrder,
createEmptyCargoItem,
getFirstLegStatusLabel,
getFirstLegStatusTone,
getFirstLegTransportModeLabel,
summarizeFirstLegCargo,
validateFirstLegOrderDraft,
} from '@/utils/firstLegOrder'
const store = useFirstLegOrdersStore()
const tenantStore = useTenantAdministrationStore()
const auth = useAuthStore()
const router = useRouter()
const companyApproved = computed(() => tenantStore.company.auditStatus === 'ACTIVE')
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses)
const availableShippers = ref(firstLegPartySource.listAvailable('SHIPPER'))
const availableConsignees = ref(firstLegPartySource.listAvailable('CONSIGNEE'))
const filters = reactive({ keyword: '', status: '' as FirstLegOrderStatus | '', transportMode: '' })
const dateRange = ref<[string, string] | []>([])
const editorOpen = ref(false)
const detailOpen = ref(false)
const editingId = ref<string>()
const detailId = ref<string>()
const saving = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
const editorBaseline = ref('')
let cargoSequence = 0
function emptyDraft(): FirstLegOrderDraft {
return {
referenceNo: '',
transportMode: 'FCL',
businessType: 'EXPORT',
origin: '',
destination: '',
receivingWarehouseId: '',
expectedDepartureDate: '',
shipperPartyId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
consigneePartySnapshot: null,
consignee: '',
priority: 'NORMAL',
customerRemark: '',
tradeTerm: 'FOB',
destinationCountryCode: 'US',
destinationWarehouseName: '',
pickupMethod: 'SELF_EXPRESS',
channelCode: 'SEA-US-FBA',
channelName: '美国头程渠道',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
destinationType: 'FBA',
amazonWarehouseCode: '',
shipmentId: '',
items: [createEmptyCargoItem(`new-cargo-${++cargoSequence}`)],
}
}
const form = reactive<FirstLegOrderDraft>(emptyDraft())
const filteredOrders = computed(() => store.orders.filter((order) => {
const keyword = filters.keyword.trim().toLowerCase()
const matchesKeyword = !keyword || [order.orderNo, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
const matchesStatus = !filters.status || order.status === filters.status
const matchesMode = !filters.transportMode || order.transportMode === filters.transportMode
const matchesDate = dateRange.value.length !== 2 || (order.createdAt.slice(0, 10) >= dateRange.value[0] && order.createdAt.slice(0, 10) <= dateRange.value[1])
return matchesKeyword && matchesStatus && matchesMode && matchesDate
}))
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
onMounted(async () => {
try {
await Promise.all([store.load(), firstLegPartySource.load()])
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单与往来单位数据加载失败')
}
})
function replaceForm(draft: FirstLegOrderDraft) {
Object.assign(form, structuredClone(draft))
}
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
return {
referenceNo: order.referenceNo,
transportMode: order.transportMode,
businessType: order.businessType,
origin: order.origin,
destination: order.destination,
receivingWarehouseId: order.receivingWarehouseId,
expectedDepartureDate: order.expectedDepartureDate,
shipperPartyId: order.shipperPartyId,
shipperPartySnapshot: order.shipperPartySnapshot ? structuredClone(order.shipperPartySnapshot) : null,
shipper: order.shipper,
consigneePartyId: order.consigneePartyId,
consigneePartySnapshot: order.consigneePartySnapshot ? structuredClone(order.consigneePartySnapshot) : null,
consignee: order.consignee,
priority: order.priority,
customerRemark: order.customerRemark,
tradeTerm: order.tradeTerm,
destinationCountryCode: order.destinationCountryCode,
destinationWarehouseName: order.destinationWarehouseName,
pickupMethod: order.pickupMethod,
channelCode: order.channelCode,
channelName: order.channelName,
taxMode: order.taxMode,
customsInspection: order.customsInspection,
destinationType: order.destinationType,
amazonWarehouseCode: order.amazonWarehouseCode,
shipmentId: order.shipmentId,
items: structuredClone(order.items),
}
}
function selectParty(role: FirstLegPartyRole, id: string) {
const party = firstLegPartySource.findAvailable(id, role)
if (!party) return
const snapshot = createFirstLegPartySnapshot(party, role)
if (role === 'SHIPPER') {
form.shipperPartyId = party.id
form.shipperPartySnapshot = snapshot
form.shipper = party.name
} else {
form.consigneePartyId = party.id
form.consigneePartySnapshot = snapshot
form.consignee = party.name
}
}
function clearParty(role: FirstLegPartyRole) {
if (role === 'SHIPPER') {
form.shipperPartyId = ''
form.shipperPartySnapshot = null
form.shipper = ''
} else {
form.consigneePartyId = ''
form.consigneePartySnapshot = null
form.consignee = ''
}
}
function selectShipper(id: string) {
selectParty('SHIPPER', id)
}
function selectConsignee(id: string) {
selectParty('CONSIGNEE', id)
}
function clearShipper() {
clearParty('SHIPPER')
}
function clearConsignee() {
clearParty('CONSIGNEE')
}
function openCreate() {
if (availableWarehouses.value.length === 0) {
ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
return
}
editingId.value = undefined
replaceForm(emptyDraft())
editorOpen.value = true
}
function openEdit(order: FirstLegOrder) {
if (!canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return
editingId.value = order.id
replaceForm(draftFromOrder(order))
detailOpen.value = false
editorOpen.value = true
}
async function openDetail(order: FirstLegOrder) {
detailId.value = order.id
detailOpen.value = true
detailLoading.value = true
try {
await store.loadDetail(order.id)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败')
} finally {
detailLoading.value = false
}
}
function addCargoItem() {
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
}
function removeCargoItem(index: number) {
if (form.items.length === 1) {
ElMessage.warning('订单至少需要保留一条货物明细')
return
}
form.items.splice(index, 1)
}
async function saveDraft() {
if (saving.value) return
const errors = validateFirstLegOrderDraft(form)
if (errors.length > 0) {
ElMessage.warning(errors[0])
return
}
saving.value = true
try {
const order = await store.saveDraft(structuredClone(form), tenantStore.company.companyName, editingId.value)
editingId.value = order.id
editorOpen.value = false
ElMessage.success('订单草稿已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
} finally {
saving.value = false
}
}
async function submitOrder() {
if (saving.value) return
if (!companyApproved.value) {
try {
await ElMessageBox.confirm('企业资料审核通过后才能提交订单。您可以继续保存草稿,或前往企业资料核对并补充信息。', '暂时无法提交订单', {
type: 'warning',
confirmButtonText: '前往企业资料',
cancelButtonText: '继续编辑草稿',
})
editorOpen.value = false
await router.push('/company/profile')
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
return
}
const errors = validateFirstLegOrderDraft(form)
if (errors.length > 0) {
ElMessage.warning(errors[0])
return
}
try {
saving.value = true
const order = await store.submit(structuredClone(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus)
editorOpen.value = false
detailId.value = order.id
detailOpen.value = true
ElMessage.success('订单已提交,等待平台审核')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
} finally {
saving.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.transportMode = ''
dateRange.value = []
}
</script>
<template>
<section class="first-leg-page">
<div class="order-eligibility" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
<component :is="companyApproved ? CheckCircle2 : AlertTriangle" :size="20" />
<div><strong>{{ companyApproved ? '企业资料已审核,可正常提交订单' : canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ companyApproved ? '提交后订单将进入平台审核。' : canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' }}</span></div>
<el-button v-if="!companyApproved && auth.hasPermission('company:profile:view')" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
</div>
<div class="order-metrics" aria-label="订单概览">
<article><span>全部订单</span><strong>{{ store.orders.length }}</strong></article>
<article><span>进行中</span><strong>{{ store.activeCount }}</strong></article>
<article><span>待平台审核</span><strong>{{ store.pendingCount }}</strong></article>
</div>
<div class="order-toolbar client-card">
<div class="order-filters">
<el-input v-model="filters.keyword" clearable placeholder="订单号、参考号、路线或品名" aria-label="订单关键词">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态">
<el-option v-for="item in FIRST_LEG_ORDER_STATUSES" :key="item.code" :label="item.label" :value="item.code" />
</el-select>
<el-select v-model="filters.transportMode" clearable placeholder="全部运输方式" aria-label="运输方式">
<el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" />
</el-select>
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
</div>
<div class="order-toolbar-actions">
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div>
</div>
<div v-loading="store.loading" class="client-card order-table-card">
<el-table v-if="filteredOrders.length" class="desktop-order-table" :data="filteredOrders" row-key="id">
<el-table-column label="订单" min-width="190">
<template #default="{ row }">
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
<span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无客户参考号' }}</span>
</template>
</el-table-column>
<el-table-column label="运输路线" min-width="190">
<template #default="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template>
</el-table-column>
<el-table-column label="收货仓" min-width="120" prop="receivingWarehouseName" />
<el-table-column label="货物" min-width="150">
<template #default="{ row }">{{ (row as FirstLegOrder).items[0]?.cargoNameCn || '-' }}<span v-if="(row as FirstLegOrder).items.length > 1" class="order-reference">等 {{ (row as FirstLegOrder).items.length }} 项</span></template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
</el-table-column>
<el-table-column label="负责人" prop="operatorName" width="110" />
<el-table-column label="更新时间" prop="updatedAt" width="155" />
<el-table-column label="操作" width="180">
<template #default="{ row }">
<div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<span class="order-reference">{{ order.referenceNo || '无客户参考号' }}</span>
<dl><div><dt>运输路线</dt><dd>{{ order.origin }} → {{ order.destination }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName }}</dd></div><div><dt>货物</dt><dd>{{ order.items[0]?.cargoNameCn || '-' }}<span v-if="order.items.length > 1">等 {{ order.items.length }} 项</span></dd></div><div><dt>负责人</dt><dd>{{ order.operatorName }}</dd></div></dl>
<footer><span>更新于 {{ order.updatedAt }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
</article>
</div>
<el-empty v-else description="没有符合条件的头程订单">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
</el-empty>
</div>
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close>
<el-form label-position="top" class="order-form">
<section class="order-form-section">
<h3>运输信息</h3>
<div class="order-form-grid">
<el-form-item label="运输方式" required><el-select v-model="form.transportMode"><el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" /></el-select></el-form-item>
<el-form-item label="业务类型" required><el-radio-group v-model="form.businessType"><el-radio-button value="EXPORT">出口</el-radio-button><el-radio-button value="IMPORT">进口</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="起运地" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item>
<el-form-item label="目的地" required><el-input v-model="form.destination" placeholder="例如:洛杉矶" /></el-form-item>
<el-form-item label="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择送货仓库"><el-option v-for="warehouse in availableWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.city}`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="期望出发日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item>
<el-form-item label="客户参考号"><el-input v-model="form.referenceNo" maxlength="100" placeholder="贵司内部单号(选填)" /></el-form-item>
<el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="NORMAL" /><el-option label="紧急" value="URGENT" /><el-option label="加急" value="EXPEDITED" /></el-select></el-form-item>
<el-form-item label="目的国代码" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
<el-form-item label="目的仓名称" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:ONT8" /></el-form-item>
<el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item>
<el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item>
<el-form-item label="提货方式" required><el-select v-model="form.pickupMethod"><el-option label="自送快递" value="SELF_EXPRESS" /><el-option label="上门提货" value="PICKUP" /><el-option label="自送卡车" value="SELF_TRUCK" /></el-select></el-form-item>
<el-form-item label="报关模式" required><el-select v-model="form.taxMode"><el-option label="买单报关" value="BUY_DECLARATION" /><el-option label="退税报关" value="TAX_REFUND" /></el-select></el-form-item>
<el-form-item label="目的仓类型" required><el-select v-model="form.destinationType"><el-option label="FBA" value="FBA" /><el-option label="海外仓" value="OVERSEAS" /><el-option label="私人地址" value="PRIVATE" /></el-select></el-form-item>
<el-form-item label="贸易术语"><el-input v-model="form.tradeTerm" maxlength="16" placeholder="例如:FOB" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="Amazon 仓库编码"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="Shipment ID"><el-input v-model="form.shipmentId" maxlength="64" /></el-form-item>
</div>
<div v-if="selectedWarehouse" class="selected-warehouse"><MapPin :size="17" /><div><strong>{{ selectedWarehouse.name }}</strong><span>{{ selectedWarehouse.province }}{{ selectedWarehouse.city }} · {{ selectedWarehouse.address }}</span><small>收货时间:{{ selectedWarehouse.businessHours }}</small></div></div>
</section>
<section class="order-form-section">
<div class="order-section-heading"><div><h3>收发货单位</h3><p>仅可选择平台已启用的往来单位;下方联系人和地址调整只影响本订单快照。</p></div></div>
<div class="party-editor-grid">
<article class="party-editor">
<header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header>
<el-form-item label="选择发货单位" required>
<el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择已启用的发货单位" @change="selectShipper" @clear="clearShipper">
<el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
</el-select>
</el-form-item>
<div v-if="form.shipperPartySnapshot" class="party-snapshot-fields">
<div class="party-name"><strong>{{ form.shipperPartySnapshot.name }}</strong><span>{{ form.shipperPartySnapshot.code }}</span></div>
<el-form-item label="联系人"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="form.shipperPartySnapshot.email" maxlength="100" /></el-form-item>
<el-form-item label="本单发货地址"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" /></el-form-item>
</div>
<div v-else class="party-empty">选择后可核对本单联系人和发货地址</div>
</article>
<article class="party-editor">
<header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header>
<el-form-item label="选择收货单位" required>
<el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择已启用的收货单位" @change="selectConsignee" @clear="clearConsignee">
<el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
</el-select>
</el-form-item>
<div v-if="form.consigneePartySnapshot" class="party-snapshot-fields">
<div class="party-name"><strong>{{ form.consigneePartySnapshot.name }}</strong><span>{{ form.consigneePartySnapshot.code }}</span></div>
<el-form-item label="联系人"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="form.consigneePartySnapshot.email" maxlength="100" /></el-form-item>
<el-form-item label="本单收货地址"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" /></el-form-item>
</div>
<div v-else class="party-empty">选择后可核对本单联系人和收货地址</div>
</article>
</div>
</section>
<section class="order-form-section">
<div class="order-section-heading"><div><h3>货物明细</h3><p>总件数、重量和体积将在提交时由服务端汇总。</p></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-editor-card">
<header><strong>货物 {{ index + 1 }}</strong><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="16" />删除</el-button></header>
<div class="cargo-form-grid">
<el-form-item label="中文品名" required><el-input v-model="item.cargoNameCn" /></el-form-item>
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" /></el-form-item>
<el-form-item label="客户 SKU"><el-input v-model="item.sku" /></el-form-item>
<el-form-item label="包装类型" required><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item>
<el-form-item label="HS Code"><el-input v-model="item.hsCode" maxlength="20" /></el-form-item>
<el-form-item label="件数" required><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="箱数"><el-input-number v-model="item.packageCount" :min="0" controls-position="right" /></el-form-item>
<el-form-item label="毛重 (kg)" required><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="体积 (m³)" required><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="申报货值"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="币种"><el-input v-model="item.currency" maxlength="3" /></el-form-item>
<el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
<template v-if="form.transportMode === 'FCL'">
<el-form-item label="箱型" required><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
<el-form-item label="柜量" required><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item>
</template>
<el-form-item label="货物备注" class="cargo-remark"><el-input v-model="item.remark" maxlength="300" /></el-form-item>
</div>
</article>
</section>
<section class="order-form-section">
<h3>订单备注</h3>
<el-form-item label="客户备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="平台工作人员可以看到该备注" /></el-form-item>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="editorOpen = false">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companyApproved ? '提交审核' : '提交前需完成资料审核' }}</el-button></div></template>
</el-drawer>
<el-drawer v-model="detailOpen" v-loading="detailLoading" class="first-leg-detail" title="订单详情" size="680px">
<template v-if="detailOrder">
<div class="order-detail-heading">
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin }} → {{ detailOrder.destination }}</p></div>
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
</div>
<dl class="order-detail-grid">
<div><dt>客户参考号</dt><dd>{{ detailOrder.referenceNo || '-' }}</dd></div><div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>发货方</dt><dd>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper }}<span v-if="detailOrder.shipperPartySnapshot" class="detail-subline">{{ detailOrder.shipperPartySnapshot.code }} · {{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }}</span></dd></div>
<div><dt>收货方</dt><dd>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee }}<span v-if="detailOrder.consigneePartySnapshot" class="detail-subline">{{ detailOrder.consigneePartySnapshot.code }} · {{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }}</span></dd></div>
<div><dt>平台收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div><div><dt>负责人</dt><dd>{{ detailOrder.operatorName }}</dd></div>
<div><dt>期望出发</dt><dd>{{ detailOrder.expectedDepartureDate || '-' }}</dd></div><div><dt>ETD / ETA</dt><dd>{{ detailOrder.etd || '-' }} / {{ detailOrder.eta || '-' }}</dd></div>
</dl>
<section class="detail-section">
<h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section"><h3>货物汇总</h3><div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div><ul class="cargo-detail-list"><li v-for="item in detailOrder.items" :key="item.id"><strong>{{ item.cargoNameCn }}</strong><span>{{ item.sku || '无 SKU' }} · {{ item.pieces }} 件 · {{ item.grossWeightKg }} kg</span></li></ul></section>
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description }}</p><span>{{ item.operatorName }}</span></el-timeline-item></el-timeline></section>
</template>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-drawer>
</section>
</template>
<style scoped>
.first-leg-page { display: flex; flex-direction: column; gap: 14px; }
.order-eligibility { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid; }
.order-eligibility svg { flex: 0 0 20px; margin-top: 1px; }
.order-eligibility div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.order-eligibility strong { color: var(--navy); font-size: 13px; }
.order-eligibility span { color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-eligibility.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
.order-eligibility.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
.order-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.order-metrics article { display: flex; align-items: center; justify-content: space-between; min-height: 76px; padding: 16px 20px; border: 1px solid var(--border); background: #fff; }
.order-metrics span { color: var(--muted); font-size: 12px; }
.order-metrics strong { color: var(--navy); font-size: 25px; }
.order-toolbar { display: flex; align-items: stretch; flex-direction: column; gap: 12px; padding: 16px; }
.order-filters { display: grid; width: 100%; grid-template-columns: minmax(190px, 1.35fr) minmax(130px, .65fr) minmax(150px, .75fr) minmax(230px, 1fr); gap: 10px; }
.order-toolbar-actions, .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.order-table-card { padding: 0; overflow: hidden; }
.mobile-order-list { display: none; }
.order-number-link { display: block; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-weight: 700; text-align: left; }
.order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
.order-form { padding: 0 2px 28px; }
.order-form-section + .order-form-section { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border); }
.order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; }
.order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; }
.order-form-grid :deep(.el-select), .order-form-grid :deep(.el-date-editor), .cargo-form-grid :deep(.el-select), .cargo-form-grid :deep(.el-input-number) { width: 100%; }
.selected-warehouse { display: flex; gap: 10px; margin-top: -2px; padding: 12px 14px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
.selected-warehouse svg { flex: 0 0 17px; margin-top: 2px; }
.selected-warehouse div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.selected-warehouse span, .selected-warehouse small { color: var(--muted); font-size: 11px; line-height: 1.5; }
.party-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.party-editor { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
.party-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.party-editor > header span { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--navy); }
.party-editor > header svg { flex: 0 0 17px; color: var(--primary); }
.party-editor > header small { color: var(--muted); font-size: 11px; }
.party-editor :deep(.el-select) { width: 100%; }
.party-snapshot-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.party-name { display: flex; min-width: 0; grid-column: 1 / -1; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.party-name strong { min-width: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.party-name span { flex: 0 0 auto; color: var(--muted); font-size: 10px; }
.party-snapshot-fields .el-form-item:last-child { grid-column: 1 / -1; }
.party-empty { display: grid; min-height: 150px; place-items: center; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; text-align: center; }
.order-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.order-section-heading h3 { margin-bottom: 5px; }
.order-section-heading p { margin: 0; color: var(--muted); font-size: 11px; }
.cargo-editor-card { padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
.cargo-editor-card + .cargo-editor-card { margin-top: 12px; }
.cargo-editor-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.cargo-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 14px; }
.cargo-remark { grid-column: 1 / -1; }
.order-detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.order-detail-heading span { color: var(--muted); font-size: 11px; }
.order-detail-heading h2 { margin: 6px 0; font-size: 21px; }
.order-detail-heading p { margin: 0; color: var(--muted); }
.order-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 18px 0; border: 1px solid var(--border); }
.order-detail-grid div { padding: 13px; border-bottom: 1px solid var(--border); }
.order-detail-grid div:nth-child(odd) { border-right: 1px solid var(--border); }
.order-detail-grid dt { color: var(--muted); font-size: 11px; }
.order-detail-grid dd { margin: 5px 0 0; font-size: 13px; font-weight: 600; }
.detail-subline { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 12px; font-size: 15px; }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }
.express-information strong { overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.express-empty { display: flex; align-items: flex-start; gap: 12px; padding: 15px 16px; border: 1px dashed #cbd5da; color: var(--muted); }
.express-empty svg { flex: 0 0 20px; color: var(--primary); }
.express-empty div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-empty strong { color: var(--navy); font-size: 13px; }
.express-empty span { font-size: 11px; line-height: 1.5; }
.cargo-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); }
.cargo-summary div { display: flex; min-height: 74px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); }
.cargo-summary div:last-child { border-right: 0; }
.cargo-summary span { color: var(--muted); font-size: 11px; }
.cargo-detail-list { margin: 10px 0 0; padding: 0; list-style: none; }
.cargo-detail-list li { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.cargo-detail-list span, :deep(.el-timeline-item__content p), :deep(.el-timeline-item__content > span) { color: var(--muted); }
:deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; }
:deep(.el-timeline-item__content > span) { font-size: 11px; }
@media (max-width: 1100px) { .order-toolbar-actions { align-self: flex-end; } .order-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) {
.order-eligibility { flex-wrap: wrap; }
.order-eligibility .el-button { width: 100%; min-height: 44px; }
.order-metrics { grid-template-columns: 1fr; }
.order-metrics article { min-height: 60px; }
.order-filters, .order-form-grid, .cargo-form-grid, .order-detail-grid, .party-editor-grid, .party-snapshot-fields { grid-template-columns: 1fr; }
.order-toolbar-actions { width: 100%; align-self: stretch; }
.order-toolbar-actions :deep(.el-button) { min-height: 44px; flex: 1; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; }
.mobile-order-list { display: grid; gap: 10px; padding: 10px; }
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mobile-order-card header button { padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; }
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.mobile-order-card dl div { min-width: 0; }
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.mobile-order-card footer > div { display: flex; }
.order-detail-grid div:nth-child(odd) { border-right: 0; }
.party-name, .party-snapshot-fields .el-form-item:last-child { grid-column: auto; }
.express-information { grid-template-columns: auto 1fr; }
.express-information > div:last-child { grid-column: 2; }
.cargo-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cargo-summary div:nth-child(2) { border-right: 0; }
.cargo-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
.cargo-detail-list li { flex-direction: column; }
:global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; }
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, ShieldX } from '@lucide/vue'
import { useAuthStore } from '@/stores/auth'
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
const router = useRouter()
const auth = useAuthStore()
const firstAccessiblePath = computed(() => getFirstAccessiblePath(auth.permissionCodes))
</script>
<template>
<section class="forbidden-state client-card" aria-labelledby="forbidden-title">
<div class="forbidden-icon"><ShieldX :size="30" /></div>
<span class="section-label">403 · ACCESS DENIED</span>
<h2 id="forbidden-title">当前账号无权访问此页面</h2>
<p>页面权限由企业管理员分配。如需处理这项业务,请联系企业管理员调整您的角色权限。</p>
<el-button type="primary" @click="router.replace(firstAccessiblePath)"><ArrowLeft :size="16" />返回可访问页面</el-button>
</section>
</template>
<style scoped>
.forbidden-state { display: grid; min-height: 420px; place-items: center; align-content: center; padding: 48px 24px; text-align: center; }
.forbidden-icon { display: grid; width: 64px; height: 64px; margin-bottom: 18px; place-items: center; background: #f4e9e8; color: #a2413b; }
.forbidden-state h2 { margin: 10px 0 8px; color: var(--navy); font-size: clamp(22px, 3vw, 30px); }
.forbidden-state p { max-width: 520px; margin: 0 0 24px; color: var(--muted); line-height: 1.75; }
@media (max-width: 720px) { .forbidden-state { min-height: 360px; } .forbidden-state :deep(.el-button) { min-height: 44px; } }
</style>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
import { useAuthStore } from '@/stores/auth'
interface LoginForm {
account: string
password: string
remember: boolean
}
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const isDevelopment = import.meta.env.DEV
const developmentHint = isDevelopment
? {
title: '一期联调账号',
message: '本地后端启动后可使用商户账号 merchant_admin 登录;密码请使用后端联调环境提供的初始密码。',
}
: null
const form = reactive<LoginForm>({
account: typeof route.query.account === 'string'
? route.query.account
: isDevelopment ? 'merchant_admin' : '',
password: '',
remember: false,
})
const accountPattern = /^[A-Za-z0-9_]{4,64}$/
const rules: FormRules<LoginForm> = {
account: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ pattern: accountPattern, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
],
}
async function submit() {
if (!formRef.value || loading.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await auth.login(form.account.trim(), form.password, form.remember)
ElMessage.success('登录成功')
await router.replace(String(route.query.redirect || '/'))
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败,请检查账号后重试')
} finally {
loading.value = false
}
}
function showResetHint() {
ElMessage.info('请联系平台客服重置密码')
}
</script>
<template>
<div class="auth-page">
<header class="auth-header">
<RouterLink to="/login" class="auth-brand"><span>CC</span><strong>虫虫出海</strong></RouterLink>
<div>还没有企业账号? <RouterLink to="/register">申请入驻</RouterLink></div>
</header>
<main class="auth-main">
<section class="auth-copy">
<h1>跨境物流业务<br />一个工作台完成</h1>
<p>从企业成员协作到头程订单跟进,统一管理业务与权限。</p>
<div class="auth-facts"><span>企业级权限</span><span>订单全程跟进</span><span>多端业务协同</span></div>
</section>
<section class="auth-card" aria-labelledby="login-title">
<h2 id="login-title">登录客户工作台</h2>
<p>企业注册成功后即可登录;提交订单前需完成企业资料审核</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="用户名" prop="account">
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名" :prefix-icon="UserRound" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
</el-form-item>
<div class="auth-form-options">
<el-checkbox v-model="form.remember">记住我</el-checkbox>
<button type="button" @click="showResetHint">忘记密码?</button>
</div>
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
</el-form>
<div v-if="developmentHint" class="demo-tip" role="note"><strong>{{ developmentHint.title }}</strong><span>{{ developmentHint.message }}</span></div>
</section>
</main>
</div>
</template>
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { KeyRound, Pencil, Plus, RotateCcw, Search, ShieldCheck, UserCheck, UserRoundX } from '@lucide/vue'
import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const keyword = ref('')
const statusFilter = ref<TenantRecordStatus | ''>('')
const editorOpen = ref(false)
const editingId = ref('')
const loading = ref(false)
const saving = ref(false)
const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] })
const filteredMembers = computed(() => store.members.filter((member) => {
const key = keyword.value.trim().toLowerCase()
const matchesKeyword = !key || [member.name, member.account, member.phone, member.email].some((value) => value.toLowerCase().includes(key))
return matchesKeyword && (!statusFilter.value || member.status === statusFilter.value)
}))
onMounted(async () => {
loading.value = true
try {
await Promise.all([store.loadRoles(), store.loadMembers()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业成员加载失败')
} finally {
loading.value = false
}
})
function resetForm() {
editingId.value = ''
Object.assign(form, { name: '', account: '', password: '', phone: '', email: '', roleIds: [] })
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(member: TenantMember) {
editingId.value = member.id
Object.assign(form, { name: member.name, account: member.account, password: '', phone: member.phone, email: member.email, roleIds: [...member.roleIds] })
editorOpen.value = true
}
async function saveMember() {
if (saving.value) return
if (!form.name.trim() || !/^[A-Za-z0-9_.@-]{4,64}$/.test(form.account) || form.roleIds.length === 0) {
ElMessage.warning('请填写姓名、4–64 位合法账号并至少选择一个角色')
return
}
if (form.phone.length > 32 || (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))) {
ElMessage.warning('请检查联系电话长度和邮箱格式')
return
}
if (!editingId.value && (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(form.password))) {
ElMessage.warning('初始密码需为 8–64 位,并同时包含字母和数字')
return
}
saving.value = true
try {
if (editingId.value) {
await store.updateMember(editingId.value, { name: form.name, memberNo: form.account, phone: form.phone, email: form.email, roleIds: [...form.roleIds] })
ElMessage.success('成员信息已更新')
} else {
await store.createMember({ ...form, memberNo: form.account, roleIds: [...form.roleIds] })
ElMessage.success('成员已新增')
}
editorOpen.value = false
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '成员保存失败,请重试')
} finally {
saving.value = false
}
}
async function toggleStatus(member: TenantMember) {
const nextLabel = member.status === 'ACTIVE' ? '停用' : '启用'
try {
await ElMessageBox.confirm(`${nextLabel}后将立即影响该成员的客户工作台访问。`, `${nextLabel}成员 ${member.name}`, { type: 'warning', confirmButtonText: nextLabel, cancelButtonText: '取消' })
await store.toggleMemberStatus(member.id)
ElMessage.success(`成员已${nextLabel}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '操作失败')
}
}
async function resetPassword(member: TenantMember) {
try {
const result = await ElMessageBox.prompt('请输入 8–64 位、同时包含字母和数字的新密码。重置后成员需要重新登录。', `重置 ${member.name} 的密码`, {
confirmButtonText: '确认重置',
cancelButtonText: '取消',
inputType: 'password',
inputPlaceholder: '请输入新密码',
inputValidator: (value) => /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(value) || '密码需为 8–64 位,并同时包含字母和数字',
})
await store.resetMemberPassword(member.id, result.value)
ElMessage.success('成员密码已重置')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '密码重置失败,请重试')
}
}
function clearFilters() {
keyword.value = ''
statusFilter.value = ''
}
</script>
<template>
<section v-loading="loading" class="members-page">
<div v-if="store.membersLoadedFromApi" class="member-contract-notice client-card">成员资料、角色回显、新增、编辑、启停和密码重置均使用当前企业的真实接口;企业所有者保护和租户隔离由服务端最终校验。</div>
<div class="member-metrics">
<article><UserCheck :size="20" /><div><span>成员总数</span><strong>{{ store.members.length }}</strong></div></article>
<article><ShieldCheck :size="20" /><div><span>正常成员</span><strong>{{ store.activeMemberCount }}</strong></div></article>
<article><UserRoundX :size="20" /><div><span>已停用</span><strong>{{ store.members.length - store.activeMemberCount }}</strong></div></article>
</div>
<div class="member-toolbar client-card">
<div class="member-filters">
<el-input v-model="keyword" clearable placeholder="姓名、账号、手机或邮箱"><template #prefix><Search :size="16" /></template></el-input>
<el-select v-model="statusFilter" clearable placeholder="全部状态"><el-option label="正常" value="ACTIVE" /><el-option label="已停用" value="INACTIVE" /></el-select>
<el-button @click="clearFilters"><RotateCcw :size="16" />重置</el-button>
</div>
<el-button v-if="auth.hasPermission('company:member:write')" type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button>
</div>
<div class="client-card member-table-card">
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id">
<el-table-column label="成员" min-width="180"><template #default="{ row }"><div class="member-identity"><span>{{ row.name.slice(0, 1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column>
<el-table-column label="联系方式" min-width="190"><template #default="{ row }"><div class="member-contact"><span>{{ row.phone }}</span><small>{{ row.email || '-' }}</small></div></template></el-table-column>
<el-table-column label="角色" min-width="180"><template #default="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames(row.roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames(row.roleIds).length === 0" class="readonly-action">未分配</span></div></template></el-table-column>
<el-table-column prop="lastLoginAt" label="登录记录" min-width="150" />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !row.isAdministrator" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template></el-table-column>
</el-table>
<div v-if="filteredMembers.length" class="mobile-member-list">
<article v-for="member in filteredMembers" :key="member.id">
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
<footer><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></footer>
</article>
</div>
<div v-else class="client-empty">没有匹配的企业成员,请调整筛选条件。</div>
</div>
<el-drawer v-model="editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm">
<el-form label-position="top">
<el-form-item label="成员姓名" required><el-input v-model="form.name" maxlength="64" /></el-form-item>
<el-form-item label="登录账号" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
<el-form-item v-if="!editingId" label="初始密码" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
<div class="member-form-grid"><el-form-item label="联系电话"><el-input v-model="form.phone" maxlength="32" placeholder="选填" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" maxlength="128" placeholder="选填" /></el-form-item></div>
<el-form-item label="成员角色" required><el-select v-model="form.roleIds" multiple placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
</el-drawer>
</section>
</template>
<style scoped>
.members-page { display: flex; flex-direction: column; gap: 14px; }
.member-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
.member-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.member-metrics article { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border: 1px solid var(--border); background: #fff; color: var(--primary); }
.member-metrics article div { display: flex; flex-direction: column; gap: 2px; }
.member-metrics span { color: var(--muted); font-size: 11px; }
.member-metrics strong { color: var(--navy); font-size: 23px; }
.member-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
.member-filters { display: grid; flex: 1; grid-template-columns: minmax(210px, 1fr) 150px auto; gap: 10px; }
.member-table-card { padding: 0; overflow: hidden; }
.member-identity { display: flex; align-items: center; gap: 10px; }
.member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
.member-identity > div, .member-contact { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.member-identity small, .member-contact small { color: var(--muted); font-size: 10px; }
.role-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.mobile-member-list { display: none; }
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.member-form-tip { display: flex; gap: 10px; padding: 13px; border-left: 3px solid var(--primary); background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 900px) { .member-toolbar { align-items: stretch; flex-direction: column; } .member-toolbar > .el-button { align-self: flex-end; } }
@media (max-width: 720px) {
.member-metrics { grid-template-columns: 1fr; }
.member-filters, .member-form-grid { grid-template-columns: 1fr; }
.member-toolbar > .el-button { width: 100%; }
.desktop-member-table { display: none; }
.mobile-member-list { display: grid; gap: 10px; padding: 10px; }
.mobile-member-list article { padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-member-list header { display: flex; align-items: center; justify-content: space-between; }
.mobile-member-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
.mobile-member-list dl .full { grid-column: 1 / -1; }
.mobile-member-list dt { color: var(--muted); font-size: 10px; }
.mobile-member-list dd { margin: 4px 0 0; font-size: 12px; }
.mobile-member-list footer { display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
}
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Construction, Plus, Search } from '@lucide/vue'
const route = useRoute()
const title = computed(() => route.meta.title || '功能模块')
const description = computed(() => route.meta.description || '')
</script>
<template>
<section class="module-stack">
<div class="module-actions"><el-input placeholder="输入关键词搜索" clearable><template #prefix><Search :size="17" /></template></el-input><el-button type="primary"><Plus :size="17" /> 新增</el-button></div>
<div class="client-card module-empty"><div class="module-empty-icon"><Construction :size="28" /></div><h2>{{ title }}</h2><p>{{ description }}</p><el-tag effect="plain">页面骨架已完成,业务接口待接入</el-tag></div>
</section>
</template>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Check, LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
import { TENANT_PERMISSION_GROUPS, useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const route = useRoute()
const initialRoleId = typeof route.query.role === 'string' && store.roles.some((role) => role.id === route.query.role) ? route.query.role : store.roles[0]?.id ?? ''
const selectedRoleId = ref(initialRoleId)
const selectedCodes = ref<string[]>([])
const savedCodes = ref<string[]>([])
const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value))
const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE'))
const hasChanges = computed(() => [...selectedCodes.value].sort().join('|') !== [...savedCodes.value].sort().join('|'))
function loadRolePermissions() {
const codes = selectedRole.value?.permissionCodes ?? []
selectedCodes.value = [...codes]
savedCodes.value = [...codes]
}
watch(selectedRoleId, loadRolePermissions, { immediate: true })
onMounted(async () => {
try {
await store.loadRoles()
if (!store.roles.some((role) => role.id === selectedRoleId.value)) selectedRoleId.value = store.roles[0]?.id ?? ''
loadRolePermissions()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '角色列表加载失败')
}
})
function groupCodes(groupKey: string) {
return TENANT_PERMISSION_GROUPS.find((group) => group.key === groupKey)?.items.map((item) => item.code) ?? []
}
function groupChecked(groupKey: string) {
const codes = groupCodes(groupKey)
return codes.length > 0 && codes.every((code) => selectedCodes.value.includes(code))
}
function groupIndeterminate(groupKey: string) {
const codes = groupCodes(groupKey)
const count = codes.filter((code) => selectedCodes.value.includes(code)).length
return count > 0 && count < codes.length
}
function toggleGroup(groupKey: string, checked: boolean) {
const codes = groupCodes(groupKey)
if (checked) selectedCodes.value = [...new Set([...selectedCodes.value, ...codes])]
else selectedCodes.value = selectedCodes.value.filter((code) => !codes.includes(code))
}
function resetPermissions() {
selectedCodes.value = [...savedCodes.value]
}
async function savePermissions() {
if (!selectedRole.value || !editable.value) return
if (!selectedCodes.value.includes('account:profile:view')) selectedCodes.value.unshift('account:profile:view')
if (!selectedCodes.value.includes('dashboard:view')) selectedCodes.value.unshift('dashboard:view')
try {
await store.setRolePermissions(selectedRole.value.id, selectedCodes.value)
savedCodes.value = [...selectedCodes.value]
ElMessage.success('角色菜单及操作权限已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
}
}
</script>
<template>
<section class="permissions-page">
<aside class="client-card role-selector">
<header><div><span class="section-label">ROLES</span><h3>选择角色</h3></div></header>
<button v-for="role in store.roles" :key="role.id" type="button" :class="{ active: role.id === selectedRoleId }" @click="selectedRoleId = role.id">
<span class="role-select-icon"><LockKeyhole v-if="role.builtin" :size="17" /><ShieldCheck v-else :size="17" /></span>
<span><strong>{{ role.name }}</strong><small>{{ role.code }}</small></span>
<el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="plain" size="small">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag>
</button>
</aside>
<div class="permission-content">
<div class="permission-heading client-card">
<div><span class="section-label">PERMISSIONS</span><h2>{{ selectedRole?.name }}</h2><p>{{ selectedRole?.description }}</p></div>
<div class="permission-actions"><el-button :disabled="!editable || !hasChanges" @click="resetPermissions"><RotateCcw :size="16" />撤销修改</el-button><el-button type="primary" :disabled="!editable || !hasChanges" @click="savePermissions"><Save :size="16" />保存权限</el-button></div>
</div>
<div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>企业管理员为内置角色,始终拥有全部客户工作台权限,不允许修改。</span></div>
<div v-else-if="selectedRole?.status === 'INACTIVE'" class="permission-notice warning"><span>该角色已停用,请先启用角色后再调整权限。</span></div>
<div v-else class="permission-notice"><ShieldCheck :size="18" /><span>保存时会全量替换该角色的权限,请确认所有需要保留的菜单和操作均已勾选。</span></div>
<div class="permission-groups">
<article v-for="group in TENANT_PERMISSION_GROUPS" :key="group.key" class="client-card permission-group">
<header>
<el-checkbox :model-value="groupChecked(group.key)" :indeterminate="groupIndeterminate(group.key)" :disabled="!editable" @change="toggleGroup(group.key, Boolean($event))"><strong>{{ group.label }}</strong></el-checkbox>
<span>{{ group.items.filter((item) => selectedCodes.includes(item.code)).length }} / {{ group.items.length }}</span>
</header>
<el-checkbox-group v-model="selectedCodes" :disabled="!editable">
<label v-for="item in group.items" :key="item.code" class="permission-item" :class="{ selected: selectedCodes.includes(item.code) }">
<el-checkbox :value="item.code"><span class="permission-check"><Check :size="14" /></span></el-checkbox>
<span><strong>{{ item.label }}</strong><small>{{ item.description }}</small><code>{{ item.code }}</code></span>
</label>
</el-checkbox-group>
</article>
</div>
</div>
</section>
</template>
<style scoped>
.permissions-page { display: grid; grid-template-columns: 250px minmax(0, 1fr); align-items: start; gap: 14px; }
.role-selector { position: sticky; top: 84px; padding: 12px; }
.role-selector header { padding: 8px 8px 12px; }
.role-selector h3 { margin-bottom: 0; }
.role-selector > button { display: flex; width: 100%; min-height: 58px; align-items: center; gap: 9px; margin: 3px 0; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--navy); cursor: pointer; text-align: left; }
.role-selector > button:hover, .role-selector > button.active { background: #edf6f4; }
.role-selector > button.active { color: var(--primary); }
.role-select-icon { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; background: #f0f5f4; }
.role-selector > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.role-selector strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.role-selector small { color: var(--muted); font-size: 9px; }
.permission-content { display: flex; min-width: 0; flex-direction: column; gap: 12px; }
.permission-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.permission-heading h2 { margin: 5px 0; font-size: 19px; }
.permission-heading p { margin: 0; color: var(--muted); font-size: 11px; }
.permission-actions { display: flex; gap: 8px; }
.permission-notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border-left: 3px solid var(--primary); background: #edf6f4; color: #45615d; font-size: 11px; }
.permission-notice.warning { border-left-color: #d97706; background: #fff7ed; color: #9a5a09; }
.permission-groups { display: grid; gap: 12px; }
.permission-group { padding: 0; overflow: hidden; }
.permission-group > header { padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; }
.permission-group > header > span { color: var(--muted); font-size: 11px; }
.permission-group :deep(.el-checkbox-group) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.permission-item { display: flex; min-height: 92px; align-items: flex-start; gap: 9px; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; }
.permission-item:nth-child(odd) { border-right: 1px solid var(--border); }
.permission-item.selected { background: #f5faf9; }
.permission-item :deep(.el-checkbox__label) { display: none; }
.permission-check { display: none; }
.permission-item > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.permission-item strong { color: var(--navy); font-size: 12px; }
.permission-item small { color: var(--muted); font-size: 10px; line-height: 1.5; }
.permission-item code { color: #86939a; font-size: 9px; }
@media (max-width: 900px) { .permissions-page { grid-template-columns: 1fr; } .role-selector { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .role-selector header { grid-column: 1 / -1; } }
@media (max-width: 720px) { .permission-heading { align-items: flex-start; flex-direction: column; } .permission-actions { width: 100%; } .permission-actions .el-button { flex: 1; } .permission-group :deep(.el-checkbox-group) { grid-template-columns: 1fr; } .permission-item:nth-child(odd) { border-right: 0; } }
@media (max-width: 480px) { .role-selector { grid-template-columns: 1fr; } .role-selector header { grid-column: auto; } }
</style>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, Building2, CheckCircle2, Mail, Phone } from '@lucide/vue'
import { registerMerchant } from '@/api/auth'
interface RegisterForm {
username: string
companyName: string
displayName: string
legalPerson: string
legalPhone: string
contactEmail: string
countryCode: string
address: string
password: string
agreement: boolean
}
const router = useRouter()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const submitted = ref(false)
const submittedAt = ref('')
const form = reactive<RegisterForm>({ username: '', companyName: '', displayName: '', legalPerson: '', legalPhone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
const rules: FormRules<RegisterForm> = {
username: [
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_]{4,64}$/, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
],
companyName: [
{ required: true, message: '请输入企业名称', trigger: 'blur' },
{ min: 2, max: 100, message: '企业名称长度应为 2–100 个字符', trigger: 'blur' },
],
displayName: [
{ required: true, message: '请输入联系人姓名', trigger: 'blur' },
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
],
legalPerson: [
{ required: true, message: '请输入法定代表人', trigger: 'blur' },
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
],
contactEmail: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
],
legalPhone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入有效的 11 位手机号', trigger: 'blur' },
],
password: [
{ required: true, message: '请设置登录密码', trigger: 'blur' },
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
],
agreement: [{ validator: (_rule, value, callback) => value ? callback() : callback(new Error('请先阅读并同意服务协议和隐私政策')), trigger: 'change' }],
}
async function submit() {
if (!formRef.value || submitting.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await registerMerchant({
username: form.username.trim(),
password: form.password,
displayName: form.displayName.trim(),
companyName: form.companyName.trim(),
legalPerson: form.legalPerson.trim(),
legalPhone: form.legalPhone.trim(),
contactEmail: form.contactEmail.trim() || undefined,
countryCode: form.countryCode,
address: form.address.trim() || undefined,
})
submittedAt.value = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date())
submitted.value = true
ElMessage.success('企业账号注册成功')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '注册失败,请检查信息后重试')
} finally {
submitting.value = false
}
}
function showDocument(name: string) {
ElMessage.info(`${name}将在正式接入前由业务方确认内容`)
}
</script>
<template>
<div class="register-page">
<header class="register-header">
<RouterLink to="/login"><ArrowLeft :size="18" /> 返回登录</RouterLink>
<div class="auth-brand"><span>CC</span><strong>虫虫出海</strong></div>
</header>
<main class="register-main">
<template v-if="!submitted">
<div class="register-title"><div><Building2 :size="22" /></div><span><h1>企业注册</h1><p>注册成功后即可登录并使用客户工作台,提交订单前需完成企业资料审核。</p></span></div>
<el-form ref="formRef" :model="form" :rules="rules" class="register-form" label-position="top" size="large" @submit.prevent="submit">
<div class="form-grid">
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="4–64 位字母、数字或下划线" /></el-form-item>
<el-form-item label="企业名称" prop="companyName" required><el-input v-model="form.companyName" maxlength="100" placeholder="请输入企业营业执照上的名称" /></el-form-item>
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="法定代表人" prop="legalPerson" required><el-input v-model="form.legalPerson" maxlength="64" placeholder="请输入营业执照法定代表人" /></el-form-item>
<el-form-item label="法人手机号" prop="legalPhone" required><el-input v-model="form.legalPhone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入手机号" :prefix-icon="Phone" /></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item>
<el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
<el-form-item label="企业地址" prop="address" class="full-row"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item>
<el-form-item label="登录密码" prop="password" required><el-input v-model="form.password" type="password" autocomplete="new-password" maxlength="72" show-password placeholder="请设置 8–72 位密码" /></el-form-item>
</div>
<el-form-item prop="agreement" class="agreement-item">
<div class="agreement-control">
<el-checkbox v-model="form.agreement">我已阅读并同意</el-checkbox>
<span><button type="button" @click="showDocument('《用户服务协议》')">《用户服务协议》</button> 和 <button type="button" @click="showDocument('《隐私政策》')">《隐私政策》</button></span>
</div>
</el-form-item>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照并等待平台审核,审核通过后才能提交订单。</p>
<div class="register-actions"><el-button @click="$router.push('/login')">取消</el-button><el-button native-type="submit" type="primary" :loading="submitting">注册企业账号</el-button></div>
</el-form>
</template>
<section v-else class="registration-result" aria-live="polite">
<CheckCircle2 :size="42" />
<h1>企业账号注册成功</h1>
<p><strong>{{ form.username }}</strong> 现在可以直接登录客户工作台,无需等待注册审核。</p>
<dl><div><dt>企业名称</dt><dd>{{ form.companyName }}</dd></div><div><dt>登录账号</dt><dd>{{ form.username }}</dd></div><div><dt>注册时间</dt><dd>{{ submittedAt }}</dd></div><div><dt>账号状态</dt><dd><el-tag type="success" effect="light">可登录</el-tag></dd></div></dl>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成一步</strong><span>登录后请在“企业资料”补充公司和资质信息,等待平台人工审核通过。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
</section>
</main>
</div>
</template>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { LockKeyhole, Pencil, Plus, Search, ShieldCheck, Trash2, Users } from '@lucide/vue'
import { useTenantAdministrationStore, type TenantRole } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const router = useRouter()
const keyword = ref('')
const editorOpen = ref(false)
const editingId = ref('')
const loading = ref(false)
const form = reactive({ name: '', code: '', description: '' })
const filteredRoles = computed(() => {
const key = keyword.value.trim().toLowerCase()
return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key)))
})
function resetForm() {
editingId.value = ''
Object.assign(form, { name: '', code: '', description: '' })
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(role: TenantRole) {
editingId.value = role.id
Object.assign(form, { name: role.name, code: role.code, description: role.description })
editorOpen.value = true
}
onMounted(async () => {
loading.value = true
try {
await store.loadRoles()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '角色列表加载失败')
} finally {
loading.value = false
}
})
async function saveRole() {
if (!form.name || !form.code || !form.description) {
ElMessage.warning('请完整填写角色名称、编码和说明')
return
}
try {
if (editingId.value) store.updateRole(editingId.value, { name: form.name, description: form.description })
else await store.createRole({ ...form, code: form.code.trim().toUpperCase() })
editorOpen.value = false
ElMessage.success(editingId.value ? '角色已更新' : '角色已新增')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
}
}
async function toggleStatus(role: TenantRole) {
const action = role.status === 'ACTIVE' ? '停用' : '启用'
try {
await ElMessageBox.confirm(`${action}后将影响拥有该角色的成员权限。`, `${action}角色 ${role.name}`, { type: 'warning', confirmButtonText: action, cancelButtonText: '取消' })
store.toggleRoleStatus(role.id)
ElMessage.success(`角色已${action}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '操作失败')
}
}
async function removeRole(role: TenantRole) {
try {
await ElMessageBox.confirm('删除后不能恢复,仅未被成员使用的自定义角色可以删除。', `删除角色 ${role.name}`, { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
store.deleteRole(role.id)
ElMessage.success('角色已删除')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function assignPermissions(role: TenantRole) {
router.push({ name: 'permissions', query: { role: role.id } })
}
</script>
<template>
<section v-loading="loading" class="roles-page">
<div class="role-guidance client-card"><ShieldCheck :size="22" /><div><strong>角色用于约束企业内部成员可见菜单和可执行操作</strong><span>企业管理员为系统内置角色,不能停用或删除;平台后台权限不会出现在客户工作台。</span></div></div>
<div v-if="store.rolesLoadedFromApi" class="role-contract-notice client-card">角色列表、新增和已授权权限回显已接入后端;编辑、启停、删除路径尚未进入当前 Gateway OpenAPI,相关写操作暂不开放。</div>
<div class="role-toolbar client-card">
<el-input v-model="keyword" clearable placeholder="搜索角色名称、编码或说明"><template #prefix><Search :size="16" /></template></el-input>
<el-button v-if="auth.hasPermission('company:role:write')" type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button>
</div>
<div v-if="filteredRoles.length" class="role-grid">
<article v-for="role in filteredRoles" :key="role.id" class="client-card role-card" :class="{ inactive: role.status === 'INACTIVE' }">
<header><div class="role-icon"><LockKeyhole v-if="role.builtin" :size="20" /><ShieldCheck v-else :size="20" /></div><div class="role-heading"><div><h3>{{ role.name }}</h3><el-tag v-if="role.builtin" effect="plain">内置</el-tag><el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></div><code>{{ role.code }}</code></div></header>
<p>{{ role.description }}</p>
<dl><div><dt><Users :size="15" />成员数</dt><dd>{{ store.roleMemberCount(role.id) }}</dd></div><div><dt><ShieldCheck :size="15" />权限数</dt><dd>{{ role.permissionCodes.length }}</dd></div></dl>
<footer><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" @click="assignPermissions(role)">{{ auth.hasPermission('company:permission:assign') && !role.builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="!store.rolesLoadedFromApi && auth.hasPermission('company:role:write')" link type="primary" @click="openEdit(role)"><Pencil :size="15" />编辑</el-button><el-button v-if="!store.rolesLoadedFromApi && auth.hasPermission('company:role:write') && !role.builtin" link :type="role.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStatus(role)">{{ role.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="!store.rolesLoadedFromApi && auth.hasPermission('company:role:write') && !role.builtin" link type="danger" @click="removeRole(role)"><Trash2 :size="15" />删除</el-button></footer>
</article>
</div>
<div v-else class="client-card client-empty">没有匹配的企业角色。</div>
<el-drawer v-model="editorOpen" :title="editingId ? '编辑角色' : '新增角色'" size="min(500px, 94vw)" @closed="resetForm">
<el-form label-position="top">
<el-form-item label="角色名称" required><el-input v-model="form.name" maxlength="30" /></el-form-item>
<el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item>
<el-form-item label="角色说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit /></el-form-item>
<div class="role-form-tip">保存后请前往“菜单权限”为角色分配可见菜单和操作权限。</div>
</el-form>
<template #footer><div class="drawer-actions"><el-button @click="editorOpen = false">取消</el-button><el-button type="primary" @click="saveRole">保存</el-button></div></template>
</el-drawer>
</section>
</template>
<style scoped>
.roles-page { display: flex; flex-direction: column; gap: 14px; }
.role-guidance { display: flex; align-items: center; gap: 13px; padding: 17px 20px; border-left: 4px solid var(--primary); color: var(--primary); }
.role-guidance > div { display: flex; flex-direction: column; gap: 4px; }
.role-guidance strong { color: var(--navy); font-size: 13px; }
.role-guidance span { color: var(--muted); font-size: 11px; }
.role-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
.role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
.role-toolbar .el-input { max-width: 360px; }
.role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.role-card { display: flex; min-width: 0; flex-direction: column; padding: 20px; }
.role-card.inactive { opacity: .72; }
.role-card header { justify-content: flex-start; gap: 12px; }
.role-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: #e7f3f1; color: var(--primary); }
.role-heading { min-width: 0; }
.role-heading > div { display: flex; align-items: center; gap: 7px; }
.role-heading h3 { margin: 0; font-size: 16px; }
.role-heading code { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; }
.role-card > p { flex: 1; min-height: 42px; margin: 18px 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.role-card dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; border: 1px solid var(--border); }
.role-card dl div { display: flex; align-items: center; justify-content: space-between; padding: 11px 13px; }
.role-card dl div:first-child { border-right: 1px solid var(--border); }
.role-card dt { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; }
.role-card dd { margin: 0; color: var(--navy); font-weight: 750; }
.role-card footer { display: flex; justify-content: flex-end; gap: 4px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); flex-wrap: wrap; }
.role-form-tip { padding: 12px; background: #f2f7f6; color: var(--muted); font-size: 11px; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 800px) { .role-grid { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .role-toolbar { align-items: stretch; flex-direction: column; } .role-toolbar .el-input { max-width: none; } .role-toolbar .el-button { width: 100%; } }
</style>
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
const profileSource = fs.readFileSync(new URL('./CompanyProfileView.vue', import.meta.url), 'utf8')
const membersSource = fs.readFileSync(new URL('./MembersView.vue', import.meta.url), 'utf8')
const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8')
const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8')
const storeSource = fs.readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8')
test('routes tenant administration modules to implemented views instead of the placeholder', () => {
assert.match(routerSource, /company\/profile'.*CompanyProfileView\.vue/)
assert.match(routerSource, /organization\/members'.*MembersView\.vue/)
assert.match(routerSource, /organization\/roles'.*RolesView\.vue/)
assert.match(routerSource, /organization\/permissions'.*PermissionsView\.vue/)
assert.doesNotMatch(routerSource, /ModuleView\.vue/)
})
test('implements company details, contact information and qualification workflow', () => {
assert.match(profileSource, /企业基本信息/)
assert.match(profileSource, /联系信息/)
assert.match(profileSource, /资质文件/)
assert.match(profileSource, /async function saveProfile\(\)/)
assert.match(profileSource, /function submitQualification\(\)/)
assert.match(profileSource, /validateQualificationFile/)
assert.match(profileSource, /function removeSelectedFile\(\)/)
assert.match(profileSource, /qualificationSubmitting/)
assert.match(profileSource, /class="mobile-qualification-list"/)
assert.match(profileSource, /downloadQualificationImage/)
assert.match(profileSource, /URL\.createObjectURL/)
assert.match(profileSource, /URL\.revokeObjectURL/)
})
test('implements member list, role assignment, password reset and status control', () => {
assert.match(membersSource, /async function saveMember\(\)/)
assert.match(membersSource, /function resetPassword\(member: TenantMember\)/)
assert.match(membersSource, /function toggleStatus\(member: TenantMember\)/)
assert.match(membersSource, /v-model="form\.roleIds" multiple/)
assert.match(membersSource, /class="mobile-member-list"/)
assert.match(membersSource, /store\.loadMembers\(\)/)
assert.match(membersSource, /await store\.resetMemberPassword/)
assert.match(membersSource, /await store\.toggleMemberStatus/)
assert.match(membersSource, /v-model="form\.password"/)
})
test('protects built-in roles and exposes role permission assignment', () => {
assert.match(rolesSource, /auth\.hasPermission\('company:role:write'\) && !role\.builtin/)
assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/)
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
assert.match(rolesSource, /store\.loadRoles\(\)/)
assert.match(rolesSource, /await store\.createRole/)
})
test('shows company audit status and applies button permissions', () => {
assert.match(profileSource, /companyApproved/)
assert.match(profileSource, /企业资料正在等待平台审核/)
assert.match(profileSource, /auth\.hasPermission\('company:profile:edit'\)/)
assert.match(membersSource, /auth\.hasPermission\('company:member:write'\)/)
assert.match(permissionsSource, /auth\.hasPermission\('company:permission:assign'\)/)
assert.match(storeSource, /auditStatus: 'PROFILE_REQUIRED'/)
assert.match(storeSource, /loadCompanyContext/)
})
test('implements grouped menu and action permission editing', () => {
assert.match(permissionsSource, /TENANT_PERMISSION_GROUPS/)
assert.match(permissionsSource, /function toggleGroup\(groupKey: string, checked: boolean\)/)
assert.match(permissionsSource, /function savePermissions\(\)/)
assert.match(permissionsSource, /内置角色,始终拥有全部客户工作台权限/)
assert.match(permissionsSource, /全量替换/)
assert.match(permissionsSource, /await store\.setRolePermissions/)
assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/)
})
test('keeps tenant administration state scoped to the client store', () => {
assert.match(storeSource, /defineStore\('tenant-administration'/)
assert.match(storeSource, /function createMember/)
assert.match(storeSource, /function createRole/)
assert.match(storeSource, /function setRolePermissions/)
assert.doesNotMatch(storeSource, /tenantId\s*:/)
})