勾股OA 源码解读 — ThinkPHP 8 + Layui 企业OA系统

项目地址:https://gitee.com/gouguopen/office
本地路径:E:\code\hexo\office
技术栈:ThinkPHP 8.1 + Layui 2.x + MySQL 8.0 + PHP 8.2+


目录

  1. 项目总览
  2. 目录结构
  3. 技术栈与依赖
  4. ThinkPHP 8 多应用架构
  5. BaseController — 认证与权限拦截
  6. 登录流程精读
  7. RBAC 权限引擎 — Systematic.php
  8. 审批工作流 — Check.php
  9. 财务模块 — 标准 CRUD 范式
  10. 数据权限设计
  11. Layui 前端架构
  12. 工具函数库 — common.php
  13. 推荐学习路线

1. 项目总览

勾股OA(GouGuOA)是一套基于 ThinkPHP 8 + Layui 构建的企业级办公自动化系统,涵盖行政、财务、合同、客户、项目等完整业务闭环。项目采用多应用架构,共 18 个子应用80+ 控制器70+ 模型,代码量大但结构清晰,是学习 ThinkPHP 8 实战开发的优秀范例。

核心业务模块

应用 路径 功能说明
home app/home/ 首页、登录、权限规则、消息、审批中心、请假/外出/加班/出差
user app/user/ 员工管理、部门、岗位、劳动合同、关怀、奖惩
finance app/finance/ 报销、借支、开票、收款、付款、票据
contract app/contract/ 合同管理、采购、供应商、产品
customer app/customer/ 客户管理、联系人、商机、跟进记录
project app/project/ 项目管理、任务、文档、里程碑
adm app/adm/ 行政管理:印章、车辆、会议、公文、资产、公告
oa app/oa/ 日常办公:日程、计划、工作汇报、会议纪要
api app/api/ API接口:审批流程、评论、导入导出
base app/base/ 基础控制器和视图模板(不直接对外)
qiye app/qiye/ 企业微信端
mobile app/mobile/ 移动端菜单配置
disk app/disk/ 网盘文件管理
crud app/crud/ 代码生成器
install app/install/ 安装向导
listener app/listener/ 事件监听器
exception app/exception/ 异常处理

2. 目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
office/
├── app/ # 应用目录(ThinkPHP 8 多应用)
│ ├── adm/ # 行政管理
│ │ ├── controller/ # 15个控制器
│ │ ├── model/ # 10个模型
│ │ └── view/ # 视图模板
│ ├── api/ # API接口
│ │ ├── controller/ # Check.php(审批)、Comment、Export、Import...
│ │ ├── BaseController.php # API基类(JWT认证)
│ │ └── model/
│ ├── base/ # 基础模块
│ │ ├── BaseController.php # ★ 核心基类
│ │ └── view/common/base.html # ★ 基础模板
│ ├── contract/ # 合同管理
│ ├── crud/ # 代码生成器
│ ├── customer/ # 客户管理
│ ├── disk/ # 网盘
│ ├── finance/ # 财务管理
│ │ ├── controller/ # Expense、Loan、Invoice...
│ │ ├── model/
│ │ ├── validate/
│ │ └── view/
│ ├── home/ # 主应用
│ │ ├── controller/ # Login、Index、Rule、Role...
│ │ ├── model/
│ │ ├── validate/
│ │ └── view/
│ ├── install/ # 安装向导
│ ├── listener/ # 事件监听器
│ ├── mobile/ # 移动端
│ ├── oa/ # 日常办公
│ ├── project/ # 项目管理
│ ├── qiye/ # 企业微信端
│ ├── user/ # 用户管理
│ ├── AppService.php # 应用服务
│ ├── BaseController.php # 默认基类(空壳)
│ ├── ExceptionHandle.php # 异常处理
│ ├── Request.php # 请求对象
│ ├── common.php # ★ 1679行工具函数库
│ └── event.php # 事件定义
├── config/ # 配置目录
│ ├── app.php # ★ 应用配置
│ ├── database.php # 数据库配置
│ ├── cache.php # 缓存配置
│ ├── session.php # Session配置
│ ├── view.php # 视图配置
│ └── ...
├── extend/ # 扩展类库
│ └── systematic/
│ └── Systematic.php # ★ RBAC权限引擎
├── public/ # Web根目录
│ ├── index.php # 入口文件
│ ├── tpl/ # 错误页面模板
│ └── static/ # 静态资源
│ ├── gougu/ # 勾股前端资源
│ │ ├── gouguInit.js # ★ 前端入口
│ │ ├── gougu/css/
│ │ └── ...
│ └── layui/ # Layui框架
├── route/ # 路由
│ └── app.php # 路由定义(极简,主要靠多应用自动路由)
├── runtime/ # 运行时目录
├── vendor/ # Composer依赖
├── composer.json # 依赖配置
└── think # 命令行入口

3. 技术栈与依赖

composer.json 核心依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"require": {
"php": ">=8.2",
"ext-curl": "*",
"ext-fileinfo": "*",
"ext-openssl": "*",
"ext-pdo": "*",
"ext-zip": "*",
"topthink/framework": "^8.1.2", // ThinkPHP 8 核心框架
"topthink/think-orm": "^4.0.5", // ORM
"topthink/think-filesystem": "^3.0", // 文件系统
"topthink/think-multi-app": "^1.0", // ★ 多应用支持
"topthink/think-view": "^2.0", // 模板引擎
"topthink/think-captcha": "^3.0", // 验证码
"topthink/think-helper": "^3.1", // 助手函数
"topthink/think-image": "^1.0.8", // 图片处理
"overtrue/pinyin": "^5.2", // 拼音转换
"phpmailer/phpmailer": "^6.8", // 邮件发送
"firebase/php-jwt": "^7.0", // JWT认证
"phpoffice/phpspreadsheet": "^1.2", // Excel导入导出
"phpoffice/phpword": "^1.2", // Word生成
"mpdf/mpdf": "^8.1", // PDF生成
"alibabacloud/dysmsapi-20170525": "^4.3" // 阿里云短信
}
}

PSR-4 自动加载

1
2
3
4
5
6
{
"autoload": {
"psr-4": { "app\\": "app" },
"psr-0": { "": "extend/" }
}
}

关键点extend/ 目录使用 PSR-0 自动加载,所以 extend/systematic/Systematic.php 中的类名空间 systematic\Systematic 可以直接全局使用。


4. ThinkPHP 8 多应用架构

4.1 自动多应用路由

勾股OA 使用 think-multi-app 实现多应用,URL 第一段自动映射到应用目录:

1
2
3
4
5
URL: /finance/expense/datalist
↓ ↓ ↓
应用名 控制器 方法
↓ ↓ ↓
app/finance Expense datalist()

对应代码文件:

1
2
3
4
app/finance/controller/Expense.php
→ namespace app\finance\controller;
→ class Expense extends BaseController
→ public function datalist()

4.2 配置文件

config/app.php 核心配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
return [
'app_host' => env('app.host', ''),
'app_namespace' => '',
'with_route' => true, // 启用路由
'default_app' => 'home', // 默认应用
'default_timezone' => 'Asia/Shanghai',

'default_filter' => 'htmlspecialchars', // 全局XSS过滤

'page_size' => 20, // 默认分页
'file_size' => 50, // 附件大小50M
'session_admin' => 'gougu_admin', // Session键名

'http_exception_template' => [
401 => public_path() . 'tpl/401.html',
403 => public_path() . 'tpl/403.html',
404 => public_path() . 'tpl/404.html',
405 => public_path() . 'tpl/405.html', // 无权限
500 => public_path() . 'tpl/500.html',
]
];

4.3 数据库配置

config/database.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
return [
'default' => 'mysql',
'auto_timestamp' => true, // 自动写入时间戳
'datetime_format' => 'Y-m-d H:i:s',
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'oa',
'username' => 'root',
'password' => '123456',
'hostport' => '3306',
'prefix' => 'oa_', // 表前缀 oa_
'charset' => 'utf8mb4',
'fields_strict' => true, // 严格检查字段
],
],
];

4.4 路由文件

route/app.php 极其简洁,几乎不定义路由,完全依赖多应用自动路由:

1
2
3
4
5
6
7
use think\facade\Route;

Route::get('think', function () {
return 'hello,ThinkPHP6!';
});

Route::get('hello/:name', 'index/hello');

4.5 入口文件

public/index.php(标准 ThinkPHP 8 入口):

1
2
3
4
5
6
7
8
9
<?php
// 调整应用目录结构(base模块不对外访问)
namespace think;
require __DIR__ . '/../vendor/autoload.php';

$http = (new App())->http;
$response = $http->run();
$response->send();
$http->end($response);

5. BaseController — 认证与权限拦截

文件:app/base/BaseController.php

这是整个系统最重要的文件,所有 Web 端控制器都继承它。它在构造函数中完成完整的认证和权限检查链。

5.1 完整源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
namespace app\base;

use think\facade\Cache;
use think\facade\Db;
use think\facade\Request;
use think\facade\Session;
use think\facade\View;
use systematic\Systematic;

abstract class BaseController
{
protected $batchValidate = false;
protected $pageSize = 20;
protected $middleware = [];
protected $module;
protected $controller;
protected $action;
protected $uid;
protected $did; // 部门ID
protected $pid; // 岗位ID
protected $model;

public function __construct()
{
// 解析当前请求的 模块/控制器/方法
$this->module = strtolower(app('http')->getName());
$this->controller = strtolower(Request::controller());
$this->action = strtolower(Request::action());
$this->uid = 0;
$this->did = 0;
$this->pid = 0;
$this->initialize();
}

protected function initialize()
{
$this->checkLogin();
$this->pageSize = Request::param('limit',
\think\facade\Config::get('app.page_size'));
}

// ... checkLogin 和 checkAuth 见下文
}

5.2 认证流程(checkLogin)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
protected function checkLogin()
{
// 1. 登录页和验证码页跳过检查
if ($this->controller !== 'login' && $this->controller !== 'captcha') {
$session_admin = get_config('app.session_admin');

// 2. 未登录 → 重定向到登录页
if (!Session::has($session_admin)) {
if (request()->isAjax()) {
return to_assign(404, '请先登录');
} else {
redirect('/home/login/index.html')->send();
exit;
}
} else {
// 3. 已登录 → 加载用户信息
$this->uid = Session::get($session_admin);
$login_admin = get_admin($this->uid);
$this->did = $login_admin['did'];
$this->pid = $login_admin['pid'];
$is_lock = $login_admin['is_lock'];

// 4. 10小时超时检查
$last_login_time = Db::name('Admin')
->where(['id' => $this->uid])
->value('last_login_time');
$timeDiff = time() - $last_login_time;
if ($timeDiff > 36000) { // 36000秒 = 10小时
Session::delete($session_admin);
redirect('/home/login/index.html')->send();
exit;
}

// 5. 更新最后活动时间(滑动会话)
Db::name('Admin')
->where(['id' => $this->uid])
->update(['last_login_time' => time()]);

// 6. 锁屏检查
if ($is_lock == 1) {
redirect('/home/login/lock.html')->send();
exit;
}

// 7. 将用户信息注入视图
View::assign('login_admin', $login_admin);

// 8. 部分控制器跳过权限检查
$not_check = ['index','leaves','outs','overtimes','trips','message'];
if ($this->module == 'home' && in_array($this->controller, $not_check)) {
return true;
} else {
// 9. 强制修改初始密码
$regPwd = $login_admin['reg_pwd'];
if ($regPwd !== '') {
redirect('/home/index/edit_password.html')->send();
exit;
}

// 10. RBAC 权限检查
if (!$this->checkAuth()) {
if (request()->isAjax()) {
return to_assign(405, '你没有权限');
} else {
redirect('/home/index/role')->send();
exit;
}
}
}
}
}
}

5.3 权限检查(checkAuth)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function checkAuth()
{
$uid = $this->uid;
$GOUGU = new Systematic();
$GOUGU->auth($uid); // 构建/读取权限缓存

$auth_list = Cache::get('RulesSrc' . $uid); // 当前用户权限
$pathUrl = $this->module . '/' . $this->controller . '/' . $this->action;

if (!in_array($pathUrl, $auth_list)) {
return false; // 无权限
}
return true; // 有权限
}

5.4 认证流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
请求进入

├─ controller == login/captcha? ──→ 跳过检查

├─ Session 存在? ──No──→ 重定向登录页

├─ 加载用户信息 (uid, did, pid)

├─ 超过10小时? ──Yes──→ 清除Session,重定向登录页

├─ 更新 last_login_time(滑动会话)

├─ is_lock == 1? ──Yes──→ 重定向锁屏页

├─ 注入 login_admin 到视图

├─ 控制器在白名单? ──Yes──→ 通过

├─ reg_pwd 非空? ──Yes──→ 重定向修改密码页

└─ RBAC checkAuth() ──Fail──→ 405无权限
└─Pass──→ 进入控制器方法

5.5 API 基类

app/api/BaseController.php 是 API 端的基类,与 Web 端不同:

  • 无 RBAC 权限检查(API 权限由业务自行控制)
  • 无锁屏、超时检查
  • 支持 JWT 配置(默认 2 小时过期)
  • 统一 JSON 响应apiSuccess / apiError / apiReturn
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
abstract class BaseController
{
protected $jwt_conf = [
'secrect' => 'gouguoa',
'iss' => 'www.gougucms.com',
'aud' => 'gouguoa',
'exptime' => 7200, // 2小时过期
];

protected function apiReturn($data, int $code = 0, $msg = '',
string $type = '', array $header = []): Response
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => time(),
'data' => $data,
];
$response = Response::create($result, $type ?: 'json')->header($header);
throw new HttpResponseException($response);
}
}

6. 登录流程精读

文件:app/home/controller/Login.php

6.1 登录页展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Login
{
public function index()
{
// 检测企业微信环境
$wxwork = is_wxwork();
$mobile = is_mobile();
if ($wxwork) {
return redirect('/qiye/login/login'); // 企业微信端
}
if ($mobile) {
return redirect('/qiye/login/index'); // 移动端
}
return View(); // PC端登录页
}
}

注意Login不继承 BaseController,因为登录页不需要认证。这是唯一不继承 BaseController 的控制器。

6.2 登录提交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public function login_submit()
{
$param = get_params();

// 1. 表单验证
try {
validate(UserCheck::class)->check($param);
} catch (ValidateException $e) {
return to_assign(1, $e->getError());
}

// 2. 支持用户名或手机号登录
$admin = Db::name('Admin')
->where(['username' => $param['username'], 'delete_time' => 0])
->find();
if (empty($admin)) {
// 尝试用手机号查找
$admin = Db::name('Admin')
->where(['mobile' => $param['username'], 'delete_time' => 0])
->find();
if (empty($admin)) {
return to_assign(1, '用户名或手机号码错误');
}
}

// 3. 密码验证(salt + md5)
$param['pwd'] = set_password($param['password'], $admin['salt']);
if ($admin['pwd'] !== $param['pwd']) {
return to_assign(1, '用户或密码错误');
}

// 4. 状态检查
if ($admin['status'] != 1) {
return to_assign(1, '该用户禁止登录');
}

// 5. 更新登录信息
$data = [
'is_lock' => 0,
'last_login_time' => time(),
'last_login_ip' => request()->ip(),
'login_num' => $admin['login_num'] + 1,
];
Db::name('admin')->where(['id' => $admin['id']])->update($data);

// 6. 写入 Session
$session_admin = get_config('app.session_admin');
Session::set($session_admin, $admin['id']);

// 7. 生成 Token(用于 API 调用)
$token = make_token();
set_cache($token, $admin, 7200);

// 8. 记录登录日志
$logdata = [
'uid' => $admin['id'],
'type' => 'login',
'action' => '登录',
'subject' => '系统',
'param_id' => $admin['id'],
'param' => '[]',
'ip' => request()->ip(),
'create_time' => time()
];
Db::name('AdminLog')->strict(false)->field(true)->insert($logdata);

return to_assign(0, '登录成功', ['uid' => $admin['id']]);
}

6.3 锁屏与解锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function lock()
{
$session_admin = get_config('app.session_admin');
$admin_id = Session::get($session_admin);
$admin = Db::name('admin')->where(['id' => $admin_id])->find();

if (request()->isAjax()) {
// 解锁:验证密码
$param = get_params();
$pwd = set_password($param['lock_password'], $admin['salt']);
if ($admin['pwd'] !== $pwd) {
return to_assign(1, '密码错误');
}
Db::name('admin')->where('id', $admin['id'])
->update(['is_lock' => 0]);
return to_assign(0, '解锁成功', ['uid' => $admin['id']]);
}

// 锁屏:设置 is_lock = 1
Db::name('admin')->where('id', $admin['id'])
->update(['is_lock' => 1]);
return View();
}

6.4 登录流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
用户输入用户名/密码


UserCheck 验证器校验


查询 Admin 表(用户名 OR 手机号)

├─ 不存在 → "用户名或手机号码错误"


set_password(password, salt) 加密比对

├─ 不匹配 → "用户或密码错误"


status != 1 → "禁止登录"


更新 last_login_time, login_num, ip


Session::set('gougu_admin', $admin_id)


生成 Token,缓存 7200 秒


记录 AdminLog 登录日志


返回 {code:0, msg:'登录成功', data:{uid}}

7. RBAC 权限引擎 — Systematic.php

文件:extend/systematic/Systematic.php

7.1 权限模型设计

勾股OA 的 RBAC 采用 岗位 → 角色组 → 权限规则 三级模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户(Admin)

├── position_id → 岗位(Position)
│ │
│ └── PositionGroup → 角色组(AdminGroup)
│ │
│ └── rules (逗号分隔的规则ID)
│ │
│ └── AdminRule
│ ├── src: "finance/expense/datalist"
│ ├── title: "报销列表"
│ └── ...

├── did → 部门(Department)
└── pid → 岗位ID

7.2 核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
namespace systematic;

use think\facade\Config;
use think\facade\Cache;
use think\facade\Db;

class Systematic
{
public function auth($uid)
{
// 缓存未命中时重建
if (!Cache::get('RulesSrc' . $uid) || !Cache::get('RulesSrc0')) {

// 1. 获取用户岗位
$position_id = Db::name('Admin')
->where('id', $uid)
->value('position_id');

// 2. 通过岗位 → 岗位角色组关联表 → 获取角色组
$groups = Db::name('PositionGroup')
->alias('a')
->join("AdminGroup g", "a.group_id=g.id", 'LEFT')
->where([
['a.pid', '=', $position_id],
['g.status', '=', 1]
])
->select()->toArray();

// 3. 合并所有角色组的权限规则ID
$ids = [];
foreach ($groups as $g) {
$ids = array_merge($ids, explode(',', trim($g['rules'], ',')));
}
$ids = array_unique($ids);

// 4. 读取所有权限规则(用于超级管理员判断)
$rules_all = Db::name('AdminRule')
->field('src')->select()->toArray();

// 5. 读取当前用户的权限规则
$rules = Db::name('AdminRule')
->where('id', 'in', $ids)
->field('src')->select()->toArray();

// 6. 提取规则路径(小写化)
$auth_list_all = [];
$auth_list = [];
foreach ($rules_all as $rule_all) {
$auth_list_all[] = strtolower($rule_all['src']);
}
foreach ($rules as $rule) {
$auth_list[] = strtolower($rule['src']);
}

// 7. 缓存 10 小时(36000秒)
Cache::tag('adminRules')->set('RulesSrc0', $auth_list_all, 36000);
Cache::tag('adminRules')->set('RulesSrc' . $uid, $auth_list, 36000);
}
}
}

7.3 权限匹配机制

BaseController::checkAuth() 中:

1
2
3
4
5
6
7
$pathUrl = $this->module . '/' . $this->controller . '/' . $this->action;
// 例如: "finance/expense/datalist"

if (!in_array($pathUrl, $auth_list)) {
return false; // 无权限
}
return true; // 有权限

权限规则表 oa_admin_rule 中的 src 字段存储路径,格式为 模块/控制器/方法,与 URL 路径一一对应。

7.4 关键设计要点

要点 说明
权限缓存 使用 Cache::tag('adminRules') 缓存,有效期 10 小时,与登录超时一致
超级管理员 拥有所有权限的用户,其 RulesSrc{uid} 等于 RulesSrc0(全部规则)
权限粒度 精确到 方法级别module/controller/action
权限更新 修改角色组权限后需清缓存,系统通过 Cache::tag('adminRules')->clear() 实现

8. 审批工作流 — Check.php

文件:app/api/controller/Check.php(654行,系统最复杂的文件)

这是勾股OA 最核心的业务逻辑,一套自研审批工作流引擎,支持自由审批、固定流程、会签、或签、回退、反确认等模式。

8.1 数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FlowCate (流程分类)
├── name: "expense_check" ← 业务标识
├── title: "报销审批"
├── check_table: "Expense" ← 关联的业务表名
├── template_id: 1 ← 消息模板ID
├── is_back: 1 ← 是否允许回退
├── is_reversed: 1 ← 是否允许反确认
└── is_copy: 1 ← 是否允许抄送

Flow (审批流程)
├── cate_id → FlowCate.id
├── flow_list: serialize(...) ← 序列化的审批步骤配置
└── department_ids: "1,2,3" ← 适用部门(空=全部)

FlowStep (审批步骤实例)
├── action_id: 123 ← 业务记录ID
├── flow_id: 1 ← 流程ID
├── sort: 0,1,2... ← 步骤顺序
├── check_role: 1-5 ← 审批角色类型
├── check_types: 1会签 / 2或签
├── check_uids: "1,2,3" ← 当前步骤审批人
└── check_position_id: 5 ← 岗位ID(check_role=3时)

FlowRecord (审批记录)
├── action_id: 123
├── check_table: "Expense"
├── step_id → FlowStep.id
├── check_uid: 1 ← 审批人
├── check_status: 0提交/1通过/2拒绝/3撤回/4反确认
├── check_files: "1,2" ← 附件
└── content: "同意" ← 审批意见

8.2 审批角色类型(check_role)

含义 审批人确定方式
0 自由审批 提交时手动指定
1 当前部门负责人 get_department_leader($uid)
2 上级部门负责人 get_department_leader($uid, 1)
3 指定岗位 查询 Admin 表中该岗位的在职人员
4 指定人员 预先配置
5 指定人员(可回退) 预先配置,拒绝时退回上一步

8.3 提交审批(submit_check)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
public function submit_check()
{
$param = get_params();
$flow_cate = Db::name('FlowCate')
->where(['name' => $param['check_name']])->find();
$flow_list = Db::name('Flow')
->where('id', $param['flow_id'])->value('flow_list');
$flow = unserialize($flow_list);

$check_table = $flow_cate['check_table'];

// 1. 删除原有的审批步骤和记录(重新提交场景)
Db::name('FlowStep')
->where(['action_id' => $param['action_id'], 'flow_id' => $param['flow_id']])
->update(['delete_time' => time()]);
Db::name('FlowRecord')
->where(['action_id' => $param['action_id'], 'check_table' => $check_table])
->update(['delete_time' => time()]);

// 2. 创建提交记录
$recordData = [
'action_id' => $param['action_id'],
'check_table' => $check_table,
'step_id' => 0,
'check_uid' => $this->uid,
'check_status' => 0, // 提交
'content' => '提交申请',
'create_time' => time()
];

if (!isset($param['check_uids'])) {
// ===== 固定流程模式 =====
$step = [];
$sort = 0;
foreach ($flow as $value) {
// 根据 check_role 解析审批人
if ($value['check_role'] == 1) {
$value['check_uids'] = get_department_leader($this->uid);
$value['flow_name'] = '当前部门负责人';
}
if ($value['check_role'] == 2) {
$value['check_uids'] = get_department_leader($this->uid, 1);
$value['flow_name'] = '上级部门负责人';
}
if ($value['check_role'] == 3) {
$check_uids = Db::name('Admin')
->where(['position_id' => $value['check_position_id'], 'status' => 1])
->column('id');
$value['check_uids'] = implode(',', $check_uids);
$value['flow_name'] = $check_position;
}
// ... check_role 4, 5 略

if (!empty($value['check_uids'])) {
$step[] = [
'action_id' => $param['action_id'],
'flow_id' => $param['flow_id'],
'check_uids' => $value['check_uids'],
'check_role' => $value['check_role'],
'check_types' => $value['check_types'],
'sort' => $sort
];
$sort++;
}
}

// 3. 批量插入审批步骤
Db::name('FlowStep')->insertAll($step);

// 4. 更新业务表状态
Db::name($check_table)->where('id', $param['action_id'])->update([
'check_flow_id' => $param['flow_id'],
'check_status' => 1, // 审批中
'check_step_sort' => 0, // 当前步骤
'check_uids' => $step[0]['check_uids'], // 当前审批人
'check_copy_uids' => $param['check_copy_uids'] ?? ''
]);

// 5. 发送消息通知(事件机制)
if ($flow_cate['template_id'] > 0) {
event('SendMessage', [
'from_uid' => $this->uid,
'to_uids' => $step[0]['check_uids'],
'template_id' => $flow_cate['template_id'],
'content' => ['action_id' => $param['action_id'], 'title' => $subject]
]);
}
} else {
// ===== 自由审批模式 =====
// 直接使用提交者指定的审批人
Db::name('FlowStep')->insert([
'action_id' => $param['action_id'],
'flow_id' => $param['flow_id'],
'check_uids' => $param['check_uids'],
'flow_name' => '自由审批',
]);
// ... 更新业务表 + 发送通知
}
}

8.4 审批通过(flow_check — check=1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
public function flow_check()
{
$param = get_params();
$detail = Db::name($check_table)->where('id', $action_id)->find();
$step = Db::name('FlowStep')
->where(['action_id' => $action_id, 'sort' => $detail['check_step_sort']])
->find();

if ($param['check'] == 1) {
// === 审批通过 ===

// 1. 验证当前用户是否有审批权限
$check_uids = explode(",", $detail['check_uids']);
if (!in_array($this->uid, $check_uids)) {
return to_assign(1, '您没权限审核该审批');
}

if ($step['check_role'] == 0) {
// 自由人审批
if ($param['check_node'] == 2) {
// 继续下一步(手动指定)
$next_step = $detail['check_step_sort'] + 1;
// ... 插入新步骤
} else {
// 审核结束
$param['check_status'] = 2; // 审核完成
}
} else {
// 固定流程审批
$check_count = Db::name('FlowRecord')
->where(['step_id' => $step['id']])->count();
$flow_count = explode(',', $step['check_uids']);

// 从当前审批人中移除自己
$new_uids = array_diff($check_uids, [$this->uid]);
$param['check_uids'] = implode(',', $new_uids);

// 判断是否本步骤所有人审批完成
// check_types=1: 会签(所有人通过才算通过)
// check_types=2: 或签(任一人通过即通过)
if ((($check_count + 1) >= count($flow_count) && $step['check_types'] == 1)
|| $step['check_types'] == 2) {

// 查找下一步
$next_step = Db::name('FlowStep')
->where(['sort' => $detail['check_step_sort'] + 1])
->find();

if ($next_step) {
// 存在下一步 → 解析下一步审批人
if ($next_step['check_role'] == 1) {
$param['check_uids'] = get_department_leader($detail['admin_id']);
} elseif ($next_step['check_role'] == 2) {
$param['check_uids'] = get_department_leader($detail['admin_id'], 1);
} elseif ($next_step['check_role'] == 3) {
$uids = Db::name('Admin')
->where(['position_id' => $next_step['check_position_id']])
->column('id');
$param['check_uids'] = implode(',', $uids);
} else {
$param['check_uids'] = $next_step['check_uids'];
}
$param['check_step_sort'] = $detail['check_step_sort'] + 1;
$param['check_status'] = 1; // 仍审批中
} else {
// 不存在下一步 → 审核结束
$param['check_status'] = 2; // 审核完成
$param['check_uids'] = '';
}
} else {
// 本步骤还有人未审批 → 不发送消息
$param['send_msg'] = 0;
}
}

// 2. 添加历史审核人
$param['check_history_uids'] = empty($detail['check_history_uids'])
? $this->uid
: $detail['check_history_uids'] . ',' . $this->uid;

// 3. 更新业务表
Db::name($check_table)->where('id', $action_id)->update($param);

// 4. 记录审批日志
Db::name('FlowRecord')->insert([
'action_id' => $action_id,
'check_uid' => $this->uid,
'check_status' => 1, // 通过
'content' => $param['content'],
'check_files' => $param['check_files'],
'check_time' => time(),
]);

// 5. 发送消息通知(审批中→下一审批人 / 审核完成→提交人+抄送人)
if ($param['check_status'] == 1 && $param['send_msg'] == 1) {
event('SendMessage', [...]); // 通知下一审批人
}
if ($param['check_status'] == 2) {
event('SendMessage', [...]); // 通知提交人:审核通过
if (!empty($detail['check_copy_uids'])) {
event('SendMessage', [...]); // 通知抄送人
}
}
}

// check=2: 审批拒绝(支持 check_role=5 回退到上一步)
// check=3: 审批撤回(仅提交人可操作)
// check=4: 审批反确认(打回重新提交)
}

8.5 审批状态流转

1
2
3
4
5
6
7
8
9
10
11
12
13
                  ┌──────────────────────────────────────────────┐
│ │
▼ │
0(待提交) ──提交──→ 1(审批中) ──通过──→ 2(审核完成) │
│ │ │
│ ├──拒绝──→ 3(审核拒绝) │
│ │ │ │
│ │ └─check_role=5──→ │
│ │ 回退上一步 │
│ │ │
│ └──撤回──→ 4(撤回) ──反确认──→ 0(待提交)

└──反确认──────────────────────────────────────→ 0(待提交)

8.6 工作流与业务表的关系

审批工作流通过 check_name + action_id 与业务表完全解耦

1
2
3
4
5
6
7
8
9
10
11
业务表 (如 Expense)
├── id (action_id)
├── check_status ← 审批状态 (0/1/2/3/4)
├── check_flow_id ← 流程ID
├── check_step_sort ← 当前步骤序号
├── check_uids ← 当前审批人 (逗号分隔)
├── check_history_uids ← 历史审批人
└── check_copy_uids ← 抄送人

FlowCate
└── check_table: "Expense" ← 指向业务表名

任何业务表只需添加上述审批字段,配置对应的 FlowCate + Flow,即可接入审批工作流,无需修改业务代码。


9. 财务模块 — 标准 CRUD 范式

控制器:app/finance/controller/Expense.php
模型:app/finance/model/Expense.php

财务模块的报销管理是理解整个项目 CRUD 范式的最佳范例。

9.1 控制器结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
namespace app\finance\controller;

use app\base\BaseController;
use app\finance\model\Expense as ExpenseModel;
use app\finance\validate\ExpenseValidate;
use think\exception\ValidateException;
use think\facade\Db;
use think\facade\View;

class Expense extends BaseController
{
protected $model;

public function __construct()
{
parent::__construct(); // 触发认证和权限检查
$this->model = new ExpenseModel();
}

// 数据列表
public function datalist() { ... }

// 添加/编辑
public function add() { ... }

// 查看详情
public function view($id) { ... }

// 删除
public function del() { ... }

// 报销记录
public function record() { ... }
}

9.2 datalist — 多Tab数据过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public function datalist()
{
$param = get_params();
if (request()->isAjax()) {
$tab = isset($param['tab']) ? $param['tab'] : 0;
$uid = $this->uid;
$where = [];
$whereOr = [];
$where[] = ['delete_time', '=', 0]; // 逻辑删除过滤

if ($tab == 0) {
// 全部(根据数据权限过滤)
$auth = isAuthExpense($uid);
if ($auth == 0) {
// 非管理员只能看自己相关的
$whereOr[] = ['admin_id', '=', $this->uid];
$whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
$whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
$whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];
$dids = array_merge(
get_leader_departments($uid),
get_role_departments($uid)
);
if (!empty($dids)) {
$whereOr[] = ['did', 'in', $dids];
}
}
}
if ($tab == 1) {
// 我创建的
$where[] = ['admin_id', '=', $this->uid];
}
if ($tab == 2) {
// 待我审核的
$where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
}
if ($tab == 3) {
// 我已审核的
$where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
}
if ($tab == 4) {
// 抄送给我的
$where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];
}
if ($tab == 5) {
// 已打款的
$where[] = ['pay_status', '=', 1];
}

// 时间范围搜索
if (!empty($param['diff_time'])) {
$diff_time = explode('~', $param['diff_time']);
$where[] = ['income_month', 'between', [
strtotime(urldecode($diff_time[0])),
strtotime(urldecode($diff_time[1] . ' 23:59:59'))
]];
}

$list = $this->model->datalist($param, $where, $whereOr);
return table_assign(0, '', $list);
} else {
return view(); // 非Ajax请求返回HTML页面
}
}

9.3 add — 添加/编辑合一 + 借支冲抵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public function add()
{
$param = get_params();
if (request()->isAjax()) {
// 时间戳转换
$param['income_month'] = strtotime(urldecode($param['income_month']));
$param['expense_time'] = strtotime(urldecode($param['expense_time']));

// 计算报销总金额(多条报销明细)
$cost = 0;
foreach ($param['amount'] as $value) {
if ($value == 0) {
return to_assign(1, '金额不能为零');
}
$cost += $value;
}

$param['admin_id'] = $this->uid;
$param['did'] = $this->did;
$param['cost'] = $cost;
$param['pay_amount'] = $cost;

if (!empty($param['id']) && $param['id'] > 0) {
// ===== 编辑 =====
validate(ExpenseValidate::class)->scene('edit')->check($param);

// 借支冲抵计算
if (!empty($param['loan_id'])) {
$loan = Db::name('Loan')->where('id', $param['loan_id'])->find();
// 查询该借支已冲抵的总额
$balance_cost = Db::name('Expense')
->where([
['delete_time', '=', 0],
['loan_id', '=', $param['loan_id']],
['id', '<>', $param['id']]
])->sum('balance_cost');

// ★ *100/100 避免浮点精度问题
$un_balance_cost = ($loan['cost'] * 100 - $balance_cost * 100) / 100;

if ($un_balance_cost < 0) {
return to_assign(1, '所选借支已经冲抵完毕');
}

$balance = $un_balance_cost * 100 - $cost * 100;
if ($balance >= 0) {
// 全额冲抵
$param['balance_cost'] = $cost;
$param['pay_amount'] = 0;
} else {
// 部分冲抵
$param['balance_cost'] = $un_balance_cost;
$param['pay_amount'] = ($cost * 100 - $un_balance_cost * 100) / 100;
}
}

$this->model->edit($param);
} else {
// ===== 新增 =====
validate(ExpenseValidate::class)->scene('add')->check($param);
// ... 同样的借支冲抵逻辑
$this->model->add($param);
}
} else {
// 渲染表单页面
$id = isset($param['id']) ? $param['id'] : 0;
View::assign('expense_cate', Db::name('ExpenseCate')
->where(['status' => 1])->select()->toArray());

if ($id > 0) {
$detail = $this->model->getById($id);
View::assign('detail', $detail);
return view('edit');
}
return view();
}
}

9.4 Model — 五件套范式

所有 Model 都遵循统一的 datalist / add / edit / getById / delById 五件套范式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
namespace app\finance\model;

use think\model;
use think\facade\Db;

class Expense extends Model
{
// 1. 分页列表
public function datalist($param = [], $where = [], $whereOr = [])
{
$rows = empty($param['limit'])
? get_config('app.page_size')
: $param['limit'];
$order = empty($param['order']) ? 'id desc' : $param['order'];

try {
$list = self::where($where)
->where(function ($query) use ($whereOr) {
if (!empty($whereOr)) {
$query->whereOr($whereOr);
}
})
->order($order)
->paginate(['list_rows' => $rows])
->each(function ($item, $key) {
// 数据加工:关联查询、格式化
$item->check_status_str = check_status_name($item->check_status);
$item->admin_name = Db::name('Admin')
->where(['id' => $item->admin_id])->value('name');
$item->department = Db::name('Department')
->where(['id' => $item->did])->value('title');
$item->create_time = to_date($item->create_time);
// ...
});
return $list;
} catch (\Exception $e) {
return ['code' => 1, 'data' => [], 'msg' => $e->getMessage()];
}
}

// 2. 新增
public function add($param)
{
try {
$param['create_time'] = time();
$insertId = self::strict(false)->field(true)->insertGetId($param);

// 处理子表(报销明细)
if (isset($param['amount'])) {
foreach ($param['amount'] as $key => $value) {
Db::name('ExpenseInterfix')->insert([
'exid' => $insertId,
'amount' => $value,
'cate_id' => $param['cate_id'][$key],
'remarks' => $param['remarks'][$key],
'create_time' => time(),
]);
}
}

// 更新借支冲抵
if (!empty($param['loan_id'])) {
$this->update_loan($param['loan_id']);
}

add_log('add', $insertId, $param); // 操作日志
} catch (\Exception $e) {
return to_assign(1, '操作失败:' . $e->getMessage());
}
return to_assign(0, '操作成功', ['return_id' => $insertId]);
}

// 3. 编辑
public function edit($param)
{
try {
$param['update_time'] = time();
self::where('id', $param['id'])->strict(false)->field(true)->update($param);
// ... 处理子表更新
add_log('edit', $param['id'], $param);
} catch (\Exception $e) {
return to_assign(1, '操作失败:' . $e->getMessage());
}
return to_assign(0, '操作成功', ['return_id' => $param['id']]);
}

// 4. 获取详情
public function getById($id)
{
$info = self::find($id);
// 关联查询补充字段
$info['admin_name'] = Db::name('Admin')
->where(['id' => $info['admin_id']])->value('name');
$info['department'] = Db::name('Department')
->where(['id' => $info['did']])->value('title');
// 查询子表数据
$info['list'] = Db::name('ExpenseInterfix')
->alias('a')
->join('ExpenseCate c', 'a.cate_id = c.id', 'LEFT')
->where(['a.exid' => $info['id']])
->select();
return $info;
}

// 5. 删除(逻辑删除)
public function delById($id, $type = 0)
{
if ($type == 0) {
// 逻辑删除
self::where('id', $id)->update(['delete_time' => time()]);
add_log('delete', $id);
} else {
// 物理删除
self::destroy($id);
}
return to_assign();
}

// 借支冲抵更新
public function update_loan($id)
{
$balance_cost = Db::name('Expense')
->where(['delete_time' => 0, 'loan_id' => $id])
->sum('balance_cost');
$loan = Db::name('Loan')->where('id', $id)->find();

if ($balance_cost * 100 == $loan['cost'] * 100) {
// 全额冲抵
Db::name('Loan')->where('id', $id)->update([
'balance_cost' => $balance_cost,
'balance_status' => 2,
'back_status' => 2
]);
}
// ... 部分冲抵、未冲抵
}
}

9.5 关键设计模式

模式 实现 说明
Ajax 双模式 isAjax() ? JSON : View() 同一个方法处理数据请求和页面渲染
逻辑删除 delete_time 字段 删除时写入时间戳,查询时 where delete_time=0
子表处理 ExpenseInterfix 主表+子表一对多,foreach 循环插入
操作日志 add_log() 统一记录操作类型和参数
表单验证 ExpenseValidate 使用 ThinkPHP 验证器场景验证
浮点精度 *100/100 金额计算先乘100转整数再除回,避免浮点误差

10. 数据权限设计

勾股OA 的数据权限通过 auth_did 字段 + FIND_IN_SET 实现。

10.1 数据权限级别

auth_did 值 含义 数据范围
0 个人 仅自己创建的数据
1 本部门 自己部门的数据
2 本部门及下级 本部门+子部门数据
10 全部 所有数据(管理员)

10.2 实现方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 在 datalist 中根据权限构建查询条件
$auth = isAuthExpense($uid); // 返回数据权限级别
if ($auth == 0) {
// 个人权限:只能看自己相关
$whereOr[] = ['admin_id', '=', $this->uid];
$whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
$whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
$whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];

// 如果是部门负责人,可以看部门数据
$dids = array_merge(
get_leader_departments($uid), // 作为负责人的部门
get_role_departments($uid) // 作为角色管理的部门
);
if (!empty($dids)) {
$whereOr[] = ['did', 'in', $dids];
}
}
// auth == 10 时不加额外条件,可看全部

10.3 FIND_IN_SET 的使用

FIND_IN_SET 是 MySQL 内置函数,用于在逗号分隔的字符串中查找值:

1
2
-- 查找 check_uids 字段中包含 uid=5 的记录
SELECT * FROM oa_expense WHERE FIND_IN_SET('5', check_uids);

在 ThinkPHP 8 中使用 Db::raw() 表达式:

1
$where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];

11. Layui 前端架构

11.1 基础模板继承

文件:app/base/view/common/base.html

所有页面通过 ThinkPHP 模板继承复用基础结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<title>{:get_system_config('web','admin_title')}</title>
<!-- CSS区块 -->
{block name="css"}
<link rel="stylesheet" href="{__GOUGU__}/gougu/css/gougu.css?v={:get_system_config('system','version')}">
{/block}
{block name="style"}{/block}

<!-- 全局JS变量 -->
<script>
const login_admin = {$login_admin.id};
</script>
</head>
<body class="main-body">
<!-- 主体区块 -->
{block name="body"}{/block}

<!-- 底部 -->
{block name="footer"}{/block}

<!-- 脚本区块 -->
{block name="script"}{/block}

<!-- 全局脚本 -->
<script src="{__GOUGU__}/layui/layui.js"></script>
<script src="{__GOUGU__}/gougu/gouguInit.js?v={:get_system_config('system','version')}"></script>
</body>
</html>

11.2 子页面继承示例

app/finance/view/expense/datalist.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
{extend name="../../base/view/common/base" /}

{block name="body"}
<div class="p-page">
<!-- Tab 切换栏 -->
<div class="layui-tab layui-tab-brief" lay-filter="tab">
<ul class="layui-tab-title">
<li class="layui-this">全部</li>
<li>我申请的</li>
<li>待我审批</li>
<li>我已审批</li>
<li>抄送我的</li>
<li>已打款</li>
</ul>
</div>

<!-- 搜索表单 -->
<form class="layui-form" id="barsearchform">
<input type="text" id="diff_time" placeholder="选择时间区间">
<select name="check_status">...</select>
<button lay-submit lay-filter="table-search">搜索</button>
</form>

<!-- 数据表格 -->
<table id="table_expense" lay-filter="table_expense"></table>
</div>

<!-- 工具栏模板 -->
<script type="text/html" id="toolbarDemo">
<button class="layui-btn tool-add" data-href="/finance/expense/add">
+ 添加报销申请
</button>
</script>
{/block}

{block name="script"}
<script>
// 模块声明
const moduleInit = ['tool', 'tablePlus', 'laydatePlus'];

// ★ gouguInit 是全局入口函数
function gouguInit() {
var table = layui.tablePlus,
tool = layui.tool,
laydatePlus = layui.laydatePlus;

// Tab 切换
element.on('tab(tab)', function(data) {
$('[name="tab"]').val(data.index);
layui.pageTable.reload({
where: { tab: data.index },
page: { curr: 1 }
});
});

// 表格渲染
layui.pageTable = table.render({
elem: "#table_expense",
toolbar: "#toolbarDemo",
url: "/finance/expense/datalist",
page: true,
limit: 20,
height: 'full-154',
cols: [[
{ field: 'id', title: 'ID', width: 80 },
{ field: 'cost', title: '报销金额', width: 110 },
{ field: 'check_status', title: '审批状态',
templet: function(d) {
return '<span class="check-status-color-' + d.check_status + '">『'
+ d.check_status_str + '』</span>';
}
},
{ field: 'right', title: '操作', fixed: 'right',
templet: function(d) {
var html = '<div class="layui-btn-group">';
html += '<span lay-event="view">详情</span>';
if ((d.check_status == 0 || d.check_status == 4)
&& d.admin_id == login_admin) {
html += '<span lay-event="edit">编辑</span>';
html += '<span lay-event="del">删除</span>';
}
html += '</div>';
return html;
}
}
]]
});

// 行事件
table.on('tool(table_expense)', function(obj) {
var data = obj.data;
if (obj.event === 'view') {
tool.side("/finance/expense/view?id=" + data.id);
}
if (obj.event === 'edit') {
tool.side("/finance/expense/add?id=" + data.id);
}
if (obj.event === 'del') {
layer.confirm('确定删除?', function(index) {
tool.delete("/finance/expense/del", { id: data.id }, function(e) {
if (e.code == 0) obj.del();
});
layer.close(index);
});
}
});
}
</script>
{/block}

11.3 前端核心组件

组件 说明
gouguInit() 页面入口函数,类似 DOMContentLoaded,所有页面逻辑从这里开始
moduleInit 声明当前页面需要的 Layui 扩展模块
layui.tablePlus 增强版表格组件(基于 Layui table 封装)
layui.tool 工具集:tool.side() 侧边栏打开、tool.delete() 删除请求
layui.laydatePlus 增强版日期选择器
layui.pageTable 全局表格实例引用,用于 Tab 切换时 reload()
login_admin 全局 JS 变量,当前登录用户 ID

11.4 前端交互模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
页面加载


gouguInit.js 自动执行

├─ 加载 moduleInit 声明的模块

├─ 调用 gouguInit() 函数

├─ table.render() 渲染表格
│ │
│ └─ Ajax 请求 url → 后端 datalist()
│ │
│ └─ 返回 JSON {code:0, data:[...], count:N}

├─ Tab 切换 → table.reload({where:{tab:N}})

├─ 搜索按钮 → table.reload({where: 表单数据})

└─ 行操作
├─ view → tool.side() 侧边栏打开详情
├─ edit → tool.side() 侧边栏打开编辑
└─ del → layer.confirm() → tool.delete() → obj.del()

12. 工具函数库 — common.php

文件:app/common.php(1679 行)

这是项目的工具函数大全,包含缓存操作、数据格式化、权限判断、消息发送等核心函数。

12.1 核心函数分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ============ 缓存相关 ============
set_cache($key, $value, $date = 86400) // 设置缓存
get_cache($key) // 读取缓存
clear_cache($key) // 清空缓存

// ============ 配置相关 ============
get_config($key) // 读取文件配置
get_system_config($name, $key) // 读取数据库配置(带缓存)
set_system_config($name, $key, $value) // 设置数据库配置

// ============ 用户相关 ============
get_admin($uid) // 获取用户信息(带缓存)
set_password($pwd, $salt) // 密码加密 (md5(pwd + salt))
make_token() // 生成Token

// ============ 数据处理 ============
get_params() // 获取请求参数(统一入口)
to_assign($code, $msg, $data) // 统一JSON响应
table_assign($code, $msg, $list) // 表格JSON响应
to_date($time, $format) // 时间戳格式化

// ============ 权限相关 ============
get_department_leader($uid, $level=0) // 获取部门负责人
get_leader_departments($uid) // 获取用户负责的部门
get_role_departments($uid) // 获取用户角色管理的部门
isAuthExpense($uid) // 获取报销数据权限级别

// ============ 审批相关 ============
check_status_name($status) // 审批状态名称
get_check_status() // 审批状态列表

// ============ 日志相关 ============
add_log($type, $id, $param, $subject) // 添加操作日志

// ============ 工具 ============
is_mobile() // 判断移动端
is_wxwork() // 判断企业微信
get_codeno($type) // 生成单据编号

12.2 关键函数详解

get_params() — 统一参数获取:

1
2
3
4
5
function get_params()
{
// 合并 GET + POST 参数,应用 htmlspecialchars 过滤
return Request::param();
}

to_assign() — 统一 JSON 响应:

1
2
3
4
5
6
7
8
9
function to_assign($code = 0, $msg = '操作成功', $data = [])
{
$result = [
'code' => $code, // 0=成功, 1=失败, 404=未登录, 405=无权限
'msg' => $msg,
'data' => $data
];
throw new HttpResponseException(json($result));
}

set_password() — 密码加密:

1
2
3
4
5
function set_password($pwd, $salt)
{
return md5(md5($pwd) . $salt);
}
// 双重 md5 + 盐值,虽然不是最优方案,但在 OA 系统中够用

13. 推荐学习路线

13.1 按文件阅读顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
第1步:理解配置
config/app.php → 应用配置
config/database.php → 数据库配置
composer.json → 依赖关系

第2步:理解认证体系
app/base/BaseController.php → ★ 核心基类(认证+权限拦截)
app/home/controller/Login.php → 登录流程
extend/systematic/Systematic.php → RBAC权限引擎

第3步:理解CRUD范式
app/finance/controller/Expense.php → 标准控制器
app/finance/model/Expense.php → 标准模型
app/finance/validate/ExpenseValidate.php → 验证器

第4步:理解审批工作流(最复杂)
app/api/controller/Check.php → ★ 审批引擎
app/api/controller/Comment.php → 评论系统

第5步:理解前端架构
app/base/view/common/base.html → 基础模板
app/finance/view/expense/datalist.html → 列表页模板
public/static/gougu/gouguInit.js → 前端入口

第6步:深入业务模块
app/contract/ → 合同管理
app/customer/ → 客户管理
app/project/ → 项目管理
app/user/ → 人事管理

第7步:工具函数
app/common.php → 1679行函数库(随时查阅)

13.2 ThinkPHP 8 关键知识点对照

ThinkPHP 8 特性 项目中的使用
多应用模式 think-multi-app,18个子应用
控制器基类 app\base\BaseController 抽象类
模型 继承 think\Model,五件套范式
Db门面 Db::name('Admin') 查询构造器
Session门面 Session::set/get/delete
Cache门面 Cache::tag()->set/get 带标签缓存
View门面 View::assign() + 模板继承
验证器 validate(ExpenseValidate::class)->scene('add')->check()
事件系统 event('SendMessage', $msg)
中间件 app/middleware.php 定义
路由 多应用自动路由,route/app.php 极简

13.3 Layui 关键知识点对照

Layui 特性 项目中的使用
表格组件 tablePlus(增强版),table.render()
表单组件 layui-formlay-submitlay-filter
日期选择 laydatePlus(增强版)
弹窗 layer.confirm()layer.msg()
Tab切换 layui.element.on('tab(tab)')
工具集 layui.tool.side()layui.tool.delete()
模板 <script type="text/html"> + templet 函数

附录:数据库核心表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
oa_admin              用户表(uid, username, pwd, salt, did, pid, position_id...)
oa_department 部门表(树形结构,pid 父部门)
oa_position 岗位表
oa_admin_group 角色组(rules: 逗号分隔的规则ID)
oa_position_group 岗位-角色组关联表
oa_admin_rule 权限规则表(src: module/controller/action)
oa_admin_log 操作日志

oa_flow_cate 审批流程分类(name, check_table, template_id...)
oa_flow 审批流程定义(flow_list: 序列化步骤配置)
oa_flow_step 审批步骤实例(action_id, sort, check_uids...)
oa_flow_record 审批记录(check_uid, check_status, content...)

oa_expense 报销主表
oa_expense_interfix 报销明细子表
oa_expense_cate 报销分类
oa_loan 借支表

oa_config 系统配置(content: 序列化配置数组)
oa_file 文件表
oa_message 消息表
oa_message_template 消息模板

文档总结:勾股OA 是一个结构清晰、范式统一的企业OA系统。其核心价值在于:

  1. BaseController 统一处理认证、权限、锁屏、超时
  2. 审批工作流引擎通过 check_name + action_id 与业务完全解耦
  3. Model 五件套范式让所有模块代码风格一致,易于理解和维护
  4. Layui + gouguInit 前端架构简洁高效,适合后台管理系统

建议按照第13章的学习路线逐文件阅读源码,配合实际运行调试,可以快速掌握 ThinkPHP 8 + Layui 的企业级开发方法。