feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道): - 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、 phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行, 北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。 - wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID), 并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上 playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。 - 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。 - test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。 部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败): - 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连), 不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布, 不再因为缺插件整条流水线失败。 注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见 命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
This commit is contained in:
+11
-6
@@ -122,11 +122,16 @@ AGGREGATE_OAUTH_ENDPOINT=
|
|||||||
WECHAT_MINI_APPID=
|
WECHAT_MINI_APPID=
|
||||||
WECHAT_MINI_SECRET=
|
WECHAT_MINI_SECRET=
|
||||||
|
|
||||||
# 微信「订阅消息」模板(可选,但**想让通知发到微信里就必须配**)
|
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
|
||||||
# 公众平台「功能 → 订阅消息 → 选用模板」拿到模板 ID 后,按下面的 JSON 填:
|
# 内置模板(见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES):
|
||||||
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle","time2":"time","thing3":"message"}}}
|
# SONG_SELECTED「歌曲排期」—— 管理员把歌排进排期/发布排期时发
|
||||||
|
# SONG_REJECTED「歌曲驳回」—— 投稿被驳回时发
|
||||||
|
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的字段名):
|
||||||
|
# {"SONG_SELECTED":{"id":"模板ID","fields":{"thing2":"songTitle","time1":"playTime"}}}
|
||||||
# 键是通知类型(SONG_SELECTED / SONG_PLAYED / SONG_REJECTED / SYSTEM_NOTICE),
|
# 键是通知类型(SONG_SELECTED / SONG_PLAYED / SONG_REJECTED / SYSTEM_NOTICE),
|
||||||
# fields 的键是模板里的字段名(thing1/time2/... 选用模板时能看到),值是变量名
|
# fields 的键是模板里的字段名(thing2/date4/time1/character_string3/phrase5,选用模板时能看到),
|
||||||
# (内置变量:songTitle / message / time)。不配就等于关闭微信推送,其它渠道照常。
|
# 值是变量名(内置变量:songTitle / message / time / playDate / playTime / sequence / status)。
|
||||||
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;且只有绑定了微信的账号才有 openid。
|
# 同键只给 id 不给 fields 时,会沿用内置的字段映射(不会退化成 thing1/time2/thing3)。
|
||||||
|
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
|
||||||
|
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度。
|
||||||
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
|
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
|
||||||
|
|||||||
+172
-23
@@ -519,7 +519,63 @@ jobs:
|
|||||||
echo "环境准备完成"
|
echo "环境准备完成"
|
||||||
cat /etc/os-release 2>/dev/null | head -4 || true
|
cat /etc/os-release 2>/dev/null | head -4 || true
|
||||||
docker --version
|
docker --version
|
||||||
docker buildx version || echo "buildx 未安装"
|
docker buildx version 2>&1 | head -1 || echo "buildx 子命令不可用"
|
||||||
|
|
||||||
|
- name: 确保 Docker Buildx 插件可用(缺失则装官方插件,否则回退 docker build)
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# 症状:`docker buildx create --name xxx` 报
|
||||||
|
# unknown flag: --name / See 'docker --help'
|
||||||
|
# 打出的是 docker 的总帮助 → 说明 `buildx` 子命令没落到真插件上,docker CLI 把
|
||||||
|
# `--name` 当自己的顶层 flag 解析了。这里先探一次并按需装官方插件,不能等 build-image
|
||||||
|
# 作业才炸(各 job 是独立容器,装了也得在该 job 里再装一次)。
|
||||||
|
ensure_buildx() {
|
||||||
|
# 不用 `docker buildx version` 当唯一依据:这种“子命令没落到真插件上”的坏状态里
|
||||||
|
# 它仍可能返回 0。优先相信插件文件在位,其次才信命令。
|
||||||
|
if [ -x "${HOME}/.docker/cli-plugins/docker-buildx" ] \
|
||||||
|
|| [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] \
|
||||||
|
|| { docker buildx version >/dev/null 2>&1 && docker buildx create --help 2>/dev/null | grep -q -- '--name'; }; then
|
||||||
|
echo "[buildx] 插件可用:$(docker buildx version 2>&1 | head -1)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "[buildx] 未发现可用插件,从官方 release 安装"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "${ARCH}" in
|
||||||
|
x86_64|amd64) BUILDX_ARCH=amd64 ;;
|
||||||
|
aarch64|arm64) BUILDX_ARCH=arm64 ;;
|
||||||
|
*) echo "[buildx] 不支持的架构 ${ARCH},跳过安装"; return 1 ;;
|
||||||
|
esac
|
||||||
|
BUILDX_VER="v0.19.3"
|
||||||
|
BUILDX_BIN="buildx-${BUILDX_VER}.linux-${BUILDX_ARCH}"
|
||||||
|
BUILDX_DIR="${HOME}/.docker/cli-plugins"
|
||||||
|
mkdir -p "${BUILDX_DIR}"
|
||||||
|
if curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
|
||||||
|
"${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}" \
|
||||||
|
|| curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
|
||||||
|
"https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}"; then
|
||||||
|
chmod +x "${BUILDX_DIR}/docker-buildx"
|
||||||
|
echo "[buildx] 已安装到 ${BUILDX_DIR}/docker-buildx"
|
||||||
|
else
|
||||||
|
rm -f "${BUILDX_DIR}/docker-buildx"
|
||||||
|
echo "[buildx] 下载失败(GitHub 与 ${GH_PROXY_PREFIX} 都不通)"
|
||||||
|
fi
|
||||||
|
if ! docker buildx version >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
|
||||||
|
echo "[buildx] 当前 docker 不认 ~/.docker/cli-plugins,改写系统插件目录"
|
||||||
|
sudo mkdir -p /usr/local/lib/docker/cli-plugins
|
||||||
|
sudo cp -f "${BUILDX_DIR}/docker-buildx" /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
|
||||||
|
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
[ -x "${BUILDX_DIR}/docker-buildx" ] || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ensure_buildx && docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
|
||||||
|
echo "[buildx] 自检通过(create 支持 --name)"
|
||||||
|
echo "BUILDX_INSTALLED=yes" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "[buildx] 自检未通过,镜像构建作业将自动回退 docker build(不阻塞部署)"
|
||||||
|
echo "BUILDX_INSTALLED=no" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
run: |
|
run: |
|
||||||
@@ -669,7 +725,61 @@ jobs:
|
|||||||
BRANCH="${GITHUB_REF_NAME:-main}"
|
BRANCH="${GITHUB_REF_NAME:-main}"
|
||||||
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||||||
|
|
||||||
- name: 初始化 Docker Buildx builder (为本作业重新创建)
|
- name: 确保 Docker Buildx 插件可用(本作业容器内独立安装)
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# 每个 job 跑在独立容器里,meta-warmup 装的插件这里看不到,必须自己再确保一次。
|
||||||
|
# 症状回顾:`docker buildx create --name xxx` 报 unknown flag: --name(打出的是 docker
|
||||||
|
# 总帮助)→ buildx 子命令没落到真插件上。装完仍不可用则不报错,交给后面的 docker build 兜底。
|
||||||
|
# 判定同 meta-warmup:优先看插件文件是否在位,不把 `docker buildx version` 当唯一依据。
|
||||||
|
if [ -x "${HOME}/.docker/cli-plugins/docker-buildx" ] \
|
||||||
|
|| [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ] \
|
||||||
|
|| { docker buildx version >/dev/null 2>&1 && docker buildx create --help 2>/dev/null | grep -q -- '--name'; }; then
|
||||||
|
echo "[buildx] 插件已可用:$(docker buildx version 2>&1 | head -1)"
|
||||||
|
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "[buildx] 未发现可用插件,从官方 release 安装"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "${ARCH}" in
|
||||||
|
x86_64|amd64) BUILDX_ARCH=amd64 ;;
|
||||||
|
aarch64|arm64) BUILDX_ARCH=arm64 ;;
|
||||||
|
*) echo "[buildx] 不支持的架构 ${ARCH}"; echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"; exit 0 ;;
|
||||||
|
esac
|
||||||
|
BUILDX_VER="v0.19.3"
|
||||||
|
BUILDX_BIN="buildx-${BUILDX_VER}.linux-${BUILDX_ARCH}"
|
||||||
|
BUILDX_DIR="${HOME}/.docker/cli-plugins"
|
||||||
|
mkdir -p "${BUILDX_DIR}"
|
||||||
|
if curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
|
||||||
|
"${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}" \
|
||||||
|
|| curl -fsSL --retry 3 --connect-timeout 20 -o "${BUILDX_DIR}/docker-buildx" \
|
||||||
|
"https://github.com/docker/buildx/releases/download/${BUILDX_VER}/${BUILDX_BIN}"; then
|
||||||
|
chmod +x "${BUILDX_DIR}/docker-buildx"
|
||||||
|
echo "[buildx] 已安装到 ${BUILDX_DIR}/docker-buildx"
|
||||||
|
else
|
||||||
|
rm -f "${BUILDX_DIR}/docker-buildx"
|
||||||
|
echo "[buildx] 下载失败(GitHub 与 ${GH_PROXY_PREFIX} 都不通)"
|
||||||
|
fi
|
||||||
|
if ! docker buildx version >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo mkdir -p /usr/local/lib/docker/cli-plugins
|
||||||
|
sudo cp -f "${BUILDX_DIR}/docker-buildx" /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
|
||||||
|
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -x "${BUILDX_DIR}/docker-buildx" ] || [ -x /usr/local/lib/docker/cli-plugins/docker-buildx ]; then
|
||||||
|
if docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
|
||||||
|
echo "[buildx] 自检通过"
|
||||||
|
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "[buildx] 插件在位但 create 仍不认识 --name,改用 docker build 兜底"
|
||||||
|
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[buildx] 安装未成功,改用 docker build 兜底"
|
||||||
|
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 初始化 Docker Buildx builder (为本作业重新创建,不可用则回退 docker build)
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
||||||
@@ -708,17 +818,35 @@ jobs:
|
|||||||
done
|
done
|
||||||
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像"; exit 1; }
|
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像"; exit 1; }
|
||||||
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" -u "${REGISTRY_USER}" --password-stdin
|
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" -u "${REGISTRY_USER}" --password-stdin
|
||||||
|
|
||||||
|
# buildx 插件不可用时(`docker buildx create --name` 报 unknown flag: --name,
|
||||||
|
# 说明子命令没落到真插件上),不再硬失败,转用普通 docker build 继续部署。
|
||||||
|
if [ "${BUILDX_USABLE:-no}" != "yes" ] \
|
||||||
|
|| ! docker buildx version >/dev/null 2>&1 \
|
||||||
|
|| ! docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
|
||||||
|
echo "[buildx] 本作业不可用,跳过 builder 初始化,将由 docker build 回退构建"
|
||||||
|
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||||||
|
rm -f "${BKK_CFG}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
|
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
|
||||||
docker buildx create \
|
if docker buildx create \
|
||||||
--name "${BUILDER_NAME}" \
|
--name "${BUILDER_NAME}" \
|
||||||
--driver docker-container \
|
--driver docker-container \
|
||||||
--use \
|
--use \
|
||||||
--config "${BKK_CFG}" \
|
--config "${BKK_CFG}" \
|
||||||
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
|
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
|
||||||
--driver-opt network=host \
|
--driver-opt network=host \
|
||||||
--driver-opt "image=${BUILDKIT_IMAGE}"
|
--driver-opt "image=${BUILDKIT_IMAGE}" \
|
||||||
|
&& docker buildx inspect --bootstrap; then
|
||||||
|
echo "[buildx] builder ${BUILDER_NAME} 就绪,使用 buildx 构建(含分层缓存)"
|
||||||
|
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "[buildx] builder 创建或启动失败,回退 docker build(不使用分层缓存)"
|
||||||
|
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
rm -f "${BKK_CFG}"
|
rm -f "${BKK_CFG}"
|
||||||
docker buildx inspect --bootstrap
|
|
||||||
|
|
||||||
- name: 复用 meta-warmup 结果(与 Preflight 并发省 ~5min)
|
- name: 复用 meta-warmup 结果(与 Preflight 并发省 ~5min)
|
||||||
run: |
|
run: |
|
||||||
@@ -777,18 +905,39 @@ jobs:
|
|||||||
echo "使用 BuildKit 分层缓存加速(默认不写 --no-cache)"
|
echo "使用 BuildKit 分层缓存加速(默认不写 --no-cache)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker buildx build \
|
# BUILDX_USABLE 由「初始化 Docker Buildx builder」步骤写入 GITHUB_ENV:
|
||||||
--file ./Dockerfile \
|
# yes → buildx(分层缓存 + --load 打进本地 daemon)
|
||||||
--platform linux/amd64 \
|
# no → 普通 docker build 兜底(无分层缓存;依赖 daemon.json 的 registry-mirrors
|
||||||
--load \
|
# 或 Dockerfile 里的 DOCKER_MIRROR build-arg 拉基础镜像)
|
||||||
--provenance=false \
|
# 注意:--provenance 只有 buildx 认,回退时必须去掉。
|
||||||
"${BUILD_ARGS[@]}" \
|
if [ "${BUILDX_USABLE:-yes}" = "yes" ]; then
|
||||||
--tag "${IMG_FULL}" \
|
echo "[build] 使用 docker buildx build"
|
||||||
--tag "${IMG_LATEST}" \
|
docker buildx build \
|
||||||
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
|
--file ./Dockerfile \
|
||||||
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
|
--platform linux/amd64 \
|
||||||
--label "org.opencontainers.image.created=${OCS_CREATED}" \
|
--load \
|
||||||
.
|
--provenance=false \
|
||||||
|
"${BUILD_ARGS[@]}" \
|
||||||
|
--tag "${IMG_FULL}" \
|
||||||
|
--tag "${IMG_LATEST}" \
|
||||||
|
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
|
||||||
|
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
|
||||||
|
--label "org.opencontainers.image.created=${OCS_CREATED}" \
|
||||||
|
.
|
||||||
|
else
|
||||||
|
echo "[build] buildx 不可用,回退 docker build(生效的 mirror 由 daemon.json 决定)"
|
||||||
|
docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true
|
||||||
|
docker build \
|
||||||
|
--file ./Dockerfile \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
"${BUILD_ARGS[@]}" \
|
||||||
|
--tag "${IMG_FULL}" \
|
||||||
|
--tag "${IMG_LATEST}" \
|
||||||
|
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
|
||||||
|
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
|
||||||
|
--label "org.opencontainers.image.created=${OCS_CREATED}" \
|
||||||
|
.
|
||||||
|
fi
|
||||||
|
|
||||||
docker image inspect "${IMG_FULL}" >/dev/null
|
docker image inspect "${IMG_FULL}" >/dev/null
|
||||||
docker image inspect "${IMG_LATEST}" >/dev/null
|
docker image inspect "${IMG_LATEST}" >/dev/null
|
||||||
|
|||||||
@@ -21,15 +21,6 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 前台页面强制浅色 color-scheme(防止系统深色干扰)===== */
|
|
||||||
body[data-frontend="true"]:not(.admin-body),
|
|
||||||
body[data-frontend="true"]:not(.admin-body) input,
|
|
||||||
body[data-frontend="true"]:not(.admin-body) textarea,
|
|
||||||
body[data-frontend="true"]:not(.admin-body) select,
|
|
||||||
body[data-frontend="true"]:not(.admin-body) button {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 保护表单元素外观,但不强锁 color-scheme = none(none 会导致控件 fallback 到浅色) */
|
/* 保护表单元素外观,但不强锁 color-scheme = none(none 会导致控件 fallback 到浅色) */
|
||||||
input[type='text'],
|
input[type='text'],
|
||||||
input[type='email'],
|
input[type='email'],
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-bold text-zinc-700">暂无API密钥</h3>
|
<h3 class="text-lg font-bold text-zinc-700">暂无API密钥</h3>
|
||||||
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
|
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
|
||||||
您还没有创建任何访问密钥。创建密钥后,您可以安全地将 VoiceHub 集成到第三方应用程序中。
|
您还没有创建任何访问密钥。创建密钥后,您可以安全地将宝一外校园点歌系统集成到第三方应用程序中。
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
class="mt-8 flex items-center gap-2 px-6 py-3 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 text-xs font-bold rounded-2xl transition-all"
|
class="mt-8 flex items-center gap-2 px-6 py-3 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 text-xs font-bold rounded-2xl transition-all"
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
||||||
仅支持 VoiceHub 导出的 .json 格式
|
仅支持宝一外校园点歌系统导出的 .json 格式
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
|
|||||||
@@ -208,7 +208,7 @@
|
|||||||
<h4 class="text-zinc-900 font-bold flex items-center gap-1.5 text-sm">
|
<h4 class="text-zinc-900 font-bold flex items-center gap-1.5 text-sm">
|
||||||
<Icon name="info" :size="14" /> 如何启动酷狗 sidecar?
|
<Icon name="info" :size="14" /> 如何启动酷狗 sidecar?
|
||||||
</h4>
|
</h4>
|
||||||
<p>推荐方式 B:部署开源 KuGouMusicApi 项目到本机 3000 端口(与 VoiceHub 同一台机器即可)。</p>
|
<p>推荐方式 B:部署开源 KuGouMusicApi 项目到本机 3000 端口(与宝一外校园点歌系统同一台机器即可)。</p>
|
||||||
<pre class="bg-zinc-900 text-zinc-100 text-[11px] rounded-xl p-3 overflow-x-auto font-mono"><code>git clone https://github.com/MakcRe/KuGouMusicApi
|
<pre class="bg-zinc-900 text-zinc-100 text-[11px] rounded-xl p-3 overflow-x-auto font-mono"><code>git clone https://github.com/MakcRe/KuGouMusicApi
|
||||||
cd KuGouMusicApi
|
cd KuGouMusicApi
|
||||||
npm install
|
npm install
|
||||||
|
|||||||
@@ -344,7 +344,7 @@
|
|||||||
<Bell :size="14" />
|
<Bell :size="14" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>VoiceHub 系统</span
|
>宝一外校园点歌系统</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
|
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ const drawCanvas = () => {
|
|||||||
}
|
}
|
||||||
ctx.font = '12px "Inter", "system-ui", sans-serif'
|
ctx.font = '12px "Inter", "system-ui", sans-serif'
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.fillText('Generated by VoiceHub', 200, height - 20)
|
ctx.fillText('Generated by 宝一外校园点歌系统', 200, height - 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadCard = () => {
|
const downloadCard = () => {
|
||||||
|
|||||||
Binary file not shown.
@@ -260,7 +260,7 @@
|
|||||||
<!-- 页面头部 -->
|
<!-- 页面头部 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<img :src="logoUrl" alt="VoiceHub Logo" class="logo" >
|
<img :src="logoUrl" alt="宝一外校园点歌系统 Logo" class="logo" >
|
||||||
<!-- 竖线分割 -->
|
<!-- 竖线分割 -->
|
||||||
<div class="logo-divider" />
|
<div class="logo-divider" />
|
||||||
<!-- 学校logo -->
|
<!-- 学校logo -->
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
>备注:{{ settings.remark }}</span
|
>备注:{{ settings.remark }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="footer-right">Generated by VoiceHub | 广播管理系统</span>
|
<span class="footer-right">Generated by 宝一外校园点歌系统 | 广播管理系统</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
|
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
|
||||||
<img
|
<img
|
||||||
:src="logo"
|
:src="logo"
|
||||||
alt="VoiceHub Logo"
|
alt="宝一外校园点歌系统 Logo"
|
||||||
class="w-8 h-8 object-contain"
|
class="w-8 h-8 object-contain"
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 品牌文字 -->
|
<!-- 品牌文字 -->
|
||||||
<div class="flex flex-col justify-center">
|
<div class="flex flex-col justify-center">
|
||||||
<h1 class="font-bold text-lg text-zinc-900 leading-none tracking-tight">VoiceHub</h1>
|
<h1 class="font-bold text-lg text-zinc-900 leading-none tracking-tight">宝一外校园点歌系统</h1>
|
||||||
<p
|
<p
|
||||||
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ const saveConfig = async () => {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
const configToSave = {
|
const configToSave = {
|
||||||
...formData.value,
|
...formData.value,
|
||||||
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
|
siteTitle: (formData.value.siteTitle || '').trim() || '宝一外校园点歌系统',
|
||||||
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
|
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
|
||||||
siteVersion: (formData.value.siteVersion || '').trim() || null,
|
siteVersion: (formData.value.siteVersion || '').trim() || null,
|
||||||
submissionGuidelines:
|
submissionGuidelines:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
|
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
|
||||||
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
|
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
|
||||||
<p v-else>登录您的VoiceHub账户</p>
|
<p v-else>登录宝一外校园点歌系统账户</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
|
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<AuthOAuthButtons v-if="!isBindMode" :always-show="true" />
|
<AuthOAuthButtons v-if="!isBindMode" :always-show="true" />
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p class="help-text">不同VoiceHub平台的账号不互通</p>
|
<p class="help-text">不同宝一外校园点歌系统平台的账号不互通</p>
|
||||||
<div style="margin-top: 16px;">
|
<div style="margin-top: 16px;">
|
||||||
<NuxtLink to="/register" class="register-link-btn">
|
<NuxtLink to="/register" class="register-link-btn">
|
||||||
还没有账号?去注册
|
还没有账号?去注册
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="login-form">
|
<div class="login-form">
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h2>注册账号</h2>
|
<h2>注册账号</h2>
|
||||||
<p>创建您的VoiceHub账户</p>
|
<p>创建您的宝一外校园点歌系统账户</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
|
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
|
||||||
|
|||||||
@@ -96,9 +96,8 @@ onMounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
border-top: 1px solid #E5E7EB;
|
border-top: 1px solid var(--border-primary);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-info {
|
.footer-info {
|
||||||
@@ -110,7 +109,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-item {
|
.footer-item {
|
||||||
color: #374151;
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -118,7 +117,7 @@ onMounted(() => {
|
|||||||
.footer-item:not(:last-child)::after {
|
.footer-item:not(:last-child)::after {
|
||||||
content: ' | ';
|
content: ' | ';
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
color: #6B7280;
|
color: var(--text-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-item a {
|
.footer-item a {
|
||||||
@@ -128,7 +127,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-item a:hover {
|
.footer-item a:hover {
|
||||||
color: #7C3AED;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icp-link,
|
.icp-link,
|
||||||
@@ -140,7 +139,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.icp-link:hover,
|
.icp-link:hover,
|
||||||
.voicehub-link:hover {
|
.voicehub-link:hover {
|
||||||
color: #7C3AED;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.netlify-badge {
|
.netlify-badge {
|
||||||
@@ -151,7 +150,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.netlify-badge img {
|
.netlify-badge img {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
opacity: 0.9;
|
opacity: 0.8;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg" style="display:block; width:100%; height:auto; max-width:100%; max-width:320px; margin:0 auto;">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="petalGrad" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#A855F7"/>
|
|
||||||
<stop offset="50%" stop-color="#EC4899"/>
|
|
||||||
<stop offset="100%" stop-color="#3B82F6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="micGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1f2937"/>
|
|
||||||
<stop offset="100%" stop-color="#111827"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(0 200 190)"/>
|
|
||||||
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(60 200 190)"/>
|
|
||||||
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(120 200 190)"/>
|
|
||||||
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(180 200 190)"/>
|
|
||||||
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(240 200 190)"/>
|
|
||||||
<ellipse cx="200" cy="80" rx="50" ry="110" fill="url(#petalGrad)" opacity="0.92" transform="rotate(300 200 190)"/>
|
|
||||||
|
|
||||||
<circle cx="310" cy="190" r="4" fill="url(#petalGrad)"/>
|
|
||||||
<circle cx="255" cy="285.26" r="4" fill="url(#petalGrad)"/>
|
|
||||||
<circle cx="145" cy="285.26" r="4" fill="url(#petalGrad)"/>
|
|
||||||
<circle cx="90" cy="190" r="4" fill="url(#petalGrad)"/>
|
|
||||||
<circle cx="145" cy="94.74" r="4" fill="url(#petalGrad)"/>
|
|
||||||
<circle cx="255" cy="94.74" r="4" fill="url(#petalGrad)"/>
|
|
||||||
|
|
||||||
<circle cx="200" cy="200" r="170" stroke="url(#petalGrad)" stroke-width="1" stroke-dasharray="2 6" fill="none"/>
|
|
||||||
<circle cx="200" cy="200" r="185" stroke="url(#petalGrad)" stroke-width="1" stroke-dasharray="2 6" fill="none"/>
|
|
||||||
<circle cx="200" cy="200" r="200" stroke="url(#petalGrad)" stroke-width="1" stroke-dasharray="2 6" fill="none"/>
|
|
||||||
|
|
||||||
<circle cx="200" cy="190" r="46" fill="#ffffff" stroke="url(#petalGrad)" stroke-width="3"/>
|
|
||||||
<circle cx="200" cy="190" r="28" fill="url(#micGrad)"/>
|
|
||||||
<rect x="195" y="216" width="10" height="80" rx="5" fill="#1f2937"/>
|
|
||||||
<rect x="170" y="288" width="60" height="10" rx="5" fill="#111827"/>
|
|
||||||
<line x1="150" y1="304" x2="250" y2="304" stroke="#D1D5DB" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<template>
|
|
||||||
<nav
|
|
||||||
class="fixed bottom-0 left-0 right-0 z-40 lg:hidden bg-white border-t border-gray-200 px-4 py-2 pb-[max(8px,env(safe-area-inset-bottom))] backdrop-blur-sm shadow-[0_-2px_16px_rgba(0,0,0,0.04)]"
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex flex-col items-center justify-center gap-1 py-1 text-gray-500"
|
|
||||||
@click="$emit('navigate', 'request-section')"
|
|
||||||
>
|
|
||||||
<Music :size="20" />
|
|
||||||
<span class="text-[11px] font-semibold">点歌</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex flex-col items-center justify-center gap-1 py-1 text-gray-500"
|
|
||||||
@click="$emit('navigate', 'schedule-section')"
|
|
||||||
>
|
|
||||||
<CalendarDays :size="20" />
|
|
||||||
<span class="text-[11px] font-semibold">排期</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex flex-col items-center justify-center gap-1 py-1 text-gray-500"
|
|
||||||
@click="$emit('navigate', 'account')"
|
|
||||||
>
|
|
||||||
<User :size="20" />
|
|
||||||
<span class="text-[11px] font-semibold">我的</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Music, CalendarDays, User } from '@lucide/vue'
|
|
||||||
|
|
||||||
defineEmits(['navigate'])
|
|
||||||
</script>
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<BookOpen class="w-5 h-5 text-primary" />
|
<BookOpen class="w-5 h-5 text-primary" />
|
||||||
使用指南
|
使用指南
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-text-secondary mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
|
<p class="text-xs text-text-secondary mt-1">快速了解如何使用宝一外校园点歌系统</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="start-tour-btn"
|
class="start-tour-btn"
|
||||||
@@ -92,7 +92,7 @@ const windowSize = ref({ width: 0, height: 0 })
|
|||||||
const tourSteps = [
|
const tourSteps = [
|
||||||
{
|
{
|
||||||
element: '.user-tutorial-container',
|
element: '.user-tutorial-container',
|
||||||
title: '欢迎来到 VoiceHub!',
|
title: '欢迎来到宝一外校园点歌系统!',
|
||||||
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
|
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
|
||||||
position: 'top'
|
position: 'top'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div v-show="active" class="content">
|
<div v-show="active" class="content">
|
||||||
<div class="year-bg">{{ data.year }}</div>
|
<div class="year-bg">{{ data.year }}</div>
|
||||||
<div class="sub-title">年度点歌报告</div>
|
<div class="sub-title">年度点歌报告</div>
|
||||||
<h1 class="main-title">VoiceHub</h1>
|
<h1 class="main-title">宝一外校园点歌系统</h1>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">VoiceHub</div>
|
<div class="logo">宝一外校园点歌系统</div>
|
||||||
<div class="year">{{ data.year }}</div>
|
<div class="year">{{ data.year }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import { SpeedInsights } from '@vercel/speed-insights/nuxt'
|
|||||||
|
|
||||||
const { initSiteConfig } = useSiteConfig()
|
const { initSiteConfig } = useSiteConfig()
|
||||||
|
|
||||||
|
// 初始化站点配置
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof document !== 'undefined' && document.body) {
|
|
||||||
document.body.setAttribute('data-frontend', 'true')
|
|
||||||
}
|
|
||||||
initSiteConfig()
|
initSiteConfig()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">鍥剧墖姝屽崟</h2>
|
||||||
|
<p class="text-sm text-zinc-500">閫夋嫨鏃ユ湡鐢熸垚骞挎挱鎺掓湡鍗$墖骞跺垎浜?/p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<!-- 璁剧疆闈㈡澘 -->
|
||||||
|
<div class="w-full flex flex-col gap-6">
|
||||||
|
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
|
||||||
|
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
|
||||||
|
<Layout class="w-4 h-4 text-blue-500" /> 鍗$墖璁剧疆
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-5 items-end">
|
||||||
|
<!-- 鏃ユ湡閫夋嫨 -->
|
||||||
|
<div class="space-y-2 flex-1 w-full">
|
||||||
|
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">鎺掓湡鏃ユ湡</label>
|
||||||
|
|
||||||
|
<!-- 绫讳技鎺掓湡绠$悊鐨勬棩鏈熼€夋嫨鍣?-->
|
||||||
|
<AdminDatePicker
|
||||||
|
:modelValue="selectedDate"
|
||||||
|
@update:modelValue="selectedDate = $event"
|
||||||
|
@change="fetchSchedules"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="loading || schedules.length === 0"
|
||||||
|
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
|
||||||
|
@click="showModal = true"
|
||||||
|
>
|
||||||
|
<ImageIcon class="w-4 h-4" />
|
||||||
|
{{ loading ? '鍔犺浇涓?..' : (schedules.length === 0 ? '璇ユ棩鏈熸棤鎺掓湡' : '鐢熸垚鍗$墖') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 棰勮鍖?-->
|
||||||
|
<div class="w-full flex flex-col gap-4">
|
||||||
|
<div v-if="schedules.length > 0" class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 class="text-lg font-bold text-zinc-100 mb-4">鎺掓湡棰勮 ({{ schedules.length }} 棣?</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-for="(schedule, index) in schedules" :key="schedule.id" class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-lg">
|
||||||
|
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<p class="text-sm text-zinc-200 font-medium truncate">{{ schedule.song?.title || schedule.title }}</p>
|
||||||
|
<p class="text-xs text-zinc-500 truncate">{{ schedule.song?.artist || schedule.artist }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
|
||||||
|
<CalendarDays class="w-12 h-12 text-zinc-700 mb-4" />
|
||||||
|
<p class="text-zinc-500">璇ユ棩鏈熸病鏈夋帓鏈熺殑姝屾洸</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminPlaylistCardGenerator
|
||||||
|
:show="showModal"
|
||||||
|
:songs="schedules"
|
||||||
|
:default-title="`${selectedDate} 鏍″洯骞挎挱姝屽崟`"
|
||||||
|
@close="showModal = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Layout, Image as ImageIcon, CalendarDays } from '@lucide/vue'
|
||||||
|
import AdminDatePicker from './AdminDatePicker.vue'
|
||||||
|
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
|
||||||
|
import { useAuth } from '~/composables/useAuth'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const schedules = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
|
||||||
|
const fetchSchedules = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/admin/schedule/full', {
|
||||||
|
...auth.getAuthConfig(),
|
||||||
|
query: {
|
||||||
|
date: selectedDate.value,
|
||||||
|
includeDrafts: 'true'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.success && response.data && response.data.schedules) {
|
||||||
|
schedules.value = response.data.schedules
|
||||||
|
} else {
|
||||||
|
schedules.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch schedules:', error)
|
||||||
|
if (window.$showNotification) {
|
||||||
|
window.$showNotification('鑾峰彇鎺掓湡澶辫触', 'error')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSchedules()
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -155,7 +155,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
{
|
{
|
||||||
title: schedule.song.title,
|
title: schedule.song.title,
|
||||||
artist: schedule.song.artist,
|
artist: schedule.song.artist,
|
||||||
playDate: schedule.playDate
|
playDate: schedule.playDate,
|
||||||
|
// 微信「歌曲排期」订阅消息的排期序号字段要它
|
||||||
|
sequence: schedule.sequence
|
||||||
},
|
},
|
||||||
clientIP
|
clientIP
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const notificationsToSend: Array<{
|
const notificationsToSend: Array<{
|
||||||
requesterId: number
|
requesterId: number
|
||||||
songId: number
|
songId: number
|
||||||
songInfo: { title: string; artist: string; playDate: Date }
|
// sequence 要给微信「歌曲排期」订阅消息的排期序号字段用
|
||||||
|
songInfo: { title: string; artist: string; playDate: Date; sequence?: number | null }
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
// 开始事务
|
// 开始事务
|
||||||
@@ -119,7 +120,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
songInfo: {
|
songInfo: {
|
||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
playDate: playDate
|
playDate: playDate,
|
||||||
|
sequence: item.sequence
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
{
|
{
|
||||||
title: draft.song.title,
|
title: draft.song.title,
|
||||||
artist: draft.song.artist,
|
artist: draft.song.artist,
|
||||||
playDate: draft.playDate
|
playDate: draft.playDate,
|
||||||
|
// 微信「歌曲排期」订阅消息的排期序号字段要它
|
||||||
|
sequence: draft.sequence
|
||||||
},
|
},
|
||||||
clientIP
|
clientIP
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
* 1. access_token 能不能拿到(appid / secret 是否有效)
|
* 1. access_token 能不能拿到(appid / secret 是否有效)
|
||||||
* 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null)
|
* 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null)
|
||||||
* 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码)
|
* 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码)
|
||||||
* 4. 微信返回的原文
|
* 4. 微信返回的原文(data 里能直接看到每个字段最终发了什么值)
|
||||||
*
|
*
|
||||||
* body(都可省):
|
* body(都可省):
|
||||||
* { userId?: number, kind?: string, openid?: string, templateId?: string, data?: object }
|
* { userId?: number, kind?: string, openid?: string, templateId?: string,
|
||||||
|
* vars?: object, data?: object }
|
||||||
* · 默认给自己发、用配置里的第一个模板
|
* · 默认给自己发、用配置里的第一个模板
|
||||||
* · 想直接指定 openid / 模板 ID / data 也可以(用于排查)
|
* · vars 覆盖**变量**(songTitle / playDate / playTime / sequence / status / message…),
|
||||||
|
* 仍走 buildTemplateData → clampField,所以看到的是真发出去的值
|
||||||
|
* · data 直接当微信的 data 用(最后的兜底排查手段)
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = event.context.user
|
const user = event.context.user
|
||||||
@@ -41,7 +44,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: 'no-template',
|
reason: 'no-template',
|
||||||
hint: '服务器环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 没配置模板 ID(见 wechatSubscribeService.ts 头部注释)',
|
hint: '没有可用的模板 ID:内置默认模板见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES,也可用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 配置',
|
||||||
configuredKinds: kinds,
|
configuredKinds: kinds,
|
||||||
tokenOk: !!token,
|
tokenOk: !!token,
|
||||||
openid: openid ? openid.slice(0, 6) + '****' : null
|
openid: openid ? openid.slice(0, 6) + '****' : null
|
||||||
@@ -60,13 +63,40 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 各通知类型的**真实例子**:拿这些值走一遍 buildTemplateData → clampField,
|
||||||
|
* 站长看到的 data 就是线上真会发出去的样子(比 '测试歌曲' 更能验字段名/长度)。
|
||||||
|
*/
|
||||||
|
const SAMPLE_VARS: Record<string, Record<string, unknown>> = {
|
||||||
|
SONG_SELECTED: {
|
||||||
|
songTitle: '测试歌曲《晴天》',
|
||||||
|
playDate: '2026-02-14',
|
||||||
|
playTime: '2026-02-14 12:30',
|
||||||
|
sequence: '1',
|
||||||
|
status: '已排期',
|
||||||
|
message: '您投稿的歌曲《晴天》已被安排播放,播放日期:2026-02-14。'
|
||||||
|
},
|
||||||
|
SONG_REJECTED: {
|
||||||
|
songTitle: '测试歌曲《晴天》',
|
||||||
|
reason: '已有同名单曲,暂不重复收录',
|
||||||
|
message: '您投稿的歌曲《晴天》未通过审核。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
body?.data && typeof body.data === 'object'
|
body?.data && typeof body.data === 'object'
|
||||||
? body.data
|
? body.data
|
||||||
: buildTemplateData(conf || { id: templateId }, {
|
: buildTemplateData(
|
||||||
songTitle: '测试歌曲',
|
conf || { id: templateId },
|
||||||
message: '这是一条来自宝一外校园点歌系统的测试通知'
|
Object.assign(
|
||||||
})
|
{
|
||||||
|
songTitle: '测试歌曲',
|
||||||
|
message: '这是一条来自宝一外校园点歌系统的测试通知'
|
||||||
|
},
|
||||||
|
SAMPLE_VARS[kind] || {},
|
||||||
|
body?.vars && typeof body.vars === 'object' ? body.vars : {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const result = await sendRawSubscribe(openid, templateId, data)
|
const result = await sendRawSubscribe(openid, templateId, data)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '~/drizzle/schema'
|
} from '~/drizzle/schema'
|
||||||
import { and, eq, gte, inArray } from 'drizzle-orm'
|
import { and, eq, gte, inArray } from 'drizzle-orm'
|
||||||
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
|
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
|
||||||
import { sendWechatSubscribeToUser } from './wechatSubscribeService'
|
import { buildWechatTimeValue, sendWechatSubscribeToUser } from './wechatSubscribeService'
|
||||||
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
|
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
|
||||||
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
|
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
|
||||||
|
|
||||||
@@ -122,6 +122,8 @@ export async function createSongSelectedNotification(
|
|||||||
title: string
|
title: string
|
||||||
artist: string
|
artist: string
|
||||||
playDate: Date
|
playDate: Date
|
||||||
|
/** 排期在该时段里的序号(微信「歌曲排期」模板的排期序号字段用) */
|
||||||
|
sequence?: number | null
|
||||||
},
|
},
|
||||||
ipAddress?: string
|
ipAddress?: string
|
||||||
) {
|
) {
|
||||||
@@ -207,7 +209,23 @@ export async function createSongSelectedNotification(
|
|||||||
}
|
}
|
||||||
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
|
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
|
||||||
try {
|
try {
|
||||||
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', { songTitle: songInfo.title, message })
|
/*
|
||||||
|
* 微信模板字段比站内通知挑数据:thing/date/time/character_string 各有格式要求,
|
||||||
|
* 空值或格式不对会让**整条**订阅消息被拒(47003),所以这里把每个字段都算好再传。
|
||||||
|
*/
|
||||||
|
const playTime = isPlayTimeEnabled && schedule?.playTime ? schedule.playTime : null
|
||||||
|
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', {
|
||||||
|
songTitle: songInfo.title,
|
||||||
|
// date 型:'YYYY-MM-DD'
|
||||||
|
playDate: formatDate(songInfo.playDate),
|
||||||
|
// time 型:'YYYY-MM-DD HH:mm',有排期时段就取它的开始时间('12:30-13:00' 也能解析)
|
||||||
|
playTime: buildWechatTimeValue(songInfo.playDate, playTime?.startTime || playTime?.endTime),
|
||||||
|
// phrase 型:5 个以内纯汉字;“已排期”只有 3 个字,clampField 会补全角空格
|
||||||
|
status: '已排期',
|
||||||
|
// character_string 型:只允许数字/字母,没有序号时不传(避免空值 47003)
|
||||||
|
sequence: songInfo.sequence == null ? undefined : String(songInfo.sequence),
|
||||||
|
message
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送微信订阅消息失败:', error)
|
console.error('发送微信订阅消息失败:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { db } from '~/drizzle/db'
|
import { db } from '~/drizzle/db'
|
||||||
import { notificationSettings, userIdentities } from '~/drizzle/schema'
|
import { notificationSettings, userIdentities } from '~/drizzle/schema'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
// ⚠️ 服务端 utils 在仓库根的 server/ 下,Nuxt 4 里 `~` 指向 app/,必须用 `~~`
|
||||||
|
import { beijingNow, clampField } from '~~/server/utils/wechatSubscribeFields'
|
||||||
|
|
||||||
|
// 字段裁剪规则集中在 utils/wechatSubscribeFields.ts(纯函数,可单独测),这里只做转发
|
||||||
|
export { beijingNow, buildWechatTimeValue, clampField } from '~~/server/utils/wechatSubscribeFields'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
|
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
|
||||||
@@ -10,15 +15,22 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
|
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
|
||||||
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
|
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
|
||||||
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
|
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
|
||||||
* 4. 模板字段的类型和长度由模板决定:`thing` 最长 20 字、`time` 必须 `YYYY-MM-DD HH:mm`,
|
* 4. 模板字段的类型和长度由模板决定,**某一个字段超长会让整条消息被拒**(47003,报错里那个
|
||||||
* 字段名与长度写错会被微信拒(47003),所以这里按模板配置逐个裁剪。
|
* 字段名是微信挑的,不代表只有它有问题)。各类型的限制:
|
||||||
|
* · thing 20 字(本服务截 19 字 + 省略号)
|
||||||
|
* · phrase 5 个以内纯汉字(恰好 5 个在部分模板上会被拒,见 utils/wechatSubscribeFields.ts)
|
||||||
|
* · time 必须是 'YYYY-MM-DD HH:mm'
|
||||||
|
* · date 必须是 'YYYY-MM-DD'
|
||||||
|
* · character_string 32 字(只能数字、字母)
|
||||||
|
* · number/amount 32 字(只能数字)
|
||||||
|
* 所以这里按模板配置逐个裁剪;另外**空值也会 47003**,拿不到值的字段用占位文案兜住。
|
||||||
*
|
*
|
||||||
* 配置(服务器环境变量;模板一项缺失就整体静默关闭,不影响其它通知渠道):
|
* 配置(服务器环境变量;模板一项缺失就整体静默关闭,不影响其它通知渠道):
|
||||||
* WECHAT_MINI_APPID / WECHAT_MINI_SECRET —— 已有(微信登录在用)
|
* WECHAT_MINI_APPID / WECHAT_MINI_SECRET —— 已有(微信登录在用)
|
||||||
* WECHAT_MINI_SUBSCRIBE_TEMPLATES —— JSON:
|
* WECHAT_MINI_SUBSCRIBE_TEMPLATES —— JSON:
|
||||||
* {
|
* {
|
||||||
* "SONG_PLAYED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time", "thing3": "message" } },
|
* "SONG_PLAYED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time", "thing3": "message" } },
|
||||||
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time" } }
|
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing2": "songTitle", "time1": "playTime" } }
|
||||||
* }
|
* }
|
||||||
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3),
|
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3),
|
||||||
* **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。
|
* **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。
|
||||||
@@ -38,11 +50,38 @@ type TemplateConf = { id: string; fields?: Record<string, string> }
|
|||||||
* 模板字段「驳回原因」= thing4 → reason
|
* 模板字段「驳回原因」= thing4 → reason
|
||||||
* ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003),
|
* ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003),
|
||||||
* 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。
|
* 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。
|
||||||
|
*
|
||||||
|
* SONG_SELECTED = 歌曲被选中(管理员把歌排进排期 / 发布排期)。
|
||||||
|
* 站点上这条通知一直是发出去的,但之前**没有**内置微信模板,所以小程序那边收不到;
|
||||||
|
* 站长 2026-02 在公众平台选好了「歌曲排期」模板,这里补上默认映射:
|
||||||
|
* 模板字段「歌曲名称」= thing2 → songTitle
|
||||||
|
* 模板字段「播放日期」= date4 → playDate('YYYY-MM-DD',与站内通知里的日期一致)
|
||||||
|
* 模板字段「播放时间」= time1 → playTime('YYYY-MM-DD HH:mm',优先取排期时段的开始时间)
|
||||||
|
* 模板字段「排期序号」= character_string3 → sequence
|
||||||
|
* 模板字段「排期状态」= phrase5 → status(固定「已排期」,phrase 必须恰好 5 个汉字)
|
||||||
|
* ⚠️ 字段名(thing2/date4/…)必须和公众平台模板里的一模一样,否则微信 47003。
|
||||||
|
* 站长若选了别的字段名,用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 覆盖 fields 即可,
|
||||||
|
* 不用改代码(例:`{"SONG_SELECTED":{"id":"oS4Ev…","fields":{"thing1":"songTitle"}}}`)。
|
||||||
|
*
|
||||||
|
* 另外一个**必须让用户知道**的平台特性:模板 ID 变了 = 用户要重新授权。
|
||||||
|
* 老用户之前点的授权只对旧模板 ID 有效,新增模板后他们得再点一次「开启微信通知」,
|
||||||
|
* 否则这条消息会以 43101(无可用授权)静默跳过 —— 那是正常状态,不是 bug。
|
||||||
*/
|
*/
|
||||||
const DEFAULT_TEMPLATES: Record<string, TemplateConf> = {
|
const DEFAULT_TEMPLATES: Record<string, TemplateConf> = {
|
||||||
SONG_REJECTED: {
|
SONG_REJECTED: {
|
||||||
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
|
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
|
||||||
fields: { thing6: 'songTitle', thing4: 'reason' }
|
fields: { thing6: 'songTitle', thing4: 'reason' }
|
||||||
|
},
|
||||||
|
SONG_SELECTED: {
|
||||||
|
// 站长提供的「歌曲排期」模板 ID
|
||||||
|
id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk',
|
||||||
|
fields: {
|
||||||
|
thing2: 'songTitle',
|
||||||
|
date4: 'playDate',
|
||||||
|
time1: 'playTime',
|
||||||
|
character_string3: 'sequence',
|
||||||
|
phrase5: 'status'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,31 +184,8 @@ export async function getMiniAccessToken(force = false): Promise<string | null>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 取值 / 裁剪 ----------------
|
// ---------------- 取值 / 裁剪 ----------------
|
||||||
|
// clampField / beijingNow / buildWechatTimeValue 都在 ~/server/utils/wechatSubscribeFields.ts,
|
||||||
/** 模板字段的长度限制(微信侧的限制,超了直接 47003) */
|
// 顶部已 import + re-export(那里的注释写清了每种字段类型的微信限制)。
|
||||||
function clampField(key: string, value: unknown): string {
|
|
||||||
const text = value === null || value === undefined ? '' : String(value)
|
|
||||||
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
|
|
||||||
if (/^thing/.test(key)) return text.length > 20 ? text.slice(0, 19) + '…' : text
|
|
||||||
return text.slice(0, 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 'YYYY-MM-DD HH:mm'(微信 time 型字段要求的格式,北京时间) */
|
|
||||||
export function beijingNow(): string {
|
|
||||||
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
|
|
||||||
const p = (n: number) => (n < 10 ? '0' + n : String(n))
|
|
||||||
return (
|
|
||||||
now.getUTCFullYear() +
|
|
||||||
'-' +
|
|
||||||
p(now.getUTCMonth() + 1) +
|
|
||||||
'-' +
|
|
||||||
p(now.getUTCDate()) +
|
|
||||||
' ' +
|
|
||||||
p(now.getUTCHours()) +
|
|
||||||
':' +
|
|
||||||
p(now.getUTCMinutes())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用模板配置 + 变量拼出微信要的 data */
|
/** 用模板配置 + 变量拼出微信要的 data */
|
||||||
export function buildTemplateData(
|
export function buildTemplateData(
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 微信**订阅消息**字段的取值规则(纯函数,不依赖 Nitro / 数据库,可直接用 node 跑验证)。
|
||||||
|
*
|
||||||
|
* 为什么单独放一个文件:微信的字段校验是**整条消息级别**的 —— 只要有一个字段超长、
|
||||||
|
* 格式不对或为空,整条消息发不出去,微信只回一个 `47003 argument invalid! data.xxx.value invalid`
|
||||||
|
* (而且报错里点名的字段不一定是真正有问题的那一个)。所以这些裁剪规则必须能单独测。
|
||||||
|
*
|
||||||
|
* 各类型限制(公众平台模板字段名后缀就是类型):
|
||||||
|
* thing 20 字
|
||||||
|
* phrase 5 个以内纯汉字(官方文档的写法;社区反馈**恰好 5 个汉字**在部分模板上会报
|
||||||
|
* `47003 data.phraseN.value invalid`,实测 4 个汉字正常)
|
||||||
|
* time 必须是 'YYYY-MM-DD HH:mm'
|
||||||
|
* date 必须是 'YYYY-MM-DD'
|
||||||
|
* character_string 32 字(只能数字、字母)
|
||||||
|
* number/amount 32 字(只能数字)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 各类型字段的长度上限 */
|
||||||
|
export const FIELD_MAX_LEN: Record<string, number> = {
|
||||||
|
thing: 20,
|
||||||
|
phrase: 5,
|
||||||
|
time: 24,
|
||||||
|
date: 10,
|
||||||
|
character_string: 32,
|
||||||
|
number: 32,
|
||||||
|
amount: 32,
|
||||||
|
digit: 32,
|
||||||
|
letter: 32,
|
||||||
|
symbol: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段名 → 类型(thing1/character_string3 → thing/character_string) */
|
||||||
|
export function fieldType(key: string): string {
|
||||||
|
return String(key || '').replace(/[0-9]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按字段类型裁剪成微信能接受的值。
|
||||||
|
* ⚠️ 返回空字符串仍然会被微信拒,调用方要保证变量有值(见 wechatSubscribeService 的占位逻辑)。
|
||||||
|
*/
|
||||||
|
export function clampField(key: string, value: unknown): string {
|
||||||
|
const raw = value === null || value === undefined ? '' : String(value)
|
||||||
|
// 微信不认换行,先压成一行(站内通知的 message 是多行拼的)
|
||||||
|
const text = raw.replace(/\s+/g, ' ').trim()
|
||||||
|
const type = fieldType(key)
|
||||||
|
|
||||||
|
if (type === 'thing') {
|
||||||
|
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
|
||||||
|
return Array.from(text).length > 20 ? Array.from(text).slice(0, 19).join('') + '…' : text
|
||||||
|
}
|
||||||
|
if (type === 'phrase') {
|
||||||
|
/*
|
||||||
|
* phrase = 5 个以内纯汉字。官方文档是「5个以内」,但社区反馈**恰好 5 个汉字**在部分模板上
|
||||||
|
* 会被判 47003 invalid(4 个正常,见 wxopen.club/topic/61daa41e947eea50368f28ec),所以
|
||||||
|
* 按最保守的写法统一补到 5 个字:短了补全角空格,长到 5 个字保持原样,超了截断。
|
||||||
|
* “已排期”只有 3 个字 —— 真发出去不是被拒就是显示得莫名其妙,这里补齐后不会超长。
|
||||||
|
*/
|
||||||
|
const chars = Array.from(text)
|
||||||
|
if (chars.length >= 5) return chars.slice(0, 5).join('')
|
||||||
|
let padded = text
|
||||||
|
while (Array.from(padded).length < 5) padded += ' '
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
const max = FIELD_MAX_LEN[type] || 32
|
||||||
|
return text.slice(0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间(UTC+8)的 'YYYY-MM-DD' */
|
||||||
|
export function beijingDateString(date: Date): string {
|
||||||
|
const t = new Date(date.getTime() + 8 * 60 * 60 * 1000)
|
||||||
|
const p = (n: number) => (n < 10 ? '0' + n : String(n))
|
||||||
|
return t.getUTCFullYear() + '-' + p(t.getUTCMonth() + 1) + '-' + p(t.getUTCDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把播放日期 + 排期时段揉成微信 time 型字段要求的 'YYYY-MM-DD HH:mm'。
|
||||||
|
* 支持 '12:30'、'12:30-13:00'、'12:30~13:00'、'12:30'(全角冒号),取**开始时间**。
|
||||||
|
* 解析不出来时退化成当天 00:00 —— time 型字段不能为空,否则整条消息 47003。
|
||||||
|
*/
|
||||||
|
export function buildWechatTimeValue(date: Date, timeRange?: string | null): string {
|
||||||
|
const day = beijingDateString(date)
|
||||||
|
const m = String(timeRange || '').match(/(\d{1,2})\s*[::]\s*(\d{2})/)
|
||||||
|
if (!m) return day + ' 00:00'
|
||||||
|
const hh = String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0')
|
||||||
|
return day + ' ' + hh + ':' + m[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD HH:mm'(微信 time 型字段要求的格式,北京时间) */
|
||||||
|
export function beijingNow(): string {
|
||||||
|
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
|
||||||
|
const p = (n: number) => (n < 10 ? '0' + n : String(n))
|
||||||
|
return (
|
||||||
|
now.getUTCFullYear() +
|
||||||
|
'-' +
|
||||||
|
p(now.getUTCMonth() + 1) +
|
||||||
|
'-' +
|
||||||
|
p(now.getUTCDate()) +
|
||||||
|
' ' +
|
||||||
|
p(now.getUTCHours()) +
|
||||||
|
':' +
|
||||||
|
p(now.getUTCMinutes())
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* 微信订阅消息字段裁剪规则的单元测试(`npm test` 跑)。
|
||||||
|
*
|
||||||
|
* 为什么值得单独测:微信对订阅消息的字段校验是**整条消息级别**的 —— 一个字段超长、
|
||||||
|
* 格式不对或为空,整条消息就被拒(47003),而且报错里点名的字段不一定是真凶。
|
||||||
|
* 这些规则一旦写错,线上表现就是「通知发不出去」,却看不出哪里错。
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
beijingNow,
|
||||||
|
buildWechatTimeValue,
|
||||||
|
clampField
|
||||||
|
} from '../server/utils/wechatSubscribeFields.ts'
|
||||||
|
|
||||||
|
const len = (s) => Array.from(s).length
|
||||||
|
|
||||||
|
test('thing 型:20 字以内原样,超了截 19 字 + 省略号(正好 20)', () => {
|
||||||
|
assert.equal(clampField('thing2', '晴天'), '晴天')
|
||||||
|
const twenty = '一二三四五六七八九十一二三四五六七八九十'
|
||||||
|
assert.equal(clampField('thing2', twenty), twenty)
|
||||||
|
assert.equal(len(clampField('thing2', twenty)), 20)
|
||||||
|
|
||||||
|
const long = twenty + '崩坏的世界'
|
||||||
|
const clamped = clampField('thing2', long)
|
||||||
|
assert.equal(len(clamped), 20)
|
||||||
|
assert.ok(clamped.endsWith('…'))
|
||||||
|
assert.ok(long.startsWith(clamped.slice(0, 19)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('phrase 型:5 个以内纯汉字——“已排期”会被补成全角 5 字,不是原样发出去', () => {
|
||||||
|
const padded = clampField('phrase5', '已排期')
|
||||||
|
assert.equal(len(padded), 5)
|
||||||
|
assert.ok(padded.startsWith('已排期'))
|
||||||
|
|
||||||
|
// 刚好 5 个汉字原样保留
|
||||||
|
assert.equal(clampField('phrase5', '已排期播放'), '已排期播放')
|
||||||
|
// 超了截到 5 个
|
||||||
|
assert.equal(len(clampField('phrase5', '已排期播放中啦啦啦')), 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('time 型:必须是 YYYY-MM-DD HH:mm 且不超过 24 字', () => {
|
||||||
|
const w = (v) => ({ value: clampField('time1', v) })
|
||||||
|
assert.deepEqual(w(buildWechatTimeValue(new Date('2026-02-14T00:00:00Z'), '12:30')), {
|
||||||
|
value: '2026-02-14 12:30'
|
||||||
|
})
|
||||||
|
assert.match(clampField('time1', '2026-02-14 12:30'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||||
|
assert.equal(len(clampField('time1', '2026-02-14 12:30')), 16)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildWechatTimeValue:取时段开始时间,兼容各种写法,兜底当天 00:00', () => {
|
||||||
|
const day = new Date('2026-02-14T00:00:00Z')
|
||||||
|
assert.equal(buildWechatTimeValue(day, '12:30-13:00'), '2026-02-14 12:30')
|
||||||
|
assert.equal(buildWechatTimeValue(day, '12:30~13:00'), '2026-02-14 12:30')
|
||||||
|
assert.equal(buildWechatTimeValue(day, '12:30'), '2026-02-14 12:30')
|
||||||
|
assert.equal(buildWechatTimeValue(day, '9:05'), '2026-02-14 09:05')
|
||||||
|
assert.equal(buildWechatTimeValue(day, '13:00'), '2026-02-14 13:00')
|
||||||
|
// time 型字段不能为空,解析不出来也要给个合法值
|
||||||
|
assert.equal(buildWechatTimeValue(day, ''), '2026-02-14 00:00')
|
||||||
|
assert.equal(buildWechatTimeValue(day, null), '2026-02-14 00:00')
|
||||||
|
assert.equal(buildWechatTimeValue(day, '午间'), '2026-02-14 00:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildWechatTimeValue:以北京时间取日期(UTC 16:00 已是次日)', () => {
|
||||||
|
assert.equal(buildWechatTimeValue(new Date('2026-02-13T16:00:00Z'), '12:30'), '2026-02-14 12:30')
|
||||||
|
assert.equal(buildWechatTimeValue(new Date('2026-02-13T15:59:00Z'), '12:30'), '2026-02-13 12:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('character_string 型:序号原样发(不补零、不加字)', () => {
|
||||||
|
assert.equal(clampField('character_string3', 1), '1')
|
||||||
|
assert.equal(clampField('character_string3', '12'), '12')
|
||||||
|
assert.equal(clampField('character_string3', 0), '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('date 型:YYYY-MM-DD 原样,脏值截到 10 字', () => {
|
||||||
|
assert.equal(clampField('date4', '2026-02-14'), '2026-02-14')
|
||||||
|
assert.equal(clampField('date4', '2026-02-14 12:30:00'), '2026-02-14')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空白与换行会被压成一行(微信不接受换行)', () => {
|
||||||
|
assert.equal(clampField('thing1', ' 晴天\n 周杰伦 '), '晴天 周杰伦')
|
||||||
|
assert.equal(clampField('thing1', null), '')
|
||||||
|
assert.equal(clampField('thing1', undefined), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => {
|
||||||
|
// 与 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES.SONG_SELECTED 保持一致
|
||||||
|
const fields = {
|
||||||
|
thing2: 'songTitle',
|
||||||
|
date4: 'playDate',
|
||||||
|
time1: 'playTime',
|
||||||
|
character_string3: 'sequence',
|
||||||
|
phrase5: 'status'
|
||||||
|
}
|
||||||
|
const vars = {
|
||||||
|
songTitle: '晴天',
|
||||||
|
playDate: '2026-02-14',
|
||||||
|
playTime: '2026-02-14 12:30',
|
||||||
|
sequence: '1',
|
||||||
|
status: '已排期'
|
||||||
|
}
|
||||||
|
const out = {}
|
||||||
|
for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]])
|
||||||
|
|
||||||
|
assert.equal(out.thing2, '晴天')
|
||||||
|
assert.equal(out.date4, '2026-02-14')
|
||||||
|
assert.match(out.time1, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||||
|
assert.equal(out.character_string3, '1')
|
||||||
|
assert.equal(len(out.phrase5), 5) // “已排期” 3 字 → 补齐到 5 字,避开 47003
|
||||||
|
// 不能有任何字段是空值:空值同样会被微信拒
|
||||||
|
for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('beijingNow 是合法的 time 型值,且与北京时间一致', () => {
|
||||||
|
assert.match(beijingNow(), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||||
|
// beijingNow() 内部按 UTC+8 取值,用同一算法反推对比
|
||||||
|
const t = new Date(Date.now() + 8 * 3600 * 1000)
|
||||||
|
const p = (n) => (n < 10 ? '0' + n : String(n))
|
||||||
|
assert.equal(
|
||||||
|
beijingNow(),
|
||||||
|
`${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user