初始化

This commit is contained in:
Weiming
2026-06-11 15:27:52 +08:00
commit 19d0df4fa9
10 changed files with 5332 additions and 0 deletions

173
README.md Normal file
View File

@@ -0,0 +1,173 @@
# 飞书审批配置服务
内网 Web 服务:用户在浏览器中设计审批表单与流程,保存时**下载包含全部配置的独立 HTML 文件**,可离线打开或分发给他人。
## 功能
- 在线编辑:基础信息、表单设计、流程设计
- **保存并下载 HTML**:配置写入 HTML 文件,无需本地 bat / data 文件夹
- **导入 HTML**:继续编辑之前导出的配置文件
## 环境要求
- **Node.js 16.x**CentOS 7 推荐 16.20 LTS
- 无需 `npm install`(零第三方依赖,仅使用 Node 内置模块)
## 快速启动
```bash
cd Approval_of_design
node server.js
```
浏览器访问:`http://服务器IP:8080`
端口与监听地址可在 `project.config.json` 中修改:
```json
{
"projectId": "approval_of_design",
"port": 8080,
"host": "0.0.0.0",
"label": "飞书审批配置服务"
}
```
## CentOS 7 部署
### 1. 安装 Node.js 16.x
CentOS 7 自带 glibc 较旧,建议使用 NodeSource 16.x 或 nvm
**方式 ANodeSource推荐**
```bash
curl -fsSL https://rpm.nodesource.com/setup_16.x | sudo bash -
sudo yum install -y nodejs
node -v # 应显示 v16.x.x
```
**方式 Bnvm**
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 16
nvm use 16
```
### 2. 上传并启动
```bash
sudo mkdir -p /opt/feishu-approval
# 将项目文件上传到 /opt/feishu-approval
cd /opt/feishu-approval
node server.js
```
或使用项目自带脚本:
```bash
chmod +x deploy/start.sh
./deploy/start.sh
```
### 3. systemd 开机自启(推荐)
```bash
# 修改 deploy/feishu-approval.service 中 WorkingDirectory、User 后:
sudo cp deploy/feishu-approval.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable feishu-approval
sudo systemctl start feishu-approval
sudo systemctl status feishu-approval
```
### 4. 防火墙
```bash
sudo firewall-cmd --permanent --add-port=8080/tcp
sudo firewall-cmd --reload
```
### 5. 验证
```bash
curl http://127.0.0.1:8080/api/health
```
## 部署到其他环境
### 1. 上传项目
将整个 `Approval_of_design` 目录复制到内网服务器。
### 2. 启动服务
**方式 A直接运行**
```bash
node server.js
```
**方式 B使用 pm2 守护进程(推荐)**
```bash
npm install -g pm2
pm2 start server.js --name feishu-approval
pm2 save
pm2 startup
```
**方式 CWindows 服务**
使用 [nssm](https://nssm.cc/) 将 `node server.js` 注册为 Windows 服务,开机自启。
### 3. 反向代理(可选)
若需通过 80 端口或 HTTPS 访问,可在 Nginx 中配置:
```nginx
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
### 4. 防火墙
在内网服务器开放 `project.config.json` 中配置的端口(默认 8080
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 在线编辑器 |
| GET | `/api/health` | 健康检查 |
| POST | `/api/export` | 提交 JSON 配置,返回 HTML 文件下载 |
| POST | `/api/import` | 提交 HTML 文本,解析其中的配置 JSON |
## 文件说明
| 文件 | 说明 |
|------|------|
| `server.js` | Web 服务主程序 |
| `飞书审批配置文件_V1.0.html` | 编辑器模板(服务与导出共用) |
| `project.config.json` | 端口、服务名称等配置 |
| `deploy/start.sh` | CentOS 7 启动脚本 |
| `deploy/feishu-approval.service` | systemd 服务单元示例 |
## 使用流程
1. 访问服务地址,开始新建或导入已有 HTML
2. 完成表单与流程设计
3. 点击 **「保存并下载 HTML」**,获得独立配置文件
4. 下载的 HTML 可双击离线打开;再次编辑可导入回在线服务
## 健康检查
```bash
curl http://localhost:8080/api/health
```

292
address-regions.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Feishu Approval Designer Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/feishu-approval
Environment=NODE_ENV=production
Environment=PORT=8080
Environment=HOST=0.0.0.0
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

18
deploy/start.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# CentOS 7 启动脚本Node.js 16.x
set -e
cd "$(dirname "$0")/.."
if ! command -v node >/dev/null 2>&1; then
echo "[ERROR] node not found. Install Node.js 16.x first."
exit 1
fi
NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]")
if [ "$NODE_MAJOR" -lt 16 ]; then
echo "[ERROR] Node.js 16+ required. Current: $(node -v)"
exit 1
fi
echo "Starting with Node $(node -v) ..."
exec node server.js

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "feishu-approval-designer",
"private": true,
"version": "2.0.0",
"description": "飞书审批配置在线设计服务",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"build:regions": "node scripts/build-address-regions.js"
},
"engines": {
"node": ">=16.0.0"
}
}

6
project.config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"projectId": "approval_of_design",
"port": 8080,
"host": "0.0.0.0",
"label": "飞书审批配置服务"
}

View File

@@ -0,0 +1,424 @@
/* eslint-disable */
const fs = require('fs');
const path = require('path');
function mkTree(stateMap) {
const t = {};
Object.keys(stateMap).forEach(function (st) {
t[st] = {};
stateMap[st].forEach(function (c) { t[st][c] = ['—']; });
});
return t;
}
function mkFixed(fixedRegion, cities) {
const t = {};
t[fixedRegion] = {};
Object.keys(cities).forEach(function (city) {
t[fixedRegion][city] = cities[city];
});
return t;
}
function mkFlat(cities) {
const t = {};
cities.forEach(function (c) { t[c] = ['—']; });
return t;
}
const US_STATES = {
Alabama: ['Birmingham', 'Montgomery', 'Mobile', 'Huntsville', 'Tuscaloosa'],
Alaska: ['Anchorage', 'Fairbanks', 'Juneau', 'Sitka', 'Ketchikan'],
Arizona: ['Phoenix', 'Tucson', 'Mesa', 'Chandler', 'Scottsdale'],
Arkansas: ['Little Rock', 'Fort Smith', 'Fayetteville', 'Springdale', 'Jonesboro'],
California: ['Los Angeles', 'San Francisco', 'San Diego', 'San Jose', 'Sacramento', 'Oakland', 'Fresno', 'Long Beach'],
Colorado: ['Denver', 'Colorado Springs', 'Aurora', 'Fort Collins', 'Lakewood'],
Connecticut: ['Bridgeport', 'New Haven', 'Hartford', 'Stamford', 'Waterbury'],
Delaware: ['Wilmington', 'Dover', 'Newark', 'Middletown', 'Smyrna'],
'District of Columbia': ['Washington'],
Florida: ['Jacksonville', 'Miami', 'Tampa', 'Orlando', 'St. Petersburg', 'Fort Lauderdale'],
Georgia: ['Atlanta', 'Augusta', 'Columbus', 'Savannah', 'Athens'],
Hawaii: ['Honolulu', 'Pearl City', 'Hilo', 'Kailua', 'Waipahu'],
Idaho: ['Boise', 'Meridian', 'Nampa', 'Idaho Falls', 'Pocatello'],
Illinois: ['Chicago', 'Aurora', 'Naperville', 'Joliet', 'Rockford', 'Springfield'],
Indiana: ['Indianapolis', 'Fort Wayne', 'Evansville', 'South Bend', 'Carmel'],
Iowa: ['Des Moines', 'Cedar Rapids', 'Davenport', 'Sioux City', 'Iowa City'],
Kansas: ['Wichita', 'Overland Park', 'Kansas City', 'Topeka', 'Olathe'],
Kentucky: ['Louisville', 'Lexington', 'Bowling Green', 'Owensboro', 'Covington'],
Louisiana: ['New Orleans', 'Baton Rouge', 'Shreveport', 'Lafayette', 'Lake Charles'],
Maine: ['Portland', 'Lewiston', 'Bangor', 'South Portland', 'Auburn'],
Maryland: ['Baltimore', 'Frederick', 'Rockville', 'Gaithersburg', 'Bowie'],
Massachusetts: ['Boston', 'Worcester', 'Springfield', 'Cambridge', 'Lowell'],
Michigan: ['Detroit', 'Grand Rapids', 'Warren', 'Sterling Heights', 'Ann Arbor', 'Lansing'],
Minnesota: ['Minneapolis', 'Saint Paul', 'Rochester', 'Duluth', 'Bloomington'],
Mississippi: ['Jackson', 'Gulfport', 'Southaven', 'Hattiesburg', 'Biloxi'],
Missouri: ['Kansas City', 'Saint Louis', 'Springfield', 'Columbia', 'Independence'],
Montana: ['Billings', 'Missoula', 'Great Falls', 'Bozeman', 'Butte'],
Nebraska: ['Omaha', 'Lincoln', 'Bellevue', 'Grand Island', 'Kearney'],
Nevada: ['Las Vegas', 'Henderson', 'Reno', 'North Las Vegas', 'Sparks'],
'New Hampshire': ['Manchester', 'Nashua', 'Concord', 'Dover', 'Rochester'],
'New Jersey': ['Newark', 'Jersey City', 'Paterson', 'Elizabeth', 'Edison', 'Trenton'],
'New Mexico': ['Albuquerque', 'Las Cruces', 'Rio Rancho', 'Santa Fe', 'Roswell'],
'New York': ['New York City', 'Buffalo', 'Rochester', 'Yonkers', 'Syracuse', 'Albany'],
'North Carolina': ['Charlotte', 'Raleigh', 'Greensboro', 'Durham', 'Winston-Salem'],
'North Dakota': ['Fargo', 'Bismarck', 'Grand Forks', 'Minot', 'West Fargo'],
Ohio: ['Columbus', 'Cleveland', 'Cincinnati', 'Toledo', 'Akron', 'Dayton'],
Oklahoma: ['Oklahoma City', 'Tulsa', 'Norman', 'Broken Arrow', 'Lawton'],
Oregon: ['Portland', 'Eugene', 'Salem', 'Gresham', 'Hillsboro'],
Pennsylvania: ['Philadelphia', 'Pittsburgh', 'Allentown', 'Erie', 'Reading', 'Harrisburg'],
'Rhode Island': ['Providence', 'Warwick', 'Cranston', 'Pawtucket', 'East Providence'],
'South Carolina': ['Charleston', 'Columbia', 'North Charleston', 'Mount Pleasant', 'Greenville'],
'South Dakota': ['Sioux Falls', 'Rapid City', 'Aberdeen', 'Brookings', 'Watertown'],
Tennessee: ['Nashville', 'Memphis', 'Knoxville', 'Chattanooga', 'Clarksville'],
Texas: ['Houston', 'San Antonio', 'Dallas', 'Austin', 'Fort Worth', 'El Paso', 'Arlington', 'Corpus Christi'],
Utah: ['Salt Lake City', 'West Valley City', 'Provo', 'West Jordan', 'Orem'],
Vermont: ['Burlington', 'South Burlington', 'Rutland', 'Barre', 'Montpelier'],
Virginia: ['Virginia Beach', 'Norfolk', 'Chesapeake', 'Richmond', 'Newport News', 'Alexandria'],
Washington: ['Seattle', 'Spokane', 'Tacoma', 'Vancouver', 'Bellevue', 'Olympia'],
'West Virginia': ['Charleston', 'Huntington', 'Morgantown', 'Parkersburg', 'Wheeling'],
Wisconsin: ['Milwaukee', 'Madison', 'Green Bay', 'Kenosha', 'Racine'],
Wyoming: ['Cheyenne', 'Casper', 'Laramie', 'Gillette', 'Rock Springs']
};
const CHINA = require('./china-regions-data.json');
const HK = mkFixed('香港特别行政区', {
'香港岛': ['中西区', '湾仔区', '东区', '南区'],
'九龙': ['油尖旺区', '深水埗区', '九龙城区', '黄大仙区', '观塘区'],
'新界': ['荃湾区', '屯门区', '元朗区', '北区', '大埔区', '沙田区', '西贡区', '葵青区', '离岛区']
});
const MO = mkFixed('澳门特别行政区', {
'花地玛堂区': ['—'], '花王堂区': ['—'], '望德堂区': ['—'], '大堂区': ['—'],
'风顺堂区': ['—'], '嘉模堂区': ['—'], '圣方济各堂区': ['—'], '路氹填海区': ['—']
});
const TW = mkFixed('台湾省', {
'台北市': ['中正区', '大同区', '中山区', '松山区', '大安区', '万华区', '信义区', '士林区', '北投区', '内湖区', '南港区', '文山区'],
'新北市': ['板桥区', '三重区', '中和区', '永和区', '新庄区', '新店区', '树林区', '莺歌区', '三峡区', '淡水区', '汐止区', '瑞芳区', '土城区', '芦洲区', '五股区', '泰山区', '林口区'],
'桃园市': ['桃园区', '中坜区', '平镇区', '八德区', '杨梅区', '芦竹区', '大溪区', '龙潭区', '龟山区', '大园区', '观音区', '新屋区', '复兴区'],
'台中市': ['中区', '东区', '南区', '西区', '北区', '北屯区', '西屯区', '南屯区', '太平区', '大里区', '雾峰区', '乌日区', '丰原区', '后里区', '石冈区', '东势区', '和平区', '新社区', '潭子区', '大雅区', '神冈区', '大肚区', '沙鹿区', '龙井区', '梧栖区', '清水区', '大甲区', '外埔区', '大安区'],
'台南市': ['中西区', '东区', '南区', '北区', '安平区', '安南区', '永康区', '归仁区', '新化区', '左镇区', '玉井区', '楠西区', '南化区', '仁德区', '关庙区', '龙崎区', '官田区', '麻豆区', '佳里区', '西港区', '七股区', '将军区', '学甲区', '北门区', '新营区', '后壁区', '白河区', '东山区', '六甲区', '下营区', '柳营区', '盐水区', '大内区', '山上区', '新市区', '安定区'],
'高雄市': ['新兴区', '前金区', '苓雅区', '盐埕区', '鼓山区', '旗津区', '前镇区', '三民区', '楠梓区', '小港区', '左营区', '仁武区', '大社区', '冈山区', '路竹区', '阿莲区', '田寮区', '燕巢区', '桥头区', '梓官区', '弥陀区', '永安区', '湖内区', '凤山区', '大寮区', '林园区', '鸟松区', '大树区', '旗山区', '美浓区', '六龟区', '内门区', '杉林区', '甲仙区', '桃源区', '那玛夏区', '茂林区', '茄萣区'],
'基隆市': ['仁爱区', '信义区', '中正区', '中山区', '安乐区', '暖暖区', '七堵区'],
'新竹市': ['东区', '北区', '香山区'],
'嘉义市': ['东区', '西区'],
'新竹县': ['竹北市', '湖口乡', '新丰乡', '新埔镇', '关西镇', '芎林乡', '宝山乡', '竹东镇', '五峰乡', '横山乡', '尖石乡', '北埔乡', '峨眉乡'],
'苗栗县': ['苗栗市', '苑里镇', '通霄镇', '竹南镇', '头份市', '后龙镇', '卓兰镇', '大湖乡', '公馆乡', '铜锣乡', '南庄乡', '头屋乡', '三义乡', '西湖乡', '造桥乡', '三湾乡', '狮潭乡', '泰安乡'],
'彰化县': ['彰化市', '鹿港镇', '和美镇', '线西乡', '伸港乡', '福兴乡', '秀水乡', '花坛乡', '芬园乡', '员林镇', '溪湖镇', '田中镇', '大村乡', '埔盐乡', '埔心乡', '永靖乡', '社头乡', '二水乡', '北斗镇', '埤头乡', '溪州乡', '竹塘乡', '大城乡', '芳苑乡', '二林镇'],
'南投县': ['南投市', '埔里镇', '草屯镇', '竹山镇', '集集镇', '名间乡', '鹿谷乡', '中寮乡', '鱼池乡', '国姓乡', '水里乡', '信义乡', '仁爱乡'],
'云林县': ['斗六市', '斗南镇', '虎尾镇', '西螺镇', '土库镇', '北港镇', '古坑乡', '大埤乡', '莿桐乡', '林内乡', '二仑乡', '仑背乡', '麦寮乡', '东势乡', '褒忠乡', '台西乡', '元长乡', '四湖乡', '口湖乡', '水林乡'],
'嘉义县': ['太保市', '朴子市', '布袋镇', '大林镇', '民雄乡', '溪口乡', '新港乡', '六脚乡', '东石乡', '义竹乡', '鹿草乡', '水上乡', '中埔乡', '竹崎乡', '梅山乡', '番路乡', '大埔乡', '阿里山乡'],
'屏东县': ['屏东市', '潮州镇', '东港镇', '恒春镇', '万丹乡', '长治乡', '麟洛乡', '九如乡', '里港乡', '盐埔乡', '高树乡', '万峦乡', '内埔乡', '竹田乡', '新埤乡', '枋寮乡', '新园乡', '崁顶乡', '林边乡', '南州乡', '佳冬乡', '琉球乡', '车城乡', '满州乡', '枋山乡', '三地门乡', '雾台乡', '玛家乡', '泰武乡', '来义乡', '春日乡', '狮子乡', '牡丹乡'],
'宜兰县': ['宜兰市', '罗东镇', '苏澳镇', '头城镇', '礁溪乡', '壮围乡', '员山乡', '冬山乡', '五结乡', '三星乡', '大同乡', '南澳乡'],
'花莲县': ['花莲市', '凤林镇', '玉里镇', '新城乡', '吉安乡', '寿丰乡', '光复乡', '丰滨乡', '瑞穗乡', '富里乡', '秀林乡', '万荣乡', '卓溪乡'],
'台东县': ['台东市', '成功镇', '关山镇', '卑南乡', '鹿野乡', '池上乡', '东河乡', '长滨乡', '太麻里乡', '大武乡', '绿岛乡', '海端乡', '延平乡', '金峰乡', '达仁乡', '兰屿乡'],
'澎湖县': ['马公市', '湖西乡', '白沙乡', '西屿乡', '望安乡', '七美乡'],
'金门县': ['金城镇', '金湖镇', '金沙镇', '金宁乡', '烈屿乡', '乌坵乡'],
'连江县': ['南竿乡', '北竿乡', '莒光乡', '东引乡']
});
const JP = mkTree({
Hokkaido: ['Sapporo', 'Asahikawa', 'Hakodate', 'Kushiro', 'Obihiro'],
Aomori: ['Aomori', 'Hachinohe', 'Hirosaki'],
Iwate: ['Morioka', 'Ofunato', 'Hanamaki'],
Miyagi: ['Sendai', 'Ishinomaki', 'Osaki'],
Akita: ['Akita', 'Yokote', 'Oga'],
Yamagata: ['Yamagata', 'Tsuruoka', 'Sakata'],
Fukushima: ['Fukushima', 'Koriyama', 'Iwaki'],
Ibaraki: ['Mito', 'Tsukuba', 'Hitachi'],
Tochigi: ['Utsunomiya', 'Ashikaga', 'Oyama'],
Gunma: ['Maebashi', 'Takasaki', 'Ota'],
Saitama: ['Saitama', 'Kawagoe', 'Kawaguchi'],
Chiba: ['Chiba', 'Funabashi', 'Ichikawa'],
Tokyo: ['Shinjuku', 'Shibuya', 'Minato', 'Chiyoda', 'Setagaya'],
Kanagawa: ['Yokohama', 'Kawasaki', 'Sagamihara'],
Niigata: ['Niigata', 'Nagaoka', 'Joetsu'],
Toyama: ['Toyama', 'Takaoka', 'Uozu'],
Ishikawa: ['Kanazawa', 'Komatsu', 'Hakusan'],
Fukui: ['Fukui', 'Sakai', 'Obama'],
Yamanashi: ['Kofu', 'Fujiyoshida', 'Kai'],
Nagano: ['Nagano', 'Matsumoto', 'Ueda'],
Gifu: ['Gifu', 'Ogaki', 'Takayama'],
Shizuoka: ['Shizuoka', 'Hamamatsu', 'Numazu'],
Aichi: ['Nagoya', 'Toyohashi', 'Okazaki'],
Mie: ['Tsu', 'Yokkaichi', 'Suzuka'],
Shiga: ['Otsu', 'Hikone', 'Kusatsu'],
Kyoto: ['Kyoto', 'Uji', 'Maizuru'],
Osaka: ['Osaka', 'Sakai', 'Higashiosaka'],
Hyogo: ['Kobe', 'Himeji', 'Nishinomiya'],
Nara: ['Nara', 'Kashihara', 'Ikoma'],
Wakayama: ['Wakayama', 'Tanabe', 'Kinokawa'],
Tottori: ['Tottori', 'Yonago', 'Kurayoshi'],
Shimane: ['Matsue', 'Izumo', 'Hamada'],
Okayama: ['Okayama', 'Kurashiki', 'Tsuyama'],
Hiroshima: ['Hiroshima', 'Fukuyama', 'Kure'],
Yamaguchi: ['Yamaguchi', 'Shimonoseki', 'Ube'],
Tokushima: ['Tokushima', 'Naruto', 'Anan'],
Kagawa: ['Takamatsu', 'Marugame', 'Sakaide'],
Ehime: ['Matsuyama', 'Imabari', 'Uwajima'],
Kochi: ['Kochi', 'Nankoku', 'Shimanto'],
Fukuoka: ['Fukuoka', 'Kitakyushu', 'Kurume'],
Saga: ['Saga', 'Karatsu', 'Tosu'],
Nagasaki: ['Nagasaki', 'Sasebo', 'Isahaya'],
Kumamoto: ['Kumamoto', 'Yatsushiro', 'Amakusa'],
Oita: ['Oita', 'Beppu', 'Nakatsu'],
Miyazaki: ['Miyazaki', 'Miyakonojo', 'Nobeoka'],
Kagoshima: ['Kagoshima', 'Kanoya', 'Satsumasendai'],
Okinawa: ['Naha', 'Okinawa', 'Uruma']
});
const KR = mkTree({
Seoul: ['Jongno-gu', 'Jung-gu', 'Gangnam-gu', 'Mapo-gu', 'Seocho-gu'],
Busan: ['Jung-gu', 'Seo-gu', 'Haeundae-gu', 'Saha-gu'],
Daegu: ['Jung-gu', 'Dong-gu', 'Seo-gu'],
Incheon: ['Jung-gu', 'Nam-gu', 'Yeonsu-gu'],
Gwangju: ['Dong-gu', 'Seo-gu', 'Nam-gu'],
Daejeon: ['Jung-gu', 'Dong-gu', 'Seo-gu'],
Ulsan: ['Jung-gu', 'Nam-gu', 'Dong-gu'],
Sejong: ['Sejong'],
Gyeonggi: ['Suwon', 'Seongnam', 'Goyang', 'Yongin', 'Bucheon'],
Gangwon: ['Chuncheon', 'Wonju', 'Gangneung'],
'North Chungcheong': ['Cheongju', 'Chungju', 'Jecheon'],
'South Chungcheong': ['Daejeon', 'Cheonan', 'Asan'],
'North Jeolla': ['Jeonju', 'Iksan', 'Gunsan'],
'South Jeolla': ['Gwangju', 'Mokpo', 'Suncheon'],
'North Gyeongsang': ['Pohang', 'Gyeongju', 'Gimhae'],
'South Gyeongsang': ['Changwon', 'Jinju', 'Geoje'],
Jeju: ['Jeju', 'Seogwipo']
});
const CA = mkTree({
Alberta: ['Calgary', 'Edmonton', 'Red Deer', 'Lethbridge'],
'British Columbia': ['Vancouver', 'Victoria', 'Surrey', 'Burnaby', 'Kelowna'],
Manitoba: ['Winnipeg', 'Brandon', 'Steinbach'],
'New Brunswick': ['Moncton', 'Saint John', 'Fredericton'],
'Newfoundland and Labrador': ["St. John's", 'Corner Brook', 'Mount Pearl'],
'Nova Scotia': ['Halifax', 'Dartmouth', 'Sydney'],
Ontario: ['Toronto', 'Ottawa', 'Mississauga', 'Hamilton', 'London', 'Kitchener'],
'Prince Edward Island': ['Charlottetown', 'Summerside'],
Quebec: ['Montreal', 'Quebec City', 'Laval', 'Gatineau', 'Sherbrooke'],
Saskatchewan: ['Saskatoon', 'Regina', 'Prince Albert'],
Yukon: ['Whitehorse', 'Dawson City'],
'Northwest Territories': ['Yellowknife', 'Hay River'],
Nunavut: ['Iqaluit', 'Rankin Inlet']
});
const AU = mkTree({
'New South Wales': ['Sydney', 'Newcastle', 'Wollongong', 'Central Coast'],
Victoria: ['Melbourne', 'Geelong', 'Ballarat', 'Bendigo'],
Queensland: ['Brisbane', 'Gold Coast', 'Sunshine Coast', 'Townsville', 'Cairns'],
'Western Australia': ['Perth', 'Fremantle', 'Mandurah', 'Bunbury'],
'South Australia': ['Adelaide', 'Mount Gambier', 'Whyalla'],
Tasmania: ['Hobart', 'Launceston', 'Devonport'],
'Australian Capital Territory': ['Canberra'],
'Northern Territory': ['Darwin', 'Alice Springs', 'Palmerston']
});
const GB = mkTree({
England: ['London', 'Manchester', 'Birmingham', 'Leeds', 'Liverpool', 'Bristol', 'Sheffield'],
Scotland: ['Edinburgh', 'Glasgow', 'Aberdeen', 'Dundee'],
Wales: ['Cardiff', 'Swansea', 'Newport', 'Wrexham'],
'Northern Ireland': ['Belfast', 'Derry', 'Lisburn', 'Newry']
});
const DE = mkTree({
'Baden-Wurttemberg': ['Stuttgart', 'Mannheim', 'Karlsruhe', 'Freiburg'],
Bavaria: ['Munich', 'Nuremberg', 'Augsburg', 'Regensburg'],
Berlin: ['Berlin'],
Brandenburg: ['Potsdam', 'Cottbus', 'Frankfurt (Oder)'],
Bremen: ['Bremen', 'Bremerhaven'],
Hamburg: ['Hamburg'],
Hesse: ['Frankfurt am Main', 'Wiesbaden', 'Darmstadt', 'Kassel'],
'Lower Saxony': ['Hanover', 'Braunschweig', 'Osnabruck', 'Oldenburg'],
'Mecklenburg-Vorpommern': ['Rostock', 'Schwerin', 'Stralsund'],
'North Rhine-Westphalia': ['Cologne', 'Dusseldorf', 'Dortmund', 'Essen', 'Bonn'],
'Rhineland-Palatinate': ['Mainz', 'Ludwigshafen', 'Koblenz', 'Trier'],
Saarland: ['Saarbrucken', 'Neunkirchen', 'Homburg'],
Saxony: ['Dresden', 'Leipzig', 'Chemnitz'],
'Saxony-Anhalt': ['Magdeburg', 'Halle (Saale)', 'Dessau-Rosslau'],
'Schleswig-Holstein': ['Kiel', 'Lubeck', 'Flensburg'],
Thuringia: ['Erfurt', 'Jena', 'Gera', 'Weimar']
});
const FR = mkTree({
'Auvergne-Rhone-Alpes': ['Lyon', 'Grenoble', 'Saint-Etienne', 'Clermont-Ferrand'],
'Bourgogne-Franche-Comte': ['Dijon', 'Besancon'],
Brittany: ['Rennes', 'Brest', 'Nantes'],
'Centre-Val de Loire': ['Orleans', 'Tours', 'Bourges'],
Corsica: ['Ajaccio', 'Bastia'],
'Grand Est': ['Strasbourg', 'Reims', 'Metz', 'Nancy'],
'Hauts-de-France': ['Lille', 'Amiens', 'Roubaix'],
'Ile-de-France': ['Paris', 'Versailles', 'Boulogne-Billancourt', 'Saint-Denis'],
Normandy: ['Rouen', 'Le Havre', 'Caen'],
'Nouvelle-Aquitaine': ['Bordeaux', 'Limoges', 'Poitiers'],
Occitanie: ['Toulouse', 'Montpellier', 'Nimes'],
'Pays de la Loire': ['Nantes', 'Angers', 'Le Mans'],
'Provence-Alpes-Cote dAzur': ['Marseille', 'Nice', 'Toulon', 'Aix-en-Provence']
});
const COUNTRY_META = {
'中国': { lang: 'zh', hasRegion: true, hasDistrict: true },
'中国香港': { lang: 'zh', fixedRegion: '香港特别行政区', hasRegion: true, hasDistrict: true },
'中国澳门': { lang: 'zh', fixedRegion: '澳门特别行政区', hasRegion: true, hasDistrict: false },
'中国台湾': { lang: 'zh', fixedRegion: '台湾省', hasRegion: true, hasDistrict: true },
'美国': { lang: 'en', hasRegion: true, hasDistrict: false },
'日本': { lang: 'en', hasRegion: true, hasDistrict: false },
'韩国': { lang: 'en', hasRegion: true, hasDistrict: false },
'新加坡': { lang: 'en', hasRegion: false, hasDistrict: false },
'马来西亚': { lang: 'en', hasRegion: true, hasDistrict: false },
'泰国': { lang: 'en', hasRegion: true, hasDistrict: false },
'越南': { lang: 'en', hasRegion: true, hasDistrict: false },
'印度': { lang: 'en', hasRegion: true, hasDistrict: false },
'印度尼西亚': { lang: 'en', hasRegion: true, hasDistrict: false },
'菲律宾': { lang: 'en', hasRegion: true, hasDistrict: false },
'英国': { lang: 'en', hasRegion: true, hasDistrict: false },
'法国': { lang: 'en', hasRegion: true, hasDistrict: false },
'德国': { lang: 'en', hasRegion: true, hasDistrict: false },
'意大利': { lang: 'en', hasRegion: true, hasDistrict: false },
'西班牙': { lang: 'en', hasRegion: true, hasDistrict: false },
'荷兰': { lang: 'en', hasRegion: true, hasDistrict: false },
'瑞士': { lang: 'en', hasRegion: true, hasDistrict: false },
'瑞典': { lang: 'en', hasRegion: true, hasDistrict: false },
'挪威': { lang: 'en', hasRegion: true, hasDistrict: false },
'丹麦': { lang: 'en', hasRegion: true, hasDistrict: false },
'芬兰': { lang: 'en', hasRegion: true, hasDistrict: false },
'俄罗斯': { lang: 'en', hasRegion: true, hasDistrict: false },
'波兰': { lang: 'en', hasRegion: true, hasDistrict: false },
'捷克': { lang: 'en', hasRegion: true, hasDistrict: false },
'奥地利': { lang: 'en', hasRegion: true, hasDistrict: false },
'比利时': { lang: 'en', hasRegion: true, hasDistrict: false },
'加拿大': { lang: 'en', hasRegion: true, hasDistrict: false },
'墨西哥': { lang: 'en', hasRegion: true, hasDistrict: false },
'巴西': { lang: 'en', hasRegion: true, hasDistrict: false },
'阿根廷': { lang: 'en', hasRegion: true, hasDistrict: false },
'智利': { lang: 'en', hasRegion: true, hasDistrict: false },
'哥伦比亚': { lang: 'en', hasRegion: true, hasDistrict: false },
'澳大利亚': { lang: 'en', hasRegion: true, hasDistrict: false },
'新西兰': { lang: 'en', hasRegion: true, hasDistrict: false },
'南非': { lang: 'en', hasRegion: true, hasDistrict: false },
'埃及': { lang: 'en', hasRegion: true, hasDistrict: false },
'阿联酋': { lang: 'en', hasRegion: true, hasDistrict: false },
'沙特阿拉伯': { lang: 'en', hasRegion: true, hasDistrict: false },
'以色列': { lang: 'en', hasRegion: true, hasDistrict: false },
'土耳其': { lang: 'en', hasRegion: true, hasDistrict: false },
'其他': { lang: 'en', hasRegion: true, hasDistrict: false }
};
const WORLD_REGIONS = {
'中国': CHINA,
'中国香港': HK,
'中国澳门': MO,
'中国台湾': TW,
'美国': mkTree(US_STATES),
'日本': JP,
'韩国': KR,
'新加坡': mkFlat(['Central Region', 'East Region', 'North Region', 'North-East Region', 'West Region']),
'马来西亚': mkTree({ 'Kuala Lumpur': ['Kuala Lumpur'], Selangor: ['Shah Alam', 'Petaling Jaya'], Penang: ['George Town'], Johor: ['Johor Bahru'] }),
'泰国': mkTree({ Bangkok: ['Bangkok'], ChiangMai: ['Chiang Mai'], Phuket: ['Phuket'] }),
'越南': mkTree({ Hanoi: ['Hanoi'], 'Ho Chi Minh City': ['Ho Chi Minh City'], DaNang: ['Da Nang'] }),
'印度': mkTree({ Delhi: ['New Delhi'], Maharashtra: ['Mumbai', 'Pune'], Karnataka: ['Bengaluru'] }),
'印度尼西亚': mkTree({ Jakarta: ['Central Jakarta', 'South Jakarta'], 'West Java': ['Bandung'], Bali: ['Denpasar'] }),
'菲律宾': mkTree({ 'Metro Manila': ['Manila', 'Quezon City'], Cebu: ['Cebu City'], Davao: ['Davao City'] }),
'英国': GB,
'法国': FR,
'德国': DE,
'意大利': mkTree({ Lazio: ['Rome'], Lombardy: ['Milan'], Veneto: ['Venice'], Tuscany: ['Florence'] }),
'西班牙': mkTree({ Madrid: ['Madrid'], Catalonia: ['Barcelona'], Andalusia: ['Seville'] }),
'荷兰': mkTree({ 'North Holland': ['Amsterdam', 'Haarlem'], 'South Holland': ['Rotterdam', 'The Hague'] }),
'瑞士': mkTree({ Zurich: ['Zurich'], Bern: ['Bern'], Geneva: ['Geneva'] }),
'瑞典': mkTree({ Stockholm: ['Stockholm'], VastraGotaland: ['Gothenburg'], Skane: ['Malmo'] }),
'挪威': mkTree({ Oslo: ['Oslo'], Viken: ['Drammen'], Vestland: ['Bergen'] }),
'丹麦': mkTree({ 'Capital Region': ['Copenhagen'], 'Central Denmark': ['Aarhus'], 'Southern Denmark': ['Odense'] }),
'芬兰': mkTree({ Uusimaa: ['Helsinki'], Pirkanmaa: ['Tampere'], 'Southwest Finland': ['Turku'] }),
'俄罗斯': mkTree({ Moscow: ['Moscow'], 'Saint Petersburg': ['Saint Petersburg'], 'Novosibirsk Oblast': ['Novosibirsk'] }),
'波兰': mkTree({ Masovian: ['Warsaw'], 'Lesser Poland': ['Krakow'], Silesian: ['Katowice'] }),
'捷克': mkTree({ Prague: ['Prague'], 'South Moravian': ['Brno'] }),
'奥地利': mkTree({ Vienna: ['Vienna'], Tyrol: ['Innsbruck'], Styria: ['Graz'] }),
'比利时': mkTree({ Brussels: ['Brussels'], Flanders: ['Antwerp', 'Ghent'], Wallonia: ['Liege', 'Charleroi'] }),
'加拿大': CA,
'墨西哥': mkTree({ 'Mexico City': ['Mexico City'], Jalisco: ['Guadalajara'], 'Nuevo Leon': ['Monterrey'] }),
'巴西': mkTree({ 'Sao Paulo': ['Sao Paulo'], 'Rio de Janeiro': ['Rio de Janeiro'], 'Minas Gerais': ['Belo Horizonte'] }),
'阿根廷': mkTree({ 'Buenos Aires': ['Buenos Aires'], Cordoba: ['Cordoba'], Mendoza: ['Mendoza'] }),
'智利': mkTree({ 'Santiago Metropolitan': ['Santiago'], Valparaiso: ['Valparaiso'], Biobio: ['Concepcion'] }),
'哥伦比亚': mkTree({ Bogota: ['Bogota'], Antioquia: ['Medellin'], Valle: ['Cali'] }),
'澳大利亚': AU,
'新西兰': mkTree({ Auckland: ['Auckland'], Wellington: ['Wellington'], Canterbury: ['Christchurch'] }),
'南非': mkTree({ Gauteng: ['Johannesburg', 'Pretoria'], 'Western Cape': ['Cape Town'], 'KwaZulu-Natal': ['Durban'] }),
'埃及': mkTree({ Cairo: ['Cairo'], Alexandria: ['Alexandria'], Giza: ['Giza'] }),
'阿联酋': mkTree({ Dubai: ['Dubai'], 'Abu Dhabi': ['Abu Dhabi'], Sharjah: ['Sharjah'] }),
'沙特阿拉伯': mkTree({ Riyadh: ['Riyadh'], Makkah: ['Jeddah'], 'Eastern Province': ['Dammam'] }),
'以色列': mkTree({ TelAviv: ['Tel Aviv'], Jerusalem: ['Jerusalem'], Haifa: ['Haifa'] }),
'土耳其': mkTree({ Istanbul: ['Istanbul'], Ankara: ['Ankara'], Izmir: ['Izmir'] }),
'其他': mkTree({ 'Province/State': ['City'] })
};
const ADDRESS_COUNTRIES = Object.keys(COUNTRY_META);
const out = `/* Auto-generated address region data for Feishu-style address conditions */
(function (g) {
var ADDRESS_COUNTRIES = ${JSON.stringify(ADDRESS_COUNTRIES)};
var COUNTRY_META = ${JSON.stringify(COUNTRY_META, null, 2)};
var WORLD_REGIONS = ${JSON.stringify(WORLD_REGIONS)};
function getCountryMeta(country) {
return COUNTRY_META[country] || COUNTRY_META['其他'];
}
function getCountryRegionTree(country) {
return WORLD_REGIONS[country] || WORLD_REGIONS['其他'];
}
function getProvincesForCountry(country) {
var meta = getCountryMeta(country);
if (meta.fixedRegion) return [meta.fixedRegion];
if (!meta.hasRegion) return [];
var tree = getCountryRegionTree(country);
return Object.keys(tree);
}
function getCitiesForCountry(country, province) {
var meta = getCountryMeta(country);
var tree = getCountryRegionTree(country);
if (!meta.hasRegion) return Object.keys(tree);
if (!province) return [];
if (meta.fixedRegion && tree[meta.fixedRegion]) return Object.keys(tree[meta.fixedRegion]);
return tree[province] ? Object.keys(tree[province]) : [];
}
function getDistrictsForCountry(country, province, city) {
var meta = getCountryMeta(country);
if (!meta.hasDistrict) return [];
var tree = getCountryRegionTree(country);
var regionKey = meta.fixedRegion || province;
if (!regionKey || !city) return [];
if (meta.fixedRegion && tree[meta.fixedRegion] && tree[meta.fixedRegion][city]) return tree[meta.fixedRegion][city];
if (tree[regionKey] && tree[regionKey][city]) return tree[regionKey][city];
return [];
}
function applyCountryAddressDefaults(country, addr) {
var meta = getCountryMeta(country);
addr.country = country;
if (meta.fixedRegion) addr.province = meta.fixedRegion;
else if (!meta.hasRegion) addr.province = '';
else if (addr.province && !getProvincesForCountry(country).includes(addr.province)) addr.province = '';
if (addr.city && !getCitiesForCountry(country, addr.province || meta.fixedRegion).includes(addr.city)) addr.city = '';
if (addr.district && !getDistrictsForCountry(country, addr.province, addr.city).includes(addr.district)) addr.district = '';
return addr;
}
g.ADDRESS_COUNTRIES = ADDRESS_COUNTRIES;
g.COUNTRY_META = COUNTRY_META;
g.WORLD_REGIONS = WORLD_REGIONS;
g.getCountryMeta = getCountryMeta;
g.getCountryRegionTree = getCountryRegionTree;
g.getProvincesForCountry = getProvincesForCountry;
g.getCitiesForCountry = getCitiesForCountry;
g.getDistrictsForCountry = getDistrictsForCountry;
g.applyCountryAddressDefaults = applyCountryAddressDefaults;
})(typeof window !== 'undefined' ? window : global);
`;
fs.writeFileSync(path.join(__dirname, '..', 'address-regions.js'), out, 'utf8');
console.log('Wrote address-regions.js', (out.length / 1024).toFixed(1), 'KB');

File diff suppressed because one or more lines are too long

205
server.js Normal file
View File

@@ -0,0 +1,205 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const ROOT = __dirname;
const CONFIG_PATH = path.join(ROOT, 'project.config.json');
const TEMPLATE_HTML = path.join(ROOT, '飞书审批配置文件_V1.0.html');
const ADDRESS_REGIONS_JS = path.join(ROOT, 'address-regions.js');
function loadProjectConfig() {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch (e) {}
return { projectId: 'approval_of_design', port: 8080, host: '0.0.0.0', label: '飞书审批配置服务' };
}
const PROJECT = loadProjectConfig();
const PORT = Number(process.env.PORT) || PROJECT.port || 8080;
const HOST = process.env.HOST || PROJECT.host || '0.0.0.0';
const NODE_MAJOR = parseInt(String(process.versions.node).split('.')[0], 10);
if (NODE_MAJOR < 16) {
console.error('[ERROR] Node.js 16.x or later is required. Current: ' + process.version);
process.exit(1);
}
let templateCache = null;
function readTemplate() {
if (!templateCache) {
templateCache = fs.readFileSync(TEMPLATE_HTML, 'utf8');
}
return templateCache;
}
function reloadTemplate() {
templateCache = null;
return readTemplate();
}
function safeJsonForScript(value) {
return JSON.stringify(value).replace(/<\//g, '\\u003c/');
}
function renderBootScript(config, mode) {
const project = {
id: PROJECT.projectId || 'approval_of_design',
label: PROJECT.label || '飞书审批配置服务'
};
return [
'<script id="app-boot">',
'window.APP_PROJECT = ' + safeJsonForScript(project) + ';',
"window.APP_MODE = '" + mode + "';",
'window.__INITIAL_CONFIG__ = ' + safeJsonForScript(config) + ';',
'</script>'
].join('\n');
}
function injectBoot(html, config, mode) {
return html.replace(/<script id="app-boot">[\s\S]*?<\/script>/, renderBootScript(config, mode));
}
function inlineAddressRegions(html) {
if (!fs.existsSync(ADDRESS_REGIONS_JS)) return html;
const js = fs.readFileSync(ADDRESS_REGIONS_JS, 'utf8');
return html.replace(
/<script src="address-regions\.js"><\/script>/,
'<script>\n' + js + '\n</script>'
);
}
function prepareHtml(config, mode) {
return inlineAddressRegions(injectBoot(readTemplate(), config, mode));
}
function sanitizeFilename(name) {
return String(name || '审批配置')
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 80) || '审批配置';
}
function sendJson(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
}
function readBody(req, limit) {
const maxBytes = limit || 20 * 1024 * 1024;
return new Promise(function (resolve, reject) {
var body = '';
var size = 0;
req.on('data', function (chunk) {
size += chunk.length;
if (size > maxBytes) {
reject(new Error('request body too large'));
req.destroy();
return;
}
body += chunk;
});
req.on('end', function () { resolve(body); });
req.on('error', reject);
});
}
function parseInitialConfigFromHtml(html) {
const match = html.match(/window\.__INITIAL_CONFIG__\s*=\s*([\s\S]*?);\s*(?:\n|<\/script>)/);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch (e) {
return null;
}
}
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
try {
if (req.method === 'GET' && (pathname === '/' || pathname === '/editor')) {
const initial = parsed.query.config ? JSON.parse(parsed.query.config) : null;
const html = prepareHtml(initial, 'server');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(html);
return;
}
if (req.method === 'GET' && pathname === '/address-regions.js') {
if (!fs.existsSync(ADDRESS_REGIONS_JS)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('address-regions.js not found');
return;
}
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(fs.readFileSync(ADDRESS_REGIONS_JS, 'utf8'));
return;
}
if (req.method === 'GET' && pathname === '/api/health') {
sendJson(res, 200, { ok: true, service: PROJECT.label, projectId: PROJECT.projectId });
return;
}
if (req.method === 'POST' && pathname === '/api/export') {
const body = await readBody(req);
const config = JSON.parse(body);
config.exportedAt = new Date().toISOString();
config.projectId = PROJECT.projectId;
const html = prepareHtml(config, 'standalone');
const filename = sanitizeFilename(config.approvalName) + '.html';
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': 'attachment; filename="' + encodeURIComponent(filename) + '"',
'Cache-Control': 'no-store'
});
res.end(html);
return;
}
if (req.method === 'POST' && pathname === '/api/import') {
const body = await readBody(req);
const payload = JSON.parse(body);
let config = null;
if (payload.config) {
config = payload.config;
} else if (payload.html) {
config = parseInitialConfigFromHtml(payload.html);
}
if (!config) {
sendJson(res, 400, { ok: false, error: 'invalid import payload' });
return;
}
sendJson(res, 200, { ok: true, config });
return;
}
if (req.method === 'POST' && pathname === '/api/reload-template') {
reloadTemplate();
sendJson(res, 200, { ok: true });
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
} catch (err) {
sendJson(res, 500, { ok: false, error: err.message || 'server error' });
}
});
server.listen(PORT, HOST, () => {
console.log('[' + (PROJECT.label || 'approval') + '] http://' + HOST + ':' + PORT);
});

File diff suppressed because it is too large Load Diff