mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
11 Commits
Binary file not shown.
@@ -15,6 +15,8 @@ on:
|
||||
types: [published]
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -43,4 +45,11 @@ jobs:
|
||||
I_URL: ${{ github.event.issue.html_url }}
|
||||
I_USER: ${{ github.event.issue.user.login }}
|
||||
I_BODY: ${{ github.event.issue.body }}
|
||||
# issue_comment 事件:issue 下的新回复(PR 评论也走此事件,脚本里据 C_PR 跳过)
|
||||
C_NUM: ${{ github.event.issue.number }}
|
||||
C_TITLE: ${{ github.event.issue.title }}
|
||||
C_URL: ${{ github.event.comment.html_url }}
|
||||
C_USER: ${{ github.event.comment.user.login }}
|
||||
C_BODY: ${{ github.event.comment.body }}
|
||||
C_PR: ${{ github.event.issue.pull_request.html_url }}
|
||||
run: node .github/scripts/tg-notify.mjs
|
||||
|
||||
@@ -15,6 +15,8 @@ RUN set -eux; \
|
||||
curl ca-certificates locales dpkg \
|
||||
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
||||
libnss3 libgbm1 libasound2 libxss1 \
|
||||
xz-utils \
|
||||
chromium \
|
||||
xdotool xclip; \
|
||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
@@ -68,6 +70,11 @@ RUN chmod 755 /woc/woc-www-patch.sh && chmod 644 /woc/woc-ime.pl && /woc/woc-www
|
||||
COPY wechat-ctl.sh /woc/wechat-ctl.sh
|
||||
RUN chmod 755 /woc/wechat-ctl.sh
|
||||
|
||||
# v1.2.0 多应用:应用定义(app-defs.sh,被 autostart/app-ctl 引用)+ 通用安装/状态控制(app-ctl.sh,
|
||||
# 微信委托回 wechat-ctl.sh、其它应用各自实现)。app-defs.sh 是被 source 的、不需可执行位。
|
||||
COPY app-defs.sh app-ctl.sh /woc/
|
||||
RUN chmod 644 /woc/app-defs.sh && chmod 755 /woc/app-ctl.sh
|
||||
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||
COPY autostart /defaults/autostart
|
||||
RUN chmod 755 /defaults/autostart
|
||||
@@ -81,5 +88,10 @@ RUN chmod 755 /custom-cont-init.d/00-woc-identity
|
||||
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod 755 /custom-cont-init.d/01-woc-autostart
|
||||
|
||||
# 启动钩子(02):把容器环境 WOC_APP_TYPE 写入 /config/.woc-app,供 autostart 选择启动哪个应用。
|
||||
# 须在 autostart 之前执行;缺 WOC_APP_TYPE 则不写 → autostart 回退微信(向后兼容)。
|
||||
COPY woc-app-init.sh /custom-cont-init.d/02-woc-app
|
||||
RUN chmod 755 /custom-cont-init.d/02-woc-app
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# 多应用安装/状态控制(面板经 docker exec --user abc 调用):
|
||||
# app-ctl.sh <appType> <install|update|status>
|
||||
# 设计:微信完全委托给原 wechat-ctl.sh(逻辑零改动);其它应用各自实现,状态 JSON 复用同一格式与文件,
|
||||
# 故面板的轮询逻辑无需区分应用类型。状态文件:/config/.woc-state/status.json。
|
||||
set -u
|
||||
|
||||
APP="${1:-wechat}"
|
||||
ACTION="${2:-status}"
|
||||
|
||||
# 微信:原样委托,保持既有行为不变(向后兼容老实例与旧面板调用路径)
|
||||
if [ "$APP" = "wechat" ]; then exec /woc/wechat-ctl.sh "$ACTION"; fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. /woc/app-defs.sh
|
||||
woc_app_def "$APP"
|
||||
|
||||
STATE_DIR="${WOC_STATE_DIR:-/config/.woc-state}"
|
||||
STATUS_FILE="$STATE_DIR/status.json"
|
||||
|
||||
is_installed() { [ -n "${APP_BIN:-}" ] && [ -x "$APP_BIN" ]; }
|
||||
|
||||
write_status() {
|
||||
local phase="$1" percent="$2" message="$3" installed=false
|
||||
is_installed && installed=true
|
||||
mkdir -p "$STATE_DIR"
|
||||
cat > "$STATUS_FILE.tmp" <<EOF
|
||||
{"phase":"$phase","percent":$percent,"installed":$installed,"version":"","message":"$message","updatedAt":$(date +%s)}
|
||||
EOF
|
||||
mv -f "$STATUS_FILE.tmp" "$STATUS_FILE"
|
||||
}
|
||||
|
||||
print_status() {
|
||||
if [ -f "$STATUS_FILE" ]; then
|
||||
cat "$STATUS_FILE"
|
||||
elif is_installed; then
|
||||
echo "{\"phase\":\"done\",\"percent\":100,\"installed\":true,\"version\":\"\",\"message\":\"已就绪\",\"updatedAt\":$(date +%s)}"
|
||||
else
|
||||
echo "{\"phase\":\"idle\",\"percent\":0,\"installed\":false,\"version\":\"\",\"message\":\"未安装\",\"updatedAt\":$(date +%s)}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_telegram() {
|
||||
case "$(dpkg --print-architecture 2>/dev/null)" in
|
||||
amd64) ;;
|
||||
*) write_status error 0 "Telegram 官方仅提供 x86_64 版本,当前架构($(dpkg --print-architecture 2>/dev/null))不支持"; return ;;
|
||||
esac
|
||||
local work=/config/.woc-dl tmp
|
||||
tmp="$work/tg.tar.xz"
|
||||
rm -rf "$work"; mkdir -p "$work"
|
||||
write_status downloading -1 "正在下载 Telegram"
|
||||
if ! curl -fSL --retry 3 -A "Mozilla/5.0" -o "$tmp" "https://telegram.org/dl/desktop/linux"; then
|
||||
write_status error 0 "下载失败,请检查网络后重试"; rm -rf "$work"; return
|
||||
fi
|
||||
write_status extracting 92 "正在解压安装"
|
||||
local newdir="$work/x"; mkdir -p "$newdir"
|
||||
# 官方包内顶层是 Telegram/ 目录,strip 掉一层 → newdir 下直接是 Telegram + Updater
|
||||
if ! tar -xJf "$tmp" -C "$newdir" --strip-components=1 2>/dev/null; then
|
||||
write_status error 0 "解压失败,安装包可能损坏"; rm -rf "$work"; return
|
||||
fi
|
||||
if [ ! -x "$newdir/Telegram" ]; then
|
||||
write_status error 0 "解压后未找到 Telegram 可执行文件"; rm -rf "$work"; return
|
||||
fi
|
||||
write_status installing 96 "正在安装"
|
||||
rm -rf /config/telegram.old
|
||||
[ -e /config/telegram ] && mv /config/telegram /config/telegram.old
|
||||
mv "$newdir" /config/telegram
|
||||
rm -rf /config/telegram.old "$work"
|
||||
write_status done 100 "安装完成"
|
||||
pkill -f "/config/telegram/Telegram" 2>/dev/null || true
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
status) print_status ;;
|
||||
install | update)
|
||||
case "$APP" in
|
||||
telegram) install_telegram ;;
|
||||
chromium) write_status done 100 "Chromium 随镜像就绪" ;; # 后续:apt 烤进镜像后即就绪
|
||||
custom)
|
||||
if is_installed; then write_status done 100 "就绪"; else write_status error 0 "请先在「数据卷」上传并配置自定义应用"; fi ;;
|
||||
*) echo "未知应用: $APP" >&2; exit 1 ;;
|
||||
esac ;;
|
||||
*) echo "用法: $0 <appType> {install|update|status}" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,31 @@
|
||||
# 应用定义(被 autostart 与 app-ctl.sh source)。给定应用类型,输出该应用的:
|
||||
# APP_BIN — 可执行文件路径(autostart 据此判断"是否就绪/已安装")
|
||||
# APP_LAUNCH — 启动命令(可带参数;autostart 以 word-split 方式执行,参数勿含空格)
|
||||
# APP_NAME — 显示名(日志用)
|
||||
# 缺省/未知类型一律回退微信,保证老实例零改动。v1.2.0 多应用平台。
|
||||
woc_app_def() {
|
||||
case "${1:-wechat}" in
|
||||
telegram)
|
||||
APP_BIN=/config/telegram/Telegram
|
||||
APP_LAUNCH="$APP_BIN"
|
||||
APP_NAME=Telegram
|
||||
;;
|
||||
chromium)
|
||||
# 容器内无 user namespace / GPU:--no-sandbox + 软件渲染;--password-store=basic 免 keyring 弹窗
|
||||
APP_BIN=/usr/bin/chromium
|
||||
APP_LAUNCH="$APP_BIN --no-sandbox --no-first-run --no-default-browser-check --start-maximized --password-store=basic --disable-gpu --user-data-dir=/config/chromium"
|
||||
APP_NAME=Chromium
|
||||
;;
|
||||
custom)
|
||||
# 自定义:启动命令由面板写入 .woc-app 的 WOC_CUSTOM_LAUNCH(用户上传安装包后设定)
|
||||
APP_LAUNCH="${WOC_CUSTOM_LAUNCH:-}"
|
||||
APP_BIN="${WOC_CUSTOM_BIN:-}"
|
||||
APP_NAME="自定义应用"
|
||||
;;
|
||||
*)
|
||||
APP_BIN=/config/wechat/opt/wechat/wechat
|
||||
APP_LAUNCH="$APP_BIN"
|
||||
APP_NAME=微信
|
||||
;;
|
||||
esac
|
||||
}
|
||||
+29
-14
@@ -1,17 +1,25 @@
|
||||
#!/bin/bash
|
||||
# 由 KasmVNC base 的 openbox 会话在桌面就绪后执行(以 app 用户身份)。
|
||||
# 微信本体由面板经 docker exec 触发下载/解压到数据卷 /config/wechat(见 wechat-ctl.sh),
|
||||
# 本脚本只负责:等待微信就绪 + 常驻拉起(关窗自动重开;更新后从新版本路径重启)。
|
||||
# v1.2.0 多应用:实例承载的应用(微信/Telegram/Chromium/自定义)由面板写入容器环境 WOC_APP_TYPE,
|
||||
# 再由 02-woc-app 钩子落到数据卷 /config/.woc-app。本脚本据此等待对应应用就绪并常驻拉起
|
||||
# (关窗自动重开;更新后从新版本路径重启)。读不到类型 → 回退微信,老实例零改动。
|
||||
|
||||
set -u
|
||||
|
||||
WECHAT_BIN=/config/wechat/opt/wechat/wechat
|
||||
|
||||
# 容器内无 GPU,强制软件渲染
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
|
||||
# 解析本实例应用类型与启动信息(APP_BIN / APP_LAUNCH / APP_NAME)
|
||||
APP_TYPE=wechat
|
||||
# shellcheck source=/dev/null
|
||||
[ -f /config/.woc-app ] && . /config/.woc-app 2>/dev/null || true
|
||||
APP_TYPE="${WOC_APP_TYPE:-wechat}"
|
||||
# shellcheck source=/dev/null
|
||||
. /woc/app-defs.sh
|
||||
woc_app_def "$APP_TYPE"
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,窗口被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。
|
||||
(
|
||||
export DISPLAY="${DISPLAY:-:1}"
|
||||
while sleep 2; do
|
||||
@@ -23,25 +31,32 @@ export LIBGL_ALWAYS_SOFTWARE=1
|
||||
done
|
||||
) &
|
||||
|
||||
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
|
||||
# 自定义应用若未配置启动命令,给出提示并退出(避免空等)
|
||||
if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then
|
||||
echo "[autostart] 自定义应用尚未配置启动命令,请在面板「数据卷」上传安装包并设定后重启实例"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1) 等待应用安装就绪(首次需在面板点「下载并安装」)
|
||||
notified=0
|
||||
while [ ! -x "${WECHAT_BIN}" ]; do
|
||||
while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
|
||||
if [ "${notified}" -eq 0 ]; then
|
||||
echo "[autostart] 微信尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装微信」)"
|
||||
echo "[autostart] ${APP_NAME} 尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装」)"
|
||||
notified=1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 3) 常驻拉起微信
|
||||
# 3) 常驻拉起应用
|
||||
while true; do
|
||||
if [ ! -x "${WECHAT_BIN}" ]; then
|
||||
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
|
||||
# 更新过程中本体被临时挪走,等就位再继续
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
echo "[autostart] 启动微信: ${WECHAT_BIN}"
|
||||
"${WECHAT_BIN}"
|
||||
echo "[autostart] 微信已退出,2 秒后重启"
|
||||
echo "[autostart] 启动 ${APP_NAME}: ${APP_LAUNCH}"
|
||||
# APP_LAUNCH 可带参数,按 word-split 执行(各应用参数均不含空格,见 app-defs.sh)
|
||||
${APP_LAUNCH}
|
||||
echo "[autostart] ${APP_NAME} 已退出,2 秒后重启"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# /custom-cont-init.d 钩子(02):把容器环境里的应用类型写入数据卷 /config/.woc-app,
|
||||
# 供 autostart(以 abc 身份的桌面会话)读取。由 s6 在 autostart 之前以 root 运行。
|
||||
# 缺 WOC_APP_TYPE(老实例/老面板)则不写文件 → autostart 回退微信,完全向后兼容。
|
||||
APP_TYPE="${WOC_APP_TYPE:-}"
|
||||
[ -z "$APP_TYPE" ] && exit 0
|
||||
|
||||
# 仅允许已知的简单标识,杜绝写入异常内容
|
||||
case "$APP_TYPE" in
|
||||
wechat | telegram | chromium | custom) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
TMP=/config/.woc-app.tmp
|
||||
{
|
||||
echo "WOC_APP_TYPE='${APP_TYPE}'"
|
||||
# 自定义应用的启动命令由面板经环境传入(admin 设定);用单引号包裹,转义内部单引号
|
||||
if [ -n "${WOC_CUSTOM_LAUNCH:-}" ]; then
|
||||
esc=${WOC_CUSTOM_LAUNCH//\'/\'\\\'\'}
|
||||
echo "WOC_CUSTOM_LAUNCH='${esc}'"
|
||||
fi
|
||||
} > "$TMP"
|
||||
mv -f "$TMP" /config/.woc-app
|
||||
chown abc:abc /config/.woc-app 2>/dev/null || true
|
||||
echo "[woc-app] 实例应用类型 = ${APP_TYPE}"
|
||||
@@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import Docker from 'dockerode';
|
||||
import type { Instance } from './store.js';
|
||||
import { instanceAppType, type Instance } from './store.js';
|
||||
|
||||
const WECHAT_IMAGE = process.env.WOC_WECHAT_IMAGE || 'ghcr.io/gloridust/wechat-on-cloud:latest';
|
||||
const PUID = process.env.PUID || '1000';
|
||||
@@ -114,6 +114,11 @@ function envList(inst: Instance): string[] {
|
||||
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
|
||||
// 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。
|
||||
env.push(`WOC_SPOOF_OS=${SPOOF_OS ? '1' : '0'}`);
|
||||
// v1.2.0 多应用:透传应用类型给 02-woc-app 钩子(写入 /config/.woc-app,autostart 据此启动)。
|
||||
// 老实例无 appType → instanceAppType 回退 wechat;自定义应用额外透传启动命令。
|
||||
const appType = instanceAppType(inst);
|
||||
env.push(`WOC_APP_TYPE=${appType}`);
|
||||
if (appType === 'custom' && inst.customLaunch) env.push(`WOC_CUSTOM_LAUNCH=${inst.customLaunch}`);
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -404,11 +409,15 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// 触发下载/安装(detached,立即返回,后台下载)。
|
||||
// 触发下载/安装(detached,立即返回,后台下载)。按实例 appType 分发:app-ctl.sh wechat → 委托回
|
||||
// wechat-ctl.sh;telegram 等各自实现。兼容旧容器(升级前镜像里没有 /woc/app-ctl.sh):有则用之,无则
|
||||
// 回退老的 wechat-ctl.sh(旧实例都是微信)。appType 取值受 instanceAppType 约束,可安全内插进 shell。
|
||||
export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise<void> {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const at = instanceAppType(inst);
|
||||
const action = cmd === 'update' ? 'update' : 'install';
|
||||
const exec = await c.exec({
|
||||
Cmd: ['/woc/wechat-ctl.sh', cmd === 'update' ? 'update' : 'install'],
|
||||
Cmd: ['bash', '-c', `if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} ${action}; else /woc/wechat-ctl.sh ${action}; fi`],
|
||||
AttachStdout: false,
|
||||
AttachStderr: false,
|
||||
User: 'abc',
|
||||
@@ -429,7 +438,13 @@ const DEFAULT_STATUS: WechatStatus = { phase: 'idle', percent: 0, installed: fal
|
||||
|
||||
export async function wechatStatus(inst: Instance): Promise<WechatStatus> {
|
||||
try {
|
||||
const raw = await execCapture(inst, ['/woc/wechat-ctl.sh', 'status']);
|
||||
// 兼容旧容器(无 /woc/app-ctl.sh):有则按 appType 取状态,无则回退老的 wechat-ctl.sh(旧实例皆微信)。
|
||||
const at = instanceAppType(inst);
|
||||
const raw = await execCapture(inst, [
|
||||
'bash',
|
||||
'-c',
|
||||
`if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} status; else /woc/wechat-ctl.sh status; fi`,
|
||||
]);
|
||||
const json = JSON.parse(raw.trim());
|
||||
return { ...DEFAULT_STATUS, ...json };
|
||||
} catch {
|
||||
|
||||
@@ -26,8 +26,11 @@ import {
|
||||
createInstance,
|
||||
removeInstance as removeInstanceRecord,
|
||||
renameInstance,
|
||||
setInstanceIcon,
|
||||
setInstanceUsers,
|
||||
publicInstance,
|
||||
APP_TYPES,
|
||||
type AppType,
|
||||
type User,
|
||||
type Instance,
|
||||
} from './store.js';
|
||||
@@ -270,11 +273,12 @@ app.get('/api/instances', async (req, reply) => {
|
||||
app.post('/api/admin/instances', async (req, reply) => {
|
||||
const admin = requireAdmin(req, reply);
|
||||
if (!admin) return;
|
||||
const { name, reuseVolume } = (req.body as any) ?? {};
|
||||
const { name, reuseVolume, appType } = (req.body as any) ?? {};
|
||||
const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : [];
|
||||
if (!name || String(name).trim().length === 0 || String(name).length > 30) {
|
||||
return reply.code(400).send({ error: '实例名称为 1-30 个字符' });
|
||||
}
|
||||
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
|
||||
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
|
||||
let reuseVolumeName: string | undefined;
|
||||
if (reuseVolume) {
|
||||
@@ -286,7 +290,7 @@ app.post('/api/admin/instances', async (req, reply) => {
|
||||
}
|
||||
reuseVolumeName = reuseVolume;
|
||||
}
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName);
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
|
||||
try {
|
||||
await runInstance(inst);
|
||||
} catch (e: any) {
|
||||
@@ -446,6 +450,17 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 设置实例自定义图标(仅管理员):icon = builtin:<key> / data:image 图片 / 空串(恢复默认)。
|
||||
app.post('/api/admin/instances/:id/icon', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const { icon } = (req.body as any) ?? {};
|
||||
try {
|
||||
return { instance: setInstanceIcon((req.params as any).id, typeof icon === 'string' ? icon : null) };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '设置图标失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
|
||||
@@ -25,15 +25,33 @@ export interface User {
|
||||
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
|
||||
const DEFAULT_ADMIN_PASSWORD = 'wechat';
|
||||
|
||||
// v1.2.0:实例可承载多种应用(不止微信)。同一镜像运行时按 appType 安装/启动对应应用。
|
||||
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
|
||||
export const APP_TYPES: AppType[] = ['wechat', 'telegram', 'chromium', 'custom'];
|
||||
export const APP_LABELS: Record<AppType, string> = {
|
||||
wechat: '微信',
|
||||
telegram: 'Telegram',
|
||||
chromium: '浏览器',
|
||||
custom: '自定义应用',
|
||||
};
|
||||
// 向后兼容:v1.2.0 之前创建的实例没有 appType 字段,一律视为微信。
|
||||
export function instanceAppType(i: Instance): AppType {
|
||||
return i.appType && APP_TYPES.includes(i.appType) ? i.appType : 'wechat';
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
id: string; // 短 id,用于容器/卷命名
|
||||
name: string; // 显示名
|
||||
appType?: AppType; // 承载的应用类型;缺省(老实例)= wechat(见 instanceAppType)
|
||||
icon?: string; // 自定义图标:data: 图片(base64) 或 builtin:<key>;缺省按 appType 取默认图标
|
||||
containerName: string; // woc-wx-<id>
|
||||
volumeName: string; // woc-data-<id>
|
||||
kasmUser: string; // 随机生成,服务端注入反代,永不下发前端
|
||||
kasmPassword: string;
|
||||
createdAt: string;
|
||||
createdBy: string; // userId
|
||||
// 自定义应用(appType=custom):用户上传的安装包信息,autostart 据此启动。
|
||||
customLaunch?: string; // 启动命令(容器内绝对路径或命令)
|
||||
// 自愈 watchdog 的"安全阀",per-instance 覆盖全局默认;缺省时使用 env / 内置默认。
|
||||
// soft:内存超此值时,仅在"当前没有用户在远程会话"才主动重启(柔和自愈);
|
||||
// hard:内存超此值时,无论是否有人在会话都重启(防止 OOM 拖垮宿主)。
|
||||
@@ -198,6 +216,8 @@ export function publicInstance(i: Instance) {
|
||||
return {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
appType: instanceAppType(i), // 老实例无字段时回退 wechat
|
||||
icon: i.icon,
|
||||
createdAt: i.createdAt,
|
||||
createdBy: i.createdBy,
|
||||
memSoftLimitMB: i.memSoftLimitMB,
|
||||
@@ -263,7 +283,9 @@ export function createInstance(
|
||||
createdBy: string,
|
||||
allowedUserIds: string[] = [],
|
||||
reuseVolumeName?: string,
|
||||
appType: AppType = 'wechat',
|
||||
) {
|
||||
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
|
||||
let id = randomBytes(5).toString('hex'); // 10 hex chars
|
||||
let volumeName = `woc-data-${id}`;
|
||||
if (reuseVolumeName) {
|
||||
@@ -275,7 +297,8 @@ export function createInstance(
|
||||
}
|
||||
const inst: Instance = {
|
||||
id,
|
||||
name: name.trim() || `微信-${id.slice(0, 4)}`,
|
||||
name: name.trim() || `${APP_LABELS[type]}-${id.slice(0, 4)}`,
|
||||
appType: type,
|
||||
containerName: `woc-wx-${id}`,
|
||||
volumeName,
|
||||
kasmUser: 'woc',
|
||||
@@ -307,6 +330,23 @@ export function renameInstance(id: string, name: string) {
|
||||
return publicInstance(inst);
|
||||
}
|
||||
|
||||
// 设置/清除实例自定义图标。传空 → 恢复按 appType 的默认图标。
|
||||
// 仅允许 builtin:<key> 或 data:image/...(裁剪后约 128px,限 ~225KB,防滥用撑大 accounts.json)。
|
||||
export function setInstanceIcon(id: string, icon: string | null) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
const v = (icon ?? '').trim();
|
||||
if (!v) {
|
||||
delete inst.icon;
|
||||
} else if (/^builtin:[a-z0-9_-]{1,32}$/.test(v) || (v.startsWith('data:image/') && v.length <= 300000)) {
|
||||
inst.icon = v;
|
||||
} else {
|
||||
throw new Error('图标格式不合法或过大');
|
||||
}
|
||||
persist();
|
||||
return publicInstance(inst);
|
||||
}
|
||||
|
||||
export function removeInstance(id: string) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
|
||||
Generated
+20
@@ -8,6 +8,7 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4515,6 +4516,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -4735,6 +4742,19 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
|
||||
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AppType } from './api';
|
||||
|
||||
// 实例图标。支持三种来源(优先级从高到低):
|
||||
// 1) 自定义上传/裁剪的图片 → inst.icon = "data:image/...;base64,..."
|
||||
// 2) 内置图标 → inst.icon = "builtin:<key>"(如 builtin:xiaohongshu)
|
||||
// 3) 缺省:按 appType 给默认图标(微信 / Chromium / Telegram / 通用)
|
||||
// 内置图标用简洁 SVG(彩色圆角块 + 白色字形),风格统一、无需联网抓取。后续可往 BUILTIN 里加更多平台。
|
||||
|
||||
type Glyph = { bg: string; el: JSX.Element };
|
||||
const G = (bg: string, el: JSX.Element): Glyph => ({ bg, el });
|
||||
|
||||
// 白色字形(viewBox 0 0 48 48,置于彩色圆角块上)
|
||||
const chat = (
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M19 12c-6.6 0-12 4.2-12 9.5 0 3 1.8 5.7 4.6 7.4l-1.1 3.9 4.4-2.3c1.3.3 2.7.5 4.1.5 6.6 0 12-4.2 12-9.5S25.6 12 19 12zm-4 8.2a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2zm8 0a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2z"
|
||||
/>
|
||||
);
|
||||
const globe = (
|
||||
<g fill="none" stroke="#fff" strokeWidth="2.4">
|
||||
<circle cx="24" cy="24" r="13" />
|
||||
<ellipse cx="24" cy="24" rx="5.5" ry="13" />
|
||||
<path d="M11.5 20h25M11.5 28h25" />
|
||||
</g>
|
||||
);
|
||||
const plane = <path fill="#fff" d="M35 14L13 23.2l6.1 2.2 2.3 7.2 3.3-3.9 5.6 4.1L35 14zm-12.4 12.6l9-7.2-6.7 8.1-.1 3.6-2.2-4.5z" />;
|
||||
const dots = (
|
||||
<g fill="#fff">
|
||||
<circle cx="16" cy="24" r="2.6" />
|
||||
<circle cx="24" cy="24" r="2.6" />
|
||||
<circle cx="32" cy="24" r="2.6" />
|
||||
</g>
|
||||
);
|
||||
|
||||
// 文字字形(品牌色块 + 白字),用于没有简单标志的平台
|
||||
const txt = (s: string, fs = 22) => (
|
||||
<text x="24" y="25" fill="#fff" fontSize={fs} fontWeight="700" textAnchor="middle" dominantBaseline="central" fontFamily="-apple-system, system-ui, sans-serif">
|
||||
{s}
|
||||
</text>
|
||||
);
|
||||
const play = <path fill="#fff" d="M20 17l12 7-12 7z" />;
|
||||
|
||||
// key → 字形。default-by-appType 与「内置图标选择器」共用同一张表。
|
||||
export const BUILTIN_ICONS: Record<string, Glyph> = {
|
||||
wechat: G('#07c160', chat),
|
||||
chromium: G('#4285f4', globe),
|
||||
telegram: G('#2aabee', plane),
|
||||
xiaohongshu: G('#ff2442', txt('书')),
|
||||
douyin: G('#111111', txt('抖')),
|
||||
bilibili: G('#fb7299', txt('B', 26)),
|
||||
weibo: G('#e6162d', txt('微')),
|
||||
zhihu: G('#0084ff', txt('知')),
|
||||
youtube: G('#ff0000', play),
|
||||
globe: G('#5b8def', globe),
|
||||
app: G('#8a9099', dots),
|
||||
};
|
||||
// 「内置图标」选择器里展示的可选项(顺序即展示顺序)
|
||||
export const ICON_CHOICES: { key: string; label: string }[] = [
|
||||
{ key: 'wechat', label: '微信' },
|
||||
{ key: 'chromium', label: 'Chromium' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'xiaohongshu', label: '小红书' },
|
||||
{ key: 'douyin', label: '抖音' },
|
||||
{ key: 'bilibili', label: 'B站' },
|
||||
{ key: 'weibo', label: '微博' },
|
||||
{ key: 'zhihu', label: '知乎' },
|
||||
{ key: 'youtube', label: 'YouTube' },
|
||||
{ key: 'globe', label: '通用' },
|
||||
];
|
||||
const DEFAULT_BY_APP: Record<AppType, string> = {
|
||||
wechat: 'wechat',
|
||||
chromium: 'chromium',
|
||||
telegram: 'telegram',
|
||||
custom: 'app',
|
||||
};
|
||||
|
||||
export function InstanceIcon({
|
||||
icon,
|
||||
appType,
|
||||
size = 36,
|
||||
radius = 12,
|
||||
}: {
|
||||
icon?: string;
|
||||
appType?: AppType;
|
||||
size?: number;
|
||||
radius?: number;
|
||||
}) {
|
||||
// 1) 自定义图片
|
||||
if (icon && icon.startsWith('data:')) {
|
||||
return <img src={icon} width={size} height={size} alt="" style={{ borderRadius: radius, objectFit: 'cover', display: 'block' }} />;
|
||||
}
|
||||
// 2) 内置 / 3) 默认
|
||||
const key = icon && icon.startsWith('builtin:') ? icon.slice(8) : DEFAULT_BY_APP[appType ?? 'wechat'] ?? 'app';
|
||||
const g = BUILTIN_ICONS[key] ?? BUILTIN_ICONS.app;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" style={{ display: 'block' }} aria-hidden="true">
|
||||
<rect width="48" height="48" rx={(radius / size) * 48} fill={g.bg} />
|
||||
{g.el}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+14
-10
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './auth';
|
||||
import { useUI, PasswordInput } from './ui';
|
||||
import { api, type InstanceWithStatus } from './api';
|
||||
import { api, appProfile, type InstanceWithStatus } from './api';
|
||||
import { InstanceIcon } from './AppIcon';
|
||||
import InstanceView from './pages/Desktop';
|
||||
import Admin from './pages/Admin';
|
||||
|
||||
@@ -175,7 +176,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className="sb-section">微信实例</div>}
|
||||
{!collapsed && <div className="sb-section">实例</div>}
|
||||
<div className="sb-list">
|
||||
{instances.length === 0 && !collapsed && <div className="sb-empty">暂无可用实例</div>}
|
||||
{instances.map((inst) => {
|
||||
@@ -184,7 +185,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
return (
|
||||
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
|
||||
<span className="sb-avatar">
|
||||
{inst.name.slice(0, 1)}
|
||||
<InstanceIcon icon={inst.icon} appType={inst.appType} size={34} radius={10} />
|
||||
<span className={'sb-dot ' + st.cls} />
|
||||
</span>
|
||||
{!collapsed && <span className="sb-label">{inst.name}</span>}
|
||||
@@ -252,7 +253,7 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
|
||||
)}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">我的微信实例</span>
|
||||
<span className="section-title">我的实例</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => nav('/admin')}>
|
||||
管理 ›
|
||||
@@ -265,21 +266,24 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
|
||||
<div className="empty-blob">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
</div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
<div className="empty-title">还没有实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => {
|
||||
const st = statusOf(inst);
|
||||
const prof = appProfile(inst.appType);
|
||||
const meta = inst.wechat.installed
|
||||
? `微信 ${inst.wechat.version || ''}`.trim()
|
||||
: inst.runtime === 'running'
|
||||
? '待下载安装微信'
|
||||
? `${prof.label} ${inst.wechat.version || ''}`.trim()
|
||||
: inst.runtime === 'running' && prof.needsInstall
|
||||
? `待下载安装${prof.label}`
|
||||
: '';
|
||||
return (
|
||||
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
|
||||
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
|
||||
<span className="home-card-av">
|
||||
<InstanceIcon icon={inst.icon} appType={inst.appType} size={42} radius={12} />
|
||||
</span>
|
||||
<span className="home-card-main">
|
||||
<span className="home-card-name">{inst.name}</span>
|
||||
<span className="home-card-meta">
|
||||
|
||||
+31
-2
@@ -19,9 +19,36 @@ export interface WechatStatus {
|
||||
}
|
||||
|
||||
export type RuntimeState = 'running' | 'stopped' | 'missing';
|
||||
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
|
||||
export const APP_LABELS: Record<AppType, string> = {
|
||||
wechat: '微信',
|
||||
telegram: 'Telegram',
|
||||
chromium: 'Chromium',
|
||||
custom: '自定义应用',
|
||||
};
|
||||
|
||||
// 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。
|
||||
// needsInstall: 是否需要运行时下载安装(微信/Telegram 是;Chromium 已烤进镜像、即创建即就绪)。
|
||||
// enterHint: 首次进入实例的提示。
|
||||
// updateLabel: 「管理」菜单里的更新按钮文案(needsInstall=false 时不显示)。
|
||||
export interface AppProfile {
|
||||
label: string;
|
||||
needsInstall: boolean;
|
||||
enterHint: string;
|
||||
updateLabel: string;
|
||||
}
|
||||
export const APP_PROFILES: Record<AppType, AppProfile> = {
|
||||
wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' },
|
||||
telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' },
|
||||
chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' },
|
||||
custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' },
|
||||
};
|
||||
export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat;
|
||||
export interface PanelInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
appType?: AppType; // 缺省(老实例)= wechat
|
||||
icon?: string; // 自定义图标:data: 图片 / builtin:<key>;缺省按 appType 取默认图标
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
memSoftLimitMB?: number;
|
||||
@@ -106,10 +133,10 @@ export const api = {
|
||||
|
||||
// 微信实例
|
||||
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
|
||||
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) =>
|
||||
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') =>
|
||||
req<{ instance: PanelInstance }>('/api/admin/instances', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }),
|
||||
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
|
||||
}),
|
||||
regenMachineId: (id: string) =>
|
||||
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
|
||||
@@ -128,6 +155,8 @@ export const api = {
|
||||
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
|
||||
deleteOrphanContainer: (idOrName: string) =>
|
||||
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
|
||||
setInstanceIcon: (id: string, icon: string | null) =>
|
||||
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
deleteInstance: (id: string, purge = false) =>
|
||||
|
||||
+190
-14
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, type PanelUser, type InstanceWithStatus, type VolEntry } from '../api';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
|
||||
import { InstanceIcon, ICON_CHOICES } from '../AppIcon';
|
||||
import { useUI, PasswordInput } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
@@ -95,6 +97,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
|
||||
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
|
||||
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
|
||||
const [iconInst, setIconInst] = useState<InstanceWithStatus | null>(null); // 图标编辑弹窗
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
||||
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
||||
@@ -271,7 +274,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="section-row">
|
||||
<span className="section-title">微信实例</span>
|
||||
<span className="section-title">实例</span>
|
||||
<button className="btn-text" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建实例
|
||||
</button>
|
||||
@@ -279,11 +282,11 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="🖥️"
|
||||
title="还没有微信实例"
|
||||
sub="新建一个实例,进入后扫码登录即可在浏览器里用微信"
|
||||
title="还没有实例"
|
||||
sub="新建一个实例(微信 / Chromium 浏览器),进入后即可在浏览器里使用"
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建微信实例
|
||||
+ 新建实例
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -306,6 +309,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
onSecurity={() => setSecurityInst(inst)}
|
||||
onVolume={() => setVolumeInst(inst)}
|
||||
onIcon={() => setIconInst(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -531,6 +535,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
{volumeInst && (
|
||||
<VolumeManager inst={volumeInst} onClose={() => setVolumeInst(null)} onChanged={load} />
|
||||
)}
|
||||
{iconInst && (
|
||||
<InstanceIconEditor
|
||||
inst={iconInst}
|
||||
onClose={() => setIconInst(null)}
|
||||
onDone={() => {
|
||||
toast('已更新图标', 'ok');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -866,6 +880,7 @@ function InstanceAdminCard({
|
||||
onDelete,
|
||||
onSecurity,
|
||||
onVolume,
|
||||
onIcon,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
@@ -881,6 +896,7 @@ function InstanceAdminCard({
|
||||
onDelete: () => void;
|
||||
onSecurity: () => void;
|
||||
onVolume: () => void;
|
||||
onIcon: () => void;
|
||||
}) {
|
||||
const wx = inst.wechat;
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
@@ -899,6 +915,8 @@ function InstanceAdminCard({
|
||||
return () => document.removeEventListener('mousedown', onDocDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
const profile = appProfile(inst.appType);
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
@@ -911,8 +929,8 @@ function InstanceAdminCard({
|
||||
else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
|
||||
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
|
||||
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
|
||||
else sub = '微信尚未安装';
|
||||
else if (installed) sub = wx.version ? `${profile.label} ${wx.version}` : `${profile.label}已就绪`;
|
||||
else sub = `${profile.label}尚未安装`;
|
||||
|
||||
return (
|
||||
<div className={'inst-card' + (menuOpen ? ' open-menu' : '')}>
|
||||
@@ -943,7 +961,7 @@ function InstanceAdminCard({
|
||||
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装' + profile.label}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
@@ -960,9 +978,9 @@ function InstanceAdminCard({
|
||||
<div className="inst-menu-group">
|
||||
<div className="inst-menu-label">运维</div>
|
||||
<div className="inst-menu-items">
|
||||
{!offline && (
|
||||
{!offline && profile.needsInstall && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
{installed ? profile.updateLabel : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
|
||||
@@ -995,6 +1013,9 @@ function InstanceAdminCard({
|
||||
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
|
||||
安全
|
||||
</button>
|
||||
<button className="btn-text" onClick={onIcon} title="设置实例图标:内置图标 / 上传图片裁剪">
|
||||
图标
|
||||
</button>
|
||||
<button className="btn-text" onClick={onVolume} title="数据卷:备份/恢复、上传 PC 微信数据、文件管理">
|
||||
数据卷
|
||||
</button>
|
||||
@@ -1016,6 +1037,132 @@ function InstanceAdminCard({
|
||||
);
|
||||
}
|
||||
|
||||
// 把裁剪区域画到 128px 画布并导出 PNG dataURL(存进 inst.icon)
|
||||
async function cropToDataUrl(src: string, area: { x: number; y: number; width: number; height: number }): Promise<string> {
|
||||
const img = await new Promise<HTMLImageElement>((res, rej) => {
|
||||
const i = new Image();
|
||||
i.onload = () => res(i);
|
||||
i.onerror = rej;
|
||||
i.src = src;
|
||||
});
|
||||
const SIZE = 128;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SIZE;
|
||||
c.height = SIZE;
|
||||
c.getContext('2d')!.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, SIZE, SIZE);
|
||||
return c.toDataURL('image/png');
|
||||
}
|
||||
|
||||
// 实例图标编辑:选内置图标 / 上传图片裁剪 / 恢复默认。
|
||||
function InstanceIconEditor({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
|
||||
const { toast } = useUI();
|
||||
const [sel, setSel] = useState<string>(inst.icon || ''); // '' = 按应用默认
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [cropSrc, setCropSrc] = useState(''); // 非空 = 裁剪态
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [area, setArea] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!f) return;
|
||||
if (!f.type.startsWith('image/')) return toast('请选择图片文件', 'error');
|
||||
if (f.size > 8 * 1024 * 1024) return toast('图片过大(>8MB)', 'error');
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
setCropSrc(String(r.result));
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
};
|
||||
r.readAsDataURL(f);
|
||||
};
|
||||
|
||||
const confirmCrop = async () => {
|
||||
if (!cropSrc || !area) return;
|
||||
try {
|
||||
setSel(await cropToDataUrl(cropSrc, area));
|
||||
setCropSrc('');
|
||||
} catch {
|
||||
toast('裁剪失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setInstanceIcon(inst.id, sel || null);
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
toast(e?.message || '保存失败', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 460 }}>
|
||||
<h2>图标 · {inst.name}</h2>
|
||||
{cropSrc ? (
|
||||
<>
|
||||
<div className="icon-crop">
|
||||
<Cropper
|
||||
image={cropSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, a) => setArea(a)}
|
||||
/>
|
||||
</div>
|
||||
<input type="range" min={1} max={3} step={0.01} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={() => setCropSrc('')}>返回</button>
|
||||
<button type="button" className="btn btn-primary" onClick={confirmCrop}>裁剪并使用</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="icon-edit-top">
|
||||
<InstanceIcon icon={sel || undefined} appType={inst.appType} size={56} radius={14} />
|
||||
<div className="muted small">预览({sel.startsWith('data:') ? '自定义图片' : sel.startsWith('builtin:') ? '内置图标' : '按应用默认'})</div>
|
||||
</div>
|
||||
<div className="field-label">内置图标</div>
|
||||
<div className="icon-grid">
|
||||
<button type="button" className={'icon-pick' + (sel === '' ? ' sel' : '')} onClick={() => setSel('')}>
|
||||
<InstanceIcon appType={inst.appType} size={38} radius={11} />
|
||||
<span>默认</span>
|
||||
</button>
|
||||
{ICON_CHOICES.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.key}
|
||||
className={'icon-pick' + (sel === `builtin:${c.key}` ? ' sel' : '')}
|
||||
onClick={() => setSel(`builtin:${c.key}`)}
|
||||
>
|
||||
<InstanceIcon icon={`builtin:${c.key}`} size={38} radius={11} />
|
||||
<span>{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={() => fileRef.current?.click()}>上传图片并裁剪…</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPickFile} />
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose} disabled={busy}>取消</button>
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>保存</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 数据卷管理(仅管理员):整卷备份/恢复 + 文件浏览器(浏览/上传/解压/下载/改名/移动/删除)。
|
||||
// 主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。全程在「运行中」的实例上操作
|
||||
// (浏览/改名/删除靠 docker exec,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。
|
||||
@@ -1367,8 +1514,16 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta
|
||||
);
|
||||
}
|
||||
|
||||
// 可创建的应用类型。ready=false 的暂时禁用(即将支持)。Telegram(仅 x86_64)与其它应用暂缓。
|
||||
const APP_OPTIONS: { type: AppType; desc: string; ready: boolean }[] = [
|
||||
{ type: 'wechat', desc: '默认', ready: true },
|
||||
{ type: 'chromium', desc: '浏览器', ready: true },
|
||||
{ type: 'custom', desc: '即将支持', ready: false },
|
||||
];
|
||||
|
||||
function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: () => void; onDone: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [appType, setAppType] = useState<AppType>('wechat');
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -1394,7 +1549,7 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
setErr('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createInstance(name.trim(), [...sel], reuse || undefined);
|
||||
await api.createInstance(name.trim(), [...sel], reuse || undefined, appType);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '创建失败');
|
||||
@@ -1406,8 +1561,27 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>新建微信实例</h2>
|
||||
<input className="input" placeholder="实例名称(如:我的微信 / 公司号)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<h2>新建实例</h2>
|
||||
<div className="field-label">应用类型</div>
|
||||
<div className="app-picker">
|
||||
{APP_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.type}
|
||||
type="button"
|
||||
className={'app-pick' + (appType === o.type ? ' sel' : '')}
|
||||
disabled={!o.ready}
|
||||
title={o.ready ? '' : '即将支持'}
|
||||
onClick={() => o.ready && setAppType(o.type)}
|
||||
>
|
||||
<span className="app-pick-name">{APP_LABELS[o.type]}</span>
|
||||
<span className="app-pick-desc">{o.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="input" placeholder="实例名称(留空自动命名)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{appType === 'chromium' && (
|
||||
<div className="muted small">Chromium 浏览器随镜像就绪,创建后直接「进入实例」即可(无需下载安装)。</div>
|
||||
)}
|
||||
<div className="field-label">允许访问的子账号(管理员默认可访问全部)</div>
|
||||
<ChipMultiSelect
|
||||
options={subs.map((u) => ({ id: u.id, label: u.username }))}
|
||||
@@ -1433,7 +1607,9 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
</>
|
||||
)}
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="muted small" style={{ marginTop: 4 }}>创建后会拉起一个新的微信容器,进入后扫码登录。</div>
|
||||
<div className="muted small" style={{ marginTop: 4 }}>
|
||||
创建后拉起一个新的 {APP_LABELS[appType]} 容器;进入实例后点「下载并安装」,再登录即可。
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { api, appProfile } from '../api';
|
||||
import { useUI } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { useInstances } from '../AppShell';
|
||||
@@ -150,6 +150,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const audioRef = useRef<VncAudio | null>(null);
|
||||
|
||||
const inst = instances.find((i) => i.id === id);
|
||||
const profile = appProfile(inst?.appType); // 按应用类型显示正确文案(微信/Chromium…)
|
||||
const appLabel = profile.label;
|
||||
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
|
||||
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
|
||||
const [probing, setProbing] = useState(true);
|
||||
@@ -366,7 +368,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
setUploading(false);
|
||||
if (ok) {
|
||||
toast(`已上传 ${ok} 个文件到桌面,微信里可直接选取`, 'ok');
|
||||
toast(`已上传 ${ok} 个文件到桌面,应用里可直接取用`, 'ok');
|
||||
refreshFiles();
|
||||
}
|
||||
};
|
||||
@@ -379,7 +381,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
};
|
||||
|
||||
const delFile = async (name: string) => {
|
||||
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
|
||||
if (!(await confirm({ title: `删除「${name}」?`, body: '将从桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
|
||||
try {
|
||||
await api.deleteFile(id, name);
|
||||
toast('已删除', 'ok');
|
||||
@@ -445,7 +447,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
return;
|
||||
}
|
||||
if (pushClipboardToRemote(t)) {
|
||||
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
|
||||
toast('已发送到容器剪贴板,请在应用输入框按 Ctrl+V 粘贴', 'ok');
|
||||
} else {
|
||||
toast('发送失败:桌面尚未连接', 'error');
|
||||
}
|
||||
@@ -486,7 +488,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const restartInstance = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重启该实例?',
|
||||
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
|
||||
body: `会重建容器(数据保留),${appLabel}重新启动,约十几秒;用于修复卡死/最小化丢失等。`,
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
@@ -525,7 +527,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
const title = inst?.name || '微信实例';
|
||||
const title = inst?.name || '实例';
|
||||
|
||||
return (
|
||||
<div className="ws-page">
|
||||
@@ -550,8 +552,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
className={'ws-action' + (inputMode === 'seamless' ? ' on' : '')}
|
||||
title={
|
||||
inputMode === 'seamless'
|
||||
? '无感输入:直接在微信输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
|
||||
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在微信里打)'
|
||||
? '无感输入:直接在应用输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
|
||||
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在应用里打)'
|
||||
}
|
||||
onClick={() => setMode(inputMode === 'seamless' ? 'forward' : 'seamless')}
|
||||
>
|
||||
@@ -609,7 +611,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="spinner" />
|
||||
<div className="iv-notice-title">微信安装中…</div>
|
||||
<div className="iv-notice-title">{appLabel}安装中…</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.message || '请稍候'}
|
||||
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''} ——完成后自动进入,无需刷新
|
||||
@@ -619,18 +621,18 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
) : !installed ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
|
||||
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? `${appLabel}安装出错` : `${appLabel}尚未安装`}</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.phase === 'error'
|
||||
? inst.wechat.message || '安装失败,可在「管理」重试'
|
||||
: '该实例容器已就绪,但尚未安装微信'}
|
||||
: `该实例容器已就绪,但尚未安装${appLabel}`}
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
|
||||
去「管理」{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装微信</div>
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装{appLabel}</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
|
||||
@@ -647,7 +649,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
ref={frameRef}
|
||||
className="iv-frame"
|
||||
src={desktopUrl(id)}
|
||||
title="电脑版微信"
|
||||
title={`${appLabel} · 实例桌面`}
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
@@ -664,7 +666,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="iv-loading">
|
||||
<div className="spinner" />
|
||||
<div className="iv-loading-text">正在连接桌面…</div>
|
||||
<div className="iv-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="iv-loading-sub">{profile.enterHint}</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;声音自动开启,点一下画面即可出声</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="iv-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
@@ -703,8 +705,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="drop-card">
|
||||
<div className="drop-icon">⬇</div>
|
||||
<div className="drop-title">松开上传到微信桌面</div>
|
||||
<div className="drop-sub">上传后在微信里「+ / 文件」选择即可</div>
|
||||
<div className="drop-title">松开上传到桌面</div>
|
||||
<div className="drop-sub">上传后在应用里「+ / 文件」选择即可</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -742,7 +744,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
|
||||
{uploading ? '上传中…' : '+ 选择文件上传'}
|
||||
</button>
|
||||
<div className="files-hint">也可直接把文件拖进来。下方为桌面(~/Desktop)里的文件,微信收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-hint">也可直接把文件拖进来。下方为桌面(~/Desktop)里的文件,应用收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-list">
|
||||
{files.length === 0 && (
|
||||
<div className="muted small" style={{ padding: '10px 2px' }}>
|
||||
@@ -776,17 +778,17 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
className="clip-area"
|
||||
value={clipText}
|
||||
onChange={(e) => setClipText(e.target.value)}
|
||||
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
|
||||
placeholder="在此输入或粘贴文本,点「发送到剪贴板」后到应用输入框按 Ctrl+V 粘贴"
|
||||
rows={5}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" onClick={sendClip}>
|
||||
发送到微信(容器剪贴板)
|
||||
发送到剪贴板
|
||||
</button>
|
||||
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
|
||||
↓ 读取容器剪贴板到此框
|
||||
</button>
|
||||
<div className="files-hint">
|
||||
局域网 http 访问时浏览器会禁用系统级剪贴板同步,故用此框中转:文本→容器剪贴板,再在微信里 Ctrl+V。
|
||||
局域网 http 访问时浏览器会禁用系统级剪贴板同步,故用此框中转:文本→容器剪贴板,再在应用里 Ctrl+V。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -804,7 +806,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
sendImeText();
|
||||
}
|
||||
}}
|
||||
placeholder="中文输入这里 → 回车送进微信(先点好微信的输入框)。Shift+回车换行。"
|
||||
placeholder="中文输入这里 → 回车送进应用(先点好应用的输入框)。Shift+回车换行。"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
|
||||
+89
-18
@@ -1397,15 +1397,6 @@ button {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sb-dot {
|
||||
position: absolute;
|
||||
@@ -1562,15 +1553,6 @@ button {
|
||||
flex: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.home-card-main {
|
||||
position: relative;
|
||||
@@ -2041,3 +2023,92 @@ button {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 新建实例·应用类型选择 ─────────────────────────────── */
|
||||
.app-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.app-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 10px 6px;
|
||||
border: none;
|
||||
border-radius: var(--r-small);
|
||||
background: var(--trough);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.app-pick:hover:not(:disabled):not(.sel) {
|
||||
background: var(--base);
|
||||
}
|
||||
.app-pick.sel {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-accent);
|
||||
}
|
||||
.app-pick.sel .app-pick-name {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.app-pick:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.app-pick-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-pick-desc {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── 实例图标编辑 ─────────────────────────────────────── */
|
||||
.icon-edit-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.icon-crop {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: var(--r-small);
|
||||
overflow: hidden;
|
||||
background: #1a1d24;
|
||||
}
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: none;
|
||||
border-radius: var(--r-small);
|
||||
background: var(--trough);
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||
}
|
||||
.icon-pick span {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.icon-pick.sel {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-accent);
|
||||
}
|
||||
.icon-pick.sel span {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.icon-pick:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user