这个基于Vue.js+Node.js+ElementUI的法律法院案件阅卷申请系统,是为法院、律师事务所等法律机构设计的电子化案件管理系统。系统实现了从案件登记、卷宗上传到阅卷申请审批的全流程数字化管理,同时提供了微信小程序端适配,方便律师和当事人随时随地进行案件查询和阅卷申请。
我在开发这个系统时,主要解决了以下几个核心问题:
系统采用前后端分离架构,前端使用Vue.js+ElementUI构建管理后台,uni-app开发小程序端;后端基于Node.js(Express/Koa框架)提供RESTful API服务;数据库选用MySQL存储结构化数据,文件存储使用云存储服务(如阿里云OSS)。
在实际开发前,我花了大量时间与法院工作人员和律师沟通,梳理出系统的核心功能需求:
用户认证与权限管理
案件全生命周期管理
阅卷申请流程
数据统计与分析
选择Vue.js+Node.js技术栈主要基于以下考虑:
数据库设计遵循第三范式,核心表结构如下:
用户表(users)
sql复制CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '登录账号',
`password` varchar(255) NOT NULL COMMENT '加密后的密码',
`real_name` varchar(50) NOT NULL COMMENT '真实姓名',
`id_card` varchar(18) NOT NULL COMMENT '身份证号(AES加密)',
`phone` varchar(11) NOT NULL COMMENT '手机号',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像URL',
`status` tinyint(1) DEFAULT '1' COMMENT '状态(0-禁用,1-正常)',
`last_login` datetime DEFAULT NULL COMMENT '最后登录时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
案件表(cases)
sql复制CREATE TABLE `cases` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`case_no` varchar(50) NOT NULL COMMENT '案号',
`title` varchar(100) NOT NULL COMMENT '案件名称',
`type` varchar(20) NOT NULL COMMENT '案件类型',
`status` varchar(20) NOT NULL COMMENT '案件状态',
`judge_id` int(11) NOT NULL COMMENT '承办法官ID',
`court_id` int(11) NOT NULL COMMENT '所属法院ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_case_no` (`case_no`),
KEY `idx_judge_id` (`judge_id`),
KEY `idx_court_id` (`court_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
提示:敏感字段如身份证号应采用AES加密存储,密码字段使用bcrypt等算法加盐哈希,切勿明文存储。
使用Vue CLI脚手架初始化项目,我的目录结构设计如下:
code复制src/
├── api/ # 接口请求封装
├── assets/ # 静态资源
├── components/ # 公共组件
├── router/ # 路由配置
├── store/ # Vuex状态管理
├── styles/ # 全局样式
├── utils/ # 工具函数
├── views/ # 页面组件
│ ├── case/ # 案件相关页面
│ ├── file/ # 卷宗相关页面
│ ├── application/ # 阅卷申请页面
│ └── ...
└── main.js # 入口文件
ElementUI提供了丰富的组件,但需要根据司法系统特点进行定制:
scss复制// variables.scss
$--color-primary: #1a56a0; // 司法蓝
$--font-path: '~element-ui/lib/theme-chalk/fonts';
@import "~element-ui/packages/theme-chalk/src/index";
vue复制<el-table
:data="caseList"
v-loading="loading"
@sort-change="handleSortChange"
@filter-change="handleFilterChange">
<el-table-column
prop="caseNo"
label="案号"
sortable
width="180"/>
<el-table-column
prop="title"
label="案件名称"
show-overflow-tooltip/>
<el-table-column
prop="type"
label="案件类型"
:filters="typeFilters"
column-key="type"
filter-placement="bottom-end">
<template slot-scope="{row}">
<el-tag :type="getCaseTypeTag(row.type)">
{{ row.type }}
</el-tag>
</template>
</el-table-column>
</el-table>
javascript复制rules: {
caseNo: [
{ required: true, message: '请输入案号', trigger: 'blur' },
{ pattern: /^\(\d{4}\)\w+字第\d+号$/, message: '案号格式不正确' }
],
idCard: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{ validator: validateIdCard, trigger: 'blur' }
]
}
电子卷宗主要是PDF格式,使用pdf.js实现浏览器端预览:
vue复制<template>
<div class="pdf-viewer-container">
<div class="toolbar">
<el-button-group>
<el-button
size="mini"
@click="currentPage > 1 ? currentPage-- : 0"
:disabled="currentPage <= 1">
上一页
</el-button>
<el-button size="mini" disabled>
第 {{ currentPage }} 页 / 共 {{ pageCount }} 页
</el-button>
<el-button
size="mini"
@click="currentPage < pageCount ? currentPage++ : 0"
:disabled="currentPage >= pageCount">
下一页
</el-button>
</el-button-group>
</div>
<canvas
v-for="page in pages"
:key="page"
:id="'pdf-canvas-'+page"
class="pdf-canvas"/>
</div>
</template>
<script>
import * as pdfjsLib from 'pdfjs-dist'
export default {
props: ['src'],
data() {
return {
pdfDoc: null,
currentPage: 1,
pageCount: 0,
pages: []
}
},
watch: {
src() {
this.loadPDF()
},
currentPage(val) {
this.renderPage(val)
}
},
methods: {
async loadPDF() {
const loadingTask = pdfjsLib.getDocument(this.src)
this.pdfDoc = await loadingTask.promise
this.pageCount = this.pdfDoc.numPages
this.pages = Array.from({length: this.pageCount}, (v,i)=>i+1)
this.currentPage = 1
},
async renderPage(num) {
const page = await this.pdfDoc.getPage(num)
const viewport = page.getViewport({ scale: 1.5 })
const canvas = document.getElementById(`pdf-canvas-${num}`)
const context = canvas.getContext('2d')
canvas.height = viewport.height
canvas.width = viewport.width
await page.render({
canvasContext: context,
viewport: viewport
}).promise
}
}
}
</script>
注意:PDF.js默认使用CMAP包需要额外配置,建议在vue.config.js中配置webpack externals避免打包问题。
我的Express应用采用分层架构设计:
code复制app/
├── config/ # 配置文件
├── controllers/ # 控制器
├── middleware/ # 中间件
├── models/ # 数据模型
├── routes/ # 路由定义
├── services/ # 业务服务
├── utils/ # 工具类
└── app.js # 应用入口
使用jsonwebtoken实现基于角色的访问控制:
javascript复制// middleware/auth.js
const jwt = require('jsonwebtoken')
const { User, Role } = require('../models')
module.exports = (roles = []) => {
return async (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: '未提供认证令牌' })
}
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const user = await User.findByPk(decoded.userId, {
include: [Role]
})
if (!user) {
return res.status(401).json({ error: '用户不存在' })
}
if (roles.length > 0 && !roles.includes(user.role.name)) {
return res.status(403).json({ error: '权限不足' })
}
req.user = user
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: '令牌已过期' })
}
return res.status(401).json({ error: '无效令牌' })
}
}
}
对接阿里云OSS实现安全的文件上传:
javascript复制// services/fileService.js
const OSS = require('ali-oss')
const path = require('path')
const crypto = require('crypto')
const client = new OSS({
region: process.env.OSS_REGION,
accessKeyId: process.env.OSS_ACCESS_KEY,
accessKeySecret: process.env.OSS_ACCESS_SECRET,
bucket: process.env.OSS_BUCKET
})
class FileService {
static async upload(file, userId) {
const ext = path.extname(file.originalname).toLowerCase()
const hash = crypto.createHash('md5').update(file.buffer).digest('hex')
const filename = `files/${userId}/${hash}${ext}`
try {
const result = await client.put(filename, file.buffer, {
headers: {
'Content-Disposition': `attachment; filename="${encodeURIComponent(file.originalname)}"`
}
})
return {
url: result.url,
name: file.originalname,
size: file.size,
hash
}
} catch (err) {
throw new Error(`文件上传失败: ${err.message}`)
}
}
}
实现多级审批工作流:
javascript复制// controllers/applicationController.js
const { Application, Case, User } = require('../models')
exports.createApplication = async (req, res) => {
try {
const { caseId, purpose } = req.body
const case = await Case.findByPk(caseId)
if (!case) {
return res.status(404).json({ error: '案件不存在' })
}
const application = await Application.create({
caseId,
applicantId: req.user.id,
purpose,
status: 'pending',
currentStep: 1
})
// 触发审批流程
await this.startApprovalProcess(application)
res.status(201).json(application)
} catch (err) {
res.status(500).json({ error: err.message })
}
}
exports.startApprovalProcess = async (application) => {
const case = await application.getCase()
const judge = await case.getJudge()
// 第一级审批:承办法官
await application.createApproval({
approverId: judge.id,
step: 1,
status: 'pending'
})
// 复杂案件需要庭长审批
if (case.type === 'complex') {
const chiefJudge = await User.findOne({
where: { roleId: 3 } // 庭长角色
})
await application.createApproval({
approverId: chiefJudge.id,
step: 2,
status: 'waiting'
})
}
}
使用uni-app实现一套代码多端发布,关键配置:
javascript复制// manifest.json
{
"mp-weixin": {
"appid": "wx1234567890abcdef",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "用于快速定位管辖法院"
}
}
}
}
利用微信小程序扫码API快速关联案件:
javascript复制methods: {
scanCaseCode() {
uni.scanCode({
onlyFromCamera: true,
scanType: ['qrCode'],
success: (res) => {
try {
const caseInfo = JSON.parse(res.result)
this.queryCaseDetail(caseInfo.id)
} catch (e) {
uni.showToast({
title: '二维码无效',
icon: 'none'
})
}
},
fail: () => {
uni.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
},
async queryCaseDetail(caseId) {
try {
const res = await this.$http.get(`/cases/${caseId}`)
this.caseDetail = res.data
} catch (e) {
uni.showToast({
title: '获取案件详情失败',
icon: 'none'
})
}
}
}
小程序端文件处理方案:
javascript复制previewFile(file) {
if (file.type === 'pdf') {
uni.downloadFile({
url: file.url,
success: (res) => {
const filePath = res.tempFilePath
uni.openDocument({
filePath,
fileType: 'pdf',
success: () => console.log('打开文档成功')
})
}
})
} else {
uni.previewImage({
urls: [file.url]
})
}
},
downloadFile(file) {
uni.showLoading({ title: '下载中...' })
uni.downloadFile({
url: file.url,
success: (res) => {
uni.hideLoading()
if (res.statusCode === 200) {
uni.saveFile({
tempFilePath: res.tempFilePath,
success: (saveRes) => {
uni.showToast({ title: '保存成功' })
// 记录下载行为
this.logDownload(file.id)
}
})
}
}
})
}
javascript复制// utils/crypto.js
const crypto = require('crypto')
const algorithm = 'aes-256-cbc'
const key = Buffer.from(process.env.AES_KEY, 'hex')
const iv = Buffer.from(process.env.AES_IV, 'hex')
function encrypt(text) {
const cipher = crypto.createCipheriv(algorithm, key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return encrypted
}
function decrypt(encrypted) {
const decipher = crypto.createDecipheriv(algorithm, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
javascript复制// middleware/audit.js
const { OperationLog } = require('../models')
module.exports = async (req, res, next) => {
const start = Date.now()
res.on('finish', async () => {
try {
await OperationLog.create({
userId: req.user?.id || null,
method: req.method,
path: req.path,
statusCode: res.statusCode,
ip: req.ip,
userAgent: req.headers['user-agent'],
duration: Date.now() - start,
requestBody: req.method !== 'GET' ? JSON.stringify(req.body) : null
})
} catch (err) {
console.error('操作日志记录失败:', err)
}
})
next()
}
《人民法院电子诉讼档案管理暂行办法》合规点:
《网络安全法》合规措施:
javascript复制// 使用sequelize的优化查询
Case.findAll({
attributes: ['id', 'caseNo', 'title', 'type', 'status', 'createTime'],
include: [{
model: User,
as: 'Judge',
attributes: ['id', 'realName']
}],
where: {
status: 'processing'
},
order: [['createTime', 'DESC']],
limit: 10,
offset: (page - 1) * 10,
benchmark: true, // 记录查询耗时
logging: console.log // 输出SQL语句
})
javascript复制// middleware/cache.js
const redis = require('redis')
const client = redis.createClient(process.env.REDIS_URL)
module.exports = (ttl = 60) => {
return (req, res, next) => {
const key = `cache:${req.originalUrl}`
client.get(key, (err, data) => {
if (err) return next()
if (data) {
return res.json(JSON.parse(data))
}
const originalSend = res.json
res.json = (body) => {
client.setex(key, ttl, JSON.stringify(body))
originalSend.call(res, body)
}
next()
})
}
}
完整的docker-compose.yml配置:
yaml复制version: '3.8'
services:
app:
build: .
image: legal-system:1.0.0
container_name: legal-app
restart: unless-stopped
environment:
- NODE_ENV=production
- DB_HOST=mysql
- REDIS_HOST=redis
- OSS_REGION=oss-cn-hangzhou
ports:
- "3000:3000"
depends_on:
- mysql
- redis
networks:
- legal-net
mysql:
image: mysql:5.7
container_name: legal-mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MYSQL_DATABASE=legal_system
- MYSQL_USER=${DB_USER}
- MYSQL_PASSWORD=${DB_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3306:3306"
networks:
- legal-net
redis:
image: redis:6-alpine
container_name: legal-redis
restart: unless-stopped
volumes:
- redis-data:/data
ports:
- "6379:6379"
networks:
- legal-net
nginx:
image: nginx:1.21-alpine
container_name: legal-nginx
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
ports:
- "80:80"
- "443:443"
depends_on:
- app
networks:
- legal-net
volumes:
mysql-data:
redis-data:
networks:
legal-net:
driver: bridge
nginx.conf关键配置:
nginx复制http {
upstream nodejs {
server app:3000;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name legal.example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://nodejs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
}
location /files/ {
alias /var/www/files/;
expires 30d;
add_header Cache-Control "public";
}
}
}
使用PM2+Prometheus+Grafana搭建监控系统:
javascript复制module.exports = {
apps: [{
name: 'legal-system',
script: 'app.js',
instances: 'max',
exec_mode: 'cluster',
max_memory_restart: '1G',
env: {
NODE_ENV: 'production'
},
pmx: true,
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm Z',
error_file: '/var/log/pm2/legal-error.log',
out_file: '/var/log/pm2/legal-out.log'
}]
}
javascript复制const promClient = require('prom-client')
const collectDefaultMetrics = promClient.collectDefaultMetrics
collectDefaultMetrics({ timeout: 5000 })
const httpRequestDurationMicroseconds = new promClient.Histogram({
name: 'http_request_duration_ms',
help: 'Duration of HTTP requests in ms',
labelNames: ['method', 'route', 'code'],
buckets: [0.1, 5, 15, 50, 100, 300, 500, 1000, 3000, 5000]
})
app.use((req, res, next) => {
const end = httpRequestDurationMicroseconds.startTimer()
res.on('finish', () => {
end({
method: req.method,
route: req.route?.path || req.path,
code: res.statusCode
})
})
next()
})
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType)
res.end(await promClient.register.metrics())
})
javascript复制// 后端设置CORS头
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
next()
})
// 前端PDF.js worker配置
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.10.377/build/pdf.worker.min.js'
javascript复制const CaseList = () => import('./views/case/CaseList.vue')
const CaseDetail = () => import('./views/case/CaseDetail.vue')
bash复制# 生成堆内存快照
node --inspect app.js
# 然后在Chrome打开 chrome://inspect
sql复制-- 使用EXPLAIN分析查询
EXPLAIN SELECT * FROM cases WHERE status = 'open' AND create_time > '2023-01-01';
-- 添加复合索引
ALTER TABLE cases ADD INDEX idx_status_created (status, create_time);
code复制main - 生产环境代码
release/* - 预发布分支
develop - 集成测试分支
feature/* - 功能开发分支
hotfix/* - 紧急修复分支
json复制// .eslintrc.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:vue/recommended'
],
rules: {
'vue/multi-word-component-names': 'off',
'vue/html-indent': ['error', 2],
'vue/order-in-components': ['error', {
order: [
'el',
'name',
'parent',
'functional',
['components', 'directives', 'filters'],
'extends',
'mixins',
'inheritAttrs',
'model',
['props', 'propsData'],
'data',
'computed',
'watch',
'methods',
'LIFECYCLE_HOOKS',
['template', 'render'],
'renderError'
]
}]
}
}
code复制feat: 添加案件导出功能
fix: 修复PDF预览页面跳转问题
docs: 更新API文档
style: 调整案件列表页样式
refactor: 重构审批流程代码
perf: 优化案件查询性能
test: 添加用户认证测试用例
chore: 更新依赖包版本