| @@ -0,0 +1,3 @@ | |||||
| { | |||||
| "extends": "think" | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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 优化 | |||||
| ## 生成说明 | |||||
| - 无特殊要求,请使用中文回复 | |||||
| - 无特殊要求,不要生成说明文档 | |||||
| @@ -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); | |||||
| }); | |||||
| ``` | |||||
| @@ -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 | |||||
| ``` | |||||
| @@ -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(); | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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": { | |||||
| } | |||||
| }] | |||||
| } | |||||
| @@ -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(); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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`); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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='单页内容'; | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| @@ -0,0 +1 @@ | |||||
| // invoked in master | |||||
| @@ -0,0 +1 @@ | |||||
| // invoked in worker | |||||
| @@ -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') | |||||
| } | |||||
| }; | |||||
| @@ -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 | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,6 @@ | |||||
| // default config | |||||
| module.exports = { | |||||
| workers: 1, | |||||
| errnoField: 'code', // errno field | |||||
| errmsgField: 'msg' // errmsg field | |||||
| }; | |||||
| @@ -0,0 +1,4 @@ | |||||
| // production config, it will load in production enviroment | |||||
| module.exports = { | |||||
| workers: 0 | |||||
| }; | |||||
| @@ -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 | |||||
| } | |||||
| }; | |||||
| @@ -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 | |||||
| ]; | |||||
| @@ -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' | |||||
| ]; | |||||
| @@ -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'], | |||||
| ]; | |||||
| @@ -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'); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '保存成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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: '删除成功' }); | |||||
| } | |||||
| }; | |||||
| @@ -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 []; | |||||
| } | |||||
| }; | |||||
| @@ -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(); | |||||
| } | |||||
| }; | |||||
| @@ -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(); | |||||
| } | |||||
| }; | |||||
| @@ -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, '保存成功'); | |||||
| } | |||||
| }; | |||||
| @@ -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设置', '联系信息'] | |||||
| } | |||||
| ]; | |||||
| } | |||||
| }; | |||||
| @@ -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(); | |||||
| } | |||||
| }; | |||||
| @@ -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('获取上传配置失败'); | |||||
| } | |||||
| } | |||||
| }; | |||||
| @@ -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; | |||||
| } | |||||
| }; | |||||
| @@ -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; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Logic { | |||||
| indexAction() { | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_article'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_banner'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_column'; | |||||
| } | |||||
| }; | |||||
| @@ -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; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_donation'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_donation_stat'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_image'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,3 @@ | |||||
| module.exports = class extends think.Model { | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_job'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_medicine'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_page'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_person'; | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,5 @@ | |||||
| module.exports = class extends think.Model { | |||||
| get tableName() { | |||||
| return 'pap_text'; | |||||
| } | |||||
| }; | |||||
| @@ -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'); | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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> | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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 %} | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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>新闻 & 动态</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 %} | |||||
| @@ -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 +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);} | |||||
| @@ -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;} | |||||
| } | |||||