From ceaf45ef03ae7f777c384b9dadf18a3095309c1d Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 00:58:56 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + deploy/deploy.env.example | 9 ++ deploy/deploy.sh | 124 ++++++++++++++++++++ deploy/ecosystem.config.cjs | 9 ++ deploy/enable-api-lingshi-ssl.sh | 65 +++++++++++ deploy/nginx-lingshivip-http.conf | 105 +++++++++++++++++ deploy/nginx-lingshivip.conf | 135 ++++++++++++++++++++++ deploy/remote-release.sh | 88 ++++++++++++++ deploy/sync-api-env.sh | 48 ++++++++ server/dukang-api/.env.example | 7 +- server/dukang-api/.env.production.example | 40 +++++++ 11 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 deploy/deploy.env.example create mode 100644 deploy/deploy.sh create mode 100644 deploy/enable-api-lingshi-ssl.sh create mode 100644 deploy/nginx-lingshivip-http.conf create mode 100644 deploy/nginx-lingshivip.conf create mode 100644 deploy/remote-release.sh create mode 100644 deploy/sync-api-env.sh create mode 100644 server/dukang-api/.env.production.example diff --git a/.gitignore b/.gitignore index 27bb47a..7bcbf3e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ dist/ *.log .env .env.local +server/dukang-api/.env +server/dukang-api/.env.development +server/dukang-api/.env.production +deploy/deploy.env .DS_Store coverage/ *.tsbuildinfo diff --git a/deploy/deploy.env.example b/deploy/deploy.env.example new file mode 100644 index 0000000..cc0c8fe --- /dev/null +++ b/deploy/deploy.env.example @@ -0,0 +1,9 @@ +# 复制为 deploy.env 后填写(deploy.env 勿提交) +DEPLOY_HOST=your.server.ip.or.domain +DEPLOY_USER=root +DEPLOY_PORT=22 +# DEPLOY_SSH_KEY=~/.ssh/id_rsa + +APP_ROOT=/opt/dukang-haoke +GIT_REMOTE=origin +GIT_BRANCH=main diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..f65eab4 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# 本地执行:SSH 到服务器拉代码并发布 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/deploy.env" + +DEPLOY_HOST="" +DEPLOY_USER="root" +DEPLOY_PORT="22" +DEPLOY_SSH_KEY="" +APP_ROOT="/opt/dukang-haoke" +GIT_REMOTE="origin" +GIT_BRANCH="main" + +CHECK_ONLY=false +RELEASE_ARGS=() + +usage() { + cat <<'EOF' +用法: deploy/deploy.sh [选项] [-- 远程 release 参数] + +环境配置: deploy/deploy.env(可复制 deploy.env.example) + +选项: + --host HOST 服务器地址(覆盖 deploy.env) + --user USER SSH 用户,默认 root + --port PORT SSH 端口,默认 22 + --key PATH SSH 私钥路径 + --branch BRANCH 发布分支,默认 main + --check 仅 SSH 连接并查看 PM2 / 端口状态 + -h, --help 显示帮助 + +示例: + ./deploy/deploy.sh + ./deploy/deploy.sh --branch main + ./deploy/deploy.sh -- --skip-db + ./deploy/deploy.sh -- --seed --accept-data-loss +EOF +} + +load_env() { + if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) DEPLOY_HOST="$2"; shift 2 ;; + --user) DEPLOY_USER="$2"; shift 2 ;; + --port) DEPLOY_PORT="$2"; shift 2 ;; + --key) DEPLOY_SSH_KEY="$2"; shift 2 ;; + --branch) GIT_BRANCH="$2"; shift 2 ;; + --check) CHECK_ONLY=true; shift ;; + -h|--help) usage; exit 0 ;; + --) + shift + RELEASE_ARGS=("$@") + break + ;; + *) RELEASE_ARGS+=("$1"); shift ;; + esac +done + +load_env + +if [[ -z "$DEPLOY_HOST" ]]; then + echo "错误: 未配置 DEPLOY_HOST。请创建 deploy/deploy.env 或使用 --host" >&2 + exit 1 +fi + +SSH_OPTS=(-o "StrictHostKeyChecking=accept-new" -p "$DEPLOY_PORT") +if [[ -n "$DEPLOY_SSH_KEY" ]]; then + SSH_OPTS+=(-i "$DEPLOY_SSH_KEY") +fi +TARGET="${DEPLOY_USER}@${DEPLOY_HOST}" + +run_remote() { + ssh "${SSH_OPTS[@]}" "$TARGET" "$@" +} + +if [[ "$CHECK_ONLY" == true ]]; then + echo "==> 检查 $TARGET" + run_remote "pm2 list; ss -tlnp | grep -E '809[0-3]' || true" + exit 0 +fi + +REMOTE_RELEASE_ARGS="" +if [[ ${#RELEASE_ARGS[@]} -gt 0 ]]; then + REMOTE_RELEASE_ARGS="${RELEASE_ARGS[*]}" +fi + +echo "==> 发布到 $TARGET ($GIT_BRANCH)" + +run_remote bash -s <&2 + exit 1 +fi + +echo "==> 拉取代码" +git fetch "\$GIT_REMOTE" +git checkout "\$GIT_BRANCH" +git pull --ff-only "\$GIT_REMOTE" "\$GIT_BRANCH" + +if [[ ! -f deploy/remote-release.sh ]]; then + echo "错误: deploy/remote-release.sh 不存在,请先推送部署脚本到仓库" >&2 + exit 1 +fi + +bash deploy/remote-release.sh \$RELEASE_ARGS +EOF + +echo "==> 本地发布命令已完成" diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs index 8f8dce5..22f5798 100644 --- a/deploy/ecosystem.config.cjs +++ b/deploy/ecosystem.config.cjs @@ -41,5 +41,14 @@ module.exports = { instances: 1, exec_mode: 'fork', }, + { + name: 'dukang-admin-web', + cwd: `${APP_ROOT}/apps/admin-web`, + script: 'npx', + args: 'serve -s dist -l 8094', + interpreter: 'none', + instances: 1, + exec_mode: 'fork', + }, ], }; diff --git a/deploy/enable-api-lingshi-ssl.sh b/deploy/enable-api-lingshi-ssl.sh new file mode 100644 index 0000000..e028a91 --- /dev/null +++ b/deploy/enable-api-lingshi-ssl.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# 为 api.lingshivip.cn 申请 DNS 证书并启用 HTTPS +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SSL_DIR="/etc/nginx/ssl/api.lingshivip.cn" +NGINX_CONF="/etc/nginx/conf.d/dukang-lingshivip.conf" + +mkdir -p "$SSL_DIR" /var/www/certbot/.well-known/acme-challenge + +echo "==> 1. 发起 DNS 验证(需先在 DNSPod 添加 TXT 记录)" +~/.acme.sh/acme.sh --issue -d api.lingshivip.cn --dns \ + --yes-I-know-dns-manual-mode-enough-go-ahead-please \ + --server letsencrypt || true + +echo "" +echo "请在 DNSPod 添加上述 TXT 记录后,执行:" +echo " $0 --renew" +echo "" + +if [[ "${1:-}" != "--renew" ]]; then + exit 0 +fi + +echo "==> 2. 继续 DNS 验证并签发证书" +~/.acme.sh/acme.sh --renew -d api.lingshivip.cn --yes-I-know-dns-manual-mode-enough-go-ahead-please + +echo "==> 3. 安装证书到 nginx 目录" +install -m 644 ~/.acme.sh/api.lingshivip.cn_ecc/fullchain.cer "$SSL_DIR/fullchain.cer" +install -m 600 ~/.acme.sh/api.lingshivip.cn_ecc/api.lingshivip.cn.key "$SSL_DIR/api.lingshivip.cn.key" + +echo "==> 4. 更新 nginx 并重载" +cp "$SCRIPT_DIR/nginx-lingshivip.conf" "$NGINX_CONF" +python3 - <<'PY' +from pathlib import Path +p = Path("/etc/nginx/conf.d/dukang-lingshivip.conf") +text = p.read_text() +old = """ location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } +} + +# API 独立域名 HTTPS""" +new = """ location / { + return 301 https://$host$request_uri; + } +} + +# API 独立域名 HTTPS""" +if old not in text: + raise SystemExit("nginx api http redirect patch failed") +p.write_text(text.replace(old, new, 1)) +PY + +nginx -t +systemctl reload nginx + +echo "==> 5. 健康检查" +curl -sf -o /dev/null -w "api-https:%{http_code}\n" https://api.lingshivip.cn/api/v1/health +echo "==> api.lingshivip.cn HTTPS 已启用" diff --git a/deploy/nginx-lingshivip-http.conf b/deploy/nginx-lingshivip-http.conf new file mode 100644 index 0000000..4da8d6a --- /dev/null +++ b/deploy/nginx-lingshivip-http.conf @@ -0,0 +1,105 @@ +# 证书申请期间 — lingshivip.cn HTTP(api HTTPS 证书就绪后换 nginx-lingshivip.conf) + +map $host $dukang_lingshi_port { + user.lingshivip.cn 8091; + shop.lingshivip.cn 8092; + partner.lingshivip.cn 8093; + admin.lingshivip.cn 8094; +} + +server { + listen 80; + server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 80; + server_name api.lingshivip.cn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } +} + +server { + listen 80; + server_name admin.lingshivip.cn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:8094; + proxy_http_version 1.1; + 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; + } +} + +server { + listen 443 ssl; + server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn; + + access_log /var/log/nginx/dukang/lingshi.access.log main; + error_log /var/log/nginx/dukang/lingshi.error.log warn; + + ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + client_max_body_size 20m; + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:$dukang_lingshi_port; + proxy_http_version 1.1; + 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; + } +} diff --git a/deploy/nginx-lingshivip.conf b/deploy/nginx-lingshivip.conf new file mode 100644 index 0000000..1efb64b --- /dev/null +++ b/deploy/nginx-lingshivip.conf @@ -0,0 +1,135 @@ +# 杜康好客 — lingshivip.cn 五端 + API +# API 8090;H5 8091/8092/8093;admin-web 8094 + +map $host $dukang_lingshi_port { + user.lingshivip.cn 8091; + shop.lingshivip.cn 8092; + partner.lingshivip.cn 8093; + admin.lingshivip.cn 8094; +} + +# HTTP → HTTPS(H5 三端) +server { + listen 80; + server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# API:证书就绪前 HTTP 直连;就绪后改走 HTTPS server +server { + listen 80; + server_name api.lingshivip.cn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } +} + +# admin 待 DNS + 证书就绪前先走 HTTP +server { + listen 80; + server_name admin.lingshivip.cn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:8094; + proxy_http_version 1.1; + 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; + } +} + +# API 独立域名 HTTPS(证书:/etc/nginx/ssl/api.lingshivip.cn/) +server { + listen 443 ssl; + server_name api.lingshivip.cn; + + access_log /var/log/nginx/dukang/lingshi-api.access.log main; + error_log /var/log/nginx/dukang/lingshi-api.error.log warn; + + ssl_certificate /etc/nginx/ssl/api.lingshivip.cn/fullchain.cer; + ssl_certificate_key /etc/nginx/ssl/api.lingshivip.cn/api.lingshivip.cn.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } +} + +server { + listen 443 ssl; + server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn; + + access_log /var/log/nginx/dukang/lingshi.access.log main; + error_log /var/log/nginx/dukang/lingshi.error.log warn; + + ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + client_max_body_size 20m; + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:$dukang_lingshi_port; + proxy_http_version 1.1; + 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; + } +} diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh new file mode 100644 index 0000000..b574373 --- /dev/null +++ b/deploy/remote-release.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# 在服务器上执行:拉取后的常规发版(不覆盖 .env、默认不 seed) +set -euo pipefail + +APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}" +DEPLOY_DIR="$APP_ROOT/deploy" + +SKIP_BUILD=false +SKIP_DB=false +RUN_SEED=false +DB_PUSH_ACCEPT_DATA_LOSS=false + +usage() { + cat <<'EOF' +用法: remote-release.sh [选项] + + --skip-build 跳过 pnpm build(仅重启 PM2) + --skip-db 跳过 Prisma generate / db push + --seed 执行 prisma:seed(默认不执行) + --accept-data-loss prisma db push 时允许数据丢失(慎用) + -h, --help 显示帮助 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) SKIP_BUILD=true ;; + --skip-db) SKIP_DB=true ;; + --seed) RUN_SEED=true ;; + --accept-data-loss) DB_PUSH_ACCEPT_DATA_LOSS=true ;; + -h|--help) usage; exit 0 ;; + *) echo "未知参数: $1" >&2; usage; exit 1 ;; + esac + shift +done + +cd "$APP_ROOT" + +echo "==> 1. 启用 pnpm" +corepack enable 2>/dev/null || true +corepack prepare pnpm@11.2.2 --activate 2>/dev/null || true + +echo "==> 2. 安装依赖" +export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}" +pnpm install --frozen-lockfile 2>/dev/null || pnpm install + +if [[ "$SKIP_BUILD" == false ]]; then + echo "==> 3. 构建" + pnpm build +else + echo "==> 3. 跳过构建" +fi + +if [[ "$SKIP_DB" == false ]]; then + echo "==> 4. 数据库 schema 同步" + cd "$APP_ROOT/server/dukang-api" + pnpm prisma:generate + if [[ "$DB_PUSH_ACCEPT_DATA_LOSS" == true ]]; then + npx prisma db push --accept-data-loss + else + npx prisma db push + fi + if [[ "$RUN_SEED" == true ]]; then + echo "==> 执行 seed" + pnpm prisma:seed + fi +else + echo "==> 4. 跳过数据库" +fi + +echo "==> 5. 重启 PM2" +if pm2 describe dukang-api &>/dev/null; then + pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner +else + pm2 start "$DEPLOY_DIR/ecosystem.config.cjs" +fi +pm2 save + +echo "==> 6. 健康检查" +sleep 2 +pm2 list +ss -tlnp | grep -E '809[0-3]' || true +curl -sf -o /dev/null -w "h5-user: %{http_code}\n" http://127.0.0.1:8091/ || echo "h5-user: FAIL" +curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null \ + || curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/ \ + || echo "api: FAIL" + +echo "==> 发版完成" diff --git a/deploy/sync-api-env.sh b/deploy/sync-api-env.sh new file mode 100644 index 0000000..6f1ee62 --- /dev/null +++ b/deploy/sync-api-env.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# 同步 API 环境变量到服务器 +# 用法: deploy/sync-api-env.sh [development|production] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$REPO_ROOT/deploy/deploy.env" + +DEPLOY_HOST="" +DEPLOY_USER="root" +DEPLOY_PORT="22" +DEPLOY_SSH_KEY="" +APP_ROOT="/opt/dukang-haoke" + +TARGET="${1:-development}" +case "$TARGET" in + development|dev) SRC_ENV="$REPO_ROOT/server/dukang-api/.env.development" ;; + production|prod) SRC_ENV="$REPO_ROOT/server/dukang-api/.env.production" ;; + *) + echo "用法: $0 [development|production]" >&2 + exit 1 + ;; +esac + +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" +fi + +if [[ ! -f "$SRC_ENV" ]]; then + echo "错误: 未找到 $SRC_ENV" >&2 + exit 1 +fi + +if [[ -z "$DEPLOY_HOST" ]]; then + echo "错误: 未配置 DEPLOY_HOST(deploy/deploy.env)" >&2 + exit 1 +fi + +SSH_OPTS=(-o "StrictHostKeyChecking=accept-new" -p "$DEPLOY_PORT") +[[ -n "$DEPLOY_SSH_KEY" ]] && SSH_OPTS+=(-i "$DEPLOY_SSH_KEY") +REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}" + +echo "==> 同步 $TARGET 环境到 $REMOTE:$APP_ROOT/server/dukang-api/.env" +scp "${SSH_OPTS[@]}" "$SRC_ENV" "$REMOTE:$APP_ROOT/server/dukang-api/.env" +ssh "${SSH_OPTS[@]}" "$REMOTE" "pm2 restart dukang-api && sleep 2 && curl -sf -o /dev/null -w 'api-health:%{http_code}\n' http://127.0.0.1:8090/api/v1/health" +echo "==> 完成" diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index cb14b88..4884396 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -1,3 +1,8 @@ +# 杜康 API 环境变量模板 +# 开发:cp .env.development.example .env.development → 本地再 cp 为 .env(改 PORT/DATABASE_URL) +# 生产:cp .env.production.example .env.production +# 服务器同步:bash deploy/sync-api-env.sh development|production + DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke" REDIS_URL="redis://localhost:6379" JWT_SECRET="dukang-prev1-dev-secret-change-in-prod" @@ -46,4 +51,4 @@ OSS_UPLOAD_EXPIRE_SECONDS=900 # 单文件大小上限(字节,默认 10MB) OSS_MAX_UPLOAD_BYTES=10485760 # 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加 -# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com \ No newline at end of file +# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example new file mode 100644 index 0000000..ecba97a --- /dev/null +++ b/server/dukang-api/.env.production.example @@ -0,0 +1,40 @@ +# 生产环境(真实第三方,关闭 Mock) +# 用法:cp .env.production.example .env.production 后填写密钥 +# 服务器:bash deploy/sync-api-env.sh production + +NODE_ENV=production + +DATABASE_URL="mysql://dukang:CHANGE_ME@localhost:3306/dukang_haoke" +REDIS_URL="redis://localhost:6379" +JWT_SECRET="CHANGE_ME-strong-random-secret" +JWT_EXPIRES_IN="7d" +PORT=8090 + +MOCK_SMS=false +MOCK_SMS_CODE= +MOCK_PAY=false +MOCK_DELIVERY_AUTO=false +AUTO_APPROVE_STORE=false + +TRUST_PROXY=true + +WECHAT_AUTH_ENABLED=true +WECHAT_PAY_ENABLED=true +WX_APP_ID= +WX_APP_SECRET= +WX_MCH_ID= +WX_MCH_SERIAL_NO= +WX_MCH_PRIVATE_KEY= +WX_API_V3_KEY= +WX_PLATFORM_CERT= +WX_PAY_NOTIFY_URL=https://api.lingshivip.cn/api/v1/callbacks/wechat/pay + +OSS_ENABLED=true +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_BUCKET=dukang-prod +OSS_REGION=oss-cn-beijing +OSS_CDN_BASE=https://lingshivip.cn +OSS_UPLOAD_PREFIX=uploads +OSS_UPLOAD_EXPIRE_SECONDS=900 +OSS_MAX_UPLOAD_BYTES=10485760 From 160fdcb0d743ae65d341ad10189e83da3e2a000c Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 09:58:40 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt diff --git a/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt b/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt new file mode 100644 index 0000000..af78290 --- /dev/null +++ b/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt @@ -0,0 +1 @@ +ayPJ4CQqbUcec3jX From cc399751176c793dc913489848893a0e75a0f641 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 12:57:01 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E6=8E=A5=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/enable-runxian-dukang-ssl.sh | 55 ++++++++++ deploy/nginx-runxian-dukang-ssl.conf | 150 +++++++++++++++++++++++++++ deploy/nginx-runxian-dukang.conf | 110 ++++++++++++++++++++ deploy/remote-release.sh | 3 +- 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 deploy/enable-runxian-dukang-ssl.sh create mode 100644 deploy/nginx-runxian-dukang-ssl.conf create mode 100644 deploy/nginx-runxian-dukang.conf diff --git a/deploy/enable-runxian-dukang-ssl.sh b/deploy/enable-runxian-dukang-ssl.sh new file mode 100644 index 0000000..4240266 --- /dev/null +++ b/deploy/enable-runxian-dukang-ssl.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# 为杜康 runxian.top 五域名申请 Let's Encrypt 并切换 HTTPS 配置 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DOMAINS=( + user.runxian.top + shop.runxian.top + partner.runxian.top + webadmin.runxian.top + dkapi.runxian.top +) + +echo "==> 检查 DNS 解析到本机..." +for d in "${DOMAINS[@]}"; do + ip="$(dig +short "$d" @223.5.5.5 | tail -1)" + if [[ -z "$ip" ]]; then + echo "ERROR: $d 无 A 记录,请先在 DNS 添加指向本机公网 IP" + exit 1 + fi + echo " $d -> $ip" +done + +mkdir -p /var/www/certbot /var/log/nginx/dukang + +echo "==> 申请证书..." +certbot certonly --webroot -w /var/www/certbot \ + --cert-name user.runxian.top \ + -d user.runxian.top \ + -d shop.runxian.top \ + -d partner.runxian.top \ + -d webadmin.runxian.top \ + -d dkapi.runxian.top \ + --non-interactive --agree-tos -m admin@runxian.top || { + echo "certbot 失败,请确认 DNS 已生效且 80 端口可从公网访问" + exit 1 +} + +echo "==> 切换 nginx HTTPS 配置..." +install -m 644 "$SCRIPT_DIR/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf +nginx -t +systemctl reload nginx + +echo "==> 验证..." +for url in \ + https://user.runxian.top/ \ + https://shop.runxian.top/ \ + https://partner.runxian.top/ \ + https://webadmin.runxian.top/ \ + https://dkapi.runxian.top/api/v1/health; do + code="$(curl -sf -o /dev/null -w '%{http_code}' "$url" || echo fail)" + echo " $url -> $code" +done + +echo "==> runxian.top 杜康域名 HTTPS 已启用" diff --git a/deploy/nginx-runxian-dukang-ssl.conf b/deploy/nginx-runxian-dukang-ssl.conf new file mode 100644 index 0000000..b240a5c --- /dev/null +++ b/deploy/nginx-runxian-dukang-ssl.conf @@ -0,0 +1,150 @@ +# 杜康好客 — runxian.top HTTPS(证书申请后启用) +# 执行: deploy/enable-runxian-dukang-ssl.sh + +map $host $dukang_runxian_port { + user.runxian.top 8091; + shop.runxian.top 8092; + partner.runxian.top 8093; + webadmin.runxian.top 8094; +} + +server { + listen 80; + server_name user.runxian.top shop.runxian.top partner.runxian.top; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location ~ ^/MP_verify_.*\.txt$ { + root /opt/dukang-haoke/apps/h5-user/dist; + default_type text/plain; + access_log off; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name user.runxian.top shop.runxian.top partner.runxian.top; + + access_log /var/log/nginx/dukang/runxian-h5.access.log main; + error_log /var/log/nginx/dukang/runxian-h5.error.log warn; + + ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + client_max_body_size 20m; + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:$dukang_runxian_port; + proxy_http_version 1.1; + 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; + } +} + +server { + listen 80; + server_name dkapi.runxian.top; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name dkapi.runxian.top; + + access_log /var/log/nginx/dukang/runxian-api.access.log main; + error_log /var/log/nginx/dukang/runxian-api.error.log warn; + + ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } +} + +server { + listen 80; + server_name webadmin.runxian.top; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name webadmin.runxian.top; + + access_log /var/log/nginx/dukang/runxian-admin.access.log main; + error_log /var/log/nginx/dukang/runxian-admin.error.log warn; + + ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + client_max_body_size 20m; + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:8094; + proxy_http_version 1.1; + 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; + } +} diff --git a/deploy/nginx-runxian-dukang.conf b/deploy/nginx-runxian-dukang.conf new file mode 100644 index 0000000..7247625 --- /dev/null +++ b/deploy/nginx-runxian-dukang.conf @@ -0,0 +1,110 @@ +# 杜康好客 — runxian.top 别名域名(与 lingshivip.cn 同后端) +# user/shop/partner → 8091/8092/8093;webadmin → 8094;dkapi → 8090 +# 证书:certbot --cert-name user.runxian.top -d user.runxian.top -d shop.runxian.top ... + +map $host $dukang_runxian_port { + user.runxian.top 8091; + shop.runxian.top 8092; + partner.runxian.top 8093; + webadmin.runxian.top 8094; +} + +# --- HTTP: H5 三端(校验文件 + API;证书就绪后见 dukang-runxian-ssl.conf)--- +server { + listen 80; + server_name user.runxian.top shop.runxian.top partner.runxian.top; + + access_log /var/log/nginx/dukang/runxian-h5.access.log main; + error_log /var/log/nginx/dukang/runxian-h5.error.log warn; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location ~ ^/MP_verify_.*\.txt$ { + root /opt/dukang-haoke/apps/h5-user/dist; + default_type text/plain; + access_log off; + } + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:$dukang_runxian_port; + proxy_http_version 1.1; + 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; + } +} + +# --- HTTP: API --- +server { + listen 80; + server_name dkapi.runxian.top; + + access_log /var/log/nginx/dukang/runxian-api.access.log main; + error_log /var/log/nginx/dukang/runxian-api.error.log warn; + + client_max_body_size 20m; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } +} + +# --- HTTP: admin-web --- +server { + listen 80; + server_name webadmin.runxian.top; + + access_log /var/log/nginx/dukang/runxian-admin.access.log main; + error_log /var/log/nginx/dukang/runxian-admin.error.log warn; + + client_max_body_size 20m; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location /api/ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://127.0.0.1:8094; + proxy_http_version 1.1; + 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; + } +} diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh index b574373..b0f24ff 100644 --- a/deploy/remote-release.sh +++ b/deploy/remote-release.sh @@ -70,7 +70,8 @@ fi echo "==> 5. 重启 PM2" if pm2 describe dukang-api &>/dev/null; then - pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner + pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner dukang-admin-web 2>/dev/null \ + || pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner else pm2 start "$DEPLOY_DIR/ecosystem.config.cjs" fi From c7dc6a227438f9d1282ec8485ffd1b1c2cb2f136 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 13:22:41 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=90=8E=E7=99=BB=E5=BD=95=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=94=99?= =?UTF-8?q?=E8=AF=AFbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/dukang-api/.env.example | 2 +- server/dukang-api/.env.production.example | 4 ++-- server/dukang-api/src/modules/iam/auth.service.ts | 13 ++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 4884396..acbc058 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -28,7 +28,7 @@ WX_MCH_PRIVATE_KEY= WX_API_V3_KEY= # 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空) WX_PLATFORM_CERT= -WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay +WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay # 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL) # RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写 diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index ecba97a..1bfcc93 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -27,14 +27,14 @@ WX_MCH_SERIAL_NO= WX_MCH_PRIVATE_KEY= WX_API_V3_KEY= WX_PLATFORM_CERT= -WX_PAY_NOTIFY_URL=https://api.lingshivip.cn/api/v1/callbacks/wechat/pay +WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay OSS_ENABLED=true OSS_ACCESS_KEY_ID= OSS_ACCESS_KEY_SECRET= OSS_BUCKET=dukang-prod OSS_REGION=oss-cn-beijing -OSS_CDN_BASE=https://lingshivip.cn +OSS_CDN_BASE=https://dukang-prod.oss-cn-beijing.aliyuncs.com OSS_UPLOAD_PREFIX=uploads OSS_UPLOAD_EXPIRE_SECONDS=900 OSS_MAX_UPLOAD_BYTES=10485760 diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index c906dc7..be076eb 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -17,7 +17,6 @@ import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.c import { ISmsProvider } from '../../integrations/sms/sms.interface'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; -import type { Prisma } from '@prisma/client'; import type { User } from '@prisma/client'; @@ -584,12 +583,12 @@ export class AuthService { } } - const primaryUpdate: Prisma.UserUpdateInput = {}; - if (guest.deviceKey && !primary.deviceKey) { - primaryUpdate.deviceKey = guest.deviceKey; - } - if (Object.keys(primaryUpdate).length > 0) { - await tx.user.update({ where: { id: primaryId }, data: primaryUpdate }); + const deviceKeyToTransfer = + guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null; + if (deviceKeyToTransfer) { + // 先清空访客 deviceKey,避免 uk_user_user_device_key 冲突 + await tx.user.update({ where: { id: guestId }, data: { deviceKey: null } }); + await tx.user.update({ where: { id: primaryId }, data: { deviceKey: deviceKeyToTransfer } }); } await tx.user.update({ From f2175b83f770482c1e07f926b80237d1b7526559 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 14:53:21 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E9=97=A8=E5=BA=97=E5=BC=80=E9=80=9A?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/pages/StoreMediaPage.tsx | 4 +- apps/admin-web/src/pages/StoresPage.tsx | 16 +- apps/h5-partner/package.json | 2 + .../src/components/ChinaRegionPicker.tsx | 178 ++++++++++++ .../src/components/OssUploadField.tsx | 136 +++++++++ apps/h5-partner/src/lib/china-region.ts | 59 ++++ apps/h5-partner/src/lib/storeDraft.ts | 56 +++- apps/h5-partner/src/lib/storeStatus.ts | 16 ++ apps/h5-partner/src/lib/upload.ts | 57 ++++ apps/h5-partner/src/lib/weixin.ts | 9 + apps/h5-partner/src/pages/HomePage.tsx | 41 +-- apps/h5-partner/src/pages/StoreCreatePage.tsx | 190 +++++++++---- apps/h5-partner/src/pages/StoreDetailPage.tsx | 163 ++++++++--- apps/h5-partner/src/pages/StoreListPage.tsx | 106 ++++--- apps/h5-partner/src/styles.css | 265 +++++++++++++++++- packages/weixin-sdk/src/chooseImage.ts | 83 ++++++ packages/weixin-sdk/src/index.ts | 5 + packages/weixin-sdk/src/types.ts | 14 + pnpm-lock.yaml | 6 + .../integrations/oss/oss.aliyun.provider.ts | 5 +- .../src/integrations/oss/oss.key.util.ts | 15 +- .../wechat/wechat.api.provider.ts | 2 +- .../src/modules/store/store.controller.ts | 28 ++ .../src/modules/store/store.service.ts | 181 +++++++++++- 24 files changed, 1451 insertions(+), 186 deletions(-) create mode 100644 apps/h5-partner/src/components/ChinaRegionPicker.tsx create mode 100644 apps/h5-partner/src/components/OssUploadField.tsx create mode 100644 apps/h5-partner/src/lib/china-region.ts create mode 100644 apps/h5-partner/src/lib/storeStatus.ts create mode 100644 apps/h5-partner/src/lib/upload.ts create mode 100644 apps/h5-partner/src/lib/weixin.ts create mode 100644 packages/weixin-sdk/src/chooseImage.ts diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx index c293c8b..5549185 100644 --- a/apps/admin-web/src/pages/StoreMediaPage.tsx +++ b/apps/admin-web/src/pages/StoreMediaPage.tsx @@ -106,7 +106,7 @@ export default function StoreMediaPage() { prev.mediaType !== cur.mediaType}> {({ getFieldValue }) => ( - + )} @@ -126,7 +126,7 @@ export default function StoreMediaPage() { prev.mediaType !== cur.mediaType}> {({ getFieldValue }) => ( - + )} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 5800d91..15c71f8 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -249,16 +249,20 @@ export default function StoresPage() { return (
- 门店 + + 门店 + 共 {data?.total ?? 0} 家门店(含合伙人录入) +
{ setFilters(v); setPage(1); }}> - ({ value, label }))} /> +
{ setPage(p); setPageSize(ps); } }} /> @@ -298,7 +302,7 @@ export default function StoresPage() { - + @@ -387,7 +391,7 @@ export default function StoresPage() { preV1 照片上传为选填,可直接下一步(与合伙人端一致)。 - + 环境照片 @@ -395,14 +399,14 @@ export default function StoresPage() {
{fields.map((field, index) => ( - + ))}
)}
- +
diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json index d10412d..ca1ff80 100644 --- a/apps/h5-partner/package.json +++ b/apps/h5-partner/package.json @@ -11,6 +11,8 @@ "dependencies": { "@dukang/shared-ui": "workspace:*", "@dukang/shared-types": "workspace:*", + "@dukang/weixin-sdk": "workspace:*", + "element-china-area-data": "^6.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/apps/h5-partner/src/components/ChinaRegionPicker.tsx b/apps/h5-partner/src/components/ChinaRegionPicker.tsx new file mode 100644 index 0000000..da79c9c --- /dev/null +++ b/apps/h5-partner/src/components/ChinaRegionPicker.tsx @@ -0,0 +1,178 @@ +import { createPortal } from 'react-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { codeToText } from 'element-china-area-data'; +import { CHINA_REGION_OPTIONS, formatRegionLabel, parseRegionCodes } from '../lib/china-region'; + +type RegionNode = { + value: string; + label: string; + children?: RegionNode[]; +}; + +type ChinaRegionPickerProps = { + value?: string[]; + onChange?: (codes: string[]) => void; + disabled?: boolean; +}; + +const STEP_LABELS = ['选择省份', '选择城市', '选择区县'] as const; + +function lockBodyScroll(lock: boolean) { + if (typeof document === 'undefined') return; + const body = document.body; + if (lock) { + const scrollY = window.scrollY; + body.dataset.dukangScrollY = String(scrollY); + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.overflow = 'hidden'; + } else { + const scrollY = Number(body.dataset.dukangScrollY ?? '0'); + body.style.position = ''; + body.style.top = ''; + body.style.left = ''; + body.style.right = ''; + body.style.overflow = ''; + delete body.dataset.dukangScrollY; + window.scrollTo(0, scrollY); + } +} + +export default function ChinaRegionPicker({ value = [], onChange, disabled }: ChinaRegionPickerProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(value); + const [step, setStep] = useState(0); + + const options = CHINA_REGION_OPTIONS as RegionNode[]; + const parsed = parseRegionCodes(value); + const display = parsed ? formatRegionLabel(parsed) : '请选择省 / 市 / 区县'; + + const provinces = options; + const cities = useMemo(() => { + const p = provinces.find((item) => item.value === draft[0]); + return p?.children ?? []; + }, [draft, provinces]); + + const districts = useMemo(() => { + const c = cities.find((item) => item.value === draft[1]); + return c?.children ?? []; + }, [draft, cities]); + + const listItems = step === 0 ? provinces : step === 1 ? cities : districts; + const activeCode = draft[step]; + + useEffect(() => { + if (!open) return; + lockBodyScroll(true); + return () => lockBodyScroll(false); + }, [open]); + + function openPicker() { + if (disabled) return; + const next = value.length === 3 ? [...value] : []; + setDraft(next); + setStep(next.length >= 3 ? 2 : next.length); + setOpen(true); + } + + function closePicker() { + setOpen(false); + } + + function selectItem(code: string) { + const next = [...draft.slice(0, step), code]; + setDraft(next); + if (step < 2) { + setStep(step + 1); + return; + } + onChange?.(next); + closePicker(); + } + + function confirm() { + if (draft.length < 3) return; + onChange?.(draft); + closePicker(); + } + + const modal = open ? ( +
+
e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > +
+ + 选择地区 + +
+ +
+ {STEP_LABELS.map((label, index) => ( + + ))} +
+ + {draft[0] && ( +

+ {codeToText[draft[0]]} + {draft[1] ? ` / ${codeToText[draft[1]]}` : ''} + {draft[2] ? ` / ${codeToText[draft[2]]}` : ''} +

+ )} + +
+ {listItems.map((item) => ( + + ))} +
+
+
+ ) : null; + + return ( + <> + + {typeof document !== 'undefined' ? createPortal(modal, document.body) : null} + + ); +} \ No newline at end of file diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx new file mode 100644 index 0000000..a14e3eb --- /dev/null +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -0,0 +1,136 @@ +import { useId, useRef, useState } from 'react'; +import { uploadFileToOss, type OssMediaType } from '../lib/upload'; +import { isWechatEnv, weixinSdk } from '../lib/weixin'; + +type OssUploadFieldProps = { + value?: string; + onChange?: (url: string) => void; + bizType: string; + mediaType?: OssMediaType; + accept?: string; + wide?: boolean; + compact?: boolean; + label?: string; +}; + +const DEFAULT_MAX_MB = 10; + +export default function OssUploadField({ + value, + onChange, + bizType, + mediaType = 'IMAGE', + accept, + wide, + compact, + label, +}: OssUploadFieldProps) { + const inputId = useId(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + + const resolvedAccept = + accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); + const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO'; + + async function uploadSelectedFile(file: File) { + if (file.size > DEFAULT_MAX_MB * 1024 * 1024) { + setError(`文件不能超过 ${DEFAULT_MAX_MB}MB`); + return; + } + setUploading(true); + setError(''); + try { + const result = await uploadFileToOss(file, { bizType, mediaType }); + onChange?.(result.url); + } catch (e) { + setError(e instanceof Error ? e.message : '上传失败'); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ''; + } + } + + async function pickFile() { + if (uploading) return; + setError(''); + + if (useWechatPicker) { + try { + const files = await weixinSdk.chooseImages({ + count: 1, + sourceType: ['album', 'camera'], + }); + if (files?.[0]) { + await uploadSelectedFile(files[0]); + return; + } + } catch (e) { + const msg = e instanceof Error ? e.message : '无法打开相册'; + if (!/cancel/i.test(msg)) setError(msg); + return; + } + } + + inputRef.current?.click(); + } + + const isImage = mediaType === 'IMAGE' && value; + const isFile = mediaType === 'FILE' && value; + + const triggerProps = { + type: 'button' as const, + disabled: uploading, + onClick: () => void pickFile(), + }; + + return ( +
+ { + const file = e.target.files?.[0]; + if (file) void uploadSelectedFile(file); + }} + /> + {isImage ? ( + + ) : isFile ? ( + + ) : ( + + )} + {error &&

{error}

} +
+ ); +} diff --git a/apps/h5-partner/src/lib/china-region.ts b/apps/h5-partner/src/lib/china-region.ts new file mode 100644 index 0000000..98e2ad3 --- /dev/null +++ b/apps/h5-partner/src/lib/china-region.ts @@ -0,0 +1,59 @@ +import { codeToText, regionData } from 'element-china-area-data'; + +export { regionData as CHINA_REGION_OPTIONS }; + +export type OpenCityRef = { + id: string; + name: string; + code: string; + partnerId?: string | null; +}; +export type ParsedChinaRegion = { + province: string; + city: string; + district: string; + provinceCode: string; + cityCode: string; + districtCode: string; +}; + +export function districtCodeToCityCode(districtCode: string): string { + if (districtCode.length < 6) return districtCode; + return `${districtCode.slice(0, 4)}00`; +} + +export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null { + if (!codes || codes.length < 3) return null; + const [provinceCode, cityCode, districtCode] = codes; + const province = codeToText[provinceCode]; + const city = codeToText[cityCode]; + const district = codeToText[districtCode]; + if (!province || !city || !district) return null; + return { province, city, district, provinceCode, cityCode, districtCode }; +} + +export function formatRegionLabel(region: ParsedChinaRegion): string { + return `${region.province} / ${region.city} / ${region.district}`; +} + +export function matchOpenCityId(cities: OpenCityRef[], districtCode: string): string | undefined { + const cityCode = districtCodeToCityCode(districtCode); + return ( + cities.find((c) => c.code === cityCode)?.id + ?? cities.find((c) => c.code === districtCode)?.id + ?? cities.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id + ); +} + +export function resolveRegionBinding(codes: string[], cities: OpenCityRef[]) { + const region = parseRegionCodes(codes); + if (!region) return null; + const cityId = matchOpenCityId(cities, region.districtCode); + const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined; + return { + region, + cityId, + matchedCity, + cityCode: districtCodeToCityCode(region.districtCode), + }; +} diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index 90bb5ed..f480ce6 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -1,9 +1,18 @@ export type StoreDraftForm = { + regionCodes: string[]; + cityId: string; + province: string; + city: string; + district: string; name: string; phone: string; - district: string; address: string; intro: string; + accountPhone: string; + accountName: string; + coverUrl: string; + envPhotoUrls: string[]; + contractUrl: string; bankAccountName: string; bankAccountNo: string; bankBranch: string; @@ -14,27 +23,55 @@ export type StoreDraft = { form: StoreDraftForm; }; -export const STORE_DRAFT_KEY = 'partner_store_draft_v1'; +export const STORE_DRAFT_KEY = 'partner_store_draft_v2'; export const defaultStoreForm = (): StoreDraftForm => ({ + regionCodes: [], + cityId: '', + province: '', + city: '', + district: '', name: '', phone: '', - district: '金水区', address: '', intro: '', + accountPhone: '', + accountName: '', + coverUrl: '', + envPhotoUrls: ['', '', ''], + contractUrl: '', bankAccountName: '', bankAccountNo: '', bankBranch: '', }); +function normalizeStringArray(raw: unknown, length: number): string[] { + if (!Array.isArray(raw)) return Array.from({ length }, () => ''); + const items = raw.map((item) => String(item ?? '')); + while (items.length < length) items.push(''); + return items.slice(0, length); +} + function normalizeForm(raw: Record): StoreDraftForm { const base = defaultStoreForm(); + const regionCodes = Array.isArray(raw.regionCodes) + ? raw.regionCodes.map((code) => String(code)) + : base.regionCodes; return { + regionCodes, + cityId: String(raw.cityId ?? base.cityId), + province: String(raw.province ?? base.province), + city: String(raw.city ?? base.city), + district: String(raw.district ?? base.district), name: String(raw.name ?? base.name), phone: String(raw.phone ?? base.phone), - district: String(raw.district ?? base.district), address: String(raw.address ?? base.address), intro: String(raw.intro ?? base.intro), + accountPhone: String(raw.accountPhone ?? base.accountPhone), + accountName: String(raw.accountName ?? base.accountName), + coverUrl: String(raw.coverUrl ?? base.coverUrl), + envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), + contractUrl: String(raw.contractUrl ?? base.contractUrl), bankAccountName: String(raw.bankAccountName ?? base.bankAccountName), bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo), bankBranch: String(raw.bankBranch ?? base.bankBranch), @@ -69,11 +106,14 @@ export function clearStoreDraft() { const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; -export function validateStoreStep1(form: StoreDraftForm): string | null { +export function validateStoreStep1( + form: Pick, +): string | null { + if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; + if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划'; if (!form.name.trim()) return '请填写门店名称'; if (!form.phone.trim()) return '请填写联系电话'; if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; - if (!form.district.trim()) return '请填写区县'; if (!form.address.trim()) return '请填写详细地址'; if (form.intro.trim()) { const len = form.intro.trim().length; @@ -82,7 +122,9 @@ export function validateStoreStep1(form: StoreDraftForm): string | null { return null; } -export function validateStoreStep3(form: StoreDraftForm): string | null { +export function validateStoreStep3( + form: Pick, +): string | null { if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountNo.trim()) return '请填写银行卡号'; if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; diff --git a/apps/h5-partner/src/lib/storeStatus.ts b/apps/h5-partner/src/lib/storeStatus.ts new file mode 100644 index 0000000..80d8780 --- /dev/null +++ b/apps/h5-partner/src/lib/storeStatus.ts @@ -0,0 +1,16 @@ +export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED'; + +export function storeStatusLabel(status: string): string { + const s = String(status).toUpperCase(); + if (s === 'OPEN') return '营业中'; + if (s === 'PAUSED') return '暂时闭店'; + if (s === 'CLOSED') return '关闭'; + return status; +} + +export function storeStatusPillClass(status: string): string { + const s = String(status).toUpperCase(); + if (s === 'OPEN') return 'partner-status-pill--open'; + if (s === 'PAUSED') return 'partner-status-pill--paused'; + return 'partner-status-pill--closed'; +} diff --git a/apps/h5-partner/src/lib/upload.ts b/apps/h5-partner/src/lib/upload.ts new file mode 100644 index 0000000..9ff01c6 --- /dev/null +++ b/apps/h5-partner/src/lib/upload.ts @@ -0,0 +1,57 @@ +import { apiBase, request } from './api'; + +export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; + +export type UploadFileResult = { + url: string; + ossKey: string; + bucket: string; + mock: boolean; +}; + +/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ +export async function uploadFileToOss( + file: File, + options: { bizType: string; mediaType?: OssMediaType }, +): Promise { + const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); + const formData = new FormData(); + formData.append('file', file); + formData.append('bizType', options.bizType); + formData.append('mediaType', mediaType); + + const token = localStorage.getItem('accessToken'); + const headers: Record = { 'X-Client-App': 'PARTNER_H5' }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${apiBase}/common/resources/upload`, { + method: 'POST', + headers, + body: formData, + }); + const json = await res.json(); + if (json.code === 401) { + localStorage.removeItem('accessToken'); + throw new Error('未登录'); + } + if (json.code !== 0) throw new Error(json.message || '上传失败'); + + const data = json.data as UploadFileResult; + return { + url: data.url, + ossKey: data.ossKey, + bucket: data.bucket, + mock: data.mock, + }; +} + +export type OpenCityOption = { + id: string; + name: string; + code: string; + province: string; +}; + +export async function fetchPartnerCities(): Promise { + return request('PARTNER_H5', '/partner/stores/cities'); +} diff --git a/apps/h5-partner/src/lib/weixin.ts b/apps/h5-partner/src/lib/weixin.ts new file mode 100644 index 0000000..4af1596 --- /dev/null +++ b/apps/h5-partner/src/lib/weixin.ts @@ -0,0 +1,9 @@ +import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk'; + +export const weixinSdk = createWeixinSdk({ + apiBase: '/api/v1', + clientApp: 'PARTNER_H5', + getAccessToken: () => localStorage.getItem('accessToken'), +}); + +export { isWechatEnv }; diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index 00ea2ed..9dbc058 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -1,27 +1,13 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; import { LEADERBOARD_MOCK } from '../lib/leaderboard'; +import { storeStatusLabel } from '../lib/storeStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -function auditStatus(store: Record) { - const audits = store.audits as Array<{ status?: string }> | undefined; - return String(audits?.[0]?.status ?? '').toUpperCase(); -} - -function storeStatusLabel(store: Record) { - const audit = auditStatus(store); - if (audit === 'PENDING') return '待审核'; - const s = String(store.status).toUpperCase(); - if (s === 'OPEN') return '营业中'; - if (s === 'PAUSED') return '暂时闭店'; - if (s === 'CLOSED') return '已关闭'; - return s; -} - export default function HomePage() { const navigate = useNavigate(); const [dash, setDash] = useState | null>(null); @@ -37,10 +23,6 @@ export default function HomePage() { const orderCount = Number(dash?.orderCount || 0); const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const abnormalStores = Math.max(0, storeCount - activeStores); - const pendingStores = useMemo( - () => stores.filter((s) => auditStatus(s) === 'PENDING'), - [stores], - ); const revenue = orderCount * 128.45; const profit = revenue * 0.25; const pendingShip = Math.ceil(orderCount * 0.04); @@ -173,25 +155,8 @@ export default function HomePage() { {monthNew}
-
-

待审核门店

-

{pendingStores.length}

-
- {pendingStores.length > 0 && ( -
-

待审核门店名称

-
    - {pendingStores.map((s) => ( -
  • - {String(s.name || '未命名门店')} -
  • - ))} -
-
- )} - {displayStores.length > 0 && (
  • @@ -201,7 +166,7 @@ export default function HomePage() { {displayStores.slice(0, 5).map((s) => (
  • {String(s.name || '未命名门店')} - {storeStatusLabel(s)} + {storeStatusLabel(String(s.status))}
  • ))}
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 9aeb050..5803c16 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -1,7 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; +import ChinaRegionPicker from '../components/ChinaRegionPicker'; +import OssUploadField from '../components/OssUploadField'; import { request } from '../lib/api'; +import { resolveRegionBinding } from '../lib/china-region'; +import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; import { clearStoreDraft, defaultStoreForm, @@ -20,6 +24,9 @@ export default function StoreCreatePage() { const saved = loadStoreDraft(); const [form, setForm] = useState(saved?.form ?? defaultStoreForm()); const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [cities, setCities] = useState([]); + const [citiesError, setCitiesError] = useState(''); const stepFromUrl = Number(params.get('step') || 0); const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1); @@ -34,11 +41,45 @@ export default function StoreCreatePage() { saveStoreDraft({ step, form }); }, [step, form]); + useEffect(() => { + void fetchPartnerCities() + .then((items) => { + setCities(items); + if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置'); + }) + .catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败')); + }, []); + + const regionBindingHint = useMemo(() => { + if (!form.regionCodes.length) return null; + const binding = resolveRegionBinding(form.regionCodes, cities); + if (!binding) return null; + if (binding.cityId && binding.matchedCity) { + return `已匹配开城城市:${binding.matchedCity.name}`; + } + return '所选地区暂未开城,请联系总部配置对应区划'; + }, [form.regionCodes, cities]); + function patchForm(patch: Partial) { setForm((prev) => ({ ...prev, ...patch })); setError(''); } + function bindRegionSelection(codes: string[]) { + const binding = resolveRegionBinding(codes, cities); + if (!binding) { + patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' }); + return; + } + patchForm({ + regionCodes: codes, + cityId: binding.cityId ?? '', + province: binding.region.province, + city: binding.region.city, + district: binding.region.district, + }); + } + function goStep(n: number) { setParams({ step: String(n) }); setError(''); @@ -58,21 +99,38 @@ export default function StoreCreatePage() { const step1Msg = validateStoreStep1(form); if (step1Msg) { setError(step1Msg); return; } - await request('PARTNER_H5', '/partner/stores', { - method: 'POST', - body: JSON.stringify({ - name: form.name.trim(), - phone: form.phone.trim(), - district: form.district.trim(), - address: form.address.trim(), - intro: form.intro.trim() || undefined, - bankAccountName: form.bankAccountName.trim(), - bankAccountNo: form.bankAccountNo.replace(/\s/g, ''), - bankBranch: form.bankBranch.trim(), - }), - }); - clearStoreDraft(); - navigate('/stores'); + setSubmitting(true); + setError(''); + try { + const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean); + const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { + method: 'POST', + body: JSON.stringify({ + cityId: form.cityId, + province: form.province, + city: form.city, + name: form.name.trim(), + phone: form.phone.trim(), + district: form.district.trim(), + address: form.address.trim(), + intro: form.intro.trim() || undefined, + coverUrl: form.coverUrl.trim() || undefined, + envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, + contractUrl: form.contractUrl.trim() || undefined, + bankAccountName: form.bankAccountName.trim(), + bankAccountNo: form.bankAccountNo.replace(/\s/g, ''), + bankBranch: form.bankBranch.trim(), + accountPhone: form.accountPhone.trim() || undefined, + accountName: form.accountName.trim() || undefined, + }), + }); + clearStoreDraft(); + navigate(`/stores/${result.store.id}`); + } catch (e) { + setError(e instanceof Error ? e.message : '提交失败'); + } finally { + setSubmitting(false); + } } const progress = step === 1 ? 0 : step === 2 ? 50 : 100; @@ -102,8 +160,8 @@ export default function StoreCreatePage() { - {error && ( -

{error}

+ {(error || citiesError) && ( +

{error || citiesError}

)} {step === 1 && ( @@ -113,6 +171,15 @@ export default function StoreCreatePage() {

门店基本信息

+
+ + + {regionBindingHint && ( +

+ {regionBindingHint} +

+ )} +
@@ -127,13 +194,6 @@ export default function StoreCreatePage() { patchForm({ phone: e.target.value })} />
-
- -
- location_on - patchForm({ district: e.target.value })} /> -
-