WeChat Web QR Code Integration (#1961)

This commit is contained in:
hsguo
2026-03-24 18:37:41 +08:00
committed by GitHub
parent fcc20ec72c
commit fa5ab72022
10 changed files with 661 additions and 11 deletions
+7
View File
@@ -815,6 +815,7 @@ func (c *WeComAIBotConfig) SetSecret(secret string) {
type WeixinConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
token string
AccountID string `json:"account_id,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
@@ -2019,6 +2020,12 @@ func (c *Config) SecurityCopyFrom(cfg *Config) {
}
}
// ApplySecurity re-applies the stored security config to populate private fields (tokens, API keys, etc.).
// Call this after SecurityCopyFrom when you need private fields to be accessible for validation or use.
func (c *Config) ApplySecurity() error {
return applySecurityConfig(c, c.security)
}
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
seen := make(map[string]struct{})
var all []string
+1
View File
@@ -12,6 +12,7 @@ type channelCatalogItem struct {
}
var channelCatalog = []channelCatalogItem{
{Name: "weixin", ConfigKey: "weixin"},
{Name: "telegram", ConfigKey: "telegram"},
{Name: "discord", ConfigKey: "discord"},
{Name: "slack", ConfigKey: "slack"},
+6 -2
View File
@@ -152,9 +152,13 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
return
}
// Copy security credentials before validation so security-managed
// fields (e.g. pico token) are available for validation checks.
// Restore security fields (tokens/keys) from the loaded config before validation,
// because private fields are lost during JSON round-trip.
newCfg.SecurityCopyFrom(cfg)
if err := newCfg.ApplySecurity(); err != nil {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
+10 -4
View File
@@ -17,15 +17,18 @@ type Handler struct {
oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow
oauthState map[string]string
weixinMu sync.Mutex
weixinFlows map[string]*weixinFlow
}
// NewHandler creates an instance of the API handler.
func NewHandler(configPath string) *Handler {
return &Handler{
configPath: configPath,
serverPort: launcherconfig.DefaultPort,
oauthFlows: make(map[string]*oauthFlow),
oauthState: make(map[string]string),
configPath: configPath,
serverPort: launcherconfig.DefaultPort,
oauthFlows: make(map[string]*oauthFlow),
oauthState: make(map[string]string),
weixinFlows: make(map[string]*weixinFlow),
}
}
@@ -69,6 +72,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
// WeChat QR login flow
h.registerWeixinRoutes(mux)
}
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
+300
View File
@@ -0,0 +1,300 @@
package api
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"rsc.io/qr"
"github.com/sipeed/picoclaw/pkg/channels/weixin"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
weixinFlowTTL = 5 * time.Minute
weixinFlowGCAge = 30 * time.Minute
weixinBaseURL = "https://ilinkai.weixin.qq.com/"
weixinBotType = "3"
)
const (
weixinStatusWait = "wait"
weixinStatusScanned = "scaned"
weixinStatusConfirmed = "confirmed"
weixinStatusExpired = "expired"
weixinStatusError = "error"
)
type weixinFlow struct {
ID string
Qrcode string // qrcode token from WeChat API (used for status polling)
QRDataURI string // base64 PNG data URI for display
AccountID string // IlinkBotID returned on confirmed
Status string // wait / scaned / confirmed / expired / error
Error string
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
type weixinFlowResponse struct {
FlowID string `json:"flow_id"`
Status string `json:"status"`
QRDataURI string `json:"qr_data_uri,omitempty"`
AccountID string `json:"account_id,omitempty"`
Error string `json:"error,omitempty"`
}
// registerWeixinRoutes binds WeChat QR login endpoints to the ServeMux.
func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/weixin/flows", h.handleStartWeixinFlow)
mux.HandleFunc("GET /api/weixin/flows/{id}", h.handlePollWeixinFlow)
}
// handleStartWeixinFlow starts a new WeChat QR login flow.
//
// POST /api/weixin/flows
func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
api, err := weixin.NewApiClient(weixinBaseURL, "", "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to create weixin client: %v", err), http.StatusInternalServerError)
return
}
qrResp, err := api.GetQRCode(ctx, weixinBotType)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError)
return
}
dataURI, err := generateQRDataURI(qrResp.QrcodeImgContent)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError)
return
}
now := time.Now()
flow := &weixinFlow{
ID: newWeixinFlowID(),
Qrcode: qrResp.Qrcode,
QRDataURI: dataURI,
Status: weixinStatusWait,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(weixinFlowTTL),
}
h.storeWeixinFlow(flow)
logger.InfoCF("weixin", "QR flow started", map[string]any{"flow_id": flow.ID})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
QRDataURI: flow.QRDataURI,
})
}
// handlePollWeixinFlow polls the WeChat API for QR code status and updates the flow.
//
// GET /api/weixin/flows/{id}
func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
flowID := strings.TrimSpace(r.PathValue("id"))
if flowID == "" {
http.Error(w, "missing flow id", http.StatusBadRequest)
return
}
flow, ok := h.getWeixinFlow(flowID)
if !ok {
http.Error(w, "flow not found", http.StatusNotFound)
return
}
// Return terminal states directly without polling WeChat again
if flow.Status == weixinStatusConfirmed ||
flow.Status == weixinStatusExpired ||
flow.Status == weixinStatusError {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
Error: flow.Error,
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
api, err := weixin.NewApiClient(weixinBaseURL, "", "")
if err != nil {
h.setWeixinFlowError(flowID, fmt.Sprintf("client error: %v", err))
flow, _ = h.getWeixinFlow(flowID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{FlowID: flow.ID, Status: flow.Status, Error: flow.Error})
return
}
statusResp, err := api.GetQRCodeStatus(ctx, flow.Qrcode)
if err != nil {
// Transient error — keep current status, return it
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
QRDataURI: flow.QRDataURI,
})
return
}
switch statusResp.Status {
case weixinStatusWait:
// no change
case weixinStatusScanned:
h.updateWeixinFlowStatus(flowID, weixinStatusScanned)
case weixinStatusConfirmed:
if statusResp.BotToken == "" {
h.setWeixinFlowError(flowID, "login confirmed but missing bot_token")
break
}
if saveErr := h.saveWeixinToken(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil {
h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr))
logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()})
break
}
h.setWeixinFlowConfirmed(flowID, statusResp.IlinkBotID)
logger.InfoCF("weixin", "QR login confirmed, token saved", map[string]any{
"flow_id": flowID,
"account_id": statusResp.IlinkBotID,
})
case weixinStatusExpired:
h.updateWeixinFlowStatus(flowID, weixinStatusExpired)
default:
// unknown status, keep as-is
}
flow, _ = h.getWeixinFlow(flowID)
w.Header().Set("Content-Type", "application/json")
resp := weixinFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
AccountID: flow.AccountID,
Error: flow.Error,
}
if flow.Status == weixinStatusWait || flow.Status == weixinStatusScanned {
resp.QRDataURI = flow.QRDataURI
}
_ = json.NewEncoder(w).Encode(resp)
}
// saveWeixinToken writes the token and account ID into the config file.
func (h *Handler) saveWeixinToken(token, accountID string) error {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.Channels.Weixin.SetToken(token)
if accountID != "" {
cfg.Channels.Weixin.AccountID = accountID
}
return config.SaveConfig(h.configPath, cfg)
}
// generateQRDataURI encodes content as a QR code PNG and returns a data URI.
func generateQRDataURI(content string) (string, error) {
code, err := qr.Encode(content, qr.L)
if err != nil {
return "", fmt.Errorf("qr encode: %w", err)
}
pngBytes := code.PNG()
encoded := base64.StdEncoding.EncodeToString(pngBytes)
return "data:image/png;base64," + encoded, nil
}
func newWeixinFlowID() string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("wx_%d", time.Now().UnixNano())
}
return "wx_" + hex.EncodeToString(buf)
}
func (h *Handler) storeWeixinFlow(flow *weixinFlow) {
h.weixinMu.Lock()
defer h.weixinMu.Unlock()
h.gcWeixinFlowsLocked(time.Now())
h.weixinFlows[flow.ID] = flow
}
func (h *Handler) getWeixinFlow(flowID string) (*weixinFlow, bool) {
h.weixinMu.Lock()
defer h.weixinMu.Unlock()
h.gcWeixinFlowsLocked(time.Now())
flow, ok := h.weixinFlows[flowID]
if !ok {
return nil, false
}
cp := *flow
return &cp, true
}
func (h *Handler) updateWeixinFlowStatus(flowID, status string) {
h.weixinMu.Lock()
defer h.weixinMu.Unlock()
if flow, ok := h.weixinFlows[flowID]; ok {
flow.Status = status
flow.UpdatedAt = time.Now()
}
}
func (h *Handler) setWeixinFlowConfirmed(flowID, accountID string) {
h.weixinMu.Lock()
defer h.weixinMu.Unlock()
if flow, ok := h.weixinFlows[flowID]; ok {
flow.Status = weixinStatusConfirmed
flow.AccountID = accountID
flow.UpdatedAt = time.Now()
}
}
func (h *Handler) setWeixinFlowError(flowID, errMsg string) {
h.weixinMu.Lock()
defer h.weixinMu.Unlock()
if flow, ok := h.weixinFlows[flowID]; ok {
flow.Status = weixinStatusError
flow.Error = errMsg
flow.UpdatedAt = time.Now()
}
}
func (h *Handler) gcWeixinFlowsLocked(now time.Time) {
for id, flow := range h.weixinFlows {
if flow.Status == weixinStatusWait || flow.Status == weixinStatusScanned {
if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
flow.Status = weixinStatusExpired
flow.UpdatedAt = now
}
}
if flow.Status != weixinStatusWait &&
flow.Status != weixinStatusScanned &&
now.Sub(flow.UpdatedAt) > weixinFlowGCAge {
delete(h.weixinFlows, id)
}
}
}
+18
View File
@@ -62,4 +62,22 @@ export async function patchAppConfig(
})
}
// WeChat QR login flow API
export interface WeixinFlowResponse {
flow_id: string
status: "wait" | "scaned" | "confirmed" | "expired" | "error"
qr_data_uri?: string
account_id?: string
error?: string
}
export async function startWeixinFlow(): Promise<WeixinFlowResponse> {
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
}
export async function pollWeixinFlow(flowID: string): Promise<WeixinFlowResponse> {
return request<WeixinFlowResponse>(`/api/weixin/flows/${encodeURIComponent(flowID)}`)
}
export type { ChannelsCatalogResponse, ConfigActionResponse }
@@ -17,6 +17,7 @@ import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
@@ -142,6 +143,8 @@ function isConfigured(
)
case "onebot":
return asString(config.ws_url) !== ""
case "weixin":
return asString(config.account_id) !== ""
case "wecom":
return asString(config.token) !== ""
case "wecom_app":
@@ -251,8 +254,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [enabled, setEnabled] = useState(false)
const loadData = useCallback(async () => {
setLoading(true)
const loadData = useCallback(async (silent = false) => {
if (!silent) setLoading(true)
try {
const [catalog, appConfig] = await Promise.all([
getChannelsCatalog(),
@@ -285,7 +288,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
} catch (e) {
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
} finally {
setLoading(false)
if (!silent) setLoading(false)
}
}, [channelName, t])
@@ -446,6 +449,15 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
fieldErrors={fieldErrors}
/>
)
case "weixin":
return (
<WeixinForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
onBindSuccess={() => void loadData(true)}
/>
)
default:
return (
<GenericForm
@@ -0,0 +1,270 @@
import { IconLoader2, IconRefresh, IconCheck, IconX, IconQrcode } from "@tabler/icons-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
import { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type BindingState = "idle" | "loading" | "waiting" | "scaned" | "confirmed" | "expired" | "error"
interface WeixinFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
onBindSuccess?: () => void
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFormProps) {
const { t } = useTranslation()
const [bindState, setBindState] = useState<BindingState>("idle")
const [qrDataURI, setQrDataURI] = useState<string | null>(null)
const [accountID, setAccountID] = useState<string | null>(null)
const [errorMsg, setErrorMsg] = useState("")
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isBound = isEdit && asString(config.account_id) !== ""
const existingAccountID = asString(config.account_id)
const stopPolling = useCallback(() => {
if (pollTimerRef.current !== null) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}, [])
useEffect(() => () => stopPolling(), [stopPolling])
const startPolling = useCallback(
(id: string) => {
stopPolling()
pollTimerRef.current = setInterval(async () => {
try {
const resp = await pollWeixinFlow(id)
if (resp.status === "scaned") {
setBindState("scaned")
} else if (resp.status === "confirmed") {
stopPolling()
setAccountID(resp.account_id ?? null)
setBindState("confirmed")
onBindSuccess?.()
} else if (resp.status === "expired") {
stopPolling()
setBindState("expired")
} else if (resp.status === "error") {
stopPolling()
setBindState("error")
setErrorMsg(resp.error ?? t("channels.weixin.errorGeneric"))
}
} catch {
// transient network error — keep polling
}
}, 2000)
},
[stopPolling, onBindSuccess, t],
)
const handleBind = async () => {
setBindState("loading")
setErrorMsg("")
setQrDataURI(null)
stopPolling()
try {
const resp = await startWeixinFlow()
setQrDataURI(resp.qr_data_uri ?? null)
setBindState("waiting")
startPolling(resp.flow_id)
} catch (e) {
setBindState("error")
setErrorMsg(e instanceof Error ? e.message : t("channels.weixin.errorGeneric"))
}
}
const handleRebind = () => {
stopPolling()
setBindState("idle")
setQrDataURI(null)
setAccountID(null)
setErrorMsg("")
void handleBind()
}
const renderBindSection = () => {
if (bindState === "idle") {
if (isBound) {
return (
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex items-center gap-2 rounded-full bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<IconCheck size={16} />
{t("channels.weixin.bound")}
</div>
{existingAccountID && (
<p className="text-xs text-muted-foreground font-mono">{existingAccountID}</p>
)}
<Button variant="outline" size="sm" onClick={handleRebind} className="mt-1 gap-2">
<IconRefresh size={14} />
{t("channels.weixin.rebind")}
</Button>
</div>
)
}
return (
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-sm text-muted-foreground">{t("channels.weixin.notBound")}</p>
<Button onClick={handleBind} className="gap-2">
<IconQrcode size={16} />
{t("channels.weixin.bind")}
</Button>
</div>
)
}
if (bindState === "loading") {
return (
<div className="flex flex-col items-center gap-3 py-8">
<IconLoader2 className="animate-spin text-muted-foreground" size={32} />
<p className="text-sm text-muted-foreground">{t("channels.weixin.generating")}</p>
</div>
)
}
if (bindState === "waiting" || bindState === "scaned") {
return (
<div className="flex flex-col items-center gap-4 py-4">
{qrDataURI ? (
<img
src={qrDataURI}
alt="WeChat QR Code"
className="h-48 w-48 rounded-xl border border-border/60 bg-white p-2 shadow-sm"
/>
) : (
<div className="flex h-48 w-48 items-center justify-center rounded-xl border border-border/60 bg-muted">
<IconLoader2 className="animate-spin text-muted-foreground" size={32} />
</div>
)}
{bindState === "scaned" ? (
<div className="flex items-center gap-2 rounded-full bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-600 dark:text-amber-400">
<IconLoader2 size={14} className="animate-spin" />
{t("channels.weixin.scanned")}
</div>
) : (
<p className="text-sm text-muted-foreground">{t("channels.weixin.scanHint")}</p>
)}
<Button variant="ghost" size="sm" onClick={handleRebind} className="text-muted-foreground">
<IconRefresh size={14} className="mr-1" />
{t("channels.weixin.refresh")}
</Button>
</div>
)
}
if (bindState === "confirmed") {
return (
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<IconCheck size={28} className="text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("channels.weixin.bound")}
</p>
{accountID && (
<p className="text-xs text-muted-foreground font-mono">{accountID}</p>
)}
<Button variant="outline" size="sm" onClick={handleRebind} className="mt-1 gap-2">
<IconRefresh size={14} />
{t("channels.weixin.rebind")}
</Button>
</div>
)
}
if (bindState === "expired") {
return (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
<IconX size={28} className="text-amber-600 dark:text-amber-400" />
</div>
<p className="text-sm text-amber-600 dark:text-amber-400">{t("channels.weixin.expired")}</p>
<Button onClick={handleRebind} className="gap-2">
<IconRefresh size={14} />
{t("channels.weixin.retry")}
</Button>
</div>
)
}
if (bindState === "error") {
return (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<IconX size={28} className="text-destructive" />
</div>
<p className="text-sm text-destructive">{errorMsg || t("channels.weixin.errorGeneric")}</p>
<Button variant="outline" onClick={handleRebind} className="gap-2">
<IconRefresh size={14} />
{t("channels.weixin.retry")}
</Button>
</div>
)
}
return null
}
return (
<div className="space-y-5">
{/* QR Bind Section */}
<div className="rounded-xl border border-border/60 bg-muted/30">
<div className="border-b border-border/60 px-4 py-3">
<p className="text-sm font-medium">{t("channels.weixin.bindTitle")}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t("channels.weixin.bindDesc")}</p>
</div>
{renderBindSection()}
</div>
{/* allow_from */}
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
>
<Input
value={asStringArray(config.allow_from).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
{/* proxy */}
<Field
label={t("channels.field.proxy")}
hint={t("channels.form.desc.proxy")}
>
<Input
value={asString(config.proxy)}
onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://localhost:7890"
/>
</Field>
</div>
)
}
+17 -1
View File
@@ -240,7 +240,23 @@
"pico": "Web",
"maixcam": "MaixCam",
"matrix": "Matrix",
"irc": "IRC"
"irc": "IRC",
"weixin": "WeChat"
},
"weixin": {
"bindTitle": "WeChat Account Binding",
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
"bind": "Bind WeChat",
"rebind": "Re-bind",
"bound": "WeChat Bound",
"notBound": "WeChat account not bound yet.",
"generating": "Generating QR code...",
"scanHint": "Open WeChat and scan the QR code",
"scanned": "Scanned — please confirm in WeChat",
"expired": "QR code expired",
"retry": "Try Again",
"refresh": "Refresh QR",
"errorGeneric": "An error occurred. Please try again."
},
"field": {
"token": "Bot Token",
+17 -1
View File
@@ -240,7 +240,23 @@
"pico": "Web",
"maixcam": "MaixCam",
"matrix": "Matrix",
"irc": "IRC"
"irc": "IRC",
"weixin": "微信"
},
"weixin": {
"bindTitle": "微信账号绑定",
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
"bind": "绑定微信",
"rebind": "重新绑定",
"bound": "微信已绑定",
"notBound": "尚未绑定微信账号。",
"generating": "正在生成二维码...",
"scanHint": "打开微信,扫描二维码",
"scanned": "已扫码 — 请在微信中确认",
"expired": "二维码已过期",
"retry": "重试",
"refresh": "刷新二维码",
"errorGeneric": "发生错误,请重试。"
},
"field": {
"token": "Bot Token",