Explorar el Código

feat: 前台首页实现 - 全屏滚动、Banner轮播、捐赠数据、公益项目、新闻动态、合作伙伴

master
leiyun hace 1 mes
commit
e1c935e06f
Se han modificado 95 ficheros con 17590 adiciones y 0 borrados
  1. +3
    -0
      .eslintrc
  2. +37
    -0
      .gitignore
  3. +155
    -0
      .kiro/steering/project.md
  4. +246
    -0
      .kiro/steering/thinkjs.md
  5. +22
    -0
      README.md
  6. +12
    -0
      development.js
  7. +30
    -0
      nginx.conf
  8. +53
    -0
      package.json
  9. +15
    -0
      pm2.json
  10. +7539
    -0
      pnpm-lock.yaml
  11. +11
    -0
      production.js
  12. +85
    -0
      sql/init.sql
  13. +34
    -0
      sql/update_20260211_article.sql
  14. +31
    -0
      sql/update_20260211_banner.sql
  15. +74
    -0
      sql/update_20260211_column.sql
  16. +45
    -0
      sql/update_20260211_config.sql
  17. +49
    -0
      sql/update_20260211_donation.sql
  18. +25
    -0
      sql/update_20260211_image.sql
  19. +26
    -0
      sql/update_20260211_job.sql
  20. +44
    -0
      sql/update_20260211_medicine.sql
  21. +13
    -0
      sql/update_20260211_page.sql
  22. +28
    -0
      sql/update_20260211_person.sql
  23. +14
    -0
      sql/update_20260211_role.sql
  24. +28
    -0
      sql/update_20260211_text.sql
  25. +1
    -0
      src/bootstrap/master.js
  26. +1
    -0
      src/bootstrap/worker.js
  27. +110
    -0
      src/config/adapter.js
  28. +46
    -0
      src/config/adapter.production.js
  29. +6
    -0
      src/config/config.js
  30. +4
    -0
      src/config/config.production.js
  31. +39
    -0
      src/config/cos.js
  32. +11
    -0
      src/config/extend.js
  33. +40
    -0
      src/config/middleware.js
  34. +121
    -0
      src/config/router.js
  35. +52
    -0
      src/controller/admin/auth.js
  36. +129
    -0
      src/controller/admin/content/article.js
  37. +117
    -0
      src/controller/admin/content/banner.js
  38. +215
    -0
      src/controller/admin/content/donation.js
  39. +94
    -0
      src/controller/admin/content/image.js
  40. +100
    -0
      src/controller/admin/content/job.js
  41. +67
    -0
      src/controller/admin/content/page.js
  42. +117
    -0
      src/controller/admin/content/person.js
  43. +113
    -0
      src/controller/admin/content/text.js
  44. +37
    -0
      src/controller/admin/dashboard.js
  45. +94
    -0
      src/controller/admin/data/medicine.js
  46. +136
    -0
      src/controller/admin/system/column.js
  47. +46
    -0
      src/controller/admin/system/config.js
  48. +305
    -0
      src/controller/admin/system/role.js
  49. +221
    -0
      src/controller/admin/system/user.js
  50. +306
    -0
      src/controller/admin/upload.js
  51. +137
    -0
      src/controller/base.js
  52. +117
    -0
      src/controller/index.js
  53. +5
    -0
      src/logic/index.js
  54. +5
    -0
      src/model/article.js
  55. +5
    -0
      src/model/banner.js
  56. +5
    -0
      src/model/column.js
  57. +56
    -0
      src/model/config.js
  58. +5
    -0
      src/model/donation.js
  59. +5
    -0
      src/model/donation_stat.js
  60. +5
    -0
      src/model/image.js
  61. +3
    -0
      src/model/index.js
  62. +5
    -0
      src/model/job.js
  63. +5
    -0
      src/model/medicine.js
  64. +5
    -0
      src/model/page.js
  65. +5
    -0
      src/model/person.js
  66. +5
    -0
      src/model/text.js
  67. +7
    -0
      test/index.js
  68. +219
    -0
      view/admin/auth_login.html
  69. +41
    -0
      view/admin/common/_header.html
  70. +117
    -0
      view/admin/common/_sidebar.html
  71. +436
    -0
      view/admin/content/article_index.html
  72. +369
    -0
      view/admin/content/banner_index.html
  73. +384
    -0
      view/admin/content/donation_index.html
  74. +260
    -0
      view/admin/content/image_index.html
  75. +211
    -0
      view/admin/content/job_index.html
  76. +198
    -0
      view/admin/content/page_index.html
  77. +304
    -0
      view/admin/content/person_index.html
  78. +309
    -0
      view/admin/content/text_index.html
  79. +207
    -0
      view/admin/dashboard_index.html
  80. +306
    -0
      view/admin/data/medicine_index.html
  81. +68
    -0
      view/admin/layout.html
  82. +428
    -0
      view/admin/system/column_index.html
  83. +281
    -0
      view/admin/system/config_index.html
  84. +290
    -0
      view/admin/system/role_index.html
  85. +329
    -0
      view/admin/system/user_index.html
  86. +47
    -0
      view/common/_footer.html
  87. +67
    -0
      view/common/_header.html
  88. +384
    -0
      view/index_index.html
  89. +56
    -0
      view/layout.html
  90. +0
    -0
     
  91. +198
    -0
      www/static/css/admin.css
  92. +554
    -0
      www/static/css/web.css
  93. +0
    -0
     
  94. BIN
     
  95. +0
    -0
     

+ 3
- 0
.eslintrc Ver fichero

@@ -0,0 +1,3 @@
{
"extends": "think"
}

+ 37
- 0
.gitignore Ver fichero

@@ -0,0 +1,37 @@
# Logs
logs
*.log

# Runtime data
pids
*.pid
*.seed

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage/

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules/

# IDE config
.idea

# output
output/
output.tar.gz

runtime/
app/

config.development.js
adapter.development.js

+ 155
- 0
.kiro/steering/project.md Ver fichero

@@ -0,0 +1,155 @@
# 北京维康慈善基金会官网项目

## 项目概述

这是北京维康慈善基金会官方网站项目,基于 ThinkJS 3.x 框架开发。项目包含前台官网展示和后台管理系统两部分。

## 技术栈

- 后端框架:ThinkJS 3.x (Node.js)
- 模板引擎:Nunjucks
- 数据库:MySQL
- 缓存:文件缓存 (think-cache-file)
- 会话:文件会话 (think-session-file)
- 进程管理:PM2

## 项目结构

```
pap_web/
├── src/
│ ├── bootstrap/ # 启动配置 (master/worker)
│ ├── config/ # 配置文件
│ │ ├── adapter.js # 适配器配置
│ │ ├── config.js # 基础配置
│ │ ├── middleware.js # 中间件配置
│ │ └── router.js # 路由配置
│ ├── controller/ # 控制器
│ ├── logic/ # 逻辑层 (参数校验)
│ └── model/ # 数据模型
├── view/ # 视图模板 (Nunjucks)
├── www/static/ # 静态资源
└── runtime/ # 运行时文件
```

## 原型参考

项目原型位于 `pap_web_pm` 目录:

- `pap_web_pm/web/` - 前台官网原型
- `pap_web_pm/admin/` - 后台管理原型

## 功能模块

### 前台官网

| 栏目 | 子栏目 |
|------|--------|
| 首页 | Banner轮播、数据看板、药品援助公示、公益项目、新闻动态、合作伙伴 |
| 关于我们 | 基金会简介、组织架构、理事会&监事、资质证书、联系我们 |
| 公益项目 | 妇幼健康促进、"安心医"患者关爱、卫生健康促进、医疗科普公益、品牌建设与传播 |
| 党建专栏 | 党建规章、党建活动、党建学习 |
| 信息公示 | 管理制度、机构年报、审计报告、财务报告、关联方信息、项目执行报告 |
| 新闻中心 | 基金会动态、行业资讯、通知公告 |
| 联系我们 | 基本信息、关注我们、人才招聘、志愿者中心、合作申请 |

### 后台管理

- 控制台 (Dashboard)
- 内容管理 (文章、图片、文本、页面、人员)
- 数据管理 (药品援助记录、捐赠数据)
- 系统设置 (栏目管理、网站配置、用户管理、角色权限、操作日志)

## 内容类型映射

| 类型 | 说明 | 管理页面 |
|------|------|----------|
| article | 文章内容 | article-list.html |
| image | 图片内容 | image-list.html |
| text | 文本/文档 | text-list.html |
| page | 单页内容 | page-manage.html |
| person | 人员信息 | person-list.html |
| form | 表单数据 | form-data.html |
| donation | 捐赠数据 | donation.html |
| job | 招聘信息 | job-manage.html |

## 开发规范

### 前端组件规范 (Element Plus + Vue 3)

- `el-table-column` 必须使用完整闭合标签,不能使用自闭合:
- 正确:`<el-table-column prop="id" label="ID" width="60"></el-table-column>`
- 错误:`<el-table-column prop="id" label="ID" width="60" />`
- Vue 3 使用 CDN 引入时,delimiters 设置为 `['${', '}']` 避免与 Nunjucks 冲突
- Element Plus 组件使用 `ElementPlus.ElMessage` 和 `ElementPlus.ElMessageBox`

### 路由规范

- 前端页面跳转 (`window.location.href`、`<a href>`) 加 `.html` 后缀,如 `/admin/login.html`、`/admin/dashboard.html`
- 后端 `this.redirect()` 也加 `.html` 后缀
- `router.js` 路由配置不加 `.html`
- API 接口路径不加后缀,如 `/admin/auth/login`

### 命名约定

- Controller: 小写,如 `index.js`
- Model: 小写,如 `article.js`
- View: `{controller}_{action}.html`,如 `index_index.html`
- 路由: RESTful 风格

### 代码风格

- 使用 ESLint 进行代码检查
- 运行 `npm run lint` 检查代码
- 运行 `npm run lint-fix` 自动修复

### 常用命令

```bash
# 开发环境启动
npm start

# 代码检查
npm run lint

# 生产环境部署
pm2 startOrReload pm2.json
```

## 数据库设计建议

### 核心表

- `column` - 栏目表
- `article` - 文章表
- `image` - 图片表
- `page` - 单页表
- `person` - 人员表
- `donation` - 捐赠记录表
- `medicine_aid` - 药品援助记录表
- `job` - 招聘信息表
- `form_data` - 表单提交数据表
- `user` - 管理员用户表
- `role` - 角色表
- `log` - 操作日志表
- `config` - 网站配置表

### 通用字段规范
- `is_deleted`: 软删除 (0=正常, 1=已删除)
- `status`: 状态 (1=启用, 0=停用)
- `create_by/update_by`: 创建人/修改人ID
- `create_time/update_time`: 创建/更新时间
- JSON字段存储多选值 (如 `disease_ids`, `delivery_methods`)
- 生成的sql文件放到sql文件夹

## 注意事项

1. 所有涉及金额的字段使用 DECIMAL 类型
2. 患者姓名等敏感信息需脱敏处理显示
3. 图片上传需限制大小和格式
4. 后台操作需记录日志
5. 前台页面需考虑 SEO 优化

## 生成说明
- 无特殊要求,请使用中文回复
- 无特殊要求,不要生成说明文档

+ 246
- 0
.kiro/steering/thinkjs.md Ver fichero

@@ -0,0 +1,246 @@
# ThinkJS 开发指南

## 框架简介

ThinkJS 是一个基于 Koa 2.x 的 Node.js MVC 框架,支持 ES6/7 特性,使用 async/await 处理异步。

## 目录结构说明

### src/config/

- `config.js` - 通用配置
- `config.production.js` - 生产环境配置(会覆盖 config.js)
- `adapter.js` - 适配器配置(数据库、缓存、会话等)
- `router.js` - 自定义路由
- `middleware.js` - 中间件配置
- `extend.js` - 扩展配置

### src/controller/

控制器目录,处理用户请求。

```javascript
// src/controller/index.js
module.exports = class extends think.Controller {
async indexAction() {
// 获取参数
const id = this.get('id');
const data = this.post();
// 调用 model
const list = await this.model('article').select();
// 渲染模板
this.assign('list', list);
return this.display();
}
};
```

### src/model/

数据模型目录,操作数据库。

```javascript
// src/model/article.js
module.exports = class extends think.Model {
// 获取文章列表
async getList(columnId, page = 1, pageSize = 10) {
return this.where({ column_id: columnId, status: 1 })
.order('create_time DESC')
.page(page, pageSize)
.countSelect();
}
};
```

### src/logic/

逻辑层,用于参数校验。

```javascript
// src/logic/article.js
module.exports = class extends think.Logic {
indexAction() {
this.allowMethods = 'get';
this.rules = {
id: { int: true, required: true }
};
}
};
```

## 常用 API

### Controller

```javascript
// 获取参数
this.get('name'); // GET 参数
this.post('name'); // POST 参数
this.param('name'); // GET + POST
this.file('upload'); // 上传文件

// 响应
this.success(data); // JSON 成功响应
this.fail(errno, errmsg); // JSON 失败响应
this.json(data); // JSON 响应
this.redirect(url); // 重定向

// 模板
this.assign('key', value); // 赋值
this.display(); // 渲染模板

// Session
await this.session('user'); // 获取
await this.session('user', data); // 设置

// Cookie
this.cookie('name'); // 获取
this.cookie('name', value); // 设置
```

### Model

```javascript
const model = this.model('article');

// 查询
model.where({ id: 1 }).find(); // 单条
model.where({ status: 1 }).select(); // 多条
model.where({ status: 1 }).count(); // 计数
model.page(1, 10).countSelect(); // 分页

// 新增
model.add({ title: 'xxx', content: 'xxx' });
model.addMany([{...}, {...}]);

// 更新
model.where({ id: 1 }).update({ title: 'new' });

// 删除
model.where({ id: 1 }).delete();

// 链式调用
model.field('id, title')
.where({ status: 1 })
.order('id DESC')
.limit(10)
.select();
```

## 路由配置

```javascript
// src/config/router.js
module.exports = [
// [匹配规则, 'controller/action', method]
['/article/:id', 'article/detail', 'get'],
['/api/article', 'api/article', 'rest'],
['/admin/:controller/:action?', 'admin/:controller/:action'],
];
```

## 中间件

```javascript
// src/config/middleware.js
module.exports = [
{
handle: 'meta',
options: { logRequest: true }
},
{
handle: 'resource',
enable: true
},
'trace',
'payload',
'router',
'logic',
'controller'
];
```

## 数据库配置

```javascript
// src/config/adapter.js
exports.model = {
type: 'mysql',
common: {
logConnect: true,
logSql: true,
logger: msg => think.logger.info(msg)
},
mysql: {
handle: mysql,
database: 'pap_web',
prefix: 'pap_',
encoding: 'utf8mb4',
host: '127.0.0.1',
port: '3306',
user: 'root',
password: '',
dateStrings: true
}
};
```

## 视图模板 (Nunjucks)

```html
<!-- view/index_index.html -->
{% extends "layout.html" %}

{% block content %}
<ul>
{% for item in list %}
<li>{{ item.title }}</li>
{% endfor %}
</ul>

{% if pagination.totalPages > 1 %}
<div class="pagination">
第 {{ pagination.currentPage }} / {{ pagination.totalPages }} 页
</div>
{% endif %}
{% endblock %}
```

## 常见问题

### 跨域处理

```javascript
// src/config/middleware.js 添加
{
handle: 'cors',
options: {
origin: '*',
credentials: true
}
}
```

### 文件上传

```javascript
// controller
const file = this.file('image');
if (file) {
const filepath = file.path;
// 移动文件到目标目录
think.fs.move(filepath, targetPath);
}
```

### 事务处理

```javascript
const model = this.model('article');
await model.transaction(async () => {
await model.add(data1);
await this.model('log').add(data2);
});
```

+ 22
- 0
README.md Ver fichero

@@ -0,0 +1,22 @@

Application created by [ThinkJS](http://www.thinkjs.org)

## Install dependencies

```
npm install
```

## Start server

```
npm start
```

## Deploy with pm2

Use pm2 to deploy app on production enviroment.

```
pm2 startOrReload pm2.json
```

+ 12
- 0
development.js Ver fichero

@@ -0,0 +1,12 @@
const path = require('path');
const Application = require('thinkjs');
const watcher = require('think-watcher');

const instance = new Application({
ROOT_PATH: __dirname,
APP_PATH: path.join(__dirname, 'src'),
watcher: watcher,
env: 'development'
});

instance.run();

+ 30
- 0
nginx.conf Ver fichero

@@ -0,0 +1,30 @@
server {
listen 80;
server_name example.com www.example.com;
root E:\PAP\project_web\pap_web_pm\pap_web;
set $node_port 8360;

index index.js index.html index.htm;
if ( -f $request_filename/index.html ){
rewrite (.*) $1/index.html break;
}
if ( !-f $request_filename ){
rewrite (.*) /index.js;
}
location = /index.js {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://127.0.0.1:$node_port$request_uri;
proxy_redirect off;
}

location ~ /static/ {
etag on;
expires max;
}
}

+ 53
- 0
package.json Ver fichero

@@ -0,0 +1,53 @@
{
"name": "pap_web",
"description": "application created by thinkjs",
"version": "1.0.0",
"author": "leiyun <leiyun@home.com>",
"scripts": {
"start": "node development.js",
"test": "THINK_UNIT_TEST=1 nyc ava test/ && nyc report --reporter=html",
"lint": "eslint src/",
"lint-fix": "eslint --fix src/"
},
"dependencies": {
"cos-nodejs-sdk-v5": "^2.14.0",
"jsonwebtoken": "^9.0.3",
"sharp": "^0.33.0",
"think-cache": "^1.0.0",
"think-cache-file": "^1.0.8",
"think-cache-redis": "^1.2.6",
"think-logger3": "^1.0.0",
"think-model": "^1.0.0",
"think-model-mysql": "^1.0.0",
"think-session": "^1.0.0",
"think-session-file": "^1.0.5",
"think-view": "^1.0.0",
"think-view-nunjucks": "^1.0.1",
"thinkjs": "^3.0.0"
},
"devDependencies": {
"ava": "^0.18.0",
"eslint": "^4.2.0",
"eslint-config-think": "^1.0.0",
"nyc": "^7.0.0",
"think-watcher": "^3.0.0"
},
"repository": "",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
},
"readmeFilename": "README.md",
"thinkjs": {
"metadata": {
"name": "pap_web",
"description": "application created by thinkjs",
"author": "leiyun <leiyun@home.com>",
"babel": false
},
"projectName": "pap_web",
"template": "D:\\Program Files\\nvm\\nvm\\v24.10.0\\node_modules\\think-cli\\default_template",
"clone": false,
"isMultiModule": false
}
}

+ 15
- 0
pm2.json Ver fichero

@@ -0,0 +1,15 @@
{
"apps": [{
"name": "pap_web",
"script": "production.js",
"cwd": "E:\PAP\project_web\pap_web_pm\pap_web",
"exec_mode": "fork",
"max_memory_restart": "1G",
"autorestart": true,
"node_args": [],
"args": [],
"env": {
}
}]
}

+ 7539
- 0
pnpm-lock.yaml
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 11
- 0
production.js Ver fichero

@@ -0,0 +1,11 @@
const path = require('path');
const Application = require('thinkjs');

const instance = new Application({
ROOT_PATH: __dirname,
APP_PATH: path.join(__dirname, 'src'),
proxy: true, // use proxy
env: 'production'
});

instance.run();

+ 85
- 0
sql/init.sql Ver fichero

@@ -0,0 +1,85 @@
-- 创建数据库
CREATE DATABASE IF NOT EXISTS `pap_web` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

USE `pap_web`;

-- ========================================
-- 系统管理模块
-- ========================================

-- 管理员用户表
CREATE TABLE `admin_user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(64) NOT NULL COMMENT '密码(MD5)',
`nickname` varchar(50) DEFAULT '' COMMENT '昵称',
`avatar` varchar(255) DEFAULT '' COMMENT '头像',
`email` varchar(100) DEFAULT '' COMMENT '邮箱',
`phone` varchar(20) DEFAULT '' COMMENT '手机号',
`role_id` int(11) unsigned DEFAULT 0 COMMENT '角色ID',
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` varchar(50) DEFAULT '' COMMENT '最后登录IP',
`status` tinyint(1) DEFAULT 1 COMMENT '状态: 1启用 0停用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_by` int(11) unsigned DEFAULT 0 COMMENT '创建人ID',
`update_by` int(11) unsigned DEFAULT 0 COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_role_id` (`role_id`),
KEY `idx_status` (`status`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员用户表';

-- 角色表
CREATE TABLE `admin_role` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '角色名称',
`code` varchar(50) DEFAULT '' COMMENT '角色编码',
`description` varchar(200) DEFAULT '' COMMENT '角色描述',
`permissions` json DEFAULT NULL COMMENT '权限列表(JSON)',
`is_default` tinyint(1) DEFAULT 0 COMMENT '是否默认角色: 0否 1是',
`sort` int(11) DEFAULT 0 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态: 1启用 0停用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_by` int(11) unsigned DEFAULT 0 COMMENT '创建人ID',
`update_by` int(11) unsigned DEFAULT 0 COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_is_deleted` (`is_deleted`),
KEY `idx_sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

-- 操作日志表
CREATE TABLE `admin_log` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned DEFAULT 0 COMMENT '操作用户ID',
`username` varchar(50) DEFAULT '' COMMENT '操作用户名',
`action` varchar(100) NOT NULL COMMENT '操作类型',
`module` varchar(50) DEFAULT '' COMMENT '操作模块',
`content` text COMMENT '操作内容',
`ip` varchar(50) DEFAULT '' COMMENT 'IP地址',
`user_agent` varchar(500) DEFAULT '' COMMENT 'User-Agent',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_action` (`action`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';

-- ========================================
-- 初始化数据
-- ========================================

-- 插入默认角色
INSERT INTO `admin_role` (`id`, `name`, `code`, `description`, `permissions`, `is_default`, `sort`, `create_by`) VALUES
(1, '超级管理员', 'ADMIN', '拥有所有权限', '["*"]', 1, 1, 0),
(2, '内容编辑', 'EDITOR', '内容管理权限', '["content:article","content:image","content:page"]', 0, 2, 0),
(3, '审核员', 'AUDITOR', '内容审核权限', '["content:article:view","content:article:audit"]', 0, 3, 0);

-- 插入默认管理员 (密码: admin123, MD5加密)
INSERT INTO `admin_user` (`username`, `password`, `nickname`, `role_id`, `create_by`) VALUES
('admin', '0192023a7bbd73250516f069df18b500', '超级管理员', 1, 0);

+ 34
- 0
sql/update_20260211_article.sql Ver fichero

@@ -0,0 +1,34 @@
-- 图文列表表
CREATE TABLE IF NOT EXISTS `pap_article` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`title` varchar(500) NOT NULL DEFAULT '' COMMENT '文章标题',
`summary` varchar(1000) DEFAULT '' COMMENT '文章摘要',
`content` longtext COMMENT '文章正文',
`cover` varchar(500) DEFAULT '' COMMENT '封面图',
`category` varchar(100) DEFAULT '' COMMENT '分类',
`is_top` tinyint(1) DEFAULT 0 COMMENT '置顶 1是 0否',
`is_recommend` tinyint(1) DEFAULT 0 COMMENT '推荐到首页 1是 0否',
`sort` int(11) DEFAULT 0 COMMENT '排序权重',
`status` tinyint(1) DEFAULT 0 COMMENT '状态 1已发布 2待审核 0草稿',
`publish_time` datetime DEFAULT NULL COMMENT '发布时间',
`seo_title` varchar(200) DEFAULT '' COMMENT 'SEO标题',
`seo_keywords` varchar(500) DEFAULT '' COMMENT 'SEO关键词',
`seo_description` varchar(1000) DEFAULT '' COMMENT 'SEO描述',
`view_count` int(11) DEFAULT 0 COMMENT '浏览量',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_status` (`status`, `is_deleted`),
KEY `idx_top` (`is_top`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图文列表';

-- 测试数据
INSERT INTO `pap_article` (`column_id`, `title`, `summary`, `cover`, `category`, `is_top`, `status`, `publish_time`, `sort`) VALUES
(0, '北京维康慈善基金会再次获得2026年度-2028年度公益性捐赠税前扣除资格', '北京维康慈善基金会近日再次获得财政部、国家税务总局联合认定的2026年度至2028年度公益性捐赠税前扣除资格。', '/static/images/article1.jpg', '基金会动态', 1, 1, '2026-01-27 10:30:00', 100),
(0, '北京维康慈善基金会荣获第十五届公益节年度两项大奖', '在第十五届公益节上,北京维康慈善基金会凭借在公益领域的突出贡献,荣获年度两项大奖。', '/static/images/article2.jpg', '基金会动态', 0, 1, '2026-01-26 14:20:00', 0),
(0, '善行边疆,"爱护妳"公益项目走进漠河', '"爱护妳"公益项目团队深入祖国最北端漠河,为当地妇女儿童送去健康关爱。', '/static/images/article3.jpg', '基金会动态', 0, 1, '2026-01-23 09:15:00', 0),
(0, '妇幼健康促进项目', '聚焦妇幼健康促进,为偏远地区家庭带去专业医疗资源与关怀。', '/static/images/project1.jpg', '公益项目', 1, 1, '2026-01-20 08:30:00', 90),
(0, '"安心医"患者关爱项目', '携手医药企业,为经济困难的患者提供药品援助,减轻治疗负担。', '/static/images/project2.jpg', '公益项目', 1, 1, '2026-01-18 15:20:00', 80);

+ 31
- 0
sql/update_20260211_banner.sql Ver fichero

@@ -0,0 +1,31 @@
-- Banner轮播图表
CREATE TABLE IF NOT EXISTS `pap_banner` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '主标题',
`subtitle` varchar(200) DEFAULT '' COMMENT '副标题',
`description` text COMMENT '描述文字',
`image` varchar(500) NOT NULL DEFAULT '' COMMENT '图片地址',
`btn1_text` varchar(50) DEFAULT '了解更多' COMMENT '按钮1文字',
`btn1_link` varchar(500) DEFAULT '#' COMMENT '按钮1链接',
`btn1_show` tinyint(1) DEFAULT 1 COMMENT '按钮1显示 1是 0否',
`btn2_text` varchar(50) DEFAULT '我要捐赠' COMMENT '按钮2文字',
`btn2_link` varchar(500) DEFAULT '#' COMMENT '按钮2链接',
`btn2_show` tinyint(1) DEFAULT 1 COMMENT '按钮2显示 1是 0否',
`position` varchar(20) DEFAULT 'left' COMMENT '文字位置 left/center/right',
`glass` tinyint(1) DEFAULT 1 COMMENT '毛玻璃效果 1启用 0关闭',
`sort` int(11) DEFAULT 1 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1启用 0禁用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Banner轮播图';

-- 测试数据
INSERT INTO `pap_banner` (`column_id`, `title`, `subtitle`, `description`, `image`, `btn1_text`, `btn1_link`, `btn1_show`, `btn2_text`, `btn2_link`, `btn2_show`, `position`, `glass`, `sort`) VALUES
(0, '也许因为您的一次帮助', '天真的笑容将再次回到他的脸上', '关注儿童健康成长,让每一份爱心都能传递温暖与希望。北京维康慈善基金会致力于妇幼健康、患者关爱、卫生健康促进等公益事业。', '/static/images/banner1.jpg', '了解更多', '#', 1, '我要捐赠', '#', 1, 'left', 0, 1),
(0, '守护每一份健康', '让爱与希望同行', '聚焦妇幼健康促进,为偏远地区家庭带去专业医疗资源与关怀,让每个生命都被温柔以待。', '/static/images/banner2.jpg', '了解项目', '#', 1, '参与公益', '#', 1, 'left', 1, 2),
(0, '透明公益 值得信赖', '每一笔善款都有迹可循', '严格遵循FTI中基透明指数标准,实时公示捐赠收支明细,让公益在阳光下运行。', '/static/images/banner3.jpg', '查看公示', '#', 1, '信息公开', '#', 0, 'left', 1, 3);

+ 74
- 0
sql/update_20260211_column.sql Ver fichero

@@ -0,0 +1,74 @@
-- 栏目管理表
CREATE TABLE IF NOT EXISTS `pap_column` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`parent_id` int(11) DEFAULT '0' COMMENT '父级ID,0为顶级',
`name` varchar(100) NOT NULL COMMENT '栏目名称',
`key` varchar(50) DEFAULT '' COMMENT '栏目标识,用于路由',
`icon` varchar(50) DEFAULT '' COMMENT '图标名称(Element Plus图标)',
`type` varchar(20) DEFAULT '' COMMENT '内容类型:article/image/text/page/person/form/donation/job',
`is_single_page` tinyint(1) DEFAULT '0' COMMENT '一级栏目类型:1=单页面(二级为模块), 0=多页面(二级为独立页面)',
`sort` int(11) DEFAULT '1' COMMENT '排序',
`visible` tinyint(1) DEFAULT '1' COMMENT '显示状态:1=显示, 0=隐藏',
`link` varchar(500) DEFAULT '' COMMENT '外部链接',
`seo_title` varchar(200) DEFAULT '' COMMENT 'SEO标题',
`seo_keywords` varchar(300) DEFAULT '' COMMENT 'SEO关键词',
`seo_description` text COMMENT 'SEO描述',
`slug` varchar(100) DEFAULT '' COMMENT 'URL别名',
`form_config` text COMMENT '表单配置JSON',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '0=正常, 1=删除',
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_sort` (`sort`),
KEY `idx_key` (`key`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='栏目管理';

-- 如果表已存在,添加字段
-- ALTER TABLE `pap_column` ADD COLUMN `key` varchar(50) DEFAULT '' COMMENT '栏目标识,用于路由' AFTER `name`;
-- ALTER TABLE `pap_column` ADD COLUMN `icon` varchar(50) DEFAULT '' COMMENT '图标名称(Element Plus图标)' AFTER `key`;
-- ALTER TABLE `pap_column` ADD COLUMN `is_single_page` tinyint(1) DEFAULT '0' COMMENT '一级栏目类型:1=单页面(二级为模块), 0=多页面(二级为独立页面)' AFTER `type`;

-- 初始数据
INSERT INTO `pap_column` (`id`, `parent_id`, `name`, `key`, `icon`, `type`, `is_single_page`, `sort`, `visible`) VALUES
(1, 0, '首页', 'home', 'HomeFilled', '', 1, 1, 1),
(2, 1, 'Banner轮播', 'home-banner', '', 'image', 0, 1, 1),
(3, 1, '数据看板', 'home-data', '', 'donation', 0, 2, 1),
(4, 1, '药品援助公示', 'home-medicine', '', 'text', 0, 3, 1),
(5, 1, '公益项目', 'home-project', '', 'article', 0, 4, 1),
(6, 1, '新闻动态', 'home-news', '', 'article', 0, 5, 1),
(7, 1, '合作伙伴', 'home-partner', '', 'image', 0, 6, 1),
(10, 0, '关于我们', 'about', 'OfficeBuilding', '', 0, 2, 1),
(11, 10, '基金会简介', 'about-intro', '', 'page', 0, 1, 1),
(12, 10, '组织架构', 'about-org', '', 'page', 0, 2, 1),
(13, 10, '理事会&监事', 'about-council', '', 'person', 0, 3, 1),
(14, 10, '资质证书', 'about-cert', '', 'image', 0, 4, 1),
(15, 10, '联系我们', 'about-contact', '', 'page', 0, 5, 1),
(20, 0, '公益项目', 'project', 'Present', 'article', 0, 3, 1),
(21, 20, '妇幼健康促进', 'proj-1', '', 'article', 0, 1, 1),
(22, 20, '"安心医"患者关爱', 'proj-2', '', 'article', 0, 2, 1),
(23, 20, '卫生健康促进', 'proj-3', '', 'article', 0, 3, 1),
(24, 20, '医疗科普公益', 'proj-4', '', 'article', 0, 4, 1),
(25, 20, '品牌建设与传播', 'proj-5', '', 'article', 0, 5, 1),
(30, 0, '党建专栏', 'party', 'Flag', '', 0, 4, 1),
(31, 30, '党建规章', 'party-rule', '', 'text', 0, 1, 1),
(32, 30, '党建活动', 'party-act', '', 'article', 0, 2, 1),
(33, 30, '党建学习', 'party-study', '', 'article', 0, 3, 1),
(40, 0, '信息公示', 'disclosure', 'Document', 'text', 0, 5, 1),
(41, 40, '管理制度', 'disc-rule', '', 'text', 0, 1, 1),
(42, 40, '机构年报', 'disc-annual', '', 'text', 0, 2, 1),
(43, 40, '审计报告', 'disc-audit', '', 'text', 0, 3, 1),
(44, 40, '财务报告', 'disc-finance', '', 'text', 0, 4, 1),
(45, 40, '关联方信息', 'disc-related', '', 'text', 0, 5, 1),
(46, 40, '项目执行报告', 'disc-exec', '', 'text', 0, 6, 1),
(50, 0, '新闻中心', 'news', 'Notification', 'article', 0, 6, 1),
(51, 50, '基金会动态', 'news-found', '', 'article', 0, 1, 1),
(52, 50, '行业资讯', 'news-indust', '', 'article', 0, 2, 1),
(53, 50, '通知公告', 'news-notice', '', 'article', 0, 3, 1),
(60, 0, '联系我们', 'contact', 'Phone', '', 0, 7, 1),
(61, 60, '基本信息', 'ct-info', '', 'page', 0, 1, 1),
(62, 60, '关注我们', 'ct-follow', '', 'page', 0, 2, 1),
(63, 60, '人才招聘', 'ct-job', '', 'job', 0, 3, 1),
(64, 60, '志愿者中心', 'ct-volunteer', '', 'form', 0, 4, 1),
(65, 60, '合作申请', 'ct-coop', '', 'form', 0, 5, 1);

+ 45
- 0
sql/update_20260211_config.sql Ver fichero

@@ -0,0 +1,45 @@
-- 网站配置表
-- 执行时间: 2026-02-11

CREATE TABLE IF NOT EXISTS `config` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`group` varchar(50) NOT NULL COMMENT '配置分组: basic/seo/contact/social',
`key` varchar(100) NOT NULL COMMENT '配置键',
`value` text COMMENT '配置值',
`type` varchar(20) DEFAULT 'text' COMMENT '类型: text/textarea/image',
`label` varchar(100) COMMENT '显示名称',
`sort` int(11) DEFAULT 0 COMMENT '排序',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_key` (`group`, `key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网站配置表';

-- 初始化基础配置
INSERT INTO `config` (`group`, `key`, `value`, `type`, `label`, `sort`) VALUES
-- 基础设置
('basic', 'site_name', '北京维康慈善基金会', 'text', '站点名称', 1),
('basic', 'site_subtitle', '促进公众健康,助力医疗公益', 'text', '站点副标题', 2),
('basic', 'site_domain', 'www.vkfoundation.cn', 'text', '网站域名', 3),
('basic', 'icp_number', '', 'text', 'ICP备案号', 4),
('basic', 'copyright', '© 2026 北京维康慈善基金会 版权所有', 'text', '版权信息', 5),
('basic', 'logo', '', 'image', '网站Logo', 6),
('basic', 'wechat_qrcode', '', 'image', '公众号二维码', 7),
-- SEO设置
('seo', 'page_title', '北京维康慈善基金会 - 促进公众健康,助力医疗公益', 'text', '页面标题', 1),
('seo', 'keywords', '维康慈善基金会,公益,慈善,医疗公益,妇幼健康', 'text', '关键词', 2),
('seo', 'description', '北京维康慈善基金会致力于妇幼健康促进、患者关爱、卫生健康促进、医疗科普等公益事业。', 'textarea', '描述', 3),
('seo', 'favicon', '', 'image', 'Favicon', 4),
-- 联系信息
('contact', 'phone', '', 'text', '联系电话', 1),
('contact', 'email', '', 'text', '联系邮箱', 2),
('contact', 'address', '', 'text', '办公地址', 3),
('contact', 'postcode', '', 'text', '邮政编码', 4),
('contact', 'map_lng', '', 'text', '地图坐标(经度)', 5),
('contact', 'map_lat', '', 'text', '地图坐标(纬度)', 6),
-- 社交媒体
('social', 'wechat_name', '', 'text', '微信公众号名称', 1),
('social', 'wechat_id', '', 'text', '微信公众号ID', 2),
('social', 'weibo_url', '', 'text', '微博链接', 3),
('social', 'douyin_url', '', 'text', '抖音链接', 4),
('social', 'video_url', '', 'text', '视频号链接', 5)
ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `sort` = VALUES(`sort`);

+ 49
- 0
sql/update_20260211_donation.sql Ver fichero

@@ -0,0 +1,49 @@
-- 捐赠收支数据表
CREATE TABLE IF NOT EXISTS `pap_donation` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`type` varchar(20) NOT NULL DEFAULT 'income' COMMENT '类型:income收入/expense支出',
`name` varchar(200) NOT NULL DEFAULT '' COMMENT '捐赠人/单位 或 支出项目',
`amount` decimal(18,2) DEFAULT 0.00 COMMENT '金额',
`purpose` varchar(200) DEFAULT '' COMMENT '用途/项目 或 受益对象',
`source` varchar(20) DEFAULT 'manual' COMMENT '来源:manual手动/kingdee金蝶',
`record_date` date DEFAULT NULL COMMENT '日期',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1已公示 0待公示',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_type` (`type`),
KEY `idx_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='捐赠收支数据';

-- 捐赠统计表(单独维护)
CREATE TABLE IF NOT EXISTS `pap_donation_stat` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`total_income` decimal(18,2) DEFAULT 0.00 COMMENT '累计捐赠收入',
`total_expense` decimal(18,2) DEFAULT 0.00 COMMENT '累计公益支出',
`year_income` decimal(18,2) DEFAULT 0.00 COMMENT '本年度收入',
`year_expense` decimal(18,2) DEFAULT 0.00 COMMENT '本年度支出',
`sync_time` datetime DEFAULT NULL COMMENT '最后同步时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_column` (`column_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='捐赠统计';

-- 测试数据
INSERT INTO `pap_donation` (`column_id`, `type`, `name`, `amount`, `purpose`, `source`, `record_date`, `status`) VALUES
(0, 'income', '上海复宏汉霖生物制药有限公司', 948839117.38, '妇幼健康促进项目', 'kingdee', '2026-01-20', 1),
(0, 'income', 'CStone Pharm (HK) Holding Limited', 547853600.47, '"安心医"患者关爱', 'kingdee', '2026-01-15', 1),
(0, 'income', '齐鲁制药有限公司', 395642961.90, '卫生健康促进', 'kingdee', '2026-01-10', 1),
(0, 'income', '江西济民可信医药贸易有限公司', 187654617.64, '医疗科普公益', 'kingdee', '2026-01-05', 1),
(0, 'income', '康方药业有限公司', 141383697.53, '品牌建设与传播', 'kingdee', '2025-12-28', 1),
(0, 'expense', '妇幼健康促进项目-第一季度执行', 948839117.38, '妇幼群体', 'kingdee', '2026-01-28', 1),
(0, 'expense', '"安心医"患者关爱-药品采购', 547853600.47, '患者群体', 'kingdee', '2026-01-20', 1),
(0, 'expense', '卫生健康促进-社区义诊活动', 395642961.90, '社区居民', 'kingdee', '2026-01-15', 1),
(0, 'expense', '医疗科普公益-宣传物料制作', 187654617.64, '公众', 'kingdee', '2026-01-10', 1);

-- 初始统计数据
INSERT INTO `pap_donation_stat` (`column_id`, `total_income`, `total_expense`, `year_income`, `year_expense`) VALUES
(0, 178255656.50, 178255656.50, 32456789.00, 28123456.00);

+ 25
- 0
sql/update_20260211_image.sql Ver fichero

@@ -0,0 +1,25 @@
-- 图片列表表
CREATE TABLE IF NOT EXISTS `pap_image` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '标题',
`image` varchar(500) NOT NULL DEFAULT '' COMMENT '图片地址',
`link` varchar(500) DEFAULT '' COMMENT '链接地址',
`sort` int(11) DEFAULT 1 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1启用 0禁用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图片列表';

-- 测试数据
INSERT INTO `pap_image` (`column_id`, `title`, `image`, `link`, `sort`, `status`) VALUES
(0, '合作伙伴1', '/static/images/partner1.png', '', 1, 1),
(0, '合作伙伴2', '/static/images/partner2.png', '', 2, 1),
(0, '合作伙伴3', '/static/images/partner3.png', '', 3, 1),
(0, '合作伙伴4', '/static/images/partner4.png', '', 4, 1),
(0, '合作伙伴5', '/static/images/partner5.png', '', 5, 1),
(0, '合作伙伴6', '/static/images/partner6.png', '', 6, 1);

+ 26
- 0
sql/update_20260211_job.sql Ver fichero

@@ -0,0 +1,26 @@
-- 岗位管理表
CREATE TABLE IF NOT EXISTS `pap_job` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '岗位名称',
`department` varchar(100) DEFAULT '' COMMENT '所属部门',
`location` varchar(100) DEFAULT '' COMMENT '工作地点',
`count` int(11) DEFAULT 1 COMMENT '招聘人数',
`duty` text COMMENT '岗位职责',
`requirement` text COMMENT '任职要求',
`salary` varchar(100) DEFAULT '' COMMENT '薪资范围',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1招聘中 0已关闭',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='岗位管理';

-- 测试数据
INSERT INTO `pap_job` (`column_id`, `name`, `department`, `location`, `count`, `duty`, `requirement`, `salary`, `status`) VALUES
(0, '项目专员', '项目部', '北京', 2, '负责公益项目的策划、执行与跟进', '本科及以上学历,1年以上公益项目经验', '8K-12K', 1),
(0, '新媒体运营', '品牌传播部', '北京', 1, '负责基金会新媒体平台内容运营', '本科及以上,熟悉微信公众号运营', '7K-10K', 1),
(0, '财务主管', '财务部', '北京', 1, '负责基金会日常财务管理与报表编制', '本科及以上,3年以上财务工作经验', '12K-18K', 1),
(0, '行政助理', '综合管理部', '北京', 1, '协助行政日常事务处理', '大专及以上学历', '5K-7K', 0);

+ 44
- 0
sql/update_20260211_medicine.sql Ver fichero

@@ -0,0 +1,44 @@
-- 药品援助记录表
CREATE TABLE IF NOT EXISTS `pap_medicine` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(200) NOT NULL COMMENT '药品名称',
`person` varchar(100) NOT NULL COMMENT '受助人',
`region` varchar(100) DEFAULT NULL COMMENT '地区',
`quantity` varchar(50) DEFAULT '1盒' COMMENT '数量',
`amount` decimal(12,2) DEFAULT '0.00' COMMENT '金额',
`status` tinyint(1) DEFAULT '2' COMMENT '状态:1=已发放, 2=待发放, 3=已取消',
`distribute_date` date DEFAULT NULL COMMENT '发放日期',
`year` int(4) DEFAULT NULL COMMENT '年度',
`remark` text COMMENT '备注',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '0=正常, 1=删除',
PRIMARY KEY (`id`),
KEY `idx_year` (`year`),
KEY `idx_status` (`status`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品援助记录';


-- 测试数据
INSERT INTO `pap_medicine` (`name`, `person`, `region`, `quantity`, `amount`, `status`, `distribute_date`, `year`) VALUES
('佳泰莱(芦康沙妥珠单抗)', '张*三', '上海', '1盒', 15000.00, 1, '2026-02-10', 2026),
('佳泰莱(芦康沙妥珠单抗)', '李*四', '四川成都', '1盒', 15000.00, 1, '2026-02-10', 2026),
('佳泰莱(芦康沙妥珠单抗)', '王*五', '湖北武汉', '1盒', 15000.00, 1, '2026-02-09', 2026),
('佳泰莱(芦康沙妥珠单抗)', '赵*六', '北京', '1盒', 15000.00, 1, '2026-02-09', 2026),
('佳泰莱(芦康沙妥珠单抗)', '刘*七', '广东广州', '1盒', 15000.00, 1, '2026-02-08', 2026),
('佳泰莱(芦康沙妥珠单抗)', '陈*八', '浙江杭州', '1盒', 15000.00, 2, '2026-02-08', 2026),
('佳泰莱(芦康沙妥珠单抗)', '周*九', '江苏南京', '1盒', 15000.00, 1, '2026-02-07', 2026),
('佳泰莱(芦康沙妥珠单抗)', '吴*十', '山东济南', '1盒', 15000.00, 1, '2026-02-07', 2026),
('佳泰莱(芦康沙妥珠单抗)', '郑*一', '河南郑州', '1盒', 15000.00, 1, '2026-02-06', 2026),
('佳泰莱(芦康沙妥珠单抗)', '孙*二', '湖南长沙', '1盒', 15000.00, 2, '2026-02-06', 2026),
('达伯舒(信迪利单抗)', '钱*三', '福建福州', '2盒', 28000.00, 1, '2026-02-05', 2026),
('达伯舒(信迪利单抗)', '冯*四', '安徽合肥', '2盒', 28000.00, 1, '2026-02-05', 2026),
('达伯舒(信迪利单抗)', '褚*五', '江西南昌', '2盒', 28000.00, 1, '2026-02-04', 2026),
('达伯舒(信迪利单抗)', '卫*六', '陕西西安', '2盒', 28000.00, 3, '2026-02-04', 2026),
('泰瑞沙(奥希替尼)', '蒋*七', '辽宁沈阳', '1盒', 12800.00, 1, '2026-02-03', 2026),
('泰瑞沙(奥希替尼)', '沈*八', '吉林长春', '1盒', 12800.00, 1, '2026-02-03', 2026),
('泰瑞沙(奥希替尼)', '韩*九', '黑龙江哈尔滨', '1盒', 12800.00, 2, '2026-02-02', 2026),
('泰瑞沙(奥希替尼)', '杨*十', '云南昆明', '1盒', 12800.00, 1, '2026-02-02', 2026),
('可瑞达(帕博利珠单抗)', '朱*一', '贵州贵阳', '1盒', 18500.00, 1, '2026-02-01', 2026),
('可瑞达(帕博利珠单抗)', '秦*二', '广西南宁', '1盒', 18500.00, 1, '2026-02-01', 2026);

+ 13
- 0
sql/update_20260211_page.sql Ver fichero

@@ -0,0 +1,13 @@
-- 单页内容表
CREATE TABLE IF NOT EXISTS `pap_page` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`content` longtext COMMENT '页面内容',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1已发布 0草稿',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`update_by` varchar(50) DEFAULT '' COMMENT '最后更新人',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_column` (`column_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单页内容';

+ 28
- 0
sql/update_20260211_person.sql Ver fichero

@@ -0,0 +1,28 @@
-- 人员列表表
CREATE TABLE IF NOT EXISTS `pap_person` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '姓名',
`title` varchar(100) DEFAULT '' COMMENT '职务',
`category` varchar(50) DEFAULT '' COMMENT '分类',
`avatar` varchar(500) DEFAULT '' COMMENT '头像',
`description` text COMMENT '简介',
`sort` int(11) DEFAULT 1 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1启用 0禁用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_category` (`category`),
KEY `idx_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人员列表';

-- 测试数据
INSERT INTO `pap_person` (`column_id`, `name`, `title`, `category`, `avatar`, `description`, `sort`, `status`) VALUES
(0, '张XX', '理事长', '理事会成员', '', 'XXXX大学医学博士,从事公益事业20余年,曾获"全国优秀公益人物"称号。', 1, 1),
(0, '李XX', '副理事长', '理事会成员', '', '资深医疗行业管理专家,曾任某三甲医院副院长,在医疗公益领域有丰富经验。', 2, 1),
(0, '王XX', '理事', '理事会成员', '', '知名企业家,长期关注并支持慈善公益事业,为基金会发展提供战略指导。', 3, 1),
(0, '陈XX', '秘书长', '理事会成员', '', '负责基金会日常运营管理,协调各部门工作,推动项目落地执行。', 4, 1),
(0, '林XX', '监事', '监事', '', '注册会计师,具有丰富的财务审计经验,负责基金会财务监督工作。', 1, 1),
(0, '黄XX', '监事', '监事', '', '资深律师,专注于非营利组织法律事务,为基金会合规运营提供监督保障。', 2, 1);

+ 14
- 0
sql/update_20260211_role.sql Ver fichero

@@ -0,0 +1,14 @@
-- 角色表新增字段
ALTER TABLE `admin_role`
ADD COLUMN `code` varchar(50) DEFAULT '' COMMENT '角色编码' AFTER `name`,
ADD COLUMN `is_default` tinyint(1) DEFAULT 0 COMMENT '是否默认角色: 0否 1是' AFTER `permissions`,
ADD COLUMN `sort` int(11) DEFAULT 0 COMMENT '排序' AFTER `is_default`,
ADD INDEX `idx_sort` (`sort`);

-- 更新现有角色数据
UPDATE `admin_role` SET `code` = 'ADMIN', `is_default` = 1, `sort` = 1 WHERE `id` = 1;
UPDATE `admin_role` SET `code` = 'EDITOR', `sort` = 2 WHERE `id` = 2;

-- 新增审核员角色
INSERT INTO `admin_role` (`name`, `code`, `description`, `permissions`, `is_default`, `sort`, `create_by`) VALUES
('审核员', 'AUDITOR', '内容审核权限', '["content:article:view","content:article:audit"]', 0, 3, 0);

+ 28
- 0
sql/update_20260211_text.sql Ver fichero

@@ -0,0 +1,28 @@
-- 文字列表表
CREATE TABLE IF NOT EXISTS `pap_text` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`column_id` int(11) NOT NULL DEFAULT 0 COMMENT '所属栏目ID',
`title` varchar(500) NOT NULL DEFAULT '' COMMENT '标题',
`category` varchar(100) DEFAULT '' COMMENT '分类',
`year` varchar(10) DEFAULT '' COMMENT '年度',
`file_url` varchar(500) DEFAULT '' COMMENT '附件地址',
`file_name` varchar(200) DEFAULT '' COMMENT '附件原始文件名',
`file_type` varchar(20) DEFAULT 'PDF' COMMENT '附件类型 PDF/DOC/XLS',
`sort` int(11) DEFAULT 1 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态 1已发布 2待审核 0草稿',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_column` (`column_id`),
KEY `idx_status` (`status`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文字列表';

-- 测试数据
INSERT INTO `pap_text` (`column_id`, `title`, `category`, `year`, `file_type`, `status`, `sort`) VALUES
(0, '佳泰莱(芦康沙妥珠单抗)2026年1月第三批援助公示', '药品援助公示', '2026', 'PDF', 1, 1),
(0, '佳泰莱(芦康沙妥珠单抗)2026年1月第二批援助公示', '药品援助公示', '2026', 'PDF', 1, 2),
(0, '佳泰莱(芦康沙妥珠单抗)2026年1月第一批援助公示', '药品援助公示', '2026', 'PDF', 1, 3),
(0, '北京维康慈善基金会2025年度审计报告', '审计报告', '2025', 'PDF', 1, 4),
(0, '北京维康慈善基金会2025年度工作报告', '机构年报', '2025', 'PDF', 1, 5),
(0, '2025年第四季度财务报告', '财务报告', '2025', 'XLS', 1, 6);

+ 1
- 0
src/bootstrap/master.js Ver fichero

@@ -0,0 +1 @@
// invoked in master

+ 1
- 0
src/bootstrap/worker.js Ver fichero

@@ -0,0 +1 @@
// invoked in worker

+ 110
- 0
src/config/adapter.js Ver fichero

@@ -0,0 +1,110 @@
const fileCache = require('think-cache-file');
const nunjucks = require('think-view-nunjucks');
const fileSession = require('think-session-file');
const mysql = require('think-model-mysql');
const {Console, File, DateFile} = require('think-logger3');
const path = require('path');
const isDev = think.env === 'development';

/**
* cache adapter config
* @type {Object}
*/
exports.cache = {
type: 'file',
common: {
timeout: 24 * 60 * 60 * 1000 // millisecond
},
file: {
handle: fileCache,
cachePath: path.join(think.ROOT_PATH, 'runtime/cache'), // absoulte path is necessarily required
pathDepth: 1,
gcInterval: 24 * 60 * 60 * 1000 // gc interval
}
};

/**
* model adapter config
* @type {Object}
*/
exports.model = {
type: 'pap',
common: {
logConnect: isDev,
logSql: isDev,
logger: msg => think.logger.info(msg)
},
// pap 测试数据库
pap: {
handle: mysql,
database: 'pap_web',
prefix: '',
encoding: 'utf8mb4',
host: 'sh-cynosdbmysql-grp-jjq5h0fk.sql.tencentcdb.com',
port: '26821',
user: 'root',
password: '5orKUdDN3QhESVcS',
dateStrings: true
}
};

/**
* session adapter config
* @type {Object}
*/
exports.session = {
type: 'file',
common: {
cookie: {
name: 'thinkjs'
// keys: ['werwer', 'werwer'],
// signed: true
}
},
file: {
handle: fileSession,
sessionPath: path.join(think.ROOT_PATH, 'runtime/session')
}
};

/**
* view adapter config
* @type {Object}
*/
exports.view = {
type: 'nunjucks',
common: {
viewPath: path.join(think.ROOT_PATH, 'view'),
sep: '_',
extname: '.html'
},
nunjucks: {
handle: nunjucks
}
};

/**
* logger adapter config
* @type {Object}
*/
exports.logger = {
type: isDev ? 'console' : 'dateFile',
console: {
handle: Console
},
file: {
handle: File,
backups: 10, // max chunk number
absolute: true,
maxLogSize: 50 * 1024, // 50M
filename: path.join(think.ROOT_PATH, 'logs/app.log')
},
dateFile: {
handle: DateFile,
level: 'ALL',
absolute: true,
pattern: '-yyyy-MM-dd',
alwaysIncludePattern: true,
filename: path.join(think.ROOT_PATH, 'logs/app.log')
}
};

+ 46
- 0
src/config/adapter.production.js Ver fichero

@@ -0,0 +1,46 @@
const mysql = require('think-model-mysql');
const redisCache = require('think-cache-redis');

/**
* 生产环境 cache adapter config
* @type {Object}
*/
exports.cache = {
type: 'redis',
common: {
timeout: 24 * 60 * 60 * 1000
},
redis: {
handle: redisCache,
host: '127.0.0.1',
port: 6319,
password: '8cLgEgZSdWr57CTe',
timeout: 0,
log_connect: true
}
};

/**
* 生产环境 model adapter config
* @type {Object}
*/
exports.model = {
type: 'pap',
common: {
logConnect: false,
logSql: false,
logger: msg => think.logger.info(msg)
},
// pap 线上数据库
pap: {
handle: mysql,
database: 'pap_web',
prefix: '',
encoding: 'utf8mb4',
host: 'sh-cynosdbmysql-grp-jjq5h0fk.sql.tencentcdb.com',
port: '26822',
user: 'root',
password: '5orKUdDN3QhESVcS',
dateStrings: true
}
};

+ 6
- 0
src/config/config.js Ver fichero

@@ -0,0 +1,6 @@
// default config
module.exports = {
workers: 1,
errnoField: 'code', // errno field
errmsgField: 'msg' // errmsg field
};

+ 4
- 0
src/config/config.production.js Ver fichero

@@ -0,0 +1,4 @@
// production config, it will load in production enviroment
module.exports = {
workers: 0
};

+ 39
- 0
src/config/cos.js Ver fichero

@@ -0,0 +1,39 @@
// 腾讯云 COS 配置
module.exports = {
// COS 配置
secretId: process.env.COS_SECRET_ID || 'AKIDcH6qOipkT0Lo9HJ8kEhaJrBML8f4GF1l',
secretKey: process.env.COS_SECRET_KEY || 'Yt1vc5LNCnBSdeS2RdbMvHOIyTkoeIjw',
bucket: process.env.COS_BUCKET || 'pap-1314297964',
region: process.env.COS_REGION || 'ap-shanghai',

// URL 配置
bucketUrl: 'https://pap-1314297964.cos.ap-shanghai.myqcloud.com',
cdnUrl: 'https://cdn.csybhelp.com',

// 文件配置
allowedImageTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'],
allowedDocTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain'
],
allowedVideoTypes: ['video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo', 'video/x-ms-wmv'],

// 文件大小限制(字节)
maxImageSize: 10 * 1024 * 1024, // 10MB
maxDocSize: 50 * 1024 * 1024, // 50MB
maxVideoSize: 200 * 1024 * 1024, // 200MB

// 图片压缩配置
imageCompress: {
quality: 80, // 压缩质量 0-100
maxWidth: 1920, // 最大宽度
maxHeight: 1920, // 最大高度
minSize: 100 * 1024 // 最小压缩大小(字节),小于此值不压缩,默认 100KB
}
};

+ 11
- 0
src/config/extend.js Ver fichero

@@ -0,0 +1,11 @@
const view = require('think-view');
const model = require('think-model');
const cache = require('think-cache');
const session = require('think-session');

module.exports = [
view, // make application support view
model(think.app),
cache,
session
];

+ 40
- 0
src/config/middleware.js Ver fichero

@@ -0,0 +1,40 @@
const path = require('path');
const isDev = think.env === 'development';

module.exports = [
{
handle: 'meta',
options: {
logRequest: isDev,
sendResponseTime: isDev
}
},
{
handle: 'resource',
enable: isDev,
options: {
root: path.join(think.ROOT_PATH, 'www'),
publicPath: /^\/(static|favicon\.ico)/
}
},
{
handle: 'trace',
enable: !think.isCli,
options: {
debug: isDev
}
},
{
handle: 'payload',
options: {
keepExtensions: true,
limit: '5mb'
}
},
{
handle: 'router',
options: {}
},
'logic',
'controller'
];

+ 121
- 0
src/config/router.js Ver fichero

@@ -0,0 +1,121 @@
module.exports = [
// 前台路由
['/', 'index/index'],
['/index', 'index/index'],

// 后台管理路由
['/admin/login', 'admin/auth/login', 'get'],
['/admin/auth/login', 'admin/auth/doLogin', 'post'],
['/admin/logout', 'admin/auth/logout'],

// 后台页面
['/admin/dashboard', 'admin/dashboard/index'],

// 系统管理 - 用户
['/admin/system/user', 'admin/system/user/index'],
['/admin/system/user/list', 'admin/system/user/list'],
['/admin/system/user/detail', 'admin/system/user/detail'],
['/admin/system/user/add', 'admin/system/user/add', 'post'],
['/admin/system/user/edit', 'admin/system/user/edit', 'post'],
['/admin/system/user/resetPassword', 'admin/system/user/resetPassword', 'post'],
['/admin/system/user/delete', 'admin/system/user/delete', 'post'],
['/admin/system/user/toggleStatus', 'admin/system/user/toggleStatus', 'post'],

// 系统管理 - 角色
['/admin/system/role', 'admin/system/role/index'],
['/admin/system/role/list', 'admin/system/role/list'],
['/admin/system/role/all', 'admin/system/role/all'],
['/admin/system/role/detail', 'admin/system/role/detail'],
['/admin/system/role/add', 'admin/system/role/add', 'post'],
['/admin/system/role/edit', 'admin/system/role/edit', 'post'],
['/admin/system/role/delete', 'admin/system/role/delete', 'post'],
['/admin/system/role/batchDelete', 'admin/system/role/batchDelete', 'post'],
['/admin/system/role/assignPermissions', 'admin/system/role/assignPermissions', 'post'],

// 系统管理 - 网站配置
['/admin/system/config', 'admin/system/config/index'],
['/admin/system/config/list', 'admin/system/config/list'],
['/admin/system/config/save', 'admin/system/config/save', 'post'],

// 文件上传
['/admin/upload', 'admin/upload/index', 'post'],
['/admin/upload/config', 'admin/upload/config'],

// 数据管理 - 药品援助
['/admin/data/medicine', 'admin/data/medicine/index'],
['/admin/data/medicine/list', 'admin/data/medicine/list'],
['/admin/data/medicine/add', 'admin/data/medicine/add', 'post'],
['/admin/data/medicine/edit', 'admin/data/medicine/edit', 'post'],
['/admin/data/medicine/delete', 'admin/data/medicine/delete', 'post'],

// 系统管理 - 栏目管理
['/admin/system/column', 'admin/system/column/index'],
['/admin/system/column/list', 'admin/system/column/list'],
['/admin/system/column/parents', 'admin/system/column/parents'],
['/admin/system/column/add', 'admin/system/column/add', 'post'],
['/admin/system/column/edit', 'admin/system/column/edit', 'post'],
['/admin/system/column/delete', 'admin/system/column/delete', 'post'],
['/admin/system/column/saveFormConfig', 'admin/system/column/saveFormConfig', 'post'],

// 内容管理 - 轮播图
['/admin/content/banner', 'admin/content/banner/index'],
['/admin/content/banner/list', 'admin/content/banner/list'],
['/admin/content/banner/add', 'admin/content/banner/add', 'post'],
['/admin/content/banner/edit', 'admin/content/banner/edit', 'post'],
['/admin/content/banner/delete', 'admin/content/banner/delete', 'post'],

// 内容管理 - 文字列表
['/admin/content/text', 'admin/content/text/index'],
['/admin/content/text/list', 'admin/content/text/list'],
['/admin/content/text/add', 'admin/content/text/add', 'post'],
['/admin/content/text/edit', 'admin/content/text/edit', 'post'],
['/admin/content/text/delete', 'admin/content/text/delete', 'post'],
['/admin/content/text/batchDelete', 'admin/content/text/batchDelete', 'post'],

// 内容管理 - 图文列表
['/admin/content/article', 'admin/content/article/index'],
['/admin/content/article/list', 'admin/content/article/list'],
['/admin/content/article/detail', 'admin/content/article/detail'],
['/admin/content/article/add', 'admin/content/article/add', 'post'],
['/admin/content/article/edit', 'admin/content/article/edit', 'post'],
['/admin/content/article/delete', 'admin/content/article/delete', 'post'],
['/admin/content/article/batchDelete', 'admin/content/article/batchDelete', 'post'],

// 内容管理 - 图片列表
['/admin/content/image', 'admin/content/image/index'],
['/admin/content/image/list', 'admin/content/image/list'],
['/admin/content/image/add', 'admin/content/image/add', 'post'],
['/admin/content/image/edit', 'admin/content/image/edit', 'post'],
['/admin/content/image/delete', 'admin/content/image/delete', 'post'],

// 内容管理 - 单页
['/admin/content/page', 'admin/content/page/index'],
['/admin/content/page/detail', 'admin/content/page/detail'],
['/admin/content/page/save', 'admin/content/page/save', 'post'],

// 内容管理 - 岗位管理
['/admin/content/job', 'admin/content/job/index'],
['/admin/content/job/list', 'admin/content/job/list'],
['/admin/content/job/add', 'admin/content/job/add', 'post'],
['/admin/content/job/edit', 'admin/content/job/edit', 'post'],
['/admin/content/job/delete', 'admin/content/job/delete', 'post'],

// 内容管理 - 人员列表
['/admin/content/person', 'admin/content/person/index'],
['/admin/content/person/list', 'admin/content/person/list'],
['/admin/content/person/categories', 'admin/content/person/categories'],
['/admin/content/person/add', 'admin/content/person/add', 'post'],
['/admin/content/person/edit', 'admin/content/person/edit', 'post'],
['/admin/content/person/delete', 'admin/content/person/delete', 'post'],

// 内容管理 - 捐赠收支
['/admin/content/donation', 'admin/content/donation/index'],
['/admin/content/donation/stat', 'admin/content/donation/stat'],
['/admin/content/donation/saveStat', 'admin/content/donation/saveStat', 'post'],
['/admin/content/donation/syncStat', 'admin/content/donation/syncStat', 'post'],
['/admin/content/donation/list', 'admin/content/donation/list'],
['/admin/content/donation/export', 'admin/content/donation/export'],
['/admin/content/donation/add', 'admin/content/donation/add', 'post'],
['/admin/content/donation/edit', 'admin/content/donation/edit', 'post'],
['/admin/content/donation/delete', 'admin/content/donation/delete', 'post'],
];

+ 52
- 0
src/controller/admin/auth.js Ver fichero

@@ -0,0 +1,52 @@
const Base = require('../base');

module.exports = class extends Base {
// 登录页面
loginAction() {
return this.display();
}

// 登录接口
async doLoginAction() {
const { username, password, remember } = this.post();

if (!username || !password) {
return this.fail('请输入用户名和密码');
}

// 验证用户
const user = await this.model('admin_user').where({ username, status: 1 }).find();

if (think.isEmpty(user)) {
return this.fail('用户名或密码错误');
}

// 密码验证 (MD5加密比对)
if (user.password !== think.md5(password)) {
return this.fail('用户名或密码错误');
}

// 生成JWT Token
const token = Base.generateToken({
id: user.id,
username: user.username,
role_id: user.role_id
});

// 设置cookie
const maxAge = remember ? Base.JWT_EXPIRES_IN * 1000 : 0;
this.cookie('admin_token', token, {
maxAge,
httpOnly: true,
path: '/'
});

return this.success({ token });
}

// 退出登录
logoutAction() {
this.cookie('admin_token', null);
return this.redirect('/admin/login.html');
}
};

+ 129
- 0
src/controller/admin/content/article.js Ver fichero

@@ -0,0 +1,129 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('currentPage', col || 'article');
return this.display();
}

async listAction() {
const col = this.get('col') || '';
const keyword = this.get('keyword') || '';
const status = this.get('status');
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('article');
const where = { is_deleted: 0 };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}
if (keyword) {
where.title = ['like', `%${keyword}%`];
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}

const list = await model.where(where)
.field('id,column_id,title,summary,cover,category,is_top,is_recommend,sort,status,publish_time,view_count,create_time')
.order('is_top DESC, sort DESC, id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

async detailAction() {
const id = this.get('id');
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
const article = await this.model('article').where({ id, is_deleted: 0 }).find();
return this.json({ code: 0, data: article });
}

async addAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
title: data.title || '',
summary: data.summary || '',
content: data.content || '',
cover: data.cover || '',
category: data.category || '',
is_top: data.is_top ? 1 : 0,
is_recommend: data.is_recommend ? 1 : 0,
sort: data.sort || 0,
status: data.status !== undefined ? data.status : 0,
publish_time: data.publish_time || null,
seo_title: data.seo_title || '',
seo_keywords: data.seo_keywords || '',
seo_description: data.seo_description || ''
};

const id = await this.model('article').add(insertData);
return this.json({ code: 0, msg: '添加成功', data: { id } });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
title: data.title || '',
summary: data.summary || '',
content: data.content || '',
cover: data.cover || '',
category: data.category || '',
is_top: data.is_top ? 1 : 0,
is_recommend: data.is_recommend ? 1 : 0,
sort: data.sort || 0,
status: data.status !== undefined ? data.status : 0,
publish_time: data.publish_time || null,
seo_title: data.seo_title || '',
seo_keywords: data.seo_keywords || '',
seo_description: data.seo_description || ''
};

await this.model('article').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('article').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}

async batchDeleteAction() {
const { ids } = this.post();
if (!ids || !ids.length) {
return this.json({ code: 1, msg: '请选择要删除的记录' });
}
await this.model('article').where({ id: ['in', ids] }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 117
- 0
src/controller/admin/content/banner.js Ver fichero

@@ -0,0 +1,117 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
// 获取栏目信息
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
// 使用栏目key作为currentPage,用于侧边栏选中
this.assign('currentPage', col || 'banner');
return this.display();
}

async listAction() {
const col = this.get('col') || '';
const keyword = this.get('keyword') || '';
const status = this.get('status');
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('banner');
const where = { is_deleted: 0 };

// 根据栏目key查找column_id
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}

if (keyword) {
where.title = ['like', `%${keyword}%`];
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}

const list = await model.where(where)
.order('sort ASC, id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

async addAction() {
const data = this.post();
const col = data.col || '';

// 获取column_id
let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
title: data.title || '',
subtitle: data.subtitle || '',
description: data.description || '',
image: data.image || '',
btn1_text: data.btn1_text || '了解更多',
btn1_link: data.btn1_link || '#',
btn1_show: data.btn1_show ? 1 : 0,
btn2_text: data.btn2_text || '我要捐赠',
btn2_link: data.btn2_link || '#',
btn2_show: data.btn2_show ? 1 : 0,
position: data.position || 'left',
glass: data.glass ? 1 : 0,
sort: data.sort || 1,
status: data.status ? 1 : 0
};

await this.model('banner').add(insertData);
return this.json({ code: 0, msg: '添加成功' });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
title: data.title || '',
subtitle: data.subtitle || '',
description: data.description || '',
image: data.image || '',
btn1_text: data.btn1_text || '',
btn1_link: data.btn1_link || '',
btn1_show: data.btn1_show ? 1 : 0,
btn2_text: data.btn2_text || '',
btn2_link: data.btn2_link || '',
btn2_show: data.btn2_show ? 1 : 0,
position: data.position || 'left',
glass: data.glass ? 1 : 0,
sort: data.sort || 1,
status: data.status ? 1 : 0
};

await this.model('banner').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('banner').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 215
- 0
src/controller/admin/content/donation.js Ver fichero

@@ -0,0 +1,215 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('currentPage', col || 'donation');
return this.display();
}

// 获取统计数据
async statAction() {
const col = this.get('col') || '';
let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

let stat = await this.model('donation_stat').where({ column_id: columnId }).find();
if (!stat || !stat.id) {
stat = { total_income: 0, total_expense: 0, year_income: 0, year_expense: 0, sync_time: null };
}
return this.json({ code: 0, data: stat });
}

// 保存统计数据
async saveStatAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const saveData = {
total_income: data.total_income || 0,
total_expense: data.total_expense || 0,
year_income: data.year_income || 0,
year_expense: data.year_expense || 0
};

const existing = await this.model('donation_stat').where({ column_id: columnId }).find();
if (existing && existing.id) {
await this.model('donation_stat').where({ id: existing.id }).update(saveData);
} else {
saveData.column_id = columnId;
await this.model('donation_stat').add(saveData);
}

return this.json({ code: 0, msg: '保存成功' });
}

// 同步统计数据(从明细计算)
async syncStatAction() {
const col = this.post('col') || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const currentYear = new Date().getFullYear();
const yearStart = `${currentYear}-01-01`;

// 计算累计收入
const totalIncome = await this.model('donation')
.where({ column_id: columnId, type: 'income', is_deleted: 0 })
.sum('amount') || 0;

// 计算累计支出
const totalExpense = await this.model('donation')
.where({ column_id: columnId, type: 'expense', is_deleted: 0 })
.sum('amount') || 0;

// 计算本年收入
const yearIncome = await this.model('donation')
.where({ column_id: columnId, type: 'income', is_deleted: 0, record_date: ['>=', yearStart] })
.sum('amount') || 0;

// 计算本年支出
const yearExpense = await this.model('donation')
.where({ column_id: columnId, type: 'expense', is_deleted: 0, record_date: ['>=', yearStart] })
.sum('amount') || 0;

const saveData = {
total_income: totalIncome,
total_expense: totalExpense,
year_income: yearIncome,
year_expense: yearExpense,
sync_time: new Date()
};

const existing = await this.model('donation_stat').where({ column_id: columnId }).find();
if (existing && existing.id) {
await this.model('donation_stat').where({ id: existing.id }).update(saveData);
} else {
saveData.column_id = columnId;
await this.model('donation_stat').add(saveData);
}

return this.json({ code: 0, msg: '同步成功', data: saveData });
}

async listAction() {
const col = this.get('col') || '';
const type = this.get('type') || 'income';
const keyword = this.get('keyword') || '';
const source = this.get('source') || '';
const status = this.get('status');
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('donation');
const where = { is_deleted: 0, type };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}
if (keyword) {
where.name = ['like', `%${keyword}%`];
}
if (source) {
where.source = source;
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}

const list = await model.where(where)
.order('record_date DESC, id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

// 导出数据
async exportAction() {
const col = this.get('col') || '';
const type = this.get('type') || 'income';

const where = { is_deleted: 0, type };
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}

const list = await this.model('donation').where(where).order('record_date DESC').select();
return this.json({ code: 0, data: list });
}

async addAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
type: data.type || 'income',
name: data.name || '',
amount: data.amount || 0,
purpose: data.purpose || '',
source: data.source || 'manual',
record_date: data.record_date || null,
status: data.status !== undefined ? data.status : 1
};

await this.model('donation').add(insertData);
return this.json({ code: 0, msg: '添加成功' });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
type: data.type || 'income',
name: data.name || '',
amount: data.amount || 0,
purpose: data.purpose || '',
source: data.source || 'manual',
record_date: data.record_date || null,
status: data.status !== undefined ? data.status : 1
};

await this.model('donation').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('donation').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 94
- 0
src/controller/admin/content/image.js Ver fichero

@@ -0,0 +1,94 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('currentPage', col || 'image');
return this.display();
}

async listAction() {
const col = this.get('col') || '';
const keyword = this.get('keyword') || '';
const status = this.get('status');
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 50;

const model = this.model('image');
const where = { is_deleted: 0 };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}
if (keyword) {
where.title = ['like', `%${keyword}%`];
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}

const list = await model.where(where)
.order('sort ASC, id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

async addAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
title: data.title || '',
image: data.image || '',
link: data.link || '',
sort: data.sort || 1,
status: data.status !== undefined ? data.status : 1
};

await this.model('image').add(insertData);
return this.json({ code: 0, msg: '添加成功' });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
title: data.title || '',
image: data.image || '',
link: data.link || '',
sort: data.sort || 1,
status: data.status !== undefined ? data.status : 1
};

await this.model('image').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('image').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 100
- 0
src/controller/admin/content/job.js Ver fichero

@@ -0,0 +1,100 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('currentPage', col || 'job');
return this.display();
}

async listAction() {
const col = this.get('col') || '';
const keyword = this.get('keyword') || '';
const status = this.get('status');
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('job');
const where = { is_deleted: 0 };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}
if (keyword) {
where.name = ['like', `%${keyword}%`];
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}

const list = await model.where(where)
.order('id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

async addAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
name: data.name || '',
department: data.department || '',
location: data.location || '',
count: data.count || 1,
duty: data.duty || '',
requirement: data.requirement || '',
salary: data.salary || '',
status: data.status !== undefined ? data.status : 1
};

await this.model('job').add(insertData);
return this.json({ code: 0, msg: '添加成功' });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
name: data.name || '',
department: data.department || '',
location: data.location || '',
count: data.count || 1,
duty: data.duty || '',
requirement: data.requirement || '',
salary: data.salary || '',
status: data.status !== undefined ? data.status : 1
};

await this.model('job').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('job').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 67
- 0
src/controller/admin/content/page.js Ver fichero

@@ -0,0 +1,67 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
let pageData = null;

if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
if (columnInfo && columnInfo.id) {
pageData = await this.model('page').where({ column_id: columnInfo.id, is_deleted: 0 }).find();
}
}

this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('pageData', pageData || {});
this.assign('currentPage', col || 'page');
return this.display();
}

async detailAction() {
const col = this.get('col') || '';
if (!col) {
return this.json({ code: 1, msg: '参数错误' });
}

const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
if (!column || !column.id) {
return this.json({ code: 1, msg: '栏目不存在' });
}

const pageData = await this.model('page').where({ column_id: column.id, is_deleted: 0 }).find();
return this.json({ code: 0, data: pageData || {} });
}

async saveAction() {
const data = this.post();
const col = data.col || '';

if (!col) {
return this.json({ code: 1, msg: '参数错误' });
}

const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
if (!column || !column.id) {
return this.json({ code: 1, msg: '栏目不存在' });
}

const existing = await this.model('page').where({ column_id: column.id, is_deleted: 0 }).find();
const saveData = {
content: data.content || '',
status: data.status !== undefined ? data.status : 1,
update_by: this.adminUser?.username || '管理员'
};

if (existing && existing.id) {
await this.model('page').where({ id: existing.id }).update(saveData);
} else {
saveData.column_id = column.id;
await this.model('page').add(saveData);
}

return this.json({ code: 0, msg: '保存成功' });
}
};

+ 117
- 0
src/controller/admin/content/person.js Ver fichero

@@ -0,0 +1,117 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('currentPage', col || 'person');
return this.display();
}

async listAction() {
const col = this.get('col') || '';
const keyword = this.get('keyword') || '';
const category = this.get('category') || '';
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 50;

const model = this.model('person');
const where = { is_deleted: 0 };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}
if (keyword) {
where.name = ['like', `%${keyword}%`];
}
if (category) {
where.category = category;
}

const list = await model.where(where)
.order('sort ASC, id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

// 获取分类列表
async categoriesAction() {
const col = this.get('col') || '';
const where = { is_deleted: 0 };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}

const list = await this.model('person')
.where(where)
.field('DISTINCT category')
.select();

const categories = list.map(item => item.category).filter(c => c);
return this.json({ code: 0, data: categories });
}

async addAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
name: data.name || '',
title: data.title || '',
category: data.category || '',
avatar: data.avatar || '',
description: data.description || '',
sort: data.sort || 1,
status: data.status !== undefined ? data.status : 1
};

await this.model('person').add(insertData);
return this.json({ code: 0, msg: '添加成功' });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
name: data.name || '',
title: data.title || '',
category: data.category || '',
avatar: data.avatar || '',
description: data.description || '',
sort: data.sort || 1,
status: data.status !== undefined ? data.status : 1
};

await this.model('person').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('person').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 113
- 0
src/controller/admin/content/text.js Ver fichero

@@ -0,0 +1,113 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
const col = this.get('col') || '';
let columnInfo = null;
if (col) {
columnInfo = await this.model('column').where({ key: col, is_deleted: 0 }).find();
}
this.assign('col', col);
this.assign('columnInfo', columnInfo);
this.assign('currentPage', col || 'text');
return this.display();
}

async listAction() {
const col = this.get('col') || '';
const keyword = this.get('keyword') || '';
const year = this.get('year') || '';
const status = this.get('status');
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('text');
const where = { is_deleted: 0 };

if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
where.column_id = column ? column.id : 0;
}
if (keyword) {
where.title = ['like', `%${keyword}%`];
}
if (year) {
where.year = year;
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}

const list = await model.where(where)
.order('sort ASC, id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

async addAction() {
const data = this.post();
const col = data.col || '';

let columnId = 0;
if (col) {
const column = await this.model('column').where({ key: col, is_deleted: 0 }).find();
columnId = column ? column.id : 0;
}

const insertData = {
column_id: columnId,
title: data.title || '',
category: data.category || '',
year: data.year || new Date().getFullYear().toString(),
file_url: data.file_url || '',
file_name: data.file_name || '',
file_type: data.file_type || 'PDF',
sort: data.sort || 1,
status: data.status !== undefined ? data.status : 1
};

await this.model('text').add(insertData);
return this.json({ code: 0, msg: '添加成功' });
}

async editAction() {
const data = this.post();
if (!data.id) {
return this.json({ code: 1, msg: '参数错误' });
}

const updateData = {
title: data.title || '',
category: data.category || '',
year: data.year || '',
file_url: data.file_url || '',
file_name: data.file_name || '',
file_type: data.file_type || 'PDF',
sort: data.sort || 1,
status: data.status !== undefined ? data.status : 1
};

await this.model('text').where({ id: data.id }).update(updateData);
return this.json({ code: 0, msg: '保存成功' });
}

async deleteAction() {
const { id } = this.post();
if (!id) {
return this.json({ code: 1, msg: '参数错误' });
}
await this.model('text').where({ id }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}

async batchDeleteAction() {
const { ids } = this.post();
if (!ids || !ids.length) {
return this.json({ code: 1, msg: '请选择要删除的记录' });
}
await this.model('text').where({ id: ['in', ids] }).update({ is_deleted: 1 });
return this.json({ code: 0, msg: '删除成功' });
}
};

+ 37
- 0
src/controller/admin/dashboard.js Ver fichero

@@ -0,0 +1,37 @@
const Base = require('../base');

module.exports = class extends Base {
async indexAction() {
// 页面信息
this.assign('currentPage', 'dashboard');
this.assign('pageTitle', '控制台');

// 统计数据(后续从数据库获取)
this.assign('stats', {
contentCount: 128,
pendingCount: 5,
visitCount: '1,256',
donationTotal: '178.2M'
});

// 最近更新列表(后续从数据库获取)
this.assign('recentList', []);

// 待办事项(后续从数据库获取)
this.assign('todoList', []);

// 动态栏目菜单(后续从接口获取)
this.assign('columnMenus', await this.getColumnMenus());

// 当前登录用户
this.assign('adminUser', this.adminUser || {});

return this.display();
}

// 获取动态栏目菜单(暂时返回空,后续从数据库获取)
async getColumnMenus() {
// TODO: 从数据库获取栏目配置
return [];
}
};

+ 94
- 0
src/controller/admin/data/medicine.js Ver fichero

@@ -0,0 +1,94 @@
const Base = require('../../base.js');

module.exports = class extends Base {
// 列表页
async indexAction() {
// 设置当前页面标识
this.assign('currentPage', 'medicine');
// 获取年份列表
const years = [];
const currentYear = new Date().getFullYear();
for (let i = currentYear; i >= currentYear - 5; i--) {
years.push(i);
}
this.assign('years', years);
return this.display();
}

// 列表数据
async listAction() {
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 10;
const keyword = this.get('keyword') || '';
const year = this.get('year') || '';
const status = this.get('status');

const model = this.model('medicine');
const where = { is_deleted: 0 };

if (keyword) {
where['name|person'] = ['like', `%${keyword}%`];
}
if (year) {
where.year = year;
}
if (status !== '' && status !== undefined) {
where.status = status;
}

const list = await model.where(where)
.order('id DESC')
.page(page, pageSize)
.countSelect();

return this.success(list);
}

// 新增
async addAction() {
const data = this.post();
const model = this.model('medicine');

// 自动设置年度
if (data.distribute_date) {
data.year = new Date(data.distribute_date).getFullYear();
} else {
data.year = new Date().getFullYear();
}

const id = await model.add(data);
return this.success({ id });
}

// 编辑
async editAction() {
const data = this.post();
const id = data.id;
if (!id) {
return this.fail('缺少ID');
}

const model = this.model('medicine');

// 自动设置年度
if (data.distribute_date) {
data.year = new Date(data.distribute_date).getFullYear();
}

delete data.id;
await model.where({ id }).update(data);
return this.success();
}

// 删除
async deleteAction() {
const id = this.post('id');
if (!id) {
return this.fail('缺少ID');
}

await this.model('medicine').where({ id }).update({ is_deleted: 1 });
return this.success();
}
};

+ 136
- 0
src/controller/admin/system/column.js Ver fichero

@@ -0,0 +1,136 @@
const Base = require('../../base');

module.exports = class extends Base {
// 页面
async indexAction() {
this.assign('currentPage', 'column');
return this.display();
}

// 获取树形列表
async listAction() {
const model = this.model('column');
const list = await model.where({ is_deleted: 0 }).order('sort ASC, id ASC').select();

// 构建树形结构
const tree = [];
const map = {};
list.forEach(item => {
map[item.id] = { ...item, children: [] };
});
list.forEach(item => {
if (item.parent_id === 0) {
tree.push(map[item.id]);
} else if (map[item.parent_id]) {
map[item.parent_id].children.push(map[item.id]);
}
});

return this.success(tree);
}

// 获取所有顶级栏目(用于下拉选择)
async parentsAction() {
const list = await this.model('column')
.where({ parent_id: 0, is_deleted: 0 })
.order('sort ASC')
.select();
return this.success(list);
}

// 新增
async addAction() {
const data = this.post();
const model = this.model('column');

// 检查key是否重复
if (data.key) {
const exists = await model.where({ key: data.key, is_deleted: 0 }).find();
if (!think.isEmpty(exists)) {
return this.fail('栏目标识已存在');
}
}

const id = await model.add({
parent_id: data.parent_id || 0,
name: data.name,
key: data.key || '',
icon: data.icon || '',
type: data.type || '',
is_single_page: data.is_single_page || 0,
sort: data.sort || 1,
visible: data.visible !== undefined ? data.visible : 1,
link: data.link || '',
seo_title: data.seo_title || '',
seo_keywords: data.seo_keywords || '',
seo_description: data.seo_description || '',
slug: data.slug || ''
});
return this.success({ id });
}

// 编辑
async editAction() {
const data = this.post();
const id = data.id;
if (!id) {
return this.fail('缺少ID');
}

// 检查key是否重复(排除自己)
if (data.key) {
const exists = await this.model('column').where({ key: data.key, is_deleted: 0, id: ['!=', id] }).find();
if (!think.isEmpty(exists)) {
return this.fail('栏目标识已存在');
}
}

await this.model('column').where({ id }).update({
parent_id: data.parent_id || 0,
name: data.name,
key: data.key || '',
icon: data.icon || '',
type: data.type || '',
is_single_page: data.is_single_page || 0,
sort: data.sort || 1,
visible: data.visible !== undefined ? data.visible : 1,
link: data.link || '',
seo_title: data.seo_title || '',
seo_keywords: data.seo_keywords || '',
seo_description: data.seo_description || '',
slug: data.slug || ''
});
return this.success();
}

// 删除
async deleteAction() {
const id = this.post('id');
if (!id) {
return this.fail('缺少ID');
}

// 检查是否有子栏目
const children = await this.model('column').where({ parent_id: id, is_deleted: 0 }).count();
if (children > 0) {
return this.fail('请先删除子栏目');
}

await this.model('column').where({ id }).update({ is_deleted: 1 });
return this.success();
}

// 保存表单配置
async saveFormConfigAction() {
const id = this.post('id');
const formConfig = this.post('form_config');
if (!id) {
return this.fail('缺少ID');
}

await this.model('column').where({ id }).update({
form_config: JSON.stringify(formConfig)
});
return this.success();
}
};

+ 46
- 0
src/controller/admin/system/config.js Ver fichero

@@ -0,0 +1,46 @@
const Base = require('../../base');

module.exports = class extends Base {
/**
* 配置页面
*/
async indexAction() {
const configModel = this.model('config');
const configs = await configModel.getAllGrouped();

this.assign('configs', configs);
this.assign('currentPage', 'config');
this.assign('pageTitle', '网站配置');
return this.display();
}

/**
* 获取配置列表(API)
*/
async listAction() {
const configModel = this.model('config');
const configs = await configModel.getAllGrouped();
return this.success(configs);
}

/**
* 保存配置
*/
async saveAction() {
const { group, data } = this.post();

if (!group || !data) {
return this.fail('参数错误');
}

const validGroups = ['basic', 'seo', 'contact', 'social'];
if (!validGroups.includes(group)) {
return this.fail('无效的配置分组');
}

const configModel = this.model('config');
await configModel.saveGroup(group, data);

return this.success(null, '保存成功');
}
};

+ 305
- 0
src/controller/admin/system/role.js Ver fichero

@@ -0,0 +1,305 @@
const Base = require('../../base');

module.exports = class extends Base {
// 角色列表页面
async indexAction() {
this.assign('currentPage', 'sys-role');
this.assign('pageTitle', '角色权限');
this.assign('breadcrumb', [
{ name: '系统管理', url: '#' },
{ name: '角色权限' }
]);

// 权限树数据
this.assign('permissionTree', this.getPermissionTree());

this.assign('adminUser', this.adminUser || {});
this.assign('columnMenus', []);

return this.display();
}

// 获取角色列表接口
async listAction() {
const { keyword, page = 1, pageSize = 20 } = this.get();

const where = { is_deleted: 0 };

if (keyword) {
where.name = ['like', `%${keyword}%`];
}

const list = await this.model('admin_role')
.where(where)
.order('sort ASC, id ASC')
.page(page, pageSize)
.countSelect();

return this.success(list);
}

// 获取所有角色(下拉用)
async allAction() {
const list = await this.model('admin_role')
.where({ is_deleted: 0, status: 1 })
.order('sort ASC')
.select();

return this.success(list);
}

// 获取单个角色
async detailAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

return this.success(role);
}

// 新增角色
async addAction() {
const { name, code, description, is_default = 0, sort = 0 } = this.post();

if (!name) {
return this.fail('角色名称不能为空');
}

// 检查名称是否存在
const exist = await this.model('admin_role')
.where({ name, is_deleted: 0 })
.find();

if (!think.isEmpty(exist)) {
return this.fail('角色名称已存在');
}

const data = {
name,
code: code || '',
description: description || '',
permissions: JSON.stringify([]),
is_default: is_default ? 1 : 0,
sort: sort || 0,
create_by: this.adminUser?.id || 0
};

const id = await this.model('admin_role').add(data);
return this.success({ id });
}

// 编辑角色
async editAction() {
const { id, name, code, description, is_default, sort } = this.post();

if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

// 默认角色不能编辑
if (role.is_default === 1) {
return this.fail('默认角色不能编辑');
}

// 检查名称是否重复
if (name && name !== role.name) {
const exist = await this.model('admin_role')
.where({ name, is_deleted: 0, id: ['!=', id] })
.find();

if (!think.isEmpty(exist)) {
return this.fail('角色名称已存在');
}
}

const data = {
name: name || role.name,
code: code !== undefined ? code : role.code,
description: description !== undefined ? description : role.description,
is_default: is_default !== undefined ? (is_default ? 1 : 0) : role.is_default,
sort: sort !== undefined ? sort : role.sort,
update_by: this.adminUser?.id || 0
};

await this.model('admin_role').where({ id }).update(data);
return this.success();
}

// 分配权限
async assignPermissionsAction() {
const { id, permissions } = this.post();

if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

await this.model('admin_role')
.where({ id })
.update({
permissions: JSON.stringify(permissions || []),
update_by: this.adminUser?.id || 0
});

return this.success();
}

// 删除角色
async deleteAction() {
const { id } = this.post();

if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

// 默认角色不能删除
if (role.is_default === 1) {
return this.fail('默认角色不能删除');
}

// 检查是否有用户使用该角色
const userCount = await this.model('admin_user')
.where({ role_id: id, is_deleted: 0 })
.count();

if (userCount > 0) {
return this.fail(`该角色下有 ${userCount} 个用户,无法删除`);
}

await this.model('admin_role')
.where({ id })
.update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

return this.success();
}

// 批量删除
async batchDeleteAction() {
const { ids } = this.post();

if (!ids || !ids.length) return this.fail('参数错误');

// 检查是否包含默认角色
const defaultCount = await this.model('admin_role')
.where({ id: ['in', ids], is_default: 1, is_deleted: 0 })
.count();

if (defaultCount > 0) {
return this.fail('选中的角色包含默认角色,无法删除');
}

// 检查是否有用户使用这些角色
const userCount = await this.model('admin_user')
.where({ role_id: ['in', ids], is_deleted: 0 })
.count();

if (userCount > 0) {
return this.fail(`选中的角色下有 ${userCount} 个用户,无法删除`);
}

await this.model('admin_role')
.where({ id: ['in', ids] })
.update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

return this.success();
}

// 获取权限树配置
getPermissionTree() {
return [
{
name: '系统管理',
key: 'system',
children: [
{
name: '用户管理',
key: 'system:user',
children: ['查询', '新增', '编辑', '删除', '重置密码']
},
{
name: '角色管理',
key: 'system:role',
children: ['查询', '新增', '编辑', '删除', '分配权限']
},
{
name: '操作日志',
key: 'system:log',
children: ['查询', '删除', '导出']
}
]
},
{
name: '内容管理',
key: 'content',
children: [
{
name: '文章管理',
key: 'content:article',
children: ['查询', '新增', '编辑', '删除', '审核', '发布']
},
{
name: '图片管理',
key: 'content:image',
children: ['查询', '新增', '编辑', '删除', '排序']
},
{
name: '单页管理',
key: 'content:page',
children: ['查询', '编辑']
}
]
},
{
name: '数据管理',
key: 'data',
children: [
{
name: '药品援助',
key: 'data:medicine',
children: ['查询', '新增', '编辑', '删除', '导出']
}
]
},
{
name: '栏目管理',
key: 'column',
children: ['查询', '新增', '编辑', '删除', '排序']
},
{
name: '网站配置',
key: 'site',
children: ['基础设置', 'SEO设置', '联系信息']
}
];
}
};

+ 221
- 0
src/controller/admin/system/user.js Ver fichero

@@ -0,0 +1,221 @@
const Base = require('../../base');

module.exports = class extends Base {
// 用户列表页面
async indexAction() {
this.assign('currentPage', 'sys-user');
this.assign('pageTitle', '用户管理');
this.assign('breadcrumb', [
{ name: '系统管理', url: '#' },
{ name: '用户管理' }
]);

// 获取角色列表
const roles = await this.model('admin_role')
.where({ is_deleted: 0 })
.select();
this.assign('roles', roles);

// 当前登录用户
this.assign('adminUser', this.adminUser || {});
this.assign('columnMenus', []);

return this.display();
}

// 获取用户列表接口
async listAction() {
const { keyword, role_id, status, page = 1, pageSize = 10 } = this.get();

const where = { is_deleted: 0 };

if (keyword) {
where._complex = {
username: ['like', `%${keyword}%`],
nickname: ['like', `%${keyword}%`],
_logic: 'or'
};
}

if (role_id) {
where.role_id = role_id;
}

if (status !== undefined && status !== '') {
where.status = status;
}

const list = await this.model('admin_user')
.where(where)
.order('id ASC')
.page(page, pageSize)
.countSelect();

// 获取角色名称
const roleIds = [...new Set(list.data.map(item => item.role_id))];
const roles = roleIds.length ? await this.model('admin_role')
.where({ id: ['in', roleIds] })
.select() : [];
const roleMap = {};
roles.forEach(r => roleMap[r.id] = r.name);

list.data.forEach(item => {
item.role_name = roleMap[item.role_id] || '-';
delete item.password;
});

return this.success(list);
}

// 获取单个用户
async detailAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

delete user.password;
return this.success(user);
}

// 新增用户
async addAction() {
const { username, password, nickname, email, phone, role_id, status = 1 } = this.post();

if (!username || !password) {
return this.fail('用户名和密码不能为空');
}

// 检查用户名是否存在
const exist = await this.model('admin_user')
.where({ username, is_deleted: 0 })
.find();

if (!think.isEmpty(exist)) {
return this.fail('用户名已存在');
}

const data = {
username,
password: think.md5(password),
nickname: nickname || '',
email: email || '',
phone: phone || '',
role_id: role_id || 0,
status,
create_by: this.adminUser?.id || 0
};

const id = await this.model('admin_user').add(data);
return this.success({ id });
}

// 编辑用户
async editAction() {
const { id, nickname, email, phone, role_id, status } = this.post();

if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

const data = {
nickname: nickname || user.nickname,
email: email || user.email,
phone: phone || user.phone,
role_id: role_id !== undefined ? role_id : user.role_id,
status: status !== undefined ? status : user.status,
update_by: this.adminUser?.id || 0
};

await this.model('admin_user').where({ id }).update(data);
return this.success();
}

// 重置密码
async resetPasswordAction() {
const { id, password } = this.post();

if (!id || !password) {
return this.fail('参数错误');
}

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

await this.model('admin_user')
.where({ id })
.update({
password: think.md5(password),
update_by: this.adminUser?.id || 0
});

return this.success();
}

// 删除用户(软删除)
async deleteAction() {
const { id } = this.post();

if (!id) return this.fail('参数错误');

// 不能删除自己
if (id == this.adminUser?.id) {
return this.fail('不能删除当前登录用户');
}

await this.model('admin_user')
.where({ id })
.update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

return this.success();
}

// 切换状态
async toggleStatusAction() {
const { id } = this.post();

if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

// 不能禁用自己
if (id == this.adminUser?.id) {
return this.fail('不能禁用当前登录用户');
}

await this.model('admin_user')
.where({ id })
.update({
status: user.status === 1 ? 0 : 1,
update_by: this.adminUser?.id || 0
});

return this.success();
}
};

+ 306
- 0
src/controller/admin/upload.js Ver fichero

@@ -0,0 +1,306 @@
const Base = require('../base.js');
const COS = require('cos-nodejs-sdk-v5');
const fs = require('fs');
const path = require('path');
const cosConfig = require('../../config/cos.js');

module.exports = class extends Base {
/**
* 文件上传
* POST /admin/upload
*/
async indexAction() {
// 辅助函数:延迟
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// 获取文件(处理 ThinkJS 文件获取延迟问题)
let file = this.file('file');
let filePath = file ? file.path : '';

if (!filePath) {
await sleep(1000);
file = this.file('file');
filePath = file ? file.path : '';
if (!filePath) {
await sleep(1000);
file = this.file('file');
filePath = file ? file.path : '';
if (!filePath) {
return this.fail('请选择要上传的文件');
}
}
}

try {
// 获取文件信息
const originalName = file.name;
const fileSize = file.size;
const mimeType = file.type;

// 验证文件类型和大小
const validation = this.validateFile(mimeType, fileSize);
if (!validation.valid) {
// 删除临时文件
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return this.fail(validation.message);
}

// 生成存储路径(按日期分类)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const dateFolder = `${year}/${month}/${day}`;

// 生成文件名(保留原文件名,添加时间戳避免重复)
const timestamp = Date.now();
const ext = path.extname(originalName);
const baseName = path.basename(originalName, ext);
const fileName = `${baseName}_${timestamp}${ext}`;
const cosKey = `uploads/${dateFolder}/${fileName}`;

// 如果是图片,进行压缩处理
let uploadFilePath = filePath;
let actualFileSize = fileSize;

if (this.isImage(mimeType)) {
// 检查文件大小是否需要压缩
if (fileSize < cosConfig.imageCompress.minSize) {
think.logger.info(`图片小于 ${cosConfig.imageCompress.minSize / 1024}KB,跳过压缩`);
} else {
try {
const compressResult = await this.compressImage(filePath, mimeType);
// 比较压缩后的文件大小,如果压缩后更大则使用原图
if (compressResult.compressedPath && compressResult.compressedSize < fileSize) {
uploadFilePath = compressResult.compressedPath;
actualFileSize = compressResult.compressedSize;
think.logger.info(`图片压缩成功: ${fileSize} -> ${compressResult.compressedSize} (节省 ${((1 - compressResult.compressedSize / fileSize) * 100).toFixed(2)}%)`);
} else {
think.logger.info('压缩后文件更大,使用原图上传');
// 删除压缩后的文件
if (compressResult.compressedPath && fs.existsSync(compressResult.compressedPath)) {
try {
fs.unlinkSync(compressResult.compressedPath);
} catch (e) {
think.logger.warn('删除压缩文件失败:', e.message);
}
}
}
} catch (compressError) {
think.logger.warn('图片压缩失败,使用原图上传:', compressError);
// 压缩失败时使用原图
}
}
}

// 初始化 COS 客户端
const cos = new COS({
SecretId: cosConfig.secretId,
SecretKey: cosConfig.secretKey
});

// 上传文件到 COS
const uploadResult = await new Promise((resolve, reject) => {
cos.putObject({
Bucket: cosConfig.bucket,
Region: cosConfig.region,
Key: cosKey,
Body: fs.createReadStream(uploadFilePath),
ContentType: mimeType,
onProgress: (progressData) => {
// 可以在这里处理上传进度
}
}, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});

// 清理临时文件(延迟删除,避免 Windows 文件句柄未释放问题)
setTimeout(() => {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (e) {
think.logger.warn('删除临时文件失败:', e.message);
}
try {
if (uploadFilePath !== filePath && fs.existsSync(uploadFilePath)) {
fs.unlinkSync(uploadFilePath);
}
} catch (e) {
think.logger.warn('删除压缩文件失败:', e.message);
}
}, 1000);

// 返回结果
if (uploadResult.statusCode === 200) {
const bucketUrl = `https://${uploadResult.Location}`;
const cdnUrl = bucketUrl.replace(cosConfig.bucketUrl, cosConfig.cdnUrl);

return this.success({
url: cdnUrl,
name: originalName,
bucket_url: bucketUrl,
file_name: originalName,
file_size: actualFileSize,
mime_type: mimeType
});
} else {
return this.fail('文件上传失败');
}
} catch (error) {
think.logger.error('文件上传失败:', error);
// 清理临时文件
if (file && file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (cleanError) {
think.logger.error('清理临时文件失败:', cleanError);
}
}
return this.fail('文件上传失败: ' + error.message);
}
}

/**
* 验证文件类型和大小
*/
validateFile(mimeType, fileSize) {
const allAllowedTypes = [
...cosConfig.allowedImageTypes,
...cosConfig.allowedDocTypes,
...cosConfig.allowedVideoTypes
];

// 验证文件类型
if (!allAllowedTypes.includes(mimeType)) {
return {
valid: false,
message: '不支持的文件类型'
};
}

// 验证文件大小
let maxSize = 0;
if (cosConfig.allowedImageTypes.includes(mimeType)) {
maxSize = cosConfig.maxImageSize;
} else if (cosConfig.allowedDocTypes.includes(mimeType)) {
maxSize = cosConfig.maxDocSize;
} else if (cosConfig.allowedVideoTypes.includes(mimeType)) {
maxSize = cosConfig.maxVideoSize;
}

if (fileSize > maxSize) {
const maxSizeMB = (maxSize / 1024 / 1024).toFixed(0);
return {
valid: false,
message: `文件大小不能超过 ${maxSizeMB}MB`
};
}

return { valid: true };
}

/**
* 判断是否为图片
*/
isImage(mimeType) {
return cosConfig.allowedImageTypes.includes(mimeType);
}

/**
* 压缩图片
* 返回压缩后的文件路径和文件大小
*/
async compressImage(filePath, mimeType) {
// 尝试加载 sharp,如果没有安装则跳过压缩
let sharp;
try {
sharp = require('sharp');
} catch (e) {
think.logger.warn('sharp 模块未安装,跳过图片压缩');
return { compressedPath: null, compressedSize: 0 };
}

const compressedPath = filePath + '_compressed';
const { quality, maxWidth, maxHeight } = cosConfig.imageCompress;

try {
const image = sharp(filePath);
const metadata = await image.metadata();

// 计算缩放尺寸
let resizeOptions = {};
if (metadata.width > maxWidth || metadata.height > maxHeight) {
resizeOptions = {
width: maxWidth,
height: maxHeight,
fit: 'inside',
withoutEnlargement: true
};
}

// 根据图片类型选择压缩格式
let outputOptions = {};
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
outputOptions = { quality };
await image.resize(resizeOptions).jpeg(outputOptions).toFile(compressedPath);
} else if (mimeType === 'image/png') {
outputOptions = { quality, compressionLevel: 9 };
await image.resize(resizeOptions).png(outputOptions).toFile(compressedPath);
} else if (mimeType === 'image/webp') {
outputOptions = { quality };
await image.resize(resizeOptions).webp(outputOptions).toFile(compressedPath);
} else {
// 其他格式不压缩
return {
compressedPath: null,
compressedSize: 0
};
}

// 获取压缩后的文件大小
const stats = fs.statSync(compressedPath);
const compressedSize = stats.size;

return {
compressedPath,
compressedSize
};
} catch (error) {
think.logger.error('图片压缩失败:', error);
throw error;
}
}

/**
* 获取上传配置信息
* GET /admin/upload/config
*/
async configAction() {
try {
return this.success({
allowedTypes: {
images: cosConfig.allowedImageTypes,
documents: cosConfig.allowedDocTypes,
videos: cosConfig.allowedVideoTypes
},
maxSize: {
image: cosConfig.maxImageSize,
document: cosConfig.maxDocSize,
video: cosConfig.maxVideoSize
}
});
} catch (error) {
think.logger.error('获取上传配置失败:', error);
return this.fail('获取上传配置失败');
}
}
};

+ 137
- 0
src/controller/base.js Ver fichero

@@ -0,0 +1,137 @@
const jwt = require('jsonwebtoken');

// JWT配置
const JWT_SECRET = think.md5('yzx');
const JWT_EXPIRES_IN = 24 * 60 * 60;

// 白名单配置
const WHITE_LIST = [
// 后台登录相关
'/admin/login',
'/admin/auth/login',
'/admin/logout',
];

// 前台路由前缀(全部放行)
const PUBLIC_PREFIXES = [
'/index',
'/api', // 如果有前台API
];

// 内容类型对应的URL
const TYPE_URL_MAP = {
'article': '/admin/content/article.html',
'image': '/admin/content/image.html',
'text': '/admin/content/text.html',
'page': '/admin/content/page.html',
'person': '/admin/content/person.html',
'form': '/admin/content/form.html',
'donation': '/admin/content/donation.html',
'job': '/admin/content/job.html',
'banner': '/admin/content/banner.html'
};

module.exports = class extends think.Controller {
async __before() {
const path = this.ctx.path;

// 前台路由全部放行
for (const prefix of PUBLIC_PREFIXES) {
if (path.startsWith(prefix) || path === '/') {
return;
}
}

// 白名单放行
if (WHITE_LIST.includes(path)) {
return;
}

// 后台路由需要验证JWT
if (path.startsWith('/admin')) {
const token = this.cookie('admin_token') || this.header('authorization')?.replace('Bearer ', '');

if (!token) {
return this.handleUnauthorized();
}

try {
const decoded = jwt.verify(token, JWT_SECRET);
this.adminUser = decoded;
// 加载栏目菜单数据(非Ajax请求时)
if (!this.isAjax()) {
await this.loadColumnMenus();
}
} catch (err) {
return this.handleUnauthorized();
}
}
}

// 加载栏目菜单
async loadColumnMenus() {
const model = this.model('column');
const list = await model.where({ is_deleted: 0 }).order('sort ASC, id ASC').select();
// 获取当前col参数,用于判断菜单选中
const currentCol = this.get('col') || '';
// 构建树形结构
const menus = [];
const map = {};
let menuGroup = ''; // 当前选中的父级菜单key
list.forEach(item => {
map[item.id] = { ...item, children: [] };
});
list.forEach(item => {
if (item.parent_id === 0) {
// 一级栏目
menus.push(map[item.id]);
} else if (map[item.parent_id]) {
// 二级栏目
const child = map[item.id];
child.url = TYPE_URL_MAP[item.type] ? `${TYPE_URL_MAP[item.type]}?col=${item.key}` : '#';
map[item.parent_id].children.push(child);
// 如果当前col匹配,设置父级菜单key
if (currentCol && item.key === currentCol) {
menuGroup = map[item.parent_id].key;
}
}
});
this.assign('columnMenus', menus);
this.assign('menuGroup', menuGroup);
}

// 未授权处理
handleUnauthorized() {
if (this.isAjax()) {
return this.fail(401, '请先登录');
}
return this.redirect('/admin/login.html');
}

// 判断是否Ajax请求
isAjax() {
return this.header('x-requested-with') === 'XMLHttpRequest' ||
this.header('accept')?.includes('application/json');
}

// 生成JWT Token
static generateToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}

// 获取JWT配置
static get JWT_SECRET() {
return JWT_SECRET;
}

static get JWT_EXPIRES_IN() {
return JWT_EXPIRES_IN;
}
};

+ 117
- 0
src/controller/index.js Ver fichero

@@ -0,0 +1,117 @@
const Base = require('./base.js');

module.exports = class extends Base {
async indexAction() {
// 获取栏目配置
const columns = await this.model('column').where({ is_deleted: 0, visible: 1 }).order('sort ASC').select();
// 构建导航菜单树
const navMenus = this.buildNavTree(columns);
// 获取首页栏目ID映射
const homeColumn = columns.find(c => c.key === 'home');
const homeChildren = columns.filter(c => c.parent_id === homeColumn?.id);
const colMap = {};
homeChildren.forEach(c => { colMap[c.key] = c.id; });
// 获取Banner数据(根据栏目key查找column_id,或者column_id=0表示通用)
const bannerColId = colMap['home-banner'] || 0;
const banners = await this.model('banner').where({
status: 1,
is_deleted: 0
}).where('column_id = ' + bannerColId + ' OR column_id = 0').order('sort ASC').select();
// 获取捐赠统计
const dataColId = colMap['home-data'] || 0;
let donationStat = await this.model('donation_stat').where({
column_id: dataColId
}).find();
if (!donationStat || !donationStat.id) {
donationStat = await this.model('donation_stat').where({ column_id: 0 }).find();
}
donationStat = donationStat || { total_income: 0, total_expense: 0, year_income: 0 };
// 获取捐赠明细(收入/支出各取最新10条)
const donationIncome = await this.model('donation').where({
type: 'income',
status: 1,
is_deleted: 0
}).where('column_id = ' + dataColId + ' OR column_id = 0').order('record_date DESC').limit(10).select();
const donationExpense = await this.model('donation').where({
type: 'expense',
status: 1,
is_deleted: 0
}).where('column_id = ' + dataColId + ' OR column_id = 0').order('record_date DESC').limit(10).select();
// 获取药品援助数据(只显示已发放的)
const medicines = await this.model('medicine').where({
status: 1,
is_deleted: 0
}).order('distribute_date DESC').limit(20).select();
// 获取公益项目
const projectColId = colMap['home-project'] || 0;
const projects = await this.model('article').where({
status: 1,
is_deleted: 0
}).where('column_id = ' + projectColId + ' OR column_id = 0').order('sort DESC, publish_time DESC').limit(5).select();
// 获取新闻动态
const newsColId = colMap['home-news'] || 0;
const newsList = await this.model('article').where({
status: 1,
is_deleted: 0
}).where('column_id = ' + newsColId + ' OR column_id = 0').order('is_top DESC, publish_time DESC').limit(5).select();
// 获取合作伙伴
const partnerColId = colMap['home-partner'] || 0;
const partners = await this.model('image').where({
status: 1,
is_deleted: 0
}).where('column_id = ' + partnerColId + ' OR column_id = 0').order('sort ASC').select();
// 获取网站配置
const configList = await this.model('config').select();
const siteConfig = {};
configList.forEach(c => {
siteConfig[c.key] = c.value;
});
this.assign({
navMenus,
banners,
donationStat,
donationIncome,
donationExpense,
medicines,
projects,
newsList,
partners,
siteConfig,
now: new Date()
});
return this.display();
}
// 构建导航树
buildNavTree(columns) {
const tree = [];
const map = {};
columns.forEach(item => {
map[item.id] = { ...item, children: [] };
});
columns.forEach(item => {
if (item.parent_id === 0) {
tree.push(map[item.id]);
} else if (map[item.parent_id]) {
map[item.parent_id].children.push(map[item.id]);
}
});
return tree;
}
};

+ 5
- 0
src/logic/index.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Logic {
indexAction() {

}
};

+ 5
- 0
src/model/article.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_article';
}
};

+ 5
- 0
src/model/banner.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_banner';
}
};

+ 5
- 0
src/model/column.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_column';
}
};

+ 56
- 0
src/model/config.js Ver fichero

@@ -0,0 +1,56 @@
module.exports = class extends think.Model {
/**
* 获取所有配置(按分组)
*/
async getAllGrouped() {
const list = await this.order('`group` ASC, sort ASC').select();
const grouped = {};
for (const item of list) {
if (!grouped[item.group]) {
grouped[item.group] = [];
}
grouped[item.group].push(item);
}
return grouped;
}

/**
* 获取指定分组的配置
*/
async getByGroup(group) {
return this.where({ group }).order('sort ASC').select();
}

/**
* 获取单个配置值
*/
async getValue(group, key) {
const item = await this.where({ group, key }).find();
return item ? item.value : null;
}

/**
* 批量保存配置
*/
async saveGroup(group, data) {
for (const key in data) {
await this.where({ group, key }).update({ value: data[key] });
}
return true;
}

/**
* 获取前台需要的配置(转为对象形式)
*/
async getForFrontend() {
const list = await this.select();
const config = {};
for (const item of list) {
if (!config[item.group]) {
config[item.group] = {};
}
config[item.group][item.key] = item.value;
}
return config;
}
};

+ 5
- 0
src/model/donation.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_donation';
}
};

+ 5
- 0
src/model/donation_stat.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_donation_stat';
}
};

+ 5
- 0
src/model/image.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_image';
}
};

+ 3
- 0
src/model/index.js Ver fichero

@@ -0,0 +1,3 @@
module.exports = class extends think.Model {

};

+ 5
- 0
src/model/job.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_job';
}
};

+ 5
- 0
src/model/medicine.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_medicine';
}
};

+ 5
- 0
src/model/page.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_page';
}
};

+ 5
- 0
src/model/person.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_person';
}
};

+ 5
- 0
src/model/text.js Ver fichero

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'pap_text';
}
};

+ 7
- 0
test/index.js Ver fichero

@@ -0,0 +1,7 @@
const test = require('ava');
const path = require('path');
require(path.join(process.cwd(), 'production.js'));

test('first test', t => {
const indexModel = think.model('index');
})

+ 219
- 0
view/admin/auth_login.html Ver fichero

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 北京维康慈善基金会管理系统</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
:root{
--primary:#E8751A;
--primary-dark:#C96012;
--primary-light:#F5A623;
--blue:#1A3550;
--white:#fff;
--bg:#f0f2f5;
--text:#303133;
--text-secondary:#909399;
--border:#dcdfe6;
--danger:#f56c6c;
--font-cn:"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
--font-en:"Roboto",sans-serif;
}
html,body{height:100%;font-family:var(--font-cn);color:var(--text);}

.login-wrapper{display:flex;height:100vh;}

/* 左侧品牌区 */
.login-left{
flex:0 0 50%;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:linear-gradient(135deg,var(--primary) 0%,var(--primary-dark) 100%);
position:relative;overflow:hidden;
}
.login-left::before{
content:"";position:absolute;width:600px;height:600px;border-radius:50%;
background:rgba(255,255,255,.06);top:-120px;right:-180px;
}
.login-left::after{
content:"";position:absolute;width:400px;height:400px;border-radius:50%;
background:rgba(255,255,255,.04);bottom:-80px;left:-100px;
}
.brand{position:relative;z-index:1;text-align:center;color:var(--white);padding:0 60px;}
.brand-logo{width:80px;height:80px;background:rgba(255,255,255,.15);border-radius:20px;
display:flex;align-items:center;justify-content:center;margin:0 auto 28px;backdrop-filter:blur(10px);}
.brand-logo svg{width:44px;height:44px;}
.brand h1{font-size:28px;font-weight:700;margin-bottom:12px;letter-spacing:1px;}
.brand p{font-size:14px;opacity:.75;line-height:1.8;max-width:360px;}

/* 右侧表单区 */
.login-right{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg);}
.login-box{
width:400px;background:var(--white);border-radius:12px;
box-shadow:0 2px 12px rgba(0,0,0,.08);padding:48px 40px;
}
.login-box h2{font-size:22px;font-weight:600;color:var(--text);margin-bottom:6px;}
.login-box .subtitle{font-size:13px;color:var(--text-secondary);margin-bottom:32px;}

/* 表单 */
.form-item{margin-bottom:22px;}
.form-item label{display:block;font-size:13px;color:var(--text);font-weight:500;margin-bottom:8px;}
.input-wrap{
position:relative;display:flex;align-items:center;
border:1px solid var(--border);border-radius:8px;transition:.2s;background:var(--white);
}
.input-wrap:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(232,117,26,.1);}
.input-wrap .icon{position:absolute;left:12px;color:var(--text-secondary);display:flex;align-items:center;}
.input-wrap .icon svg{width:18px;height:18px;}
.input-wrap input{
width:100%;border:none;outline:none;padding:11px 12px 11px 40px;
font-size:14px;font-family:var(--font-cn);color:var(--text);background:transparent;border-radius:8px;
}
.input-wrap input::placeholder{color:#c0c4cc;}
.input-wrap .toggle-pwd{
position:absolute;right:12px;cursor:pointer;color:var(--text-secondary);
display:flex;align-items:center;background:none;border:none;padding:0;
}
.input-wrap .toggle-pwd:hover{color:var(--primary);}
.input-wrap .toggle-pwd svg{width:18px;height:18px;}

.form-options{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px;}
.remember{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:var(--text-secondary);}
.remember input[type="checkbox"]{width:16px;height:16px;accent-color:var(--primary);cursor:pointer;}

.btn-login{
width:100%;padding:12px 0;border:none;border-radius:8px;
background:linear-gradient(135deg,var(--primary),var(--primary-dark));
color:var(--white);font-size:15px;font-weight:600;cursor:pointer;
transition:.25s;letter-spacing:1px;font-family:var(--font-cn);
}
.btn-login:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(232,117,26,.35);}
.btn-login:active{transform:translateY(0);}
.btn-login:disabled{opacity:.6;cursor:not-allowed;transform:none;box-shadow:none;}

.login-footer{text-align:center;margin-top:24px;font-size:12px;color:var(--text-secondary);}

.error-msg{color:var(--danger);font-size:13px;margin-bottom:16px;display:none;}
.error-msg.show{display:block;}

@media(max-width:960px){
.login-left{display:none;}
.login-right{background:linear-gradient(135deg,#fdf0e6 0%,var(--bg) 100%);}
}
@media(max-width:480px){
.login-box{width:100%;margin:0 20px;padding:36px 28px;}
}
</style>
</head>
<body>

<div class="login-wrapper">
<div class="login-left">
<div class="brand">
<div class="brand-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</div>
<h1>北京维康慈善基金会</h1>
<p>官网内容管理系统<br>致力于妇幼健康、患者关爱、卫生健康促进等公益事业</p>
</div>
</div>

<div class="login-right">
<div class="login-box">
<h2>欢迎回来</h2>
<p class="subtitle">请登录您的管理员账号</p>

<form id="loginForm">
<div class="error-msg" id="errorMsg"></div>

<div class="form-item">
<label>用户名</label>
<div class="input-wrap">
<span class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<input type="text" name="username" id="username" placeholder="请输入用户名" autocomplete="username" required>
</div>
</div>

<div class="form-item">
<label>密码</label>
<div class="input-wrap">
<span class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input type="password" name="password" id="password" placeholder="请输入密码" autocomplete="current-password" required>
<button type="button" class="toggle-pwd" onclick="togglePassword()" aria-label="显示密码">
<svg id="eyeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>

<div class="form-options">
<label class="remember">
<input type="checkbox" name="remember" id="remember"> 记住我
</label>
</div>

<button type="submit" class="btn-login" id="submitBtn">登 录</button>
</form>

<div class="login-footer">
© <span id="currentYear"></span> 北京维康慈善基金会 · 内容管理系统
</div>
</div>
</div>
</div>

<script>
document.getElementById('currentYear').textContent = new Date().getFullYear();

function togglePassword(){
const input = document.getElementById('password');
input.type = input.type === 'password' ? 'text' : 'password';
}

document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const errorMsg = document.getElementById('errorMsg');
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const remember = document.getElementById('remember').checked;

if (!username || !password) {
errorMsg.textContent = '请输入用户名和密码';
errorMsg.classList.add('show');
return;
}

btn.disabled = true;
btn.textContent = '登录中...';
errorMsg.classList.remove('show');

try {
const res = await fetch('/admin/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember })
});
const data = await res.json();
if (data.errno === 0) {
window.location.href = '/admin/dashboard.html';
} else {
errorMsg.textContent = data.errmsg || '登录失败';
errorMsg.classList.add('show');
}
} catch (err) {
errorMsg.textContent = '网络错误,请稍后重试';
errorMsg.classList.add('show');
} finally {
btn.disabled = false;
btn.textContent = '登 录';
}
});
</script>

</body>
</html>

+ 41
- 0
view/admin/common/_header.html Ver fichero

@@ -0,0 +1,41 @@
<header class="top-header">
<div class="header-left">
<div class="breadcrumb">
<a href="/admin/dashboard.html">首页</a>
{% if breadcrumb %}
{% for item in breadcrumb %}
<span class="sep">/</span>
{% if loop.last %}
<span class="current">{{ item.name }}</span>
{% else %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% endif %}
{% endfor %}
{% else %}
<span class="sep">/</span>
<span class="current">{{ pageTitle }}</span>
{% endif %}
</div>
</div>
<div class="header-right">
<button class="header-btn" title="刷新" onclick="location.reload()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="header-btn" title="全屏" onclick="toggleFullscreen()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<div class="header-avatar" id="userDropdown">
<div class="avatar-placeholder">{{ adminUser.nickname[0] if adminUser.nickname else '管' }}</div>
<span>{{ adminUser.nickname or adminUser.username or '管理员' }}</span>
</div>
</div>
</header>
<script>
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
</script>

+ 117
- 0
view/admin/common/_sidebar.html Ver fichero

@@ -0,0 +1,117 @@
<aside class="sidebar" id="sidebarApp">
<div class="sidebar-logo">
<img src="/static/images/logo.png" alt="维康基金会">
</div>
<nav class="sidebar-menu">
{# 控制台 #}
<div class="menu-group">
<a class="menu-item {% if currentPage == 'dashboard' %}active{% endif %}" href="/admin/dashboard.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" fill="currentColor"/><path d="M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z" fill="currentColor"/></svg></span>
<span>控制台</span>
</a>
</div>

{# 内容管理 - 动态栏目菜单 #}
{% if columnMenus and columnMenus.length %}
<div class="menu-group">
<div class="menu-group-title">内容管理</div>
{% for menu in columnMenus %}
{% if menu.children and menu.children.length %}
<div class="menu-parent {% if menuGroup == menu.key %}open{% endif %}">
<div class="menu-item">
<span class="menu-icon" data-icon="{{ menu.icon }}"></span>
<span>{{ menu.name }}</span>
<i class="arrow"><svg viewBox="0 0 640 640"><path d="M300.3 440.8C312.9 451 331.4 450.3 343.1 438.6L471.1 310.6C480.3 301.4 483 287.7 478 275.7C473 263.7 461.4 256 448.5 256L192.5 256C179.6 256 167.9 263.8 162.9 275.8C157.9 287.8 160.7 301.5 169.9 310.6L297.9 438.6L300.3 440.8z" fill="currentColor"/></svg></i>
</div>
<div class="submenu-items">
{% for child in menu.children %}
<a class="menu-item {% if currentPage == child.key %}active{% endif %}" href="{{ child.url }}"><span>{{ child.name }}</span></a>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}

{# 数据管理 #}
<div class="menu-group">
<div class="menu-group-title">数据管理</div>
<a class="menu-item {% if currentPage == 'medicine' %}active{% endif %}" href="/admin/data/medicine.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M839.2 295.2l-110.4-110.4c-12.8-12.8-33.6-12.8-46.4 0L574.4 292.8l-46.4-46.4c-12.8-12.8-33.6-12.8-46.4 0l-296 296c-12.8 12.8-12.8 33.6 0 46.4l110.4 110.4c12.8 12.8 33.6 12.8 46.4 0l108-108 46.4 46.4c12.8 12.8 33.6 12.8 46.4 0l296-296c12.8-12.8 12.8-33.6 0-46.4zM319.2 652.8l-64-64 249.6-249.6 64 64-249.6 249.6z" fill="currentColor"/></svg></span>
<span>药品援助记录</span>
</a>
</div>

{# 系统设置 #}
<div class="menu-group">
<div class="menu-group-title">系统设置</div>
<a class="menu-item {% if currentPage == 'column' %}active{% endif %}" href="/admin/system/column.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M128 128h256v256H128V128zm0 512h256v256H128V640zm512-512h256v256H640V128zm0 512h256v256H640V640z" fill="currentColor"/></svg></span>
<span>栏目管理</span>
</a>
<a class="menu-item {% if currentPage == 'config' %}active{% endif %}" href="/admin/system/config.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M512 682.7c-94.3 0-170.7-76.4-170.7-170.7S417.7 341.3 512 341.3 682.7 417.7 682.7 512 606.3 682.7 512 682.7zm0-277.4c-58.8 0-106.7 47.9-106.7 106.7s47.9 106.7 106.7 106.7 106.7-47.9 106.7-106.7-47.9-106.7-106.7-106.7z" fill="currentColor"/><path d="M924.4 405.3l-67.8-11.3c-7.1-26.8-17.4-52.4-30.6-76.3l40.3-55.4c10.5-14.4 8.8-34.3-4-46.9l-54.2-54.2c-12.6-12.8-32.5-14.5-46.9-4l-55.4 40.3c-23.9-13.2-49.5-23.5-76.3-30.6l-11.3-67.8c-2.9-17.6-18.2-30.5-36-30.5h-76.6c-17.8 0-33.1 12.9-36 30.5l-11.3 67.8c-26.8 7.1-52.4 17.4-76.3 30.6l-55.4-40.3c-14.4-10.5-34.3-8.8-46.9 4l-54.2 54.2c-12.8 12.6-14.5 32.5-4 46.9l40.3 55.4c-13.2 23.9-23.5 49.5-30.6 76.3l-67.8 11.3c-17.6 2.9-30.5 18.2-30.5 36v76.6c0 17.8 12.9 33.1 30.5 36l67.8 11.3c7.1 26.8 17.4 52.4 30.6 76.3l-40.3 55.4c-10.5 14.4-8.8 34.3 4 46.9l54.2 54.2c12.6 12.8 32.5 14.5 46.9 4l55.4-40.3c23.9 13.2 49.5 23.5 76.3 30.6l11.3 67.8c2.9 17.6 18.2 30.5 36 30.5h76.6c17.8 0 33.1-12.9 36-30.5l11.3-67.8c26.8-7.1 52.4-17.4 76.3-30.6l55.4 40.3c14.4 10.5 34.3 8.8 46.9-4l54.2-54.2c12.8-12.6 14.5-32.5 4-46.9l-40.3-55.4c13.2-23.9 23.5-49.5 30.6-76.3l67.8-11.3c17.6-2.9 30.5-18.2 30.5-36v-76.6c0-17.8-12.9-33.1-30.5-36z" fill="currentColor"/></svg></span>
<span>网站配置</span>
</a>
<div class="menu-parent {% if currentPage in ['sys-user','sys-role','sys-log'] %}open{% endif %}">
<div class="menu-item">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M832 464H192c-17.7 0-32 14.3-32 32v352c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zm-40 344H232V536h560v272z" fill="currentColor"/><path d="M512 624c-35.3 0-64 28.7-64 64s28.7 64 64 64 64-28.7 64-64-28.7-64-64-64z" fill="currentColor"/><path d="M736 464V288c0-123.7-100.3-224-224-224S288 164.3 288 288v176h64V288c0-88.2 71.8-160 160-160s160 71.8 160 160v176h64z" fill="currentColor"/></svg></span>
<span>系统管理</span>
<i class="arrow"><svg viewBox="0 0 640 640"><path d="M300.3 440.8C312.9 451 331.4 450.3 343.1 438.6L471.1 310.6C480.3 301.4 483 287.7 478 275.7C473 263.7 461.4 256 448.5 256L192.5 256C179.6 256 167.9 263.8 162.9 275.8C157.9 287.8 160.7 301.5 169.9 310.6L297.9 438.6L300.3 440.8z" fill="currentColor"/></svg></i>
</div>
<div class="submenu-items">
<a class="menu-item {% if currentPage == 'sys-user' %}active{% endif %}" href="/admin/system/user.html"><span>用户管理</span></a>
<a class="menu-item {% if currentPage == 'sys-role' %}active{% endif %}" href="/admin/system/role.html"><span>角色权限</span></a>
<a class="menu-item {% if currentPage == 'sys-log' %}active{% endif %}" href="/admin/system/log.html"><span>操作日志</span></a>
</div>
</div>
</div>
</nav>
</aside>

<script>
// 渲染动态图标 - 使用临时Vue实例渲染图标
document.addEventListener('DOMContentLoaded', function() {
if (!window.ElementPlusIconsVue || !window.Vue) return;
document.querySelectorAll('.menu-icon[data-icon]').forEach(function(el) {
var iconName = el.getAttribute('data-icon');
if (!iconName) {
el.innerHTML = '<svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zm-40 728H184V184h656v656z" fill="currentColor"/></svg>';
return;
}
var iconComp = window.ElementPlusIconsVue[iconName];
if (iconComp) {
// 创建临时容器
var tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
// 创建临时Vue应用渲染图标
var tempApp = Vue.createApp({
template: '<el-icon :size="18"><component :is="icon"></component></el-icon>',
data: function() { return { icon: iconComp }; }
});
tempApp.use(ElementPlus);
tempApp.mount(tempDiv);
// 提取渲染后的SVG
var svg = tempDiv.querySelector('svg');
if (svg) {
svg.setAttribute('width', '18');
svg.setAttribute('height', '18');
svg.querySelectorAll('path').forEach(function(p) {
p.setAttribute('fill', 'currentColor');
});
el.innerHTML = svg.outerHTML;
}
// 清理
tempApp.unmount();
document.body.removeChild(tempDiv);
}
});
});
</script>

+ 436
- 0
view/admin/content/article_index.html Ver fichero

@@ -0,0 +1,436 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '图文列表' }}{% endblock %}

{% block css %}
<!-- WangEditor CSS -->
<link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
<style>
/* 全屏弹窗 */
.fullscreen-dialog .el-dialog { margin: 0 !important; width: 100vw !important; height: 100vh !important; max-width: 100vw !important; }
.fullscreen-dialog .el-dialog__body { height: calc(100vh - 120px); padding: 0 20px; overflow: hidden; }
.fullscreen-dialog .el-dialog__header { border-bottom: 1px solid #eee; }

/* 编辑器布局 */
.editor-layout { display: flex; gap: 20px; height: 100%; }
.editor-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 0; }
.editor-side { width: 320px; flex-shrink: 0; overflow-y: auto; padding: 20px 0; }

/* 侧边区块 */
.side-section { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.side-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }

/* 封面上传 */
.cover-upload { width: 100%; aspect-ratio: 16/9; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #fafafa; transition: .2s; }
.cover-upload:hover { border-color: #ff7800; background: #fff7f0; }
.cover-upload img { width: 100%; height: 100%; object-fit: cover; }
.cover-placeholder { display: flex; flex-direction: column; align-items: center; gap: 6px; color: #bbb; font-size: 12px; }

/* 富文本编辑器 */
.editor-container { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
.editor-toolbar { border-bottom: 1px solid #ddd !important; }
.editor-content { height: 400px; overflow-y: auto; }

/* 列表缩略图 */
.thumb { width: 60px; height: 40px; border-radius: 4px; overflow: hidden; background: #f5f5f5; }
.thumb img { width: 100%; height: 100%; object-fit: cover; }
</style>
{% endblock %}

{% block content %}
<div id="articleApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else '图文列表' }}</span>
<el-tag type="primary" size="small">图文列表</el-tag>
</div>
<el-button type="primary" @click="openDialog()">+ 新增文章</el-button>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4">
<el-input v-model="query.keyword" placeholder="搜索标题..." style="width:200px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
<el-option label="已发布" :value="1"></el-option>
<el-option label="待审核" :value="2"></el-option>
<el-option label="草稿" :value="0"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery" class="ml-2">重置</el-button>
</div>

<!-- 信息栏 -->
<div class="flex items-center gap-3 mb-3 text-sm text-gray-500">
<span>已选择 <b class="text-orange-500">${ selectedIds.length }</b> 项,共 <b class="text-orange-500">${ total }</b> 条记录</span>
<div class="flex-1"></div>
<el-button size="small" :disabled="!selectedIds.length" @click="batchDelete">批量删除</el-button>
</div>

<!-- 表格 -->
<el-table :data="list" v-loading="loading" @selection-change="handleSelectionChange" border>
<el-table-column type="selection" width="40"></el-table-column>
<el-table-column prop="title" label="标题" min-width="300"></el-table-column>
<el-table-column prop="category" label="分类" width="100"></el-table-column>
<el-table-column label="封面图" width="80">
<template #default="{ row }">
<div class="thumb" v-if="row.cover"><img :src="row.cover" alt=""></div>
<span v-else class="text-gray-300">-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="statusMap[row.status]?.type" size="small">${ statusMap[row.status]?.text }</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_top" label="置顶" width="70">
<template #default="{ row }">
<el-tag :type="row.is_top ? 'primary' : 'info'" size="small">${ row.is_top ? '是' : '否' }</el-tag>
</template>
</el-table-column>
<el-table-column prop="publish_time" label="发布时间" width="140">
<template #default="{ row }">${ row.publish_time?.slice(0,16) || '-' }</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDialog(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="previewArticle(row)">查看</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 全屏编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" fullscreen class="fullscreen-dialog" destroy-on-close :close-on-click-modal="false">
<div class="editor-layout">
<!-- 左侧主区域 -->
<div class="editor-main">
<el-form :model="form" label-position="top">
<el-form-item label="文章标题" required>
<el-input v-model="form.title" placeholder="请输入文章标题" size="large"></el-input>
</el-form-item>
<el-form-item label="文章摘要">
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要(选填,不填则自动截取正文前200字)"></el-input>
</el-form-item>
<el-form-item label="文章正文" required>
<div class="editor-container">
<div id="toolbar-container" class="editor-toolbar"></div>
<div id="editor-container" class="editor-content"></div>
</div>
</el-form-item>
</el-form>
</div>

<!-- 右侧属性区 -->
<div class="editor-side">
<div class="side-section">
<div class="side-title">发布设置</div>
<el-form :model="form" label-position="top" size="default">
<el-form-item label="分类">
<el-select v-model="form.category" placeholder="选择分类" style="width:100%;" filterable allow-create :teleported="false">
<el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%;" :teleported="false">
<el-option label="草稿" :value="0"></el-option>
<el-option label="待审核" :value="2"></el-option>
<el-option label="已发布" :value="1"></el-option>
</el-select>
</el-form-item>
<el-form-item label="发布时间">
<el-date-picker v-model="form.publish_time" type="datetime" placeholder="选择发布时间" style="width:100%;" value-format="YYYY-MM-DD HH:mm:ss" :teleported="false"></el-date-picker>
</el-form-item>
<el-form-item label="排序权重">
<el-input-number v-model="form.sort" :min="0" style="width:100%;"></el-input-number>
</el-form-item>
<div class="flex gap-6">
<el-checkbox v-model="form.is_top" :true-value="1" :false-value="0">置顶</el-checkbox>
<el-checkbox v-model="form.is_recommend" :true-value="1" :false-value="0">推荐到首页</el-checkbox>
</div>
</el-form>
</div>

<div class="side-section">
<div class="side-title">封面图</div>
<div class="cover-upload" @click="triggerCoverUpload">
<img v-if="form.cover" :src="form.cover" alt="封面图">
<div v-else class="cover-placeholder">
<el-icon :size="32"><Plus /></el-icon>
<span>点击上传封面图</span>
</div>
</div>
<input type="file" ref="coverInput" accept="image/*" style="display:none;" @change="handleCoverUpload">
<el-button v-if="form.cover" size="small" class="mt-2 w-full" @click="form.cover = ''">移除封面</el-button>
</div>

<div class="side-section">
<div class="side-title">SEO 设置</div>
<el-form :model="form" label-position="top" size="default">
<el-form-item label="SEO 标题">
<el-input v-model="form.seo_title" placeholder="留空则使用文章标题"></el-input>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="form.seo_keywords" placeholder="多个关键词用逗号分隔"></el-input>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.seo_description" type="textarea" :rows="2" placeholder="留空则使用文章摘要"></el-input>
</el-form-item>
</el-form>
</div>
</div>
</div>

<template #footer>
<div class="flex justify-between">
<el-button @click="dialogVisible = false">返回</el-button>
<div class="flex gap-2">
<el-button @click="saveAsDraft" :loading="saving">保存草稿</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<!-- WangEditor JS -->
<script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.js"></script>
<script>
const col = '{{ col }}';
const { createApp, ref, reactive, onMounted, onBeforeUnmount, nextTick } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const query = reactive({ keyword: '', status: '' });
const selectedIds = ref([]);

const dialogVisible = ref(false);
const dialogTitle = ref('新增文章');
const saving = ref(false);
const coverInput = ref(null);

let editor = null;

const categoryOptions = ['基金会动态', '行业资讯', '通知公告', '公益项目', '党建活动', '党建学习'];
const statusMap = {
1: { type: 'success', text: '已发布' },
2: { type: 'warning', text: '待审核' },
0: { type: 'info', text: '草稿' }
};

const defaultForm = {
id: null, title: '', summary: '', content: '', cover: '', category: '',
is_top: 0, is_recommend: 0, sort: 0, status: 0, publish_time: '',
seo_title: '', seo_keywords: '', seo_description: ''
};
const form = reactive({ ...defaultForm });

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/article/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.status = '';
page.value = 1;
loadList();
}

function handleSelectionChange(rows) {
selectedIds.value = rows.map(r => r.id);
}

async function openDialog(item) {
if (item) {
dialogTitle.value = '编辑文章';
// 获取完整内容
const res = await fetch('/admin/content/article/detail?id=' + item.id).then(r => r.json());
if (res.code === 0 && res.data) {
const data = res.data;
Object.assign(form, {
id: data.id, title: data.title, summary: data.summary || '', content: data.content || '',
cover: data.cover || '', category: data.category || '', is_top: data.is_top,
is_recommend: data.is_recommend, sort: data.sort || 0, status: data.status,
publish_time: data.publish_time || '', seo_title: data.seo_title || '',
seo_keywords: data.seo_keywords || '', seo_description: data.seo_description || ''
});
}
} else {
dialogTitle.value = '新增文章';
Object.assign(form, { ...defaultForm });
}
dialogVisible.value = true;
nextTick(() => initEditor());
}

function initEditor() {
if (editor) {
editor.destroy();
editor = null;
}
const { createEditor, createToolbar } = window.wangEditor;
editor = createEditor({
selector: '#editor-container',
html: form.content || '<p></p>',
config: {
placeholder: '请输入文章正文...',
onChange(ed) {
form.content = ed.getHtml();
},
MENU_CONF: {
uploadImage: {
async customUpload(file, insertFn) {
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
if (res.code === 0) {
insertFn(res.data.url, file.name, res.data.url);
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (e) {
ElementPlus.ElMessage.error('上传失败');
}
}
}
}
}
});
createToolbar({
editor,
selector: '#toolbar-container',
config: {}
});
}

function triggerCoverUpload() {
coverInput.value?.click();
}

async function handleCoverUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
if (res.code === 0) {
form.cover = res.data.url;
ElementPlus.ElMessage.success('上传成功');
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (err) {
ElementPlus.ElMessage.error('上传失败');
}
e.target.value = '';
}

async function saveAsDraft() {
form.status = 0;
await doSave();
}

async function saveItem() {
await doSave();
}

async function doSave() {
if (!form.title.trim()) { ElementPlus.ElMessage.warning('请输入文章标题'); return; }
saving.value = true;
try {
const url = form.id ? '/admin/content/article/edit' : '/admin/content/article/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
if (editor) { editor.destroy(); editor = null; }
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + item.title + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/article/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

async function batchDelete() {
if (!selectedIds.value.length) return;
try {
await ElementPlus.ElMessageBox.confirm('确定删除选中的 ' + selectedIds.value.length + ' 条记录?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/article/batchDelete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds.value }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

function previewArticle(row) {
// 可以打开新窗口预览
ElementPlus.ElMessage.info('预览功能待实现');
}

onMounted(() => loadList());

onBeforeUnmount(() => {
if (editor) { editor.destroy(); editor = null; }
});

return {
loading, list, total, page, pageSize, query, selectedIds,
dialogVisible, dialogTitle, saving, form, coverInput,
categoryOptions, statusMap,
loadList, resetQuery, handleSelectionChange, openDialog,
triggerCoverUpload, handleCoverUpload, saveAsDraft, saveItem,
deleteItem, batchDelete, previewArticle
};
}
});

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#articleApp');
</script>
{% endblock %}

+ 369
- 0
view/admin/content/banner_index.html Ver fichero

@@ -0,0 +1,369 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else 'Banner管理' }}{% endblock %}

{% block content %}
<div id="bannerApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else 'Banner管理' }}</span>
<el-tag type="warning" size="small">轮播图</el-tag>
</div>
<el-button type="primary" @click="openDialog()">+ 新增Banner</el-button>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4">
<el-input v-model="query.keyword" placeholder="搜索标题..." style="width:180px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
<el-option label="启用" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery" class="ml-2">重置</el-button>
</div>

<!-- 卡片列表 -->
<div class="banner-grid" v-loading="loading">
<div v-for="item in list" :key="item.id" class="banner-card">
<div class="banner-thumb">
<img :src="item.image || '/static/images/placeholder.png'" :alt="item.title">
<span class="banner-sort">${ item.sort }</span>
<span v-if="item.glass" class="banner-glass">毛玻璃</span>
<!-- 文字叠加预览 -->
<div class="banner-overlay">
<div class="banner-overlay-title">${ item.title }</div>
<div v-if="item.subtitle" class="banner-overlay-sub">${ item.subtitle }</div>
<div class="banner-overlay-btns">
<span v-if="item.btn1_show && item.btn1_text">${ item.btn1_text }</span>
<span v-if="item.btn2_show && item.btn2_text">${ item.btn2_text }</span>
</div>
</div>
</div>
<div class="banner-info">
<div class="banner-title">${ item.title }</div>
<div v-if="item.subtitle" class="banner-subtitle">${ item.subtitle }</div>
<div class="banner-meta">
<el-tag :type="item.status ? 'success' : 'info'" size="small">${ item.status ? '启用' : '禁用' }</el-tag>
<span class="banner-date">${ item.create_time?.slice(0,10) }</span>
</div>
</div>
<div class="banner-actions">
<el-button type="primary" link size="small" @click="openDialog(item)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(item)">删除</el-button>
</div>
</div>
<!-- 新增卡片 -->
<div class="banner-card banner-card-add" @click="openDialog()">
<div class="add-inner">
<el-icon :size="36"><Plus /></el-icon>
<span>新增Banner</span>
</div>
</div>
</div>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px" destroy-on-close draggable top="5vh">
<div class="dialog-scroll-body">
<el-form :model="form" label-width="100px">
<!-- 图片上传 -->
<el-form-item label="Banner图片" required>
<div class="img-upload-wrap">
<div class="img-upload-area" @click="triggerUpload">
<img v-if="form.image" :src="form.image" class="img-preview">
<div v-else class="img-placeholder">
<el-icon :size="32"><Plus /></el-icon>
<span>点击上传图片</span>
</div>
</div>
<input type="file" ref="fileInput" accept="image/*" style="display:none;" @change="handleUpload">
<div class="img-tip">建议尺寸 1920×800,支持 JPG/PNG</div>
</div>
</el-form-item>
<!-- 基本信息 -->
<el-form-item label="主标题" required>
<el-input v-model="form.title" placeholder="如:也许因为您的一次帮助"></el-input>
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" placeholder="如:天真的笑容将再次回到他的脸上"></el-input>
</el-form-item>
<el-form-item label="描述文字">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="Banner描述文字"></el-input>
</el-form-item>

<!-- 按钮配置 -->
<el-divider content-position="left">按钮配置</el-divider>
<div class="flex gap-3 items-end mb-4">
<el-form-item label="按钮1文字" class="mb-0" style="flex:2;">
<el-input v-model="form.btn1_text" placeholder="了解更多"></el-input>
</el-form-item>
<el-form-item label="链接" class="mb-0" style="flex:2;">
<el-input v-model="form.btn1_link" placeholder="#"></el-input>
</el-form-item>
<el-form-item label="显示" class="mb-0" style="flex:1;">
<el-switch v-model="form.btn1_show" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</div>
<div class="flex gap-3 items-end">
<el-form-item label="按钮2文字" class="mb-0" style="flex:2;">
<el-input v-model="form.btn2_text" placeholder="我要捐赠"></el-input>
</el-form-item>
<el-form-item label="链接" class="mb-0" style="flex:2;">
<el-input v-model="form.btn2_link" placeholder="#"></el-input>
</el-form-item>
<el-form-item label="显示" class="mb-0" style="flex:1;">
<el-switch v-model="form.btn2_show" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</div>

<!-- 样式设置 -->
<el-divider content-position="left">样式设置</el-divider>
<el-form-item label="文字位置">
<el-radio-group v-model="form.position">
<el-radio value="left">左侧</el-radio>
<el-radio value="center">居中</el-radio>
<el-radio value="right">右侧</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="毛玻璃效果">
<el-switch v-model="form.glass" :active-value="1" :inactive-value="0"></el-switch>
<span class="ml-2 text-gray-400 text-sm">启用后文字区域会有半透明模糊背景</span>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="排序" class="flex-1">
<el-input-number v-model="form.sort" :min="1" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="状态" class="flex-1">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</div>

<!-- 预览区域 -->
<el-divider content-position="left">前台预览</el-divider>
<div class="banner-preview" :class="'pos-' + form.position">
<img :src="form.image || '/static/images/placeholder.png'" class="preview-bg">
<div class="preview-overlay"></div>
<div class="preview-content" :class="{ 'glass-on': form.glass }">
<div class="preview-title">${ form.title || '标题文字' }${ form.subtitle ? '<br>' + form.subtitle : '' }</div>
<div v-if="form.description" class="preview-desc">${ form.description }</div>
<div class="preview-btns">
<span v-if="form.btn1_show && form.btn1_text" class="pv-btn pv-btn-w">${ form.btn1_text }</span>
<span v-if="form.btn2_show && form.btn2_text" class="pv-btn pv-btn-o">${ form.btn2_text }</span>
</div>
</div>
</div>
</el-form>
</div>

<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>

<style>
.banner-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; margin-top: 16px; }
.banner-card { background: #fff; border: 1px solid #eee; border-radius: 8px; overflow: hidden; transition: .2s; }
.banner-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); transform: translateY(-2px); }
.banner-thumb { position: relative; aspect-ratio: 16/9; background: #f5f5f5; overflow: hidden; }
.banner-thumb img { width: 100%; height: 100%; object-fit: cover; }
.banner-sort { position: absolute; top: 6px; left: 6px; background: #ff7800; color: #fff; font-size: 11px; font-weight: 600; width: 22px; height: 22px; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.banner-glass { position: absolute; top: 6px; right: 6px; background: rgba(255,255,255,.2); backdrop-filter: blur(4px); padding: 1px 6px; border-radius: 3px; font-size: 9px; color: #fff; border: 1px solid rgba(255,255,255,.3); }
.banner-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 8px 10px; background: linear-gradient(transparent, rgba(0,0,0,.7)); color: #fff; }
.banner-overlay-title { font-size: 12px; font-weight: 600; line-height: 1.3; text-shadow: 0 1px 2px rgba(0,0,0,.5); }
.banner-overlay-sub { font-size: 10px; opacity: .85; margin-top: 2px; }
.banner-overlay-btns { display: flex; gap: 4px; margin-top: 4px; }
.banner-overlay-btns span { font-size: 9px; padding: 1px 6px; border-radius: 2px; border: 1px solid rgba(255,255,255,.6); color: rgba(255,255,255,.9); }
.banner-info { padding: 10px 12px 6px; }
.banner-title { font-size: 13px; font-weight: 500; color: #333; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.banner-subtitle { font-size: 11px; color: #999; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.banner-meta { display: flex; align-items: center; gap: 8px; }
.banner-date { font-size: 11px; color: #bbb; }
.banner-actions { display: flex; align-items: center; gap: 4px; padding: 6px 12px 10px; border-top: 1px solid #f0f0f0; }
.banner-card-add { border: 2px dashed #ddd; display: flex; align-items: center; justify-content: center; cursor: pointer; min-height: 200px; }
.banner-card-add:hover { border-color: #ff7800; background: #fff7f0; }
.add-inner { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #bbb; font-size: 13px; }

/* 弹窗滚动区域 */
.dialog-scroll-body { max-height: 65vh; overflow-y: auto; overflow-x: hidden; }

/* 图片上传 */
.img-upload-wrap { width: 100%; max-width: 360px; }
.img-upload-area { width: 100%; aspect-ratio: 16/9; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; overflow: hidden; transition: .2s; background: #fafafa; }
.img-upload-area:hover { border-color: #ff7800; background: #fff7f0; }
.img-preview { width: 100%; height: 100%; object-fit: cover; }
.img-placeholder { display: flex; flex-direction: column; align-items: center; gap: 6px; color: #bbb; font-size: 12px; }
.img-tip { font-size: 12px; color: #999; margin-top: 8px; }

/* 预览区域 */
.banner-preview { width: 100%; aspect-ratio: 16/7; border-radius: 8px; overflow: hidden; position: relative; background: #222; }
.preview-bg { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; }
.preview-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.35); }
.preview-content { position: absolute; top: 50%; transform: translateY(-50%); padding: 16px 24px; max-width: 60%; color: #fff; z-index: 2; }
.pos-left .preview-content { left: 24px; }
.pos-center .preview-content { left: 50%; transform: translate(-50%, -50%); text-align: center; max-width: 70%; }
.pos-right .preview-content { right: 24px; text-align: right; }
.preview-content.glass-on { background: rgba(255,255,255,.08); backdrop-filter: blur(12px); border-radius: 8px; border: 1px solid rgba(255,255,255,.12); padding: 20px 28px; }
.preview-title { font-size: 16px; font-weight: 700; line-height: 1.4; margin-bottom: 6px; }
.preview-desc { font-size: 11px; color: rgba(255,255,255,.75); line-height: 1.5; margin-bottom: 10px; }
.preview-btns { display: flex; gap: 8px; }
.pv-btn { padding: 4px 14px; border-radius: 4px; font-size: 11px; }
.pv-btn-w { background: #fff; color: #333; }
.pv-btn-o { border: 1px solid rgba(255,255,255,.7); color: #fff; background: transparent; }
.pos-center .preview-btns { justify-content: center; }
.pos-right .preview-btns { justify-content: flex-end; }
</style>
{% endblock %}

{% block js %}
<script>
const col = '{{ col }}';
const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const query = reactive({ keyword: '', status: '' });

const dialogVisible = ref(false);
const dialogTitle = ref('新增Banner');
const activeTab = ref('basic');
const saving = ref(false);
const fileInput = ref(null);

const defaultForm = {
id: null, title: '', subtitle: '', description: '', image: '',
btn1_text: '了解更多', btn1_link: '#', btn1_show: 1,
btn2_text: '我要捐赠', btn2_link: '#', btn2_show: 1,
position: 'left', glass: 1, sort: 1, status: 1
};
const form = reactive({ ...defaultForm });

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/banner/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.status = '';
page.value = 1;
loadList();
}

function openDialog(item) {
activeTab.value = 'basic';
if (item) {
dialogTitle.value = '编辑Banner';
Object.assign(form, {
id: item.id, title: item.title, subtitle: item.subtitle || '', description: item.description || '',
image: item.image, btn1_text: item.btn1_text || '', btn1_link: item.btn1_link || '',
btn1_show: item.btn1_show, btn2_text: item.btn2_text || '', btn2_link: item.btn2_link || '',
btn2_show: item.btn2_show, position: item.position || 'left', glass: item.glass,
sort: item.sort || 1, status: item.status
});
} else {
dialogTitle.value = '新增Banner';
Object.assign(form, { ...defaultForm, sort: list.value.length + 1 });
}
dialogVisible.value = true;
}

function triggerUpload() {
fileInput.value?.click();
}

async function handleUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
if (res.code === 0) {
form.image = res.data.url;
ElementPlus.ElMessage.success('上传成功');
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (err) {
ElementPlus.ElMessage.error('上传失败');
}
e.target.value = '';
}

function refreshPreview() {
// 触发Vue响应式更新
}

async function saveItem() {
if (!form.title.trim()) { ElementPlus.ElMessage.warning('请输入标题'); return; }
if (!form.image) { ElementPlus.ElMessage.warning('请上传图片'); return; }
saving.value = true;
try {
const url = form.id ? '/admin/content/banner/edit' : '/admin/content/banner/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + item.title + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/banner/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

onMounted(() => loadList());

return {
loading, list, total, page, pageSize, query,
dialogVisible, dialogTitle, activeTab, saving, form, fileInput,
loadList, resetQuery, openDialog, triggerUpload, handleUpload, refreshPreview, saveItem, deleteItem
};
}
});

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#bannerApp');
</script>
{% endblock %}

+ 384
- 0
view/admin/content/donation_index.html Ver fichero

@@ -0,0 +1,384 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '捐赠收支' }}{% endblock %}

{% block content %}
<div id="donationApp">
<!-- 统计卡片 -->
<div class="stat-row">
<div class="stat-card">
<div class="stat-label">累计捐赠收入</div>
<div class="stat-value" style="color:#ff7800;">${ formatMoney(stat.total_income) }</div>
</div>
<div class="stat-card">
<div class="stat-label">累计公益支出</div>
<div class="stat-value" style="color:#1A3550;">${ formatMoney(stat.total_expense) }</div>
</div>
<div class="stat-card">
<div class="stat-label">本年度收入</div>
<div class="stat-value" style="color:#67c23a;">${ formatMoney(stat.year_income) }</div>
</div>
<div class="stat-card">
<div class="stat-label">本年度支出</div>
<div class="stat-value" style="color:#e6a23c;">${ formatMoney(stat.year_expense) }</div>
</div>
</div>

<!-- 统计操作栏 -->
<el-card shadow="never" class="mb-4">
<div class="flex items-center gap-3">
<span class="text-sm">统计数据管理</span>
<el-button size="small" @click="openStatDialog">编辑统计</el-button>
<el-button size="small" type="primary" @click="syncStat" :loading="syncing">从明细同步</el-button>
<span v-if="stat.sync_time" class="text-xs text-gray-400 ml-2">上次同步: ${ stat.sync_time?.slice(0,16).replace('T',' ') }</span>
</div>
</el-card>

<!-- Tab 切换 -->
<div class="content-tabs mb-4">
<div class="tab-item" :class="{ active: activeTab === 'income' }" @click="switchTab('income')">捐赠收入</div>
<div class="tab-item" :class="{ active: activeTab === 'expense' }" @click="switchTab('expense')">公益支出</div>
</div>

<el-card shadow="never">
<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4 flex-wrap">
<el-input v-model="query.keyword" :placeholder="activeTab === 'income' ? '捐赠人/单位...' : '支出项目...'" style="width:180px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.source" placeholder="来源" style="width:120px;" clearable>
<el-option label="金蝶同步" value="kingdee"></el-option>
<el-option label="手动录入" value="manual"></el-option>
</el-select>
<el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
<el-option label="已公示" :value="1"></el-option>
<el-option label="待公示" :value="0"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
<div class="flex-1"></div>
<el-button type="primary" @click="openDialog()">+ 手动录入</el-button>
<el-button type="success" @click="exportData">导出</el-button>
</div>

<!-- 表格 -->
<el-table :data="list" v-loading="loading" border>
<el-table-column :label="activeTab === 'income' ? '捐赠人/单位' : '支出项目'" prop="name" min-width="200"></el-table-column>
<el-table-column label="金额 (元)" width="160">
<template #default="{ row }">
<span :style="{ fontWeight: 600, color: activeTab === 'income' ? '#ff7800' : '#1A3550' }">${ formatMoney(row.amount) }</span>
</template>
</el-table-column>
<el-table-column :label="activeTab === 'income' ? '用途/项目' : '受益对象'" prop="purpose" min-width="140"></el-table-column>
<el-table-column label="来源" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.source === 'kingdee' ? 'primary' : 'info'" size="small">${ row.source === 'kingdee' ? '金蝶同步' : '手动录入' }</el-tag>
</template>
</el-table-column>
<el-table-column label="日期" width="120" prop="record_date"></el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'warning'" size="small">${ row.status ? '已公示' : '待公示' }</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDialog(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close draggable top="5vh">
<el-form :model="form" label-width="100px">
<el-form-item label="类型">
<el-radio-group v-model="form.type">
<el-radio value="income">捐赠收入</el-radio>
<el-radio value="expense">公益支出</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="form.type === 'income' ? '捐赠人/单位' : '支出项目'" required>
<el-input v-model="form.name" placeholder="请输入"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="金额 (元)" required class="flex-1">
<el-input-number v-model="form.amount" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="日期" class="flex-1">
<el-date-picker v-model="form.record_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%;"></el-date-picker>
</el-form-item>
</div>
<el-form-item :label="form.type === 'income' ? '用途/项目' : '受益对象'">
<el-input v-model="form.purpose" placeholder="请输入"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="来源" class="flex-1">
<el-select v-model="form.source" style="width:100%;">
<el-option label="手动录入" value="manual"></el-option>
<el-option label="金蝶同步" value="kingdee"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态" class="flex-1">
<el-select v-model="form.status" style="width:100%;">
<el-option label="已公示" :value="1"></el-option>
<el-option label="待公示" :value="0"></el-option>
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>

<!-- 统计编辑弹窗 -->
<el-dialog v-model="statDialogVisible" title="编辑统计数据" width="480px" destroy-on-close draggable>
<el-form :model="statForm" label-width="120px">
<el-form-item label="累计捐赠收入">
<el-input-number v-model="statForm.total_income" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="累计公益支出">
<el-input-number v-model="statForm.total_expense" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="本年度收入">
<el-input-number v-model="statForm.year_income" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="本年度支出">
<el-input-number v-model="statForm.year_expense" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="statDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveStat" :loading="statSaving">保存</el-button>
</template>
</el-dialog>
</div>

<style>
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
.stat-card { background: #fff; border-radius: 4px; box-shadow: 0 2px 12px rgba(0,0,0,.06); padding: 20px; text-align: center; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 22px; font-weight: 700; font-family: "Roboto", sans-serif; }
.content-tabs { display: flex; gap: 0; background: #fff; border-radius: 4px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
.tab-item { padding: 12px 24px; font-size: 14px; cursor: pointer; color: #606266; transition: .2s; border-bottom: 2px solid transparent; }
.tab-item:hover { color: #ff7800; }
.tab-item.active { color: #ff7800; font-weight: 600; border-bottom-color: #ff7800; background: #fff7f0; }
</style>
{% endblock %}

{% block js %}
<!-- SheetJS for Excel export -->
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>

<script>
const col = '{{ col }}';
const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const activeTab = ref('income');
const query = reactive({ keyword: '', source: '', status: '' });

const stat = reactive({ total_income: 0, total_expense: 0, year_income: 0, year_expense: 0, sync_time: null });
const syncing = ref(false);

const dialogVisible = ref(false);
const dialogTitle = ref('手动录入');
const saving = ref(false);
const defaultForm = { id: null, type: 'income', name: '', amount: 0, purpose: '', source: 'manual', record_date: '', status: 1 };
const form = reactive({ ...defaultForm });

const statDialogVisible = ref(false);
const statSaving = ref(false);
const statForm = reactive({ total_income: 0, total_expense: 0, year_income: 0, year_expense: 0 });

function formatMoney(n) {
return '¥ ' + Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

async function loadStat() {
try {
const res = await fetch('/admin/content/donation/stat?col=' + col).then(r => r.json());
if (res.code === 0 && res.data) {
Object.assign(stat, res.data);
}
} catch {}
}

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, type: activeTab.value, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/donation/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function switchTab(tab) {
activeTab.value = tab;
page.value = 1;
loadList();
}

function resetQuery() {
query.keyword = '';
query.source = '';
query.status = '';
page.value = 1;
loadList();
}

function openDialog(item) {
if (item) {
dialogTitle.value = '编辑记录';
Object.assign(form, {
id: item.id, type: item.type, name: item.name,
amount: parseFloat(item.amount) || 0, purpose: item.purpose || '',
source: item.source || 'manual', record_date: item.record_date || '',
status: item.status
});
} else {
dialogTitle.value = '手动录入';
Object.assign(form, { ...defaultForm, type: activeTab.value });
}
dialogVisible.value = true;
}

async function saveItem() {
if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入名称'); return; }
if (!form.amount || form.amount <= 0) { ElementPlus.ElMessage.warning('请输入有效金额'); return; }

saving.value = true;
try {
const url = form.id ? '/admin/content/donation/edit' : '/admin/content/donation/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + item.name + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/donation/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

function openStatDialog() {
Object.assign(statForm, {
total_income: parseFloat(stat.total_income) || 0,
total_expense: parseFloat(stat.total_expense) || 0,
year_income: parseFloat(stat.year_income) || 0,
year_expense: parseFloat(stat.year_expense) || 0
});
statDialogVisible.value = true;
}

async function saveStat() {
statSaving.value = true;
try {
const res = await fetch('/admin/content/donation/saveStat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...statForm, col })
}).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
Object.assign(stat, statForm);
statDialogVisible.value = false;
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { statSaving.value = false; }
}

async function syncStat() {
try {
await ElementPlus.ElMessageBox.confirm('确定从明细数据同步统计?这将覆盖当前统计数据。', '提示', { type: 'warning' });
syncing.value = true;
const res = await fetch('/admin/content/donation/syncStat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ col })
}).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('同步成功');
Object.assign(stat, res.data);
} else {
ElementPlus.ElMessage.error(res.msg || '同步失败');
}
} catch {} finally { syncing.value = false; }
}

async function exportData() {
try {
const res = await fetch('/admin/content/donation/export?col=' + col + '&type=' + activeTab.value).then(r => r.json());
if (res.code === 0) {
const data = res.data || [];
const isIncome = activeTab.value === 'income';
const headers = isIncome
? ['捐赠人/单位', '金额', '用途/项目', '来源', '日期', '状态']
: ['支出项目', '金额', '受益对象', '来源', '日期', '状态'];
const rows = data.map(item => [
item.name,
parseFloat(item.amount) || 0,
item.purpose,
item.source === 'kingdee' ? '金蝶同步' : '手动录入',
item.record_date,
item.status ? '已公示' : '待公示'
]);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, isIncome ? '捐赠收入' : '公益支出');
XLSX.writeFile(wb, (isIncome ? '捐赠收入' : '公益支出') + '_' + new Date().toISOString().slice(0,10) + '.xlsx');
ElementPlus.ElMessage.success('导出成功');
}
} catch (err) {
ElementPlus.ElMessage.error('导出失败');
}
}

onMounted(() => {
loadStat();
loadList();
});

return {
loading, list, total, page, pageSize, activeTab, query, stat, syncing,
dialogVisible, dialogTitle, saving, form,
statDialogVisible, statSaving, statForm,
formatMoney, loadList, switchTab, resetQuery, openDialog, saveItem, deleteItem,
openStatDialog, saveStat, syncStat, exportData
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#donationApp');
</script>
{% endblock %}

+ 260
- 0
view/admin/content/image_index.html Ver fichero

@@ -0,0 +1,260 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '图片列表' }}{% endblock %}

{% block content %}
<div id="imageApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else '图片列表' }}</span>
<el-tag type="success" size="small">图片列表</el-tag>
</div>
<el-button type="primary" @click="openDialog()">+ 新增图片</el-button>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4">
<el-input v-model="query.keyword" placeholder="搜索标题..." style="width:180px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
<el-option label="启用" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>

<!-- 卡片列表 -->
<div class="image-grid" v-loading="loading">
<div v-for="item in list" :key="item.id" class="image-card">
<div class="image-thumb">
<img :src="item.image || '/static/images/placeholder.png'" :alt="item.title">
<span class="image-sort">${ item.sort }</span>
</div>
<div class="image-info">
<div class="image-title">${ item.title }</div>
<div class="image-meta">
<el-tag :type="item.status ? 'success' : 'info'" size="small">${ item.status ? '启用' : '禁用' }</el-tag>
<span class="image-date">${ item.create_time?.slice(0,10) }</span>
</div>
</div>
<div class="image-actions">
<el-button type="primary" link size="small" @click="openDialog(item)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(item)">删除</el-button>
</div>
</div>
<!-- 新增卡片 -->
<div class="image-card image-card-add" @click="openDialog()">
<div class="add-inner">
<el-icon :size="36"><Plus /></el-icon>
<span>新增图片</span>
</div>
</div>
</div>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close draggable top="5vh">
<div class="dialog-scroll-body">
<el-form :model="form" label-width="80px">
<!-- 图片上传 -->
<el-form-item label="图片" required>
<div class="img-upload-wrap">
<div class="img-upload-area" @click="triggerUpload">
<img v-if="form.image" :src="form.image" class="img-preview">
<div v-else class="img-placeholder">
<el-icon :size="32"><Plus /></el-icon>
<span>点击上传图片</span>
</div>
</div>
<input type="file" ref="fileInput" accept="image/*" style="display:none;" @change="handleUpload">
<div class="img-tip">支持 JPG/PNG/GIF,建议宽度不小于 800px</div>
</div>
</el-form-item>
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="链接地址">
<el-input v-model="form.link" placeholder="点击图片跳转的链接(可选)"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="排序" class="flex-1">
<el-input-number v-model="form.sort" :min="1" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="状态" class="flex-1">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>

<style>
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 16px; }
.image-card { background: #fff; border: 1px solid #eee; border-radius: 8px; overflow: hidden; transition: .2s; }
.image-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); transform: translateY(-2px); }
.image-thumb { position: relative; aspect-ratio: 4/3; background: #f5f5f5; overflow: hidden; }
.image-thumb img { width: 100%; height: 100%; object-fit: contain; background: #fafafa; }
.image-sort { position: absolute; top: 6px; left: 6px; background: #ff7800; color: #fff; font-size: 11px; font-weight: 600; width: 22px; height: 22px; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.image-info { padding: 10px 12px 6px; }
.image-title { font-size: 13px; font-weight: 500; color: #333; margin-bottom: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-meta { display: flex; align-items: center; gap: 8px; }
.image-date { font-size: 11px; color: #bbb; }
.image-actions { display: flex; align-items: center; gap: 4px; padding: 6px 12px 10px; border-top: 1px solid #f0f0f0; }
.image-card-add { border: 2px dashed #ddd; display: flex; align-items: center; justify-content: center; cursor: pointer; min-height: 180px; }
.image-card-add:hover { border-color: #ff7800; background: #fff7f0; }
.add-inner { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #bbb; font-size: 13px; }

/* 弹窗滚动区域 */
.dialog-scroll-body { max-height: 65vh; overflow-y: auto; overflow-x: hidden; }

/* 图片上传 */
.img-upload-wrap { width: 100%; max-width: 280px; }
.img-upload-area { width: 100%; aspect-ratio: 4/3; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; overflow: hidden; transition: .2s; background: #fafafa; }
.img-upload-area:hover { border-color: #ff7800; background: #fff7f0; }
.img-preview { width: 100%; height: 100%; object-fit: contain; }
.img-placeholder { display: flex; flex-direction: column; align-items: center; gap: 6px; color: #bbb; font-size: 12px; }
.img-tip { font-size: 12px; color: #999; margin-top: 8px; }
</style>
{% endblock %}

{% block js %}
<script>
const col = '{{ col }}';
const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
const query = reactive({ keyword: '', status: '' });

const dialogVisible = ref(false);
const dialogTitle = ref('新增图片');
const saving = ref(false);
const fileInput = ref(null);

const defaultForm = { id: null, title: '', image: '', link: '', sort: 1, status: 1 };
const form = reactive({ ...defaultForm });

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/image/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.status = '';
page.value = 1;
loadList();
}

function openDialog(item) {
if (item) {
dialogTitle.value = '编辑图片';
Object.assign(form, {
id: item.id, title: item.title, image: item.image,
link: item.link || '', sort: item.sort || 1, status: item.status
});
} else {
dialogTitle.value = '新增图片';
Object.assign(form, { ...defaultForm, sort: list.value.length + 1 });
}
dialogVisible.value = true;
}

function triggerUpload() {
fileInput.value?.click();
}

async function handleUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
if (res.code === 0) {
form.image = res.data.url;
ElementPlus.ElMessage.success('上传成功');
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (err) {
ElementPlus.ElMessage.error('上传失败');
}
e.target.value = '';
}

async function saveItem() {
if (!form.title.trim()) { ElementPlus.ElMessage.warning('请输入标题'); return; }
if (!form.image) { ElementPlus.ElMessage.warning('请上传图片'); return; }
saving.value = true;
try {
const url = form.id ? '/admin/content/image/edit' : '/admin/content/image/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + item.title + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/image/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

onMounted(() => loadList());

return {
loading, list, total, page, pageSize, query,
dialogVisible, dialogTitle, saving, form, fileInput,
loadList, resetQuery, openDialog, triggerUpload, handleUpload, saveItem, deleteItem
};
}
});

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#imageApp');
</script>
{% endblock %}

+ 211
- 0
view/admin/content/job_index.html Ver fichero

@@ -0,0 +1,211 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '岗位管理' }}{% endblock %}

{% block content %}
<div id="jobApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else '岗位管理' }}</span>
<el-tag size="small">岗位管理</el-tag>
</div>
<el-button type="primary" @click="openDialog()">+ 发布岗位</el-button>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4">
<el-input v-model="query.keyword" placeholder="岗位名称..." style="width:180px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.status" placeholder="状态" style="width:110px;" clearable>
<el-option label="招聘中" :value="1"></el-option>
<el-option label="已关闭" :value="0"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>

<!-- 表格 -->
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="name" label="岗位名称" min-width="140"></el-table-column>
<el-table-column prop="department" label="部门" width="120"></el-table-column>
<el-table-column prop="location" label="工作地点" width="100"></el-table-column>
<el-table-column prop="count" label="人数" width="80" align="center"></el-table-column>
<el-table-column prop="salary" label="薪资" width="120"></el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'" size="small">${ row.status ? '招聘中' : '已关闭' }</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="发布日期" width="120">
<template #default="{ row }">
${ row.create_time?.slice(0, 10) }
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDialog(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="620px" destroy-on-close draggable top="5vh">
<div class="dialog-scroll-body">
<el-form :model="form" label-width="90px">
<el-form-item label="岗位名称" required>
<el-input v-model="form.name" placeholder="请输入岗位名称"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="所属部门" required class="flex-1">
<el-input v-model="form.department" placeholder="如:项目部"></el-input>
</el-form-item>
<el-form-item label="工作地点" required class="flex-1">
<el-input v-model="form.location" placeholder="如:北京"></el-input>
</el-form-item>
</div>
<div class="flex gap-4">
<el-form-item label="招聘人数" class="flex-1">
<el-input-number v-model="form.count" :min="1" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="薪资范围" class="flex-1">
<el-input v-model="form.salary" placeholder="如:8K-12K / 面议"></el-input>
</el-form-item>
</div>
<el-form-item label="岗位职责" required>
<el-input v-model="form.duty" type="textarea" :rows="4" placeholder="请输入岗位职责描述"></el-input>
</el-form-item>
<el-form-item label="任职要求" required>
<el-input v-model="form.requirement" type="textarea" :rows="4" placeholder="请输入任职要求"></el-input>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="招聘中" inactive-text="已关闭"></el-switch>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>

<style>
.dialog-scroll-body { max-height: 65vh; overflow-y: auto; overflow-x: hidden; }
</style>
{% endblock %}

{% block js %}
<script>
const col = '{{ col }}';
const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const query = reactive({ keyword: '', status: '' });

const dialogVisible = ref(false);
const dialogTitle = ref('发布岗位');
const saving = ref(false);

const defaultForm = { id: null, name: '', department: '', location: '', count: 1, duty: '', requirement: '', salary: '', status: 1 };
const form = reactive({ ...defaultForm });

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/job/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.status = '';
page.value = 1;
loadList();
}

function openDialog(item) {
if (item) {
dialogTitle.value = '编辑岗位';
Object.assign(form, {
id: item.id, name: item.name, department: item.department || '',
location: item.location || '', count: item.count || 1,
duty: item.duty || '', requirement: item.requirement || '',
salary: item.salary || '', status: item.status
});
} else {
dialogTitle.value = '发布岗位';
Object.assign(form, { ...defaultForm });
}
dialogVisible.value = true;
}

async function saveItem() {
if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入岗位名称'); return; }
if (!form.department.trim()) { ElementPlus.ElMessage.warning('请输入所属部门'); return; }
if (!form.location.trim()) { ElementPlus.ElMessage.warning('请输入工作地点'); return; }
if (!form.duty.trim()) { ElementPlus.ElMessage.warning('请输入岗位职责'); return; }
if (!form.requirement.trim()) { ElementPlus.ElMessage.warning('请输入任职要求'); return; }

saving.value = true;
try {
const url = form.id ? '/admin/content/job/edit' : '/admin/content/job/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除岗位"' + item.name + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/job/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

onMounted(() => loadList());

return {
loading, list, total, page, pageSize, query,
dialogVisible, dialogTitle, saving, form,
loadList, resetQuery, openDialog, saveItem, deleteItem
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#jobApp');
</script>
{% endblock %}

+ 198
- 0
view/admin/content/page_index.html Ver fichero

@@ -0,0 +1,198 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '单页管理' }}{% endblock %}

{% block content %}
<div id="pageApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else '单页管理' }}</span>
<el-tag type="info" size="small">单页</el-tag>
<el-tag :type="pageStatus ? 'success' : 'warning'" size="small">${ pageStatus ? '已发布' : '草稿' }</el-tag>
<span v-if="updateInfo" class="text-gray-400 text-xs ml-2">${ updateInfo }</span>
</div>
<div class="flex gap-2">
<el-button :icon="View" @click="openPreview">预览</el-button>
<el-button type="primary" :icon="DocumentChecked" @click="savePage" :loading="saving">保存</el-button>
</div>
</div>
</template>

<!-- WangEditor 富文本编辑器 -->
<div class="editor-wrap">
<div id="toolbar-container"></div>
<div id="editor-container"></div>
</div>

<!-- 发布状态 -->
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="flex items-center gap-4">
<span class="text-gray-500">发布状态:</span>
<el-switch v-model="pageStatus" :active-value="1" :inactive-value="0" active-text="已发布" inactive-text="草稿"></el-switch>
</div>
<div class="flex gap-2">
<el-button :icon="View" @click="openPreview">预览</el-button>
<el-button type="primary" :icon="DocumentChecked" @click="savePage" :loading="saving">保存</el-button>
</div>
</div>
</el-card>
</div>

<style>
.editor-wrap { border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; }
#toolbar-container { border-bottom: 1px solid #e4e7ed; }
#editor-container { min-height: 500px; }
</style>
{% endblock %}

{% block js %}
<!-- WangEditor -->
<link href="https://unpkg.com/@wangeditor/editor@5.1.23/dist/css/style.css" rel="stylesheet">
<script src="https://unpkg.com/@wangeditor/editor@5.1.23/dist/index.js"></script>

<script>
const col = '{{ col }}';
const initContent = `{{ pageData.content | safe if pageData.content else "" }}`;
const initStatus = parseInt('{{ pageData.status if pageData.status is defined else 1 }}') || 1;
const initUpdateTime = '{{ pageData.update_time if pageData.update_time else "" }}';
const initUpdateBy = '{{ pageData.update_by if pageData.update_by else "" }}';

const { createApp, ref, onMounted, onBeforeUnmount } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const saving = ref(false);
const pageStatus = ref(initStatus);
const updateInfo = ref('');
let editor = null;

// 格式化更新信息
if (initUpdateTime) {
updateInfo.value = '最后更新:' + initUpdateTime.slice(0, 16).replace('T', ' ') + (initUpdateBy ? ' · ' + initUpdateBy : '');
}

onMounted(() => {
// 创建编辑器
const { createEditor, createToolbar } = window.wangEditor;

editor = createEditor({
selector: '#editor-container',
html: initContent || '<p></p>',
config: {
placeholder: '请输入页面内容...',
MENU_CONF: {
uploadImage: {
server: '/admin/upload',
fieldName: 'file',
maxFileSize: 10 * 1024 * 1024,
customInsert(res, insertFn) {
if (res.code === 0) {
insertFn(res.data.url, res.data.name || '', res.data.url);
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
}
}
}
}
});

createToolbar({
editor,
selector: '#toolbar-container',
config: {}
});
});

onBeforeUnmount(() => {
if (editor) {
editor.destroy();
editor = null;
}
});

function openPreview() {
if (!editor) return;
const content = editor.getHtml();
const columnName = '{{ columnInfo.name if columnInfo else "页面预览" }}';
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${columnName} - 预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; color: #303133; background: #fff; }
.preview-tip { background: #ff7800; color: #fff; padding: 10px 0; text-align: center; font-size: 13px; }
.preview-tip span { background: rgba(0,0,0,.15); padding: 3px 10px; border-radius: 3px; font-size: 12px; margin-left: 8px; }
.preview-header { background: #fff; border-bottom: 1px solid #e4e7ed; padding: 20px 40px; }
.preview-header h1 { font-size: 24px; color: #1A3550; font-weight: 700; }
.preview-body { max-width: 960px; margin: 0 auto; padding: 40px 20px 60px; font-size: 15px; line-height: 2; }
.preview-body h1 { font-size: 28px; margin: 24px 0 16px; font-weight: 700; color: #1A3550; }
.preview-body h2 { font-size: 22px; margin: 20px 0 12px; font-weight: 600; color: #1A3550; border-left: 4px solid #ff7800; padding-left: 14px; }
.preview-body h3 { font-size: 17px; margin: 16px 0 10px; font-weight: 600; }
.preview-body p { margin-bottom: 14px; }
.preview-body blockquote { border-left: 4px solid #ff7800; padding: 14px 20px; margin: 16px 0; background: #fff8f0; color: #606266; }
.preview-body ul, .preview-body ol { padding-left: 26px; margin-bottom: 14px; }
.preview-body li { margin-bottom: 6px; }
.preview-body table { width: 100%; border-collapse: collapse; margin: 16px 0; }
.preview-body td, .preview-body th { border: 1px solid #e4e7ed; padding: 10px 14px; font-size: 14px; }
.preview-body th { background: #fafafa; font-weight: 600; }
.preview-body img { max-width: 100%; border-radius: 6px; margin: 12px 0; }
.preview-body a { color: #ff7800; }
</style>
</head>
<body>
<div class="preview-tip">前台预览模式 <span>内容尚未发布,仅供预览</span></div>
<div class="preview-header"><h1>${columnName}</h1></div>
<div class="preview-body">${content}</div>
</body>
</html>`;

const win = window.open('', '_blank');
win.document.write(html);
win.document.close();
}

async function savePage() {
if (!editor) return;
const content = editor.getHtml();
saving.value = true;
try {
const res = await fetch('/admin/content/page/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ col, content, status: pageStatus.value })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
// 更新时间显示
const now = new Date();
const timeStr = now.toISOString().slice(0, 16).replace('T', ' ');
updateInfo.value = '最后更新:' + timeStr + ' · 管理员';
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving.value = false;
}
}

return { saving, pageStatus, updateInfo, openPreview, savePage, View: ElementPlusIconsVue.View, DocumentChecked: ElementPlusIconsVue.DocumentChecked };
}
});

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#pageApp');
</script>
{% endblock %}

+ 304
- 0
view/admin/content/person_index.html Ver fichero

@@ -0,0 +1,304 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '人员列表' }}{% endblock %}

{% block content %}
<div id="personApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else '人员列表' }}</span>
<el-tag type="danger" size="small">人员列表</el-tag>
</div>
<el-button type="primary" @click="openDialog()">+ 新增人员</el-button>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4">
<el-input v-model="query.keyword" placeholder="搜索姓名..." style="width:160px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.category" placeholder="分类" style="width:140px;" clearable>
<el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>

<!-- 卡片列表 -->
<div class="person-grid" v-loading="loading">
<div v-for="(item, index) in list" :key="item.id" class="person-card">
<div class="person-avatar" :style="{ background: item.avatar ? 'transparent' : getColor(index) }">
<img v-if="item.avatar" :src="item.avatar" :alt="item.name">
<span v-else>${ getInitial(item.name) }</span>
</div>
<div class="person-body">
<div class="person-name">${ item.name }</div>
<div class="person-title">${ item.title }</div>
<div class="person-category" v-if="item.category">
<el-tag size="small" type="info">${ item.category }</el-tag>
</div>
<div class="person-desc" v-if="item.description">${ item.description }</div>
<div class="person-actions">
<span class="person-sort">排序: ${ item.sort }</span>
<el-button type="primary" link size="small" @click="openDialog(item)">编辑</el-button>
<el-button type="danger" link size="small" @click="deleteItem(item)">删除</el-button>
</div>
</div>
</div>
<!-- 新增卡片 -->
<div class="person-card person-card-add" @click="openDialog()">
<div class="add-inner">
<el-icon :size="32"><Plus /></el-icon>
<span>新增人员</span>
</div>
</div>
</div>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="580px" destroy-on-close draggable top="5vh">
<div class="dialog-scroll-body">
<el-form :model="form" label-width="80px">
<!-- 头像上传 -->
<el-form-item label="头像">
<div class="avatar-upload-wrap">
<div class="avatar-upload" @click="triggerUpload">
<img v-if="form.avatar" :src="form.avatar" class="avatar-preview">
<template v-else>
<div class="person-avatar-ph">
<span class="person-avatar-plus">+</span>
<span class="person-avatar-txt">上传<br>头像</span>
</div>
</template>
</div>
<input type="file" ref="fileInput" accept="image/*" style="display:none;" @change="handleUpload">
<div class="avatar-tip">建议尺寸 200×200,支持 JPG/PNG</div>
</div>
</el-form-item>

<el-form-item label="姓名" required>
<el-input v-model="form.name" placeholder="请输入姓名"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="职务" required class="flex-1">
<el-input v-model="form.title" placeholder="如:理事长"></el-input>
</el-form-item>
<el-form-item label="分类" required class="flex-1">
<el-select v-model="form.category" placeholder="选择或输入分类" filterable allow-create style="width:100%;">
<el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat"></el-option>
</el-select>
</el-form-item>
</div>
<el-form-item label="简介">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入人员简介"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="排序" class="flex-1">
<el-input-number v-model="form.sort" :min="1" style="width:100%;"></el-input-number>
</el-form-item>
<el-form-item label="状态" class="flex-1">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>

<style>
.person-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.person-card { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 16px; display: flex; gap: 14px; transition: .2s; align-items: flex-start; }
.person-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
.person-avatar { width: 64px; height: 64px; border-radius: 50%; background: #ff7800; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 600; flex-shrink: 0; overflow: hidden; }
.person-avatar img { width: 100%; height: 100%; object-fit: cover; }
.person-body { flex: 1; min-width: 0; }
.person-name { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 2px; }
.person-title { font-size: 12px; color: #ff7800; margin-bottom: 4px; }
.person-category { margin-bottom: 6px; }
.person-desc { font-size: 12px; color: #909399; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.person-actions { display: flex; align-items: center; gap: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0; margin-top: 8px; }
.person-sort { font-size: 11px; color: #c0c4cc; margin-right: auto; }
.person-card-add { border: 2px dashed #ddd; display: flex; align-items: center; justify-content: center; cursor: pointer; min-height: 140px; }
.person-card-add:hover { border-color: #ff7800; background: #fff7f0; }
.add-inner { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #bbb; font-size: 13px; }

/* 弹窗滚动区域 */
.dialog-scroll-body { max-height: 65vh; overflow-y: auto; overflow-x: hidden; }

/* 头像上传 */
.avatar-upload-wrap { display: flex; flex-direction: column; align-items: flex-start; }
.avatar-upload { width: 80px; height: 80px; border-radius: 50%; border: 2px dashed #ddd; cursor: pointer; display: flex; align-items: center; justify-content: center; overflow: hidden; transition: .2s; background: #fafafa; }
.avatar-upload:hover { border-color: #ff7800; background: #fff7f0; }
.avatar-preview { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.person-avatar-ph { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.person-avatar-plus { font-size: 24px; color: #bbb; line-height: 1; }
.person-avatar-txt { font-size: 11px; color: #bbb; line-height: 1.3; margin-top: 2px; }
.avatar-tip { font-size: 12px; color: #999; margin-top: 8px; }
</style>
{% endblock %}

{% block js %}
<script>
const col = '{{ col }}';
const { createApp, ref, reactive, onMounted } = Vue;

const avatarColors = ['#ff7800', '#1A3550', '#67c23a', '#e6a23c', '#909399', '#409eff', '#f56c6c'];

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
const query = reactive({ keyword: '', category: '' });
const categories = ref([]);

const dialogVisible = ref(false);
const dialogTitle = ref('新增人员');
const saving = ref(false);
const fileInput = ref(null);

const defaultForm = { id: null, name: '', title: '', category: '', avatar: '', description: '', sort: 1, status: 1 };
const form = reactive({ ...defaultForm });

function getInitial(name) {
return (name || '?').charAt(0);
}

function getColor(index) {
return avatarColors[index % avatarColors.length];
}

async function loadCategories() {
try {
const res = await fetch('/admin/content/person/categories?col=' + col).then(r => r.json());
if (res.code === 0) {
categories.value = res.data || [];
}
} catch {}
}

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/person/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.category = '';
page.value = 1;
loadList();
}

function openDialog(item) {
if (item) {
dialogTitle.value = '编辑人员';
Object.assign(form, {
id: item.id, name: item.name, title: item.title || '',
category: item.category || '', avatar: item.avatar || '',
description: item.description || '', sort: item.sort || 1, status: item.status
});
} else {
dialogTitle.value = '新增人员';
Object.assign(form, { ...defaultForm, sort: list.value.length + 1 });
}
dialogVisible.value = true;
}

function triggerUpload() {
fileInput.value?.click();
}

async function handleUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
if (res.code === 0) {
form.avatar = res.data.url;
ElementPlus.ElMessage.success('上传成功');
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (err) {
ElementPlus.ElMessage.error('上传失败');
}
e.target.value = '';
}

async function saveItem() {
if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入姓名'); return; }
if (!form.title.trim()) { ElementPlus.ElMessage.warning('请输入职务'); return; }
if (!form.category) { ElementPlus.ElMessage.warning('请选择分类'); return; }

saving.value = true;
try {
const url = form.id ? '/admin/content/person/edit' : '/admin/content/person/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
loadCategories();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + item.name + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/person/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

onMounted(() => {
loadCategories();
loadList();
});

return {
loading, list, total, page, pageSize, query, categories,
dialogVisible, dialogTitle, saving, form, fileInput,
getInitial, getColor,
loadList, resetQuery, openDialog, triggerUpload, handleUpload, saveItem, deleteItem
};
}
});

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#personApp');
</script>
{% endblock %}

+ 309
- 0
view/admin/content/text_index.html Ver fichero

@@ -0,0 +1,309 @@
{% extends "../layout.html" %}

{% block title %}{{ columnInfo.name if columnInfo else '文字列表' }}{% endblock %}

{% block content %}
<div id="textApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ columnInfo.name if columnInfo else '文字列表' }}</span>
<el-tag type="warning" size="small">文字列表</el-tag>
</div>
<el-button type="primary" @click="openDialog()">+ 新增</el-button>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4">
<el-input v-model="query.keyword" placeholder="搜索标题..." style="width:200px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.year" placeholder="年度" style="width:100px;" clearable>
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y"></el-option>
</el-select>
<el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
<el-option label="已发布" :value="1"></el-option>
<el-option label="待审核" :value="2"></el-option>
<el-option label="草稿" :value="0"></el-option>
</el-select>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery" class="ml-2">重置</el-button>
</div>

<!-- 信息栏 -->
<div class="flex items-center gap-3 mb-3 text-sm text-gray-500">
<span>共 <b class="text-orange-500">${ total }</b> 条记录</span>
<div class="flex-1"></div>
<el-button size="small" :disabled="!selectedIds.length" @click="batchDelete">批量删除</el-button>
</div>

<!-- 表格 -->
<el-table :data="list" v-loading="loading" @selection-change="handleSelectionChange" border>
<el-table-column type="selection" width="40"></el-table-column>
<el-table-column prop="title" label="标题" min-width="300">
<template #default="{ row }">
<a :href="row.file_url" target="_blank" class="text-link">${ row.title }</a>
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="120"></el-table-column>
<el-table-column prop="file_type" label="附件" width="80">
<template #default="{ row }">
<span class="file-badge" :class="'file-' + row.file_type?.toLowerCase()">${ row.file_type || '-' }</span>
</template>
</el-table-column>
<el-table-column prop="year" label="年度" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusMap[row.status]?.type" size="small">${ statusMap[row.status]?.text }</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="发布日期" width="110">
<template #default="{ row }">${ row.create_time?.slice(0,10) }</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDialog(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="downloadFile(row)" :disabled="!row.file_url">下载</el-button>
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="flex justify-end mt-4" v-if="total > pageSize">
<el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close draggable top="5vh">
<el-form :model="form" label-width="80px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题"></el-input>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="分类" class="flex-1">
<el-select v-model="form.category" placeholder="选择分类" style="width:100%;" :teleported="false" filterable allow-create>
<el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c"></el-option>
</el-select>
</el-form-item>
<el-form-item label="年度" class="flex-1">
<el-select v-model="form.year" placeholder="选择年度" style="width:100%;" :teleported="false">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y"></el-option>
</el-select>
</el-form-item>
</div>
<el-form-item label="附件">
<div class="flex items-center gap-2 w-full">
<el-input v-model="form.file_name" placeholder="选择附件(PDF/DOC/XLS)" readonly class="flex-1"></el-input>
<el-button @click="triggerUpload">选择文件</el-button>
<input type="file" ref="fileInput" accept=".pdf,.doc,.docx,.xls,.xlsx" style="display:none;" @change="handleUpload">
</div>
<div class="text-xs text-gray-400 mt-1">支持 PDF、DOC、XLS 格式</div>
</el-form-item>
<div class="flex gap-4">
<el-form-item label="附件类型" class="flex-1">
<el-select v-model="form.file_type" style="width:100%;" :teleported="false">
<el-option label="PDF" value="PDF"></el-option>
<el-option label="DOC" value="DOC"></el-option>
<el-option label="XLS" value="XLS"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态" class="flex-1">
<el-select v-model="form.status" style="width:100%;" :teleported="false">
<el-option label="已发布" :value="1"></el-option>
<el-option label="待审核" :value="2"></el-option>
<el-option label="草稿" :value="0"></el-option>
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>

<style>
.text-link { color: #333; transition: .15s; }
.text-link:hover { color: #ff7800; }
.file-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: 600; background: #fef0f0; color: #f56c6c; letter-spacing: .5px; }
.file-badge.file-xls { background: #f0f9eb; color: #67c23a; }
.file-badge.file-xlsx { background: #f0f9eb; color: #67c23a; }
.file-badge.file-doc { background: #ecf5ff; color: #409eff; }
.file-badge.file-docx { background: #ecf5ff; color: #409eff; }
</style>
{% endblock %}

{% block js %}
<script>
const col = '{{ col }}';
const { createApp, ref, reactive, computed, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const query = reactive({ keyword: '', year: '', status: '' });
const selectedIds = ref([]);

const dialogVisible = ref(false);
const dialogTitle = ref('新增');
const saving = ref(false);
const fileInput = ref(null);

const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => String(currentYear - i));
const categoryOptions = ['药品援助公示', '管理制度', '机构年报', '审计报告', '财务报告', '关联方信息', '项目执行报告', '党建规章'];
const statusMap = {
1: { type: 'success', text: '已发布' },
2: { type: 'warning', text: '待审核' },
0: { type: 'info', text: '草稿' }
};

const defaultForm = {
id: null, title: '', category: '', year: String(currentYear),
file_url: '', file_name: '', file_type: 'PDF', status: 1
};
const form = reactive({ ...defaultForm });

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
const res = await fetch('/admin/content/text/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.year = '';
query.status = '';
page.value = 1;
loadList();
}

function handleSelectionChange(rows) {
selectedIds.value = rows.map(r => r.id);
}

function openDialog(item) {
if (item) {
dialogTitle.value = '编辑';
Object.assign(form, {
id: item.id, title: item.title, category: item.category || '',
year: item.year || String(currentYear), file_url: item.file_url || '',
file_name: item.file_name || '', file_type: item.file_type || 'PDF',
status: item.status
});
} else {
dialogTitle.value = '新增';
Object.assign(form, { ...defaultForm });
}
dialogVisible.value = true;
}

function triggerUpload() {
fileInput.value?.click();
}

async function handleUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
if (res.code === 0) {
form.file_url = res.data.url;
form.file_name = file.name;
// 自动识别文件类型
const ext = file.name.split('.').pop().toUpperCase();
if (['PDF'].includes(ext)) form.file_type = 'PDF';
else if (['DOC', 'DOCX'].includes(ext)) form.file_type = 'DOC';
else if (['XLS', 'XLSX'].includes(ext)) form.file_type = 'XLS';
ElementPlus.ElMessage.success('上传成功');
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (err) {
ElementPlus.ElMessage.error('上传失败');
}
e.target.value = '';
}

async function saveItem() {
if (!form.title.trim()) { ElementPlus.ElMessage.warning('请输入标题'); return; }
saving.value = true;
try {
const url = form.id ? '/admin/content/text/edit' : '/admin/content/text/add';
const body = { ...form, col };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally { saving.value = false; }
}

async function deleteItem(item) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + item.title + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/text/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

async function batchDelete() {
if (!selectedIds.value.length) return;
try {
await ElementPlus.ElMessageBox.confirm('确定删除选中的 ' + selectedIds.value.length + ' 条记录?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/text/batchDelete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds.value }) }).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

function downloadFile(row) {
if (row.file_url) {
window.open(row.file_url, '_blank');
}
}

onMounted(() => loadList());

return {
loading, list, total, page, pageSize, query, selectedIds,
dialogVisible, dialogTitle, saving, form, fileInput,
yearOptions, categoryOptions, statusMap,
loadList, resetQuery, handleSelectionChange, openDialog,
triggerUpload, handleUpload, saveItem, deleteItem, batchDelete, downloadFile
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#textApp');
</script>
{% endblock %}

+ 207
- 0
view/admin/dashboard_index.html Ver fichero

@@ -0,0 +1,207 @@
{% extends "./layout.html" %}

{% block title %}控制台{% endblock %}

{% block css %}
<style>
.stat-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px;}
.stat-card{
background:var(--white);border-radius:var(--radius);box-shadow:var(--shadow);
padding:20px;display:flex;flex-wrap:wrap;align-items:flex-start;gap:14px;
position:relative;overflow:hidden;transition:.2s;
}
.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.1);}
.stat-icon{
width:48px;height:48px;border-radius:10px;display:flex;
align-items:center;justify-content:center;flex-shrink:0;
}
.stat-icon svg{width:24px;height:24px;}
.stat-info{flex:1;min-width:0;}
.stat-value{font-size:26px;font-weight:700;color:var(--text);font-family:var(--font-en);line-height:1.2;}
.stat-label{font-size:12px;color:var(--text-secondary);margin-top:4px;}
.stat-footer{
width:100%;padding-top:12px;margin-top:4px;border-top:1px solid var(--border-light);
font-size:12px;color:var(--text-secondary);
}
.stat-up{color:var(--success);font-weight:500;}
.stat-down{color:var(--danger);font-weight:500;}

.dashboard-row{display:flex;gap:16px;}

.shortcuts{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;}
.shortcut-item{
display:flex;flex-direction:column;align-items:center;gap:8px;
padding:14px 8px;border-radius:var(--radius);transition:.15s;cursor:pointer;
text-decoration:none;
}
.shortcut-item:hover{background:var(--bg);}
.shortcut-icon{
width:40px;height:40px;border-radius:10px;display:flex;
align-items:center;justify-content:center;
}
.shortcut-icon svg{width:20px;height:20px;}
.shortcut-item span{font-size:12px;color:var(--text-regular);}

.todo-list{display:flex;flex-direction:column;gap:0;}
.todo-item{
display:flex;align-items:center;gap:10px;padding:10px 0;
border-bottom:1px solid var(--border-light);font-size:13px;color:var(--text-regular);
}
.todo-item:last-child{border-bottom:none;}
.todo-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;}
.todo-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}

@media(max-width:1200px){
.stat-cards{grid-template-columns:repeat(2,1fr);}
.dashboard-row{flex-direction:column;}
.dashboard-row>div:last-child{width:100%;}
}
</style>
{% endblock %}

{% block content %}
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(232,117,26,.1);color:var(--primary);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.contentCount or 0 }}</div>
<div class="stat-label">内容总数</div>
</div>
<div class="stat-footer"><span class="stat-up">↑ 12%</span> 较上月</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:rgba(230,162,60,.1);color:var(--warning);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.pendingCount or 0 }}</div>
<div class="stat-label">待审核</div>
</div>
<div class="stat-footer"><span class="stat-down">↓ 3</span> 较昨日</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:rgba(103,194,58,.1);color:var(--success);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.visitCount or '0' }}</div>
<div class="stat-label">本月访问</div>
</div>
<div class="stat-footer"><span class="stat-up">↑ 28%</span> 较上月</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:rgba(26,53,80,.1);color:var(--blue);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
<div class="stat-info">
<div class="stat-value">¥{{ stats.donationTotal or '0' }}</div>
<div class="stat-label">累计捐赠</div>
</div>
<div class="stat-footer"><span class="stat-up">↑ 5.2%</span> 较上季</div>
</div>
</div>

<!-- 下方两列 -->
<div class="dashboard-row">
<!-- 最近更新 -->
<div class="card" style="flex:1;">
<div class="card-header">
<span class="card-title">最近更新</span>
<button class="el-btn el-btn-sm">查看全部</button>
</div>
<table class="el-table">
<thead>
<tr>
<th>标题</th>
<th>栏目</th>
<th>状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{% if recentList and recentList.length %}
{% for item in recentList %}
<tr>
<td>{{ item.title }}</td>
<td>{{ item.column_name }}</td>
<td>
{% if item.status == 1 %}
<span class="el-tag el-tag-success">已发布</span>
{% else %}
<span class="el-tag el-tag-warning">待审核</span>
{% endif %}
</td>
<td>{{ item.update_time }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" style="text-align:center;color:var(--text-secondary);">暂无数据</td>
</tr>
{% endif %}
</tbody>
</table>
</div>

<!-- 快捷操作 & 待办 -->
<div style="width:320px;display:flex;flex-direction:column;gap:16px;">
<!-- 快捷操作 -->
<div class="card">
<div class="card-header">
<span class="card-title">快捷操作</span>
</div>
<div class="shortcuts">
<a href="/admin/content/article-edit.html" class="shortcut-item">
<div class="shortcut-icon" style="background:rgba(232,117,26,.1);color:var(--primary);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
<span>发布文章</span>
</a>
<a href="/admin/content/image.html" class="shortcut-item">
<div class="shortcut-icon" style="background:rgba(103,194,58,.1);color:var(--success);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<span>上传图片</span>
</a>
<a href="/admin/data/medicine.html" class="shortcut-item">
<div class="shortcut-icon" style="background:rgba(26,53,80,.1);color:var(--blue);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
<span>录入数据</span>
</a>
<a href="#" class="shortcut-item">
<div class="shortcut-icon" style="background:rgba(230,162,60,.1);color:var(--warning);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<span>审核内容</span>
</a>
</div>
</div>

<!-- 待办事项 -->
<div class="card">
<div class="card-header">
<span class="card-title">待办事项</span>
</div>
<div class="todo-list">
{% if todoList and todoList.length %}
{% for item in todoList %}
<div class="todo-item">
<span class="todo-dot" style="background:var(--warning);"></span>
<span class="todo-text">{{ item.title }}</span>
</div>
{% endfor %}
{% else %}
<div class="todo-item">
<span class="todo-dot" style="background:var(--success);"></span>
<span class="todo-text">暂无待办事项</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

+ 306
- 0
view/admin/data/medicine_index.html Ver fichero

@@ -0,0 +1,306 @@
{% extends "../layout.html" %}

{% block title %}药品援助记录{% endblock %}

{% block content %}
<div id="medicineApp">
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键词" class="!mb-0">
<el-input v-model="keyword" placeholder="药品名称/受助人..." clearable style="width:180px;" />
</el-form-item>
<el-form-item label="年度" class="!mb-0">
<el-select v-model="yearFilter" placeholder="全部" clearable style="width:100px;">
<el-option v-for="y in years" :key="y" :label="y" :value="y"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态" class="!mb-0">
<el-select v-model="statusFilter" placeholder="全部" clearable style="width:110px;">
<el-option label="已发放" :value="1"></el-option>
<el-option label="待发放" :value="2"></el-option>
<el-option label="已取消" :value="3"></el-option>
</el-select>
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 列表 -->
<el-card shadow="never">
<template #header>
<el-button type="primary" @click="showAddModal">+ 新增记录</el-button>
</template>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="name" label="药品名称" min-width="200" ></el-table-column>
<el-table-column prop="person" label="受助人" width="100" ></el-table-column>
<el-table-column prop="region" label="地区" width="120">
<template #default="{ row }">${ row.region || '—' }</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" ></el-table-column>
<el-table-column prop="amount" label="金额 (元)" width="120" align="right">
<template #default="{ row }">¥ ${ formatMoney(row.amount) }</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success" size="small">已发放</el-tag>
<el-tag v-else-if="row.status === 2" type="warning" size="small">待发放</el-tag>
<el-tag v-else type="info" size="small">已取消</el-tag>
</template>
</el-table-column>
<el-table-column prop="distribute_date" label="发放日期" width="120">
<template #default="{ row }">${ row.distribute_date || '—' }</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="editItem(row)">编辑</el-button>
<el-button type="danger" link @click="deleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item label="药品名称" required>
<el-input v-model="form.name" placeholder="请输入药品名称" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="受助人" required>
<el-input v-model="form.person" placeholder="如:张*三" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="地区">
<el-input v-model="form.region" placeholder="如:北京" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="数量" required>
<el-input v-model="form.quantity" placeholder="如:1盒" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="金额 (元)" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" style="width:100%;" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%;" :teleported="false">
<el-option label="已发放" :value="1"></el-option>
<el-option label="待发放" :value="2"></el-option>
<el-option label="已取消" :value="3"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发放日期">
<el-date-picker v-model="form.distribute_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%;" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<script>
const yearsData = {{ years | dump | safe }};

const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const yearFilter = ref('');
const statusFilter = ref('');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const years = ref(yearsData || []);

// 弹窗
const dialogVisible = ref(false);
const dialogTitle = ref('新增记录');
const saving = ref(false);
const form = reactive({
id: null,
name: '',
person: '',
region: '',
quantity: '1盒',
amount: 0,
status: 2,
distribute_date: '',
remark: ''
});

// 格式化金额
function formatMoney(n) {
return Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2 });
}

// 加载列表
async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
const params = new URLSearchParams({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: keyword.value,
year: yearFilter.value,
status: statusFilter.value
});
const res = await fetch('/admin/data/medicine/list?' + params).then(r => r.json());
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

// 重置筛选
function resetFilter() {
keyword.value = '';
yearFilter.value = '';
statusFilter.value = '';
pagination.page = 1;
loadList();
}

// 显示新增弹窗
function showAddModal() {
dialogTitle.value = '新增记录';
Object.assign(form, {
id: null,
name: '',
person: '',
region: '',
quantity: '1盒',
amount: 0,
status: 2,
distribute_date: '',
remark: ''
});
dialogVisible.value = true;
}

// 编辑
function editItem(row) {
dialogTitle.value = '编辑记录';
Object.assign(form, {
id: row.id,
name: row.name,
person: row.person,
region: row.region || '',
quantity: row.quantity || '1盒',
amount: row.amount || 0,
status: row.status || 2,
distribute_date: row.distribute_date || '',
remark: row.remark || ''
});
dialogVisible.value = true;
}

// 保存
async function saveItem() {
if (!form.name.trim()) {
ElementPlus.ElMessage.warning('请输入药品名称');
return;
}
if (!form.person.trim()) {
ElementPlus.ElMessage.warning('请输入受助人');
return;
}
saving.value = true;
try {
const url = form.id ? '/admin/data/medicine/edit' : '/admin/data/medicine/add';
const body = { ...form };
if (!form.id) delete body.id;

const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving.value = false;
}
}

// 删除
async function deleteItem(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要删除该记录吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/data/medicine/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

onMounted(() => loadList());

return {
keyword, yearFilter, statusFilter, loading, tableData, pagination, years,
dialogVisible, dialogTitle, saving, form,
formatMoney, loadList, resetFilter, showAddModal, editItem, saveItem, deleteItem
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#medicineApp');
</script>
{% endblock %}

+ 68
- 0
view/admin/layout.html Ver fichero

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}管理后台{% endblock %} - 北京维康慈善基金会</title>
<!-- 自定义样式(先加载,作为基础样式) -->
<link rel="stylesheet" href="/static/css/admin.css">
<!-- Tailwind CSS 4(后加载,工具类优先级更高) -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Element Plus CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.13.2/dist/index.css">
<!-- Element Plus 主色调覆盖 -->
<style>
:root {
--el-color-primary: #ff7800;
--el-color-primary-light-1: #ff861a;
--el-color-primary-light-2: #ff9333;
--el-color-primary-light-3: #ffa14d;
--el-color-primary-light-4: #ffae66;
--el-color-primary-light-5: #ffbc80;
--el-color-primary-light-6: #ffc999;
--el-color-primary-light-7: #ffd7b3;
--el-color-primary-light-8: #ffe4cc;
--el-color-primary-light-9: #fff2e6;
--el-color-primary-dark-2: #cc6000;
}
/* 修复 Tailwind CSS 4 与 Element Plus 表格冲突 */
.el-table table { display: table; }
.el-table thead { display: table-header-group; }
.el-table tbody { display: table-row-group; }
.el-table tr { display: table-row; }
.el-table th, .el-table td { display: table-cell; }
</style>
{% block css %}{% endblock %}
</head>
<body data-page="{{ currentPage }}">
<div class="admin-layout" id="app">
{% include "./common/_sidebar.html" %}

<div class="main-area">
{% include "./common/_header.html" %}

<div class="page-content">
{% block content %}{% endblock %}
</div>
</div>
</div>

<!-- Vue 3.5 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.5/dist/vue.global.prod.js"></script>
<!-- Element Plus -->
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.13.2/dist/index.full.min.js"></script>
<!-- Element Plus Icons -->
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
<!-- Element Plus 中文语言包 -->
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.13.2/dist/locale/zh-cn.min.js"></script>
{% block js %}{% endblock %}
<script>
// 菜单展开/收起
document.querySelectorAll('.menu-parent > .menu-item').forEach(item => {
item.addEventListener('click', () => {
item.parentElement.classList.toggle('open');
});
});
</script>
</body>
</html>

+ 428
- 0
view/admin/system/column_index.html Ver fichero

@@ -0,0 +1,428 @@
{% extends "../layout.html" %}

{% block title %}栏目管理{% endblock %}

{% block content %}
<div id="columnApp">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">栏目管理</span>
<div class="flex gap-2">
<el-button @click="toggleAllTree">展开/折叠全部</el-button>
<el-button type="primary" @click="showAddModal(0, null)">+ 新增顶级栏目</el-button>
</div>
</div>
</template>

<el-tree ref="treeRef" :data="treeData" node-key="id" default-expand-all :expand-on-click-node="false" v-loading="loading">
<template #default="{ node, data }">
<div class="tree-node flex items-center justify-between w-full py-2 pr-4">
<div class="flex items-center gap-2">
<el-icon v-if="data.icon && data.parent_id === 0" :size="18"><component :is="'el-icon-' + data.icon"></component></el-icon>
<span v-else class="text-base">${ data.parent_id === 0 ? '📁' : '📄' }</span>
<span class="font-medium">${ data.name }</span>
<el-tag v-if="data.key" size="small" type="info">${ data.key }</el-tag>
<el-tag v-if="data.parent_id === 0 && data.is_single_page" size="small">单页面</el-tag>
<el-tag v-if="data.type" :type="typeTagMap[data.type]" size="small">${ typeMap[data.type] }</el-tag>
<el-tag v-if="!data.visible" type="info" size="small">隐藏</el-tag>
</div>
<div class="flex items-center gap-1">
<el-button type="primary" link size="small" @click.stop="editItem(data)">编辑</el-button>
<el-button v-if="data.parent_id === 0" type="primary" link size="small" @click.stop="showAddModal(data.id, data)">+ 子栏目</el-button>
<el-button v-if="data.type === 'form'" type="warning" link size="small" @click.stop="openFormConfig(data)">表单配置</el-button>
<el-button v-if="data.parent_id !== 0" type="danger" link size="small" @click.stop="deleteItem(data)">删除</el-button>
</div>
</div>
</template>
</el-tree>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
<el-tabs v-model="activeTab" v-if="showSeoTab">
<el-tab-pane label="基本信息" name="basic">
<el-form :model="form" label-width="100px">
<el-form-item label="栏目名称" required>
<el-input v-model="form.name" placeholder="请输入栏目名称"></el-input>
</el-form-item>
<el-form-item label="栏目标识">
<el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input>
</el-form-item>
<el-form-item label="图标" v-if="form.parent_id === 0">
<div class="flex items-center gap-2">
<el-button @click="iconPickerVisible = true">
<el-icon v-if="form.icon"><component :is="'el-icon-' + form.icon"></component></el-icon>
<span v-else>选择图标</span>
</el-button>
<span v-if="form.icon" class="text-gray-500">${ form.icon }</span>
<el-button v-if="form.icon" link type="danger" @click="form.icon = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="栏目类型" v-if="form.parent_id === 0">
<el-radio-group v-model="form.is_single_page">
<el-radio :value="1">单页面(二级为模块)</el-radio>
<el-radio :value="0">多页面(二级为独立页面)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容类型">
<el-select v-model="form.type" placeholder="无" style="width:100%;" :teleported="false">
<el-option label="无" value=""></el-option>
<el-option label="图文列表" value="article"></el-option>
<el-option label="图片列表" value="image"></el-option>
<el-option label="文字列表" value="text"></el-option>
<el-option label="单页" value="page"></el-option>
<el-option label="人员列表" value="person"></el-option>
<el-option label="自定义表单" value="form"></el-option>
<el-option label="捐赠收支数据" value="donation"></el-option>
<el-option label="岗位管理" value="job"></el-option>
<el-option label="轮播图" value="banner"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="1" style="width:120px;"></el-input-number>
</el-form-item>
<el-form-item label="显示状态">
<el-switch v-model="form.visible" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="外部链接">
<el-input v-model="form.link" placeholder="留空则使用系统页面"></el-input>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="SEO配置" name="seo"></el-tab-pane>
<el-form :model="form" label-width="120px">
<el-form-item label="页面标题">
<el-input v-model="form.seo_title" placeholder="用于浏览器标签"></el-input>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="form.seo_keywords" placeholder="多个关键词用逗号分隔"></el-input>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.seo_description" type="textarea" :rows="3"></el-input>
</el-form-item>
<el-form-item label="URL别名">
<el-input v-model="form.slug" placeholder="如: about-us"></el-input>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<!-- 无SEO配置时直接显示基本信息 -->
<el-form v-if="!showSeoTab" :model="form" label-width="100px">
<el-form-item label="栏目名称" required>
<el-input v-model="form.name" placeholder="请输入栏目名称"></el-input>
</el-form-item>
<el-form-item label="栏目标识">
<el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input>
</el-form-item>
<el-form-item label="图标" v-if="form.parent_id === 0">
<div class="flex items-center gap-2">
<el-button @click="iconPickerVisible = true">
<el-icon v-if="form.icon"><component :is="'el-icon-' + form.icon"></component></el-icon>
<span v-else>选择图标</span>
</el-button>
<span v-if="form.icon" class="text-gray-500">${ form.icon }</span>
<el-button v-if="form.icon" link type="danger" @click="form.icon = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="栏目类型" v-if="form.parent_id === 0">
<el-radio-group v-model="form.is_single_page">
<el-radio :value="1">单页面(二级为模块)</el-radio>
<el-radio :value="0">多页面(二级为独立页面)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容类型">
<el-select v-model="form.type" placeholder="无" style="width:100%;" :teleported="false">
<el-option label="无" value=""></el-option>
<el-option label="图文列表" value="article"></el-option>
<el-option label="图片列表" value="image"></el-option>
<el-option label="文字列表" value="text"></el-option>
<el-option label="单页" value="page"></el-option>
<el-option label="人员列表" value="person"></el-option>
<el-option label="自定义表单" value="form"></el-option>
<el-option label="捐赠收支数据" value="donation"></el-option>
<el-option label="岗位管理" value="job"></el-option>
<el-option label="轮播图" value="banner"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="1" style="width:120px;"></el-input-number>
</el-form-item>
<el-form-item label="显示状态">
<el-switch v-model="form.visible" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="外部链接">
<el-input v-model="form.link" placeholder="留空则使用系统页面"></el-input>
</el-form-item>
</el-form>
<template #footer></template>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
</template>
</el-dialog>

<!-- 图标选择弹窗 -->
<el-dialog v-model="iconPickerVisible" title="选择图标" width="600px">
<el-input v-model="iconSearch" placeholder="搜索图标..." class="mb-4" clearable></el-input>
<div class="icon-grid">
<div v-for="icon in filteredIcons" :key="icon" class="icon-item" :class="{ active: form.icon === icon }" @click="selectIcon(icon)">
<el-icon :size="20"><component :is="'el-icon-' + icon"></component></el-icon>
<span class="icon-name">${ icon }</span>
</div>
</div>
</el-dialog>

<!-- 表单配置抽屉 -->
<el-drawer v-model="formDrawerVisible" :title="formDrawerTitle" size="450px" destroy-on-close>
<div class="mb-3">
<el-button type="primary" size="small" @click="addFormField">+ 添加字段</el-button>
</div>
<div v-if="formFields.length === 0" class="text-center text-gray-400 py-10">暂无字段</div>
<div v-else class="space-y-3">
<el-card v-for="(field, idx) in formFields" :key="idx" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span>#${ idx + 1 }</span>
<div class="flex gap-1">
<el-button v-if="idx > 0" link size="small" @click="moveField(idx, -1)">↑</el-button>
<el-button v-if="idx < formFields.length - 1" link size="small" @click="moveField(idx, 1)">↓</el-button>
<el-button type="danger" link size="small" @click="removeField(idx)">删除</el-button>
</div>
</div>
</template>
<el-form label-width="70px" size="small">
<el-form-item label="字段名"><el-input v-model="field.name"></el-input></el-form-item>
<el-form-item label="类型">
<el-select v-model="field.fieldType" style="width:100%;" :teleported="false">
<el-option label="单行文本" value="text"></el-option>
<el-option label="多行文本" value="textarea"></el-option>
<el-option label="邮箱" value="email"></el-option>
<el-option label="日期" value="date"></el-option>
<el-option label="单选" value="radio"></el-option>
<el-option label="下拉选择" value="select"></el-option>
<el-option label="多选" value="checkbox"></el-option>
<el-option label="文件上传" value="file"></el-option>
</el-select>
</el-form-item>
<el-form-item label="必填"><el-switch v-model="field.required"></el-switch></el-form-item>
</el-form>
</el-card>
</div>
<template #footer>
<el-button @click="formDrawerVisible = false">取消</el-button>
<el-button type="primary" @click="saveFormConfig" :loading="formSaving">保存</el-button>
</template>
</el-drawer>
</div>

<style>
.tree-node { min-height: 40px; }
.el-tree-node__content { height: auto !important; padding: 4px 0; }
.icon-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; max-height: 360px; overflow-y: auto; }
.icon-item { display: flex; flex-direction: column; align-items: center; padding: 8px 4px; border: 1px solid #eee; border-radius: 4px; cursor: pointer; }
.icon-item:hover { border-color: #ff7800; background: #fff7f0; }
.icon-item.active { border-color: #ff7800; background: #ff7800; color: #fff; }
.icon-name { font-size: 9px; text-align: center; word-break: break-all; margin-top: 4px; line-height: 1.2; max-width: 100%; overflow: hidden; }
</style>
{% endblock %}

{% block js %}
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;

// Element Plus 常用图标列表
const iconList = [
'HomeFilled', 'House', 'OfficeBuilding', 'School', 'Shop', 'ShoppingCart',
'Document', 'DocumentCopy', 'Folder', 'FolderOpened', 'Files', 'Collection',
'Reading', 'Notebook', 'Tickets', 'Memo', 'Postcard', 'Message', 'ChatDotRound',
'Phone', 'PhoneFilled', 'Cellphone', 'Position', 'Location', 'LocationFilled',
'User', 'UserFilled', 'Avatar', 'Service', 'Coordinate', 'Management',
'Setting', 'Tools', 'Operation', 'Platform', 'Monitor', 'DataAnalysis',
'PieChart', 'DataLine', 'TrendCharts', 'Histogram', 'Odometer', 'Timer',
'Calendar', 'Clock', 'AlarmClock', 'Watch', 'Stopwatch', 'Bell', 'BellFilled',
'Notification', 'Flag', 'Trophy', 'Medal', 'Present', 'GoodsFilled', 'Goods',
'Box', 'Briefcase', 'Suitcase', 'SuitcaseLine', 'Wallet', 'WalletFilled',
'Money', 'Coin', 'CreditCard', 'Discount', 'PriceTag', 'Sell', 'SoldOut',
'Camera', 'CameraFilled', 'Picture', 'PictureFilled', 'Film', 'VideoCamera',
'Headset', 'Microphone', 'Mute', 'VideoPlay', 'VideoPause', 'MuteNotification',
'Star', 'StarFilled', 'Sunny', 'Moon', 'Cloudy', 'PartlyCloudy', 'Lightning',
'Sunrise', 'Sunset', 'MostlyCloudy', 'Pouring', 'Drizzling', 'WindPower',
'Link', 'Share', 'Connection', 'Promotion', 'Guide', 'Help', 'HelpFilled',
'QuestionFilled', 'InfoFilled', 'WarningFilled', 'SuccessFilled', 'CircleCheckFilled',
'Edit', 'EditPen', 'Delete', 'DeleteFilled', 'Plus', 'Minus', 'Check', 'Close',
'Search', 'ZoomIn', 'ZoomOut', 'View', 'Hide', 'Refresh', 'RefreshRight',
'Upload', 'Download', 'UploadFilled', 'Printer', 'List', 'Grid', 'Menu',
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'DArrowLeft', 'DArrowRight',
'Top', 'Bottom', 'Back', 'Right', 'Sort', 'SortUp', 'SortDown', 'Rank',
'Lock', 'Unlock', 'Key', 'Open', 'TurnOff', 'SwitchButton', 'SwitchFilled',
'Cpu', 'SetUp', 'Opportunity', 'Aim', 'Compass', 'MapLocation', 'Place',
'Bicycle', 'Ship', 'Van', 'Truck', 'CaretTop', 'CaretBottom', 'CaretLeft', 'CaretRight',
'Coffee', 'CoffeeCup', 'Dessert', 'IceCream', 'Food', 'Burger', 'KnifeFork',
'Apple', 'Grape', 'Cherry', 'Orange', 'Pear', 'Watermelon', 'Sugar', 'Lollipop',
'FirstAidKit', 'MagicStick', 'Brush', 'Scissor', 'Stamp', 'Paperclip', 'Magnet',
'Basketball', 'Football', 'Baseball', 'Soccer', 'GobletFull', 'GobletSquareFull',
'IceDrink', 'IceTea', 'Milk', 'HotWater', 'NoSmoking', 'Smoking', 'Female', 'Male'
];

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const treeRef = ref(null);
const treeData = ref([]);

const typeMap = {
'article': '图文列表', 'image': '图片列表', 'text': '文字列表',
'page': '单页', 'person': '人员列表', 'form': '自定义表单',
'donation': '捐赠收支', 'job': '岗位管理', 'banner': '轮播图'
};
const typeTagMap = {
'article': 'primary', 'image': 'success', 'text': 'warning',
'page': 'info', 'person': 'danger', 'form': 'primary',
'donation': 'success', 'job': 'info', 'banner': 'warning'
};

const dialogVisible = ref(false);
const dialogTitle = ref('新增栏目');
const activeTab = ref('basic');
const saving = ref(false);
const parentIsSinglePage = ref(false);
const form = reactive({
id: null, parent_id: 0, name: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1,
link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: ''
});

// 图标选择
const iconPickerVisible = ref(false);
const iconSearch = ref('');
const filteredIcons = computed(() => {
if (!iconSearch.value) return iconList;
return iconList.filter(i => i.toLowerCase().includes(iconSearch.value.toLowerCase()));
});
function selectIcon(icon) {
form.icon = icon;
iconPickerVisible.value = false;
}

const showSeoTab = computed(() => {
if (form.parent_id === 0 && form.is_single_page === 1) return true;
if (form.parent_id !== 0 && !parentIsSinglePage.value) return true;
return false;
});

const formDrawerVisible = ref(false);
const formDrawerTitle = ref('表单字段配置');
const formFields = ref([]);
const formSaving = ref(false);
const currentFormColumnId = ref(null);

async function loadList() {
loading.value = true;
try {
const res = await fetch('/admin/system/column/list').then(r => r.json());
if (res.code === 0) treeData.value = res.data || [];
} finally { loading.value = false; }
}

let allExpanded = true;
function toggleAllTree() {
if (!treeRef.value) return;
allExpanded = !allExpanded;
Object.values(treeRef.value.store.nodesMap).forEach(node => {
if (node.childNodes?.length > 0) node.expanded = allExpanded;
});
}

function showAddModal(parentId, parentData) {
dialogTitle.value = parentId ? '新增子栏目' : '新增顶级栏目';
activeTab.value = 'basic';
parentIsSinglePage.value = parentData ? !!parentData.is_single_page : false;
Object.assign(form, {
id: null, parent_id: parentId || 0, name: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1,
link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: ''
});
dialogVisible.value = true;
}

function editItem(row) {
dialogTitle.value = '编辑栏目';
activeTab.value = 'basic';
if (row.parent_id !== 0) {
const parent = treeData.value.find(p => p.id === row.parent_id);
parentIsSinglePage.value = parent ? !!parent.is_single_page : false;
} else {
parentIsSinglePage.value = false;
}
Object.assign(form, {
id: row.id, parent_id: row.parent_id || 0, name: row.name, key: row.key || '', icon: row.icon || '',
type: row.type || '', is_single_page: row.is_single_page || 0, sort: row.sort || 1, visible: row.visible,
link: row.link || '', seo_title: row.seo_title || '', seo_keywords: row.seo_keywords || '',
seo_description: row.seo_description || '', slug: row.slug || ''
});
dialogVisible.value = true;
}

async function saveItem() {
if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入栏目名称'); return; }
saving.value = true;
try {
const url = form.id ? '/admin/system/column/edit' : '/admin/system/column/add';
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }).then(r => r.json());
if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); dialogVisible.value = false; loadList(); }
else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
} finally { saving.value = false; }
}

async function deleteItem(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定删除"' + row.name + '"?', '提示', { type: 'warning' });
const res = await fetch('/admin/system/column/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: row.id }) }).then(r => r.json());
if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
} catch {}
}

function openFormConfig(row) {
currentFormColumnId.value = row.id;
formDrawerTitle.value = '【' + row.name + '】表单配置';
formFields.value = row.form_config ? JSON.parse(row.form_config) : [];
formDrawerVisible.value = true;
}
function addFormField() { formFields.value.push({ name: '新字段', fieldType: 'text', required: false }); }
function moveField(idx, dir) {
const t = idx + dir;
if (t < 0 || t >= formFields.value.length) return;
[formFields.value[idx], formFields.value[t]] = [formFields.value[t], formFields.value[idx]];
}
function removeField(idx) { formFields.value.splice(idx, 1); }
async function saveFormConfig() {
formSaving.value = true;
try {
const res = await fetch('/admin/system/column/saveFormConfig', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: currentFormColumnId.value, form_config: formFields.value }) }).then(r => r.json());
if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); formDrawerVisible.value = false; loadList(); }
else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
} finally { formSaving.value = false; }
}

onMounted(() => loadList());

return {
loading, treeRef, treeData, typeMap, typeTagMap,
dialogVisible, dialogTitle, activeTab, saving, form, showSeoTab,
iconPickerVisible, iconSearch, filteredIcons, selectIcon,
formDrawerVisible, formDrawerTitle, formFields, formSaving,
loadList, toggleAllTree, showAddModal, editItem, saveItem, deleteItem,
openFormConfig, addFormField, moveField, removeField, saveFormConfig
};
}
});

// 注册所有图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component('el-icon-' + key, component);
}

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#columnApp');
</script>
{% endblock %}

+ 281
- 0
view/admin/system/config_index.html Ver fichero

@@ -0,0 +1,281 @@
{% extends "../layout.html" %}

{% block title %}网站配置{% endblock %}

{% block content %}
<div id="configApp">
<el-tabs v-model="activeTab" type="border-card">
<!-- 基础设置 -->
<el-tab-pane label="基础设置" name="basic">
<el-form :model="form.basic" label-width="120px" class="max-w-2xl">
<el-form-item label="站点名称">
<el-input v-model="form.basic.site_name" placeholder="请输入站点名称" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="站点副标题">
<el-input v-model="form.basic.site_subtitle" placeholder="请输入站点副标题" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="网站域名">
<el-input v-model="form.basic.site_domain" placeholder="请输入网站域名" style="width:400px;" disabled></el-input>
</el-form-item>
<el-form-item label="ICP备案号">
<el-input v-model="form.basic.icp_number" placeholder="请输入ICP备案号" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="版权信息">
<el-input v-model="form.basic.copyright" placeholder="请输入版权信息" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="网站Logo">
<div class="flex items-center gap-3">
<div class="upload-box" @click="handleUpload('basic', 'logo')">
<img v-if="form.basic.logo" :src="form.basic.logo" class="upload-preview">
<div v-else class="upload-placeholder">
<el-icon :size="28"><Plus /></el-icon>
<span>点击上传</span>
</div>
</div>
<span class="text-xs text-gray-400">建议尺寸: 200×60px,PNG格式</span>
</div>
</el-form-item>
<el-form-item label="公众号二维码">
<div class="flex items-center gap-3">
<div class="upload-box upload-box-square" @click="handleUpload('basic', 'wechat_qrcode')">
<img v-if="form.basic.wechat_qrcode" :src="form.basic.wechat_qrcode" class="upload-preview">
<div v-else class="upload-placeholder">
<el-icon :size="28"><Plus /></el-icon>
<span>上传二维码</span>
</div>
</div>
<span class="text-xs text-gray-400">建议尺寸: 300×300px</span>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig('basic')" :loading="saving.basic">保存配置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>

<!-- SEO设置 -->
<el-tab-pane label="SEO设置" name="seo">
<el-form :model="form.seo" label-width="140px" class="max-w-2xl">
<el-form-item label="页面标题(Title)">
<el-input v-model="form.seo.page_title" placeholder="请输入页面标题" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="关键词(Keywords)">
<el-input v-model="form.seo.keywords" placeholder="请输入关键词,用逗号分隔" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="描述(Description)">
<el-input v-model="form.seo.description" type="textarea" :rows="3" placeholder="请输入页面描述" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="Favicon">
<div class="flex items-center gap-3">
<div class="upload-box upload-box-small" @click="handleUpload('seo', 'favicon')">
<img v-if="form.seo.favicon" :src="form.seo.favicon" class="upload-preview">
<div v-else class="upload-placeholder">
<el-icon :size="20"><Plus /></el-icon>
<span>上传</span>
</div>
</div>
<span class="text-xs text-gray-400">建议尺寸: 32×32px,ICO/PNG格式</span>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig('seo')" :loading="saving.seo">保存配置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>

<!-- 联系信息 -->
<el-tab-pane label="联系信息" name="contact">
<el-form :model="form.contact" label-width="140px" class="max-w-2xl">
<el-form-item label="联系电话">
<el-input v-model="form.contact.phone" placeholder="请输入联系电话" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="联系邮箱">
<el-input v-model="form.contact.email" placeholder="请输入联系邮箱" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="办公地址">
<el-input v-model="form.contact.address" placeholder="请输入办公地址" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="邮政编码">
<el-input v-model="form.contact.postcode" placeholder="请输入邮政编码" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="地图坐标(经度)">
<el-input v-model="form.contact.map_lng" placeholder="如: 116.480881" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="地图坐标(纬度)">
<el-input v-model="form.contact.map_lat" placeholder="如: 39.948692" style="width:400px;"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig('contact')" :loading="saving.contact">保存配置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>

<!-- 社交媒体 -->
<el-tab-pane label="社交媒体" name="social">
<el-form :model="form.social" label-width="140px" class="max-w-2xl">
<el-form-item label="微信公众号名称">
<el-input v-model="form.social.wechat_name" placeholder="请输入公众号名称" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="微信公众号ID">
<el-input v-model="form.social.wechat_id" placeholder="请输入公众号微信号" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="微博链接">
<el-input v-model="form.social.weibo_url" placeholder="请输入微博主页链接" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="抖音链接">
<el-input v-model="form.social.douyin_url" placeholder="请输入抖音主页链接" style="width:400px;"></el-input>
</el-form-item>
<el-form-item label="视频号链接">
<el-input v-model="form.social.video_url" placeholder="请输入视频号链接" style="width:400px;"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig('social')" :loading="saving.social">保存配置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
{% endblock %}

{% block css %}
<style>
.upload-box {
width: 160px;
height: 80px;
border: 2px dashed var(--el-border-color);
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
}
.upload-box:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.upload-box-square {
width: 120px;
height: 120px;
}
.upload-box-small {
width: 80px;
height: 80px;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.upload-preview {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
{% endblock %}

{% block js %}
<script>
const configsData = {{ configs | dump | safe }};

const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
setup() {
const activeTab = ref('basic');
const saving = reactive({ basic: false, seo: false, contact: false, social: false });

// 将配置数组转为对象
function arrayToObject(arr) {
const obj = {};
if (arr && Array.isArray(arr)) {
arr.forEach(item => {
obj[item.key] = item.value || '';
});
}
return obj;
}

const form = reactive({
basic: arrayToObject(configsData.basic),
seo: arrayToObject(configsData.seo),
contact: arrayToObject(configsData.contact),
social: arrayToObject(configsData.social)
});

// 保存配置
async function saveConfig(group) {
saving[group] = true;
try {
const res = await fetch('/admin/system/config/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group, data: form[group] })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving[group] = false;
}
}

// 上传图片
function handleUpload(group, key) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;

const formData = new FormData();
formData.append('file', file);

try {
ElementPlus.ElMessage.info('上传中...');
const res = await fetch('/admin/upload', {
method: 'POST',
body: formData
}).then(r => r.json());

if (res.code === 0) {
form[group][key] = res.data.url;
ElementPlus.ElMessage.success('上传成功');
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
} catch (err) {
ElementPlus.ElMessage.error('上传失败: ' + err.message);
}
};
input.click();
}

return {
activeTab,
form,
saving,
saveConfig,
handleUpload
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
// 注册 Element Plus Icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.mount('#configApp');
</script>
{% endblock %}

+ 290
- 0
view/admin/system/role_index.html Ver fichero

@@ -0,0 +1,290 @@
{% extends "../layout.html" %}

{% block title %}角色权限{% endblock %}

{% block content %}
<div id="roleApp">
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键字" class="!mb-0">
<el-input v-model="keyword" placeholder="角色名称" clearable style="width:180px;" />
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 角色列表 -->
<el-card shadow="never">
<template #header>
<el-button type="primary" @click="showAddModal">+ 新增</el-button>
</template>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="name" label="角色名称">
<template #default="{ row }">
<span class="text-orange-500 font-medium">${ row.name }</span>
</template>
</el-table-column>
<el-table-column prop="is_default" label="默认" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_default" type="success" size="small">是</el-tag>
</template>
</el-table-column>
<el-table-column prop="code" label="角色编码">
<template #default="{ row }">${ row.code || '-' }</template>
</el-table-column>
<el-table-column prop="description" label="描述">
<template #default="{ row }">${ row.description || '-' }</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="openPermDrawer(row)">分配权限</el-button>
<el-button v-if="!row.is_default" type="primary" link @click="editRole(row)">编辑</el-button>
<el-button v-if="!row.is_default" type="danger" link @click="deleteRole(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="角色名称" required>
<el-input v-model="form.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码">
<el-input v-model="form.code" placeholder="请输入角色编码(如 ADMIN)" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入角色描述" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
<el-form-item label="默认角色">
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveRole" :loading="saving">确定</el-button>
</template>
</el-dialog>

<!-- 权限分配抽屉 -->
<el-drawer v-model="permDrawerVisible" :title="permTitle" size="420px" destroy-on-close>
<template #header>
<span class="font-semibold">${ permTitle }</span>
</template>
<div class="mb-3">
<el-checkbox v-model="expandAll" @change="handleExpandAll">全部展开</el-checkbox>
</div>
<el-tree
ref="permTreeRef"
:data="permissionTree"
show-checkbox
node-key="key"
:default-expand-all="expandAll"
:props="{ label: 'name', children: 'children' }"
/>
<template #footer>
<el-button @click="permDrawerVisible = false">取消</el-button>
<el-button type="primary" @click="savePermissions" :loading="permSaving">确定</el-button>
</template>
</el-drawer>
</div>
{% endblock %}

{% block js %}
<script>
const permissionTreeData = {{ permissionTree | dump | safe }};

const { createApp, ref, reactive, onMounted, nextTick } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });

// 弹窗
const dialogVisible = ref(false);
const dialogTitle = ref('新增角色');
const saving = ref(false);
const form = reactive({ id: null, name: '', code: '', description: '', sort: 0, is_default: 0 });

// 权限抽屉
const permDrawerVisible = ref(false);
const permTitle = ref('权限分配');
const permTreeRef = ref(null);
const expandAll = ref(false);
const permSaving = ref(false);
const currentPermRoleId = ref(null);
const permissionTree = ref(permissionTreeData || []);

// 加载列表
async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
const params = new URLSearchParams({ page: pagination.page, pageSize: pagination.pageSize, keyword: keyword.value });
const res = await fetch('/admin/system/role/list?' + params).then(r => r.json());
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

// 重置筛选
function resetFilter() {
keyword.value = '';
pagination.page = 1;
loadList();
}

// 显示新增弹窗
function showAddModal() {
dialogTitle.value = '新增角色';
Object.assign(form, { id: null, name: '', code: '', description: '', sort: 0, is_default: 0 });
dialogVisible.value = true;
}

// 编辑角色
function editRole(row) {
dialogTitle.value = '编辑角色';
Object.assign(form, { id: row.id, name: row.name, code: row.code || '', description: row.description || '', sort: row.sort || 0, is_default: row.is_default || 0 });
dialogVisible.value = true;
}

// 保存角色
async function saveRole() {
if (!form.name.trim()) {
ElementPlus.ElMessage.warning('角色名称不能为空');
return;
}
saving.value = true;
try {
const url = form.id ? '/admin/system/role/edit' : '/admin/system/role/add';
const body = { name: form.name, code: form.code, description: form.description, sort: form.sort, is_default: form.is_default };
if (form.id) body.id = form.id;

const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving.value = false;
}
}

// 删除角色
async function deleteRole(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要删除该角色吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/system/role/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

// 打开权限抽屉
async function openPermDrawer(row) {
currentPermRoleId.value = row.id;
permTitle.value = `【${row.name}】权限分配`;
permDrawerVisible.value = true;

await nextTick();
const res = await fetch('/admin/system/role/detail?id=' + row.id).then(r => r.json());
const currentPerms = res.code === 0 ? (res.data.permissions || []) : [];
if (permTreeRef.value) {
permTreeRef.value.setCheckedKeys(currentPerms);
}
}

// 全部展开/折叠
function handleExpandAll(val) {
if (permTreeRef.value) {
const nodes = permTreeRef.value.store.nodesMap;
for (const key in nodes) {
nodes[key].expanded = val;
}
}
}

// 保存权限
async function savePermissions() {
permSaving.value = true;
try {
const permissions = permTreeRef.value ? permTreeRef.value.getCheckedKeys(true) : [];
const res = await fetch('/admin/system/role/assignPermissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentPermRoleId.value, permissions })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('权限保存成功');
permDrawerVisible.value = false;
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
permSaving.value = false;
}
}

onMounted(() => loadList());

return {
keyword, loading, tableData, pagination,
dialogVisible, dialogTitle, saving, form,
permDrawerVisible, permTitle, permTreeRef, expandAll, permSaving, permissionTree,
loadList, resetFilter, showAddModal, editRole, saveRole, deleteRole,
openPermDrawer, handleExpandAll, savePermissions
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#roleApp');
</script>
{% endblock %}

+ 329
- 0
view/admin/system/user_index.html Ver fichero

@@ -0,0 +1,329 @@
{% extends "../layout.html" %}

{% block title %}用户管理{% endblock %}

{% block content %}
<div id="userApp">
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键词" class="!mb-0">
<el-input v-model="keyword" placeholder="用户名/姓名..." clearable style="width:180px;" />
</el-form-item>
<el-form-item label="角色" class="!mb-0">
<el-select v-model="roleFilter" placeholder="全部" clearable style="width:120px;">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
</el-form-item>
<el-form-item label="状态" class="!mb-0">
<el-select v-model="statusFilter" placeholder="全部" clearable style="width:100px;">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 用户列表 -->
<el-card shadow="never">
<template #header>
<el-button type="primary" @click="showAddModal">+ 新增用户</el-button>
</template>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="id" label="ID" width="60" ></el-table-column>
<el-table-column prop="username" label="用户名" ></el-table-column>
<el-table-column prop="nickname" label="姓名">
<template #default="{ row }">${ row.nickname || '-' }</template>
</el-table-column>
<el-table-column prop="role_name" label="角色" width="120">
<template #default="{ row }">
<el-tag type="primary" size="small">${ row.role_name || '-' }</el-tag>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" width="180">
<template #default="{ row }">${ row.email || '-' }</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success" size="small">启用</el-tag>
<el-tag v-else type="info" size="small">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_time" label="最后登录" width="160">
<template #default="{ row }">${ row.last_login_time || '-' }</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="editUser(row)">编辑</el-button>
<el-button type="primary" link @click="showResetModal(row)">重置密码</el-button>
<el-button :type="row.status === 1 ? 'danger' : 'success'" link @click="toggleStatus(row)">
${ row.status === 1 ? '禁用' : '启用' }
</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="用户名" required>
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="!!form.id" />
</el-form-item>
<el-form-item v-if="!form.id" label="密码" required>
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.nickname" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role_id" placeholder="请选择角色" style="width:100%;">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveUser" :loading="saving">确定</el-button>
</template>
</el-dialog>

<!-- 重置密码弹窗 -->
<el-dialog v-model="resetPwdVisible" title="重置密码" width="400px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="新密码" required>
<el-input v-model="newPassword" type="password" placeholder="请输入新密码" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwdVisible = false">取消</el-button>
<el-button type="primary" @click="resetPassword" :loading="resetPwdSaving">确定</el-button>
</template>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<script>
const rolesData = {{ roles | dump | safe }};

const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const roleFilter = ref('');
const statusFilter = ref('');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const roles = ref(rolesData || []);

// 弹窗
const dialogVisible = ref(false);
const dialogTitle = ref('新增用户');
const saving = ref(false);
const form = reactive({ id: null, username: '', password: '', nickname: '', email: '', phone: '', role_id: '', status: 1 });

// 重置密码弹窗
const resetPwdVisible = ref(false);
const resetPwdSaving = ref(false);
const newPassword = ref('');
const resetUserId = ref(null);

// 加载列表
async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
const params = new URLSearchParams({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: keyword.value,
role_id: roleFilter.value,
status: statusFilter.value
});
const res = await fetch('/admin/system/user/list?' + params).then(r => r.json());
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

// 重置筛选
function resetFilter() {
keyword.value = '';
roleFilter.value = '';
statusFilter.value = '';
pagination.page = 1;
loadList();
}

// 显示新增弹窗
function showAddModal() {
dialogTitle.value = '新增用户';
Object.assign(form, { id: null, username: '', password: '', nickname: '', email: '', phone: '', role_id: '', status: 1 });
dialogVisible.value = true;
}

// 编辑用户
async function editUser(row) {
const res = await fetch('/admin/system/user/detail?id=' + row.id).then(r => r.json());
if (res.code !== 0) {
ElementPlus.ElMessage.error(res.msg || '获取失败');
return;
}
const user = res.data;
dialogTitle.value = '编辑用户';
Object.assign(form, {
id: user.id,
username: user.username,
password: '',
nickname: user.nickname || '',
email: user.email || '',
phone: user.phone || '',
role_id: user.role_id || '',
status: user.status
});
dialogVisible.value = true;
}

// 保存用户
async function saveUser() {
if (!form.username.trim()) {
ElementPlus.ElMessage.warning('用户名不能为空');
return;
}
if (!form.id && !form.password) {
ElementPlus.ElMessage.warning('密码不能为空');
return;
}
saving.value = true;
try {
const url = form.id ? '/admin/system/user/edit' : '/admin/system/user/add';
const body = {
username: form.username,
password: form.password,
nickname: form.nickname,
email: form.email,
phone: form.phone,
role_id: form.role_id,
status: form.status
};
if (form.id) body.id = form.id;

const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving.value = false;
}
}

// 显示重置密码弹窗
function showResetModal(row) {
resetUserId.value = row.id;
newPassword.value = '';
resetPwdVisible.value = true;
}

// 重置密码
async function resetPassword() {
if (!newPassword.value) {
ElementPlus.ElMessage.warning('请输入新密码');
return;
}
resetPwdSaving.value = true;
try {
const res = await fetch('/admin/system/user/resetPassword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: resetUserId.value, password: newPassword.value })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('密码重置成功');
resetPwdVisible.value = false;
} else {
ElementPlus.ElMessage.error(res.msg || '重置失败');
}
} finally {
resetPwdSaving.value = false;
}
}

// 切换状态
async function toggleStatus(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要切换该用户状态吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/system/user/toggleStatus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('操作成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} catch {}
}

onMounted(() => loadList());

return {
keyword, roleFilter, statusFilter, loading, tableData, pagination, roles,
dialogVisible, dialogTitle, saving, form,
resetPwdVisible, resetPwdSaving, newPassword,
loadList, resetFilter, showAddModal, editUser, saveUser,
showResetModal, resetPassword, toggleStatus
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#userApp');
</script>
{% endblock %}

+ 47
- 0
view/common/_footer.html Ver fichero

@@ -0,0 +1,47 @@
<!-- Footer -->
<footer class="footer-component">
<div class="footer-main">
<div class="footer-content">
<div class="footer-left">
<h3 class="footer-title">{{siteConfig.site_name | default('北京维康慈善基金会')}}</h3>
<div class="footer-info">
{% if siteConfig.email %}
<p>邮箱:{{siteConfig.email}}</p>
{% endif %}
{% if siteConfig.address %}
<p>地址:{{siteConfig.address}}</p>
{% endif %}
{% if siteConfig.phone %}
<p>电话:{{siteConfig.phone}}</p>
{% endif %}
</div>
</div>
<div class="footer-right">
{% if siteConfig.wechat_qrcode %}
<div class="footer-qr">
<img src="{{siteConfig.wechat_qrcode}}" alt="微信公众号">
<span>扫一扫关注官方微信</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="footer-bottom">
<div class="footer-bottom-content">
<div class="footer-copyright">
{{siteConfig.copyright | default('版权所有 © 北京维康慈善基金会')}} {% if siteConfig.icp_number %}{{siteConfig.icp_number}}{% endif %}
</div>
<div class="footer-links">
{% for menu in navMenus %}
{% if loop.index <= 6 %}
<a href="{% if menu.key == 'home' %}/{% else %}/{{menu.key}}.html{% endif %}">{{menu.name}}</a>
{% if loop.index < 6 and loop.index < navMenus.length %}
<span class="separator">|</span>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
</footer>

+ 67
- 0
view/common/_header.html Ver fichero

@@ -0,0 +1,67 @@
<!-- Fixed Header -->
<header class="fixed-header" id="fixedHeader">
<a href="/" class="logo">
<img src="{{siteConfig.logo | default('/static/images/logo.png')}}" alt="{{siteConfig.site_name}}" class="logo-img">
</a>
<!-- Desktop Nav -->
<nav class="header-nav">
{% for menu in navMenus %}
{% if menu.children and menu.children.length > 0 and not menu.is_single_page %}
<div class="nav-dropdown">
<a href="{% if menu.link %}{{menu.link}}{% else %}javascript:;{% endif %}" class="nav-link">{{menu.name}}</a>
<div class="dropdown-menu">
{% for child in menu.children %}
<a href="/{{menu.key}}/{{child.key}}.html">{{child.name}}</a>
{% endfor %}
</div>
</div>
{% else %}
<a href="{% if menu.key == 'home' %}/{% else %}/{{menu.key}}.html{% endif %}" class="nav-link{% if menu.key == 'home' %} active{% endif %}">{{menu.name}}</a>
{% endif %}
{% endfor %}
</nav>
<div class="header-right">
<a href="/donate.html" class="header-donate">我要捐赠</a>
<!-- Mobile Menu Button -->
<button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="菜单">
<span></span>
<span></span>
<span></span>
</button>
</div>
</header>

<!-- Mobile Menu Drawer -->
<div class="mobile-menu-overlay" id="mobileMenuOverlay"></div>
<div class="mobile-menu" id="mobileMenu">
<div class="mobile-menu-header">
<img src="{{siteConfig.logo | default('/static/images/logo.png')}}" alt="{{siteConfig.site_name}}" class="mobile-logo">
<button class="mobile-menu-close" id="mobileMenuClose" aria-label="关闭">×</button>
</div>
<nav class="mobile-nav">
{% for menu in navMenus %}
{% if menu.children and menu.children.length > 0 and not menu.is_single_page %}
<div class="mobile-nav-item has-children">
<a href="{% if menu.link %}{{menu.link}}{% else %}javascript:;{% endif %}" class="mobile-nav-link">
{{menu.name}}
<span class="mobile-nav-arrow">›</span>
</a>
<div class="mobile-nav-sub">
{% for child in menu.children %}
<a href="/{{menu.key}}/{{child.key}}.html">{{child.name}}</a>
{% endfor %}
</div>
</div>
{% else %}
<div class="mobile-nav-item">
<a href="{% if menu.key == 'home' %}/{% else %}/{{menu.key}}.html{% endif %}" class="mobile-nav-link">{{menu.name}}</a>
</div>
{% endif %}
{% endfor %}
</nav>
<div class="mobile-menu-footer">
<a href="/donate.html" class="mobile-donate-btn">我要捐赠</a>
</div>
</div>

+ 384
- 0
view/index_index.html Ver fichero

@@ -0,0 +1,384 @@
{% extends "layout.html" %}

{% block content %}
<!-- Right Side Navigation Dots -->
<div class="slide-labels" id="slideLabels">
<div class="slide-label active" data-index="0"><span class="dot"></span><span class="txt">首页</span></div>
<div class="slide-label" data-index="1"><span class="dot"></span><span class="txt">数据公示</span></div>
<div class="slide-label" data-index="2"><span class="dot"></span><span class="txt">公益项目</span></div>
<div class="slide-label" data-index="3"><span class="dot"></span><span class="txt">新闻动态</span></div>
<div class="slide-label" data-index="4"><span class="dot"></span><span class="txt">合作伙伴</span></div>
</div>

<!-- Swiper Fullpage -->
<div class="swiper swiper-fullpage" id="fullpageSwiper">
<div class="swiper-wrapper">

<!-- Slide 1: Banner -->
<div class="swiper-slide">
<div class="slide-banner">
<div class="swiper banner-swiper" id="bannerSwiper">
<div class="swiper-wrapper">
{% for banner in banners %}
<div class="swiper-slide">
<div class="banner-slide-bg" style="background-image:url('{{banner.image}}')"></div>
<div class="banner-slide-content pos-{{banner.position | default('left')}}{% if banner.glass %} glass-on{% endif %}">
<h1>{{banner.title}}{% if banner.subtitle %}<br>{{banner.subtitle}}{% endif %}</h1>
{% if banner.description %}
<p class="desc">{{banner.description}}</p>
{% endif %}
<div class="btns">
{% if banner.btn1_show %}
<a href="{{banner.btn1_link}}" class="btn-primary">{{banner.btn1_text}}</a>
{% endif %}
{% if banner.btn2_show %}
<a href="{{banner.btn2_link}}" class="btn-ghost">{{banner.btn2_text}}</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="swiper-pagination"></div>
</div>
<div class="scroll-hint">
<div class="mouse"></div>
<span>SCROLL</span>
</div>
</div>
</div>

<!-- Slide 2: Donation Data -->
<div class="swiper-slide">
<div class="slide-donation">
<div class="donation-wrap">
<div class="section-head">
<h2>捐赠及支出公示</h2>
<div class="en">Every Penny, Tracked with Integrity</div>
</div>
<div class="data-cards">
<div class="data-card">
<div class="label">历年公益捐赠收入</div>
<div class="amount"><span class="countup" data-target="{{donationStat.total_income | default(0)}}">0</span><span class="unit">元</span></div>
<div class="note">截至今日</div>
</div>
<div class="data-card">
<div class="label">本年度捐赠收入</div>
<div class="amount"><span class="countup" data-target="{{donationStat.year_income | default(0)}}">0</span><span class="unit">元</span></div>
<div class="note">本年度</div>
</div>
<div class="data-card">
<div class="label">历年公益支出</div>
<div class="amount"><span class="countup" data-target="{{donationStat.total_expense | default(0)}}">0</span><span class="unit">元</span></div>
<div class="note">截至今日</div>
</div>
</div>
<div class="donation-tables">
<div class="dtable">
<h3>捐赠收入明细</h3>
<div class="dtable-table-wrap">
<table>
<thead><tr><th>捐赠方</th><th class="td-r" style="width:120px;">金额</th></tr></thead>
</table>
<div class="dtable-scroll-wrap">
<div class="dtable-scroll-inner">
<table>
<tbody>
{% for item in donationIncome %}
<tr><td>{{item.name}}</td><td class="td-r">{{item.amount}}</td></tr>
{% endfor %}
{% for item in donationIncome %}
<tr><td>{{item.name}}</td><td class="td-r">{{item.amount}}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="dtable">
<h3>公益支出明细</h3>
<div class="dtable-table-wrap">
<table>
<thead><tr><th>支付对象</th><th class="td-r" style="width:120px;">金额</th></tr></thead>
</table>
<div class="dtable-scroll-wrap">
<div class="dtable-scroll-inner">
<table>
<tbody>
{% for item in donationExpense %}
<tr><td>{{item.name}}</td><td class="td-r">{{item.amount}}</td></tr>
{% endfor %}
{% for item in donationExpense %}
<tr><td>{{item.name}}</td><td class="td-r">{{item.amount}}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="dtable dtable-drug">
<h3>药品援助公示</h3>
<div class="dtable-table-wrap">
<table>
<thead><tr><th>发放日期</th><th>患者姓名</th><th>地区</th><th>药品名称</th></tr></thead>
</table>
<div class="drug-scroll-wrap">
<div class="drug-scroll-inner">
<table>
<tbody>
{% for item in medicines %}
<tr>
<td>{{item.distribute_date}}</td>
<td>{{item.person}}</td>
<td>{{item.region}}</td>
<td class="drug-name">{{item.name}}</td>
</tr>
{% endfor %}
{% for item in medicines %}
<tr>
<td>{{item.distribute_date}}</td>
<td>{{item.person}}</td>
<td>{{item.region}}</td>
<td class="drug-name">{{item.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>


<!-- Slide 3: Projects -->
<div class="swiper-slide">
<div class="slide-projects" id="slideProjects">
<!-- Background images -->
{% for proj in projects %}
<div class="proj-bg{% if loop.index == 1 %} active{% endif %}" style="background-image:url('{{proj.cover}}')"></div>
{% endfor %}

<!-- Info card -->
<div class="proj-info-card" id="projInfoCard">
<div class="card-label">项目介绍</div>
<h3 id="projTitle">{% if projects[0] %}{{projects[0].title}}{% endif %}</h3>
<div class="card-divider"></div>
<p id="projDesc">{% if projects[0] %}{{projects[0].summary}}{% endif %}</p>
<a href="#" class="card-btn" id="projLink">了解详情 →</a>
</div>

<!-- Bottom tabs -->
<div class="proj-tabs" id="projTabs">
{% for proj in projects %}
<div class="proj-tab{% if loop.index == 1 %} active{% endif %}" data-index="{{loop.index0}}">{{proj.title}}</div>
{% endfor %}
</div>

<a href="/project.html" class="proj-more-link">查看全部项目</a>
</div>
</div>

<!-- Slide 4: News -->
<div class="swiper-slide">
<div class="slide-news">
<div class="news-wrap">
<div class="section-head">
<h2>新闻 &amp; 动态</h2>
<div class="en">Latest News and Updates</div>
</div>
<div class="news-grid">
{% if newsList[0] %}
<a href="/news/detail/{{newsList[0].id}}.html" class="news-featured">
<img src="{{newsList[0].cover}}" alt="{{newsList[0].title}}">
<div class="overlay">
<h3>{{newsList[0].title}}</h3>
<div class="date">{{newsList[0].publish_time}}</div>
</div>
</a>
{% endif %}
<div class="news-list">
{% for news in newsList %}
{% if loop.index > 1 %}
<a href="/news/detail/{{news.id}}.html" class="news-item">
<div class="date-box">
<div class="day">{{loop.index}}</div>
<div class="ym">{{news.publish_time}}</div>
</div>
<div class="text">
<h4>{{news.title}}</h4>
<p>{{news.summary}}</p>
</div>
</a>
{% endif %}
{% endfor %}
<div style="text-align:right;padding-top:12px;">
<a href="/news.html" class="more-link">更多新闻 →</a>
</div>
</div>
</div>
</div>
</div>
</div>

<!-- Slide 5: Partners + Footer -->
<div class="swiper-slide">
<div class="slide-footer">
<div class="partners-section">
<div class="section-head">
<h2>合作伙伴</h2>
<div class="en">Thank You for Your Cooperation on the Way to Charity</div>
</div>
<div class="partners-grid">
{% for partner in partners %}
<div class="partner-item">
{% if partner.link %}
<a href="{{partner.link}}" target="_blank"><img src="{{partner.image}}" alt="{{partner.title}}"></a>
{% else %}
<img src="{{partner.image}}" alt="{{partner.title}}">
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% include "common/_footer.html" %}
</div>
</div>

</div>
</div>
{% endblock %}

{% block js %}
<script>
// Project data for tab switching
var projData = [
{% for proj in projects %}
{ title: '{{proj.title}}', desc: '{{proj.summary}}', link: '/project/detail/{{proj.id}}.html' }{% if not loop.last %},{% endif %}
{% endfor %}
];

(function(){
var labels = document.getElementById('slideLabels');
var labelItems = labels.querySelectorAll('.slide-label');

function updateLabels(index) {
labelItems.forEach(function(el, i) {
el.classList.toggle('active', i === index);
});
var lightSlides = [1, 3, 4];
labels.classList.toggle('light', lightSlides.indexOf(index) > -1);
}

// Project tab switching
var projBgs = document.querySelectorAll('.proj-bg');
var projTabs = document.querySelectorAll('.proj-tab');
var projTitle = document.getElementById('projTitle');
var projDesc = document.getElementById('projDesc');
var projLink = document.getElementById('projLink');
var currentProj = 0;

function switchProject(idx) {
projBgs.forEach(function(bg, i) { bg.classList.toggle('active', i === idx); });
projTabs.forEach(function(tab, i) { tab.classList.toggle('active', i === idx); });
if (projData[idx]) {
projTitle.textContent = projData[idx].title;
projDesc.textContent = projData[idx].desc;
projLink.href = projData[idx].link;
}
}

projTabs.forEach(function(tab) {
tab.addEventListener('click', function() {
currentProj = parseInt(this.dataset.index);
switchProject(currentProj);
startProjAuto();
});
});

// Auto-rotate projects
var projTimer = null;
function startProjAuto() {
stopProjAuto();
projTimer = setInterval(function() {
currentProj = (currentProj + 1) % projData.length;
switchProject(currentProj);
}, 5000);
}
function stopProjAuto() { if(projTimer){clearInterval(projTimer);projTimer=null;} }

// Banner Swiper
var bannerSwiper = new Swiper('#bannerSwiper', {
direction: 'horizontal',
loop: true,
speed: 1000,
autoplay: { delay: 5000, disableOnInteraction: false },
effect: 'fade',
fadeEffect: { crossFade: true },
pagination: {
el: '#bannerSwiper .swiper-pagination',
clickable: true,
},
nested: true,
});

// Fullpage Swiper
var fullpage = new Swiper('#fullpageSwiper', {
direction: 'vertical',
speed: 800,
mousewheel: {
sensitivity: 1,
thresholdDelta: 30,
},
keyboard: { enabled: true },
on: {
slideChange: function() {
updateLabels(this.activeIndex);
if(this.activeIndex === 2) { startProjAuto(); } else { stopProjAuto(); }
if(this.activeIndex === 0) { bannerSwiper.autoplay.start(); } else { bannerSwiper.autoplay.stop(); }
if(this.activeIndex === 1) triggerCountUp();
},
init: function() {
updateLabels(0);
}
}
});

// Click labels to navigate
labelItems.forEach(function(el) {
el.addEventListener('click', function() {
fullpage.slideTo(parseInt(this.dataset.index));
});
});

// CountUp animation
function animateCountUp(el) {
var target = parseFloat(el.dataset.target) || 0;
var duration = 2000;
var startTime = performance.now();
var easeOut = function(t) { return 1 - Math.pow(1 - t, 3); };

function update(now) {
var elapsed = now - startTime;
var progress = Math.min(elapsed / duration, 1);
var value = Math.floor(easeOut(progress) * target);
el.textContent = value.toLocaleString();
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}

var countUpDone = false;
function triggerCountUp() {
if (countUpDone) return;
countUpDone = true;
document.querySelectorAll('.countup').forEach(function(el) { animateCountUp(el); });
}
})();
</script>
{% endblock %}

+ 56
- 0
view/layout.html Ver fichero

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{siteConfig.page_title or siteConfig.site_name}}{% endblock %}</title>
<meta name="keywords" content="{% block keywords %}{{siteConfig.keywords}}{% endblock %}">
<meta name="description" content="{% block description %}{{siteConfig.description}}{% endblock %}">
{% if siteConfig.favicon %}
<link rel="icon" href="{{siteConfig.favicon}}">
{% endif %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css">
<link rel="stylesheet" href="/static/css/web.css">
{% block css %}{% endblock %}
</head>
<body>
{% include "common/_header.html" %}
{% block content %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script>
// Mobile menu toggle
(function(){
const btn = document.getElementById('mobileMenuBtn');
const menu = document.getElementById('mobileMenu');
const overlay = document.getElementById('mobileMenuOverlay');
const close = document.getElementById('mobileMenuClose');
function openMenu() {
menu.classList.add('open');
overlay.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeMenu() {
menu.classList.remove('open');
overlay.classList.remove('open');
document.body.style.overflow = '';
}
btn && btn.addEventListener('click', openMenu);
close && close.addEventListener('click', closeMenu);
overlay && overlay.addEventListener('click', closeMenu);
// Toggle sub menu
document.querySelectorAll('.mobile-nav-item.has-children .mobile-nav-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
this.parentElement.classList.toggle('open');
});
});
})();
</script>
{% block js %}{% endblock %}
</body>
</html>

+ 0
- 0
Ver fichero


+ 198
- 0
www/static/css/admin.css Ver fichero

@@ -0,0 +1,198 @@
/* ===== Admin Layout - vue3-element-admin style ===== */
/* 基础重置 - 使用较低优先级,不覆盖 Tailwind 工具类 */
*,*::before,*::after{box-sizing:border-box;}
ul,ol{list-style:none;margin:0;padding:0;}
:root{
--primary:#E8751A;
--primary-dark:#C96012;
--primary-light:#F5A623;
--primary-bg:rgba(232,117,26,.08);
--blue:#1A3550;
--white:#fff;
--bg:#f0f2f5;
--card:#fff;
--text:#303133;
--text-regular:#606266;
--text-secondary:#909399;
--text-placeholder:#c0c4cc;
--border:#e4e7ed;
--border-light:#ebeef5;
--success:#67c23a;
--warning:#e6a23c;
--danger:#f56c6c;
--info:#909399;
--sidebar-bg:#1d1e1f;
--sidebar-text:rgba(255,255,255,.65);
--sidebar-active-bg:var(--primary);
--sidebar-width:210px;
--header-height:50px;
--font-cn:"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
--font-en:"Roboto",sans-serif;
--radius:4px;
--shadow:0 2px 12px rgba(0,0,0,.06);
}
html{font-size:14px;}
body{font-family:var(--font-cn);color:var(--text);background:var(--bg);min-height:100vh;}
a{text-decoration:none;color:inherit;}
img{max-width:100%;display:block;}

/* ===== Layout ===== */
.admin-layout{display:flex;min-height:100vh;}

/* ===== Sidebar ===== */
.sidebar{
width:var(--sidebar-width);background:var(--sidebar-bg);
position:fixed;top:0;left:0;bottom:0;z-index:100;
display:flex;flex-direction:column;transition:.3s;overflow:hidden;
}
.sidebar-logo{
height:var(--header-height);display:flex;align-items:center;
padding:0 16px;border-bottom:1px solid rgba(255,255,255,.08);gap:10px;flex-shrink:0;
background:var(--white);
}
.sidebar-logo img{height:36px;width:auto;}
.sidebar-logo svg{width:28px;height:28px;color:var(--primary);flex-shrink:0;}
.sidebar-logo span{font-size:14px;font-weight:600;color:var(--white);white-space:nowrap;overflow:hidden;}

.sidebar-menu{flex:1;overflow-y:auto;padding:6px 0;}
.sidebar-menu::-webkit-scrollbar{width:0;}

.menu-group{margin-bottom:2px;}
.menu-group-title{
padding:20px 16px 6px;font-size:11px;color:rgba(255,255,255,.3);
text-transform:uppercase;letter-spacing:1px;font-weight:500;
}
.menu-item{
display:flex;align-items:center;gap:10px;padding:0 16px;height:42px;
color:var(--sidebar-text);cursor:pointer;transition:.15s;position:relative;
font-size:13px;border-radius:0;
}
.menu-item:hover{color:rgba(255,255,255,.9);background:rgba(255,255,255,.06);}
.menu-item.active{color:var(--white);background:var(--primary);}
.menu-item svg{width:18px;height:18px;flex-shrink:0;opacity:.7;}
.menu-item.active svg{opacity:1;}
.menu-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}

/* 子菜单 */
.submenu-items{display:none;overflow:hidden;}
.menu-parent.open .submenu-items{display:block;}
.menu-parent>.menu-item .arrow{
margin-left:auto;transition:.2s;opacity:.5;display:flex;align-items:center;
}
.menu-parent>.menu-item .arrow svg{width:10px;height:10px;}
.menu-parent.open>.menu-item .arrow{transform:rotate(180deg);}
.menu-parent.open>.menu-item .arrow{transform:rotate(90deg);}
.submenu-items .menu-item{padding-left:46px;height:38px;font-size:12.5px;}

/* ===== Main Area ===== */
.main-area{flex:1;margin-left:var(--sidebar-width);display:flex;flex-direction:column;min-height:100vh;}

/* ===== Top Header ===== */
.top-header{
height:var(--header-height);background:var(--white);
border-bottom:1px solid var(--border-light);display:flex;
align-items:center;justify-content:space-between;padding:0 20px;
position:sticky;top:0;z-index:90;
}
.header-left{display:flex;align-items:center;gap:16px;}
.breadcrumb{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);}
.breadcrumb a{color:var(--text-secondary);transition:.15s;}
.breadcrumb a:hover{color:var(--primary);}
.breadcrumb .sep{color:var(--text-placeholder);}
.breadcrumb .current{color:var(--text);}

.header-right{display:flex;align-items:center;gap:16px;}
.header-btn{
width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;
cursor:pointer;transition:.15s;color:var(--text-secondary);background:none;border:none;
}
.header-btn:hover{background:var(--bg);color:var(--text);}
.header-btn svg{width:18px;height:18px;}
.header-avatar{
display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 8px;
border-radius:var(--radius);transition:.15s;
}
.header-avatar:hover{background:var(--bg);}
.header-avatar img{width:30px;height:30px;border-radius:50%;background:var(--border);}
.avatar-placeholder{
width:30px;height:30px;border-radius:50%;background:var(--primary);
color:var(--white);display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;
}
.header-avatar span{font-size:13px;color:var(--text-regular);}

/* ===== Content ===== */
.page-content{flex:1;padding:20px;}

/* ===== Tags View ===== */
.tags-view{
display:flex;align-items:center;gap:6px;padding:6px 20px;
background:var(--white);border-bottom:1px solid var(--border-light);
overflow-x:auto;flex-shrink:0;
}
.tags-view::-webkit-scrollbar{height:0;}
.tag-item{
display:flex;align-items:center;gap:4px;padding:4px 12px;
border-radius:3px;font-size:12px;cursor:pointer;white-space:nowrap;
border:1px solid var(--border);color:var(--text-secondary);transition:.15s;
background:var(--white);
}
.tag-item:hover{color:var(--primary);border-color:var(--primary);}
.tag-item.active{background:var(--primary);color:var(--white);border-color:var(--primary);}
.tag-item .tag-close{
font-size:10px;margin-left:2px;opacity:.6;transition:.15s;
width:14px;height:14px;display:flex;align-items:center;justify-content:center;
border-radius:50%;
}
.tag-item .tag-close:hover{opacity:1;background:rgba(0,0,0,.1);}
.tag-item.active .tag-close:hover{background:rgba(255,255,255,.3);}

/* ===== Common Components ===== */
.card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;}
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.card-title{font-size:15px;font-weight:600;color:var(--text);}

/* Table - 仅保留颜色 */
.el-table{font-size:13px;}

/* Buttons - 仅保留颜色 */
.el-btn{color:var(--text-regular);cursor:pointer;}
.el-btn:hover{color:var(--primary);}
.el-btn-primary{background:var(--primary);color:var(--white);}
.el-btn-primary:hover{background:var(--primary-dark);color:var(--white);}
.el-btn-success{background:var(--success);color:var(--white);}
.el-btn-danger{background:var(--danger);color:var(--white);}
.el-btn-text{color:var(--primary);cursor:pointer;}
.el-btn-text:hover{color:var(--primary-dark);}

/* Badge / Tag - 仅保留颜色 */
.el-tag-success{background:#f0f9eb;color:var(--success);}
.el-tag-warning{background:#fdf6ec;color:var(--warning);}
.el-tag-danger{background:#fef0f0;color:var(--danger);}
.el-tag-info{background:#f4f4f5;color:var(--info);}
.el-tag-primary{background:var(--primary-bg);color:var(--primary);}

/* Pagination */
.pagination{display:flex;align-items:center;justify-content:flex-end;gap:4px;margin-top:16px;font-size:13px;}
.pagination .page-info{color:var(--text-secondary);margin-right:8px;}
.page-btn{
min-width:30px;height:30px;display:flex;align-items:center;justify-content:center;
border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;
background:var(--white);color:var(--text-regular);font-size:13px;transition:.15s;
padding:0 6px;
}
.page-btn:hover{color:var(--primary);border-color:var(--primary);}
.page-btn.active{background:var(--primary);color:var(--white);border-color:var(--primary);}
.page-btn.disabled{color:var(--text-placeholder);cursor:not-allowed;}

/* Form inputs - 仅保留颜色 */
.el-input:focus{border-color:var(--primary);}
.el-input::placeholder{color:var(--text-placeholder);}

/* Search bar */
.filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap;}
.filter-bar label{font-size:13px;color:var(--text-secondary);white-space:nowrap;}

/* ===== 页面标题栏(通用) ===== */
.page-title-bar{display:flex;align-items:center;gap:12px;margin-bottom:16px;}
.page-title-text{font-size:16px;font-weight:600;color:var(--text);}

+ 554
- 0
www/static/css/web.css Ver fichero

@@ -0,0 +1,554 @@
/* ===== Reset & Variables ===== */
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
ul,ol{list-style:none;}
:root{
--orange:#E8751A;
--orange-dark:#C96012;
--orange-light:#F5A623;
--red:#CC2929;
--red-light:#E84040;
--blue:#3B82C4;
--blue-dark:#1A3550;
--white:#fff;
--gray-bg:#F5F6F8;
--gray-100:#ECEEF1;
--gray-300:#C4C8CE;
--gray-500:#7A8290;
--gray-700:#3D4451;
--gray-900:#1A1D24;
--font-cn:"Microsoft YaHei","PingFang SC",sans-serif;
--font-en:"Roboto",sans-serif;
}
html,body{height:100%;font-family:var(--font-cn);color:var(--gray-900);line-height:1.7;background:#000;}
a{text-decoration:none;color:inherit;transition:.2s;}
img{max-width:100%;display:block;}

/* ===== Fixed Header ===== */
.fixed-header{
position:fixed;top:0;left:0;right:0;z-index:1000;
display:flex;align-items:center;justify-content:space-between;
padding:0 60px;height:72px;
background:rgba(255,255,255,.92);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
box-shadow:0 2px 12px rgba(0,0,0,.04);
transition:all .4s;
}
.logo{display:flex;align-items:center;}
.logo-img{height:44px;width:auto;transition:.3s;}

.header-right{display:flex;align-items:center;gap:12px;}
.header-nav{display:flex;align-items:center;gap:6px;}
.nav-link{
position:relative;padding:8px 18px;font-size:15px;font-weight:500;
color:var(--gray-700);letter-spacing:1px;transition:.25s;border-radius:4px;
}
.nav-link:hover,.nav-link.active{color:var(--orange);background:rgba(232,117,26,.08);}

/* Dropdown */
.nav-dropdown{position:relative;}
.nav-dropdown .dropdown-menu{
position:absolute;top:100%;left:50%;transform:translateX(-50%) translateY(8px);
min-width:160px;background:rgba(255,255,255,.98);backdrop-filter:blur(12px);border-radius:8px;
box-shadow:0 8px 32px rgba(0,0,0,.12);padding:8px 0;border:1px solid rgba(0,0,0,.06);
opacity:0;visibility:hidden;transition:.25s;pointer-events:none;
}
.nav-dropdown:hover .dropdown-menu{opacity:1;visibility:visible;transform:translateX(-50%) translateY(0);pointer-events:auto;}
.dropdown-menu a{display:block;padding:10px 24px;font-size:14px;color:var(--gray-700);white-space:nowrap;transition:.15s;}
.dropdown-menu a:hover{color:var(--orange);background:rgba(232,117,26,.04);}

.header-donate{
padding:0 24px;border-radius:20px;font-size:14px;font-weight:600;
background:var(--orange);color:#fff;border:1px solid var(--orange);
cursor:pointer;transition:.3s;letter-spacing:1px;height:38px;
display:flex;align-items:center;justify-content:center;
}
.header-donate:hover{background:var(--orange-dark);border-color:var(--orange-dark);color:#fff;}

/* Mobile Menu Button */
.mobile-menu-btn{
display:none;width:32px;height:32px;background:none;border:none;cursor:pointer;
flex-direction:column;justify-content:center;align-items:center;gap:5px;padding:4px;
}
.mobile-menu-btn span{display:block;width:20px;height:2px;background:var(--gray-700);transition:.3s;}

/* Mobile Menu Overlay */
.mobile-menu-overlay{
position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1001;
opacity:0;visibility:hidden;transition:.3s;
}
.mobile-menu-overlay.open{opacity:1;visibility:visible;}

/* Mobile Menu Drawer */
.mobile-menu{
position:fixed;top:0;right:-280px;width:280px;height:100%;
background:#fff;z-index:1002;transition:.3s;
display:flex;flex-direction:column;
}
.mobile-menu.open{right:0;}
.mobile-menu-header{
display:flex;align-items:center;justify-content:space-between;
padding:16px 20px;border-bottom:1px solid var(--gray-100);
}
.mobile-logo{height:32px;}
.mobile-menu-close{
width:32px;height:32px;background:none;border:none;
font-size:24px;color:var(--gray-500);cursor:pointer;
}
.mobile-nav{flex:1;overflow-y:auto;padding:12px 0;}
.mobile-nav-item{border-bottom:1px solid var(--gray-100);}
.mobile-nav-link{
display:flex;align-items:center;justify-content:space-between;
padding:14px 20px;font-size:15px;color:var(--gray-700);
}
.mobile-nav-arrow{font-size:18px;transition:.3s;}
.mobile-nav-item.open .mobile-nav-arrow{transform:rotate(90deg);}
.mobile-nav-sub{display:none;background:var(--gray-bg);padding:8px 0;}
.mobile-nav-item.open .mobile-nav-sub{display:block;}
.mobile-nav-sub a{display:block;padding:10px 20px 10px 36px;font-size:14px;color:var(--gray-500);}
.mobile-nav-sub a:hover{color:var(--orange);}
.mobile-menu-footer{padding:20px;border-top:1px solid var(--gray-100);}
.mobile-donate-btn{
display:block;width:100%;padding:12px;text-align:center;
background:var(--orange);color:#fff;border-radius:24px;font-weight:600;
}


/* ===== Swiper Fullpage ===== */
.swiper-fullpage{width:100%;height:100vh;overflow:hidden;}
.swiper-fullpage .swiper-slide{height:100vh!important;overflow:hidden;position:relative;}

/* ===== Slide Labels (right side dots) ===== */
.slide-labels{
position:fixed;right:36px;top:50%;transform:translateY(-50%);z-index:999;
display:flex;flex-direction:column;gap:24px;
}
.slide-label{
display:flex;align-items:center;gap:10px;cursor:pointer;
flex-direction:row-reverse;
}
.slide-label .dot{
width:10px;height:10px;border-radius:50%;
background:rgba(255,255,255,.3);border:2px solid rgba(255,255,255,.5);
transition:.3s;flex-shrink:0;
}
.slide-label.active .dot{
background:var(--orange);border-color:var(--orange);
width:10px;height:24px;border-radius:5px;
}
.slide-label .txt{
font-size:12px;color:rgba(255,255,255,.5);letter-spacing:1px;
opacity:0;transform:translateX(8px);transition:.25s;white-space:nowrap;
}
.slide-label:hover .txt{opacity:1;transform:translateX(0);}
.slide-label.active .txt{opacity:1;transform:translateX(0);color:var(--orange);}
.slide-labels.light .slide-label .dot{background:rgba(0,0,0,.1);border-color:rgba(0,0,0,.2);}
.slide-labels.light .slide-label.active .dot{background:var(--orange);border-color:var(--orange);}
.slide-labels.light .slide-label .txt{color:rgba(0,0,0,.3);}
.slide-labels.light .slide-label.active .txt{color:var(--orange);}

/* ===== Slide 1: Banner ===== */
.slide-banner{background:#0a0a0a;height:100vh;position:relative;}
.banner-swiper{width:100%;height:100%;}
.banner-swiper .swiper-slide{height:100vh!important;overflow:hidden;}
.banner-slide-bg{
position:absolute;inset:0;
background:center/cover no-repeat;
filter:brightness(.85);transition:transform 6s ease-out;
}
.banner-swiper .swiper-slide-active .banner-slide-bg{transform:scale(1.05);}
.banner-slide-content{
position:absolute;top:50%;left:120px;transform:translateY(-50%);
z-index:2;display:flex;flex-direction:column;
align-items:flex-start;max-width:700px;
}
.banner-slide-content.pos-left{left:120px;right:auto;align-items:flex-start;text-align:left;}
.banner-slide-content.pos-center{left:50%;transform:translate(-50%,-50%);align-items:center;text-align:center;}
.banner-slide-content.pos-right{left:auto;right:120px;align-items:flex-end;text-align:right;}
.banner-slide-content.glass-on{
background:rgba(0,0,0,.25);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
border-radius:16px;border:1px solid rgba(255,255,255,.1);
padding:48px 60px;
}
.banner-slide-content.pos-center.glass-on{transform:translate(-50%,-50%);}
.banner-slide-content h1{
font-size:48px;font-weight:700;color:#fff;line-height:1.3;
margin-bottom:20px;letter-spacing:2px;
opacity:0;transform:translateY(40px);transition:all .8s .3s;
}
.banner-swiper .swiper-slide-active .banner-slide-content h1{opacity:1;transform:translateY(0);}
.banner-slide-content .desc{
font-size:16px;color:rgba(255,255,255,.75);line-height:1.9;max-width:600px;
margin-bottom:40px;
opacity:0;transform:translateY(30px);transition:all .8s .5s;
}
.banner-swiper .swiper-slide-active .banner-slide-content .desc{opacity:1;transform:translateY(0);}
.banner-slide-content .btns{
display:flex;gap:16px;
opacity:0;transform:translateY(20px);transition:all .8s .7s;
}
.banner-swiper .swiper-slide-active .banner-slide-content .btns{opacity:1;transform:translateY(0);}
.banner-swiper .swiper-pagination{bottom:90px!important;}
.banner-swiper .swiper-pagination-bullet{
width:32px;height:4px;border-radius:2px;
background:rgba(255,255,255,.35);opacity:1;border:none;transition:.3s;
}
.banner-swiper .swiper-pagination-bullet-active{width:48px;background:var(--orange);}

.btn-primary{
padding:14px 40px;border-radius:30px;font-size:15px;font-weight:600;
background:var(--orange);color:#fff;letter-spacing:1px;transition:.3s;
display:inline-block;
}
.btn-primary:hover{background:var(--orange-dark);transform:translateY(-2px);box-shadow:0 8px 24px rgba(232,117,26,.3);}
.btn-ghost{
padding:14px 40px;border-radius:30px;font-size:15px;font-weight:600;
background:transparent;color:#fff;border:1.5px solid rgba(255,255,255,.5);
letter-spacing:1px;transition:.3s;display:inline-block;
}
.btn-ghost:hover{border-color:#fff;background:rgba(255,255,255,.1);}

.scroll-hint{
position:absolute;bottom:40px;left:50%;transform:translateX(-50%);z-index:2;
display:flex;flex-direction:column;align-items:center;gap:8px;
color:rgba(255,255,255,.5);font-size:12px;letter-spacing:2px;
}
.scroll-hint .mouse{
width:24px;height:38px;border:2px solid rgba(255,255,255,.4);border-radius:12px;
position:relative;
}
.scroll-hint .mouse::after{
content:"";position:absolute;top:6px;left:50%;transform:translateX(-50%);
width:3px;height:8px;background:rgba(255,255,255,.6);border-radius:2px;
animation:scrollDown 1.8s infinite;
}
@keyframes scrollDown{0%{opacity:1;top:6px;}100%{opacity:0;top:22px;}}

/* ===== Slide 2: Donation Data ===== */
.slide-donation{
background:linear-gradient(135deg,#fff8f0 0%,#fff 50%,#f0f7ff 100%);
display:flex;align-items:center;justify-content:center;
height:100vh;padding-top:72px;
}
.donation-wrap{
width:100%;max-width:1300px;padding:0 40px;
max-height:calc(100vh - 72px);overflow:hidden;
opacity:0;transform:translateY(50px);transition:all .9s .2s;
display:flex;flex-direction:column;
}
.swiper-slide-active .donation-wrap{opacity:1;transform:translateY(0);}

.section-head{text-align:center;margin-bottom:2vh;}
.section-head h2{font-size:clamp(24px,3vw,36px);font-weight:700;color:var(--gray-900);margin-bottom:4px;}
.section-head h2::after{content:"";display:block;width:40px;height:3px;background:linear-gradient(90deg,var(--red),var(--orange));margin:8px auto 0;border-radius:2px;}
.section-head .en{font-family:var(--font-en);font-size:13px;color:var(--gray-300);letter-spacing:3px;text-transform:uppercase;}

.data-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:2vh;}
.data-card{
background:rgba(255,255,255,.65);backdrop-filter:blur(16px);
border-radius:14px;padding:18px 16px;text-align:center;
box-shadow:0 8px 32px rgba(0,0,0,.06),inset 0 1px 0 rgba(255,255,255,.8);
border:1px solid rgba(255,255,255,.6);transition:.3s;
}
.data-card:hover{transform:translateY(-4px);box-shadow:0 16px 48px rgba(232,117,26,.12);border-color:var(--orange-light);}
.data-card .label{font-size:14px;color:var(--gray-500);margin-bottom:4px;}
.data-card .amount{font-family:var(--font-en);font-size:clamp(20px,2.2vw,28px);font-weight:700;color:var(--orange);}
.data-card .amount .unit{font-size:14px;color:var(--gray-500);font-weight:400;margin-left:4px;}
.data-card .note{font-size:12px;color:var(--gray-300);margin-top:2px;}

.donation-tables{display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;}
.dtable{
background:rgba(255,255,255,.6);backdrop-filter:blur(16px);
border-radius:14px;padding:14px;
box-shadow:0 8px 32px rgba(0,0,0,.06);border:1px solid rgba(255,255,255,.6);
}
.dtable h3{font-size:14px;font-weight:600;color:var(--gray-900);margin-bottom:6px;padding-bottom:5px;border-bottom:2px solid var(--orange-light);}
.dtable table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
.dtable th{text-align:center;padding:5px 8px;color:var(--gray-500);font-weight:500;border-bottom:1px solid var(--gray-100);}
.dtable td{text-align:center;padding:5px 8px;color:var(--gray-700);border-bottom:1px solid var(--gray-100);}
.dtable .td-r{font-family:var(--font-en);font-weight:500;color:var(--orange);}
.dtable-scroll-wrap{max-height:140px;overflow:hidden;position:relative;}
.dtable-scroll-inner{animation:dtableScroll 12s cubic-bezier(.45,.05,.55,.95) infinite;}
.dtable:nth-child(2) .dtable-scroll-inner{animation-delay:1.5s;}
.dtable-scroll-wrap:hover .dtable-scroll-inner{animation-play-state:paused;}
@keyframes dtableScroll{0%,15%{transform:translateY(0);}50%,65%{transform:translateY(-50%);}100%{transform:translateY(0);}}

.dtable-drug{grid-column:1/-1;}
.dtable-drug h3{border-bottom-color:var(--orange);}
.dtable-drug .drug-name{color:var(--orange);font-weight:500;}
.drug-scroll-wrap{max-height:120px;overflow:hidden;position:relative;}
.drug-scroll-inner{animation:drugScroll 18s linear infinite;}
.drug-scroll-wrap:hover .drug-scroll-inner{animation-play-state:paused;}
@keyframes drugScroll{0%{transform:translateY(0);}100%{transform:translateY(-50%);}}


/* ===== Slide 3: Projects ===== */
.slide-projects{
position:relative;height:100vh;overflow:hidden;cursor:grab;
}
.slide-projects.dragging{cursor:grabbing;}
.slide-projects .proj-bg{
position:absolute;inset:0;
background:center/cover no-repeat;
filter:brightness(.65);
transition:opacity .8s,transform 6s ease-out;
opacity:0;
}
.slide-projects .proj-bg.active{opacity:1;}
.swiper-slide-active .slide-projects .proj-bg.active{transform:scale(1.04);}

.proj-info-card{
position:absolute;top:50%;right:8%;transform:translateY(-50%);
width:380px;
background:rgba(232,117,26,.88);backdrop-filter:blur(8px);
border-radius:4px;padding:48px 40px;color:#fff;
opacity:0;transform:translateY(-50%) translateX(40px);
transition:all .7s .3s;z-index:5;
}
.swiper-slide-active .proj-info-card{opacity:1;transform:translateY(-50%) translateX(0);}
.proj-info-card .card-label{
font-size:13px;letter-spacing:3px;opacity:.8;margin-bottom:12px;
display:flex;align-items:center;gap:8px;
}
.proj-info-card .card-label::before{
content:"";display:block;width:24px;height:2px;background:var(--red-light);opacity:.8;
}
.proj-info-card h3{font-size:28px;font-weight:700;line-height:1.4;margin-bottom:16px;}
.proj-info-card .card-divider{width:40px;height:2px;background:#fff;opacity:.5;margin-bottom:16px;}
.proj-info-card p{font-size:15px;line-height:1.9;opacity:.9;margin-bottom:24px;}
.proj-info-card .card-btn{
display:inline-flex;align-items:center;gap:6px;
padding:10px 28px;border:1.5px solid rgba(255,255,255,.6);border-radius:24px;
font-size:14px;color:#fff;transition:.3s;
}
.proj-info-card .card-btn:hover{background:rgba(255,255,255,.15);border-color:#fff;}

.proj-tabs{
position:absolute;bottom:60px;left:50%;transform:translateX(-50%);
z-index:5;display:flex;gap:0;
}
.proj-tab{
padding:14px 28px;cursor:pointer;
font-size:14px;color:rgba(255,255,255,.6);
border-bottom:3px solid transparent;
transition:.3s;white-space:nowrap;letter-spacing:1px;
}
.proj-tab:hover{color:rgba(255,255,255,.85);}
.proj-tab.active{color:#fff;border-bottom-color:var(--red);font-weight:600;}

.proj-more-link{
position:absolute;bottom:24px;left:50%;transform:translateX(-50%);
z-index:5;font-size:13px;color:rgba(255,255,255,.5);
letter-spacing:2px;transition:.2s;
}
.proj-more-link:hover{color:var(--orange);}

/* ===== Slide 4: News ===== */
.slide-news{
background:#fff;
display:flex;align-items:center;justify-content:center;
height:100vh;padding-top:72px;
}
.news-wrap{
width:100%;max-width:1200px;padding:0 60px;
max-height:calc(100vh - 72px);overflow:hidden;
opacity:0;transform:translateY(50px);transition:all .9s .2s;
}
.swiper-slide-active .news-wrap{opacity:1;transform:translateY(0);}

.news-grid{display:grid;grid-template-columns:1fr 1fr;gap:36px;align-items:start;}
.news-featured{border-radius:14px;overflow:hidden;position:relative;cursor:pointer;}
.news-featured img{width:100%;height:clamp(280px,40vh,400px);object-fit:cover;transition:.5s;}
.news-featured:hover img{transform:scale(1.03);}
.news-featured .overlay{
position:absolute;bottom:0;left:0;right:0;
padding:24px;background:linear-gradient(transparent,rgba(0,0,0,.7));
color:#fff;
}
.news-featured .overlay h3{font-size:16px;font-weight:600;margin-bottom:4px;}
.news-featured .overlay .date{font-size:13px;opacity:.7;}

.news-list{display:flex;flex-direction:column;gap:0;}
.news-item{
display:flex;align-items:flex-start;gap:16px;
padding:14px 0;border-bottom:1px solid var(--gray-100);
cursor:pointer;transition:.2s;
}
.news-item:hover{padding-left:8px;}
.news-item .date-box{flex-shrink:0;width:54px;text-align:center;}
.news-item .date-box .day{font-family:var(--font-en);font-size:24px;font-weight:700;color:var(--orange);line-height:1;}
.news-item .date-box .ym{font-size:12px;color:var(--gray-300);}
.news-item .text h4{font-size:14px;font-weight:500;color:var(--gray-900);margin-bottom:4px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.news-item .text p{font-size:13px;color:var(--gray-500);display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;}

.more-link{font-size:13px;color:var(--orange);letter-spacing:1px;}
.more-link:hover{text-decoration:underline;}

/* ===== Slide 5: Partners + Footer ===== */
.slide-footer{
background:#F8F9FA;
display:flex;flex-direction:column;justify-content:flex-start;align-items:center;
height:100vh;padding-top:72px;overflow:hidden;
}
.partners-section{
width:100%;max-width:1200px;padding:40px 60px;flex:1;
display:flex;flex-direction:column;
opacity:0;transform:translateY(50px);transition:all .9s .2s;
}
.swiper-slide-active .partners-section{opacity:1;transform:translateY(0);}

.partners-grid{
display:grid;grid-template-columns:repeat(5,1fr);gap:20px;
flex:1;align-content:center;
}
.partner-item{
background:#fff;border-radius:12px;padding:20px;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 12px rgba(0,0,0,.03);border:1px solid var(--gray-100);
transition:.3s;min-height:90px;
}
.partner-item:hover{box-shadow:0 8px 24px rgba(0,0,0,.08);transform:translateY(-2px);}
.partner-item img{max-height:48px;width:auto;filter:grayscale(.3);transition:.3s;}
.partner-item:hover img{filter:grayscale(0);}

/* Footer */
.footer-component{
width:100%;background:#371806;position:relative;
display:flex;flex-direction:column;flex-shrink:0;
}
.footer-main{flex:1;display:flex;align-items:center;padding:32px 0;}
.footer-content{
max-width:1200px;width:100%;margin:0 auto;padding:0 40px;
display:flex;justify-content:space-between;align-items:center;
}
.footer-left{flex:1;}
.footer-title{font-size:24px;font-weight:600;color:#BC6627;margin-bottom:20px;letter-spacing:1px;}
.footer-info{display:flex;flex-direction:column;gap:8px;}
.footer-info p{font-size:14px;color:#BC6627;line-height:1.6;}
.footer-right{display:flex;align-items:center;gap:16px;}
.footer-qr{text-align:center;}
.footer-qr img{width:100px;height:100px;border-radius:4px;margin-bottom:8px;background:#fff;}
.footer-qr span{font-size:12px;color:#BC6627;display:block;}

.footer-bottom{border-top:1px solid #603D2E;padding:16px 0;}
.footer-bottom-content{
max-width:1200px;width:100%;margin:0 auto;padding:0 40px;
display:flex;justify-content:space-between;align-items:center;
}
.footer-copyright{font-size:12px;color:#86543F;}
.footer-links{display:flex;gap:24px;align-items:center;}
.footer-links a{font-size:12px;color:#86543F;transition:color .2s;}
.footer-links a:hover{color:#BC6627;}
.footer-links .separator{color:#86543F;opacity:.3;}


/* ===== Responsive ===== */
@media(max-width:1024px){
.fixed-header{padding:0 24px;}
.banner-slide-content{left:40px;max-width:600px;}
.banner-slide-content.pos-left{left:40px;}
.banner-slide-content.pos-right{right:40px;}
.banner-slide-content.glass-on{padding:36px 40px;}
.banner-slide-content h1{font-size:32px;}
.data-cards{gap:12px;}
.donation-tables{gap:12px;}
.donation-wrap,.news-wrap{padding:0 32px;}
.proj-info-card{width:340px;padding:36px 32px;right:5%;}
.proj-info-card h3{font-size:24px;}
.proj-info-card p{font-size:14px;}
.proj-tab{padding:12px 20px;font-size:13px;}
.news-grid{gap:24px;}
.partners-grid{grid-template-columns:repeat(4,1fr);}
.partners-section{padding:40px 32px;}
.slide-labels{display:none;}
}

@media(max-width:768px){
.header-nav{display:none;}
.mobile-menu-btn{display:flex;}
.header-donate{display:none;}
.banner-slide-content{left:24px;right:24px;max-width:calc(100% - 48px);}
.banner-slide-content.pos-left{left:24px;right:auto;max-width:calc(100% - 48px);}
.banner-slide-content.pos-center{left:50%;right:auto;}
.banner-slide-content.pos-right{left:auto;right:24px;max-width:calc(100% - 48px);}
.banner-slide-content.glass-on{padding:24px 28px;}
.banner-slide-content h1{font-size:26px;}
.donation-wrap,.news-wrap{padding:0 20px;}
.data-cards{grid-template-columns:repeat(3,1fr);gap:8px;}
.data-card{padding:12px 8px;}
.data-card .amount{font-size:18px;}
.data-card .label{font-size:12px;}
.data-card .note{font-size:11px;}
.donation-tables{grid-template-columns:1fr;gap:8px;}
.dtable-drug{grid-column:1/-1;}
.dtable-scroll-wrap{max-height:90px;}
.drug-scroll-wrap{max-height:70px;}
.dtable{padding:10px 12px;}
.dtable h3{font-size:13px;margin-bottom:4px;padding-bottom:4px;}
.dtable td,.dtable th{font-size:12px;padding:4px 6px;}
.proj-info-card{
width:auto;left:20px;right:20px;top:auto;bottom:100px;
transform:none;padding:20px;
max-height:calc(100vh - 200px);overflow-y:auto;
}
.swiper-slide-active .proj-info-card{transform:none;opacity:1;}
.proj-info-card h3{font-size:20px;margin-bottom:10px;}
.proj-info-card p{font-size:14px;margin-bottom:16px;line-height:1.6;}
.proj-tabs{bottom:16px;left:16px;right:16px;transform:none;flex-wrap:wrap;justify-content:center;gap:4px;}
.proj-tab{padding:6px 12px;font-size:12px;}
.proj-more-link{display:none;}
.news-grid{grid-template-columns:1fr;}
.news-featured img{height:200px;}
.partners-grid{grid-template-columns:repeat(3,1fr);gap:12px;}
.partners-section{padding:24px 20px;}
.footer-content{flex-direction:column;align-items:flex-start;gap:24px;}
.footer-main{padding:24px 0;}
.footer-bottom-content{flex-direction:column;gap:12px;text-align:center;}
.footer-links{flex-wrap:wrap;justify-content:center;gap:8px 12px;}
.section-head{margin-bottom:1vh;}
}

@media(max-width:480px){
.fixed-header{padding:0 16px;height:56px;}
.logo-img{height:32px;}
.banner-slide-content{left:16px;right:16px;max-width:calc(100% - 32px);}
.banner-slide-content.pos-left{left:16px;right:auto;}
.banner-slide-content.pos-right{left:auto;right:16px;}
.banner-slide-content.glass-on{padding:20px 24px;}
.banner-slide-content h1{font-size:20px;}
.banner-slide-content .desc{font-size:14px;}
.btn-primary,.btn-ghost{padding:10px 24px;font-size:13px;}
.donation-wrap,.news-wrap{padding:0 12px;}
.section-head h2{font-size:20px;}
.data-cards{gap:6px;}
.data-card{padding:8px 6px;border-radius:10px;}
.data-card .label{font-size:11px;margin-bottom:2px;}
.data-card .amount{font-size:16px;}
.data-card .note{font-size:10px;}
.donation-tables{gap:8px;}
.dtable{padding:8px 10px;border-radius:10px;}
.dtable h3{font-size:12px;margin-bottom:4px;padding-bottom:4px;}
.dtable td,.dtable th{font-size:11px;padding:3px 4px;}
.dtable-scroll-wrap{max-height:72px;}
.drug-scroll-wrap{max-height:60px;}
.dtable-drug th:nth-child(3),.dtable-drug td:nth-child(3){display:none;}
.proj-info-card{left:12px;right:12px;bottom:80px;padding:16px 14px;}
.proj-info-card h3{font-size:18px;}
.proj-info-card p{font-size:13px;margin-bottom:12px;-webkit-line-clamp:3;display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;}
.proj-info-card .card-label{font-size:11px;margin-bottom:8px;}
.proj-info-card .card-btn{padding:8px 20px;font-size:12px;}
.proj-tabs{bottom:8px;left:8px;right:8px;}
.proj-tab{padding:5px 8px;font-size:11px;}
.news-item .date-box .day{font-size:20px;}
.news-item .text h4{font-size:13px;}
.partners-grid{grid-template-columns:repeat(2,1fr);gap:8px;}
.partner-item{padding:12px;border-radius:8px;min-height:70px;}
.partner-item img{max-height:36px;}
.footer-title{font-size:18px;margin-bottom:12px;}
.footer-info p{font-size:12px;}
.footer-qr img{width:80px;height:80px;}
.footer-qr span{font-size:11px;}
.section-head{margin-bottom:0.5vh;}
.section-head .en{display:none;}
.slide-footer{padding-top:56px;}
.slide-donation{padding-top:56px;}
.slide-news{padding-top:56px;}
}

+ 0
- 0
Ver fichero



+ 0
- 0
Ver fichero


Cargando…
Cancelar
Guardar