feat: 搭建后台管理端
- 初始化 Vue 3 + TypeScript + Vite 管理端工程 - 增加登录、租户、组织、用户、名片与系统页面 - 补充路由、状态管理、接口封装与基础样式体系
This commit is contained in:
2
frontend_admin/.env.development
Normal file
2
frontend_admin/.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=Easycard 管理后台
|
||||||
|
VITE_API_BASE_URL=
|
||||||
2
frontend_admin/.env.production
Normal file
2
frontend_admin/.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=Easycard 管理后台
|
||||||
|
VITE_API_BASE_URL=
|
||||||
13
frontend_admin/index.html
Normal file
13
frontend_admin/index.html
Normal 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
2597
frontend_admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend_admin/package.json
Normal file
31
frontend_admin/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend_admin/public/favicon.svg
Normal file
41
frontend_admin/public/favicon.svg
Normal 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 |
3
frontend_admin/src/App.vue
Normal file
3
frontend_admin/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
18
frontend_admin/src/api/auth.ts
Normal file
18
frontend_admin/src/api/auth.ts
Normal 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)
|
||||||
|
}
|
||||||
12
frontend_admin/src/api/file.ts
Normal file
12
frontend_admin/src/api/file.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
26
frontend_admin/src/api/platform.ts
Normal file
26
frontend_admin/src/api/platform.ts
Normal 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)
|
||||||
|
}
|
||||||
124
frontend_admin/src/api/tenant.ts
Normal file
124
frontend_admin/src/api/tenant.ts
Normal 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)
|
||||||
|
}
|
||||||
608
frontend_admin/src/assets/styles/index.scss
Normal file
608
frontend_admin/src/assets/styles/index.scss
Normal 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
10
frontend_admin/src/env.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
376
frontend_admin/src/layouts/AdminLayout.vue
Normal file
376
frontend_admin/src/layouts/AdminLayout.vue
Normal 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>
|
||||||
19
frontend_admin/src/main.ts
Normal file
19
frontend_admin/src/main.ts
Normal 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')
|
||||||
55
frontend_admin/src/mock/tenant.ts
Normal file
55
frontend_admin/src/mock/tenant.ts
Normal 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: '杭州',
|
||||||
|
},
|
||||||
|
]
|
||||||
38
frontend_admin/src/router/index.ts
Normal file
38
frontend_admin/src/router/index.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
190
frontend_admin/src/router/routes.ts
Normal file
190
frontend_admin/src/router/routes.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
22
frontend_admin/src/stores/app.ts
Normal file
22
frontend_admin/src/stores/app.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
109
frontend_admin/src/stores/auth.ts
Normal file
109
frontend_admin/src/stores/auth.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
3
frontend_admin/src/stores/index.ts
Normal file
3
frontend_admin/src/stores/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export const pinia = createPinia()
|
||||||
20
frontend_admin/src/types/auth.ts
Normal file
20
frontend_admin/src/types/auth.ts
Normal 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
|
||||||
|
}
|
||||||
13
frontend_admin/src/types/common.ts
Normal file
13
frontend_admin/src/types/common.ts
Normal 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'
|
||||||
8
frontend_admin/src/types/file.ts
Normal file
8
frontend_admin/src/types/file.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface FileAsset {
|
||||||
|
id: number
|
||||||
|
originalName: string
|
||||||
|
mimeType: string
|
||||||
|
fileSize: number
|
||||||
|
accessUrl: string
|
||||||
|
createdTime: string
|
||||||
|
}
|
||||||
40
frontend_admin/src/types/platform.ts
Normal file
40
frontend_admin/src/types/platform.ts
Normal 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
|
||||||
|
}
|
||||||
137
frontend_admin/src/types/tenant.ts
Normal file
137
frontend_admin/src/types/tenant.ts
Normal 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[]
|
||||||
|
}
|
||||||
13
frontend_admin/src/utils/auth.ts
Normal file
13
frontend_admin/src/utils/auth.ts
Normal 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)
|
||||||
|
}
|
||||||
40
frontend_admin/src/utils/file-upload.ts
Normal file
40
frontend_admin/src/utils/file-upload.ts
Normal 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: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
23
frontend_admin/src/utils/format.ts
Normal file
23
frontend_admin/src/utils/format.ts
Normal 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)
|
||||||
|
}
|
||||||
84
frontend_admin/src/utils/http.ts
Normal file
84
frontend_admin/src/utils/http.ts
Normal 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)
|
||||||
|
},
|
||||||
|
)
|
||||||
24
frontend_admin/src/utils/role.ts
Normal file
24
frontend_admin/src/utils/role.ts
Normal 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)
|
||||||
|
}
|
||||||
1334
frontend_admin/src/views/card/MyCardView.vue
Normal file
1334
frontend_admin/src/views/card/MyCardView.vue
Normal file
File diff suppressed because it is too large
Load Diff
285
frontend_admin/src/views/dashboard/DashboardView.vue
Normal file
285
frontend_admin/src/views/dashboard/DashboardView.vue
Normal 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>
|
||||||
37
frontend_admin/src/views/system/ArchiveCenterView.vue
Normal file
37
frontend_admin/src/views/system/ArchiveCenterView.vue
Normal 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>
|
||||||
388
frontend_admin/src/views/system/LoginView.vue
Normal file
388
frontend_admin/src/views/system/LoginView.vue
Normal 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>
|
||||||
133
frontend_admin/src/views/system/ProfileView.vue
Normal file
133
frontend_admin/src/views/system/ProfileView.vue
Normal 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>
|
||||||
35
frontend_admin/src/views/system/SystemSettingsView.vue
Normal file
35
frontend_admin/src/views/system/SystemSettingsView.vue
Normal 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>
|
||||||
750
frontend_admin/src/views/tenant/FirmProfileView.vue
Normal file
750
frontend_admin/src/views/tenant/FirmProfileView.vue
Normal 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/WEBP,5MB 以内</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/WEBP,5MB 以内</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>
|
||||||
291
frontend_admin/src/views/tenant/LawyerListView.vue
Normal file
291
frontend_admin/src/views/tenant/LawyerListView.vue
Normal 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>
|
||||||
260
frontend_admin/src/views/tenant/MiniappConfigView.vue
Normal file
260
frontend_admin/src/views/tenant/MiniappConfigView.vue
Normal 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>
|
||||||
431
frontend_admin/src/views/tenant/TenantListView.vue
Normal file
431
frontend_admin/src/views/tenant/TenantListView.vue
Normal 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>
|
||||||
335
frontend_admin/src/views/tenant/TenantMiniappBindingView.vue
Normal file
335
frontend_admin/src/views/tenant/TenantMiniappBindingView.vue
Normal 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>
|
||||||
328
frontend_admin/src/views/tenant/UserListView.vue
Normal file
328
frontend_admin/src/views/tenant/UserListView.vue
Normal 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>
|
||||||
20
frontend_admin/tsconfig.app.json
Normal file
20
frontend_admin/tsconfig.app.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
frontend_admin/tsconfig.json
Normal file
11
frontend_admin/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
frontend_admin/tsconfig.node.json
Normal file
13
frontend_admin/tsconfig.node.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
35
frontend_admin/vite.config.ts
Normal file
35
frontend_admin/vite.config.ts
Normal 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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user