feat: 搭建微信小程序展示端
- 初始化小程序工程配置与类型声明 - 增加首页、律所、律师列表、详情与历史页面 - 补充公共组件、运行时配置与示例素材
147
frontend_miniprogram/miniprogram/api/open.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { FirmInfo, Lawyer } from '../types/card';
|
||||
import { request } from '../utils/http';
|
||||
|
||||
interface OpenFirmResponse {
|
||||
name: string;
|
||||
logo: string;
|
||||
heroImage: string;
|
||||
intro: string;
|
||||
hotlinePhone: string;
|
||||
hqAddress: string;
|
||||
hqLatitude: number;
|
||||
hqLongitude: number;
|
||||
officeCount: number;
|
||||
lawyerCount: number;
|
||||
officeList: string[];
|
||||
practiceAreas: string[];
|
||||
}
|
||||
|
||||
interface OpenCardListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string;
|
||||
office: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
specialties: string[];
|
||||
}
|
||||
|
||||
interface OpenCardDetailResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string;
|
||||
office: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
avatar: string;
|
||||
coverImage: string;
|
||||
wechatQrImage: string;
|
||||
bio: string;
|
||||
specialties: string[];
|
||||
firmName: string;
|
||||
firmAddress: string;
|
||||
firmLatitude: number;
|
||||
firmLongitude: number;
|
||||
}
|
||||
|
||||
function toFirmInfo(payload: OpenFirmResponse): FirmInfo {
|
||||
return {
|
||||
id: payload.name || 'firm',
|
||||
name: payload.name || '',
|
||||
logo: payload.logo || '',
|
||||
intro: payload.intro || '',
|
||||
hotlinePhone: payload.hotlinePhone || '',
|
||||
hqAddress: payload.hqAddress || '',
|
||||
hqLatitude: payload.hqLatitude || 0,
|
||||
hqLongitude: payload.hqLongitude || 0,
|
||||
officeCount: payload.officeCount || 0,
|
||||
lawyerCount: payload.lawyerCount || 0,
|
||||
heroImage: payload.heroImage || '',
|
||||
officeList: payload.officeList || [],
|
||||
practiceAreas: payload.practiceAreas || [],
|
||||
};
|
||||
}
|
||||
|
||||
function toLawyer(payload: OpenCardListItem | OpenCardDetailResponse): Lawyer {
|
||||
return {
|
||||
id: String(payload.id),
|
||||
name: payload.name || '',
|
||||
title: payload.title || '',
|
||||
office: payload.office || '',
|
||||
phone: payload.phone || '',
|
||||
email: payload.email || '',
|
||||
address: 'address' in payload ? payload.address || '' : '',
|
||||
avatar: payload.avatar || '',
|
||||
coverImage: 'coverImage' in payload ? payload.coverImage || '' : '',
|
||||
specialties: payload.specialties || [],
|
||||
bio: 'bio' in payload ? payload.bio || '' : '',
|
||||
wechatQrImage: 'wechatQrImage' in payload ? payload.wechatQrImage || '' : '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFirmProfile(): Promise<FirmInfo> {
|
||||
const payload = await request<OpenFirmResponse>({
|
||||
url: '/api/open/firm/profile',
|
||||
});
|
||||
return toFirmInfo(payload);
|
||||
}
|
||||
|
||||
export async function listLawyers(params: {
|
||||
keyword?: string;
|
||||
office?: string;
|
||||
practiceArea?: string;
|
||||
}): Promise<Lawyer[]> {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`)
|
||||
.join('&');
|
||||
const payload = await request<OpenCardListItem[]>({
|
||||
url: `/api/open/cards${query ? `?${query}` : ''}`,
|
||||
});
|
||||
return payload.map((item) => toLawyer(item));
|
||||
}
|
||||
|
||||
export async function getLawyerDetail(cardId: string, sourceType = 'DIRECT', shareFromCardId = ''): Promise<{
|
||||
firm: FirmInfo;
|
||||
lawyer: Lawyer;
|
||||
}> {
|
||||
const queryParts = [`sourceType=${encodeURIComponent(sourceType)}`];
|
||||
if (shareFromCardId) {
|
||||
queryParts.push(`shareFromCardId=${encodeURIComponent(shareFromCardId)}`);
|
||||
}
|
||||
const payload = await request<OpenCardDetailResponse>({
|
||||
url: `/api/open/cards/${encodeURIComponent(cardId)}?${queryParts.join('&')}`,
|
||||
});
|
||||
|
||||
return {
|
||||
firm: {
|
||||
id: payload.firmName || 'firm',
|
||||
name: payload.firmName || '',
|
||||
logo: '',
|
||||
intro: '',
|
||||
hotlinePhone: '',
|
||||
hqAddress: payload.firmAddress || '',
|
||||
hqLatitude: payload.firmLatitude || 0,
|
||||
hqLongitude: payload.firmLongitude || 0,
|
||||
officeCount: 0,
|
||||
lawyerCount: 0,
|
||||
heroImage: '',
|
||||
officeList: [],
|
||||
practiceAreas: [],
|
||||
},
|
||||
lawyer: toLawyer(payload),
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordCardShare(cardId: string, sharePath: string): Promise<void> {
|
||||
await request<void>({
|
||||
url: `/api/open/cards/${encodeURIComponent(cardId)}/share`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
shareChannel: 'WECHAT_FRIEND',
|
||||
sharePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
26
frontend_miniprogram/miniprogram/app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/firm/index",
|
||||
"pages/lawyer-list/index",
|
||||
"pages/lawyer-detail/index",
|
||||
"pages/history/index"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundTextStyle": "light",
|
||||
"backgroundColor": "#f4f4f4"
|
||||
},
|
||||
"style": "v2",
|
||||
"rendererOptions": {
|
||||
"skyline": {
|
||||
"defaultDisplayBlock": true,
|
||||
"disableABTest": true,
|
||||
"sdkVersionBegin": "3.0.0",
|
||||
"sdkVersionEnd": "15.255.255"
|
||||
}
|
||||
},
|
||||
"componentFramework": "glass-easel",
|
||||
"sitemapLocation": "sitemap.json",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
201
frontend_miniprogram/miniprogram/app.less
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Law Firm Digital Card - Design System
|
||||
* 简约、扁平、现代化风格
|
||||
*/
|
||||
|
||||
page {
|
||||
/* --- 核心色彩系统 (Color Palette) --- */
|
||||
|
||||
/* 主色调:勃艮第红 - 传递权威、庄重、正义 */
|
||||
--primary-color: #8E2230;
|
||||
--primary-light: #B86A74;
|
||||
/* 用于 hover 或浅色背景 */
|
||||
--primary-dark: #5C0D15;
|
||||
|
||||
/* 强调色:金色 - 传递高端、品质 */
|
||||
--accent-color: #D4AF37;
|
||||
--accent-light: #dfc466;
|
||||
|
||||
/* 中性色系统 */
|
||||
--text-main: #1A1A1A;
|
||||
/* 主要文字,接近纯黑 */
|
||||
--text-secondary: #595959;
|
||||
/* 次要文字,深灰 */
|
||||
--text-tertiary: #8C8C8C;
|
||||
/* 辅助文字,浅灰 */
|
||||
--text-placeholder: #BFBFBF;
|
||||
|
||||
/* 背景色 */
|
||||
--bg-page: #F5F7FA;
|
||||
/* 页面底色,极浅的蓝灰 */
|
||||
--bg-card: #FFFFFF;
|
||||
/* 卡片背景,纯白 */
|
||||
--bg-surface: #F9FAFC;
|
||||
/* 次级表面颜色 */
|
||||
|
||||
/* 分割线与边框 */
|
||||
--border-color: #E8E8E8;
|
||||
--border-radius-base: 12rpx;
|
||||
--border-radius-lg: 16rpx;
|
||||
--border-radius-sm: 8rpx;
|
||||
|
||||
/* --- 排版系统 (Typography) --- */
|
||||
--font-size-xs: 20rpx;
|
||||
--font-size-sm: 24rpx;
|
||||
--font-size-base: 28rpx;
|
||||
--font-size-lg: 32rpx;
|
||||
--font-size-xl: 36rpx;
|
||||
--font-size-xxl: 40rpx;
|
||||
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 600;
|
||||
|
||||
/* --- 间距 (Spacing) --- */
|
||||
--spacing-xs: 8rpx;
|
||||
--spacing-sm: 16rpx;
|
||||
--spacing-md: 24rpx;
|
||||
--spacing-lg: 32rpx;
|
||||
--spacing-xl: 48rpx;
|
||||
|
||||
/* --- 阴影 (Shadows) --- */
|
||||
--shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||
--shadow-base: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* 全局设置 */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
|
||||
background-color: var(--bg-page);
|
||||
color: var(--text-main);
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* --- 布局工具类 (Layout Utilities) --- */
|
||||
|
||||
.container-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-page);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Flexbox Helpers */
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Spacing Helpers */
|
||||
.mt-sm {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.mt-lg {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.mb-sm {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.mb-lg {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* Common Components */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Section Title - Modern Style */
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-main);
|
||||
position: relative;
|
||||
padding-left: 20rpx;
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4rpx;
|
||||
bottom: 4rpx;
|
||||
width: 6rpx;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
/* Safe Area styling for bottom navigation */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
6
frontend_miniprogram/miniprogram/app.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
App<IAppOption>({
|
||||
globalData: {},
|
||||
onLaunch() {
|
||||
// 可在此扩展启动逻辑
|
||||
},
|
||||
});
|
||||
BIN
frontend_miniprogram/miniprogram/assets/images/avatar_1.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/avatar_2.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/avatar_3.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/avatar_4.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/avatar_5.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/hero.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/hero_v2.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend_miniprogram/miniprogram/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend_miniprogram/miniprogram/assets/qr/qr_1.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend_miniprogram/miniprogram/assets/qr/qr_2.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend_miniprogram/miniprogram/assets/qr/qr_3.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend_miniprogram/miniprogram/assets/qr/qr_4.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend_miniprogram/miniprogram/assets/qr/qr_5.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.action-dock {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 16rpx var(--spacing-lg) calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1rpx solid var(--border-color);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
color: var(--text-secondary);
|
||||
font-size: 20rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
line-height: inherit;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
/* Emoji size */
|
||||
margin-bottom: 4rpx;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.action-btn:active .icon {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Component({
|
||||
methods: {
|
||||
handleAction(event: WechatMiniprogram.TouchEvent) {
|
||||
const action = event.currentTarget.dataset.action as string;
|
||||
this.triggerEvent('action', { action });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<view class="action-dock">
|
||||
<view class="action-item" data-action="save" bindtap="handleAction">
|
||||
<text class="icon">存</text>
|
||||
<text>收下名片</text>
|
||||
</view>
|
||||
<button class="action-item action-btn" open-type="share">
|
||||
<text class="icon">享</text>
|
||||
<text>分享名片</text>
|
||||
</button>
|
||||
<view class="action-item" data-action="location" bindtap="handleAction">
|
||||
<text class="icon">导</text>
|
||||
<text>律所导航</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
Component({
|
||||
properties: {
|
||||
title: {
|
||||
type: String,
|
||||
value: '',
|
||||
},
|
||||
back: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
value: '#F4F4F4',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
value: '#1f1f1f',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
<navigation-bar
|
||||
title="{{title}}"
|
||||
back="{{back}}"
|
||||
color="{{color}}"
|
||||
background="{{background}}"
|
||||
></navigation-bar>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.empty-wrap {
|
||||
padding: 140rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 24rpx;
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Component({
|
||||
properties: {
|
||||
text: {
|
||||
type: String,
|
||||
value: '暂无数据',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
<view class="empty-wrap">
|
||||
<text class="empty-icon">∅</text>
|
||||
<text class="empty-text">{{text}}</text>
|
||||
</view>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.filter-wrap {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 0 24rpx 16rpx;
|
||||
background: var(--bg-page);
|
||||
/* Use variable */
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
height: 84rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 14rpx;
|
||||
border: 1rpx solid var(--border-color);
|
||||
/* Use variable */
|
||||
background: #fff;
|
||||
/* Card bg */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
picker {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
/* Use variable */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
/* Use variable */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--text-placeholder);
|
||||
/* Use variable */
|
||||
font-size: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
Component({
|
||||
properties: {
|
||||
officeOptions: {
|
||||
type: Array,
|
||||
value: [],
|
||||
},
|
||||
areaOptions: {
|
||||
type: Array,
|
||||
value: [],
|
||||
},
|
||||
selectedOffice: {
|
||||
type: String,
|
||||
value: '所有办公机构',
|
||||
},
|
||||
selectedArea: {
|
||||
type: String,
|
||||
value: '全部专业领域',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleOfficeChange(event: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const officeOptions = this.properties.officeOptions as string[];
|
||||
const index = Number(event.detail.value);
|
||||
const value = officeOptions[index] || officeOptions[0] || '所有办公机构';
|
||||
this.triggerEvent('officechange', { value, index });
|
||||
},
|
||||
handleAreaChange(event: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const areaOptions = this.properties.areaOptions as string[];
|
||||
const index = Number(event.detail.value);
|
||||
const value = areaOptions[index] || areaOptions[0] || '全部专业领域';
|
||||
this.triggerEvent('areachange', { value, index });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<view class="filter-wrap">
|
||||
<picker mode="selector" range="{{officeOptions}}" bindchange="handleOfficeChange">
|
||||
<view class="filter-item">
|
||||
<text class="label">机构</text>
|
||||
<text class="value">{{selectedOffice}}</text>
|
||||
<text class="arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" range="{{areaOptions}}" bindchange="handleAreaChange">
|
||||
<view class="filter-item">
|
||||
<text class="label">领域</text>
|
||||
<text class="value">{{selectedArea}}</text>
|
||||
<text class="arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
.lawyer-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.lawyer-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid var(--bg-page);
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--spacing-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22rpx;
|
||||
color: var(--primary-color);
|
||||
background: rgba(142, 34, 48, 0.08);
|
||||
/* Primary opacity */
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 8rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.office {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-page);
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.tag-more {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
|
||||
.action-col {
|
||||
margin-left: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.consult-btn {
|
||||
font-size: 24rpx;
|
||||
color: var(--primary-color);
|
||||
border: 1rpx solid var(--primary-color);
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
Component({
|
||||
data: {
|
||||
specialtiesText: '',
|
||||
},
|
||||
properties: {
|
||||
lawyer: {
|
||||
type: Object,
|
||||
value: null,
|
||||
},
|
||||
showOffice: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
observers: {
|
||||
lawyer(lawyer: { specialties?: string[] } | null) {
|
||||
const specialties =
|
||||
lawyer && Array.isArray(lawyer.specialties) ? lawyer.specialties : [];
|
||||
this.setData({
|
||||
specialties, // Expose array for wx:for
|
||||
specialtiesText: specialties.join(' | '),
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleTap() {
|
||||
const lawyer = this.properties.lawyer as { id?: string } | null;
|
||||
this.triggerEvent('select', {
|
||||
id: lawyer && typeof lawyer.id === 'string' ? lawyer.id : '',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<view class="lawyer-card" bindtap="handleTap">
|
||||
<image class="avatar" src="{{lawyer.avatar}}" mode="aspectFill"></image>
|
||||
|
||||
<view class="content">
|
||||
<view class="header-row">
|
||||
<text class="name">{{lawyer.name}}</text>
|
||||
<text class="title">{{lawyer.title}}</text>
|
||||
</view>
|
||||
|
||||
<text wx:if="{{showOffice}}" class="office">{{lawyer.office}}</text>
|
||||
|
||||
<view class="tags-row">
|
||||
<text class="tag" wx:for="{{specialties}}" wx:key="*this" wx:if="{{index < 3}}">{{item}}</text>
|
||||
<text class="tag-more" wx:if="{{specialties.length > 3}}">...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-col">
|
||||
<text class="consult-btn">咨询</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.weui-navigation-bar {
|
||||
--weui-FG-0:rgba(0,0,0,.9);
|
||||
--height: 44px;
|
||||
--left: 16px;
|
||||
}
|
||||
.weui-navigation-bar .android {
|
||||
--height: 48px;
|
||||
}
|
||||
|
||||
.weui-navigation-bar {
|
||||
overflow: hidden;
|
||||
color: var(--weui-FG-0);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__inner {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: calc(var(--height) + env(safe-area-inset-top));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__left {
|
||||
position: relative;
|
||||
padding-left: var(--left);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback_wrapper {
|
||||
padding: 11px 18px 11px 16px;
|
||||
margin: -11px -18px -11px -16px;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback_wrapper.weui-active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 24px;
|
||||
-webkit-mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
|
||||
mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
background-color: var(--weui-FG-0);
|
||||
}
|
||||
|
||||
.weui-navigation-bar__center {
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__loading {
|
||||
margin-right: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weui-loading {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
background: transparent url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='80px' height='80px' viewBox='0 0 80 80' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Eloading%3C/title%3E%3Cdefs%3E%3ClinearGradient x1='94.0869141%25' y1='0%25' x2='94.0869141%25' y2='90.559082%25' id='linearGradient-1'%3E%3Cstop stop-color='%23606060' stop-opacity='0' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3ClinearGradient x1='100%25' y1='8.67370605%25' x2='100%25' y2='90.6286621%25' id='linearGradient-2'%3E%3Cstop stop-color='%23606060' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3C/defs%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' opacity='0.9'%3E%3Cg%3E%3Cpath d='M40,0 C62.09139,0 80,17.90861 80,40 C80,62.09139 62.09139,80 40,80 L40,73 C58.2253967,73 73,58.2253967 73,40 C73,21.7746033 58.2253967,7 40,7 L40,0 Z' fill='url(%23linearGradient-1)'%3E%3C/path%3E%3Cpath d='M40,0 L40,7 C21.7746033,7 7,21.7746033 7,40 C7,58.2253967 21.7746033,73 40,73 L40,80 C17.90861,80 0,62.09139 0,40 C0,17.90861 17.90861,0 40,0 Z' fill='url(%23linearGradient-2)'%3E%3C/path%3E%3Ccircle id='Oval' fill='%23606060' cx='40.5' cy='3.5' r='3.5'%3E%3C/circle%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A") no-repeat;
|
||||
background-size: 100%;
|
||||
margin-left: 0;
|
||||
animation: loading linear infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
Component({
|
||||
options: {
|
||||
multipleSlots: true // 在组件定义时的选项中启用多slot支持
|
||||
},
|
||||
/**
|
||||
* 组件的属性列表
|
||||
*/
|
||||
properties: {
|
||||
extClass: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
back: {
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
homeButton: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
animated: {
|
||||
// 显示隐藏的时候opacity动画效果
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
show: {
|
||||
// 显示隐藏导航,隐藏的时候navigation-bar的高度占位还在
|
||||
type: Boolean,
|
||||
value: true,
|
||||
observer: '_showChange'
|
||||
},
|
||||
// back为true的时候,返回的页面深度
|
||||
delta: {
|
||||
type: Number,
|
||||
value: 1
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 组件的初始数据
|
||||
*/
|
||||
data: {
|
||||
displayStyle: ''
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const rect = wx.getMenuButtonBoundingClientRect()
|
||||
wx.getSystemInfo({
|
||||
success: (res) => {
|
||||
const isAndroid = res.platform === 'android'
|
||||
const isDevtools = res.platform === 'devtools'
|
||||
this.setData({
|
||||
ios: !isAndroid,
|
||||
innerPaddingRight: `padding-right: ${res.windowWidth - rect.left}px`,
|
||||
leftWidth: `width: ${res.windowWidth - rect.left }px`,
|
||||
safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${res.safeArea.top}px); padding-top: ${res.safeArea.top}px` : ``
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 组件的方法列表
|
||||
*/
|
||||
methods: {
|
||||
_showChange(show: boolean) {
|
||||
const animated = this.data.animated
|
||||
let displayStyle = ''
|
||||
if (animated) {
|
||||
displayStyle = `opacity: ${
|
||||
show ? '1' : '0'
|
||||
};transition:opacity 0.5s;`
|
||||
} else {
|
||||
displayStyle = `display: ${show ? '' : 'none'}`
|
||||
}
|
||||
this.setData({
|
||||
displayStyle
|
||||
})
|
||||
},
|
||||
back() {
|
||||
const data = this.data
|
||||
if (data.delta) {
|
||||
wx.navigateBack({
|
||||
delta: data.delta
|
||||
})
|
||||
}
|
||||
this.triggerEvent('back', { delta: data.delta }, {})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
<view class="weui-navigation-bar {{extClass}}">
|
||||
<view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="color: {{color}}; background: {{background}}; {{displayStyle}}; {{innerPaddingRight}}; {{safeAreaTop}};">
|
||||
|
||||
<!-- 左侧按钮 -->
|
||||
<view class='weui-navigation-bar__left' style="{{leftWidth}};">
|
||||
<block wx:if="{{back || homeButton}}">
|
||||
<!-- 返回上一页 -->
|
||||
<block wx:if="{{back}}">
|
||||
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_goback">
|
||||
<view
|
||||
bindtap="back"
|
||||
class="weui-navigation-bar__btn_goback_wrapper"
|
||||
hover-class="weui-active"
|
||||
hover-stay-time="100"
|
||||
aria-role="button"
|
||||
aria-label="返回"
|
||||
>
|
||||
<view class="weui-navigation-bar__button weui-navigation-bar__btn_goback"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<!-- 返回首页 -->
|
||||
<block wx:if="{{homeButton}}">
|
||||
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_home">
|
||||
<view
|
||||
bindtap="home"
|
||||
class="weui-navigation-bar__btn_home_wrapper"
|
||||
hover-class="weui-active"
|
||||
aria-role="button"
|
||||
aria-label="首页"
|
||||
>
|
||||
<view class="weui-navigation-bar__button weui-navigation-bar__btn_home"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<slot name="left"></slot>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class='weui-navigation-bar__center'>
|
||||
<view wx:if="{{loading}}" class="weui-navigation-bar__loading" aria-role="alert">
|
||||
<view
|
||||
class="weui-loading"
|
||||
aria-role="img"
|
||||
aria-label="加载中"
|
||||
></view>
|
||||
</view>
|
||||
<block wx:if="{{title}}">
|
||||
<text>{{title}}</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<slot name="center"></slot>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 右侧留空 -->
|
||||
<view class='weui-navigation-bar__right'>
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
16
frontend_miniprogram/miniprogram/config/runtime.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type MiniProgramEnvVersion = 'develop' | 'trial' | 'release';
|
||||
|
||||
export const API_BASE_URL_OVERRIDE_KEY = 'api_base_url_override';
|
||||
|
||||
export interface TenantRuntimeConfig {
|
||||
apiBaseUrlByEnv: Record<MiniProgramEnvVersion, string>;
|
||||
}
|
||||
|
||||
// develop 环境允许本地联调;trial/release 请替换为已备案且已配置到小程序后台“服务器域名”的 HTTPS 域名。
|
||||
export const tenantRuntimeConfig: TenantRuntimeConfig = {
|
||||
apiBaseUrlByEnv: {
|
||||
develop: 'http://127.0.0.1:8112',
|
||||
trial: 'https://trial-api.example.com',
|
||||
release: 'https://api.example.com',
|
||||
},
|
||||
};
|
||||
1
frontend_miniprogram/miniprogram/constants/storage.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CARD_VIEW_HISTORY_KEY = 'card_view_history_v1';
|
||||
102
frontend_miniprogram/miniprogram/data/mock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { FirmInfo, Lawyer } from '../types/card';
|
||||
|
||||
export const firmInfo: FirmInfo = {
|
||||
id: 'firm_xx',
|
||||
name: 'XX律师事务所',
|
||||
logo: '/assets/images/logo.png', // Keep path, but UI uses placeholder
|
||||
heroImage: '/assets/images/hero_v2.png',
|
||||
hotlinePhone: '13800138000',
|
||||
hqAddress: '南京市江宁区紫金研创中心',
|
||||
hqLatitude: 31.932, // Approximate for Jiangning Zijin
|
||||
hqLongitude: 118.825,
|
||||
officeCount: 4,
|
||||
lawyerCount: 5,
|
||||
officeList: ['南京总部', '北京分所', '上海分所', '深圳分所'],
|
||||
practiceAreas: [
|
||||
'民商事',
|
||||
'婚姻家事',
|
||||
'企业合规',
|
||||
'公司法务',
|
||||
'数字经济',
|
||||
'破产法',
|
||||
'私人财富管理',
|
||||
'合同纠纷',
|
||||
'劳动争议',
|
||||
'知识产权',
|
||||
],
|
||||
intro:
|
||||
'XX律师事务所是一家综合性律师事务所,长期聚焦民商事争议解决、公司治理与合规管理、婚姻家事与私人财富管理等方向。律所总部位于南京,面向企业与个人客户提供规范、专业、可持续的法律服务。\n\n截至目前,本所在多地设有分支办公室,拥有成熟的专业委员会体系与协同办案机制,已为大量企事业单位和个人客户提供法律顾问及专项法律服务。',
|
||||
};
|
||||
|
||||
export const lawyers: Lawyer[] = [
|
||||
{
|
||||
id: 'lawyer_zhangsan',
|
||||
name: '张三',
|
||||
title: '高级合伙人',
|
||||
office: '南京总部',
|
||||
phone: '13800138000',
|
||||
email: 'zhangsan@xx-law.com',
|
||||
address: '南京市江宁区紫金研创中心',
|
||||
avatar: '/assets/images/avatar_1.png',
|
||||
coverImage: '',
|
||||
specialties: ['企业合规', '公司法务', '数字经济', '破产法'],
|
||||
bio: '长期服务于企业客户,擅长企业合规体系建设、股权与治理结构优化、公司争议解决。',
|
||||
wechatQrImage: '/assets/qr/qr_1.png',
|
||||
},
|
||||
{
|
||||
id: 'lawyer_lisi',
|
||||
name: '李四',
|
||||
title: '资深律师',
|
||||
office: '南京总部',
|
||||
phone: '13800138001',
|
||||
email: 'lisi@xx-law.com',
|
||||
address: '南京市江宁区紫金研创中心',
|
||||
avatar: '/assets/images/avatar_2.png',
|
||||
coverImage: '',
|
||||
specialties: ['婚姻家事', '私人财富管理'],
|
||||
bio: '专注婚姻家事和财富管理争议,注重非诉协商与诉讼策略并行。',
|
||||
wechatQrImage: '/assets/qr/qr_2.png',
|
||||
},
|
||||
{
|
||||
id: 'lawyer_wangwu',
|
||||
name: '王五',
|
||||
title: '律师',
|
||||
office: '北京分所',
|
||||
phone: '13800138002',
|
||||
email: 'wangwu@xx-law.com',
|
||||
address: '北京市朝阳区国贸CBD',
|
||||
avatar: '/assets/images/avatar_3.png',
|
||||
coverImage: '',
|
||||
specialties: ['民商事', '合同纠纷'],
|
||||
bio: '擅长民商事纠纷案件办理,兼具庭审攻防与证据组织能力。',
|
||||
wechatQrImage: '/assets/qr/qr_3.png',
|
||||
},
|
||||
{
|
||||
id: 'lawyer_zhaoliu',
|
||||
name: '赵六',
|
||||
title: '律师',
|
||||
office: '上海分所',
|
||||
phone: '13800138003',
|
||||
email: 'zhaoliu@xx-law.com',
|
||||
address: '上海市浦东新区陆家嘴',
|
||||
avatar: '/assets/images/avatar_4.png',
|
||||
coverImage: '',
|
||||
specialties: ['劳动争议', '公司法务'],
|
||||
bio: '长期服务成长型企业,熟悉劳动用工、合同管理和风险治理。',
|
||||
wechatQrImage: '/assets/qr/qr_4.png',
|
||||
},
|
||||
{
|
||||
id: 'lawyer_sunqi',
|
||||
name: '孙七',
|
||||
title: '律师',
|
||||
office: '深圳分所',
|
||||
phone: '13800138004',
|
||||
email: 'sunqi@xx-law.com',
|
||||
address: '深圳市福田区中心商务区',
|
||||
avatar: '/assets/images/avatar_5.png',
|
||||
coverImage: '',
|
||||
specialties: ['知识产权', '数字经济'],
|
||||
bio: '专注数字经济与知识产权争议处理,擅长平台与内容合规。',
|
||||
wechatQrImage: '/assets/qr/qr_5.png',
|
||||
},
|
||||
];
|
||||
5
frontend_miniprogram/miniprogram/pages/firm/index.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"app-header": "/components/app-header/app-header"
|
||||
}
|
||||
}
|
||||
182
frontend_miniprogram/miniprogram/pages/firm/index.less
Normal file
@@ -0,0 +1,182 @@
|
||||
.firm-page {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
position: relative;
|
||||
height: 520rpx;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 80rpx;
|
||||
/* Space for overlap */
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Updated gradient to match Burgundy Red */
|
||||
background: linear-gradient(to bottom, rgba(142, 34, 48, 0.4), rgba(92, 13, 21, 0.9));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 72rpx;
|
||||
}
|
||||
|
||||
.firm-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.32);
|
||||
background: linear-gradient(120deg, rgba(10, 21, 34, 0.62), rgba(19, 38, 56, 0.36));
|
||||
box-shadow: 0 10rpx 28rpx rgba(7, 15, 27, 0.35), inset 0 1rpx 0 rgba(255, 255, 255, 0.24);
|
||||
backdrop-filter: blur(10rpx);
|
||||
-webkit-backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(242, 247, 252, 0.92);
|
||||
margin-top: 4rpx;
|
||||
text-shadow: 0 1rpx 8rpx rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 2rpx;
|
||||
height: 40rpx;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0.12));
|
||||
margin: 0 24rpx;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
margin-top: -60rpx;
|
||||
background: var(--bg-page);
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.address-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-base);
|
||||
}
|
||||
|
||||
.address-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.address-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.address-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
font-size: 32rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.tags-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.area-tag {
|
||||
font-size: 26rpx;
|
||||
color: var(--primary-color);
|
||||
background: rgba(142, 34, 48, 0.08);
|
||||
/* Primary color opacity */
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
padding: 20rpx var(--spacing-md);
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
border-radius: 50rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 20rpx rgba(142, 34, 48, 0.3);
|
||||
}
|
||||
|
||||
.cta-btn::after {
|
||||
border: none;
|
||||
}
|
||||
71
frontend_miniprogram/miniprogram/pages/firm/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getFirmProfile } from '../../api/open';
|
||||
import type { FirmInfo } from '../../types/card';
|
||||
|
||||
function createEmptyFirm(): FirmInfo {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
logo: '',
|
||||
intro: '',
|
||||
hotlinePhone: '',
|
||||
hqAddress: '',
|
||||
hqLatitude: 0,
|
||||
hqLongitude: 0,
|
||||
officeCount: 0,
|
||||
lawyerCount: 0,
|
||||
heroImage: '',
|
||||
officeList: [],
|
||||
practiceAreas: [],
|
||||
};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
firm: createEmptyFirm(),
|
||||
loading: false,
|
||||
},
|
||||
onLoad() {
|
||||
this.loadFirmProfile();
|
||||
},
|
||||
async loadFirmProfile() {
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
const firm = await getFirmProfile();
|
||||
this.setData({ firm });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载事务所信息失败';
|
||||
wx.showToast({
|
||||
title: message,
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
goLawyerList() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/lawyer-list/index',
|
||||
});
|
||||
},
|
||||
openLocation() {
|
||||
const { firm } = this.data;
|
||||
if (!firm.hqLatitude || !firm.hqLongitude) {
|
||||
wx.showToast({
|
||||
title: '暂未配置地图位置',
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
wx.openLocation({
|
||||
latitude: firm.hqLatitude,
|
||||
longitude: firm.hqLongitude,
|
||||
name: firm.name,
|
||||
address: firm.hqAddress,
|
||||
scale: 18,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '打开地图失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
60
frontend_miniprogram/miniprogram/pages/firm/index.wxml
Normal file
@@ -0,0 +1,60 @@
|
||||
<view class="container-page firm-page">
|
||||
<!-- Custom Navigation Bar with transparent background initially if possible, or just standard -->
|
||||
<app-header title="{{firm.name}}" back="{{false}}" background="rgba(255,255,255,0.9)"></app-header>
|
||||
|
||||
<scroll-view class="page-content" scroll-y="true" type="list">
|
||||
|
||||
<!-- Hero Section -->
|
||||
<view class="hero-section">
|
||||
<image wx:if="{{firm.heroImage}}" class="hero-bg" mode="aspectFill" src="{{firm.heroImage}}"></image>
|
||||
<view wx:if="{{!firm.heroImage}}" class="hero-overlay"></view>
|
||||
<view class="hero-content">
|
||||
<view class="firm-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-num">{{firm.officeCount}}</text>
|
||||
<text class="stat-label">办公机构</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-num">{{firm.lawyerCount}}</text>
|
||||
<text class="stat-label">专业律师</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Content Container (Overlapping) -->
|
||||
<view class="content-container">
|
||||
|
||||
<!-- Address Card -->
|
||||
<view class="card address-card" bindtap="openLocation">
|
||||
<view class="address-info">
|
||||
<text class="address-label">总部地址</text>
|
||||
<text class="address-text">{{firm.hqAddress}}</text>
|
||||
</view>
|
||||
<text class="address-icon">></text>
|
||||
</view>
|
||||
|
||||
<!-- Intro Section -->
|
||||
<view class="section">
|
||||
<view class="section-title">律所简介</view>
|
||||
<text class="intro-text">{{firm.intro}}</text>
|
||||
</view>
|
||||
|
||||
<!-- Practice Areas -->
|
||||
<view class="section">
|
||||
<view class="section-title">专业领域</view>
|
||||
<view class="tags-wrapper">
|
||||
<view class="area-tag" wx:for="{{firm.practiceAreas}}" wx:key="*this">{{item}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-area-bottom" style="height: 120rpx;"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<view class="bottom-bar safe-area-bottom">
|
||||
<button class="cta-btn" bindtap="goLawyerList">查找专业律师</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"app-header": "/components/app-header/app-header",
|
||||
"lawyer-card": "/components/lawyer-card/lawyer-card",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
39
frontend_miniprogram/miniprogram/pages/history/index.less
Normal file
@@ -0,0 +1,39 @@
|
||||
.history-page {
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 14rpx 24rpx 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
margin: 0;
|
||||
width: 156rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid var(--theme-border);
|
||||
font-size: 24rpx;
|
||||
color: var(--theme-primary);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.clear-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
padding: 16rpx 24rpx 30rpx;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
margin: 10rpx 10rpx 0;
|
||||
font-size: 22rpx;
|
||||
color: #8e96a6;
|
||||
}
|
||||
80
frontend_miniprogram/miniprogram/pages/history/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { clearCardViewHistory, getCardViewHistory } from '../../utils/history';
|
||||
import { formatTime } from '../../utils/util';
|
||||
import type { Lawyer } from '../../types/card';
|
||||
|
||||
interface HistoryCardItem {
|
||||
lawyer: Lawyer;
|
||||
viewedAt: number;
|
||||
timeText: string;
|
||||
}
|
||||
|
||||
function createFallbackLawyer(lawyerId: string): Lawyer {
|
||||
return {
|
||||
id: lawyerId,
|
||||
name: '历史浏览名片',
|
||||
title: '',
|
||||
office: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
avatar: '',
|
||||
coverImage: '',
|
||||
specialties: [],
|
||||
bio: '',
|
||||
wechatQrImage: '',
|
||||
};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
items: [] as HistoryCardItem[],
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadHistory();
|
||||
},
|
||||
|
||||
loadHistory() {
|
||||
const history = getCardViewHistory();
|
||||
if (!history.length) {
|
||||
this.setData({ items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const items = history
|
||||
.map((record) => ({
|
||||
lawyer: record.lawyer || createFallbackLawyer(record.lawyerId),
|
||||
viewedAt: record.viewedAt,
|
||||
timeText: formatTime(new Date(record.viewedAt)),
|
||||
}));
|
||||
|
||||
this.setData({ items });
|
||||
},
|
||||
|
||||
handleLawyerSelect(event: WechatMiniprogram.CustomEvent<{ id: string }>) {
|
||||
const lawyerId = event.detail.id;
|
||||
if (!lawyerId) {
|
||||
return;
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: `/pages/lawyer-detail/index?id=${lawyerId}`,
|
||||
});
|
||||
},
|
||||
|
||||
clearHistory() {
|
||||
if (!this.data.items.length) {
|
||||
return;
|
||||
}
|
||||
wx.showModal({
|
||||
title: '清空记录',
|
||||
content: '确定清空所有名片查看记录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
clearCardViewHistory();
|
||||
this.setData({ items: [] });
|
||||
wx.showToast({ title: '已清空', icon: 'success' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
22
frontend_miniprogram/miniprogram/pages/history/index.wxml
Normal file
@@ -0,0 +1,22 @@
|
||||
<view class="container-page history-page">
|
||||
<app-header title="名片查看记录" back="{{true}}" background="#f6f7fa"></app-header>
|
||||
|
||||
<scroll-view class="page-content" scroll-y="true" type="list">
|
||||
<block wx:if="{{items.length}}">
|
||||
<view class="actions">
|
||||
<button class="clear-btn" bindtap="clearHistory">清空记录</button>
|
||||
</view>
|
||||
|
||||
<view class="history-list">
|
||||
<view class="history-item" wx:for="{{items}}" wx:key="viewedAt" wx:for-item="item">
|
||||
<lawyer-card lawyer="{{item.lawyer}}" bind:select="handleLawyerSelect"></lawyer-card>
|
||||
<text class="history-time">查看时间:{{item.timeText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<empty-state text="还没有名片浏览记录"></empty-state>
|
||||
</block>
|
||||
</scroll-view>
|
||||
</view>
|
||||
5
frontend_miniprogram/miniprogram/pages/index/index.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
61
frontend_miniprogram/miniprogram/pages/index/index.less
Normal file
@@ -0,0 +1,61 @@
|
||||
/**index.less**/
|
||||
page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scrollarea {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.userinfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #aaa;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.userinfo-avatar {
|
||||
overflow: hidden;
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
margin: 20rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.usermotto {
|
||||
margin-top: 200px;
|
||||
}
|
||||
.avatar-wrapper {
|
||||
padding: 0;
|
||||
width: 56px !important;
|
||||
border-radius: 8px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.nickname-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
border-top: .5px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: .5px solid rgba(0, 0, 0, 0.1);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.nickname-label {
|
||||
width: 105px;
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
flex: 1;
|
||||
}
|
||||
54
frontend_miniprogram/miniprogram/pages/index/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// index.ts
|
||||
// 获取应用实例
|
||||
const app = getApp<IAppOption>()
|
||||
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
|
||||
|
||||
Component({
|
||||
data: {
|
||||
motto: 'Hello World',
|
||||
userInfo: {
|
||||
avatarUrl: defaultAvatarUrl,
|
||||
nickName: '',
|
||||
},
|
||||
hasUserInfo: false,
|
||||
canIUseGetUserProfile: wx.canIUse('getUserProfile'),
|
||||
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
|
||||
},
|
||||
methods: {
|
||||
// 事件处理函数
|
||||
bindViewTap() {
|
||||
wx.navigateTo({
|
||||
url: '../logs/logs',
|
||||
})
|
||||
},
|
||||
onChooseAvatar(e: any) {
|
||||
const { avatarUrl } = e.detail
|
||||
const { nickName } = this.data.userInfo
|
||||
this.setData({
|
||||
"userInfo.avatarUrl": avatarUrl,
|
||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
||||
})
|
||||
},
|
||||
onInputChange(e: any) {
|
||||
const nickName = e.detail.value
|
||||
const { avatarUrl } = this.data.userInfo
|
||||
this.setData({
|
||||
"userInfo.nickName": nickName,
|
||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
||||
})
|
||||
},
|
||||
getUserProfile() {
|
||||
// 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认,开发者妥善保管用户快速填写的头像昵称,避免重复弹窗
|
||||
wx.getUserProfile({
|
||||
desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
|
||||
success: (res) => {
|
||||
console.log(res)
|
||||
this.setData({
|
||||
userInfo: res.userInfo,
|
||||
hasUserInfo: true
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
28
frontend_miniprogram/miniprogram/pages/index/index.wxml
Normal file
@@ -0,0 +1,28 @@
|
||||
<!--index.wxml-->
|
||||
<navigation-bar title="Weixin" back="{{false}}" color="black" background="#FFF"></navigation-bar>
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container">
|
||||
<view class="userinfo">
|
||||
<block wx:if="{{canIUseNicknameComp && !hasUserInfo}}">
|
||||
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
|
||||
<image class="avatar" src="{{userInfo.avatarUrl}}"></image>
|
||||
</button>
|
||||
<view class="nickname-wrapper">
|
||||
<text class="nickname-label">昵称</text>
|
||||
<input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{!hasUserInfo}}">
|
||||
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile"> 获取头像昵称 </button>
|
||||
<view wx:else> 请使用2.10.4及以上版本基础库 </view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
|
||||
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="usermotto">
|
||||
<text class="user-motto">{{motto}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"enableShareAppMessage": true,
|
||||
"usingComponents": {
|
||||
"app-header": "/components/app-header/app-header",
|
||||
"action-dock": "/components/action-dock/action-dock",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
245
frontend_miniprogram/miniprogram/pages/lawyer-detail/index.less
Normal file
@@ -0,0 +1,245 @@
|
||||
.detail-page {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.detail-bg {
|
||||
height: 300rpx;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
border-radius: 0 0 40rpx 40rpx;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 160rpx var(--spacing-md) 0;
|
||||
background: #fff;
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-base);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-top: -60rpx;
|
||||
/* Overlap effect */
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
margin-left: var(--spacing-md);
|
||||
padding-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24rpx;
|
||||
color: var(--primary-color);
|
||||
background: rgba(142, 34, 48, 0.08);
|
||||
/* Primary opacity */
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.firm-name {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
.qr-icon-text {
|
||||
font-size: 32rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.qr-btn text {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: #fff;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
position: relative;
|
||||
padding-left: 16rpx;
|
||||
}
|
||||
|
||||
.section-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6rpx;
|
||||
bottom: 6rpx;
|
||||
width: 6rpx;
|
||||
background: var(--accent-color);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.contact-label {
|
||||
width: 80rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.contact-value {
|
||||
flex: 1;
|
||||
color: var(--text-main);
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 60rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background: var(--bg-page);
|
||||
margin: 0 0;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 24rpx;
|
||||
color: var(--primary-color);
|
||||
background: rgba(142, 34, 48, 0.05);
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 30rpx;
|
||||
}
|
||||
|
||||
.bio-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
text-align: justify;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.bio-scroll {
|
||||
height: 360rpx;
|
||||
box-sizing: border-box;
|
||||
padding-right: 8rpx;
|
||||
}
|
||||
|
||||
.firm-mini-card {
|
||||
background: #fff;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.firm-logo-mini-placeholder {
|
||||
height: 60rpx;
|
||||
width: 140rpx;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mini-logo-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
font-family: serif;
|
||||
}
|
||||
|
||||
.firm-mini-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.firm-mini-name {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.firm-mini-address {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.detail-bottom-space {
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
.not-found-action {
|
||||
padding: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
188
frontend_miniprogram/miniprogram/pages/lawyer-detail/index.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { getLawyerDetail, recordCardShare } from '../../api/open';
|
||||
import type { FirmInfo } from '../../types/card';
|
||||
import type { Lawyer } from '../../types/card';
|
||||
import { appendCardViewRecord } from '../../utils/history';
|
||||
|
||||
function createEmptyFirm(): FirmInfo {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
logo: '',
|
||||
intro: '',
|
||||
hotlinePhone: '',
|
||||
hqAddress: '',
|
||||
hqLatitude: 0,
|
||||
hqLongitude: 0,
|
||||
officeCount: 0,
|
||||
lawyerCount: 0,
|
||||
heroImage: '',
|
||||
officeList: [],
|
||||
practiceAreas: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDetailBgStyle(coverImage?: string): string {
|
||||
const cover = typeof coverImage === 'string' ? coverImage.trim() : '';
|
||||
if (!cover) {
|
||||
return '';
|
||||
}
|
||||
return `background-image: url("${cover}"); background-size: cover; background-position: center;`;
|
||||
}
|
||||
|
||||
function shouldUseBioScroll(bio?: string): boolean {
|
||||
const text = typeof bio === 'string' ? bio.trim() : '';
|
||||
return text.length > 180;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
firm: createEmptyFirm(),
|
||||
lawyer: null as Lawyer | null,
|
||||
notFound: false,
|
||||
specialtiesText: '',
|
||||
detailBgStyle: '',
|
||||
bioScrollable: false,
|
||||
loading: false,
|
||||
},
|
||||
|
||||
async onLoad(options: Record<string, string | undefined>) {
|
||||
const lawyerId = typeof options.id === 'string' ? options.id : '';
|
||||
const sourceType = typeof options.sourceType === 'string' ? options.sourceType : 'DIRECT';
|
||||
const shareFromCardId = typeof options.shareFromCardId === 'string' ? options.shareFromCardId : '';
|
||||
|
||||
if (!lawyerId) {
|
||||
this.setData({ notFound: true, specialtiesText: '', detailBgStyle: '', bioScrollable: false });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
const payload = await getLawyerDetail(lawyerId, sourceType, shareFromCardId);
|
||||
this.setData({
|
||||
firm: payload.firm,
|
||||
lawyer: payload.lawyer,
|
||||
notFound: false,
|
||||
specialtiesText: payload.lawyer.specialties.join(';'),
|
||||
detailBgStyle: buildDetailBgStyle(payload.lawyer.coverImage),
|
||||
bioScrollable: shouldUseBioScroll(payload.lawyer.bio),
|
||||
});
|
||||
appendCardViewRecord(payload.lawyer);
|
||||
wx.showShareMenu({ menus: ['shareAppMessage'] });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '律师信息不存在';
|
||||
this.setData({ notFound: true, specialtiesText: '', detailBgStyle: '', bioScrollable: false });
|
||||
wx.showToast({
|
||||
title: message,
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
handleDockAction(event: WechatMiniprogram.CustomEvent<{ action: string }>) {
|
||||
const action = event.detail.action;
|
||||
switch (action) {
|
||||
case 'save':
|
||||
this.saveContact();
|
||||
break;
|
||||
case 'location':
|
||||
this.navigateOffice();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
saveContact() {
|
||||
const lawyer = this.data.lawyer;
|
||||
if (!lawyer) {
|
||||
return;
|
||||
}
|
||||
|
||||
wx.addPhoneContact({
|
||||
firstName: lawyer.name,
|
||||
mobilePhoneNumber: lawyer.phone,
|
||||
email: lawyer.email,
|
||||
organization: this.data.firm.name,
|
||||
title: lawyer.title,
|
||||
workAddressStreet: lawyer.address,
|
||||
remark: lawyer.specialties.join('、'),
|
||||
success: () => {
|
||||
wx.showToast({ title: '已添加到通讯录', icon: 'success' });
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '添加失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
callPhone() {
|
||||
const phone = this.data.lawyer ? this.data.lawyer.phone : '';
|
||||
if (!phone) {
|
||||
wx.showToast({ title: '暂无联系电话', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
wx.makePhoneCall({
|
||||
phoneNumber: phone,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '拨号失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
navigateOffice() {
|
||||
if (!this.data.firm.hqLatitude || !this.data.firm.hqLongitude) {
|
||||
wx.showToast({ title: '暂未配置地图位置', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
wx.openLocation({
|
||||
latitude: this.data.firm.hqLatitude,
|
||||
longitude: this.data.firm.hqLongitude,
|
||||
name: this.data.firm.name,
|
||||
address: this.data.firm.hqAddress,
|
||||
scale: 18,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '打开地图失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
previewWechatQr() {
|
||||
const url = this.data.lawyer ? this.data.lawyer.wechatQrImage : '';
|
||||
if (!url) {
|
||||
wx.showToast({ title: '二维码未配置', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
wx.previewImage({
|
||||
current: url,
|
||||
urls: [url],
|
||||
});
|
||||
},
|
||||
|
||||
goLawyerList() {
|
||||
wx.navigateTo({ url: '/pages/lawyer-list/index' });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const lawyer = this.data.lawyer;
|
||||
if (!lawyer) {
|
||||
return {
|
||||
title: `${this.data.firm.name || '电子名片'}电子名片`,
|
||||
path: '/pages/firm/index',
|
||||
};
|
||||
}
|
||||
|
||||
const sharePath = `/pages/lawyer-detail/index?id=${lawyer.id}&sourceType=SHARE&shareFromCardId=${lawyer.id}`;
|
||||
recordCardShare(lawyer.id, sharePath).catch(() => {
|
||||
// 分享埋点失败不阻断分享动作
|
||||
});
|
||||
|
||||
return {
|
||||
title: `${lawyer.name}律师电子名片`,
|
||||
path: sharePath,
|
||||
imageUrl: lawyer.avatar,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<view class="container-page detail-page">
|
||||
<app-header title="名片详情" back="{{true}}" background="transparent"></app-header>
|
||||
|
||||
<block wx:if="{{notFound}}">
|
||||
<view class="page-content">
|
||||
<empty-state text="未找到律师信息"></empty-state>
|
||||
<view class="not-found-action">
|
||||
<button class="back-btn" bindtap="goLawyerList">返回律师列表</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<scroll-view class="page-content" scroll-y="true" type="list">
|
||||
<!-- Top Background -->
|
||||
<view class="detail-bg" style="{{detailBgStyle}}"></view>
|
||||
|
||||
<!-- Profile Card (Overlapping) -->
|
||||
<view class="profile-card">
|
||||
<image class="avatar" src="{{lawyer.avatar}}" mode="aspectFill"></image>
|
||||
<view class="profile-info">
|
||||
<view class="name-row">
|
||||
<text class="name">{{lawyer.name}}</text>
|
||||
<text class="title">{{lawyer.title}}</text>
|
||||
</view>
|
||||
<text class="firm-name">{{firm.name}} | {{lawyer.office}}</text>
|
||||
</view>
|
||||
<view class="qr-btn" bindtap="previewWechatQr">
|
||||
<!-- Replaced icon with text for simplicity if icon missing -->
|
||||
<text class="qr-icon-text">⚲</text>
|
||||
<text>二维码</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Content Sections -->
|
||||
<view class="content-body">
|
||||
|
||||
<!-- Contact Section -->
|
||||
<view class="section-card">
|
||||
<view class="section-header">联系方式</view>
|
||||
<view class="contact-item" bindtap="callPhone">
|
||||
<text class="contact-label">电话</text>
|
||||
<text class="contact-value">{{lawyer.phone}}</text>
|
||||
<view class="action-icon phone">📞</view>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="contact-item">
|
||||
<text class="contact-label">邮箱</text>
|
||||
<text class="contact-value">{{lawyer.email}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Practise Areas -->
|
||||
<view class="section-card">
|
||||
<view class="section-header">执业领域</view>
|
||||
<view class="tags-row">
|
||||
<text class="tag" wx:for="{{lawyer.specialties}}" wx:key="*this">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bio -->
|
||||
<view class="section-card">
|
||||
<view class="section-header">个人简介</view>
|
||||
<scroll-view wx:if="{{bioScrollable}}" class="bio-scroll" scroll-y="true" show-scrollbar="true">
|
||||
<view class="bio-text">{{lawyer.bio}}</view>
|
||||
</scroll-view>
|
||||
<view wx:else class="bio-text">{{lawyer.bio}}</view>
|
||||
</view>
|
||||
|
||||
|
||||
|
||||
<view class="detail-bottom-space"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Fixed Action Dock -->
|
||||
<action-dock bind:action="handleDockAction"></action-dock>
|
||||
</block>
|
||||
</view>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"app-header": "/components/app-header/app-header",
|
||||
"lawyer-card": "/components/lawyer-card/lawyer-card",
|
||||
"filter-bar": "/components/filter-bar/filter-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
116
frontend_miniprogram/miniprogram/pages/lawyer-list/index.less
Normal file
@@ -0,0 +1,116 @@
|
||||
.lawyer-list-page {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-page);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24rpx;
|
||||
gap: 12rpx;
|
||||
border: 1rpx solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-box:active {
|
||||
background: #fff;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-main);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: 0 var(--spacing-md) var(--spacing-md);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: var(--border-radius-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hotline-btn {
|
||||
background: rgba(142, 34, 48, 0.06);
|
||||
/* Burgundy tint */
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.map-btn {
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.card-item {
|
||||
margin-bottom: var(--spacing-md);
|
||||
background: #fff;
|
||||
/* Ensure card background is white */
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.list-bottom-space {
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
/* History Floating Action Button styling */
|
||||
.history-fab {
|
||||
position: fixed;
|
||||
right: var(--spacing-md);
|
||||
bottom: calc(var(--spacing-lg) + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 16rpx 32rpx;
|
||||
border-radius: 40rpx;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
147
frontend_miniprogram/miniprogram/pages/lawyer-list/index.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { getFirmProfile, listLawyers } from '../../api/open';
|
||||
import type { Lawyer } from '../../types/card';
|
||||
|
||||
const ALL_OFFICES = '所有办公机构';
|
||||
const ALL_AREAS = '全部专业领域';
|
||||
|
||||
let searchDebounceTimer: number | null = null;
|
||||
|
||||
Page({
|
||||
data: {
|
||||
firmName: '',
|
||||
firmAddress: '',
|
||||
firmLatitude: 0,
|
||||
firmLongitude: 0,
|
||||
keyword: '',
|
||||
selectedOffice: ALL_OFFICES,
|
||||
selectedArea: ALL_AREAS,
|
||||
officeOptions: [ALL_OFFICES],
|
||||
areaOptions: [ALL_AREAS],
|
||||
filteredLawyers: [] as Lawyer[],
|
||||
hotlinePhone: '',
|
||||
loading: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initializePage();
|
||||
},
|
||||
|
||||
async initializePage() {
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
const firm = await getFirmProfile();
|
||||
this.setData({
|
||||
firmName: firm.name,
|
||||
firmAddress: firm.hqAddress,
|
||||
firmLatitude: firm.hqLatitude,
|
||||
firmLongitude: firm.hqLongitude,
|
||||
hotlinePhone: firm.hotlinePhone,
|
||||
officeOptions: [ALL_OFFICES, ...firm.officeList],
|
||||
areaOptions: [ALL_AREAS, ...firm.practiceAreas],
|
||||
});
|
||||
await this.loadLawyers();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载列表失败';
|
||||
wx.showToast({ title: message, icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
onSearchInput(event: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
const keyword = event.detail.value || '';
|
||||
this.setData({ keyword });
|
||||
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
this.loadLawyers();
|
||||
}, 250) as unknown as number;
|
||||
},
|
||||
|
||||
handleOfficeChange(event: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ selectedOffice: event.detail.value });
|
||||
this.loadLawyers();
|
||||
},
|
||||
|
||||
handleAreaChange(event: WechatMiniprogram.CustomEvent<{ value: string }>) {
|
||||
this.setData({ selectedArea: event.detail.value });
|
||||
this.loadLawyers();
|
||||
},
|
||||
|
||||
async loadLawyers() {
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
const filteredLawyers = await listLawyers({
|
||||
keyword: this.data.keyword.trim() || undefined,
|
||||
office: this.data.selectedOffice === ALL_OFFICES ? undefined : this.data.selectedOffice,
|
||||
practiceArea: this.data.selectedArea === ALL_AREAS ? undefined : this.data.selectedArea,
|
||||
});
|
||||
this.setData({ filteredLawyers });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载律师列表失败';
|
||||
wx.showToast({ title: message, icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
handleLawyerSelect(event: WechatMiniprogram.CustomEvent<{ id: string }>) {
|
||||
const lawyerId = event.detail.id;
|
||||
if (!lawyerId) {
|
||||
return;
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: `/pages/lawyer-detail/index?id=${lawyerId}`,
|
||||
});
|
||||
},
|
||||
|
||||
callHotline() {
|
||||
if (!this.data.hotlinePhone) {
|
||||
wx.showToast({
|
||||
title: '暂无联系电话',
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
wx.makePhoneCall({
|
||||
phoneNumber: this.data.hotlinePhone,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '拨号失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openOffice() {
|
||||
if (!this.data.firmLatitude || !this.data.firmLongitude) {
|
||||
wx.showToast({ title: '暂未配置地图位置', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
wx.openLocation({
|
||||
latitude: this.data.firmLatitude,
|
||||
longitude: this.data.firmLongitude,
|
||||
name: this.data.firmName,
|
||||
address: this.data.firmAddress,
|
||||
scale: 18,
|
||||
fail: () => {
|
||||
wx.showToast({ title: '打开地图失败', icon: 'none' });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
goHistory() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/history/index',
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<view class="container-page lawyer-list-page">
|
||||
<app-header title="专业律师" back="{{true}}" background="#fff"></app-header>
|
||||
|
||||
<!-- Sticky Header Wrapper -->
|
||||
<view class="sticky-header">
|
||||
<view class="search-wrap">
|
||||
<view class="search-box">
|
||||
<icon type="search" size="16" color="#999"></icon>
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder-class="search-placeholder"
|
||||
placeholder="搜索律师姓名、专业领域..."
|
||||
value="{{keyword}}"
|
||||
bindinput="onSearchInput"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<filter-bar
|
||||
office-options="{{officeOptions}}"
|
||||
area-options="{{areaOptions}}"
|
||||
selected-office="{{selectedOffice}}"
|
||||
selected-area="{{selectedArea}}"
|
||||
bind:officechange="handleOfficeChange"
|
||||
bind:areachange="handleAreaChange"
|
||||
></filter-bar>
|
||||
</view>
|
||||
|
||||
<view class="quick-actions">
|
||||
<view class="action-btn hotline-btn" bindtap="callHotline">
|
||||
<image class="action-icon" src="/assets/icons/phone.svg" mode="aspectFit" wx:if="{{false}}"></image> <!-- Placeholder for icon -->
|
||||
<text>联系电话</text>
|
||||
</view>
|
||||
<view class="action-btn map-btn" bindtap="openOffice">
|
||||
<text>总部导航</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="page-content list-scroll" scroll-y="true" type="list">
|
||||
<view class="list-wrap">
|
||||
<block wx:if="{{filteredLawyers.length}}">
|
||||
<view class="card-item" wx:for="{{filteredLawyers}}" wx:key="id" wx:for-item="item">
|
||||
<lawyer-card lawyer="{{item}}" bind:select="handleLawyerSelect"></lawyer-card>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<empty-state text="未找到匹配的律师"></empty-state>
|
||||
</block>
|
||||
<view class="list-bottom-space"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="history-fab" bindtap="goHistory">
|
||||
<text class="history-text">浏览记录</text>
|
||||
</view>
|
||||
</view>
|
||||
5
frontend_miniprogram/miniprogram/pages/logs/logs.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
16
frontend_miniprogram/miniprogram/pages/logs/logs.less
Normal file
@@ -0,0 +1,16 @@
|
||||
page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scrollarea {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.log-item {
|
||||
margin-top: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.log-item:last-child {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
21
frontend_miniprogram/miniprogram/pages/logs/logs.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// logs.ts
|
||||
// const util = require('../../utils/util.js')
|
||||
import { formatTime } from '../../utils/util'
|
||||
|
||||
Component({
|
||||
data: {
|
||||
logs: [],
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.setData({
|
||||
logs: (wx.getStorageSync('logs') || []).map((log: string) => {
|
||||
return {
|
||||
date: formatTime(new Date(log)),
|
||||
timeStamp: log
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
7
frontend_miniprogram/miniprogram/pages/logs/logs.wxml
Normal file
@@ -0,0 +1,7 @@
|
||||
<!--logs.wxml-->
|
||||
<navigation-bar title="查看启动日志" back="{{true}}" color="black" background="#FFF"></navigation-bar>
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<block wx:for="{{logs}}" wx:key="timeStamp" wx:for-item="log">
|
||||
<view class="log-item">{{index + 1}}. {{log.date}}</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
7
frontend_miniprogram/miniprogram/sitemap.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}]
|
||||
}
|
||||
36
frontend_miniprogram/miniprogram/types/card.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface FirmInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
intro: string;
|
||||
hotlinePhone: string;
|
||||
hqAddress: string;
|
||||
hqLatitude: number;
|
||||
hqLongitude: number;
|
||||
officeCount: number;
|
||||
lawyerCount: number;
|
||||
heroImage: string;
|
||||
officeList: string[];
|
||||
practiceAreas: string[];
|
||||
}
|
||||
|
||||
export interface Lawyer {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
office: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
avatar: string;
|
||||
coverImage: string;
|
||||
specialties: string[];
|
||||
bio: string;
|
||||
wechatQrImage: string;
|
||||
}
|
||||
|
||||
export interface CardViewRecord {
|
||||
lawyerId: string;
|
||||
viewedAt: number;
|
||||
lawyer?: Lawyer;
|
||||
}
|
||||
65
frontend_miniprogram/miniprogram/utils/history.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { CARD_VIEW_HISTORY_KEY } from '../constants/storage';
|
||||
import type { CardViewRecord, Lawyer } from '../types/card';
|
||||
|
||||
function normalizeLawyerSnapshot(input: unknown): Lawyer | undefined {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lawyer = input as Partial<Lawyer>;
|
||||
if (typeof lawyer.id !== 'string' || !lawyer.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: lawyer.id,
|
||||
name: typeof lawyer.name === 'string' ? lawyer.name : '',
|
||||
title: typeof lawyer.title === 'string' ? lawyer.title : '',
|
||||
office: typeof lawyer.office === 'string' ? lawyer.office : '',
|
||||
phone: typeof lawyer.phone === 'string' ? lawyer.phone : '',
|
||||
email: typeof lawyer.email === 'string' ? lawyer.email : '',
|
||||
address: typeof lawyer.address === 'string' ? lawyer.address : '',
|
||||
avatar: typeof lawyer.avatar === 'string' ? lawyer.avatar : '',
|
||||
coverImage: typeof lawyer.coverImage === 'string' ? lawyer.coverImage : '',
|
||||
specialties: Array.isArray(lawyer.specialties)
|
||||
? lawyer.specialties.filter((item): item is string => typeof item === 'string')
|
||||
: [],
|
||||
bio: typeof lawyer.bio === 'string' ? lawyer.bio : '',
|
||||
wechatQrImage: typeof lawyer.wechatQrImage === 'string' ? lawyer.wechatQrImage : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getCardViewHistory(): CardViewRecord[] {
|
||||
const raw = wx.getStorageSync(CARD_VIEW_HISTORY_KEY);
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map((item) => {
|
||||
const record = item as { lawyerId?: unknown; viewedAt?: unknown; lawyer?: unknown };
|
||||
const lawyerId = typeof record.lawyerId === 'string' ? record.lawyerId : '';
|
||||
const viewedAt = typeof record.viewedAt === 'number' ? record.viewedAt : 0;
|
||||
const lawyer = normalizeLawyerSnapshot(record.lawyer);
|
||||
return { lawyerId, viewedAt, lawyer };
|
||||
})
|
||||
.filter((item) => Boolean(item.lawyerId) && item.viewedAt > 0)
|
||||
.sort((a, b) => b.viewedAt - a.viewedAt);
|
||||
}
|
||||
|
||||
export function appendCardViewRecord(lawyer: Lawyer): void {
|
||||
if (!lawyer.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getCardViewHistory().filter((item) => item.lawyerId !== lawyer.id);
|
||||
current.unshift({
|
||||
lawyerId: lawyer.id,
|
||||
viewedAt: Date.now(),
|
||||
lawyer,
|
||||
});
|
||||
wx.setStorageSync(CARD_VIEW_HISTORY_KEY, current);
|
||||
}
|
||||
|
||||
export function clearCardViewHistory(): void {
|
||||
wx.removeStorageSync(CARD_VIEW_HISTORY_KEY);
|
||||
}
|
||||
75
frontend_miniprogram/miniprogram/utils/http.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { API_BASE_URL_OVERRIDE_KEY, tenantRuntimeConfig } from '../config/runtime';
|
||||
import { getMiniappAppId, getMiniappEnvVersion } from './miniapp';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST';
|
||||
data?: WechatMiniprogram.IAnyObject;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const override = wx.getStorageSync(API_BASE_URL_OVERRIDE_KEY);
|
||||
if (typeof override === 'string' && override.trim()) {
|
||||
return normalizeBaseUrl(override);
|
||||
}
|
||||
|
||||
const envVersion = getMiniappEnvVersion();
|
||||
const baseUrl = tenantRuntimeConfig.apiBaseUrlByEnv[envVersion];
|
||||
if (!baseUrl || !baseUrl.trim()) {
|
||||
throw new Error(`未配置 ${envVersion} 环境的接口域名`);
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (envVersion !== 'develop' && !normalizedBaseUrl.startsWith('https://')) {
|
||||
throw new Error(`${envVersion} 环境接口域名必须使用 HTTPS`);
|
||||
}
|
||||
|
||||
return normalizedBaseUrl;
|
||||
}
|
||||
|
||||
export function request<T>(options: RequestOptions): Promise<T> {
|
||||
const appId = getMiniappAppId();
|
||||
if (!appId) {
|
||||
return Promise.reject(new Error('未获取到小程序 AppID'));
|
||||
}
|
||||
|
||||
let apiBaseUrl = '';
|
||||
try {
|
||||
apiBaseUrl = getApiBaseUrl();
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Error ? error : new Error('接口域名配置无效'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: `${apiBaseUrl}${options.url}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'X-Miniapp-Appid': appId,
|
||||
},
|
||||
success: (response) => {
|
||||
const payload = response.data as ApiResponse<T> | undefined;
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && payload && payload.code === '0') {
|
||||
resolve(payload.data);
|
||||
return;
|
||||
}
|
||||
const message = payload && payload.message ? payload.message : '请求失败';
|
||||
reject(new Error(message));
|
||||
},
|
||||
fail: () => {
|
||||
reject(new Error('网络异常,请稍后重试'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
29
frontend_miniprogram/miniprogram/utils/miniapp.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { MiniProgramEnvVersion } from '../config/runtime';
|
||||
|
||||
const APPID_OVERRIDE_KEY = 'miniapp_appid_override';
|
||||
|
||||
export function getMiniappAppId(): string {
|
||||
const override = wx.getStorageSync(APPID_OVERRIDE_KEY);
|
||||
if (typeof override === 'string' && override.trim()) {
|
||||
return override.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const accountInfo = wx.getAccountInfoSync();
|
||||
return accountInfo.miniProgram.appId || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMiniappEnvVersion(): MiniProgramEnvVersion {
|
||||
try {
|
||||
const envVersion = wx.getAccountInfoSync().miniProgram.envVersion;
|
||||
if (envVersion === 'trial' || envVersion === 'release') {
|
||||
return envVersion;
|
||||
}
|
||||
return 'develop';
|
||||
} catch {
|
||||
return 'develop';
|
||||
}
|
||||
}
|
||||
19
frontend_miniprogram/miniprogram/utils/util.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const formatTime = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const second = date.getSeconds()
|
||||
|
||||
return (
|
||||
[year, month, day].map(formatNumber).join('/') +
|
||||
' ' +
|
||||
[hour, minute, second].map(formatNumber).join(':')
|
||||
)
|
||||
}
|
||||
|
||||
const formatNumber = (n: number) => {
|
||||
const s = n.toString()
|
||||
return s[1] ? s : '0' + s
|
||||
}
|
||||