初始提交:人事共享服务中心钉钉登录功能

This commit is contained in:
zsc
2026-05-16 11:15:24 +08:00
commit 7ba21d6413
23 changed files with 1770 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@@ -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

148
README.md Normal file
View File

@@ -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_useridadmin
如需修改为其他管理员,需要手动编辑数据库。
### 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
```
---
## 功能说明
- **需求汇总**:公开页面,显示所有公开需求
- **提交新需求**:登录后可提交(需求标题、需求内容、分会、联系方式、是否公开)
- **我的需求**:查看和管理自己提交的需求
- **管理后台**:管理员可回答需求、修改公开状态
## 权限说明
| 功能 | 普通用户 | 管理员 |
|------|---------|--------|
| 查看公开需求 | ✓ | ✓ |
| 提交需求 | ✓ | ✓ |
| 编辑自己的需求 | ✓ | ✓ |
| 回答需求 | - | ✓ |
| 强制修改公开状态 | - | ✓ |
| 查看所有需求 | - | ✓ |

44
__init__.py Normal file
View File

@@ -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()

6
config.py Normal file
View File

@@ -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

292
dingtalk.py Normal file
View File

@@ -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}&timestamp={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)

23
forms.py Normal file
View File

@@ -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('保存回答')

19
init_db.py Normal file
View File

@@ -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('数据库已存在')

38
models.py Normal file
View File

@@ -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

6
requirements.txt Normal file
View File

@@ -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

148
routes.py Normal file
View File

@@ -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/<int:id>/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/<int:id>/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/<int:id>/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)

10
run.py Normal file
View File

@@ -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)

21
start.sh Executable file
View File

@@ -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

33
start_dingtalk.sh Normal file
View File

@@ -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

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

472
static/style.css Normal file
View File

@@ -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;
}
}

View File

@@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% block title %}管理后台{% endblock %}
{% block content %}
<h2>管理后台 - 所有需求</h2>
{% if demands %}
<div class="question-list">
{% for demand in demands %}
<div class="question-item">
<div class="question-header">
<span class="branch">{{ get_branch_name(demand.branch) }}</span>
<span class="time">{{ demand.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<span class="status {% if demand.is_public %}public{% else %}private{% endif %}">
{% if demand.is_public %}公开{% else %}私有{% endif %}
</span>
<span class="user">提交者: {{ demand.user.username }}</span>
</div>
<h3 class="demand-title">{{ demand.title }}</h3>
<p class="content">{{ demand.content }}</p>
<div class="question-footer">
<span class="contact">联系方式: {{ demand.contact }}</span>
{% if demand.answer %}
<div class="answer">
<strong>回答:</strong>
<p>{{ demand.answer }}</p>
<span class="answer-time">回答时间: {{ demand.answered_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% else %}
<span class="status waiting">待回答</span>
{% endif %}
<div class="actions">
<a href="{{ url_for('edit_demand', id=demand.id) }}" class="btn btn-edit">编辑</a>
<a href="{{ url_for('answer_demand', id=demand.id) }}" class="btn btn-answer">回答</a>
<form method="POST" action="{{ url_for('toggle_public', id=demand.id) }}" style="display: inline;">
<button type="submit" class="btn btn-toggle">
{% if demand.is_public %}设为私有{% else %}设为公开{% endif %}
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">暂无需求</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}回答需求{% endblock %}
{% block content %}
<h2>回答需求</h2>
<div class="question-preview">
<h3>需求内容</h3>
<h4 class="demand-title">{{ demand.title }}</h4>
<p>{{ demand.content }}</p>
<p class="meta">分会: {{ get_branch_name(demand.branch) }} | 联系方式: {{ demand.contact }}</p>
</div>
<form method="POST" class="form">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.answer.label }}
{{ form.answer(class="form-control", rows=8) }}
</div>
<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('index') }}" class="btn btn-cancel">取消</a>
</div>
</form>
{% endblock %}

47
templates/base.html Normal file
View File

@@ -0,0 +1,47 @@
<!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="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<div class="container">
<h1>
<img src="{{ url_for('static', filename='logo.png') }}" alt="logo" class="header-logo">
<a href="{{ url_for('index') }}">人事共享服务中心"码"上办</a>
</h1>
<nav>
<ul>
<li><a href="{{ url_for('index') }}">需求汇总</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('new_demand') }}">提交新需求</a></li>
<li><a href="{{ url_for('my_demands') }}">我的需求</a></li>
{% if current_user.is_admin() %}
<li><a href="{{ url_for('admin_demands') }}">管理后台</a></li>
{% endif %}
<li class="user-name">{{ current_user.dingtalk_name or current_user.username }}</li>
{% endif %}
</ul>
</nav>
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
<div class="container">
<p>© 2026 人事共享服务中心"码"上办</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h2>{{ title }}</h2>
<form method="POST" class="form">
{{ form.hidden_tag() }}
<div class="form-group">
<label>{{ form.title.label.text }} *</label>
{{ form.title(class="form-control") }}
</div>
<div class="form-group">
<label>{{ form.content.label.text }} *</label>
{{ form.content(class="form-control textarea") }}
</div>
<div class="form-group">
<label>{{ form.branch.label.text }}</label>
{{ form.branch(class="form-control") }}
</div>
<div class="form-group">
<label>{{ form.contact.label.text }} *</label>
{{ form.contact(class="form-control") }}
</div>
{{ form.is_public() }} <label for="is_public">{{ form.is_public.label.text }}</label>
<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('index') }}" class="btn btn-cancel">取消</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>人事共享服务中心"码"上办</title>
<script src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.15/dingtalk.open.js"></script>
<!-- Aplus SDK for performance monitoring -->
<script src="https://g.alicdn.com/a-plus/a-plus-web-sdk/1.1.2/index.js"></script>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
text-align: center;
color: white;
}
.loading {
font-size: 18px;
margin-top: 20px;
}
.error {
font-size: 16px;
color: #ffcccc;
margin-top: 20px;
padding: 15px;
background: rgba(255,0,0,0.2);
border-radius: 8px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.logo {
width: 80px;
height: 80px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<img src="/static/logo.png" alt="logo" class="logo">
<div class="spinner"></div>
<p class="loading">正在跳转...</p>
</div>
<script>
console.log('[前端] ============== 钉钉登录流程开始 ==============');
const APP_KEY = '{{ app_key }}';
const TARGET_URL = '{{ target_url }}';
const CORP_ID = '{{ corp_id }}';
const AGENT_ID = '{{ agent_id }}';
console.log('[前端] 配置信息:', { APP_KEY, TARGET_URL, CORP_ID, AGENT_ID });
function showError(msg) {
console.error('[前端] 错误:', msg);
document.querySelector('.loading').textContent = '配置失败';
document.querySelector('.spinner').style.display = 'none';
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = msg;
document.querySelector('.container').appendChild(errorDiv);
}
function getCleanUrl() {
var url = window.location.protocol + '//' + window.location.hostname;
if (window.location.port && window.location.port !== '80' && window.location.port !== '443') {
url += ':' + window.location.port;
}
url += window.location.pathname;
return url;
}
function handleAuthCode(code) {
console.log('[前端] 3. 获取到 code:', code);
console.log('[前端] 4. 开始请求后端 /requirement-collection/dingtalk/api/getDingUser?code=' + code);
fetch('/requirement-collection/dingtalk/api/getDingUser?code=' + code)
.then(function(resp) {
console.log('[前端] 5. 收到后端响应, 状态码:', resp.status);
return resp.json();
})
.then(function(user) {
console.log('[前端] 6. 解析响应数据:', user);
if (user.userid) {
var userId = user.userid;
var name = user.name || user.nickname || '';
var dept = user.department || '';
if (Array.isArray(dept)) {
dept = dept.join(',');
}
console.log('[前端] 7. 用户信息解析:', { userId: userId, name: name, dept: dept });
var targetUrl = TARGET_URL + '?userId=' + encodeURIComponent(userId) + '&name=' + encodeURIComponent(name) + '&dept=' + encodeURIComponent(dept);
console.log('[前端] 8. 准备跳转:', targetUrl);
window.location.href = targetUrl;
} else {
console.error('[前端] 7. 无userid, 无法登录');
showError('获取用户信息失败,请重试');
}
})
.catch(function(err) {
console.error('[前端] 6. fetch请求失败:', err);
showError('网络请求失败,请重试');
});
}
function initDingTalk() {
var url = getCleanUrl();
console.log('[前端] 0. 当前页面URL (清理后):', url);
var apiUrl = '/requirement-collection/dingtalk/api/getSignature?url=' + encodeURIComponent(url);
console.log('[前端] 1. 请求后端获取签名:', apiUrl);
fetch(apiUrl)
.then(function(resp) {
console.log('[前端] 2. 收到响应, 状态码:', resp.status);
return resp.json();
})
.then(function(data) {
console.log('[前端] 2. 收到签名数据:', data);
if (!data.signature) {
showError('获取签名失败: ' + (data.error || '未知错误'));
return;
}
console.log('[前端] 3. 配置dd.config...');
dd.config({
agentId: data.agentId ? Number(data.agentId) : (AGENT_ID ? Number(AGENT_ID) : 0),
corpId: CORP_ID,
timeStamp: data.timeStamp,
nonceStr: data.nonceStr,
signature: data.signature,
type: 0,
jsApiList: ['runtime.permission.requestAuthCode']
});
dd.ready(function() {
console.log('[前端] 4. dd.ready 成功 - 钉钉SDK已就绪');
console.log('[前端] 5. 开始调用 requestAuthCode 获取授权码...');
dd.runtime.permission.requestAuthCode({
corpId: CORP_ID,
onSuccess: function(res) {
handleAuthCode(res.code);
},
onFail: function(err) {
console.error('[前端] 6. requestAuthCode 失败:', err);
showError('获取授权码失败: ' + JSON.stringify(err));
}
});
});
dd.error(function(err) {
console.error('[前端] 4. dd.error - 钉钉SDK配置失败:', err);
showError('钉钉SDK配置失败: ' + JSON.stringify(err));
});
})
.catch(function(err) {
console.error('[前端] 1. 请求签名失败:', err);
showError('初始化失败: ' + err.message);
});
}
initDingTalk();
</script>
</body>
</html>

51
templates/index.html Normal file
View File

@@ -0,0 +1,51 @@
{% extends 'base.html' %}
{% block title %}需求汇总{% endblock %}
{% block content %}
<h2>需求汇总</h2>
{% if demands %}
<div class="question-list">
{% for demand in demands %}
<div class="question-item">
<h3 class="demand-title">{{ demand.title }} <span class="branch">{{ get_branch_name(demand.branch) }}</span></h3>
<span class="time">{{ demand.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<p class="content">{{ demand.content }}</p>
{% if demand.answer %}
<div class="answer-toggle" onclick="toggleAnswer(this)">
<span class="toggle-icon"></span>
<span class="toggle-text">查看回答</span>
</div>
<div class="answer-content" style="display: none;">
<div class="answer">
<strong>回答:</strong>
<p>{{ demand.answer }}</p>
<span class="answer-time">回答时间: {{ demand.answered_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">暂无公开需求</p>
{% endif %}
<script>
function toggleAnswer(element) {
var content = element.nextElementSibling;
var icon = element.querySelector('.toggle-icon');
var text = element.querySelector('.toggle-text');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
text.textContent = '收起回答';
} else {
content.style.display = 'none';
icon.textContent = '▶';
text.textContent = '查看回答';
}
}
</script>
{% endblock %}

56
templates/my_demands.html Normal file
View File

@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block title %}我的需求{% endblock %}
{% block content %}
<h2>我的需求</h2>
{% if demands %}
<div class="question-list">
{% for demand in demands %}
<div class="question-item">
<h3 class="demand-title">{{ demand.title }} <span class="branch">{{ get_branch_name(demand.branch) }}</span></h3>
<span class="time">{{ demand.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<p class="content">{{ demand.content }}</p>
{% if demand.answer %}
<div class="answer-toggle" onclick="toggleAnswer(this)">
<span class="toggle-icon"></span>
<span class="toggle-text">查看回答</span>
</div>
<div class="answer-content" style="display: none;">
<div class="answer">
<strong>回答:</strong>
<p>{{ demand.answer }}</p>
<span class="answer-time">回答时间: {{ demand.answered_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
{% endif %}
{% if not demand.answer %}
<div class="actions">
<a href="{{ url_for('edit_demand', id=demand.id) }}" class="btn btn-edit">编辑</a>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">暂无需求</p>
{% endif %}
<script>
function toggleAnswer(element) {
var content = element.nextElementSibling;
var icon = element.querySelector('.toggle-icon');
var text = element.querySelector('.toggle-text');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
text.textContent = '收起回答';
} else {
content.style.display = 'none';
icon.textContent = '▶';
text.textContent = '查看回答';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %}未登录 - 人事共享服务中心"码"上办{% endblock %}
{% block content %}
<div class="login-required">
<div class="login-icon">🔒</div>
<h2>请先通过钉钉登录</h2>
<p>本系统需要通过钉钉账号登录才能使用</p>
</div>
{% endblock %}