mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 03:23:32 +08:00
feat(panel): 桌面壁纸与字体管理
- 上传/应用/清除壁纸,缩略图预览 - 上传/安装/删除字体,fc-cache 刷新 - fontconfig 别名配置 + xsettingsd 实时生效 - 移除 hook-fontconfig,改为重启提示 - 切换 tsinghua 安全更新源 - 安装 xwallpaper/imagemagick/xsettingsd 依赖
This commit is contained in:
@@ -15,7 +15,7 @@ RUN set -eux; \
|
||||
'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware' \
|
||||
'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware' \
|
||||
'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware' \
|
||||
'deb https://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware' \
|
||||
'deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware' \
|
||||
> /etc/apt/sources.list
|
||||
|
||||
RUN set -eux; \
|
||||
@@ -27,7 +27,8 @@ RUN set -eux; \
|
||||
libnss3 libgbm1 libasound2 libxss1 \
|
||||
xz-utils \
|
||||
chromium \
|
||||
xdotool xclip; \
|
||||
xdotool xclip \
|
||||
xwallpaper imagemagick xsettingsd; \
|
||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
apt-get clean; \
|
||||
@@ -62,6 +63,11 @@ RUN set -eux; \
|
||||
apt-get clean; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 预建 fontconfig 目录 + 用户字体目录 symlink(持久化到 /config/.fonts)
|
||||
RUN mkdir -p /home/abc/.config/fontconfig /config/.fonts /home/abc/.local/share && \
|
||||
ln -sfn /config/.fonts /home/abc/.local/share/fonts && \
|
||||
chown -R 1000:1000 /home/abc/.config /home/abc/.local
|
||||
|
||||
ENV LANG=zh_CN.UTF-8 \
|
||||
LC_ALL=zh_CN.UTF-8 \
|
||||
LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
102
docker/autostart
102
docker/autostart
@@ -47,7 +47,107 @@ if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1) 等待应用安装就绪(首次需在面板点「下载并安装」)
|
||||
# 1) 应用壁纸(若用户设置了自定义背景图)
|
||||
WALLPAPER_FILE="/config/.wallpaper"
|
||||
WALLPAPER_DIR="/config/backgrounds"
|
||||
if [ -f "$WALLPAPER_FILE" ]; then
|
||||
wp=$(cat "$WALLPAPER_FILE")
|
||||
if [ -n "$wp" ] && [ -f "$WALLPAPER_DIR/$wp" ]; then
|
||||
export DISPLAY="${DISPLAY:-:1}"
|
||||
xwallpaper --zoom "$WALLPAPER_DIR/$wp" 2>/dev/null && \
|
||||
echo "[autostart] 壁纸已应用: $wp"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2) 应用自定义字体(若用户设置了)
|
||||
# 从持久卷恢复系统级 fontconfig 配置(跨越容器重建)
|
||||
if [ -f /config/.woc-fc-local.conf ]; then
|
||||
cp /config/.woc-fc-local.conf /etc/fonts/local.conf
|
||||
echo "[autostart] 系统字体配置已恢复"
|
||||
fi
|
||||
# 确保 ~/.local/share/fonts → /config/.fonts symlink 存在(旧容器升级兼容)
|
||||
mkdir -p /config/.fonts 2>/dev/null || true
|
||||
if [ ! -L /home/abc/.local/share/fonts ] && [ ! -d /home/abc/.local/share/fonts ]; then
|
||||
mkdir -p /home/abc/.local/share 2>/dev/null || true
|
||||
ln -sfn /config/.fonts /home/abc/.local/share/fonts 2>/dev/null || true
|
||||
fi
|
||||
FONT_CONF="/home/abc/.config/fontconfig/fonts.conf"
|
||||
FONT_FAMILY_FILE="/config/.woc-font-family"
|
||||
if [ -f "$FONT_FAMILY_FILE" ]; then
|
||||
family=$(cat "$FONT_FAMILY_FILE")
|
||||
if [ -n "$family" ]; then
|
||||
mkdir -p "$(dirname "$FONT_CONF")" 2>/dev/null || true
|
||||
cat > "$FONT_CONF" << EOF
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
||||
<fontconfig>
|
||||
<!-- generic families -->
|
||||
<alias>
|
||||
<family>sans-serif</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>serif</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>monospace</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<!-- system CJK fonts that WeChat/CEF may request -->
|
||||
<alias>
|
||||
<family>WenQuanYi Micro Hei</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>WenQuanYi Zen Hei</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>Noto Sans CJK SC</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>Noto Sans CJK</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<!-- force user font for any zh text regardless of requested family -->
|
||||
<match target="pattern">
|
||||
<test name="lang" compare="contains"><string>zh</string></test>
|
||||
<edit name="family" mode="prepend" binding="strong"><string>${family}</string></edit>
|
||||
</match>
|
||||
</fontconfig>
|
||||
EOF
|
||||
echo "[autostart] 自定义字体已恢复: ${family}"
|
||||
fc-cache -f 2>/dev/null || true
|
||||
fi
|
||||
elif [ -f "$FONT_CONF" ]; then
|
||||
echo "[autostart] 自定义字体配置已就绪"
|
||||
fc-cache -f 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3) 启动 xsettingsd(让 GTK/Qt 应用实时响应字体/DPI 等 XSETTINGS 变更,无需重启)
|
||||
XSETTINGS_CONF="/home/abc/.xsettingsd"
|
||||
if command -v xsettingsd >/dev/null 2>&1; then
|
||||
XSETTINGS_FONT="WenQuanYi Micro Hei"
|
||||
# 若用户保存了字体偏好,优先使用
|
||||
if [ -f "$FONT_FAMILY_FILE" ]; then
|
||||
family=$(cat "$FONT_FAMILY_FILE")
|
||||
[ -n "$family" ] && XSETTINGS_FONT="$family"
|
||||
fi
|
||||
cat > "${XSETTINGS_CONF}" << EOF
|
||||
Xft/Antialias 1
|
||||
Xft/Hinting 1
|
||||
Xft/HintStyle "hintslight"
|
||||
Xft/RGBA "rgb"
|
||||
Xft/DPI 96
|
||||
Gtk/FontName "${XSETTINGS_FONT} 10"
|
||||
EOF
|
||||
xsettingsd --config="${XSETTINGS_CONF}" 2>/dev/null &
|
||||
echo "[autostart] xsettingsd 已启动"
|
||||
fi
|
||||
|
||||
# 4) 等待应用安装就绪(首次需在面板点「下载并安装」)
|
||||
notified=0
|
||||
while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
|
||||
if [ "${notified}" -eq 0 ]; then
|
||||
|
||||
@@ -212,6 +212,8 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await container.start();
|
||||
appendInstanceLog(inst.id, '容器已启动');
|
||||
// 容器重建后恢复持久化的字体配置 / xsettingsd
|
||||
restoreFontFromVolume(inst).catch(() => {});
|
||||
} catch (e) {
|
||||
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
|
||||
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
|
||||
@@ -433,9 +435,9 @@ async function execCreate(c: any, opts: any): Promise<any> {
|
||||
}
|
||||
|
||||
// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方。
|
||||
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
|
||||
async function execCapture(inst: Instance, cmd: string[], user = 'abc'): Promise<string> {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const exec = await execCreate(c, { Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
|
||||
const exec = await execCreate(c, { Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: user });
|
||||
const stream = await exec.start({ hijack: true, stdin: false });
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let out = '';
|
||||
@@ -939,6 +941,202 @@ export async function volRestoreArchive(inst: Instance, archive: Buffer): Promis
|
||||
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: '/' });
|
||||
}
|
||||
|
||||
// ---------- 桌面壁纸 ----------
|
||||
const BG_DIR = '/config/backgrounds';
|
||||
const WP_FILE = '/config/.wallpaper';
|
||||
|
||||
export async function listBackgrounds(inst: Instance): Promise<string[]> {
|
||||
try {
|
||||
const out = await execCapture(inst, ['sh', '-c', `ls -1 ${BG_DIR} 2>/dev/null || true`]);
|
||||
return out.split('\n').filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getBackgroundImage(inst: Instance, name: string, thumb = false): Promise<Buffer> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
let path = `${BG_DIR}/${name}`;
|
||||
if (thumb) {
|
||||
const tmp = `/tmp/.woc-thumb-${name}`;
|
||||
try {
|
||||
await execCapture(inst, ['sh', '-c', `convert '${BG_DIR}/${name}' -resize 400x '${tmp}'`], 'root');
|
||||
path = tmp;
|
||||
} catch {
|
||||
// convert 不可用或失败,回退原始图
|
||||
}
|
||||
}
|
||||
const stream = (await docker.getContainer(inst.containerName).getArchive({ path })) as NodeJS.ReadableStream;
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', (d: Buffer) => chunks.push(d));
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const buf = extractSingleFileFromTar(Buffer.concat(chunks));
|
||||
if (thumb) {
|
||||
// 清理临时缩略图
|
||||
execCapture(inst, ['rm', '-f', `/tmp/.woc-thumb-${name}`], 'root').catch(() => {});
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export async function uploadBackground(inst: Instance, name: string, content: Buffer): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
await execCapture(inst, ['mkdir', '-p', BG_DIR]);
|
||||
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: BG_DIR });
|
||||
}
|
||||
|
||||
export async function applyBackground(inst: Instance, name: string): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
await execCapture(inst, ['sh', '-c', `DISPLAY=:1 xwallpaper --zoom '${BG_DIR}/${name}' 2>/dev/null`]);
|
||||
await execCapture(inst, ['sh', '-c', `echo '${name}' > '${WP_FILE}'`]);
|
||||
}
|
||||
|
||||
export async function deleteBackground(inst: Instance, name: string): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
await execCapture(inst, ['rm', '-f', `${BG_DIR}/${name}`]);
|
||||
await execCapture(inst, ['sh', '-c', `if [ -f '${WP_FILE}' ] && [ "$(cat '${WP_FILE}')" = '${name}' ]; then rm -f '${WP_FILE}'; fi`]);
|
||||
}
|
||||
|
||||
export async function getCurrentBackground(inst: Instance): Promise<string> {
|
||||
try {
|
||||
const out = await execCapture(inst, ['sh', '-c', `cat ${WP_FILE} 2>/dev/null || true`]);
|
||||
return out.trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export async function clearBackground(inst: Instance): Promise<void> {
|
||||
await execCapture(inst, ['sh', '-c', 'DISPLAY=:1 xsetroot -solid black 2>/dev/null']);
|
||||
await execCapture(inst, ['rm', '-f', WP_FILE]);
|
||||
}
|
||||
|
||||
// ---------- 字体管理 ----------
|
||||
const FONT_DIR = '/config/.fonts';
|
||||
|
||||
export async function listFonts(inst: Instance): Promise<string[]> {
|
||||
try {
|
||||
const out = await execCapture(inst, ['sh', '-c', `ls -1 ${FONT_DIR} 2>/dev/null || true`]);
|
||||
return out.split('\n').filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function uploadFont(inst: Instance, name: string, content: Buffer): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
await execCapture(inst, ['mkdir', '-p', FONT_DIR]);
|
||||
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: FONT_DIR });
|
||||
await execCapture(inst, ['fc-cache', '-f'], 'root');
|
||||
}
|
||||
|
||||
export async function deleteFont(inst: Instance, name: string): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
await execCapture(inst, ['rm', '-f', `${FONT_DIR}/${name}`]);
|
||||
await execCapture(inst, ['fc-cache', '-f'], 'root');
|
||||
}
|
||||
|
||||
const FONT_SEL_FILE = '/config/.woc-font';
|
||||
|
||||
// 将字体的 fontconfig family name 设为用户首选(fallback 仍用文泉驿等系统字体)。
|
||||
// 设空字符串或 "default" 则清除偏好,回退系统默认。
|
||||
export async function applyFont(inst: Instance, fontFile: string): Promise<void> {
|
||||
if (fontFile && !safeName(fontFile)) throw new Error('文件名不合法');
|
||||
if (fontFile && fontFile !== 'default') {
|
||||
// 用 fc-scan 读取字体实际 family name(取第一个)
|
||||
const out = await execCapture(inst, ['sh', '-c', `fc-scan --format='%{family[0]}' '${FONT_DIR}/${fontFile}' 2>/dev/null`]);
|
||||
const family = out.trim();
|
||||
if (!family) throw new Error('未能识别该字体的 family name');
|
||||
// 写 fontconfig 系统级配置(/etc/fonts/local.conf 保证被读取,用户级可能被 XDG 路径问题跳过)
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
||||
<fontconfig>
|
||||
<!-- generic families -->
|
||||
<alias>
|
||||
<family>sans-serif</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>serif</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>monospace</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<!-- system CJK fonts that WeChat/CEF may request -->
|
||||
<alias>
|
||||
<family>WenQuanYi Micro Hei</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>WenQuanYi Zen Hei</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>Noto Sans CJK SC</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<alias>
|
||||
<family>Noto Sans CJK</family>
|
||||
<prefer><family>${family}</family></prefer>
|
||||
</alias>
|
||||
<!-- force user font for any zh text regardless of requested family -->
|
||||
<match target="pattern">
|
||||
<test name="lang" compare="contains"><string>zh</string></test>
|
||||
<edit name="family" mode="prepend" binding="strong"><string>${family}</string></edit>
|
||||
</match>
|
||||
</fontconfig>`;
|
||||
await execCapture(inst, ['bash', '-c', `cat > /etc/fonts/local.conf << 'CONF'\n${xml}\nCONF`], 'root');
|
||||
execCapture(inst, ['bash', '-c', `cat > /config/.woc-fc-local.conf << 'CONF'\n${xml}\nCONF`], 'root').catch(() => {});
|
||||
await execCapture(inst, ['fc-cache', '-f'], 'root');
|
||||
await execCapture(inst, ['sh', '-c', `echo '${fontFile}' > ${FONT_SEL_FILE}`]);
|
||||
await execCapture(inst, ['bash', '-c', `cat > ${FONT_SEL_FILE}-family << 'FAMILYEOF'\n${family}\nFAMILYEOF`]);
|
||||
// 更新 xsettingsd 配置 → GTK/Qt 应用实时响应
|
||||
applyXsettingsFont(inst, family).catch(() => {});
|
||||
} else {
|
||||
// 清除偏好,回退默认(文泉驿等系统字体)
|
||||
await execCapture(inst, ['rm', '-f', '/etc/fonts/local.conf', '/config/.woc-fc-local.conf'], 'root');
|
||||
await execCapture(inst, ['rm', '-f', '/home/abc/.config/fontconfig/fonts.conf']);
|
||||
await execCapture(inst, ['fc-cache', '-f'], 'root');
|
||||
await execCapture(inst, ['rm', '-f', FONT_SEL_FILE, `${FONT_SEL_FILE}-family`]);
|
||||
applyXsettingsFont(inst, 'WenQuanYi Micro Hei').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function applyXsettingsFont(inst: Instance, family: string): Promise<void> {
|
||||
const conf = '/home/abc/.xsettingsd';
|
||||
const lines = [
|
||||
'Xft/Antialias 1',
|
||||
'Xft/Hinting 1',
|
||||
'Xft/HintStyle "hintslight"',
|
||||
'Xft/RGBA "rgb"',
|
||||
'Xft/DPI 96',
|
||||
`Gtk/FontName "${family} 10"`,
|
||||
];
|
||||
await execCapture(inst, ['sh', '-c', `printf '%s\\n' ${lines.map(l => `'${l}'`).join(' ')} > ${conf}`]);
|
||||
await execCapture(inst, ['sh', '-c', 'pkill -HUP xsettingsd 2>/dev/null || xsettingsd --config=/home/abc/.xsettingsd 2>/dev/null &']);
|
||||
}
|
||||
|
||||
// 返回当前选中的字体文件名,空字符串表示默认
|
||||
export async function getAppliedFont(inst: Instance): Promise<string> {
|
||||
try {
|
||||
const out = await execCapture(inst, ['sh', '-c', `cat ${FONT_SEL_FILE} 2>/dev/null || true`]);
|
||||
return out.trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export async function getFontFamily(inst: Instance, fontFile: string): Promise<string> {
|
||||
try {
|
||||
const out = await execCapture(inst, ['sh', '-c', `fc-scan '${FONT_DIR}/${fontFile}' 2>/dev/null | grep 'family:' | head -1 | cut -d'"' -f2`]);
|
||||
return out.trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// 容器重建后从挂载卷恢复字体配置 & xsettingsd(不依赖 autostart 镜像版本)
|
||||
async function restoreFontFromVolume(inst: Instance): Promise<void> {
|
||||
await execCapture(inst, ['bash', '-c', 'if [ -f /config/.woc-fc-local.conf ]; then cp /config/.woc-fc-local.conf /etc/fonts/local.conf && fc-cache -f; fi'], 'root').catch(() => {});
|
||||
const famOut = await execCapture(inst, ['sh', '-c', 'cat /config/.woc-font-family 2>/dev/null || true']).catch(() => '');
|
||||
const family = famOut.trim();
|
||||
if (family) applyXsettingsFont(inst, family).catch(() => {});
|
||||
}
|
||||
|
||||
// 实例容器名(供反代构造 target)。
|
||||
export function instanceTarget(inst: Instance): string {
|
||||
return `http://${inst.containerName}:3000`;
|
||||
|
||||
@@ -72,6 +72,19 @@ import {
|
||||
volDownloadFile,
|
||||
volBackupStream,
|
||||
volRestoreArchive,
|
||||
listBackgrounds,
|
||||
uploadBackground,
|
||||
applyBackground,
|
||||
deleteBackground,
|
||||
getBackgroundImage,
|
||||
getCurrentBackground,
|
||||
clearBackground,
|
||||
listFonts,
|
||||
uploadFont,
|
||||
deleteFont,
|
||||
applyFont,
|
||||
getAppliedFont,
|
||||
getFontFamily,
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
|
||||
@@ -1008,6 +1021,147 @@ app.post('/api/admin/instances/:id/wechat/update', async (req, reply) => {
|
||||
return triggerInstanceWechat((req.params as any).id, 'update', reply);
|
||||
});
|
||||
|
||||
// ---------- 桌面壁纸管理 ----------
|
||||
const bgHandler = (id: string, reply: FastifyReply) => {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
return inst;
|
||||
};
|
||||
|
||||
app.get('/api/admin/instances/:id/backgrounds', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try { return { backgrounds: await listBackgrounds(inst) }; }
|
||||
catch (e: any) { return reply.code(500).send({ error: e?.message || '列出壁纸失败' }); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/instances/:id/backgrounds', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
const name = String((req.query as any)?.name || '').trim();
|
||||
const body = req.body as Buffer;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件' });
|
||||
try {
|
||||
await uploadBackground(inst, name, body);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); }
|
||||
});
|
||||
|
||||
app.get('/api/admin/instances/:id/backgrounds/current', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try { return { background: await getCurrentBackground(inst) }; }
|
||||
catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前壁纸失败' }); }
|
||||
});
|
||||
|
||||
app.get('/api/admin/instances/:id/backgrounds/:name/image', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
const buf = await getBackgroundImage(inst, (req.params as any).name, true);
|
||||
const name = (req.params as any).name as string;
|
||||
const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : 'png';
|
||||
const mime: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
|
||||
reply.header('Content-Type', mime[ext] || 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
return reply.send(buf);
|
||||
} catch (e: any) { return reply.code(404).send({ error: '图片不存在' }); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/instances/:id/backgrounds/:name/apply', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
await applyBackground(inst, (req.params as any).name);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '应用壁纸失败' }); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/instances/:id/backgrounds/clear', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
await clearBackground(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '清除壁纸失败' }); }
|
||||
});
|
||||
|
||||
app.delete('/api/admin/instances/:id/backgrounds/:name', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
await deleteBackground(inst, (req.params as any).name);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); }
|
||||
});
|
||||
|
||||
// ---------- 字体管理 ----------
|
||||
app.get('/api/admin/instances/:id/fonts', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try { return { fonts: await listFonts(inst) }; }
|
||||
catch (e: any) { return reply.code(500).send({ error: e?.message || '列出字体失败' }); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/instances/:id/fonts', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
const name = String((req.query as any)?.name || '').trim();
|
||||
const body = req.body as Buffer;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件' });
|
||||
try {
|
||||
await uploadFont(inst, name, body);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); }
|
||||
});
|
||||
|
||||
app.delete('/api/admin/instances/:id/fonts/:name', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
await deleteFont(inst, (req.params as any).name);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); }
|
||||
});
|
||||
|
||||
app.get('/api/admin/instances/:id/fonts/current', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try { return { fontFile: await getAppliedFont(inst) }; }
|
||||
catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前字体失败' }); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/instances/:id/fonts/:name/apply', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
await applyFont(inst, (req.params as any).name);
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '应用字体失败' }); }
|
||||
});
|
||||
|
||||
app.post('/api/admin/instances/:id/fonts/default', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = bgHandler((req.params as any).id, reply);
|
||||
if (!inst) return;
|
||||
try {
|
||||
await applyFont(inst, 'default');
|
||||
return { ok: true };
|
||||
} catch (e: any) { return reply.code(400).send({ error: e?.message || '重置字体失败' }); }
|
||||
});
|
||||
|
||||
// ---------- 反向代理到内网 KasmVNC(按实例注入 Basic auth,会话 + 权限把守) ----------
|
||||
// 单个 proxy 实例,target 与凭据逐请求指定:凭据暂存在 req 上,proxyReq 时注入。
|
||||
const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true });
|
||||
|
||||
@@ -246,4 +246,39 @@ export const api = {
|
||||
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
|
||||
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }),
|
||||
|
||||
// 桌面壁纸
|
||||
listBackgrounds: (id: string) => req<{ backgrounds: string[] }>(`/api/admin/instances/${id}/backgrounds`),
|
||||
uploadBackground: async (id: string, name: string, file: File) => {
|
||||
const res = await fetch(`/api/admin/instances/${id}/backgrounds?name=${encodeURIComponent(name)}`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/octet-stream' }, body: file,
|
||||
});
|
||||
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
|
||||
return res.json();
|
||||
},
|
||||
applyBackground: (id: string, name: string) =>
|
||||
req(`/api/admin/instances/${id}/backgrounds/${encodeURIComponent(name)}/apply`, { method: 'POST' }),
|
||||
deleteBackground: (id: string, name: string) =>
|
||||
req(`/api/admin/instances/${id}/backgrounds/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
getCurrentBackground: (id: string) => req<{ background: string }>(`/api/admin/instances/${id}/backgrounds/current`),
|
||||
clearBackground: (id: string) => req(`/api/admin/instances/${id}/backgrounds/clear`, { method: 'POST' }),
|
||||
|
||||
// 字体管理
|
||||
listFonts: (id: string) => req<{ fonts: string[] }>(`/api/admin/instances/${id}/fonts`),
|
||||
uploadFont: async (id: string, name: string, file: File) => {
|
||||
const res = await fetch(`/api/admin/instances/${id}/fonts?name=${encodeURIComponent(name)}`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/octet-stream' }, body: file,
|
||||
});
|
||||
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
|
||||
return res.json();
|
||||
},
|
||||
deleteFont: (id: string, name: string) =>
|
||||
req(`/api/admin/instances/${id}/fonts/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
getCurrentFont: (id: string) => req<{ fontFile: string }>(`/api/admin/instances/${id}/fonts/current`),
|
||||
applyFont: (id: string, name: string) =>
|
||||
req(`/api/admin/instances/${id}/fonts/${encodeURIComponent(name)}/apply`, { method: 'POST' }),
|
||||
resetFontDefault: (id: string) =>
|
||||
req(`/api/admin/instances/${id}/fonts/default`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
@@ -212,6 +212,16 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState<'bg' | 'font'>('bg');
|
||||
const [bgList, setBgList] = useState<string[]>([]);
|
||||
const [fontList, setFontList] = useState<string[]>([]);
|
||||
const [bgUploading, setBgUploading] = useState(false);
|
||||
const [fontUploading, setFontUploading] = useState(false);
|
||||
const [currentBg, setCurrentBg] = useState('');
|
||||
const [currentFont, setCurrentFont] = useState('');
|
||||
const bgInput = useRef<HTMLInputElement>(null);
|
||||
const fontInput = useRef<HTMLInputElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
@@ -578,6 +588,100 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- 桌面壁纸 ----------
|
||||
const refreshBgList = async () => {
|
||||
if (!id) return;
|
||||
try { const r = await api.listBackgrounds(id); setBgList(r.backgrounds); } catch { /* ignore */ }
|
||||
try { const r = await api.getCurrentBackground(id); setCurrentBg(r.background); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const uploadBg = async () => {
|
||||
const input = bgInput.current;
|
||||
if (!input?.files?.length) return;
|
||||
setBgUploading(true);
|
||||
for (const f of Array.from(input.files)) {
|
||||
try {
|
||||
await api.uploadBackground(id, f.name, f);
|
||||
toast(`壁纸「${f.name}」已上传`, 'ok');
|
||||
} catch (e: any) { toast(e.message || '上传失败', 'error'); }
|
||||
}
|
||||
setBgUploading(false);
|
||||
input.value = '';
|
||||
refreshBgList();
|
||||
};
|
||||
|
||||
const applyBg = async (name: string) => {
|
||||
try {
|
||||
await api.applyBackground(id, name);
|
||||
setCurrentBg(name);
|
||||
toast('壁纸已应用', 'ok');
|
||||
} catch (e: any) { toast(e.message || '应用失败', 'error'); }
|
||||
};
|
||||
|
||||
const clearBg = async () => {
|
||||
try {
|
||||
await api.clearBackground(id);
|
||||
setCurrentBg('');
|
||||
toast('已恢复默认黑屏', 'ok');
|
||||
} catch (e: any) { toast(e.message || '清除失败', 'error'); }
|
||||
};
|
||||
|
||||
const deleteBg = async (name: string) => {
|
||||
if (!(await confirm({ title: `删除壁纸「${name}」?`, danger: true, confirmText: '删除' }))) return;
|
||||
try {
|
||||
await api.deleteBackground(id, name);
|
||||
toast('已删除', 'ok');
|
||||
refreshBgList();
|
||||
} catch (e: any) { toast(e.message || '删除失败', 'error'); }
|
||||
};
|
||||
|
||||
// ---------- 字体管理 ----------
|
||||
const refreshFontList = async () => {
|
||||
if (!id) return;
|
||||
try { const r = await api.listFonts(id); setFontList(r.fonts); } catch { /* ignore */ }
|
||||
try { const r = await api.getCurrentFont(id); setCurrentFont(r.fontFile); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const uploadFont = async () => {
|
||||
const input = fontInput.current;
|
||||
if (!input?.files?.length) return;
|
||||
setFontUploading(true);
|
||||
for (const f of Array.from(input.files)) {
|
||||
try {
|
||||
await api.uploadFont(id, f.name, f);
|
||||
toast(`字体「${f.name}」已安装`, 'ok');
|
||||
} catch (e: any) { toast(e.message || '上传失败', 'error'); }
|
||||
}
|
||||
setFontUploading(false);
|
||||
input.value = '';
|
||||
refreshFontList();
|
||||
};
|
||||
|
||||
const deleteFont = async (name: string) => {
|
||||
if (!(await confirm({ title: `删除字体「${name}」?`, danger: true, confirmText: '删除' }))) return;
|
||||
try {
|
||||
await api.deleteFont(id, name);
|
||||
toast('已删除,字体缓存已刷新', 'ok');
|
||||
refreshFontList();
|
||||
} catch (e: any) { toast(e.message || '删除失败', 'error'); }
|
||||
};
|
||||
|
||||
const applyUserFont = async (name: string) => {
|
||||
try {
|
||||
await api.applyFont(id, name);
|
||||
setCurrentFont(name);
|
||||
toast('字体已应用,重启微信后完全生效', 'ok');
|
||||
} catch (e: any) { toast(e.message || '应用失败', 'error'); }
|
||||
};
|
||||
|
||||
const resetFontDefault = async () => {
|
||||
try {
|
||||
await api.resetFontDefault(id);
|
||||
setCurrentFont('');
|
||||
toast('已恢复系统默认字体(文泉驿),重启微信后生效', 'ok');
|
||||
} catch (e: any) { toast(e.message || '重置失败', 'error'); }
|
||||
};
|
||||
|
||||
// 同源 iframe:把键盘焦点交给 VNC,帮助宿主机输入法把合成的字送进去
|
||||
const focusFrame = () => {
|
||||
try {
|
||||
@@ -779,9 +883,14 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
|
||||
重启
|
||||
</button>
|
||||
<>
|
||||
<button className={'ws-action' + (showSettings ? ' on' : '')} title="桌面设置(壁纸/字体)" onClick={() => { setShowSettings((v) => !v); if (!showSettings) { refreshBgList(); refreshFontList(); } }}>
|
||||
桌面
|
||||
</button>
|
||||
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
|
||||
重启
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1005,6 +1114,76 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<div className="iv-files">
|
||||
<div className="files-head">
|
||||
<span>桌面设置</span>
|
||||
<button className="btn-text" onClick={() => setShowSettings(false)}>关闭</button>
|
||||
</div>
|
||||
<div className="settings-tabs">
|
||||
<button className={'settings-tab' + (settingsTab === 'bg' ? ' on' : '')} onClick={() => setSettingsTab('bg')}>壁纸</button>
|
||||
<button className={'settings-tab' + (settingsTab === 'font' ? ' on' : '')} onClick={() => setSettingsTab('font')}>字体</button>
|
||||
</div>
|
||||
|
||||
{settingsTab === 'bg' && (
|
||||
<div className="settings-panel">
|
||||
<input ref={bgInput} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={uploadBg} />
|
||||
<button className="btn btn-primary files-upload" disabled={bgUploading} onClick={() => bgInput.current?.click()}>
|
||||
{bgUploading ? '上传中…' : '+ 上传壁纸'}
|
||||
</button>
|
||||
{currentBg && (
|
||||
<button className="btn-text" style={{ alignSelf: 'flex-start', color: 'var(--danger)', fontSize: 12 }} onClick={clearBg}>
|
||||
清除壁纸,恢复默认黑屏
|
||||
</button>
|
||||
)}
|
||||
<div className="files-hint">单击缩略图即可应用。支持 JPG / PNG 等常见格式。</div>
|
||||
{bgList.length === 0 && <div className="muted small" style={{ padding: '10px 2px' }}>暂无壁纸</div>}
|
||||
<div className="bg-grid">
|
||||
{bgList.map((name) => (
|
||||
<div key={name} className={'bg-card' + (currentBg === name ? ' active' : '')} onClick={() => applyBg(name)} title="单击应用">
|
||||
<div className="bg-thumb-wrap">
|
||||
<img className="bg-thumb" src={`/api/admin/instances/${id}/backgrounds/${encodeURIComponent(name)}/image`} alt={name} loading="lazy" />
|
||||
{currentBg !== name && <div className="bg-hint">单击应用</div>}
|
||||
{currentBg === name && <span className="bg-active-badge">✓ 使用中</span>}
|
||||
<button className="bg-del" title="删除" onClick={(e) => { e.stopPropagation(); deleteBg(name); }}>✕</button>
|
||||
</div>
|
||||
<span className="bg-name">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settingsTab === 'font' && (
|
||||
<div className="settings-panel">
|
||||
<input ref={fontInput} type="file" accept=".ttf,.otf,.ttc" multiple style={{ display: 'none' }} onChange={uploadFont} />
|
||||
<button className="btn btn-primary files-upload" disabled={fontUploading} onClick={() => fontInput.current?.click()}>
|
||||
{fontUploading ? '上传中…' : '+ 上传字体'}
|
||||
</button>
|
||||
{currentFont && (
|
||||
<button className="btn-text" style={{ alignSelf: 'flex-start', color: 'var(--danger)', fontSize: 12 }} onClick={resetFontDefault}>
|
||||
恢复默认(文泉驿)
|
||||
</button>
|
||||
)}
|
||||
<div className="files-hint">支持 TTF / OTF / TTC 格式。应用字体后需重启微信(在面板杀一次)才能完全生效。</div>
|
||||
{fontList.length === 0 && <div className="muted small" style={{ padding: '10px 2px' }}>暂无字体</div>}
|
||||
<div className="font-grid">
|
||||
{fontList.map((name) => (
|
||||
<div key={name} className={'font-card' + (currentFont === name ? ' active' : '')} onClick={() => applyUserFont(name)} title="点击应用">
|
||||
{currentFont === name && <span className="font-badge">✓ 使用中</span>}
|
||||
<button className="font-del" title="删除" onClick={(e) => { e.stopPropagation(); deleteFont(name); }}>✕</button>
|
||||
<div className="font-preview">
|
||||
<span className="font-preview-text">Aa</span>
|
||||
</div>
|
||||
<span className="font-name">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inputMode === 'forward' && (
|
||||
|
||||
@@ -875,6 +875,13 @@ button {
|
||||
.files-item:last-child {
|
||||
box-shadow: none;
|
||||
}
|
||||
.files-item.sel {
|
||||
background: rgba(var(--green-rgb) / 0.08);
|
||||
border-radius: var(--r-small);
|
||||
box-shadow: var(--crease-accent);
|
||||
padding: 0 4px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
.files-dl {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -2273,3 +2280,231 @@ button {
|
||||
.icon-pick:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 桌面设置面板 — 壁纸 / 字体 */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: 8px 0;
|
||||
border-radius: var(--r-small);
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 30%, transparent);
|
||||
background: var(--trough);
|
||||
}
|
||||
.settings-tab {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.settings-tab.on {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--crease);
|
||||
}
|
||||
.settings-tab + .settings-tab {
|
||||
border-left: 1px solid color-mix(in srgb, var(--text) 15%, transparent);
|
||||
}
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 壁纸缩略图网格 */
|
||||
.bg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.bg-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-small);
|
||||
box-shadow: var(--crease);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, transform 0.12s;
|
||||
}
|
||||
.bg-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--crease), 0 4px 12px rgba(var(--shadow) / 0.15);
|
||||
}
|
||||
.bg-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.bg-card.active {
|
||||
box-shadow: 0 0 0 2px var(--wx-green), var(--crease);
|
||||
}
|
||||
.bg-thumb-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
background: var(--trough);
|
||||
border-bottom: 1px solid rgba(var(--shadow) / 0.06);
|
||||
}
|
||||
.bg-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.bg-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0 0 0 / 0.45);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bg-card:not(.active):hover .bg-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
.bg-active-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: var(--wx-green);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.bg-del {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0 0 0 / 0.5);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
}
|
||||
.bg-card:hover .bg-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.bg-del:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
.bg-name {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding: 5px 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 字体卡片网格 */
|
||||
.font-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.font-card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-small);
|
||||
box-shadow: var(--crease);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, transform 0.12s;
|
||||
}
|
||||
.font-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--crease), 0 4px 12px rgba(var(--shadow) / 0.15);
|
||||
}
|
||||
.font-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.font-card.active {
|
||||
box-shadow: 0 0 0 2px var(--wx-green), var(--crease);
|
||||
}
|
||||
.font-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
background: var(--wx-green);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.font-del {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0 0 0 / 0.5);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
}
|
||||
.font-card:hover .font-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.font-del:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
.font-preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 72px;
|
||||
background: var(--trough);
|
||||
border-bottom: 1px solid rgba(var(--shadow) / 0.06);
|
||||
}
|
||||
.font-preview-text {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
.font-name {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding: 5px 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user