feat: 搭建后台管理端

- 初始化 Vue 3 + TypeScript + Vite 管理端工程

- 增加登录、租户、组织、用户、名片与系统页面

- 补充路由、状态管理、接口封装与基础样式体系
This commit is contained in:
2026-03-20 12:43:53 +08:00
parent 9ef50288e9
commit 86c321e832
47 changed files with 9387 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
VITE_APP_TITLE=Easycard 管理后台
VITE_API_BASE_URL=

View File

@@ -0,0 +1,2 @@
VITE_APP_TITLE=Easycard 管理后台
VITE_API_BASE_URL=

13
frontend_admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Easycard 管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2597
frontend_admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "easycard-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build && rm -f dist.zip && cd dist && zip -rq ../dist.zip .",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.9.0",
"element-plus": "^2.9.11",
"pinia": "^3.0.3",
"sortablejs": "^1.15.7",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/node": "^24.0.3",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/tsconfig": "^0.7.0",
"sass": "^1.89.2",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.10"
}
}

View File

@@ -0,0 +1,41 @@
<svg viewBox="0 0 54 54" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="shadow-back" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000" flood-opacity="0.12"/>
</filter>
<filter id="shadow-front" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="-2" dy="4" stdDeviation="3" flood-color="#000" flood-opacity="0.35"/>
</filter>
<linearGradient id="grad-back" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ececf0"/>
</linearGradient>
<linearGradient id="grad-front" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3c3d42"/>
<stop offset="100%" stop-color="#111214"/>
</linearGradient>
<linearGradient id="stroke-front" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#8a8b90"/>
<stop offset="100%" stop-color="#1a1b1d"/>
</linearGradient>
</defs>
<rect width="54" height="54" rx="16" fill="#ffffff" />
<g transform="translate(27, 27)">
<g transform="rotate(-8) translate(-11, -14)">
<rect x="0" y="0" width="15" height="20" rx="3.5" fill="url(#grad-back)" stroke="#dcdde0" stroke-width="1" filter="url(#shadow-back)"/>
<rect x="3" y="3" width="4" height="3" rx="1" fill="#d1d1d6"/>
<line x1="3" y1="8" x2="11" y2="8" stroke="#eaeaea" stroke-width="1.5" stroke-linecap="round"/>
</g>
<g transform="rotate(5) translate(-3, -4)">
<rect x="0" y="0" width="15" height="20" rx="3.5" fill="url(#grad-front)" stroke="url(#stroke-front)" stroke-width="1.2" filter="url(#shadow-front)"/>
<rect x="3" y="3" width="4" height="3" rx="1" fill="#5c5d63"/>
<line x1="3" y1="9" x2="10" y2="9" stroke="#222" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="12" x2="7" y2="12" stroke="#222" stroke-width="1.5" stroke-linecap="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,18 @@
import { http } from '@/utils/http'
import type { CurrentUser, LoginResult } from '@/types/auth'
export function login(data: { username: string; password: string }) {
return http.post<never, LoginResult>('/api/v1/auth/login', data)
}
export function logout() {
return http.post<never, void>('/api/v1/auth/logout')
}
export function getCurrentUser() {
return http.get<never, CurrentUser>('/api/v1/auth/me')
}
export function changePassword(data: { oldPassword: string; newPassword: string }) {
return http.post<never, void>('/api/v1/auth/change-password', data)
}

View File

@@ -0,0 +1,12 @@
import { http } from '@/utils/http'
import type { FileAsset } from '@/types/file'
export function uploadFile(file: File) {
const formData = new FormData()
formData.append('file', file)
return http.post<FormData, FileAsset>('/api/v1/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}

View File

@@ -0,0 +1,26 @@
import { http } from '@/utils/http'
import type { MiniappConfig, TenantCreatePayload, TenantDetail, TenantFormPayload } from '@/types/platform'
export function listTenants() {
return http.get<never, TenantDetail[]>('/api/v1/platform/tenants')
}
export function createTenant(data: TenantCreatePayload) {
return http.post<never, TenantDetail>('/api/v1/platform/tenants', data)
}
export function updateTenant(tenantId: number, data: TenantFormPayload) {
return http.put<never, TenantDetail>(`/api/v1/platform/tenants/${tenantId}`, data)
}
export function deleteTenant(tenantId: number) {
return http.delete<never, void>(`/api/v1/platform/tenants/${tenantId}`)
}
export function listMiniapps() {
return http.get<never, MiniappConfig[]>('/api/v1/platform/miniapps')
}
export function updateMiniapp(tenantId: number, data: Omit<MiniappConfig, 'tenantId' | 'tenantCode' | 'tenantName'> & { miniappAppSecret?: string; remark?: string }) {
return http.put<never, MiniappConfig>(`/api/v1/platform/miniapps/${tenantId}`, data)
}

View File

@@ -0,0 +1,124 @@
import { http } from '@/utils/http'
import type {
CardDetail,
CardTrendItem,
DashboardStats,
Department,
FirmProfile,
LawyerListItem,
PracticeArea,
TenantMiniappConfig,
TenantMiniappConfigPayload,
TenantUser,
TenantUserUpsertPayload,
} from '@/types/tenant'
export function getDashboardStats() {
return http.get<never, DashboardStats>('/api/v1/tenant/stats/overview')
}
export function getDashboardTrend(days = 7) {
return http.get<never, CardTrendItem[]>('/api/v1/tenant/stats/trend', {
params: { days },
})
}
export function getFirmProfile() {
return http.get<never, FirmProfile>('/api/v1/tenant/firm-profile')
}
export function saveFirmProfile(data: FirmProfile) {
return http.put<never, FirmProfile>('/api/v1/tenant/firm-profile', data)
}
export function getTenantMiniappConfig() {
return http.get<never, TenantMiniappConfig>('/api/v1/tenant/miniapp-config')
}
export function saveTenantMiniappConfig(data: TenantMiniappConfigPayload) {
return http.put<never, TenantMiniappConfig>('/api/v1/tenant/miniapp-config', data)
}
export function listPracticeAreas() {
return http.get<never, PracticeArea[]>('/api/v1/tenant/practice-areas')
}
export function createPracticeArea(data: Omit<PracticeArea, 'id'>) {
return http.post<never, PracticeArea>('/api/v1/tenant/practice-areas', data)
}
export function updatePracticeArea(areaId: number, data: Omit<PracticeArea, 'id'>) {
return http.put<never, PracticeArea>(`/api/v1/tenant/practice-areas/${areaId}`, data)
}
export function listDepartments() {
return http.get<never, Department[]>('/api/v1/tenant/departments')
}
export function createDepartment(data: Omit<Department, 'id'>) {
return http.post<never, Department>('/api/v1/tenant/departments', data)
}
export function updateDepartment(deptId: number, data: Omit<Department, 'id'>) {
return http.put<never, Department>(`/api/v1/tenant/departments/${deptId}`, data)
}
export function listUsers(keyword = '') {
return http.get<never, TenantUser[]>('/api/v1/tenant/users', {
params: { keyword: keyword || undefined },
})
}
export function createUser(data: TenantUserUpsertPayload) {
return http.post<never, TenantUser>('/api/v1/tenant/users', data)
}
export function updateUser(userId: number, data: TenantUserUpsertPayload) {
return http.put<never, TenantUser>(`/api/v1/tenant/users/${userId}`, data)
}
export function resetUserPassword(userId: number) {
return http.post<never, void>(`/api/v1/tenant/users/${userId}/reset-password`)
}
export function updateUserStatus(userId: number, userStatus: TenantUser['userStatus']) {
return http.patch<never, TenantUser>(`/api/v1/tenant/users/${userId}/status`, { userStatus })
}
export function sortUsers(userIds: number[]) {
return http.put<never, void>('/api/v1/tenant/users/sort', { userIds })
}
export function listLawyers(keyword = '') {
return http.get<never, LawyerListItem[]>('/api/v1/tenant/cards', {
params: { keyword: keyword || undefined },
})
}
export function createLawyer(data: CardDetail) {
return http.post<never, CardDetail>('/api/v1/tenant/cards', data)
}
export function getCard(cardId: number) {
return http.get<never, CardDetail>(`/api/v1/tenant/cards/${cardId}`)
}
export function updateCard(cardId: number, data: CardDetail) {
return http.put<never, CardDetail>(`/api/v1/tenant/cards/${cardId}`, data)
}
export function deleteCard(cardId: number) {
return http.delete<never, void>(`/api/v1/tenant/cards/${cardId}`)
}
export function sortCards(cardIds: number[]) {
return http.put<never, void>('/api/v1/tenant/cards/sort', { cardIds })
}
export function getMyCard() {
return http.get<never, CardDetail>('/api/v1/tenant/cards/me')
}
export function saveMyCard(data: CardDetail) {
return http.put<never, CardDetail>('/api/v1/tenant/cards/me', data)
}

View File

@@ -0,0 +1,608 @@
:root {
--ec-bg: #fbfbfd;
--ec-bg-accent: #f5f5f7;
--ec-surface: rgba(255, 255, 255, 0.82);
--ec-surface-strong: #ffffff;
--ec-surface-soft: rgba(255, 255, 255, 0.5);
--ec-border: rgba(34, 45, 37, 0.08);
--ec-border-strong: rgba(34, 45, 37, 0.14);
--ec-text: #1d1d1f;
--ec-text-secondary: #86868b;
--ec-accent: #223b31;
--ec-accent-strong: #16271f;
--ec-accent-soft: rgba(34, 59, 49, 0.08);
--ec-accent-muted: #8e9c95;
--ec-warning-soft: rgba(210, 156, 58, 0.1);
--ec-danger: #d93025;
--ec-shadow: 0 12px 36px rgba(0, 0, 0, 0.04);
--ec-shadow-soft: 0 6px 16px rgba(0, 0, 0, 0.03);
--ec-shadow-hover: 0 16px 48px rgba(0, 0, 0, 0.08);
--ec-radius-xl: 28px;
--ec-radius-lg: 20px;
--ec-radius-md: 16px;
--ec-radius-sm: 12px;
--ec-transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
margin: 0;
}
body {
background:
radial-gradient(circle at 0 0, rgba(34, 59, 49, 0.12), transparent 22%),
radial-gradient(circle at 100% 20%, rgba(211, 200, 180, 0.24), transparent 20%),
linear-gradient(180deg, #f7f5f0 0%, var(--ec-bg) 44%, var(--ec-bg-accent) 100%);
color: var(--ec-text);
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea {
font: inherit;
}
.page-shell {
padding: 24px;
}
.page-card {
background: var(--ec-surface);
border: 1px solid var(--ec-border);
border-radius: var(--ec-radius-lg);
box-shadow: var(--ec-shadow);
backdrop-filter: blur(20px);
transition: var(--ec-transition);
}
.page-card:hover {
box-shadow: var(--ec-shadow-hover);
transform: translateY(-2px);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 26px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
}
.page-subtitle {
margin: 8px 0 0;
color: var(--ec-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.page-section-title {
margin: 0 0 14px;
font-size: 16px;
font-weight: 700;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid var(--ec-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: var(--ec-text-secondary);
font-size: 11px;
letter-spacing: 0.1em;
}
.section-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
}
.section-span-4 {
grid-column: span 4;
}
.section-span-6 {
grid-column: span 6;
}
.section-span-8 {
grid-column: span 8;
}
.section-span-12 {
grid-column: span 12;
}
.stat-card {
padding: 20px;
}
.stat-card__label {
color: var(--ec-text-secondary);
font-size: 13px;
}
.stat-card__value {
margin-top: 12px;
font-size: 32px;
font-weight: 700;
}
.stat-card__helper {
margin-top: 10px;
color: var(--ec-text-secondary);
font-size: 13px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.metric-tile {
padding: 18px 18px 16px;
border: 1px solid var(--ec-border);
border-radius: var(--ec-radius-md);
background: rgba(255, 255, 255, 0.58);
box-shadow: var(--ec-shadow-soft);
transition: var(--ec-transition);
}
.metric-tile:hover {
box-shadow: var(--ec-shadow-hover);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.82);
}
.metric-tile__label {
color: var(--ec-text-secondary);
font-size: 12px;
}
.metric-tile__value {
margin-top: 12px;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.03em;
}
.metric-tile__footnote {
margin-top: 10px;
color: var(--ec-text-secondary);
font-size: 12px;
line-height: 1.4;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.filter-bar__grow {
flex: 1 1 260px;
}
.split-panel {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 20px;
}
.info-list {
display: grid;
gap: 12px;
}
.info-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid rgba(34, 45, 37, 0.08);
}
.info-item:last-child {
border-bottom: 0;
}
.info-item__label {
color: var(--ec-text-secondary);
font-size: 13px;
}
.info-item__value {
text-align: right;
font-size: 14px;
font-weight: 600;
}
.law-chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: var(--ec-accent-soft);
color: var(--ec-accent-strong);
font-size: 12px;
font-weight: 600;
}
.law-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ec-accent);
box-shadow: 0 0 0 5px rgba(34, 59, 49, 0.08);
}
.shell-stack {
display: grid;
gap: 16px;
}
.hero-panel {
position: relative;
overflow: hidden;
padding: 24px;
border-radius: var(--ec-radius-xl);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.56)),
linear-gradient(120deg, rgba(34, 59, 49, 0.06), transparent 48%);
}
.hero-panel::after {
content: '';
position: absolute;
right: -40px;
top: -40px;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(34, 59, 49, 0.1), transparent 68%);
}
.hero-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 20px;
}
.hero-main {
display: grid;
gap: 18px;
}
.hero-kicker {
display: flex;
align-items: center;
gap: 10px;
color: var(--ec-text-secondary);
font-size: 12px;
letter-spacing: 0.12em;
}
.hero-main h1,
.hero-main h2 {
margin: 0;
font-size: 34px;
line-height: 1.06;
letter-spacing: -0.04em;
}
.hero-main p {
max-width: 560px;
margin: 0;
color: var(--ec-text-secondary);
font-size: 14px;
line-height: 1.6;
}
.hero-side {
display: grid;
gap: 12px;
align-content: start;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.quick-card {
padding: 16px;
border: 1px solid var(--ec-border);
border-radius: var(--ec-radius-md);
background: rgba(255, 255, 255, 0.56);
transition: var(--ec-transition);
}
.quick-card:hover {
background: rgba(255, 255, 255, 0.88);
transform: translateY(-1px);
box-shadow: var(--ec-shadow-soft);
}
.quick-card strong {
display: block;
font-size: 24px;
letter-spacing: -0.03em;
}
.quick-card span {
display: block;
margin-top: 6px;
color: var(--ec-text-secondary);
font-size: 12px;
}
.compact-list {
display: grid;
gap: 10px;
}
.compact-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border: 1px solid var(--ec-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
transition: var(--ec-transition);
}
.compact-row:hover {
background: rgba(255, 255, 255, 0.85);
box-shadow: var(--ec-shadow-soft);
}
.compact-row__main {
display: grid;
gap: 4px;
}
.compact-row__title {
font-size: 14px;
font-weight: 600;
}
.compact-row__meta {
color: var(--ec-text-secondary);
font-size: 12px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-pill--success {
background: rgba(64, 132, 91, 0.12);
color: #2f6d4b;
}
.status-pill--warning {
background: rgba(210, 156, 58, 0.14);
color: #8a6824;
}
.status-pill--danger {
background: rgba(159, 75, 66, 0.12);
color: #8e4138;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.subtle-panel {
padding: 18px;
border: 1px solid var(--ec-border);
border-radius: var(--ec-radius-md);
background: rgba(255, 255, 255, 0.5);
}
.table-meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--ec-text-secondary);
font-size: 12px;
}
.muted {
color: var(--ec-text-secondary);
}
:deep(.el-button--primary) {
--el-button-bg-color: var(--ec-accent);
--el-button-border-color: var(--ec-accent);
--el-button-hover-bg-color: var(--ec-accent-strong);
--el-button-hover-border-color: var(--ec-accent-strong);
--el-button-active-bg-color: var(--ec-accent-strong);
--el-button-active-border-color: var(--ec-accent-strong);
}
:deep(.el-input__wrapper),
:deep(.el-select__wrapper),
:deep(.el-textarea__inner) {
border-radius: 14px;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
}
:deep(.el-table) {
--el-table-header-bg-color: rgba(242, 238, 231, 0.88);
--el-table-border-color: rgba(34, 45, 37, 0.08);
--el-table-row-hover-bg-color: rgba(228, 235, 231, 0.48);
--el-table-bg-color: transparent;
--el-fill-color-blank: transparent;
}
:deep(.el-table th.el-table__cell) {
color: var(--ec-text-secondary);
font-weight: 600;
font-size: 12px;
}
:deep(.el-table tr) {
background: transparent;
}
:deep(.el-table .cell) {
padding-top: 4px;
padding-bottom: 4px;
}
:deep(.el-menu) {
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.7);
--el-menu-active-color: var(--ec-accent-strong);
}
:deep(.el-button) {
border-radius: 14px;
}
@media (max-width: 1200px) {
.section-span-4,
.section-span-6,
.section-span-8 {
grid-column: span 12;
}
.metric-grid,
.split-panel,
.hero-grid,
.quick-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-shell {
padding: 16px;
}
.page-title {
font-size: 24px;
}
.metric-grid {
grid-template-columns: 1fr;
}
.hero-main h1,
.hero-main h2 {
font-size: 28px;
}
}
.section-panel {
padding: 24px;
}
.table-stack {
display: grid;
gap: 6px;
}
.page-form :deep(.el-form-item) {
margin-bottom: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.form-grid__full {
grid-column: 1 / -1;
}
.panel-title {
margin: 12px 0 18px;
font-size: 14px;
font-weight: 700;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--ec-border);
border-radius: var(--ec-radius-md);
background: rgba(255, 255, 255, 0.58);
transition: var(--ec-transition);
}
.summary-card:hover {
background: rgba(255, 255, 255, 0.88);
transform: translateY(-2px);
box-shadow: var(--ec-shadow-soft);
}
.summary-card__label,
.summary-card__meta {
color: var(--ec-text-secondary);
font-size: 12px;
}
.summary-card__value {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.03em;
}
@media (max-width: 768px) {
.form-grid,
.summary-grid {
grid-template-columns: 1fr;
}
}

10
frontend_admin/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { Calendar, Expand, Fold, SwitchButton } from '@element-plus/icons-vue'
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { routes } from '@/router/routes'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const menuRoutes = computed(() => {
const root = routes.find(item => item.path === '/')
return (root?.children ?? []).filter(item => !item.meta?.hidden && authStore.hasAnyRole(item.meta?.roles))
})
const activeLabel = computed(() => route.meta.title || '首页')
const activeSubtitle = computed(() => route.meta.subtitle || appStore.appTagline)
const activeMenuPath = computed(() => String(route.meta.activeMenu || route.path))
const currentUserName = computed(() => authStore.currentUser?.realName || '访客')
const currentFirmName = computed(() => authStore.currentUser?.tenantName || '平台')
const currentDateLabel = computed(() => {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(new Date())
})
async function handleLogout() {
await authStore.logout()
ElMessage.success('已退出登录')
await router.replace('/login')
}
</script>
<template>
<el-container class="admin-layout">
<el-aside :width="appStore.asideWidth" class="admin-layout__aside">
<div class="aside-main">
<div class="brand-panel">
<div class="brand-panel__badge">
<svg viewBox="0 0 36 36" width="28" height="28" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="shadow-back" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000" flood-opacity="0.12"/>
</filter>
<filter id="shadow-front" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="-2" dy="4" stdDeviation="3" flood-color="#000" flood-opacity="0.35"/>
</filter>
<linearGradient id="grad-back" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ececf0"/>
</linearGradient>
<linearGradient id="grad-front" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3c3d42"/>
<stop offset="100%" stop-color="#111214"/>
</linearGradient>
<linearGradient id="stroke-front" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#8a8b90"/>
<stop offset="100%" stop-color="#1a1b1d"/>
</linearGradient>
</defs>
<g transform="translate(18, 18)">
<!-- Back card / Top-Left -->
<g transform="rotate(-8) translate(-11, -14)">
<rect x="0" y="0" width="15" height="20" rx="3.5" fill="url(#grad-back)" stroke="#dcdde0" stroke-width="1" filter="url(#shadow-back)"/>
<rect x="3" y="3" width="4" height="3" rx="1" fill="#d1d1d6"/>
<line x1="3" y1="8" x2="11" y2="8" stroke="#eaeaea" stroke-width="1.5" stroke-linecap="round"/>
</g>
<!-- Front card / Bottom-Right -->
<g transform="rotate(5) translate(-3, -4)">
<rect x="0" y="0" width="15" height="20" rx="3.5" fill="url(#grad-front)" stroke="url(#stroke-front)" stroke-width="1.2" filter="url(#shadow-front)"/>
<rect x="3" y="3" width="4" height="3" rx="1" fill="#5c5d63"/>
<line x1="3" y1="9" x2="10" y2="9" stroke="#222" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="12" x2="7" y2="12" stroke="#222" stroke-width="1.5" stroke-linecap="round"/>
</g>
</g>
</svg>
</div>
</div>
<div class="aside-menu-wrap">
<el-menu
:default-active="activeMenuPath"
class="admin-layout__menu"
:collapse="appStore.asideCollapsed"
router
>
<el-menu-item
v-for="menu in menuRoutes"
:key="menu.path"
:index="`/${menu.path}`"
>
<el-icon v-if="menu.meta?.icon">
<component :is="menu.meta.icon" />
</el-icon>
<template #title>{{ menu.meta?.title }}</template>
</el-menu-item>
</el-menu>
</div>
</div>
<div v-if="!appStore.asideCollapsed" class="aside-profile">
<strong>{{ currentFirmName }}</strong>
</div>
</el-aside>
<el-container class="admin-layout__content">
<el-header class="admin-layout__header">
<div class="toolbar-left">
<el-button
circle
class="toolbar-toggle"
@click="appStore.toggleAside"
>
<el-icon>
<component :is="appStore.asideCollapsed ? Expand : Fold" />
</el-icon>
</el-button>
<div class="toolbar-copy">
<div class="toolbar-copy__title">{{ activeLabel }}</div>
<div class="toolbar-copy__subtitle">{{ activeSubtitle }}</div>
</div>
</div>
<div class="toolbar-right">
<div class="toolbar-chip">
<el-icon><Calendar /></el-icon>
<span>{{ currentDateLabel }}</span>
</div>
<div class="toolbar-user">
<div class="toolbar-user__meta">
<strong>{{ currentUserName }}</strong>
</div>
<el-button text class="toolbar-user__logout" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
</div>
</el-header>
<el-main class="admin-layout__main">
<RouterView />
</el-main>
</el-container>
</el-container>
</template>
<style scoped lang="scss">
.admin-layout {
min-height: 100vh;
background: transparent;
}
.admin-layout__aside {
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
padding: 22px 16px 18px;
border-right: 1px solid rgba(148, 163, 184, 0.18);
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.1), transparent 36%),
linear-gradient(180deg, #243244 0%, #1e293b 46%, #182230 100%);
box-shadow:
inset -1px 0 0 rgba(255, 255, 255, 0.04),
18px 0 36px rgba(15, 23, 42, 0.24);
}
.aside-main {
position: relative;
flex: 1;
min-height: 0;
}
.brand-panel {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0 12px;
}
.brand-panel__badge {
display: grid;
place-items: center;
width: 54px;
height: 54px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(226, 232, 240, 0.92));
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
}
.aside-menu-wrap {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
}
.admin-layout__menu {
border-right: 0;
background: transparent;
}
.admin-layout__menu :deep(.el-menu-item) {
height: 52px;
margin-bottom: 10px;
border-radius: 16px;
color: rgba(226, 232, 240, 0.84);
font-weight: 600;
}
.admin-layout__menu :deep(.el-menu-item:last-child) {
margin-bottom: 0;
}
.admin-layout__menu :deep(.el-menu-item:hover) {
color: #f8fafc;
background: rgba(255, 255, 255, 0.08);
}
.admin-layout__menu :deep(.is-active) {
color: rgba(226, 232, 240, 0.84);
background: transparent;
box-shadow: none;
}
.aside-profile {
display: grid;
gap: 4px;
padding: 16px 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16);
}
.aside-profile strong {
display: block;
color: #cbd5e1;
font-size: 16px;
font-weight: 700;
line-height: 1.5;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.admin-layout__content {
min-width: 0;
background: #F7F8FA;
}
.admin-layout__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: auto;
padding: 28px 32px 0;
background: transparent;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 14px;
}
.toolbar-toggle {
border-color: rgba(22, 39, 31, 0.08);
background: rgba(255, 255, 255, 0.82);
color: var(--ec-accent-strong);
}
.toolbar-copy__title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.04em;
}
.toolbar-copy__subtitle {
margin-top: 6px;
color: var(--ec-text-secondary);
font-size: 13px;
}
.toolbar-chip,
.toolbar-user {
display: flex;
align-items: center;
gap: 10px;
height: 56px;
padding: 0 16px;
border: 1px solid rgba(22, 39, 31, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
box-shadow: 0 12px 30px rgba(22, 32, 27, 0.06);
}
.toolbar-chip {
color: var(--ec-text-secondary);
font-size: 13px;
}
.toolbar-user__meta {
display: flex;
flex-direction: column;
text-align: right;
}
.toolbar-user__meta strong {
font-size: 14px;
}
.toolbar-user__logout {
color: var(--ec-text-secondary);
}
.admin-layout__main {
padding: 10px 0 32px;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.2s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateX(-6px);
}
@media (max-width: 1100px) {
.admin-layout__header {
padding: 24px 20px 0;
}
.toolbar-chip {
display: none;
}
}
@media (max-width: 900px) {
.admin-layout__aside {
position: fixed;
z-index: 30;
}
.toolbar-user__meta {
display: none;
}
.toolbar-copy__title {
font-size: 24px;
}
}
</style>

View File

@@ -0,0 +1,19 @@
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
import { pinia } from './stores'
import './assets/styles/index.scss'
import 'element-plus/dist/index.css'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@@ -0,0 +1,55 @@
export interface TenantItem {
id: number
tenantName: string
tenantCode: string
contactName: string
contactPhone: string
adminName: string
userLimit: number
activeUsers: number
miniappAppId: string
storageUsage: string
city: string
}
export const tenantList: TenantItem[] = [
{
id: 1,
tenantName: '衡知律师事务所',
tenantCode: 'hengzhi-law',
contactName: '李主任',
contactPhone: '13800138000',
adminName: '王律师',
userLimit: 20,
activeUsers: 12,
miniappAppId: 'wx8b1c6d1f0a1e0001',
storageUsage: '1.6 GB / 10 GB',
city: '南京',
},
{
id: 2,
tenantName: '明理律师事务所',
tenantCode: 'mingli-law',
contactName: '周主任',
contactPhone: '13800138008',
adminName: '周律师',
userLimit: 50,
activeUsers: 34,
miniappAppId: 'wx8b1c6d1f0a1e0002',
storageUsage: '3.8 GB / 20 GB',
city: '上海',
},
{
id: 3,
tenantName: '启衡律师事务所',
tenantCode: 'qiheng-law',
contactName: '陈律师',
contactPhone: '13800138018',
adminName: '陈律师',
userLimit: 30,
activeUsers: 18,
miniappAppId: 'wx8b1c6d1f0a1e0003',
storageUsage: '4.2 GB / 10 GB',
city: '杭州',
},
]

View File

@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { pinia } from '@/stores'
import { routes } from './routes'
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior() {
return { top: 0 }
},
})
router.beforeEach((to) => {
const authStore = useAuthStore(pinia)
return authStore.bootstrap().then(() => {
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
path: '/login',
query: {
redirect: to.fullPath,
},
}
}
if (to.meta.guestOnly && authStore.isAuthenticated) {
return '/dashboard'
}
if (to.meta.roles && !authStore.hasAnyRole(to.meta.roles)) {
return '/dashboard'
}
return true
})
})

View File

@@ -0,0 +1,190 @@
import type { RouteRecordRaw } from 'vue-router'
import type { RoleCode } from '@/types/common'
import {
HomeFilled,
Lock,
Notebook,
OfficeBuilding,
Postcard,
Setting,
User,
UserFilled,
} from '@element-plus/icons-vue'
export interface AppRouteMeta {
title: string
icon?: unknown
hidden?: boolean
activeMenu?: string
requiresAuth?: boolean
guestOnly?: boolean
subtitle?: string
roles?: RoleCode[]
}
declare module 'vue-router' {
interface RouteMeta extends AppRouteMeta {}
}
export const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/system/LoginView.vue'),
meta: {
title: '登录',
icon: Lock,
hidden: true,
guestOnly: true,
},
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
meta: {
requiresAuth: true,
title: '控制台',
},
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: {
title: '首页',
icon: HomeFilled,
subtitle: '概览核心数字与主链路状态。',
requiresAuth: true,
roles: ['PLATFORM_SUPER_ADMIN', 'TENANT_ADMIN', 'TENANT_USER'],
},
},
{
path: 'tenants',
name: 'tenants',
component: () => import('@/views/tenant/TenantListView.vue'),
meta: {
title: '租户管理',
icon: OfficeBuilding,
subtitle: '查看租户状态、席位与续期节点。',
requiresAuth: true,
roles: ['PLATFORM_SUPER_ADMIN'],
},
},
{
path: 'firm-profile',
name: 'firm-profile',
component: () => import('@/views/tenant/FirmProfileView.vue'),
meta: {
title: '事务所资料',
icon: Notebook,
subtitle: '维护主页信息、专业领域与组织架构。',
requiresAuth: true,
roles: ['TENANT_ADMIN'],
},
},
{
path: 'lawyers',
name: 'lawyers',
component: () => import('@/views/tenant/LawyerListView.vue'),
meta: {
title: '律师管理',
icon: UserFilled,
subtitle: '统一维护律师名片、展示顺序与对外资料。',
requiresAuth: true,
roles: ['TENANT_ADMIN'],
},
},
{
path: 'lawyers/create',
name: 'lawyer-create',
component: () => import('@/views/card/MyCardView.vue'),
meta: {
title: '新增律师',
subtitle: '创建律师名片并补充对外展示资料。',
activeMenu: '/lawyers',
hidden: true,
requiresAuth: true,
roles: ['TENANT_ADMIN'],
},
},
{
path: 'lawyers/:cardId/edit',
name: 'lawyer-edit',
component: () => import('@/views/card/MyCardView.vue'),
meta: {
title: '编辑律师',
subtitle: '修改律师名片、图片素材与展示信息。',
activeMenu: '/lawyers',
hidden: true,
requiresAuth: true,
roles: ['TENANT_ADMIN'],
},
},
{
path: 'tenant-users',
name: 'tenant-users',
component: () => import('@/views/tenant/UserListView.vue'),
meta: {
title: '用户管理',
icon: UserFilled,
subtitle: '管理当前租户成员账号与角色。',
hidden: true,
requiresAuth: true,
roles: ['TENANT_ADMIN'],
},
},
{
path: 'tenant-miniapp',
name: 'tenant-miniapp',
component: () => import('@/views/tenant/TenantMiniappBindingView.vue'),
meta: {
title: '小程序管理',
icon: Setting,
subtitle: '绑定当前租户站点与小程序 AppID。',
requiresAuth: true,
roles: ['TENANT_ADMIN'],
},
},
{
path: 'my-card',
name: 'my-card',
component: () => import('@/views/card/MyCardView.vue'),
meta: {
title: '我的名片',
icon: Postcard,
subtitle: '编辑并发布个人电子名片。',
hidden: true,
requiresAuth: true,
roles: ['TENANT_ADMIN', 'TENANT_USER'],
},
},
{
path: 'profile',
name: 'profile',
component: () => import('@/views/system/ProfileView.vue'),
meta: {
title: '个人中心',
icon: User,
subtitle: '查看登录信息并修改密码。',
requiresAuth: true,
roles: ['PLATFORM_SUPER_ADMIN', 'TENANT_ADMIN', 'TENANT_USER'],
},
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/system/SystemSettingsView.vue'),
meta: {
title: '系统配置',
icon: Setting,
subtitle: '维护平台参数与基础配置。',
requiresAuth: true,
hidden: true,
roles: ['PLATFORM_SUPER_ADMIN'],
},
},
],
},
]

View File

@@ -0,0 +1,22 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', () => {
const appTitle = ref(import.meta.env.VITE_APP_TITLE || 'Easycard 管理后台')
const appTagline = ref('律所电子名片后台')
const asideCollapsed = ref(false)
const asideWidth = computed(() => (asideCollapsed.value ? '84px' : '240px'))
function toggleAside() {
asideCollapsed.value = !asideCollapsed.value
}
return {
appTitle,
appTagline,
asideCollapsed,
asideWidth,
toggleAside,
}
})

View File

@@ -0,0 +1,109 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import * as authApi from '@/api/auth'
import type { CurrentUser } from '@/types/auth'
import type { RoleCode } from '@/types/common'
import { clearAccessToken, getAccessToken, setAccessToken } from '@/utils/auth'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref(getAccessToken())
const currentUser = ref<CurrentUser | null>(null)
const initialized = ref(false)
const bootstrapping = ref(false)
let bootstrapPromise: Promise<void> | null = null
const isAuthenticated = computed(() => Boolean(accessToken.value && currentUser.value))
function clearSession() {
accessToken.value = ''
currentUser.value = null
clearAccessToken()
}
function hasAnyRole(roles?: RoleCode[]) {
if (!roles || roles.length === 0) {
return true
}
const codes = currentUser.value?.roleCodes ?? []
return roles.some(role => codes.includes(role))
}
async function bootstrap() {
if (initialized.value) {
return Promise.resolve()
}
if (!accessToken.value) {
initialized.value = true
return Promise.resolve()
}
if (bootstrapPromise) {
return bootstrapPromise
}
bootstrapping.value = true
bootstrapPromise = authApi.getCurrentUser()
.then((user) => {
currentUser.value = user
})
.catch(() => {
clearSession()
})
.finally(() => {
initialized.value = true
bootstrapping.value = false
bootstrapPromise = null
})
return bootstrapPromise
}
async function login(payload: { username: string; password: string }) {
const result = await authApi.login(payload)
accessToken.value = result.accessToken
currentUser.value = result.currentUser
initialized.value = true
setAccessToken(result.accessToken)
}
async function logout() {
try {
if (accessToken.value) {
await authApi.logout()
}
} finally {
clearSession()
initialized.value = true
}
}
async function refreshCurrentUser() {
if (!accessToken.value) {
clearSession()
return null
}
const user = await authApi.getCurrentUser()
currentUser.value = user
return user
}
async function changePassword(payload: { oldPassword: string; newPassword: string }) {
await authApi.changePassword(payload)
await refreshCurrentUser()
}
return {
accessToken,
currentUser,
initialized,
bootstrapping,
isAuthenticated,
bootstrap,
login,
logout,
refreshCurrentUser,
changePassword,
hasAnyRole,
clearSession,
}
})

View File

@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()

View File

@@ -0,0 +1,20 @@
import type { RoleCode } from './common'
export interface CurrentUser {
id: number
tenantId: number
username: string
realName: string
userType: string
tenantName: string
roleName: string
roleCodes: RoleCode[]
deptName: string
jobTitle: string
mustUpdatePassword: boolean
}
export interface LoginResult {
accessToken: string
currentUser: CurrentUser
}

View File

@@ -0,0 +1,13 @@
export interface ApiResponse<T> {
code: string
message: string
data: T
}
export interface ApiErrorPayload {
code: string
message: string
status?: number
}
export type RoleCode = 'PLATFORM_SUPER_ADMIN' | 'TENANT_ADMIN' | 'TENANT_USER'

View File

@@ -0,0 +1,8 @@
export interface FileAsset {
id: number
originalName: string
mimeType: string
fileSize: number
accessUrl: string
createdTime: string
}

View File

@@ -0,0 +1,40 @@
export interface TenantDetail {
id: number
tenantCode: string
tenantName: string
tenantShortName: string
userLimit: number
storageLimitMb: number
contactName: string
contactPhone: string
miniappAppId: string
miniappName: string
miniappOriginalId: string
}
export interface MiniappConfig {
tenantId: number
tenantCode: string
tenantName: string
envCode: 'DEV' | 'TEST' | 'PROD'
miniappAppId: string
miniappName: string
miniappOriginalId: string
requestDomain: string
uploadDomain: string
downloadDomain: string
versionTag: string
publishStatus: 'UNCONFIGURED' | 'DRAFT' | 'PUBLISHED' | 'DISABLED'
}
export interface TenantFormPayload {
tenantName: string
miniappAppId: string
}
export interface TenantCreatePayload {
tenantName: string
adminUsername: string
adminPassword: string
miniappAppId: string
}

View File

@@ -0,0 +1,137 @@
export interface DashboardStats {
totalCards: number
publishedCards: number
totalViews: number
totalShares: number
todayViews: number
}
export interface CardTrendItem {
statDate: string
viewCount: number
shareCount: number
}
export interface FirmProfile {
id: number | null
firmName: string
firmShortName: string
englishName: string
logoAssetId: number | null
heroAssetId: number | null
logoUrl?: string
heroUrl?: string
intro: string
hotlinePhone: string
websiteUrl: string
wechatOfficialAccount?: string
hqAddress: string
hqLatitude: string | null
hqLongitude: string | null
}
export interface TenantMiniappConfig {
tenantId: number
tenantCode: string
tenantName: string
envCode: 'DEV' | 'TEST' | 'PROD'
miniappAppId: string
miniappName: string
miniappOriginalId: string
requestDomain: string
uploadDomain: string
downloadDomain: string
versionTag: string
publishStatus: 'UNCONFIGURED' | 'DRAFT' | 'PUBLISHED' | 'DISABLED'
}
export interface TenantMiniappConfigPayload {
miniappAppId: string
}
export interface PracticeArea {
id: number
areaCode: string
areaName: string
displayOrder: number
areaStatus: string
}
export interface Department {
id: number
parentId: number
deptCode: string
deptName: string
deptType: string
contactPhone: string
address: string
displayOrder: number
deptStatus: string
}
export interface TenantUser {
id: number
username: string
realName: string
mobile: string
email: string
jobTitle: string
userStatus: 'ENABLED' | 'DISABLED'
memberSort: number
deptId: number | null
deptName: string
roleCode: 'TENANT_ADMIN' | 'TENANT_USER'
}
export interface TenantUserUpsertPayload {
username: string
realName: string
mobile: string
email: string
jobTitle: string
userStatus: 'ENABLED' | 'DISABLED'
deptId: number | null
roleCode: 'TENANT_ADMIN' | 'TENANT_USER'
}
export interface LawyerListItem {
id: number
userId: number | null
cardName: string
cardTitle: string
mobile: string
email: string
publishStatus: 'DRAFT' | 'PUBLISHED' | 'OFFLINE' | string
displayOrder: number
createdTime: string
updatedTime: string
}
export interface CardDetail {
id: number | null
userId: number | null
username: string
deptId: number | null
deptName: string
cardName: string
cardTitle: string
mobile: string
telephone: string
email: string
officeAddress: string
avatarAssetId: number | null
coverAssetId: number | null
wechatQrAssetId: number | null
avatarUrl: string
coverUrl: string
wechatQrUrl: string
bio: string
certificateNo: string
educationInfo: string
honorInfo: string
isPublic: number
isRecommended: number
publishStatus: string
displayOrder: number
specialties: string[]
}

View File

@@ -0,0 +1,13 @@
const ACCESS_TOKEN_KEY = 'easycard-admin-access-token'
export function getAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_KEY) || ''
}
export function setAccessToken(token: string) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, token)
}
export function clearAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_KEY)
}

View File

@@ -0,0 +1,40 @@
import { ElMessage } from 'element-plus'
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
export function validateImageFile(file: File, label: string) {
if (!file.type.startsWith('image/')) {
ElMessage.error(`${label}仅支持图片格式`)
return false
}
if (file.size > MAX_IMAGE_SIZE) {
ElMessage.error(`${label}大小不能超过 5MB`)
return false
}
return true
}
export function resolveUploadErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) {
return error.message
}
if (typeof error === 'object' && error && 'message' in error) {
const message = Reflect.get(error, 'message')
if (typeof message === 'string' && message) {
return message
}
}
return fallback
}
export function createUploadAjaxError(message: string) {
return Object.assign(new Error(message), {
status: 0,
method: 'post',
url: '',
})
}

View File

@@ -0,0 +1,23 @@
export function formatDateTime(value?: string | null, fallback = '-') {
if (!value) {
return fallback
}
const normalizedValue = value.includes('T') ? value : value.replace(' ', 'T')
const date = new Date(normalizedValue)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
export function compactNumber(value?: number | null) {
return new Intl.NumberFormat('zh-CN').format(value ?? 0)
}

View File

@@ -0,0 +1,84 @@
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import type { ApiErrorPayload, ApiResponse } from '@/types/common'
import { clearAccessToken, getAccessToken } from '@/utils/auth'
interface RequestConfig extends InternalAxiosRequestConfig {
silentError?: boolean
}
function normalizeBasePath(basePath: string) {
if (!basePath || basePath === '/') {
return ''
}
return basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
}
function stripBasePath(pathname: string, basePath: string) {
if (!basePath || !pathname.startsWith(basePath)) {
return pathname || '/'
}
return pathname.slice(basePath.length) || '/'
}
const appBasePath = normalizeBasePath(import.meta.env.BASE_URL || '/')
const loginPath = `${appBasePath}/login` || '/login'
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || `${appBasePath || ''}`,
timeout: 10000,
})
http.interceptors.request.use((config) => {
const nextConfig = config as RequestConfig
const token = getAccessToken()
const requestUrl = nextConfig.url || ''
const skipAuth = requestUrl.includes('/api/v1/auth/login')
if (token && !skipAuth) {
nextConfig.headers.Authorization = `Bearer ${token}`
}
return nextConfig
})
http.interceptors.response.use(
(response) => {
const payload = response.data as ApiResponse<unknown>
if (!payload || typeof payload.code !== 'string') {
return response.data
}
if (payload.code === '0') {
return payload.data
}
const error: ApiErrorPayload = {
code: payload.code,
message: payload.message || '请求失败',
status: response.status,
}
ElMessage.error(error.message)
return Promise.reject(error)
},
(error: AxiosError<ApiResponse<unknown>>) => {
const status = error.response?.status
const payload = error.response?.data
const message = payload?.message || error.message || '网络异常'
const apiError: ApiErrorPayload = {
code: payload?.code || 'HTTP_ERROR',
message,
status,
}
if (status === 401) {
clearAccessToken()
if (window.location.pathname !== loginPath) {
const redirectPath = stripBasePath(window.location.pathname, appBasePath)
window.location.href = `${loginPath}?redirect=${encodeURIComponent(redirectPath)}`
}
}
ElMessage.error(apiError.message)
return Promise.reject(apiError)
},
)

View File

@@ -0,0 +1,24 @@
import type { RoleCode } from '@/types/common'
const roleLabelMap: Record<RoleCode, string> = {
PLATFORM_SUPER_ADMIN: '平台超级管理员',
TENANT_ADMIN: '租户管理员',
TENANT_USER: '普通用户',
}
export function getRoleLabel(role?: string | null) {
if (!role) {
return '-'
}
return roleLabelMap[role as RoleCode] ?? role
}
export function resolveRoleLabel(roleCodes?: RoleCode[], fallback?: string | null) {
if (roleCodes && roleCodes.length > 0) {
const matched = roleLabelMap[roleCodes[0]]
if (matched) {
return matched
}
}
return getRoleLabel(fallback)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as platformApi from '@/api/platform'
import * as tenantApi from '@/api/tenant'
import { useAuthStore } from '@/stores/auth'
import type { DashboardStats } from '@/types/tenant'
import { compactNumber } from '@/utils/format'
import { resolveRoleLabel } from '@/utils/role'
const authStore = useAuthStore()
const router = useRouter()
const loading = ref(false)
const tenantTotal = ref(0)
const boundMiniapps = ref(0)
const tenantStats = ref<DashboardStats | null>(null)
const isPlatform = computed(() => authStore.hasAnyRole(['PLATFORM_SUPER_ADMIN']))
const isTenantAdmin = computed(() => authStore.hasAnyRole(['TENANT_ADMIN']))
const isTenantUser = computed(() => !isPlatform.value && !isTenantAdmin.value && authStore.hasAnyRole(['TENANT_USER']))
const currentRoleLabel = computed(() => resolveRoleLabel(authStore.currentUser?.roleCodes, authStore.currentUser?.roleName))
const greetingText = computed(() => {
const hour = new Date().getHours()
if (hour < 11) {
return '上午好'
}
if (hour < 14) {
return '中午好'
}
if (hour < 18) {
return '下午好'
}
return '晚上好'
})
const greetingName = computed(() => {
const realName = authStore.currentUser?.realName?.trim()
if (isPlatform.value) {
if (!realName || realName === '平台管理员') {
return '超级管理员'
}
return realName
}
return realName || currentRoleLabel.value || '管理员'
})
const metrics = computed(() => {
if (isPlatform.value) {
return [
{
key: 'tenantTotal',
label: '租户总数',
value: String(tenantTotal.value).padStart(2, '0'),
hint: '平台已开通主体',
},
{
key: 'boundMiniapps',
label: '已绑定 AppID',
value: String(boundMiniapps.value).padStart(2, '0'),
hint: '小程序已完成租户映射',
},
]
}
const tenantMetrics = [
{
key: 'totalCards',
label: '名片总数',
value: compactNumber(tenantStats.value?.totalCards),
hint: '用户名片数量',
},
{
key: 'totalViews',
label: '累计浏览',
value: compactNumber(tenantStats.value?.totalViews),
hint: '访客累计浏览量',
},
]
if (isTenantUser.value) {
return tenantMetrics.filter(metric => metric.key !== 'totalCards')
}
return tenantMetrics
})
async function loadDashboard() {
loading.value = true
try {
if (isPlatform.value) {
const tenants = await platformApi.listTenants()
tenantTotal.value = tenants.length
boundMiniapps.value = tenants.filter(item => Boolean(item.miniappAppId)).length
return
}
tenantStats.value = await tenantApi.getDashboardStats()
} finally {
loading.value = false
}
}
function handleMetricClick(metricKey: string) {
if (metricKey !== 'totalCards' || !isTenantAdmin.value) {
return
}
router.push({ name: 'lawyers' })
}
onMounted(() => {
loadDashboard()
})
</script>
<template>
<div class="dashboard-page page-shell">
<div class="dashboard-stage" v-loading="loading">
<section class="dashboard-hero">
<div class="dashboard-hero__copy">
<span v-if="isPlatform" class="dashboard-hero__eyebrow">平台总览</span>
<h1>{{ greetingText }}{{ greetingName }}</h1>
</div>
</section>
<section
class="dashboard-metrics"
:class="{ 'dashboard-metrics--single': metrics.length === 1 }"
>
<article
v-for="metric in metrics"
:key="metric.key"
class="dashboard-metric"
:class="{ 'dashboard-metric--clickable': metric.key === 'totalCards' && isTenantAdmin }"
:tabindex="metric.key === 'totalCards' && isTenantAdmin ? 0 : -1"
@click="handleMetricClick(metric.key)"
@keyup.enter="handleMetricClick(metric.key)"
@keyup.space.prevent="handleMetricClick(metric.key)"
>
<span class="dashboard-metric__label">{{ metric.label }}</span>
<strong class="dashboard-metric__value">{{ metric.value }}</strong>
<span class="dashboard-metric__hint">{{ metric.hint }}</span>
</article>
</section>
</div>
</div>
</template>
<style scoped lang="scss">
.dashboard-page {
display: flex;
justify-content: center;
}
.dashboard-stage {
display: grid;
gap: 20px;
width: min(100%, 1120px);
padding: 8px 0 24px;
}
.dashboard-hero {
position: relative;
display: flex;
justify-content: center;
padding: 24px 24px 8px;
}
.dashboard-hero::after {
content: '';
position: absolute;
inset: auto 40px 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(22, 39, 31, 0.1), transparent);
}
.dashboard-hero__copy {
display: grid;
justify-items: center;
gap: 12px;
max-width: 900px;
padding: 0 0 22px;
text-align: center;
}
.dashboard-hero__eyebrow {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 16px;
border: 1px solid rgba(123, 146, 117, 0.2);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #58715d;
font-size: 12px;
letter-spacing: 0.12em;
}
.dashboard-hero h1 {
margin: 0;
font-size: clamp(34px, 4.4vw, 56px);
line-height: 1.08;
letter-spacing: -0.04em;
}
.dashboard-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.dashboard-metrics--single {
grid-template-columns: minmax(0, 1fr);
}
.dashboard-metric {
display: grid;
justify-items: center;
gap: 14px;
min-height: 188px;
padding: 24px 28px;
border: 1px solid rgba(130, 146, 123, 0.14);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(250, 250, 247, 0.8));
box-shadow: 0 16px 40px rgba(163, 171, 149, 0.1);
text-align: center;
}
.dashboard-metric--clickable {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.dashboard-metric--clickable:hover,
.dashboard-metric--clickable:focus-visible {
border-color: rgba(22, 39, 31, 0.18);
box-shadow: 0 20px 44px rgba(163, 171, 149, 0.16);
transform: translateY(-2px);
outline: none;
}
.dashboard-metric__label {
color: var(--ec-text-secondary);
font-size: 14px;
}
.dashboard-metric__value {
margin: auto 0;
font-size: clamp(52px, 7vw, 76px);
line-height: 0.98;
letter-spacing: -0.05em;
}
.dashboard-metric__hint {
color: var(--ec-text-secondary);
font-size: 13px;
}
@media (max-width: 960px) {
.dashboard-stage {
gap: 16px;
}
.dashboard-hero {
padding: 24px 0 4px;
}
.dashboard-hero::after {
inset: auto 0 0;
}
.dashboard-metrics {
grid-template-columns: 1fr;
gap: 20px;
}
.dashboard-metric {
min-height: 172px;
padding: 22px 20px;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="page-shell">
<div class="page-header">
<div>
<span class="eyebrow">ARCHIVE HUB</span>
<h1 class="page-title">资料档案</h1>
<p class="page-subtitle">
面向律师事务所的资料归档中心后续可承接制度文件模板素材导入导出与生成记录
</p>
</div>
</div>
<div class="section-grid">
<section class="page-card section-span-6" style="padding: 24px;">
<h2 class="page-section-title">当前预留区域</h2>
<div class="info-list">
<div class="info-item">
<span class="info-item__label">制度文档</span>
<span class="info-item__value">待接入</span>
</div>
<div class="info-item">
<span class="info-item__label">名片素材包</span>
<span class="info-item__value">待接入</span>
</div>
<div class="info-item">
<span class="info-item__label">批量导入任务</span>
<span class="info-item__value">待接入</span>
</div>
</div>
</section>
<section class="page-card section-span-6" style="padding: 24px;">
<el-empty description="资料档案模块待开发" />
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,388 @@
<script setup lang="ts">
import { Lock, User } from '@element-plus/icons-vue'
import type { FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
password: '',
})
const rules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
if (!formRef.value) {
return
}
const valid = await formRef.value.validate().then(() => true).catch(() => false)
if (!valid) {
return
}
loading.value = true
try {
await authStore.login({
username: form.username,
password: form.password,
})
ElMessage.success('登录成功')
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.replace(redirect)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-wrapper">
<div class="login-bg" aria-hidden="true">
<span class="login-bg__orb login-bg__orb--olive"></span>
<span class="login-bg__orb login-bg__orb--sand"></span>
<span class="login-bg__orb login-bg__orb--mist"></span>
<span class="login-bg__grid"></span>
<span class="login-bg__beam"></span>
</div>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<svg viewBox="0 0 36 36" width="60" height="60" fill="none" xmlns="http://www.w3.org/2000/svg" class="login-logo">
<defs>
<filter id="shadow-back" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000" flood-opacity="0.12"/>
</filter>
<filter id="shadow-front" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="-2" dy="4" stdDeviation="3" flood-color="#000" flood-opacity="0.35"/>
</filter>
<linearGradient id="grad-back" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ececf0"/>
</linearGradient>
<linearGradient id="grad-front" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3c3d42"/>
<stop offset="100%" stop-color="#111214"/>
</linearGradient>
<linearGradient id="stroke-front" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#8a8b90"/>
<stop offset="100%" stop-color="#1a1b1d"/>
</linearGradient>
</defs>
<g transform="translate(18, 18)">
<!-- Back card / Top-Left -->
<g transform="rotate(-8) translate(-11, -14)">
<rect x="0" y="0" width="15" height="20" rx="3.5" fill="url(#grad-back)" stroke="#dcdde0" stroke-width="1" filter="url(#shadow-back)"/>
<rect x="3" y="3" width="4" height="3" rx="1" fill="#d1d1d6"/>
<line x1="3" y1="8" x2="11" y2="8" stroke="#eaeaea" stroke-width="1.5" stroke-linecap="round"/>
</g>
<!-- Front card / Bottom-Right -->
<g transform="rotate(5) translate(-3, -4)">
<rect x="0" y="0" width="15" height="20" rx="3.5" fill="url(#grad-front)" stroke="url(#stroke-front)" stroke-width="1.2" filter="url(#shadow-front)"/>
<rect x="3" y="3" width="4" height="3" rx="1" fill="#5c5d63"/>
<line x1="3" y1="9" x2="10" y2="9" stroke="#222" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="12" x2="7" y2="12" stroke="#222" stroke-width="1.5" stroke-linecap="round"/>
</g>
</g>
</svg>
<h1 class="login-title">身份认证</h1>
<p class="login-subtitle">电子名片管理</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="login-form material-form"
size="large"
>
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="请输入账号" class="material-input">
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入密码"
@keyup.enter="handleLogin"
class="material-input"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-button
type="primary"
class="login-submit-btn material-btn"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.login-wrapper {
position: relative;
overflow: hidden;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top left, rgba(236, 241, 231, 0.92), transparent 30%),
radial-gradient(circle at right 18%, rgba(224, 215, 196, 0.5), transparent 28%),
linear-gradient(145deg, #f4f1e8 0%, #f8f7f3 42%, #eef3ee 100%);
}
.login-bg {
position: absolute;
inset: 0;
pointer-events: none;
}
.login-bg__orb {
position: absolute;
border-radius: 999px;
filter: blur(10px);
opacity: 0.85;
}
.login-bg__orb--olive {
top: 8%;
left: -6%;
width: 420px;
height: 420px;
background: radial-gradient(circle, rgba(170, 188, 157, 0.34) 0%, rgba(170, 188, 157, 0.08) 58%, transparent 76%);
}
.login-bg__orb--sand {
right: -8%;
bottom: 6%;
width: 460px;
height: 460px;
background: radial-gradient(circle, rgba(215, 196, 160, 0.28) 0%, rgba(215, 196, 160, 0.06) 60%, transparent 80%);
}
.login-bg__orb--mist {
top: 50%;
left: 50%;
width: 620px;
height: 620px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255, 255, 255, 0.84) 0%, rgba(255, 255, 255, 0.28) 52%, transparent 78%);
}
.login-bg__grid {
position: absolute;
inset: 28px;
border-radius: 36px;
border: 1px solid rgba(104, 119, 99, 0.08);
background-image:
linear-gradient(rgba(111, 124, 108, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(111, 124, 108, 0.07) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: radial-gradient(circle at center, black 46%, transparent 88%);
}
.login-bg__beam {
position: absolute;
top: 50%;
left: 50%;
width: min(78vw, 980px);
height: 320px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.66) 22%, rgba(255, 255, 255, 0.82) 50%, rgba(255, 255, 255, 0.42) 76%, rgba(255, 255, 255, 0) 100%);
transform: translate(-50%, -50%) rotate(-14deg);
filter: blur(22px);
opacity: 0.68;
}
.login-page {
position: relative;
z-index: 1;
width: 100%;
display: flex;
justify-content: center;
padding: 32px;
}
.login-card {
width: 100%;
max-width: 420px;
padding: 48px 40px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 28px;
box-shadow:
0 24px 60px rgba(43, 52, 45, 0.1),
0 6px 18px rgba(43, 52, 45, 0.04);
backdrop-filter: blur(22px);
box-sizing: border-box;
animation: slide-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
text-align: center;
}
.login-logo {
margin-bottom: 24px;
}
.login-title {
margin: 0 0 8px;
font-size: 28px;
color: #1a1a1a;
letter-spacing: -0.02em;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: #717275;
font-size: 14px;
letter-spacing: 0.1em;
}
.material-form :deep(.el-form-item) {
margin-bottom: 28px;
}
.material-form :deep(.el-form-item__error) {
padding-top: 6px;
}
/* Material Design Filled TextField Look */
.material-input :deep(.el-input__wrapper) {
box-shadow: none !important;
border-bottom: 2px solid transparent;
border-radius: 12px;
background-color: #f4f5f7;
padding: 10px 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.material-input :deep(.el-input__wrapper:hover) {
background-color: #eef0f3;
}
.material-input :deep(.el-input__wrapper.is-focus) {
border-bottom-color: var(--ec-accent-strong, #1a1a1a);
background-color: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08) !important;
}
.material-input :deep(input) {
color: #1a1a1a;
font-weight: 500;
font-size: 15px;
}
.material-btn {
width: 100%;
height: 52px;
font-size: 16px;
font-weight: 600;
border-radius: 14px;
margin-top: 12px;
background-color: var(--ec-accent-strong, #1a1a1a);
color: #ffffff;
border: none;
box-shadow: 0 8px 20px rgba(26, 26, 26, 0.24);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.material-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(26, 26, 26, 0.32);
}
.material-btn:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(26, 26, 26, 0.2);
}
@media (max-width: 480px) {
.login-wrapper {
background:
radial-gradient(circle at top, rgba(236, 241, 231, 0.88), transparent 36%),
linear-gradient(180deg, #f7f4ec 0%, #f4f6f2 100%);
}
.login-bg__grid,
.login-bg__beam {
display: none;
}
.login-bg__orb--olive,
.login-bg__orb--sand,
.login-bg__orb--mist {
filter: blur(18px);
opacity: 0.72;
}
.login-page {
padding: 24px;
}
.login-card {
padding: 32px 24px;
border-radius: 0;
border: 0;
background: transparent;
backdrop-filter: none;
box-shadow: none;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.login-header {
margin-bottom: 32px;
}
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const saving = ref(false)
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
async function submitPassword() {
if (!passwordForm.oldPassword || !passwordForm.newPassword) {
ElMessage.warning('请完整填写密码信息')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.warning('两次输入的新密码不一致')
return
}
saving.value = true
try {
await authStore.changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword,
})
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
ElMessage.success('密码已修改')
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page-shell profile-page">
<section class="page-card section-panel profile-card">
<div class="page-header">
<div>
<span class="eyebrow">SECURITY</span>
<h1 class="page-title">修改密码</h1>
</div>
</div>
<div class="password-panel">
<el-form label-position="top" class="password-form">
<div class="password-form__stack">
<el-form-item label="原密码">
<el-input v-model="passwordForm.oldPassword" type="password" show-password placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input v-model="passwordForm.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
</div>
<div class="password-form__footer">
<el-button type="primary" :loading="saving" @click="submitPassword">保存新密码</el-button>
</div>
</el-form>
</div>
</section>
</div>
</template>
<style scoped>
.profile-page {
display: flex;
min-height: calc(100vh - 132px);
}
.profile-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.profile-card .page-header {
flex-shrink: 0;
}
.profile-card .password-panel {
flex: 1;
}
.password-panel {
display: flex;
flex-direction: column;
justify-content: center;
}
.password-form {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
.password-form__stack {
display: grid;
width: min(520px, 100%);
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.password-form__footer {
display: flex;
justify-content: center;
width: min(520px, 100%);
margin-top: 24px;
}
@media (max-width: 900px) {
.profile-page {
display: block;
min-height: auto;
}
.password-form__stack {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="page-shell">
<div class="page-header">
<div>
<span class="eyebrow">SYSTEM CONFIG</span>
<h1 class="page-title">系统配置</h1>
<p class="page-subtitle">构建与维护平台核心参数环境与系统级配置项</p>
</div>
</div>
<div class="section-grid">
<section class="page-card section-span-6" style="padding: 24px;">
<h2 class="page-section-title">环境参数</h2>
<div class="info-list">
<div class="info-item">
<span class="info-item__label">对象存储</span>
<span class="info-item__value">MinIO / easycard</span>
</div>
<div class="info-item">
<span class="info-item__label">接口环境</span>
<span class="info-item__value">LOCAL</span>
</div>
<div class="info-item">
<span class="info-item__label">鉴权模式</span>
<span class="info-item__value">JWT待接入</span>
</div>
</div>
</section>
<section class="page-card section-span-6" style="padding: 24px;">
<el-empty description="系统配置模块待开发" />
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,750 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import * as fileApi from '@/api/file'
import * as tenantApi from '@/api/tenant'
import type { Department, FirmProfile, PracticeArea } from '@/types/tenant'
import { createUploadAjaxError, resolveUploadErrorMessage, validateImageFile } from '@/utils/file-upload'
function generateAreaCode() {
return `AREA_${Date.now()}`
}
function generateDeptCode() {
return `DEPT_${Date.now()}`
}
const loading = ref(false)
const saving = ref(false)
const imageUploading = reactive({
logo: false,
hero: false,
})
const hasPendingUpload = computed(() => Object.values(imageUploading).some(Boolean))
const areaDialogVisible = ref(false)
const deptDialogVisible = ref(false)
const areaEditingId = ref<number | null>(null)
const deptEditingId = ref<number | null>(null)
const profile = reactive<FirmProfile>({
id: null,
firmName: '',
firmShortName: '',
englishName: '',
logoAssetId: null,
logoUrl: '',
heroAssetId: null,
heroUrl: '',
intro: '',
hotlinePhone: '',
websiteUrl: '',
hqAddress: '',
hqLatitude: null,
hqLongitude: null,
})
const areas = ref<PracticeArea[]>([])
const departments = ref<Department[]>([])
const areaForm = reactive<Omit<PracticeArea, 'id'>>({
areaCode: '',
areaName: '',
displayOrder: 0,
areaStatus: 'ENABLED',
})
const deptForm = reactive<Omit<Department, 'id'>>({
parentId: 0,
deptCode: '',
deptName: '',
deptType: 'OFFICE',
contactPhone: '',
address: '',
displayOrder: 0,
deptStatus: 'ENABLED',
})
const deptOptions = computed(() => departments.value.map(item => ({
label: item.deptName,
value: item.id,
})))
async function loadData() {
loading.value = true
try {
const [profileData, areaData, deptData] = await Promise.all([
tenantApi.getFirmProfile(),
tenantApi.listPracticeAreas(),
tenantApi.listDepartments(),
])
Object.assign(profile, {
...profileData,
hqLatitude: profileData.hqLatitude ? String(profileData.hqLatitude) : null,
hqLongitude: profileData.hqLongitude ? String(profileData.hqLongitude) : null,
logoUrl: profileData.logoUrl || '',
heroUrl: profileData.heroUrl || '',
})
areas.value = areaData
departments.value = deptData
} finally {
loading.value = false
}
}
async function handleProfileImageUpload(
field: 'logo' | 'hero',
options: UploadRequestOptions,
) {
const fieldLabel = field === 'logo' ? '事务所 Logo' : '主页封面图'
const file = options.file
if (!validateImageFile(file, fieldLabel)) {
options.onError(createUploadAjaxError(`${fieldLabel}校验失败`))
return
}
imageUploading[field] = true
try {
const asset = await fileApi.uploadFile(file)
if (field === 'logo') {
profile.logoAssetId = asset.id
profile.logoUrl = asset.accessUrl
} else {
profile.heroAssetId = asset.id
profile.heroUrl = asset.accessUrl
}
options.onSuccess(asset)
ElMessage.success(`${fieldLabel}上传成功`)
} catch (error) {
options.onError(createUploadAjaxError(resolveUploadErrorMessage(error, `${fieldLabel}上传失败`)))
ElMessage.error(resolveUploadErrorMessage(error, `${fieldLabel}上传失败`))
} finally {
imageUploading[field] = false
}
}
function handleLogoUpload(options: UploadRequestOptions) {
return handleProfileImageUpload('logo', options)
}
function handleHeroUpload(options: UploadRequestOptions) {
return handleProfileImageUpload('hero', options)
}
async function saveProfile() {
saving.value = true
try {
const saved = await tenantApi.saveFirmProfile(profile)
Object.assign(profile, {
...saved,
hqLatitude: saved.hqLatitude ? String(saved.hqLatitude) : null,
hqLongitude: saved.hqLongitude ? String(saved.hqLongitude) : null,
logoUrl: saved.logoUrl || '',
heroUrl: saved.heroUrl || '',
})
ElMessage.success('事务所资料已保存')
} finally {
saving.value = false
}
}
function openAreaDialog(row?: PracticeArea) {
areaEditingId.value = row?.id ?? null
Object.assign(areaForm, row ?? {
areaCode: generateAreaCode(),
areaName: '',
displayOrder: 0,
areaStatus: 'ENABLED',
})
areaDialogVisible.value = true
}
async function saveArea() {
const payload = {
...areaForm,
areaCode: areaForm.areaCode || generateAreaCode(),
displayOrder: 0,
areaStatus: 'ENABLED',
}
if (areaEditingId.value) {
await tenantApi.updatePracticeArea(areaEditingId.value, payload)
ElMessage.success('专业领域已更新')
} else {
await tenantApi.createPracticeArea(payload)
ElMessage.success('专业领域已新增')
}
areaDialogVisible.value = false
areas.value = await tenantApi.listPracticeAreas()
}
function openDeptDialog(row?: Department) {
deptEditingId.value = row?.id ?? null
Object.assign(deptForm, row ?? {
parentId: 0,
deptCode: generateDeptCode(),
deptName: '',
deptType: 'OFFICE',
contactPhone: '',
address: '',
displayOrder: 0,
deptStatus: 'ENABLED',
})
deptDialogVisible.value = true
}
async function saveDepartment() {
const isTopLevel = !deptForm.parentId || deptForm.parentId === 0
const payload = {
...deptForm,
deptCode: deptForm.deptCode || generateDeptCode(),
deptType: isTopLevel ? 'OFFICE' : 'DEPARTMENT',
contactPhone: '',
displayOrder: 0,
deptStatus: 'ENABLED',
}
if (deptEditingId.value) {
await tenantApi.updateDepartment(deptEditingId.value, payload)
ElMessage.success('组织已更新')
} else {
await tenantApi.createDepartment(payload)
ElMessage.success('组织已新增')
}
deptDialogVisible.value = false
departments.value = await tenantApi.listDepartments()
}
onMounted(() => {
loadData()
})
</script>
<template>
<div class="page-shell" v-loading="loading">
<section class="page-card unified-card profile-card">
<div class="unified-header">
<div class="unified-header__title">
<h2>机构主页管理</h2>
<span>维护事务所基础信息展示门面素材与概况数据将直接反映至外界</span>
</div>
<div class="unified-header__actions">
<el-button type="primary" :loading="saving" :disabled="hasPendingUpload" size="large" class="save-btn" @click="saveProfile">保存机构资料</el-button>
</div>
</div>
<div class="editor-body">
<div class="photo-upload-grid">
<div class="upload-field" v-loading="imageUploading.logo">
<span class="upload-label">事务所 Logo</span>
<el-upload
class="photo-uploader square-uploader"
accept="image/*"
:show-file-list="false"
:http-request="handleLogoUpload"
:disabled="imageUploading.logo"
>
<img v-if="profile.logoUrl" :src="profile.logoUrl" class="photo" />
<el-icon v-else class="photo-uploader-icon"><Plus /></el-icon>
</el-upload>
<span class="upload-tip">建议比例 1:1透明底色支持 JPG/PNG/WEBP5MB 以内</span>
</div>
<div class="upload-field" v-loading="imageUploading.hero">
<span class="upload-label">主页封面大图</span>
<el-upload
class="photo-uploader landscape-uploader"
accept="image/*"
:show-file-list="false"
:http-request="handleHeroUpload"
:disabled="imageUploading.hero"
>
<img v-if="profile.heroUrl" :src="profile.heroUrl" class="photo" />
<el-icon v-else class="photo-uploader-icon"><Plus /></el-icon>
</el-upload>
<span class="upload-tip">建议比例 16:9高清风貌支持 JPG/PNG/WEBP5MB 以内</span>
</div>
</div>
<el-form label-position="top" class="card-form material-form">
<div class="form-grid">
<el-form-item label="名称">
<el-input v-model="profile.firmName" class="material-input" placeholder="完整的机构注册名称" />
</el-form-item>
<el-form-item label="简称">
<el-input v-model="profile.firmShortName" class="material-input" placeholder="可于名片标识位显示的短名称" />
</el-form-item>
<el-form-item label="英文名">
<el-input v-model="profile.englishName" class="material-input" />
</el-form-item>
<el-form-item label="热线电话">
<el-input v-model="profile.hotlinePhone" class="material-input" />
</el-form-item>
<el-form-item label="官网地址">
<el-input v-model="profile.websiteUrl" class="material-input" />
</el-form-item>
<el-form-item label="详细地址" class="form-grid__full">
<el-input v-model="profile.hqAddress" class="material-input" placeholder="完整的真实办公地址" />
</el-form-item>
<el-form-item label="纬度 (LAT)">
<el-input v-model="profile.hqLatitude" class="material-input" placeholder="例如31.230416" />
</el-form-item>
<el-form-item label="经度 (LNG)">
<el-input v-model="profile.hqLongitude" class="material-input" placeholder="例如121.473701" />
</el-form-item>
<el-form-item label="机构简介" class="form-grid__full">
<el-input v-model="profile.intro" type="textarea" :rows="6" class="material-input" placeholder="填写律所概况、历史和愿景..." />
</el-form-item>
</div>
</el-form>
</div>
</section>
<!-- Support tables -->
<div class="resource-grid">
<section class="page-card sub-card">
<div class="sub-header">
<div class="sub-header__title">
<h3>专业领域</h3>
<span>( {{ areas.length }} )</span>
</div>
<el-button plain size="small" @click="openAreaDialog()">添加</el-button>
</div>
<el-table :data="areas" class="minimal-table" row-key="id">
<el-table-column prop="areaName" label="业务名称" min-width="200" />
<el-table-column label="操作" width="80" align="right">
<template #default="{ row }">
<el-button link type="primary" @click="openAreaDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</section>
<section class="page-card sub-card">
<div class="sub-header">
<div class="sub-header__title">
<h3>组织架构</h3>
<span>( {{ departments.length }} )</span>
</div>
<el-button plain size="small" @click="openDeptDialog()">添加</el-button>
</div>
<el-table :data="departments" class="minimal-table" row-key="id">
<el-table-column prop="deptName" label="架构名称" min-width="140" />
<el-table-column prop="deptType" label="属性" width="100">
<template #default="{ row }">
<span class="type-badge">{{ row.deptType === 'OFFICE' ? '总部' : '部门' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDeptDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</section>
</div>
<!-- Modals -->
<el-dialog v-model="areaDialogVisible" :title="areaEditingId ? '编辑业务领域' : '新增业务领域'" width="520px" class="minimal-dialog entity-dialog area-dialog" destroy-on-close>
<div class="entity-dialog__panel area-dialog__body">
<el-form class="material-form entity-dialog__form" label-position="top">
<el-form-item label="业务名称" class="area-dialog__field">
<el-input
v-model="areaForm.areaName"
class="material-input area-dialog__input"
placeholder="例如:民商事争议解决、企业合规、知识产权"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="areaDialogVisible = false">取消</el-button>
<el-button type="primary" class="save-btn" @click="saveArea">确认保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="deptDialogVisible" :title="deptEditingId ? '编辑部门架构' : '新增部门架构'" width="560px" class="minimal-dialog entity-dialog dept-dialog" destroy-on-close>
<div class="entity-dialog__panel">
<div class="form-grid material-form dept-dialog__grid">
<el-form-item label="归属上级" class="form-grid__full">
<el-select v-model="deptForm.parentId" clearable class="material-input" style="width: 100%;">
<el-option label="独立顶层机构" :value="0" />
<el-option v-for="item in deptOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="deptForm.deptName" class="material-input" />
</el-form-item>
<el-form-item label="部门地址" class="form-grid__full">
<el-input v-model="deptForm.address" class="material-input" />
</el-form-item>
</div>
</div>
<template #footer>
<el-button @click="deptDialogVisible = false">取消</el-button>
<el-button type="primary" class="save-btn" @click="saveDepartment">确认保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.page-shell {
display: flex;
flex-direction: column;
gap: 24px;
}
.unified-card {
padding: 0;
overflow: hidden;
margin-bottom: 0;
}
.unified-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 32px;
background: #ffffff;
border-bottom: 1px solid var(--ec-border);
position: sticky;
top: 0;
z-index: 10;
}
.unified-header__title {
display: grid;
gap: 6px;
}
.unified-header__title h2 {
margin: 0;
font-size: 24px;
letter-spacing: -0.02em;
}
.unified-header__title span {
color: var(--ec-text-secondary);
font-size: 13px;
}
.save-btn {
background: linear-gradient(135deg, #16271f 0%, #2a3c31 100%);
border: none;
box-shadow: 0 4px 12px rgba(22, 39, 31, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.save-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(22, 39, 31, 0.3);
}
.editor-body {
padding: 40px 32px;
max-width: 900px;
margin: 0 auto;
}
/* Photo upload section */
.photo-upload-grid {
display: flex;
gap: 40px;
margin-bottom: 48px;
padding-bottom: 32px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.upload-field {
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-label {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
}
.upload-tip {
font-size: 12px;
color: #757575;
}
.photo-uploader :deep(.el-upload) {
border: 1.5px dashed #dcdde0;
border-radius: 12px;
cursor: pointer;
position: relative;
overflow: hidden;
background: #f7f8fa;
transition: all 0.3s;
}
.photo-uploader :deep(.el-upload:hover) {
border-color: #1a1a1a;
background: #f0f1f4;
}
.square-uploader :deep(.el-upload) {
width: 120px;
height: 120px;
}
.landscape-uploader :deep(.el-upload) {
width: 260px;
height: 120px;
}
.photo-uploader-icon {
font-size: 24px;
color: #8c939d;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.photo {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
}
.form-grid__full {
grid-column: span 2;
}
.material-form :deep(.el-form-item) {
margin-bottom: 32px;
}
.material-form :deep(.el-form-item__label) {
padding-bottom: 8px;
font-weight: 500;
color: #4a4a4a;
font-size: 14px;
}
.material-input :deep(.el-input__wrapper),
.material-input :deep(.el-textarea__inner) {
box-shadow: none !important;
border: 1px solid #dcdde0;
border-radius: 8px;
background-color: #fcfcfc;
transition: all 0.2s ease;
padding: 8px 12px;
}
.material-input :deep(.el-input__wrapper.is-focus),
.material-input :deep(.el-textarea__inner:focus) {
border-color: #1a1a1a;
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04) !important;
}
/* Secondary Grids */
.resource-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.sub-card {
padding: 0;
overflow: hidden;
}
.sub-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: #fdfdfd;
border-bottom: 1px solid var(--ec-border);
}
.sub-header__title {
display: flex;
align-items: center;
gap: 8px;
}
.sub-header__title h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.sub-header__title span {
color: var(--ec-text-secondary);
font-size: 13px;
}
.minimal-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.minimal-table :deep(th.el-table__cell) {
height: 48px;
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
font-size: 13px;
}
.minimal-table :deep(td.el-table__cell) {
padding: 14px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.02);
}
.minimal-table :deep(.el-table__row:last-child td.el-table__cell) {
border-bottom: none;
}
.type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 6px;
background: #f0f1f4;
color: #4a4d52;
font-size: 11px;
font-weight: 500;
}
.entity-dialog :deep(.el-dialog) {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 24px 60px rgba(23, 31, 26, 0.16);
}
.entity-dialog :deep(.el-dialog__header) {
margin-right: 0;
padding: 28px 32px 18px;
border-bottom: 1px solid rgba(34, 45, 37, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 250, 246, 0.96));
}
.entity-dialog :deep(.el-dialog__title) {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
color: #243229;
}
.entity-dialog :deep(.el-dialog__headerbtn) {
top: 24px;
right: 24px;
}
.entity-dialog :deep(.el-dialog__body) {
padding: 24px 32px 8px;
background: #fff;
}
.entity-dialog :deep(.el-dialog__footer) {
padding: 8px 32px 28px;
}
.entity-dialog__panel {
padding: 24px;
border: 1px solid rgba(34, 45, 37, 0.08);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 248, 244, 0.94));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.entity-dialog__form :deep(.el-form-item__label),
.dept-dialog__grid :deep(.el-form-item__label) {
padding-bottom: 10px;
font-weight: 600;
color: #34463a;
}
.entity-dialog__panel .material-input :deep(.el-input__wrapper),
.entity-dialog__panel .material-input :deep(.el-textarea__inner),
.entity-dialog__panel .material-input :deep(.el-select__wrapper) {
min-height: 48px;
border-radius: 14px;
border-color: rgba(182, 190, 178, 0.68);
background: rgba(255, 255, 255, 0.96);
}
.entity-dialog__panel .material-input :deep(.el-textarea__inner) {
min-height: 108px;
}
.area-dialog__body {
display: grid;
}
.area-dialog__field {
margin-bottom: 0 !important;
}
.area-dialog__input :deep(.el-input__wrapper) {
min-height: 52px;
}
.dept-dialog__grid {
margin-top: 0;
row-gap: 6px;
}
@media (max-width: 900px) {
.resource-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.form-grid__full {
grid-column: span 1;
}
.unified-header {
flex-direction: column;
align-items: flex-start;
}
.unified-header__actions {
width: 100%;
margin-top: 16px;
justify-content: flex-end;
}
.photo-upload-grid {
flex-direction: column;
gap: 24px;
}
.entity-dialog :deep(.el-dialog) {
width: calc(100vw - 24px) !important;
margin-top: 6vh !important;
}
.entity-dialog :deep(.el-dialog__header),
.entity-dialog :deep(.el-dialog__body),
.entity-dialog :deep(.el-dialog__footer) {
padding-left: 20px;
padding-right: 20px;
}
.entity-dialog__panel {
padding: 18px;
border-radius: 18px;
}
}
</style>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import Sortable, { type SortableEvent } from 'sortablejs'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import * as tenantApi from '@/api/tenant'
import type { LawyerListItem } from '@/types/tenant'
import { formatDateTime } from '@/utils/format'
const router = useRouter()
const loading = ref(false)
const sorting = ref(false)
const filters = reactive({ keyword: '' })
const lawyers = ref<LawyerListItem[]>([])
const tableContainerRef = ref<HTMLElement | null>(null)
const publishingMap = reactive<Record<number, boolean>>({})
let sortable: Sortable | null = null
const isKeywordFiltering = computed(() => Boolean(filters.keyword.trim()))
async function loadData() {
loading.value = true
try {
lawyers.value = await tenantApi.listLawyers(filters.keyword)
} finally {
loading.value = false
await nextTick()
initSortable()
}
}
function goCreate() {
router.push({ name: 'lawyer-create' })
}
function goEdit(row: LawyerListItem) {
router.push({ name: 'lawyer-edit', params: { cardId: row.id } })
}
async function removeLawyer(row: LawyerListItem) {
await ElMessageBox.confirm(`确认删除律师“${row.cardName}”吗?删除后列表与小程序将不再展示该律师。`, '删除律师', {
type: 'warning',
})
await tenantApi.deleteCard(row.id)
ElMessage.success('律师已删除')
await loadData()
}
function getPublishStatusLabel(status: LawyerListItem['publishStatus']) {
return status === 'PUBLISHED' ? '已发布' : '未发布'
}
function getPublishStatusType(status: LawyerListItem['publishStatus']) {
return status === 'PUBLISHED' ? 'success' : 'info'
}
async function publishLawyer(row: LawyerListItem) {
if (publishingMap[row.id] || row.publishStatus === 'PUBLISHED') {
return
}
publishingMap[row.id] = true
try {
const detail = await tenantApi.getCard(row.id)
await tenantApi.updateCard(row.id, {
...detail,
publishStatus: 'PUBLISHED',
})
ElMessage.success('律师名片已发布')
await loadData()
} finally {
publishingMap[row.id] = false
}
}
function destroySortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
}
function initSortable() {
destroySortable()
if (!tableContainerRef.value || isKeywordFiltering.value || sorting.value || loading.value) {
return
}
const tableBody = tableContainerRef.value.querySelector('.el-table__body-wrapper tbody') as HTMLElement | null
if (!tableBody) {
return
}
sortable = Sortable.create(tableBody, {
animation: 180,
handle: '.drag-handle',
ghostClass: 'lawyer-sort-ghost',
chosenClass: 'lawyer-sort-chosen',
onEnd: async (event: SortableEvent) => {
const { oldIndex, newIndex } = event
if (oldIndex == null || newIndex == null || oldIndex === newIndex || sorting.value) {
return
}
const nextRows = [...lawyers.value]
const [movedRow] = nextRows.splice(oldIndex, 1)
if (!movedRow) {
return
}
nextRows.splice(newIndex, 0, movedRow)
lawyers.value = nextRows
sorting.value = true
try {
await tenantApi.sortCards(nextRows.map(item => item.id))
lawyers.value = nextRows.map((item, index) => ({
...item,
displayOrder: (index + 1) * 10,
}))
ElMessage.success('律师排序已更新')
} catch {
await loadData()
} finally {
sorting.value = false
await nextTick()
initSortable()
}
},
})
}
onMounted(() => {
loadData()
})
onBeforeUnmount(() => {
destroySortable()
})
</script>
<template>
<div class="page-shell shell-stack lawyer-page">
<section class="page-card section-panel lawyer-panel">
<div class="toolbar-actions toolbar-actions--top">
<el-input
v-model="filters.keyword"
placeholder="按姓名搜索律师"
clearable
style="width: 240px;"
@clear="loadData"
@keyup.enter="loadData"
/>
<el-button @click="loadData">搜索</el-button>
<el-button type="primary" @click="goCreate">新增律师</el-button>
</div>
<div ref="tableContainerRef" class="lawyer-table-wrap">
<el-table v-loading="loading || sorting" :data="lawyers" row-key="id">
<el-table-column label="姓名" min-width="136">
<template #header>
<span class="name-header-label">姓名</span>
</template>
<template #default="{ row }">
<div class="name-cell">
<span class="drag-handle" :class="{ 'drag-handle--disabled': isKeywordFiltering || sorting }"></span>
<span class="name-cell__text">{{ row.cardName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="cardTitle" label="职务" min-width="108" />
<el-table-column prop="mobile" label="手机号" min-width="138" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column label="添加时间" min-width="180">
<template #default="{ row }">
{{ formatDateTime(row.createdTime) }}
</template>
</el-table-column>
<el-table-column label="修改时间" min-width="180">
<template #default="{ row }">
{{ formatDateTime(row.updatedTime) }}
</template>
</el-table-column>
<el-table-column label="发布状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="getPublishStatusType(row.publishStatus)" effect="light">
{{ getPublishStatusLabel(row.publishStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<div class="action-group">
<el-button
link
type="success"
:disabled="row.publishStatus === 'PUBLISHED'"
:loading="publishingMap[row.id]"
@click="publishLawyer(row)"
>
发布
</el-button>
<el-button link type="primary" @click="goEdit(row)">修改</el-button>
<el-button link type="danger" @click="removeLawyer(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
</template>
<style scoped>
.lawyer-page {
min-height: calc(100vh - 132px);
}
.lawyer-panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-actions--top {
justify-content: flex-end;
margin-bottom: 20px;
flex-shrink: 0;
}
.lawyer-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.name-cell {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.name-header-label {
display: inline-block;
padding-left: 30px;
}
.name-cell__text {
font-weight: 600;
color: var(--ec-text);
}
.drag-handle {
display: inline-block;
width: 22px;
text-align: center;
font-size: 18px;
line-height: 1;
color: var(--ec-text-secondary);
cursor: grab;
user-select: none;
}
.drag-handle--disabled {
cursor: not-allowed;
opacity: 0.4;
}
.action-group {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
}
.lawyer-table-wrap :deep(.el-table) {
min-width: 100%;
}
:deep(.lawyer-sort-ghost > td) {
background: rgba(26, 26, 26, 0.06);
}
:deep(.lawyer-sort-chosen > td) {
background: rgba(26, 26, 26, 0.04);
}
</style>

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
import * as platformApi from '@/api/platform'
import type { MiniappConfig } from '@/types/platform'
const loading = ref(false)
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const rows = ref<MiniappConfig[]>([])
const form = reactive<MiniappConfig & { miniappAppSecret?: string; remark?: string }>({
tenantId: 0,
tenantCode: '',
tenantName: '',
envCode: 'PROD',
miniappAppId: '',
miniappName: '',
miniappOriginalId: '',
requestDomain: '',
uploadDomain: '',
downloadDomain: '',
versionTag: '',
publishStatus: 'DRAFT',
miniappAppSecret: '',
remark: '',
})
const stats = computed(() => {
const published = rows.value.filter(item => item.publishStatus === 'PUBLISHED').length
const draft = rows.value.filter(item => item.publishStatus === 'DRAFT').length
const disabled = rows.value.filter(item => item.publishStatus === 'DISABLED').length
return [
{ label: '已发布', value: String(published).padStart(2, '0') },
{ label: '草稿中', value: String(draft).padStart(2, '0') },
{ label: '停用', value: String(disabled).padStart(2, '0') },
]
})
function publishClass(status: string) {
if (status === 'PUBLISHED') {
return 'status-pill status-pill--success'
}
if (status === 'DRAFT') {
return 'status-pill status-pill--warning'
}
return 'status-pill status-pill--danger'
}
function publishLabel(status: string) {
if (status === 'PUBLISHED') {
return '已发布'
}
if (status === 'DRAFT') {
return '草稿'
}
if (status === 'UNCONFIGURED') {
return '未配置'
}
return '停用'
}
async function loadRows() {
loading.value = true
try {
rows.value = await platformApi.listMiniapps()
} finally {
loading.value = false
}
}
function openEditDialog(row: MiniappConfig) {
Object.assign(form, {
...row,
miniappAppSecret: '',
remark: '',
})
dialogVisible.value = true
}
async function submitForm() {
const valid = await formRef.value?.validate().then(() => true).catch(() => false)
if (!valid) {
return
}
await platformApi.updateMiniapp(form.tenantId, {
envCode: form.envCode,
miniappAppId: form.miniappAppId,
miniappAppSecret: form.miniappAppSecret,
miniappName: form.miniappName,
miniappOriginalId: form.miniappOriginalId,
requestDomain: form.requestDomain,
uploadDomain: form.uploadDomain,
downloadDomain: form.downloadDomain,
versionTag: form.versionTag,
publishStatus: form.publishStatus,
remark: form.remark,
})
ElMessage.success('小程序配置已保存')
dialogVisible.value = false
await loadRows()
}
onMounted(() => {
loadRows()
})
</script>
<template>
<div class="page-shell shell-stack">
<section class="hero-panel page-card">
<div class="hero-grid">
<div class="hero-main">
<div class="hero-kicker">
<span class="law-dot"></span>
<span>MINIAPP MAPPING</span>
</div>
<h2>小程序配置映射</h2>
<p>配置并维护各个租户与对应小程序 AppID 之间的关联规则监控线上发布状态</p>
</div>
<div class="hero-side">
<div class="quick-grid">
<article
v-for="stat in stats"
:key="stat.label"
class="quick-card"
>
<strong>{{ stat.value }}</strong>
<span>{{ stat.label }}</span>
</article>
</div>
</div>
</div>
</section>
<div class="section-grid">
<section class="page-card section-span-4 section-panel">
<div class="page-header">
<div>
<span class="eyebrow">RULES</span>
<h1 class="page-title">识别规则</h1>
</div>
</div>
<div class="compact-list">
<div class="compact-row">
<div class="compact-row__main">
<span class="compact-row__title">租户识别</span>
<span class="compact-row__meta">通过标准请求头完成业务鉴权</span>
</div>
</div>
<div class="compact-row">
<div class="compact-row__main">
<span class="compact-row__title">唯一约束</span>
<span class="compact-row__meta">确保平台内 AppID 保持唯一</span>
</div>
</div>
<div class="compact-row">
<div class="compact-row__main">
<span class="compact-row__title">环境建议</span>
<span class="compact-row__meta">默认绑定至 PROD 生产环境</span>
</div>
</div>
</div>
</section>
<section class="page-card section-span-8 section-panel">
<div class="page-header">
<div>
<span class="eyebrow">APPID LIST</span>
<h1 class="page-title">小程序配置</h1>
</div>
</div>
<el-table v-loading="loading" :data="rows">
<el-table-column label="租户" min-width="220">
<template #default="{ row }">
<div class="table-stack">
<strong>{{ row.tenantName }}</strong>
<span class="muted">{{ row.tenantCode }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="AppID" min-width="220" prop="miniappAppId" />
<el-table-column label="环境" width="100" prop="envCode" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span :class="publishClass(row.publishStatus)">
{{ publishLabel(row.publishStatus) }}
</span>
</template>
</el-table-column>
<el-table-column label="版本" width="120" prop="versionTag" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" @click="openEditDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</section>
</div>
<el-dialog v-model="dialogVisible" title="编辑小程序配置" width="720px" destroy-on-close>
<el-form ref="formRef" :model="form" label-position="top" class="page-form">
<div class="summary-grid">
<article class="summary-card">
<span class="summary-card__label">租户名称</span>
<strong class="summary-card__value">{{ form.tenantName }}</strong>
<span class="summary-card__meta">{{ form.tenantCode }}</span>
</article>
<article class="summary-card">
<span class="summary-card__label">环境</span>
<strong class="summary-card__value">{{ form.envCode }}</strong>
<span class="summary-card__meta">与租户一一对应</span>
</article>
</div>
<div class="form-grid" style="margin-top: 20px;">
<el-form-item label="AppID">
<el-input v-model="form.miniappAppId" />
</el-form-item>
<el-form-item label="小程序名称">
<el-input v-model="form.miniappName" />
</el-form-item>
<el-form-item label="原始 ID">
<el-input v-model="form.miniappOriginalId" />
</el-form-item>
<el-form-item label="版本标签">
<el-input v-model="form.versionTag" />
</el-form-item>
<el-form-item label="请求域名">
<el-input v-model="form.requestDomain" />
</el-form-item>
<el-form-item label="上传域名">
<el-input v-model="form.uploadDomain" />
</el-form-item>
<el-form-item label="下载域名">
<el-input v-model="form.downloadDomain" />
</el-form-item>
<el-form-item label="发布状态">
<el-select v-model="form.publishStatus">
<el-option label="草稿" value="DRAFT" />
<el-option label="已发布" value="PUBLISHED" />
<el-option label="停用" value="DISABLED" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as platformApi from '@/api/platform'
import type { TenantCreatePayload, TenantDetail } from '@/types/platform'
const loading = ref(false)
const createDialogVisible = ref(false)
const editDialogVisible = ref(false)
const createFormRef = ref<FormInstance>()
const editFormRef = ref<FormInstance>()
const tenants = ref<TenantDetail[]>([])
const editingTenantId = ref<number | null>(null)
const createSubmitting = ref(false)
const editSubmitting = ref(false)
const deletingTenantId = ref<number | null>(null)
const filters = reactive({
keyword: '',
})
const createForm = reactive<TenantCreatePayload>({
tenantName: '',
adminUsername: '',
adminPassword: '',
miniappAppId: '',
})
const editForm = reactive({
tenantName: '',
miniappAppId: '',
})
const filteredTenants = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return tenants.value.filter((tenant) => {
return !keyword
|| tenant.tenantName.toLowerCase().includes(keyword)
|| tenant.tenantCode.toLowerCase().includes(keyword)
|| (tenant.miniappAppId || '').toLowerCase().includes(keyword)
})
})
const createRules = {
tenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
adminUsername: [{ required: true, message: '请输入管理员账号', trigger: 'blur' }],
adminPassword: [{ required: true, message: '请输入管理员密码', trigger: 'blur' }],
}
const editRules = {
tenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
}
async function loadTenants() {
loading.value = true
try {
tenants.value = await platformApi.listTenants()
} finally {
loading.value = false
}
}
function openCreateDialog() {
Object.assign(createForm, {
tenantName: '',
adminUsername: '',
adminPassword: '',
miniappAppId: '',
})
createFormRef.value?.clearValidate()
createDialogVisible.value = true
}
function openEditDialog(row: TenantDetail) {
editingTenantId.value = row.id
Object.assign(editForm, {
tenantName: row.tenantName,
miniappAppId: row.miniappAppId || '',
})
editFormRef.value?.clearValidate()
editDialogVisible.value = true
}
async function submitCreateForm() {
const valid = await createFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid) {
return
}
createSubmitting.value = true
try {
await platformApi.createTenant(createForm)
ElMessage.success('租户已开通')
createDialogVisible.value = false
await loadTenants()
} finally {
createSubmitting.value = false
}
}
async function submitEditForm() {
const valid = await editFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid || !editingTenantId.value) {
return
}
editSubmitting.value = true
try {
await platformApi.updateTenant(editingTenantId.value, {
tenantName: editForm.tenantName,
miniappAppId: editForm.miniappAppId,
})
ElMessage.success('租户信息已更新')
editDialogVisible.value = false
await loadTenants()
} finally {
editSubmitting.value = false
}
}
async function handleDeleteTenant(row: TenantDetail) {
await ElMessageBox.confirm(
`删除后将同步清理该租户下的用户、组织、名片、素材及运行日志,且不可恢复。是否继续删除“${row.tenantName}”?`,
'删除租户',
{
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
},
)
deletingTenantId.value = row.id
try {
await platformApi.deleteTenant(row.id)
ElMessage.success('租户已删除')
await loadTenants()
} finally {
deletingTenantId.value = null
}
}
function resolveAppIdLabel(appId: string | null | undefined) {
return appId?.trim() ? appId : '暂未配置'
}
function resolveTenantMonogram(name: string) {
return name.trim().slice(0, 1) || 'T'
}
onMounted(() => {
loadTenants()
})
</script>
<template>
<div class="page-shell">
<section class="page-card unified-card">
<div class="unified-header">
<div class="unified-header__title">
<h2>租户管理</h2>
<span>管理平台下的所有事务所主体与小程序映射 {{ filteredTenants.length }} 个主体</span>
</div>
<div class="unified-header__actions">
<el-input
v-model="filters.keyword"
class="unified-search"
placeholder="搜索名称、编码或 AppID"
prefix-icon="Search"
clearable
/>
<el-button type="primary" @click="openCreateDialog">新增主体</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="filteredTenants"
row-key="id"
class="tenant-table"
>
<el-table-column label="租户主体" min-width="320">
<template #default="{ row }">
<div class="tenant-entity">
<div class="tenant-entity__badge">{{ resolveTenantMonogram(row.tenantName) }}</div>
<div class="tenant-entity__body">
<strong>{{ row.tenantName }}</strong>
<span>{{ row.tenantCode }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="小程序 AppID" min-width="280">
<template #default="{ row }">
<span :class="['tenant-appid', { 'tenant-appid--empty': !row.miniappAppId }]">
{{ resolveAppIdLabel(row.miniappAppId) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="tenant-actions">
<el-button plain @click.stop="openEditDialog(row)">编辑</el-button>
<el-button
plain
type="danger"
:loading="deletingTenantId === row.id"
@click.stop="handleDeleteTenant(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div class="tenant-empty">
<strong>还没有租户</strong>
<span>先创建第一个主体AppID 可在后续补录</span>
<el-button type="primary" @click="openCreateDialog" style="margin-top: 10px;">立即新增</el-button>
</div>
</template>
</el-table>
</section>
<el-dialog v-model="createDialogVisible" title="新增租户" width="720px" destroy-on-close>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-position="top"
class="tenant-form"
>
<div class="tenant-form__grid">
<el-form-item label="租户名称" prop="tenantName">
<el-input v-model="createForm.tenantName" placeholder="例如:衡知律师事务所" />
</el-form-item>
<el-form-item label="小程序 AppID">
<el-input v-model="createForm.miniappAppId" placeholder="可选,后续也可补录" />
</el-form-item>
<el-form-item label="管理员账号" prop="adminUsername">
<el-input v-model="createForm.adminUsername" placeholder="例如admin_hengzhi" />
</el-form-item>
<el-form-item label="管理员密码" prop="adminPassword">
<el-input
v-model="createForm.adminPassword"
type="password"
show-password
placeholder="请输入初始密码"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="createSubmitting" @click="submitCreateForm">确认创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="editDialogVisible" title="编辑租户" width="560px" destroy-on-close>
<el-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-position="top"
class="tenant-form"
>
<div class="tenant-form__stack">
<el-form-item label="租户名称" prop="tenantName">
<el-input v-model="editForm.tenantName" />
</el-form-item>
<el-form-item label="小程序 AppID">
<el-input v-model="editForm.miniappAppId" placeholder="未配置可留空" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="submitEditForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.unified-card {
overflow: hidden;
padding: 0;
}
.unified-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
background: rgba(255, 255, 255, 0.4);
border-bottom: 1px solid var(--ec-border);
}
.unified-header__title {
display: grid;
gap: 6px;
}
.unified-header__title h2 {
margin: 0;
font-size: 22px;
letter-spacing: -0.02em;
}
.unified-header__title span {
color: var(--ec-text-secondary);
font-size: 13px;
}
.unified-header__actions {
display: flex;
align-items: center;
gap: 16px;
}
.unified-search {
width: 260px;
}
.tenant-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.tenant-table :deep(th.el-table__cell) {
height: 54px;
background: rgba(246, 246, 248, 0.6);
border-bottom: 1px solid var(--ec-border);
}
.tenant-table :deep(td.el-table__cell) {
padding: 18px 0;
border-bottom: 1px solid rgba(34, 45, 37, 0.04);
}
.tenant-table :deep(.el-table__row:last-child td.el-table__cell) {
border-bottom: none;
}
.tenant-entity {
display: flex;
align-items: center;
gap: 14px;
}
.tenant-entity__badge {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--ec-accent-soft);
color: var(--ec-accent-strong);
font-size: 16px;
font-weight: 700;
}
.tenant-entity__body {
display: grid;
gap: 4px;
}
.tenant-entity__body strong {
font-size: 14px;
}
.tenant-entity__body span {
color: var(--ec-text-secondary);
font-size: 12px;
}
.tenant-appid {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: var(--ec-accent-soft);
color: var(--ec-accent-strong);
font-family: 'SFMono-Regular', 'Consolas', monospace;
font-size: 13px;
font-weight: 500;
}
.tenant-appid--empty {
background: transparent;
color: var(--ec-text-secondary);
font-family: inherit;
}
.tenant-actions {
display: inline-flex;
gap: 8px;
}
.tenant-empty {
display: grid;
justify-items: center;
gap: 12px;
padding: 60px 0;
}
.tenant-empty strong {
font-size: 16px;
}
.tenant-empty span {
color: var(--ec-text-secondary);
font-size: 13px;
}
@media (max-width: 768px) {
.hero-side {
align-items: flex-start !important;
}
}
</style>

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Check, Warning } from '@element-plus/icons-vue'
import * as tenantApi from '@/api/tenant'
import type { TenantMiniappConfig } from '@/types/tenant'
const loading = ref(false)
const saving = ref(false)
const config = reactive<Partial<TenantMiniappConfig>>({
miniappAppId: '',
miniappName: '',
tenantName: '',
})
const savedAppId = ref('')
const isBound = computed(() => Boolean(savedAppId.value && savedAppId.value.trim()))
async function loadConfig() {
loading.value = true
try {
const data = await tenantApi.getTenantMiniappConfig()
// Default handle if data is empty or missing properties
if (data) {
Object.assign(config, data)
savedAppId.value = data.miniappAppId || ''
}
} finally {
loading.value = false
}
}
async function saveConfig() {
if (!config.miniappAppId?.trim()) {
ElMessage.warning('请输入有效的小程序 AppID')
return
}
saving.value = true
try {
const data = await tenantApi.saveTenantMiniappConfig({
miniappAppId: config.miniappAppId.trim(),
})
Object.assign(config, data)
savedAppId.value = data.miniappAppId || ''
ElMessage.success('小程序配置已刷新')
} finally {
saving.value = false
}
}
onMounted(() => {
loadConfig()
})
</script>
<template>
<div class="page-shell" v-loading="loading">
<section class="page-card unified-card binding-card">
<div class="unified-header">
<div class="unified-header__title">
<h2>微信小程序绑定</h2>
<span>管理当前机构与微信小程序的授权映射开启专属展示与互联体验</span>
</div>
<div class="unified-header__actions">
<el-button type="primary" size="large" class="save-btn" :loading="saving" @click="saveConfig">
保存配置
</el-button>
</div>
</div>
<div class="editor-body">
<div class="status-panel" :class="isBound ? 'status-panel--bound' : 'status-panel--unbound'">
<div class="status-icon-wrap">
<el-icon v-if="isBound" class="status-icon"><Check /></el-icon>
<el-icon v-else class="status-icon"><Warning /></el-icon>
</div>
<div class="status-info">
<strong>{{ isBound ? '当前租户已绑定微信小程序' : '暂未绑定微信小程序' }}</strong>
<span v-if="isBound">
您已绑定的小程序标识AppID <span class="mono-text">{{ savedAppId }}</span>
<template v-if="config.miniappName">当前机构已匹配小程序名称{{ config.miniappName }}</template>
相关接口调用与鉴权路由均已连通
</span>
<span v-else>
请在下方填入您需要绑定的微信小程序 AppID, 配置完成后即可进行名片发布
</span>
</div>
</div>
<el-form label-position="top" class="card-form material-form">
<div class="form-grid">
<el-form-item label="小程序 AppID" class="form-grid__full">
<el-input
v-model="config.miniappAppId"
class="material-input miniapp-input"
size="large"
/>
</el-form-item>
</div>
</el-form>
<div class="instruction-box">
<div class="instruction-box__header">绑定须知</div>
<ul class="instruction-box__list">
<li>请确保配置的 AppID 为真实有效的微信小程序标识且已授权本平台</li>
</ul>
</div>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
.unified-card {
padding: 0;
overflow: hidden;
margin-bottom: 0;
min-height: calc(100vh - 120px);
}
.unified-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 32px;
background: #ffffff;
border-bottom: 1px solid var(--ec-border);
position: sticky;
top: 0;
z-index: 10;
}
.unified-header__title {
display: grid;
gap: 6px;
}
.unified-header__title h2 {
margin: 0;
font-size: 24px;
letter-spacing: -0.02em;
}
.unified-header__title span {
color: var(--ec-text-secondary);
font-size: 13px;
}
.save-btn {
background: linear-gradient(135deg, #16271f 0%, #2a3c31 100%);
border: none;
box-shadow: 0 4px 12px rgba(22, 39, 31, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.save-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(22, 39, 31, 0.3);
}
.editor-body {
padding: 40px 32px;
max-width: 800px;
margin: 0 auto;
}
/* Status Panel */
.status-panel {
display: flex;
align-items: flex-start;
gap: 20px;
padding: 24px 28px;
border-radius: 16px;
margin-bottom: 48px;
transition: all 0.3s ease;
}
.status-panel--bound {
background: linear-gradient(145deg, #f0fdf4 0%, #dcfce7 100%);
border: 1px solid rgba(22, 163, 74, 0.15);
.status-icon-wrap {
background: #16a34a;
box-shadow: 0 4px 12px rgba(22, 163, 74, 0.25);
}
}
.status-panel--unbound {
background: linear-gradient(145deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(217, 119, 6, 0.15);
.status-icon-wrap {
background: #d97706;
box-shadow: 0 4px 12px rgba(217, 119, 6, 0.2);
}
}
.status-icon-wrap {
display: grid;
place-items: center;
flex: 0 0 42px;
width: 42px;
height: 42px;
border-radius: 50%;
color: #ffffff;
}
.status-icon {
font-size: 22px;
}
.status-info {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding-top: 2px;
}
.status-info strong {
font-size: 16px;
color: #1a1a1a;
letter-spacing: -0.01em;
}
.status-info span {
font-size: 14px;
color: #4a4d52;
line-height: 1.6;
}
.mono-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
background: rgba(0, 0, 0, 0.04);
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
color: #1a1a1a;
user-select: all;
}
/* Material Form */
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
}
.form-grid__full {
grid-column: span 2;
}
.material-form :deep(.el-form-item) {
margin-bottom: 24px;
}
.material-form :deep(.el-form-item__label) {
padding-bottom: 8px;
font-weight: 500;
color: #4a4a4a;
font-size: 14px;
}
.material-input :deep(.el-input__wrapper) {
box-shadow: none !important;
border: 2px solid #dcdde0;
border-radius: 12px;
background-color: #f7f8fa;
transition: all 0.2s ease;
padding: 12px 16px;
}
.material-input :deep(.el-input__wrapper.is-focus) {
border-color: #1a1a1a;
background-color: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05) !important;
}
.miniapp-input :deep(input) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
letter-spacing: 0.02em;
}
/* Instruction Box */
.instruction-box {
margin-top: 48px;
padding: 24px;
background: #fafafa;
border-radius: 12px;
border: 1px dashed #dcdde0;
}
.instruction-box__header {
font-weight: 600;
color: #1a1a1a;
margin-bottom: 12px;
font-size: 14px;
}
.instruction-box__list {
margin: 0;
padding-left: 20px;
color: #757575;
font-size: 13px;
line-height: 1.8;
li::marker {
color: #b0b0b0;
}
}
@media (max-width: 768px) {
.unified-header {
flex-direction: column;
align-items: flex-start;
}
.unified-header__actions {
width: 100%;
margin-top: 16px;
justify-content: flex-end;
}
.status-panel {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import Sortable, { type SortableEvent } from 'sortablejs'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as tenantApi from '@/api/tenant'
import type { Department, TenantUser, TenantUserUpsertPayload } from '@/types/tenant'
import { getRoleLabel } from '@/utils/role'
const loading = ref(false)
const sorting = ref(false)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const filters = reactive({ keyword: '' })
const users = ref<TenantUser[]>([])
const departments = ref<Department[]>([])
const tableContainerRef = ref<HTMLElement | null>(null)
const statusUpdatingMap = reactive<Record<number, boolean>>({})
let sortable: Sortable | null = null
const form = reactive<TenantUserUpsertPayload>({
username: '',
realName: '',
mobile: '',
email: '',
jobTitle: '',
userStatus: 'ENABLED',
deptId: null,
roleCode: 'TENANT_USER',
})
const isKeywordFiltering = computed(() => Boolean(filters.keyword.trim()))
async function loadData() {
loading.value = true
try {
const [userData, deptData] = await Promise.all([
tenantApi.listUsers(filters.keyword),
tenantApi.listDepartments(),
])
users.value = userData
departments.value = deptData
} finally {
loading.value = false
await nextTick()
initSortable()
}
}
function openDialog(row?: TenantUser) {
editingId.value = row?.id ?? null
Object.assign(form, row
? {
username: row.username,
realName: row.realName,
mobile: row.mobile,
email: row.email,
jobTitle: row.jobTitle,
userStatus: row.userStatus,
deptId: row.deptId,
roleCode: row.roleCode,
}
: {
username: '',
realName: '',
mobile: '',
email: '',
jobTitle: '',
userStatus: 'ENABLED',
deptId: null,
roleCode: 'TENANT_USER',
})
dialogVisible.value = true
}
async function saveUser() {
if (editingId.value) {
await tenantApi.updateUser(editingId.value, form)
ElMessage.success('成员已更新')
} else {
await tenantApi.createUser(form)
ElMessage.success('成员已创建,默认密码为 123456')
}
dialogVisible.value = false
await loadData()
}
async function toggleUserStatus(user: TenantUser) {
if (statusUpdatingMap[user.id]) {
return
}
const nextStatus: TenantUser['userStatus'] = user.userStatus === 'ENABLED' ? 'DISABLED' : 'ENABLED'
statusUpdatingMap[user.id] = true
try {
const updated = await tenantApi.updateUserStatus(user.id, nextStatus)
Object.assign(user, updated)
ElMessage.success(nextStatus === 'ENABLED' ? '成员已启用' : '成员已停用')
} finally {
statusUpdatingMap[user.id] = false
}
}
function isStatusUpdating(userId: number) {
return Boolean(statusUpdatingMap[userId])
}
function getUserStatusLabel(status: TenantUser['userStatus']) {
return status === 'ENABLED' ? '启用中' : '已停用'
}
function destroySortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
}
function initSortable() {
destroySortable()
if (!tableContainerRef.value || isKeywordFiltering.value || sorting.value || loading.value) {
return
}
const tableBody = tableContainerRef.value.querySelector('.el-table__body-wrapper tbody') as HTMLElement | null
if (!tableBody) {
return
}
sortable = Sortable.create(tableBody, {
animation: 180,
handle: '.drag-handle',
ghostClass: 'member-sort-ghost',
chosenClass: 'member-sort-chosen',
onEnd: async (event: SortableEvent) => {
const { oldIndex, newIndex } = event
if (oldIndex == null || newIndex == null || oldIndex === newIndex || sorting.value) {
return
}
const sortedUsers = [...users.value]
const [movedUser] = sortedUsers.splice(oldIndex, 1)
if (!movedUser) {
return
}
sortedUsers.splice(newIndex, 0, movedUser)
users.value = sortedUsers
sorting.value = true
try {
await tenantApi.sortUsers(sortedUsers.map((item) => item.id))
users.value = sortedUsers.map((item, index) => ({
...item,
memberSort: (index + 1) * 10,
}))
ElMessage.success('排序已生效')
} catch {
await loadData()
} finally {
sorting.value = false
await nextTick()
initSortable()
}
},
})
}
async function resetPassword(user: TenantUser) {
await ElMessageBox.confirm(`确认将 ${user.realName} 的密码重置为 123456 吗?`, '重置密码', {
type: 'warning',
})
await tenantApi.resetUserPassword(user.id)
ElMessage.success('密码已重置')
}
onMounted(() => {
loadData()
})
onBeforeUnmount(() => {
destroySortable()
})
</script>
<template>
<div class="page-shell shell-stack">
<section class="hero-panel page-card">
<div class="hero-grid">
<div class="hero-main">
<div class="hero-kicker">
<span class="law-dot"></span>
<span>MEMBER DIRECTORY</span>
</div>
<h2>维护成员账号与身份权限</h2>
<p>集中处理成员的新增访问授权账号状态以及基础信息的维系</p>
</div>
</div>
</section>
<section class="page-card section-panel">
<div class="page-header">
<div>
<span class="eyebrow">USERS</span>
<h1 class="page-title">成员管理</h1>
</div>
<div class="toolbar-actions">
<el-input
v-model="filters.keyword"
placeholder="搜索姓名"
clearable
style="width: 220px;"
@change="loadData"
/>
<el-button type="primary" @click="openDialog()">新增成员</el-button>
</div>
</div>
<el-alert
v-if="isKeywordFiltering"
type="info"
:closable="false"
show-icon
title="当前为搜索结果,已禁用拖拽排序。清空搜索后可调整全量展示顺序。"
class="sort-tip"
/>
<div ref="tableContainerRef">
<el-table v-loading="loading || sorting" :data="users" row-key="id">
<el-table-column label="排序" width="88" align="center">
<template #default>
<span class="drag-handle" :class="{ 'drag-handle--disabled': isKeywordFiltering || sorting }"></span>
</template>
</el-table-column>
<el-table-column prop="realName" label="姓名" min-width="160" />
<el-table-column prop="username" label="账号" min-width="180" />
<el-table-column prop="deptName" label="所属组织" min-width="160" />
<el-table-column prop="jobTitle" label="职务" min-width="160" />
<el-table-column label="角色" width="140">
<template #default="{ row }">
<el-tag type="info">{{ getRoleLabel(row.roleCode) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="170">
<template #default="{ row }">
<el-button
size="small"
:type="row.userStatus === 'ENABLED' ? 'success' : 'info'"
:plain="row.userStatus !== 'ENABLED'"
:loading="isStatusUpdating(row.id)"
@click="toggleUserStatus(row)"
>
{{ getUserStatusLabel(row.userStatus) }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<div class="toolbar-actions">
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
<el-button link type="primary" @click="resetPassword(row)">重置密码</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑成员' : '新增成员'" width="620px">
<div class="form-grid">
<el-form-item label="账号">
<el-input v-model="form.username" :disabled="Boolean(editingId)" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.mobile" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="所属组织">
<el-select v-model="form.deptId" clearable>
<el-option v-for="item in departments" :key="item.id" :label="item.deptName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="职务">
<el-input v-model="form.jobTitle" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.roleCode">
<el-option label="租户管理员" value="TENANT_ADMIN" />
<el-option label="普通用户" value="TENANT_USER" />
</el-select>
</el-form-item>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveUser">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sort-tip {
margin-bottom: 16px;
}
.drag-handle {
display: inline-block;
width: 32px;
text-align: center;
font-size: 18px;
line-height: 1;
color: var(--ec-text-secondary);
cursor: grab;
user-select: none;
}
.drag-handle--disabled {
cursor: not-allowed;
opacity: 0.4;
}
:deep(.member-sort-ghost > td) {
background: rgba(26, 26, 26, 0.06);
}
:deep(.member-sort-chosen > td) {
background: rgba(26, 26, 26, 0.04);
}
</style>

View File

@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"types": [
"vite/client"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue"
]
}

View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -0,0 +1,35 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
base: '/card/',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
elementPlus: ['element-plus', '@element-plus/icons-vue'],
},
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/card/api': {
target: 'http://127.0.0.1:8112',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/card/, ''),
},
},
},
})