优化
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 549 KiB |
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2 } from '@lucide/vue'
|
||||
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import authOcean from '@/assets/brand/auth-ocean.jpg'
|
||||
|
||||
defineProps<{
|
||||
switchText: string
|
||||
switchLabel: string
|
||||
switchTo: string
|
||||
visualTitle: string
|
||||
visualDescription: string
|
||||
features: string[]
|
||||
variant?: 'login' | 'register'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<header class="auth-header">
|
||||
<RouterLink to="/login" class="auth-brand" aria-label="柏悦物流客户工作台">
|
||||
<img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
|
||||
</RouterLink>
|
||||
<div class="auth-header-action">
|
||||
<span>{{ switchText }}</span>
|
||||
<RouterLink :to="switchTo">{{ switchLabel }}</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="auth-stage">
|
||||
<div class="auth-panel" :class="`auth-panel--${variant || 'login'}`">
|
||||
<section class="auth-visual" aria-label="柏悦物流服务介绍">
|
||||
<img :src="authOcean" alt="集装箱码头与跨境物流作业场景" />
|
||||
<div class="auth-visual-shade" aria-hidden="true"></div>
|
||||
<div class="auth-visual-content">
|
||||
<div>
|
||||
<h1>{{ visualTitle }}</h1>
|
||||
<p>{{ visualDescription }}</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="feature in features" :key="feature">
|
||||
<CheckCircle2 :size="19" aria-hidden="true" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="auth-form-panel">
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ 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 authShellSource = readFileSync(new URL('../components/AuthShell.vue', import.meta.url), 'utf8')
|
||||
const legalSource = readFileSync(new URL('./LegalDocumentView.vue', import.meta.url), 'utf8')
|
||||
const legalDocumentsSource = readFileSync(new URL('../legalDocuments.ts', import.meta.url), 'utf8')
|
||||
const authRoutesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
||||
@@ -12,6 +13,22 @@ const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url)
|
||||
const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8')
|
||||
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
|
||||
const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8')
|
||||
const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8')
|
||||
|
||||
test('uses one prototype-inspired responsive shell for login and registration', () => {
|
||||
assert.match(loginSource, /<AuthShell/)
|
||||
assert.match(registerSource, /<AuthShell/)
|
||||
assert.match(authShellSource, /auth-ocean\.jpg/)
|
||||
assert.match(authShellSource, /class="auth-visual"/)
|
||||
assert.match(stylesSource, /\.auth-panel \{[^}]*grid-template-columns:/s)
|
||||
assert.match(stylesSource, /@media \(max-width: 960px\)[\s\S]*?\.auth-visual \{ display: none; \}/)
|
||||
assert.match(stylesSource, /@media \(max-width: 720px\)[\s\S]*?\.register-form-grid \{ grid-template-columns: 1fr; \}/)
|
||||
})
|
||||
|
||||
test('groups the full registration contract without adding out-of-scope sign-in methods', () => {
|
||||
for (const section of ['账号信息', '企业信息', '联系信息']) assert.match(registerSource, new RegExp(section))
|
||||
assert.doesNotMatch(loginSource + registerSource, /微信登录|企业微信登录|短信验证码|邮箱验证码|lang-switcher/)
|
||||
})
|
||||
|
||||
test('matches the backend username login contract', () => {
|
||||
assert.match(loginSource, /label="用户名"/)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { nextTick, 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 baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import { useAuthStore } from '@/modules/auth/store/auth'
|
||||
import AuthShell from '@/modules/auth/components/AuthShell.vue'
|
||||
|
||||
interface LoginForm {
|
||||
account: string
|
||||
@@ -40,7 +40,11 @@ const rules: FormRules<LoginForm> = {
|
||||
async function submit() {
|
||||
if (!formRef.value || loading.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
if (!valid) {
|
||||
await nextTick()
|
||||
document.querySelector<HTMLElement>('.auth-form-panel .el-form-item.is-error input')?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -60,20 +64,20 @@ function showResetHint() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<header class="auth-header">
|
||||
<RouterLink to="/login" class="auth-brand"><img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" /></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">
|
||||
<AuthShell
|
||||
switch-text="还没有企业账号?"
|
||||
switch-label="申请入驻"
|
||||
switch-to="/register"
|
||||
visual-title="跨境物流业务,一个工作台完成"
|
||||
visual-description="从企业协作到头程订单跟进,让业务信息与操作权限始终清晰。"
|
||||
:features="['企业级角色与权限', '头程订单全程跟进', '多成员业务协同']"
|
||||
>
|
||||
<div class="auth-form-content auth-form-content--login">
|
||||
<div class="auth-form-heading">
|
||||
<h2 id="login-title">登录客户工作台</h2>
|
||||
<p>企业注册成功后即可登录;提交订单前需完成企业资料审核</p>
|
||||
<p>使用企业账号登录,继续管理您的跨境物流业务。</p>
|
||||
</div>
|
||||
<div class="auth-account-note">企业注册成功后即可登录;提交订单前需完成企业资料审核。</div>
|
||||
<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" />
|
||||
@@ -87,7 +91,6 @@ function showResetHint() {
|
||||
</div>
|
||||
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { nextTick, 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 baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import { CheckCircle2, Mail, Phone } from '@lucide/vue'
|
||||
import { registerMerchant } from '@/modules/auth/api/auth'
|
||||
import AuthShell from '@/modules/auth/components/AuthShell.vue'
|
||||
|
||||
interface RegisterForm {
|
||||
username: string
|
||||
@@ -60,7 +60,11 @@ const rules: FormRules<RegisterForm> = {
|
||||
async function submit() {
|
||||
if (!formRef.value || submitting.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
if (!valid) {
|
||||
await nextTick()
|
||||
document.querySelector<HTMLElement>('.auth-form-panel .el-form-item.is-error input')?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -90,26 +94,49 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<header class="register-header">
|
||||
<RouterLink to="/login"><ArrowLeft :size="18" /> 返回登录</RouterLink>
|
||||
<div class="auth-brand"><img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" /></div>
|
||||
</header>
|
||||
<main class="register-main">
|
||||
<AuthShell
|
||||
variant="register"
|
||||
switch-text="已有企业账号?"
|
||||
switch-label="立即登录"
|
||||
switch-to="/login"
|
||||
visual-title="加入柏悦,开启高效跨境物流协作"
|
||||
visual-description="创建企业账号后即可进入客户工作台,维护团队、资料与头程订单。"
|
||||
:features="['注册成功即可登录', '企业资料独立审核', '审核期间可维护订单草稿']"
|
||||
>
|
||||
<div class="auth-form-content auth-form-content--register">
|
||||
<template v-if="!submitted">
|
||||
<div class="register-title"><div><Building2 :size="22" /></div><span><h1>企业注册</h1><p>注册成功后即可登录并使用客户工作台,提交订单前需完成企业资料审核。</p></span></div>
|
||||
<div class="auth-form-heading">
|
||||
<h1>企业注册</h1>
|
||||
<p>填写账号与企业基本信息,注册成功后即可登录。</p>
|
||||
</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>
|
||||
<section class="register-section" aria-labelledby="account-section-title">
|
||||
<h2 id="account-section-title">账号信息</h2>
|
||||
<div class="register-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="password" required><el-input v-model="form.password" type="password" autocomplete="new-password" maxlength="72" show-password placeholder="请设置 8–72 位密码" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="register-section" aria-labelledby="company-section-title">
|
||||
<h2 id="company-section-title">企业信息</h2>
|
||||
<div class="register-form-grid">
|
||||
<el-form-item label="企业名称" prop="companyName" class="register-form-full" required><el-input v-model="form.companyName" maxlength="100" 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="countryCode"><el-select v-model="form.countryCode" class="register-select"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="register-section" aria-labelledby="contact-section-title">
|
||||
<h2 id="contact-section-title">联系信息</h2>
|
||||
<div class="register-form-grid">
|
||||
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" 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="请输入 11 位手机号" :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="address"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-form-item prop="agreement" class="agreement-item">
|
||||
<div class="agreement-control">
|
||||
<el-checkbox v-model="form.agreement"><span class="agreement-required" aria-hidden="true">*</span>我已阅读并同意</el-checkbox>
|
||||
@@ -117,7 +144,7 @@ async function submit() {
|
||||
</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-button native-type="submit" class="register-submit" type="success" :loading="submitting">注册企业账号</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
@@ -129,6 +156,6 @@ async function submit() {
|
||||
<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>
|
||||
</div>
|
||||
</AuthShell>
|
||||
</template>
|
||||
|
||||
@@ -24,3 +24,14 @@ test('isolates recoverable dashboard failures without hiding successful sections
|
||||
assert.match(source, /@click="loadDashboardData"/)
|
||||
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
|
||||
})
|
||||
|
||||
test('matches the prototype business overview while keeping real order routing and data', () => {
|
||||
assert.match(source, /选择业务,快速下单/)
|
||||
assert.match(source, /海外采购/)
|
||||
assert.match(source, /美国本土卡/)
|
||||
assert.match(source, /美本土散板/)
|
||||
assert.match(source, /void router\.push\('\/orders\/first-leg\/create'\)/)
|
||||
assert.match(source, /order\.status === 'COMPLETED'/)
|
||||
assert.match(source, /currentMonthOrderCount/)
|
||||
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { AlertTriangle, ArrowRight, ClipboardCheck, Clock3, FilePlus2, Users } from '@lucide/vue'
|
||||
import { getFirstLegStatusLabel, getFirstLegStatusTone, useFirstLegOrdersStore } from '@/modules/first-leg/public'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CircleQuestionMark,
|
||||
Headphones,
|
||||
Lightbulb,
|
||||
PanelsTopLeft,
|
||||
ShoppingCart,
|
||||
Tags,
|
||||
Truck,
|
||||
} from '@lucide/vue'
|
||||
import { useFirstLegOrdersStore } from '@/modules/first-leg/public'
|
||||
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
|
||||
const router = useRouter()
|
||||
const orders = useFirstLegOrdersStore()
|
||||
const tenant = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
@@ -16,9 +29,8 @@ const dataSourceLabels: Record<DashboardDataSource, string> = {
|
||||
members: '企业成员',
|
||||
orders: '头程订单',
|
||||
}
|
||||
const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE')
|
||||
|
||||
const companyUnavailable = computed(() => Boolean(loadErrors.value.company))
|
||||
const membersUnavailable = computed(() => Boolean(loadErrors.value.members))
|
||||
const ordersUnavailable = computed(() => Boolean(loadErrors.value.orders))
|
||||
const loadErrorItems = computed(() =>
|
||||
(Object.keys(loadErrors.value) as DashboardDataSource[]).map((key) => ({
|
||||
@@ -27,18 +39,53 @@ const loadErrorItems = computed(() =>
|
||||
message: loadErrors.value[key] ?? '请求失败',
|
||||
})),
|
||||
)
|
||||
const stats = computed(() => [
|
||||
...(auth.hasPermission('order:first-leg:list')
|
||||
? [
|
||||
{ label: '进行中订单', value: ordersUnavailable.value ? '—' : String(orders.activeCount), icon: Clock3 },
|
||||
{ label: '待平台审核', value: ordersUnavailable.value ? '—' : String(orders.pendingCount), icon: ClipboardCheck },
|
||||
]
|
||||
: []),
|
||||
...(auth.hasPermission('company:member:list')
|
||||
? [{ label: '企业成员', value: membersUnavailable.value ? '—' : String(tenant.activeMemberCount), icon: Users }]
|
||||
: []),
|
||||
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
|
||||
const canCreateOrders = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||
const accountName = computed(() => {
|
||||
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
|
||||
return auth.user?.tenantName || auth.user?.name || '企业客户'
|
||||
})
|
||||
const accountInitial = computed(() => accountName.value.trim().slice(0, 1).toUpperCase() || 'C')
|
||||
const completedOrderCount = computed(() => orders.orders.filter((order) => order.status === 'COMPLETED').length)
|
||||
const currentMonthOrderCount = computed(() => {
|
||||
const now = new Date()
|
||||
return orders.orders.filter((order) => {
|
||||
const createdAt = new Date(order.createdAt)
|
||||
return !Number.isNaN(createdAt.getTime())
|
||||
&& createdAt.getFullYear() === now.getFullYear()
|
||||
&& createdAt.getMonth() === now.getMonth()
|
||||
}).length
|
||||
})
|
||||
const overviewStats = computed(() => [
|
||||
{ label: '进行中订单', value: dashboardOrderValue(orders.activeCount) },
|
||||
{ label: '已完成订单', value: dashboardOrderValue(completedOrderCount.value) },
|
||||
{ label: '本月下单', value: dashboardOrderValue(currentMonthOrderCount.value) },
|
||||
])
|
||||
const recentOrders = computed(() => orders.orders.slice(0, 3))
|
||||
const businessCards = [
|
||||
{
|
||||
title: '海外采购',
|
||||
description: '采购代购 · 验货质检 · 集中发货',
|
||||
icon: ShoppingCart,
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
title: '美国本土卡',
|
||||
description: '港口提柜 · 仓到仓 · 仓到门',
|
||||
icon: Truck,
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
title: '美本土散板',
|
||||
description: '拼车拼载 · 打盘服务 · 灵活派送',
|
||||
icon: PanelsTopLeft,
|
||||
tone: 'teal',
|
||||
},
|
||||
] as const
|
||||
|
||||
function dashboardOrderValue(value: number) {
|
||||
if (!canViewOrders.value || ordersUnavailable.value) return '—'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试'
|
||||
@@ -64,13 +111,34 @@ async function loadDashboardData() {
|
||||
}
|
||||
}
|
||||
|
||||
function openBusinessOrder() {
|
||||
if (canCreateOrders.value) {
|
||||
void router.push('/orders/first-leg/create')
|
||||
return
|
||||
}
|
||||
if (canViewOrders.value) {
|
||||
void router.push('/orders/first-leg')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('当前账号暂无头程订单权限,请联系企业管理员。')
|
||||
}
|
||||
|
||||
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
|
||||
const messages = {
|
||||
service: '客服联系方式正在配置中,请联系您的专属客户经理。',
|
||||
faq: '常见问题页面即将上线。',
|
||||
quote: '提交订单后,客户经理将根据货物信息为您提供报价。',
|
||||
}
|
||||
ElMessage.info(messages[type])
|
||||
}
|
||||
|
||||
onMounted(() => void loadDashboardData())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="client-dashboard" :aria-busy="loading">
|
||||
<div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
|
||||
<AlertTriangle :size="20" />
|
||||
<AlertTriangle :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>部分业务数据加载失败</strong>
|
||||
<ul>
|
||||
@@ -81,40 +149,467 @@ onMounted(() => void loadDashboardData())
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
|
||||
</div>
|
||||
<section class="quick-order-band">
|
||||
<div>
|
||||
<span>头程物流</span>
|
||||
<h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2>
|
||||
<p v-if="companyUnavailable">企业资料暂不可用,恢复后可查看下单资格。</p>
|
||||
<p v-else>{{ 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 v-if="stats.length" 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 v-if="auth.hasPermission('order:first-leg:list')" 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="ordersUnavailable" class="dashboard-section-error" role="status">订单数据暂不可用,请重新加载后查看。</div>
|
||||
<div v-else-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 v-if="auth.hasPermission('company:profile:view')" class="client-card">
|
||||
<header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header>
|
||||
<div v-if="companyUnavailable" class="dashboard-section-error" role="status">企业资料暂不可用,请重新加载后查看。</div>
|
||||
<template v-else>
|
||||
<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 plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button>
|
||||
</template>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<main class="dashboard-business" aria-labelledby="dashboard-heading">
|
||||
<header class="dashboard-intro">
|
||||
<h2 id="dashboard-heading">选择业务,快速下单</h2>
|
||||
<p>请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。</p>
|
||||
</header>
|
||||
|
||||
<section class="business-card-grid" aria-label="可选业务">
|
||||
<button
|
||||
v-for="business in businessCards"
|
||||
:key="business.title"
|
||||
type="button"
|
||||
class="business-card"
|
||||
:class="`business-card--${business.tone}`"
|
||||
@click="openBusinessOrder"
|
||||
>
|
||||
<span class="business-card__icon" aria-hidden="true">
|
||||
<component :is="business.icon" :size="29" :stroke-width="2.4" />
|
||||
</span>
|
||||
<span class="business-card__copy">
|
||||
<strong>{{ business.title }}</strong>
|
||||
<small>{{ business.description }}</small>
|
||||
</span>
|
||||
<span class="business-card__action">
|
||||
{{ canCreateOrders ? '开始下单' : canViewOrders ? '查看订单' : '暂无权限' }}
|
||||
<ArrowRight :size="18" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="dashboard-aside" aria-label="账户与业务信息">
|
||||
<section class="aside-card account-card">
|
||||
<h3>账户信息</h3>
|
||||
<div class="account-summary">
|
||||
<span class="account-avatar">{{ accountInitial }}</span>
|
||||
<span class="account-copy">
|
||||
<strong>{{ accountName }}</strong>
|
||||
<small>企业客户</small>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="companyUnavailable" class="account-load-error" role="status">企业资料暂不可用,请重新加载后查看。</p>
|
||||
</section>
|
||||
|
||||
<section class="aside-card">
|
||||
<h3>数据概览</h3>
|
||||
<dl class="overview-stats">
|
||||
<div v-for="stat in overviewStats" :key="stat.label">
|
||||
<dt>{{ stat.label }}</dt>
|
||||
<dd>{{ stat.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="ordersUnavailable" class="dashboard-section-error" role="status">订单数据暂不可用,请重新加载后查看。</p>
|
||||
</section>
|
||||
|
||||
<section class="aside-card">
|
||||
<h3>帮助与支持</h3>
|
||||
<div class="support-links">
|
||||
<button type="button" @click="showSupportMessage('service')"><Headphones :size="19" /><span>联系客服</span></button>
|
||||
<button type="button" @click="showSupportMessage('faq')"><CircleQuestionMark :size="19" /><span>常见问题</span></button>
|
||||
<button type="button" @click="showSupportMessage('quote')"><Tags :size="19" /><span>获取报价</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-tip">
|
||||
<Lightbulb :size="19" aria-hidden="true" />
|
||||
<p>提交订单后,专属客户经理将在 24 小时内与您联系,确认运输方案与报价。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-load-error ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.dashboard-load-error li { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.dashboard-load-error li span { color: #9a5a09; font-weight: 650; }
|
||||
.dashboard-section-error { display: grid; min-height: 160px; place-items: center; padding: 24px; border-top: 1px solid var(--border); color: #8a5a22; font-size: 13px; line-height: 1.6; text-align: center; }
|
||||
.client-dashboard {
|
||||
--dashboard-blue: #1565c0;
|
||||
--dashboard-green: #3f9f47;
|
||||
--dashboard-teal: #078a99;
|
||||
display: block;
|
||||
min-height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
.dashboard-load-error {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dashboard-load-error ul {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dashboard-load-error li {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-load-error li span {
|
||||
color: #9a5a09;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 286px;
|
||||
gap: clamp(24px, 3vw, 42px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-business {
|
||||
min-width: 0;
|
||||
padding: 14px 0 24px;
|
||||
}
|
||||
|
||||
.dashboard-intro {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.dashboard-intro h2 {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: clamp(30px, 3.2vw, 46px);
|
||||
line-height: 1.18;
|
||||
letter-spacing: -.025em;
|
||||
}
|
||||
|
||||
.dashboard-intro p {
|
||||
max-width: 720px;
|
||||
margin: 16px 0 0;
|
||||
color: #697586;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.business-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.business-card {
|
||||
--card-accent: var(--dashboard-blue);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 286px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 30px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 36px rgba(23, 50, 77, .08);
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform .25s cubic-bezier(.16, 1, .3, 1), box-shadow .25s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
.business-card--green { --card-accent: var(--dashboard-green); }
|
||||
.business-card--teal { --card-accent: var(--dashboard-teal); }
|
||||
|
||||
.business-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 46px rgba(23, 50, 77, .14);
|
||||
}
|
||||
|
||||
.business-card:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--card-accent) 36%, white);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.business-card__icon {
|
||||
display: grid;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: var(--card-accent);
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--card-accent) 24%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.business-card__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.business-card__copy strong {
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.business-card__copy small {
|
||||
color: #697586;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.business-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 28px;
|
||||
color: var(--card-accent);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.business-card__action svg {
|
||||
transition: transform .2s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
.business-card:hover .business-card__action svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.aside-card {
|
||||
padding: 20px;
|
||||
border: 1px solid #e9eef5;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aside-card h3 {
|
||||
margin: 0 0 18px;
|
||||
color: #8b9bb2;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #1976d2, #00a993);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.account-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.account-copy strong {
|
||||
overflow: hidden;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-copy small {
|
||||
color: #66758a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-load-error {
|
||||
margin: 14px 0 0;
|
||||
color: #8a5a22;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overview-stats div {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px dashed #dfe6ef;
|
||||
}
|
||||
|
||||
.overview-stats div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-stats dt {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.overview-stats dd {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.dashboard-section-error {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 0 0;
|
||||
border-top: 1px solid #e9eef5;
|
||||
color: #8a5a22;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.support-links {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.support-links button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-links button svg {
|
||||
flex: 0 0 19px;
|
||||
color: #00a993;
|
||||
}
|
||||
|
||||
.support-links button:hover {
|
||||
background: #edf2f7;
|
||||
color: var(--dashboard-blue);
|
||||
}
|
||||
|
||||
.support-links button:focus-visible {
|
||||
outline: 2px solid var(--dashboard-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-tip {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 169, 147, .24);
|
||||
border-radius: 14px;
|
||||
background: #eef9f8;
|
||||
color: #53657a;
|
||||
}
|
||||
|
||||
.dashboard-tip svg {
|
||||
margin-top: 2px;
|
||||
color: #00a993;
|
||||
}
|
||||
|
||||
.dashboard-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-tip {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.business-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.business-card {
|
||||
min-height: 232px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.client-dashboard {
|
||||
min-height: calc(100vh - 118px);
|
||||
}
|
||||
|
||||
.dashboard-business {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.dashboard-intro {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-intro h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.dashboard-intro p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.business-card {
|
||||
min-height: 220px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.business-card__copy {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-tip {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.business-card,
|
||||
.business-card__action svg {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -157,7 +157,7 @@ async function resetPassword(member: TenantMember) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="members-page">
|
||||
<section v-loading="loading" class="members-page" :aria-busy="loading">
|
||||
<div v-if="loadError" class="client-card collection-load-error" role="alert">
|
||||
<div><strong>企业成员加载失败</strong><span>{{ loadError }}</span></div>
|
||||
<el-button :loading="loading" @click="loadMembers">重新加载</el-button>
|
||||
@@ -167,7 +167,7 @@ async function resetPassword(member: TenantMember) {
|
||||
|
||||
<div class="client-card member-table-card">
|
||||
<div v-if="auth.hasPermission('company:member:write')" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button></div>
|
||||
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" class="desktop-member-table" :data="pagedMembers" :columns="memberTableColumns" :loading="loading" :total="filteredMembers.length" row-key="id">
|
||||
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" class="desktop-member-table" :data="pagedMembers" :columns="memberTableColumns" :total="filteredMembers.length" row-key="id">
|
||||
<template #actions="{ 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 as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template>
|
||||
<template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template>
|
||||
<template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
|
||||
@@ -215,7 +215,7 @@ async function resetPassword(member: TenantMember) {
|
||||
.member-identity strong, .member-identity small, .member-contact small { overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.mobile-member-list, .mobile-member-empty { display: none; }
|
||||
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.member-form-tip { display: flex; gap: 10px; padding: 13px; border: 1px solid #cfe1dc; background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
|
||||
|
||||
@@ -138,7 +138,7 @@ function assignPermissions(role: TenantRole) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="roles-page">
|
||||
<section v-loading="loading" class="roles-page" :aria-busy="loading">
|
||||
<div v-if="loadError" class="client-card collection-load-error" role="alert">
|
||||
<div><strong>企业角色加载失败</strong><span>{{ loadError }}</span></div>
|
||||
<el-button :loading="loading" @click="loadRoles">重新加载</el-button>
|
||||
@@ -148,7 +148,7 @@ function assignPermissions(role: TenantRole) {
|
||||
|
||||
<div class="client-card role-table-card">
|
||||
<div v-if="auth.hasPermission('company:role:write')" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></div>
|
||||
<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :loading="loading" :total="filteredRoles.length" row-key="id">
|
||||
<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :total="filteredRoles.length" row-key="id">
|
||||
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
|
||||
<template #role="{ row }"><div class="role-identity"><span class="role-icon"><LockKeyhole v-if="(row as TenantRole).builtin" :size="20" /><ShieldCheck v-else :size="20" /></span><div><strong>{{ (row as TenantRole).name }}</strong><code>{{ (row as TenantRole).code }}</code></div></div></template>
|
||||
<template #memberCount="{ row }"><span>{{ store.roleMemberCount((row as TenantRole).id) }}</span></template>
|
||||
|
||||
@@ -92,6 +92,8 @@ test('implements member list, role assignment, password reset and status control
|
||||
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, /\.mobile-member-list, \.mobile-member-empty \{ display: none; \}/)
|
||||
assert.match(membersSource, /\.mobile-member-empty \{ display: grid; min-height: 150px; place-items: center; \}/)
|
||||
assert.match(membersSource, /store\.loadMembers\(\)/)
|
||||
assert.match(membersSource, /await store\.resetMemberPassword/)
|
||||
assert.match(membersSource, /await store\.toggleMemberStatus/)
|
||||
@@ -106,6 +108,8 @@ test('implements member list, role assignment, password reset and status control
|
||||
assert.match(membersSource, /import DataTable.*DataTable\.vue/)
|
||||
assert.match(membersSource, /<QueryToolbar v-model="memberFilters"/)
|
||||
assert.match(membersSource, /<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize"/)
|
||||
assert.match(membersSource, /<section v-loading="loading" class="members-page" :aria-busy="loading">/)
|
||||
assert.doesNotMatch(membersSource, /<DataTable[^>]*:loading="loading"/)
|
||||
assert.match(membersSource, /fixed: 'left'/)
|
||||
assert.match(membersSource, /class="table-actions"/)
|
||||
assert.doesNotMatch(membersSource, /template #actions><el-button v-if="auth\.hasPermission\('company:member:write'\)/)
|
||||
@@ -133,6 +137,8 @@ test('protects built-in roles and exposes role permission assignment', () => {
|
||||
assert.match(rolesSource, /import DataTable.*DataTable\.vue/)
|
||||
assert.match(rolesSource, /<QueryToolbar v-model="roleFilters"/)
|
||||
assert.match(rolesSource, /<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize"/)
|
||||
assert.match(rolesSource, /<section v-loading="loading" class="roles-page" :aria-busy="loading">/)
|
||||
assert.doesNotMatch(rolesSource, /<DataTable[^>]*:loading="loading"/)
|
||||
assert.match(rolesSource, /fixed: 'left'/)
|
||||
assert.match(rolesSource, /class="table-actions"/)
|
||||
assert.doesNotMatch(rolesSource, /template #actions><el-button v-if="auth\.hasPermission\('company:role:write'\)/)
|
||||
|
||||
+67
-40
@@ -1,56 +1,71 @@
|
||||
@import './foundation.css';
|
||||
@import './shell.css';
|
||||
|
||||
.auth-page { min-height: 100vh; background: #eef3f3; }
|
||||
.auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 13px; }
|
||||
.auth-page { min-height: 100vh; background: #edf2f4; color: var(--navy); }
|
||||
.auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid #dfe6e8; background: #fff; color: #59646d; font-size: 13px; }
|
||||
.auth-brand { display: flex; min-width: 0; align-items: center; color: var(--navy); text-decoration: none; }
|
||||
.auth-brand-logo { display: block; width: 158px; height: 56px; object-fit: contain; object-position: left center; }
|
||||
.auth-header a { color: var(--primary); text-decoration: none; }
|
||||
.auth-main { display: grid; min-height: calc(100vh - 72px); grid-template-columns: 1fr 440px; align-items: center; gap: clamp(60px, 10vw, 150px); width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 50px 0; }
|
||||
.auth-kicker, .section-label { color: var(--primary); font-size: 11px; font-weight: 750; letter-spacing: .13em; }
|
||||
.auth-copy h1 { margin: 15px 0 20px; color: var(--navy); font-size: clamp(38px, 4.5vw, 62px); line-height: 1.14; letter-spacing: -.04em; }
|
||||
.auth-copy > p { max-width: 520px; color: var(--muted); font-size: 16px; line-height: 1.8; }
|
||||
.auth-facts { display: flex; gap: 8px; margin-top: 32px; flex-wrap: wrap; }
|
||||
.auth-facts span { padding: 8px 12px; border: 1px solid #cbdad7; background: rgba(255,255,255,.7); color: #40534f; font-size: 12px; }
|
||||
.auth-card { padding: 38px; border: 1px solid var(--border); background: #fff; box-shadow: 0 14px 36px rgba(23,50,77,.08); }
|
||||
.auth-card h2 { margin: 0 0 8px; font-size: 26px; }
|
||||
.auth-card > p { margin: 0 0 28px; color: var(--muted); }
|
||||
.auth-header-action { display: flex; min-height: 44px; align-items: center; gap: 8px; }
|
||||
.auth-header-action a { display: inline-flex; min-height: 44px; align-items: center; color: #155eaa; font-weight: 650; text-decoration: none; }
|
||||
.auth-header-action a:hover { text-decoration: underline; }
|
||||
.auth-header-action a:focus-visible, .auth-brand:focus-visible { outline: 2px solid #155eaa; outline-offset: 3px; }
|
||||
.auth-stage { display: grid; min-height: calc(100vh - 72px); padding: clamp(28px, 5vw, 64px) 24px; place-items: center; }
|
||||
.auth-panel { display: grid; width: min(1180px, 100%); min-height: 640px; grid-template-columns: minmax(360px, .95fr) minmax(440px, 1.05fr); overflow: hidden; border-radius: 16px; background: #fff; box-shadow: 0 24px 64px rgba(20, 42, 65, .16); }
|
||||
.auth-panel--register { width: min(1280px, 100%); min-height: 820px; grid-template-columns: minmax(340px, .72fr) minmax(600px, 1.28fr); }
|
||||
.auth-visual { position: relative; min-height: 100%; overflow: hidden; color: #fff; }
|
||||
.auth-visual > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 58% center; transform: scale(1.01); }
|
||||
.auth-visual-shade { position: absolute; inset: 0; background: linear-gradient(155deg, rgba(7, 23, 40, .93) 0%, rgba(13, 71, 117, .82) 58%, rgba(10, 93, 92, .74) 100%); }
|
||||
.auth-visual-content { position: relative; z-index: 1; display: flex; height: 100%; min-height: inherit; flex-direction: column; justify-content: space-between; padding: clamp(42px, 5vw, 72px); }
|
||||
.auth-panel--register .auth-visual-content { padding: 48px 42px; }
|
||||
.auth-visual h1 { max-width: 12ch; margin: 0 0 18px; color: #fff; font-size: clamp(31px, 3.2vw, 46px); line-height: 1.18; letter-spacing: -.025em; }
|
||||
.auth-panel--register .auth-visual h1 { max-width: 10ch; font-size: clamp(30px, 2.8vw, 40px); }
|
||||
.auth-visual p { max-width: 32ch; margin: 0; color: #dbeaf0; font-size: 15px; line-height: 1.8; }
|
||||
.auth-visual ul { display: flex; margin: 48px 0 0; padding: 0; flex-direction: column; gap: 16px; list-style: none; }
|
||||
.auth-visual li { display: flex; align-items: center; gap: 10px; color: #f4fbfb; font-size: 14px; font-weight: 600; }
|
||||
.auth-visual li svg { flex: 0 0 auto; color: #64c877; }
|
||||
.auth-form-panel { display: flex; min-width: 0; align-items: center; padding: clamp(40px, 5vw, 70px); background: #fff; }
|
||||
.auth-panel--register .auth-form-panel { align-items: stretch; padding: 44px 48px; }
|
||||
.auth-form-content { width: 100%; margin: auto; }
|
||||
.auth-form-content--login { max-width: 420px; }
|
||||
.auth-form-content--register { max-width: 760px; }
|
||||
.auth-form-heading { margin-bottom: 26px; }
|
||||
.auth-form-heading h1, .auth-form-heading h2 { margin: 0 0 9px; color: #18243a; font-size: 30px; line-height: 1.3; letter-spacing: -.02em; }
|
||||
.auth-form-heading p { margin: 0; color: #66717a; font-size: 14px; line-height: 1.7; }
|
||||
.section-label { color: var(--primary); font-size: 11px; font-weight: 750; letter-spacing: .13em; }
|
||||
.auth-account-note { margin: -8px 0 24px; padding: 12px 14px; background: #edf6f4; color: #385b57; font-size: 12px; line-height: 1.65; }
|
||||
.auth-form-options { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: -2px 0 18px; }
|
||||
.auth-form-options button { min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; }
|
||||
.auth-form-options button { min-height: 44px; padding: 0; border: 0; background: transparent; color: #155eaa; cursor: pointer; }
|
||||
.auth-form-options button:hover { text-decoration: underline; }
|
||||
.auth-form-options button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.auth-submit { width: 100%; }
|
||||
.auth-form-options button:focus-visible { outline: 2px solid #155eaa; outline-offset: 3px; }
|
||||
.auth-submit { width: 100%; min-height: 46px; }
|
||||
.auth-submit span { display: flex; align-items: center; gap: 7px; }
|
||||
|
||||
.register-page { min-height: 100vh; background: #f5f7f8; }
|
||||
.register-header { display: flex; height: 68px; align-items: center; justify-content: space-between; padding: 0 clamp(20px, 5vw, 72px); border-bottom: 1px solid var(--border); background: #fff; }
|
||||
.register-header > a { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; text-decoration: none; }
|
||||
.register-main { width: min(920px, calc(100% - 32px)); margin: 36px auto; padding: 34px 38px; border: 1px solid var(--border); background: #fff; }
|
||||
.register-title { display: flex; align-items: center; gap: 14px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
|
||||
.register-title > div { display: grid; width: 44px; height: 44px; place-items: center; background: #e7f3f1; color: var(--primary); }
|
||||
.register-title h1 { margin: 0 0 5px; font-size: 23px; }
|
||||
.register-title p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.register-form { margin-top: 28px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 28px; }
|
||||
.register-form { margin-top: 4px; }
|
||||
.register-section { padding: 22px 0 2px; border-top: 1px solid #e5eaec; }
|
||||
.register-section:first-child { padding-top: 0; border-top: 0; }
|
||||
.register-section h2 { margin: 0 0 16px; color: #26364a; font-size: 15px; line-height: 1.4; }
|
||||
.register-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }
|
||||
.register-form-full { grid-column: 1 / -1; }
|
||||
.register-select { width: 100%; }
|
||||
.agreement-item { margin-bottom: 8px; }
|
||||
.agreement-control { display: flex; min-height: 44px; align-items: center; gap: 4px; flex-wrap: wrap; color: var(--muted); }
|
||||
.agreement-control { display: flex; min-height: 44px; align-items: center; gap: 4px; flex-wrap: wrap; color: #59646d; }
|
||||
.agreement-item .el-checkbox { min-height: 44px; white-space: normal; }
|
||||
.agreement-required { margin-right: 2px; color: var(--el-color-danger); }
|
||||
.agreement-item a { color: var(--primary); text-decoration: none; }
|
||||
.agreement-item a { color: #155eaa; text-decoration: none; }
|
||||
.agreement-item a:hover { text-decoration: underline; }
|
||||
.agreement-item a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.register-security-note { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.register-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.registration-result { display: flex; min-height: 470px; align-items: center; justify-content: center; flex-direction: column; color: var(--primary); text-align: center; }
|
||||
.agreement-item a:focus-visible { outline: 2px solid #155eaa; outline-offset: 2px; }
|
||||
.register-security-note { margin: 0; color: #66717a; font-size: 12px; line-height: 1.65; }
|
||||
.register-submit { width: 100%; min-height: 48px; margin-top: 22px; --el-button-bg-color: #2e7d32; --el-button-border-color: #2e7d32; --el-button-hover-bg-color: #256b2a; --el-button-hover-border-color: #256b2a; }
|
||||
.registration-result { display: flex; min-height: 640px; align-items: center; justify-content: center; flex-direction: column; color: #2e7d32; text-align: center; }
|
||||
.registration-result h1 { margin: 18px 0 8px; color: var(--navy); font-size: 26px; }
|
||||
.registration-result > p { max-width: 600px; margin: 0; color: var(--muted); line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.registration-result > p { max-width: 600px; margin: 0; color: #66717a; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.registration-result > p strong { color: var(--navy); }
|
||||
.registration-result dl { display: grid; width: min(620px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 28px 0 0; border: 1px solid var(--border); color: var(--navy); text-align: left; }
|
||||
.registration-result dl { display: grid; width: min(620px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 28px 0 0; border: 1px solid #e0e6e8; color: var(--navy); text-align: left; }
|
||||
.registration-result dl div { min-width: 0; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.registration-result dl div:nth-child(odd) { border-right: 1px solid var(--border); }
|
||||
.registration-result dt { color: var(--muted); font-size: 11px; }
|
||||
.registration-result dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 650; }
|
||||
.registration-next-step { display: flex; width: min(620px, 100%); flex-direction: column; gap: 5px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d9cfad; background: #fff9e9; color: var(--muted); font-size: 12px; line-height: 1.65; text-align: left; }
|
||||
.registration-next-step { display: flex; width: min(620px, 100%); flex-direction: column; gap: 5px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d9cfad; background: #fff9e9; color: #66717a; font-size: 12px; line-height: 1.65; text-align: left; }
|
||||
.registration-next-step strong { color: var(--navy); font-size: 13px; }
|
||||
.registration-result-actions { display: flex; justify-content: center; gap: 10px; margin-top: 28px; }
|
||||
|
||||
@@ -114,8 +129,12 @@
|
||||
.el-button--primary { --el-button-bg-color: var(--primary); --el-button-border-color: var(--primary); --el-button-hover-bg-color: var(--primary-dark); --el-button-hover-border-color: var(--primary-dark); }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.auth-main { grid-template-columns: 1fr; width: min(520px, calc(100% - 32px)); }
|
||||
.auth-copy { display: none; }
|
||||
.auth-stage { padding: 32px 20px; }
|
||||
.auth-panel { width: min(680px, 100%); min-height: 0; grid-template-columns: 1fr; }
|
||||
.auth-panel--login { width: min(520px, 100%); }
|
||||
.auth-visual { display: none; }
|
||||
.auth-form-panel, .auth-panel--register .auth-form-panel { padding: 44px; }
|
||||
.registration-result { min-height: 560px; }
|
||||
.client-content-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
@@ -134,10 +153,18 @@
|
||||
.dashboard-load-error .el-button { width: 100%; min-height: 44px; }
|
||||
.client-stats { grid-template-columns: 1fr; }
|
||||
.quick-order-band { align-items: flex-start; flex-direction: column; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.auth-header { padding: 0 18px; }
|
||||
.auth-header > div:last-child { display: none; }
|
||||
.auth-card, .register-main { padding: 26px 20px; }
|
||||
.auth-header { height: 64px; padding: 0 18px; }
|
||||
.auth-brand-logo { width: 132px; height: 50px; }
|
||||
.auth-header-action > span { display: none; }
|
||||
.auth-stage { min-height: calc(100vh - 64px); padding: 0; background: #fff; place-items: start center; }
|
||||
.auth-panel, .auth-panel--register { width: 100%; border-radius: 0; box-shadow: none; }
|
||||
.auth-form-panel, .auth-panel--register .auth-form-panel { padding: 34px 20px 44px; }
|
||||
.auth-form-heading { margin-bottom: 22px; }
|
||||
.auth-form-heading h1, .auth-form-heading h2 { font-size: 26px; }
|
||||
.register-form-grid { grid-template-columns: 1fr; }
|
||||
.register-form-full { grid-column: auto; }
|
||||
.register-section { padding-top: 20px; }
|
||||
.registration-result { min-height: 0; padding: 34px 0 12px; }
|
||||
.registration-result dl { grid-template-columns: 1fr; }
|
||||
.registration-result dl div:nth-child(odd) { border-right: 0; }
|
||||
.registration-result-actions { width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user