commit 7ba21d64132e81516c5e2492f0a93ffc608db466 Author: zsc <846551675@qq.com> Date: Sat May 16 11:15:24 2026 +0800 初始提交:人事共享服务中心钉钉登录功能 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6c73ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Database +*.db +*.sqlite +*.sqlite3 + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.bak +*.cache + +# Backup files +*.tar.gz +*.zip +*.rar diff --git a/README.md b/README.md new file mode 100644 index 0000000..896bb07 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# 人事共享服务中心"码"上办 - 需求收集系统 + +## Linux服务器部署指南 + +### 1. 上传并解压包 + +```bash +# 上传 requirement-collection.tar.gz 到服务器 + +# 解压 +tar -xzf requirement-collection.tar.gz +cd mashangban +``` + +### 2. 安装依赖 + +```bash +pip3 install -r requirements.txt +``` + +### 3. 初始化数据库 + +```bash +python3 init_db.py +``` + +### 4. 启动服务 + +```bash +python3 run.py +``` + +### 5. 访问地址 + +- 基础地址:http://your-server-ip:5001/requirement-collection +- 带钉钉参数:http://your-server-ip:5001/requirement-collection/?userId={{发起人userid}}&name={{发起人姓名}}&dept={{发起人部门}} + +### 6. 钉钉集成 + +在钉钉中配置机器人或应用,传入以下参数: + +| 参数 | 说明 | 示例 | +|------|------|------| +| userId | 钉钉用户ID | manager123 | +| name | 用户姓名 | 张三 | +| dept | 部门名称 | 人事部 | + +**示例URL:** +``` +https://askill.top/requirement-collection/?userId=user001&name=张三&dept=人事部 +``` + +**首次访问:** 系统自动创建用户并记录姓名和部门信息 + +**后续访问:** 根据userId自动识别用户并登录 + +### 7. 管理员配置 + +默认管理员账户信息: +- 用户名:管理员 +- dingtalk_userid:admin + +如需修改为其他管理员,需要手动编辑数据库。 + +### 8. 修改配置(可选) + +#### 修改端口 +编辑 `run.py` 中的端口号: +```python +run_simple('0.0.0.0', 端口号, application, ...) +``` + +#### 使用Gunicorn部署(生产环境推荐) + +```bash +pip3 install gunicorn +gunicorn -w 4 -b 0.0.0.0:5001 'run:application' +``` + +#### Nginx反向代理配置 + +```nginx +server { + listen 80; + server_name your-domain.com; + + location /requirement-collection { + proxy_pass http://127.0.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 9. 后台运行 + +使用systemd管理服务: + +```bash +sudo nano /etc/systemd/system/requirement-collection.service +``` + +写入以下内容: + +```ini +[Unit] +Description=Requirement Collection Service +After=network.target + +[Service] +User=www-data +WorkingDirectory=/path/to/mashangban +ExecStart=/usr/bin/python3 /path/to/mashangban/run.py +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +启用服务: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable requirement-collection +sudo systemctl start requirement-collection +``` + +--- + +## 功能说明 + +- **需求汇总**:公开页面,显示所有公开需求 +- **提交新需求**:登录后可提交(需求标题、需求内容、分会、联系方式、是否公开) +- **我的需求**:查看和管理自己提交的需求 +- **管理后台**:管理员可回答需求、修改公开状态 + +## 权限说明 + +| 功能 | 普通用户 | 管理员 | +|------|---------|--------| +| 查看公开需求 | ✓ | ✓ | +| 提交需求 | ✓ | ✓ | +| 编辑自己的需求 | ✓ | ✓ | +| 回答需求 | - | ✓ | +| 强制修改公开状态 | - | ✓ | +| 查看所有需求 | - | ✓ | diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a796667 --- /dev/null +++ b/__init__.py @@ -0,0 +1,44 @@ +import os +from flask import Flask, request +from flask_login import LoginManager +from config import Config +from models import db, User + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +app = Flask(__name__) +app.config.from_object(Config) + +dingtalk_app_key = os.environ.get('DINGTALK_APP_KEY', '') +dingtalk_app_secret = os.environ.get('DINGTALK_APP_SECRET', '') +dingtalk_agent_id = os.environ.get('DINGTALK_AGENT_ID', '') +dingtalk_corp_id = os.environ.get('DINGTALK_CORP_ID', '') + +app.config['DINGTALK_APP_KEY'] = dingtalk_app_key +app.config['DINGTALK_APP_SECRET'] = dingtalk_app_secret +app.config['DINGTALK_AGENT_ID'] = dingtalk_agent_id +app.config['DINGTALK_CORP_ID'] = dingtalk_corp_id +app.config['DINGTALK_TARGET_URL'] = os.environ.get('DINGTALK_TARGET_URL', 'http://localhost:5001/requirement-collection') +app.config['APPLICATION_ROOT'] = '/requirement-collection' + +db.init_app(app) + +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'index' + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +from routes import * +from dingtalk import dingtalk_bp + +app.register_blueprint(dingtalk_bp, url_prefix='/dingtalk') + +with app.app_context(): + db.create_all() diff --git a/config.py b/config.py new file mode 100644 index 0000000..064281c --- /dev/null +++ b/config.py @@ -0,0 +1,6 @@ +import os + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard_to_guess_string_for_flask_app' + SQLALCHEMY_DATABASE_URI = 'sqlite:///question_collector.db' + SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/dingtalk.py b/dingtalk.py new file mode 100644 index 0000000..4b45e82 --- /dev/null +++ b/dingtalk.py @@ -0,0 +1,292 @@ +import time +import hashlib +import requests +from flask import Blueprint, render_template, jsonify, current_app, request + +dingtalk_bp = Blueprint('dingtalk', __name__) + +def get_dingtalk_config(): + print('[后端] [1] ============== get_dingtalk_config 开始 ==============') + app_key = current_app.config.get('DINGTALK_APP_KEY', '') + agent_id = current_app.config.get('DINGTALK_AGENT_ID', '') + corp_id = current_app.config.get('DINGTALK_CORP_ID', '') + app_secret = current_app.config.get('DINGTALK_APP_SECRET', '') + target_url = current_app.config.get('DINGTALK_TARGET_URL', 'http://localhost:5001/requirement-collection') + + print(f'[后端] [1] 配置读取: app_key={app_key}, agent_id={agent_id}, corp_id={corp_id}') + + if not app_key or not app_secret: + print(f'[后端] [1] 缺少 app_key 或 app_secret, 返回 None') + return None + + result = { + 'app_key': app_key, + 'app_secret': app_secret, + 'agent_id': agent_id, + 'corp_id': corp_id, + 'target_url': target_url + } + print(f'[后端] [1] get_dingtalk_config 完成, 返回:', result) + return result + +def get_access_token(): + print('[后端] [2] ============== get_access_token 开始 ==============') + app_key = current_app.config.get('DINGTALK_APP_KEY') + app_secret = current_app.config.get('DINGTALK_APP_SECRET') + + if not app_key or not app_secret: + print('[后端] [2] 缺少 app_key 或 app_secret, 返回 None') + return None + + url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' + headers = {'Content-Type': 'application/json'} + data = {'appKey': app_key, 'appSecret': app_secret} + print(f'[后端] [2] 请求钉钉API: {url}, data: appKey={app_key}') + + try: + response = requests.post(url, json=data, headers=headers, timeout=10) + result = response.json() + print(f'[后端] [2] 钉钉响应:', result) + if result.get('accessToken'): + print(f'[后端] [2] 获取access_token成功') + return result['accessToken'] + except Exception as e: + print(f'[后端] [2] 异常:', e) + return None + +def get_jsapi_ticket(): + print('[后端] [2b] ============== get_jsapi_ticket 开始 ==============') + app_key = current_app.config.get('DINGTALK_APP_KEY') + app_secret = current_app.config.get('DINGTALK_APP_SECRET') + + if not app_key or not app_secret: + print('[后端] [2b] 缺少 app_key 或 app_secret, 返回 None') + return None + + url = f'https://oapi.dingtalk.com/gettoken?appkey={app_key}&appsecret={app_secret}' + print(f'[后端] [2b] 请求钉钉API: {url}') + + try: + response = requests.get(url, timeout=10) + result = response.json() + print(f'[后端] [2b] 获取token响应:', result) + if result.get('access_token'): + access_token = result['access_token'] + ticket_url = f'https://oapi.dingtalk.com/get_jsapi_ticket?access_token={access_token}' + print(f'[后端] [2b] 请求ticket API: {ticket_url}') + ticket_response = requests.get(ticket_url, timeout=10) + ticket_result = ticket_response.json() + print(f'[后端] [2b] 获取ticket响应:', ticket_result) + if ticket_result.get('ticket'): + print(f'[后端] [2b] 获取jsapi_ticket成功') + return ticket_result['ticket'] + except Exception as e: + print(f'[后端] [2b] 异常:', e) + return None + +def generate_signature(jsapi_ticket, nonce_str, timestamp, url): + print(f'[后端] [2c] ============== generate_signature 开始 ==============') + print(f'[后端] [2c] 参数: jsapi_ticket={jsapi_ticket[:20] if jsapi_ticket else "None"}..., nonce_str={nonce_str}, timestamp={timestamp}, url={url}') + + sign_str = f'jsapi_ticket={jsapi_ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url}' + print(f'[后端] [2c] 签名字符串: {sign_str}') + + signature = hashlib.sha1(sign_str.encode('utf-8')).hexdigest() + print(f'[后端] [2c] 签名结果: {signature}') + return signature + +def get_user_info_by_code(code): + print(f'[后端] [3] ============== get_user_info_by_code 开始, code={code} ==============') + access_token = get_access_token() + if not access_token: + print('[后端] [3] 获取 access_token 失败, 返回 None') + return None + + # 使用钉钉微应用API通过临时授权码获取用户信息 + url = f'https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?access_token={access_token}' + print(f'[后端] [3] 请求钉钉API: {url}') + + headers = {'Content-Type': 'application/json'} + data = {'code': code} + + try: + response = requests.post(url, json=data, headers=headers, timeout=10) + result = response.json() + print(f'[后端] [3] 钉钉用户信息响应:', result) + return result + except Exception as e: + print(f'[后端] [3] 异常:', e) + return None + +def get_user_detail(user_id): + print(f'[后端] [4] ============== get_user_detail 开始, user_id={user_id} ==============') + access_token = get_access_token() + if not access_token: + print('[后端] [4] 获取 access_token 失败, 返回 None') + return None + + # 使用新版API获取用户详情 + url = f'https://oapi.dingtalk.com/topapi/v2/user/get?access_token={access_token}' + print(f'[后端] [4] 请求钉钉API: {url}') + + headers = {'Content-Type': 'application/json'} + data = {'userid': user_id} + + try: + response = requests.post(url, json=data, headers=headers, timeout=10) + result = response.json() + print(f'[后端] [4] 钉钉用户详情响应:', result) + return result + except Exception as e: + print(f'[后端] [4] 异常:', e) + return None + +def get_dept_name(dept_id): + print(f'[后端] [5] ============== get_dept_name 开始, dept_id={dept_id} ==============') + access_token = get_access_token() + if not access_token: + print('[后端] [5] 获取 access_token 失败, 返回 None') + return None + + # 使用新版API获取部门信息 + url = f'https://oapi.dingtalk.com/topapi/v2/department/get?access_token={access_token}' + print(f'[后端] [5] 请求钉钉API: {url}') + + headers = {'Content-Type': 'application/json'} + data = {'dept_id': int(dept_id)} + + try: + response = requests.post(url, json=data, headers=headers, timeout=10) + result = response.json() + print(f'[后端] [5] 钉钉部门信息响应:', result) + if result.get('errcode') == 0 and result.get('result') and result['result'].get('name'): + print(f'[后端] [5] 获取部门名称成功: {result["result"]["name"]}') + return result['result']['name'] + except Exception as e: + print(f'[后端] [5] 异常:', e) + return None + +@dingtalk_bp.route('/') +def dingtalk_entry(): + print('[后端] [0] ============== dingtalk_entry 请求到达 ==============') + + config = get_dingtalk_config() + + if not config: + print('[后端] [0] 配置为空,使用默认值') + return render_template('dingtalk_entry.html', + app_key='', + agent_id='', + corp_id='', + target_url='http://localhost:5001/requirement-collection' + ), 400 + + print('[后端] [0] 渲染 dingtalk_entry.html') + return render_template('dingtalk_entry.html', + app_key=config['app_key'], + agent_id=config['agent_id'], + corp_id=config['corp_id'], + target_url=config['target_url'] + ) + +@dingtalk_bp.route('/api/getSignature') +def get_signature(): + print('[后端] [S] ============== get_signature 请求到达 ==============') + url = request.args.get('url', '') + print(f'[后端] [S] 收到前端传来的URL: {url}') + + if not url: + print('[后端] [S] 缺少url参数') + return jsonify({'error': '缺少url参数'}), 400 + + config = get_dingtalk_config() + if not config: + print('[后端] [S] 配置为空') + return jsonify({'error': '配置为空'}), 400 + + jsapi_ticket = get_jsapi_ticket() + if not jsapi_ticket: + print('[后端] [S] 获取jsapi_ticket失败') + return jsonify({'error': '获取jsapi_ticket失败'}), 500 + + nonce_str = 'dingtalk' + str(int(time.time())) + timestamp = str(int(time.time() * 1000)) + + signature = generate_signature(jsapi_ticket, nonce_str, timestamp, url) + print(f'[后端] [S] ============== get_signature 完成 ==============') + + return jsonify({ + 'signature': signature, + 'nonceStr': nonce_str, + 'timeStamp': timestamp, + 'agentId': config['agent_id'] and int(config['agent_id']) or 0 + }) + +@dingtalk_bp.route('/api/getDingUser') +def get_ding_user(): + print('[后端] [6] ============== get_ding_user 请求到达 ==============') + code = request.args.get('code') + print(f'[后端] [6] 收到 code 参数: {code}') + + if not code: + print('[后端] [6] 缺少 code 参数') + return jsonify({'errcode': 400, 'errmsg': '缺少code参数'}) + + print('[后端] [6] 开始调用 get_user_info_by_code') + user_info = get_user_info_by_code(code) + if not user_info: + print('[后端] [6] 获取用户信息失败') + return jsonify({'errcode': 500, 'errmsg': '获取用户信息失败'}) + + # 处理新的API响应格式 + result = { + 'errcode': 0, + 'errmsg': 'ok', + 'userid': '', + 'name': '', + 'department': [] + } + + if user_info.get('errcode') == 0 and user_info.get('result'): + result['userid'] = user_info['result'].get('userid', '') + else: + # 兼容旧格式 + result['userid'] = user_info.get('userId', user_info.get('userid', '')) + + print(f'[后端] [6] 初步结果: {result}') + + if result['userid']: + print('[后端] [6] 有 userid, 调用 get_user_detail 获取详情') + user_detail = get_user_detail(result['userid']) + if user_detail and user_detail.get('errcode') == 0 and user_detail.get('result'): + result['name'] = user_detail['result'].get('name', '') + dept_ids = user_detail['result'].get('dept_id_list', []) + print(f'[后端] [6] 更新用户名为: {result["name"]}') + print(f'[后端] [6] 部门ID列表: {dept_ids}') + + # 获取部门名称 + if dept_ids: + print('[后端] [6] 开始获取部门名称') + dept_names = [] + for dept_id in dept_ids: + name = get_dept_name(str(dept_id)) + if name: + dept_names.append(name) + if dept_names: + result['department'] = dept_names + print(f'[后端] [6] 更新部门为: {result["department"]}') + elif user_detail: + # 兼容旧格式 + result['name'] = user_detail.get('name', '') + dept_ids = user_detail.get('department', []) + if dept_ids: + dept_names = [] + for dept_id in dept_ids: + name = get_dept_name(str(dept_id)) + if name: + dept_names.append(name) + if dept_names: + result['department'] = dept_names + + print(f'[后端] [6] ============== get_ding_user 完成, 返回: {result} ==============') + return jsonify(result) diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..d2c090b --- /dev/null +++ b/forms.py @@ -0,0 +1,23 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, TextAreaField, SelectField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Length + +class DemandForm(FlaskForm): + title = StringField('需求标题', validators=[DataRequired(), Length(max=200)]) + content = TextAreaField('需求内容', validators=[DataRequired(), Length(max=1000)]) + branch = SelectField('分会', choices=[ + ('comprehensive', '综合分会'), + ('training', '培训服务分会'), + ('hr', '基础人事服务分会'), + ('talent', '人才服务分会'), + ('functional', '职能支持分会'), + ('finance_review', '经费审查委员会'), + ('women', '女职工委员会') + ], validators=[DataRequired()]) + contact = StringField('联系方式', validators=[DataRequired(), Length(max=100)]) + is_public = BooleanField('是否公开') + submit = SubmitField('提交') + +class AnswerForm(FlaskForm): + answer = TextAreaField('回答内容', validators=[DataRequired(), Length(max=2000)]) + submit = SubmitField('保存回答') diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..df9223b --- /dev/null +++ b/init_db.py @@ -0,0 +1,19 @@ +from __init__ import app, db +from models import User + +with app.app_context(): + db.create_all() + + if not User.query.filter_by(username='管理员').first(): + admin = User( + username='管理员', + dingtalk_userid='admin', + dingtalk_name='管理员', + dingtalk_dept='', + role='admin' + ) + db.session.add(admin) + db.session.commit() + print('数据库初始化完成,已创建管理员账户') + else: + print('数据库已存在') diff --git a/models.py b/models.py new file mode 100644 index 0000000..5aa9ae0 --- /dev/null +++ b/models.py @@ -0,0 +1,38 @@ +from datetime import datetime +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin + +db = SQLAlchemy() + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + role = db.Column(db.String(20), nullable=False, default='user') + contact = db.Column(db.String(100)) + dingtalk_userid = db.Column(db.String(100), unique=True, nullable=False) + dingtalk_name = db.Column(db.String(100)) + dingtalk_dept = db.Column(db.String(200)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def is_admin(self): + return self.role == 'admin' + +class Demand(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text, nullable=False) + branch = db.Column(db.String(50), nullable=False) + contact = db.Column(db.String(100), nullable=False) + is_public = db.Column(db.Boolean, default=True) + answer = db.Column(db.Text) + answered_at = db.Column(db.DateTime) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref=db.backref('demands', lazy=True)) + + def can_edit(self, current_user): + if current_user.is_admin(): + return True + return self.user_id == current_user.id diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cbbda08 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.1 +Flask-Login==0.6.3 +requests==2.31.0 +python-dotenv==1.0.0 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..543c446 --- /dev/null +++ b/routes.py @@ -0,0 +1,148 @@ +from datetime import datetime +from flask import render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, login_required, current_user +from __init__ import app, db +from models import User, Demand +from forms import DemandForm, AnswerForm + +BRANCH_NAMES = { + 'comprehensive': '综合分会', + 'training': '培训服务分会', + 'hr': '基础人事服务分会', + 'talent': '人才服务分会', + 'functional': '职能支持分会', + 'finance_review': '经费审查委员会', + 'women': '女职工委员会' +} + +@app.context_processor +def utility_processor(): + def get_branch_name(branch_key): + return BRANCH_NAMES.get(branch_key, branch_key) + return dict(get_branch_name=get_branch_name) + +@app.before_request +def before_request(): + user_id = request.args.get('userId') + name = request.args.get('name') + dept = request.args.get('dept') + + if user_id or name or dept: + print(f'[后端] [7] ============== before_request 收到登录参数 ==============') + print(f'[后端] [7] userId={user_id}, name={name}, dept={dept}') + + if user_id and not current_user.is_authenticated: + print(f'[后端] [7] 开始登录流程, userId={user_id}') + user = User.query.filter_by(dingtalk_userid=user_id).first() + + if user: + print(f'[后端] [7] 用户已存在, 直接登录: {user.username}') + login_user(user) + elif name: + print(f'[后端] [7] 用户不存在, 创建新用户: {name}') + role = 'admin' if user_id == 'admin' else 'user' + user = User( + username=name, + dingtalk_userid=user_id, + dingtalk_name=name, + dingtalk_dept=dept or '', + role=role + ) + db.session.add(user) + db.session.commit() + login_user(user) + print(f'[后端] [7] 用户创建并登录成功') + +@app.route('/') +def index(): + demands = Demand.query.filter_by(is_public=True).order_by(Demand.created_at.desc()).all() + return render_template('index.html', demands=demands) + +@app.route('/demand/new', methods=['GET', 'POST']) +def new_demand(): + if not current_user.is_authenticated: + return render_template('not_logged_in.html') + form = DemandForm() + if form.validate_on_submit(): + demand = Demand( + title=form.title.data, + content=form.content.data, + branch=form.branch.data, + contact=form.contact.data, + is_public=form.is_public.data, + user_id=current_user.id + ) + db.session.add(demand) + db.session.commit() + flash('需求提交成功') + return redirect(url_for('index')) + return render_template('demand_form.html', form=form, title='提交新需求') + +@app.route('/demand//edit', methods=['GET', 'POST']) +def edit_demand(id): + if not current_user.is_authenticated: + return render_template('not_logged_in.html') + demand = Demand.query.get_or_404(id) + if not demand.can_edit(current_user): + flash('无权限编辑此需求') + return redirect(url_for('index')) + form = DemandForm(obj=demand) + if form.validate_on_submit(): + demand.title = form.title.data + demand.content = form.content.data + demand.branch = form.branch.data + demand.contact = form.contact.data + if current_user.is_admin() or not demand.answer: + demand.is_public = form.is_public.data + demand.updated_at = datetime.utcnow() + db.session.commit() + flash('需求更新成功') + return redirect(url_for('index')) + return render_template('demand_form.html', form=form, title='编辑需求', demand=demand) + +@app.route('/demand//answer', methods=['GET', 'POST']) +def answer_demand(id): + if not current_user.is_authenticated: + return render_template('not_logged_in.html') + demand = Demand.query.get_or_404(id) + if not current_user.is_admin(): + flash('只有管理员可以回答需求') + return redirect(url_for('index')) + form = AnswerForm(data={'answer': demand.answer or ''}) + if form.validate_on_submit(): + demand.answer = form.answer.data + demand.answered_at = datetime.utcnow() + db.session.commit() + flash('回答已保存') + return redirect(url_for('index')) + return render_template('answer_form.html', form=form, demand=demand) + +@app.route('/demand//toggle_public', methods=['POST']) +def toggle_public(id): + if not current_user.is_authenticated: + return render_template('not_logged_in.html') + demand = Demand.query.get_or_404(id) + if not current_user.is_admin(): + flash('只有管理员可以修改公开状态') + return redirect(url_for('index')) + demand.is_public = not demand.is_public + db.session.commit() + flash('公开状态已更新') + return redirect(url_for('index')) + +@app.route('/my_demands') +def my_demands(): + if not current_user.is_authenticated: + return render_template('not_logged_in.html') + demands = Demand.query.filter_by(user_id=current_user.id).order_by(Demand.created_at.desc()).all() + return render_template('my_demands.html', demands=demands) + +@app.route('/admin/demands') +def admin_demands(): + if not current_user.is_authenticated: + return render_template('not_logged_in.html') + if not current_user.is_admin(): + flash('无权限访问此页面') + return redirect(url_for('index')) + demands = Demand.query.order_by(Demand.created_at.desc()).all() + return render_template('admin_demands.html', demands=demands) diff --git a/run.py b/run.py new file mode 100644 index 0000000..18bbebb --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +from werkzeug.middleware.dispatcher import DispatcherMiddleware +from __init__ import app + +application = DispatcherMiddleware(app, { + '/requirement-collection': app +}) + +if __name__ == '__main__': + from werkzeug.serving import run_simple + run_simple('0.0.0.0', 5001, application, use_reloader=True, use_debugger=True) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..a7171e2 --- /dev/null +++ b/start.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +echo "正在启动人事共享服务中心'码'上办..." + +# 检查Python环境 +if ! command -v python3 &> /dev/null; then + echo "错误: 未找到Python3,请先安装Python" + exit 1 +fi + +# 安装依赖 +echo "检查依赖..." +pip3 install -r requirements.txt --quiet + +# 初始化数据库 +echo "初始化数据库..." +python3 init_db.py + +# 启动服务 +echo "启动服务..." +python3 run.py diff --git a/start_dingtalk.sh b/start_dingtalk.sh new file mode 100644 index 0000000..e98af4f --- /dev/null +++ b/start_dingtalk.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +echo "正在启动人事共享服务中心'码'上办..." + +# 检查Python环境 +if ! command -v python3 &> /dev/null; then + echo "错误: 未找到Python3,请先安装Python" + exit 1 +fi + +# 安装依赖 +echo "检查依赖..." +pip3 install -r requirements.txt --quiet + +# 初始化数据库 +echo "初始化数据库..." +python3 init_db.py + +# 设置钉钉配置(请根据实际情况修改) +export DINGTALK_APP_KEY="dingx1oxerkw6vfzwzsc" +export DINGTALK_APP_SECRET="_Ap2DwtSm2nyuZdid8DwFdAc4rLWuXYCbSBTJrwuwbLdyFcPtNabrs4lmnBDdoz5" +export DINGTALK_AGENT_ID="4583595778" +export DINGTALK_CORP_ID="dingaac5c6bd7188ca3935c2f4657eb6378f" +export DINGTALK_TARGET_URL="https://www.askill.top/requirement-collection" + +# 启动服务 +echo "启动服务..." +echo "" +echo "=========================================" +echo "钉钉入口地址: http://your-domain/requirement-collection/dingtalk/" +echo "需求收集地址: http://your-domain/requirement-collection/" +echo "=========================================" +python3 run.py diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..b0b9f8d Binary files /dev/null and b/static/logo.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..57dd8d6 --- /dev/null +++ b/static/style.css @@ -0,0 +1,472 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 100%; + padding: 0 15px; + margin: 0 auto; +} + +header { + background-color: #4a90d9; + color: white; + padding: 15px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +header h1 { + font-size: 24px; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 15px; +} + +header h1 a { + color: white; + text-decoration: none; +} + +.header-logo { + height: 40px; + width: auto; +} + +header nav ul { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 15px; + justify-content: space-between; +} + +.user-name { + margin-left: auto; + font-size: 14px; + color: #fff; +} + +header nav ul li a { + color: white; + text-decoration: none; + font-size: 14px; +} + +header nav ul li a:hover { + text-decoration: underline; +} + +main { + padding: 20px 0; + min-height: calc(100vh - 140px); +} + +h2 { + margin-bottom: 20px; + color: #2c3e50; +} + +.flash { + background-color: #d4edda; + color: #155724; + padding: 10px 15px; + margin-bottom: 20px; + border-radius: 4px; + border: 1px solid #c3e6cb; +} + +.form { + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #555; +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; + align-items: flex-end; +} + +.form-group-inline { + flex: 1; + min-width: 200px; +} + +.form-group-inline label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #555; +} + +.checkbox-inline { + display: flex; + align-items: center; + min-width: auto; +} + +.checkbox-inline input[type="checkbox"] { + margin-right: 8px; + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-inline label { + margin-bottom: 0; + cursor: pointer; + font-weight: normal; +} + +.checkbox-group { + display: flex; + align-items: center; + margin-bottom: 20px; +} + +.checkbox-group input[type="checkbox"] { + margin-right: 8px; + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-group label { + margin-bottom: 0; + cursor: pointer; + font-weight: normal; +} + +.form-control { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s; +} + +.form-control:focus { + outline: none; + border-color: #4a90d9; +} + +.form-control.textarea { + width: 100%; + max-width: 100%; + min-width: 100%; + height: 150px; + max-height: 200px; + min-height: 120px; + resize: none; +} + +.btn { + display: inline-block; + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + text-decoration: none; + text-align: center; + transition: background-color 0.3s; +} + +.btn-primary { + background-color: #4a90d9; + color: white; +} + +.btn-primary:hover { + background-color: #3a80c9; +} + +.btn-edit { + background-color: #f39c12; + color: white; +} + +.btn-edit:hover { + background-color: #e67e22; +} + +.btn-answer { + background-color: #27ae60; + color: white; +} + +.btn-answer:hover { + background-color: #2ecc71; +} + +.btn-toggle { + background-color: #95a5a6; + color: white; +} + +.btn-toggle:hover { + background-color: #7f8c8d; +} + +.btn-cancel { + background-color: #ecf0f1; + color: #333; + margin-left: 10px; +} + +.btn-cancel:hover { + background-color: #bdc3c7; +} + +.question-list { + display: grid; + gap: 20px; +} + +.question-item { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); +} + +.question-header { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 15px; + align-items: center; +} + +.branch { + background-color: #4a90d9; + color: white; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; +} + +.time { + font-size: 12px; + color: #999; + display: block; + margin-bottom: 10px; +} + +.status { + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; +} + +.status.public { + background-color: #d4edda; + color: #155724; +} + +.status.private { + background-color: #f8d7da; + color: #721c24; +} + +.status.waiting { + background-color: #fff3cd; + color: #856404; +} + +.user { + font-size: 12px; + color: #666; +} + +.demand-title { + font-size: 18px; + font-weight: bold; + color: #333; + margin-bottom: 5px; +} + +.content { + font-size: 15px; + line-height: 1.8; + margin-bottom: 15px; + color: #444; +} + +.question-footer { + border-top: 1px solid #eee; + padding-top: 15px; +} + +.contact { + font-size: 14px; + color: #666; + margin-bottom: 10px; + display: block; +} + +.answer { + background-color: #f8f9fa; + padding: 15px; + border-radius: 4px; + margin: 10px 0; +} + +.answer p { + margin: 10px 0; + color: #333; +} + +.answer-time { + font-size: 12px; + color: #999; +} + +.answer-toggle { + cursor: pointer; + color: #4a90d9; + font-size: 14px; + user-select: none; + padding: 8px 0; +} + +.answer-toggle:hover { + color: #3a80c9; +} + +.toggle-icon { + margin-right: 5px; + font-size: 12px; +} + +.actions { + margin-top: 15px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.empty { + text-align: center; + color: #999; + padding: 40px; + background: white; + border-radius: 8px; +} + +.login-required { + text-align: center; + padding: 60px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); +} + +.login-icon { + font-size: 64px; + margin-bottom: 20px; +} + +.login-required h2 { + color: #2c3e50; + margin-bottom: 15px; +} + +.login-required p { + color: #666; + margin-bottom: 20px; +} + +.login-hint { + background-color: #f8f9fa; + padding: 20px; + border-radius: 4px; + margin-top: 20px; +} + +.login-hint p { + margin-bottom: 10px; +} + +.url-example { + font-family: monospace; + font-size: 14px; + color: #4a90d9; + background-color: #fff; + padding: 10px; + border-radius: 4px; + border: 1px solid #ddd; + word-break: break-all; +} + +.hint { + margin-top: 15px; + padding: 10px; + background-color: #fff3cd; + border-radius: 4px; + font-size: 14px; + color: #856404; +} + +.question-preview { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; +} + +.question-preview h3 { + margin-bottom: 10px; + color: #2c3e50; +} + +.question-preview .meta { + font-size: 14px; + color: #666; + margin-top: 10px; +} + +footer { + background-color: #2c3e50; + color: white; + text-align: center; + padding: 15px 0; + font-size: 14px; +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container { + max-width: 960px; + } +} diff --git a/templates/admin_demands.html b/templates/admin_demands.html new file mode 100644 index 0000000..87bc03f --- /dev/null +++ b/templates/admin_demands.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} + +{% block title %}管理后台{% endblock %} + +{% block content %} +

管理后台 - 所有需求

+{% if demands %} +
+ {% for demand in demands %} +
+
+ {{ get_branch_name(demand.branch) }} + {{ demand.created_at.strftime('%Y-%m-%d %H:%M') }} + + {% if demand.is_public %}公开{% else %}私有{% endif %} + + 提交者: {{ demand.user.username }} +
+

{{ demand.title }}

+

{{ demand.content }}

+ +
+ {% endfor %} +
+{% else %} +

暂无需求

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/answer_form.html b/templates/answer_form.html new file mode 100644 index 0000000..45f9e17 --- /dev/null +++ b/templates/answer_form.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} + +{% block title %}回答需求{% endblock %} + +{% block content %} +

回答需求

+
+

需求内容

+

{{ demand.title }}

+

{{ demand.content }}

+

分会: {{ get_branch_name(demand.branch) }} | 联系方式: {{ demand.contact }}

+
+
+ {{ form.hidden_tag() }} +
+ {{ form.answer.label }} + {{ form.answer(class="form-control", rows=8) }} +
+
+ {{ form.submit(class="btn btn-primary") }} + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3ce7004 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,47 @@ + + + + + + {% block title %}人事共享服务中心"码"上办{% endblock %} + + + +
+
+

+ + 人事共享服务中心"码"上办 +

+ +
+
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+
+

© 2026 人事共享服务中心"码"上办

+
+
+ + diff --git a/templates/demand_form.html b/templates/demand_form.html new file mode 100644 index 0000000..9c163c7 --- /dev/null +++ b/templates/demand_form.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +

{{ title }}

+
+ {{ form.hidden_tag() }} +
+ + {{ form.title(class="form-control") }} +
+
+ + {{ form.content(class="form-control textarea") }} +
+
+ + {{ form.branch(class="form-control") }} +
+
+ + {{ form.contact(class="form-control") }} +
+ {{ form.is_public() }} +
+ {{ form.submit(class="btn btn-primary") }} + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/dingtalk_entry.html b/templates/dingtalk_entry.html new file mode 100644 index 0000000..7f5460c --- /dev/null +++ b/templates/dingtalk_entry.html @@ -0,0 +1,186 @@ + + + + + + 人事共享服务中心"码"上办 + + + + + + +
+ +
+

正在跳转...

+
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7027236 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} + +{% block title %}需求汇总{% endblock %} + +{% block content %} +

需求汇总

+{% if demands %} +
+ {% for demand in demands %} +
+

{{ demand.title }} {{ get_branch_name(demand.branch) }}

+ {{ demand.created_at.strftime('%Y-%m-%d %H:%M') }} +

{{ demand.content }}

+ {% if demand.answer %} +
+ + 查看回答 +
+ + {% endif %} +
+ {% endfor %} +
+{% else %} +

暂无公开需求

+{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/my_demands.html b/templates/my_demands.html new file mode 100644 index 0000000..eba623f --- /dev/null +++ b/templates/my_demands.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}我的需求{% endblock %} + +{% block content %} +

我的需求

+{% if demands %} +
+ {% for demand in demands %} +
+

{{ demand.title }} {{ get_branch_name(demand.branch) }}

+ {{ demand.created_at.strftime('%Y-%m-%d %H:%M') }} +

{{ demand.content }}

+ {% if demand.answer %} +
+ + 查看回答 +
+ + {% endif %} + {% if not demand.answer %} +
+ 编辑 +
+ {% endif %} +
+ {% endfor %} +
+{% else %} +

暂无需求

+{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/not_logged_in.html b/templates/not_logged_in.html new file mode 100644 index 0000000..3ebdfe3 --- /dev/null +++ b/templates/not_logged_in.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block title %}未登录 - 人事共享服务中心"码"上办{% endblock %} + +{% block content %} + +{% endblock %} \ No newline at end of file